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, do nothing.
 71    if row[-1].letter == "\n":
 72        return
 73
 74    # If the line ends with a soft-hyphen, replace it with a real hyphen
 75    elif row[-1].letter == "\xad":
 76        # This looks pretty bad, doesn't it?
 77        hyphen = hyphenbox()
 78        row[-1].letter = hyphen.letter
 79        row[-1].w = hyphen.w
 80
 81    slack = (page.x + page.w) - (row[-1].x + row[-1].w)
 82    # Get a list of all the ones that are stretchy
 83    stretchies = [b for b in row if b.stretchy]
 84    if not stretchies:
 85        # Nothing stretches, spread slack on everything
 86        stretchies = row
 87    bump = (slack / len(stretchies))
 88    # Each stretchy gets wider
 89    for b in stretchies:
 90        b.w += bump
 91    # And we put each thing next to the previous one
 92    for j, b in enumerate(row[1:], 1):
 93        b.x = row[j - 1].x + row[j - 1].w + separation
 94
 95
 96BREAKING_CHARS = (" ", "\xad")
 97
 98
 99def is_breaking(box, page):
100    """Decide if 'box' is a good candidate to be the end of a row
101    in the page."""
102    # If it's a newline
103    if box.letter == "\n":
104        return True
105
106    # If we are too much to the right
107    if (box.x + box.w) > (page.x + page.w):
108        # And it's a breaking character:
109        if box.letter in BREAKING_CHARS:
110            return True
111
112    return False
113
114
115def fill_row(boxes, page, separation):
116    """Fill a row with elements removed from boxes.
117
118    The elements put in the row should be a good fit for laying out on
119    page considering separation.
120    """
121
122    row = []
123    x = page.x
124    while boxes:
125        b = boxes.pop(0)
126        row.append(b)
127        b.x = x
128        if is_breaking(b, page):
129            break
130
131        x = x + b.w + separation
132    return row
133
134
135def layout(_boxes, _pages, separation):
136    """Layout boxes along pages."""
137
138    # We modify these lists, so use copies
139    boxes = _boxes[:]
140    pages = _pages[:]
141
142    # Start in page 0
143    current_page = pages.pop(0)
144    y = current_page.y
145
146    # If we run out of boxes or pages, stop
147    while boxes and pages:
148        # If this row would end below the page, advance to next page
149        if (y + boxes[0].h) > (current_page.y + current_page.h):
150            current_page = pages.pop(0)
151            y = current_page.y
152        # Put "enough" letters into row and out of boxes
153        row = fill_row(boxes, current_page, separation)
154        # Adjust box positions to fill the page width
155        justify_row(row, current_page, separation)
156        # Put all the letters in the right vertical position
157        y = y + row[0].h + separation
158        for b in row:
159            b.y = y
160    # Remove unused pages
161    del (_pages[-len(pages):])
162
163
164def draw_boxes(boxes, pages, fname, size, hide_boxes=False):
165    dwg = svgwrite.Drawing(fname, profile="full", size=size)
166    # Draw the pages
167    for page in pages:
168        dwg.add(
169            dwg.rect(
170                insert=(f"{page.x}cm", f"{page.y}cm"),
171                size=(f"{page.w}cm", f"{page.h}cm"),
172                fill="lightblue",
173            )
174        )
175    # Draw all the boxes
176    for box in boxes:
177        # The box color depends on its features
178        color = "green" if box.stretchy else "red"
179        # Make the colored boxes optional
180        if not hide_boxes:
181            dwg.add(
182                dwg.rect(
183                    insert=(f"{box.x}cm", f"{box.y}cm"),
184                    size=(f"{box.w}cm", f"{box.h}cm"),
185                    fill=color,
186                )
187            )
188        # Display the letter in the box
189        if box.letter:
190            dwg.add(
191                dwg.text(
192                    box.letter,
193                    insert=(f"{box.x}cm", f"{box.y + box.h}cm"),
194                    font_size=f"{box.h}cm",
195                    font_family="Arial",
196                )
197            )
198    dwg.save()
199
200
201def create_text_boxes(input_file):
202    p_and_p = open(input_file).read()
203    p_and_p = insert_soft_hyphens(p_and_p)  # Insert invisible hyphens
204    if p_and_p[-1] != "\n":
205        p_and_p += "\n"
206    text_boxes = []
207    for letter in p_and_p:
208        text_boxes.append(Box(letter=letter, stretchy=letter == " "))
209    adjust_widths_by_letter(text_boxes)
210    return text_boxes
211
212
213def create_pages(page_size):
214    # A few pages all the same size
215    w, h = page_size
216    pages = [Box(i * (w + 5), 0, w, h) for i in range(1000)]
217    return pages
218
219
220def convert(input, output, page_size=(30, 50), separation=0.05):
221    pages = create_pages(page_size)
222    text_boxes = create_text_boxes(input)
223    layout(text_boxes, pages, separation)
224    draw_boxes(
225        text_boxes,
226        pages,
227        output,
228        (f"{pages[-1].w + pages[-1].x}cm", f"{pages[-1].h}cm"),
229        True,
230    )
231
232
233if __name__ == "__main__":
234    arguments = docopt(__doc__, version="Boxes 0.13")
235
236    if arguments["--page-size"]:
237        p_size = [int(x) for x in arguments["--page-size"].split("x")]
238    else:
239        p_size = (30, 50)
240
241    if arguments["--separation"]:
242        separation = float(arguments["--separation"])
243    else:
244        separation = 0.05
245
246    convert(
247        input=arguments["<input>"],
248        output=arguments["<output>"],
249        page_size=p_size,
250        separation=separation,
251    )
252