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    row = []
125    x = page.x
126    while boxes:
127        b = boxes.pop(0)
128        row.append(b)
129        b.x = x
130        if is_breaking(b, page):
131            break
132
133        x = x + b.w + separation
134    return row
135
136
137def layout(_boxes, _pages, separation):
138    """Layout boxes along pages."""
139
140    # We modify these lists, so use copies
141    boxes = _boxes[:]
142    pages = _pages[:]
143
144    # Start in page 0
145    current_page = pages.pop(0)
146    y = current_page.y
147
148    # If we run out of boxes or pages, stop
149    while boxes and pages:
150        # If this row would end below the page, advance to next page
151        if (y + boxes[0].h) > (current_page.y + current_page.h):
152            current_page = pages.pop(0)
153            y = current_page.y
154        # Put "enough" letters into row and out of boxes
155        row = fill_row(boxes, current_page, separation)
156        # Adjust box positions to fill the page width
157        justify_row(row, current_page, separation)
158        # Put all the letters in the right vertical position
159        y = y + row[0].h + separation
160        for b in row:
161            b.y = y
162    # Remove unused pages
163    del (_pages[-len(pages):])
164
165
166def draw_boxes(boxes, pages, fname, size, hide_boxes=False):
167    dwg = svgwrite.Drawing(fname, profile="full", size=size)
168    # Draw the pages
169    for page in pages:
170        dwg.add(
171            dwg.rect(
172                insert=(f"{page.x}cm", f"{page.y}cm"),
173                size=(f"{page.w}cm", f"{page.h}cm"),
174                fill="lightblue",
175            )
176        )
177    # Draw all the boxes
178    for box in boxes:
179        # The box color depends on its features
180        color = "green" if box.stretchy else "red"
181        # Make the colored boxes optional
182        if not hide_boxes:
183            dwg.add(
184                dwg.rect(
185                    insert=(f"{box.x}cm", f"{box.y}cm"),
186                    size=(f"{box.w}cm", f"{box.h}cm"),
187                    fill=color,
188                )
189            )
190        # Display the letter in the box
191        if box.letter:
192            dwg.add(
193                dwg.text(
194                    box.letter,
195                    insert=(f"{box.x}cm", f"{box.y + box.h}cm"),
196                    font_size=f"{box.h}cm",
197                    font_family="Arial",
198                )
199            )
200    dwg.save()
201
202
203def create_text_boxes(input_file):
204    p_and_p = open(input_file).read()
205    p_and_p = insert_soft_hyphens(p_and_p)  # Insert invisible hyphens
206    if p_and_p[-1] != "\n":
207        p_and_p += "\n"
208    text_boxes = []
209    for letter in p_and_p:
210        text_boxes.append(Box(letter=letter, stretchy=letter == " "))
211    adjust_widths_by_letter(text_boxes)
212    return text_boxes
213
214
215def create_pages(page_size):
216    # A few pages all the same size
217    w, h = page_size
218    pages = [Box(i * (w + 5), 0, w, h) for i in range(1000)]
219    return pages
220
221
222def convert(input, output, page_size=(30, 50), separation=0.05):
223    pages = create_pages(page_size)
224    text_boxes = create_text_boxes(input)
225    layout(text_boxes, pages, separation)
226    draw_boxes(
227        text_boxes,
228        pages,
229        output,
230        (f"{pages[-1].w + pages[-1].x}cm", f"{pages[-1].h}cm"),
231        True,
232    )
233
234
235if __name__ == "__main__":
236    arguments = docopt(__doc__, version="Boxes 0.13")
237
238    if arguments["--page-size"]:
239        p_size = [int(x) for x in arguments["--page-size"].split("x")]
240    else:
241        p_size = (30, 50)
242
243    if arguments["--separation"]:
244        separation = float(arguments["--separation"])
245    else:
246        separation = 0.05
247
248    convert(
249        input=arguments["<input>"],
250        output=arguments["<output>"],
251        page_size=p_size,
252        separation=separation,
253    )
254