Source for: boxes.py [raw]

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