Source for: boxes.py [raw]
1"""
2Usage:
3 boxes <input> <output> [--page-size=<WxH>] [--separation=<sep>]
4 boxes --version
5"""
6
7from fonts import adjust_widths_by_letter
8from hyphen import insert_soft_hyphens
9
10import svgwrite
11from docopt import docopt
12
13
14class Box():
15
16 def __init__(self, x=0, y=0, w=1, h=1, stretchy=False, letter="x"):
17 """Accept arguments to define our box, and store them."""
18 self.x = x
19 self.y = y
20 self.w = w
21 self.h = h
22 self.stretchy = stretchy
23 self.letter = letter
24
25 def __repr__(self):
26 return 'Box(%s, %s, %s, %s, "%s")' % (
27 self.x, self.y, self.w, self.h, self.letter
28 )
29
30
31def hyphenbox():
32 b = Box(letter="-")
33 adjust_widths_by_letter([b])
34 return b
35
36
37def badness(page_width, row):
38 """Calculate how 'bad' a position to break is.
39
40 bigger is worse.
41 """
42 # Yes, this is suboptimal. It's easier to optimize working code
43 # than fixing fast code.
44 row_width = (row[-1].x + row[-1].w) - row[0].x
45 slack = page_width - row_width
46 stretchies = [b for b in row if b.stretchy]
47 if len(stretchies) > 0:
48 stretchies_width = sum(s.w for s in stretchies)
49 # More stetchy space is good. More slack is bad.
50 badness = slack / stretchies_width
51 else: # Nothing to stretch. Not good.
52 badness = 1000
53 if slack < 0:
54 # Arbitrary fudge factor, negative slack is THIS much worse
55 badness *= -2
56 return badness
57
58
59def justify_row(row, page, separation):
60 """Given a row and a page, adjust position of elements in the row
61 so it fits the page width.
62
63 It modifies the contents of the row in place, so returns nothing.
64 """
65
66 # Remove all right-margin spaces
67 while row[-1].letter == " ":
68 row.pop()
69 # If the line ends in newline, do nothing.
70 if row[-1].letter == "\n":
71 return
72
73 slack = (page.x + page.w) - (row[-1].x + row[-1].w)
74 # Get a list of all the ones that are stretchy
75 stretchies = [b for b in row if b.stretchy]
76 if not stretchies:
77 # Nothing stretches, spread slack on everything
78 stretchies = row
79 bump = (slack / len(stretchies))
80 # Each stretchy gets wider
81 for b in stretchies:
82 b.w += bump
83 # And we put each thing next to the previous one
84 for j, b in enumerate(row[1:], 1):
85 b.x = row[j - 1].x + row[j - 1].w + separation
86
87
88def add_hyphen(row, separation):
89 """If the row requires a hyphen at the end, add it, respecting separation.
90
91 Returns the added hyphen or None."""
92 h_b = None
93 if row[-1].letter == "\xad":
94 # Add a visible hyphen in the row
95 h_b = hyphenbox()
96 h_b.x = row[-2].x + row[-2].w + separation
97 h_b.y = row[-2].y
98 row.append(h_b) # So it's justified
99 return h_b
100
101
102BREAKING_CHARS = (" ", "\xad")
103
104
105def is_breaking(box, page):
106 """Decide if 'box' is a good candidate to be the end of a row
107 in the page."""
108 # If it's a newline
109 if box.letter == "\n":
110 return True
111
112 # If we are too much to the right
113 if (box.x + box.w) > (page.x + page.w):
114 # And it's a breaking character:
115 if box.letter in BREAKING_CHARS:
116 return True
117
118 return False
119
120
121def layout(_boxes, pages, separation):
122 """Layout boxes along pages.
123
124 Keep in mind that this function modifies the boxes themselves, so
125 you should be very careful about trying to call layout() more than once
126 on the same boxes.
127
128 Specifically, some spaces will become 0-width and not stretchy.
129 """
130
131 # Because we modify the box list, we will work on a copy
132 boxes = _boxes[:]
133 # We start at page 0
134 page = 0
135 # The 1st box should be placed in the correct page
136 previous = boxes.pop(0)
137 previous.x = pages[page].x
138 previous.y = pages[page].y
139 row = []
140 while boxes:
141 # We take the new 1st box
142 box = boxes.pop(0)
143 # And put it next to the other
144 box.x = previous.x + previous.w + separation
145 # At the same vertical location
146 box.y = previous.y
147
148 # Put the box in the row
149 row.append(box)
150
151 if is_breaking(box, pages[page]):
152 h_b = add_hyphen(row, separation)
153 if h_b:
154 _boxes.append(h_b) # So it's drawn
155 justify_row(row, pages[page], separation)
156 # We start a new row
157 row = []
158 # We go all the way left and a little down
159 box.x = pages[page].x
160 box.y = previous.y + previous.h + separation
161
162 # But if we go too far down
163 if box.y + box.h > pages[page].y + pages[page].h:
164 # We go to the next page
165 page += 1
166 # And put the box at the top-left
167 box.x = pages[page].x
168 box.y = pages[page].y
169
170 # Collapse all left-margin space
171 if all(b.letter == " " for b in row):
172 box.w = 0
173 box.stretchy = False
174 box.x = pages[page].x
175
176 previous = box
177
178 # Remove leftover boxes
179 del (pages[page + 1:])
180
181
182def draw_boxes(boxes, pages, fname, size, hide_boxes=False):
183 dwg = svgwrite.Drawing(fname, profile="full", size=size)
184 # Draw the pages
185 for page in pages:
186 dwg.add(
187 dwg.rect(
188 insert=(f"{page.x}cm", f"{page.y}cm"),
189 size=(f"{page.w}cm", f"{page.h}cm"),
190 fill="lightblue",
191 )
192 )
193 # Draw all the boxes
194 for box in boxes:
195 # The box color depends on its features
196 color = "green" if box.stretchy else "red"
197 # Make the colored boxes optional
198 if not hide_boxes:
199 dwg.add(
200 dwg.rect(
201 insert=(f"{box.x}cm", f"{box.y}cm"),
202 size=(f"{box.w}cm", f"{box.h}cm"),
203 fill=color,
204 )
205 )
206 # Display the letter in the box
207 if box.letter:
208 dwg.add(
209 dwg.text(
210 box.letter,
211 insert=(f"{box.x}cm", f"{box.y + box.h}cm"),
212 font_size=f"{box.h}cm",
213 font_family="Arial",
214 )
215 )
216 dwg.save()
217
218
219def create_text_boxes(input_file):
220 p_and_p = open(input_file).read()
221 p_and_p = insert_soft_hyphens(p_and_p) # Insert invisible hyphens
222 text_boxes = []
223 for letter in p_and_p:
224 text_boxes.append(Box(letter=letter, stretchy=letter == " "))
225 adjust_widths_by_letter(text_boxes)
226 return text_boxes
227
228
229def create_pages(page_size):
230 # A few pages all the same size
231 w, h = page_size
232 pages = [Box(i * (w + 5), 0, w, h) for i in range(1000)]
233 return pages
234
235
236def convert(input, output, page_size=(30, 50), separation=0.05):
237 pages = create_pages(page_size)
238 text_boxes = create_text_boxes(input)
239 layout(text_boxes, pages, separation)
240 draw_boxes(
241 text_boxes,
242 pages,
243 output,
244 (f"{pages[-1].w + pages[-1].x}cm", f"{pages[-1].h}cm"),
245 True,
246 )
247
248
249if __name__ == "__main__":
250 arguments = docopt(__doc__, version="Boxes 0.13")
251
252 if arguments["--page-size"]:
253 p_size = [int(x) for x in arguments["--page-size"].split("x")]
254 else:
255 p_size = (30, 50)
256
257 if arguments["--separation"]:
258 separation = float(arguments["--separation"])
259 else:
260 separation = 0.05
261
262 convert(
263 input=arguments["<input>"],
264 output=arguments["<output>"],
265 page_size=p_size,
266 separation=separation,
267 )
268