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    text_boxes = []
205    for letter in p_and_p:
206        text_boxes.append(Box(letter=letter, stretchy=letter == " "))
207    adjust_widths_by_letter(text_boxes)
208    return text_boxes
209
210
211def create_pages(page_size):
212    # A few pages all the same size
213    w, h = page_size
214    pages = [Box(i * (w + 5), 0, w, h) for i in range(1000)]
215    return pages
216
217
218def convert(input, output, page_size=(30, 50), separation=0.05):
219    pages = create_pages(page_size)
220    text_boxes = create_text_boxes(input)
221    layout(text_boxes, pages, separation)
222    draw_boxes(
223        text_boxes,
224        pages,
225        output,
226        (f"{pages[-1].w + pages[-1].x}cm", f"{pages[-1].h}cm"),
227        True,
228    )
229
230
231if __name__ == "__main__":
232    arguments = docopt(__doc__, version="Boxes 0.13")
233
234    if arguments["--page-size"]:
235        p_size = [int(x) for x in arguments["--page-size"].split("x")]
236    else:
237        p_size = (30, 50)
238
239    if arguments["--separation"]:
240        separation = float(arguments["--separation"])
241    else:
242        separation = 0.05
243
244    convert(
245        input=arguments["<input>"],
246        output=arguments["<output>"],
247        page_size=p_size,
248        separation=separation,
249    )
250