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
 70    # If the line ends in newline, and is underfull, do nothing.
 71    if row[-1].letter == "\n" and (row[-1].x + row[-1].w) < (
 72        page.x + page.w
 73    ):
 74        return
 75
 76    # If the line ends with a soft-hyphen, replace it with a real hyphen
 77    elif row[-1].letter == "\xad":
 78        # This looks pretty bad, doesn't it?
 79        hyphen = hyphenbox()
 80        row[-1].letter = hyphen.letter
 81        row[-1].w = hyphen.w
 82
 83    slack = (page.x + page.w) - (row[-1].x + row[-1].w)
 84    # Get a list of all the ones that are stretchy
 85    stretchies = [b for b in row if b.stretchy]
 86    if not stretchies:
 87        # Nothing stretches, spread slack on everything
 88        stretchies = row
 89    bump = (slack / len(stretchies))
 90    # Each stretchy gets wider
 91    for b in stretchies:
 92        b.w += bump
 93    # And we put each thing next to the previous one
 94    for j, b in enumerate(row[1:], 1):
 95        b.x = row[j - 1].x + row[j - 1].w + separation
 96
 97
 98BREAKING_CHARS = (" ", "\xad")
 99
100
101def is_breaking(box, page):
102    """Decide if 'box' is a good candidate to be the end of a row
103    in the page."""
104    # If it's a newline
105    if box.letter == "\n":
106        return True
107
108    # If we are too much to the right
109    if (box.x + box.w) > (page.x + page.w):
110        # And it's a breaking character:
111        if box.letter in BREAKING_CHARS:
112            return True
113
114    return False
115
116
117def fill_row(boxes, page, separation):
118    """Fill a row with elements removed from boxes.
119
120    The elements put in the row should be a good fit for laying out on
121    page considering separation.
122    """
123
124    # Calculate initial breaking point
125    row = []
126    x = page.x
127    while boxes:
128        b = boxes.pop(0)
129        row.append(b)
130        b.x = x
131        if is_breaking(b, page):
132            break
133
134        x = x + b.w + separation
135    # Calculate badness for previous breaking points
136    badnesses = {}
137    for i in range(1, len(row)):
138        _row = row[:i + 1]
139        if _row[-1].letter in [" ", "\xad", "\n"]:
140            how_bad = badness(page.w, _row)
141            badnesses[how_bad] = _row
142    if badnesses:
143        # Find minimum badness
144        min_bad = min(badnesses.keys())
145        _row = badnesses[min_bad]
146        # Put leftover letters back in boxes
147        for b in row[:len(_row) - 1:-1]:
148            boxes.insert(0, b)
149    else:
150        _row = row
151    return _row
152
153
154def layout(_boxes, _pages, separation):
155    """Layout boxes along pages."""
156
157    # We modify these lists, so use copies
158    boxes = _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        y = y + row[0].h + separation
177        for b in row:
178            b.y = y
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