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    # If the line ends in newline, do nothing.
 70    if row[-1].letter == "\n":
 71        return
 72
 73    slack = (page.x + page.w) - (row[-1].x + row[-1].w)
 74    # Get a list of all the ones that are stretchy
 75    stretchies = [b for b in row if b.stretchy]
 76    if not stretchies:
 77        # Nothing stretches, spread slack on everything
 78        stretchies = row
 79    bump = (slack / len(stretchies))
 80    # Each stretchy gets wider
 81    for b in stretchies:
 82        b.w += bump
 83    # And we put each thing next to the previous one
 84    for j, b in enumerate(row[1:], 1):
 85        b.x = row[j - 1].x + row[j - 1].w + separation
 86
 87
 88def add_hyphen(row, separation):
 89    """If the row requires a hyphen at the end, add it, respecting separation.
 90
 91    Returns the added hyphen or None."""
 92    h_b = None
 93    if row[-1].letter == "\xad":
 94        # Add a visible hyphen in the row
 95        h_b = hyphenbox()
 96        h_b.x = row[-2].x + row[-2].w + separation
 97        h_b.y = row[-2].y
 98        row.append(h_b)  # So it's justified
 99    return h_b
100
101
102BREAKING_CHARS = (" ", "\xad")
103
104
105def is_breaking(box, page):
106    """Decide if 'box' is a good candidate to be the end of a row
107    in the page."""
108    # If it's a newline
109    if box.letter == "\n":
110        return True
111
112    # If we are too much to the right
113    if (box.x + box.w) > (page.x + page.w):
114        # And it's a breaking character:
115        if box.letter in BREAKING_CHARS:
116            return True
117
118    return False
119
120
121def layout(_boxes, pages, separation):
122    """Layout boxes along pages.
123
124    Keep in mind that this function modifies the boxes themselves, so
125    you should be very careful about trying to call layout() more than once
126    on the same boxes.
127
128    Specifically, some spaces will become 0-width and not stretchy.
129    """
130
131    # Because we modify the box list, we will work on a copy
132    boxes = _boxes[:]
133    # We start at page 0
134    page = 0
135    # The 1st box should be placed in the correct page
136    previous = boxes.pop(0)
137    previous.x = pages[page].x
138    previous.y = pages[page].y
139    row = []
140    while boxes:
141        # We take the new 1st box
142        box = boxes.pop(0)
143        # And put it next to the other
144        box.x = previous.x + previous.w + separation
145        # At the same vertical location
146        box.y = previous.y
147
148        # Put the box in the row
149        row.append(box)
150
151        if is_breaking(box, pages[page]):
152            h_b = add_hyphen(row, separation)
153            if h_b:
154                _boxes.append(h_b)  # So it's drawn
155            justify_row(row, pages[page], separation)
156            # We start a new row
157            row = []
158            # We go all the way left and a little down
159            box.x = pages[page].x
160            box.y = previous.y + previous.h + separation
161
162        # But if we go too far down
163        if box.y + box.h > pages[page].y + pages[page].h:
164            # We go to the next page
165            page += 1
166            # And put the box at the top-left
167            box.x = pages[page].x
168            box.y = pages[page].y
169
170        # Collapse all left-margin space
171        if all(b.letter == " " for b in row):
172            box.w = 0
173            box.stretchy = False
174            box.x = pages[page].x
175
176        previous = box
177
178    # Remove leftover boxes
179    del (pages[page + 1:])
180
181
182def draw_boxes(boxes, pages, fname, size, hide_boxes=False):
183    dwg = svgwrite.Drawing(fname, profile="full", size=size)
184    # Draw the pages
185    for page in pages:
186        dwg.add(
187            dwg.rect(
188                insert=(f"{page.x}cm", f"{page.y}cm"),
189                size=(f"{page.w}cm", f"{page.h}cm"),
190                fill="lightblue",
191            )
192        )
193    # Draw all the boxes
194    for box in boxes:
195        # The box color depends on its features
196        color = "green" if box.stretchy else "red"
197        # Make the colored boxes optional
198        if not hide_boxes:
199            dwg.add(
200                dwg.rect(
201                    insert=(f"{box.x}cm", f"{box.y}cm"),
202                    size=(f"{box.w}cm", f"{box.h}cm"),
203                    fill=color,
204                )
205            )
206        # Display the letter in the box
207        if box.letter:
208            dwg.add(
209                dwg.text(
210                    box.letter,
211                    insert=(f"{box.x}cm", f"{box.y + box.h}cm"),
212                    font_size=f"{box.h}cm",
213                    font_family="Arial",
214                )
215            )
216    dwg.save()
217
218
219def create_text_boxes(input_file):
220    p_and_p = open(input_file).read()
221    p_and_p = insert_soft_hyphens(p_and_p)  # Insert invisible hyphens
222    text_boxes = []
223    for letter in p_and_p:
224        text_boxes.append(Box(letter=letter, stretchy=letter == " "))
225    adjust_widths_by_letter(text_boxes)
226    return text_boxes
227
228
229def create_pages(page_size):
230    # A few pages all the same size
231    w, h = page_size
232    pages = [Box(i * (w + 5), 0, w, h) for i in range(1000)]
233    return pages
234
235
236def convert(input, output, page_size=(30, 50), separation=0.05):
237    pages = create_pages(page_size)
238    text_boxes = create_text_boxes(input)
239    layout(text_boxes, pages, separation)
240    draw_boxes(
241        text_boxes,
242        pages,
243        output,
244        (f"{pages[-1].w + pages[-1].x}cm", f"{pages[-1].h}cm"),
245        True,
246    )
247
248
249if __name__ == "__main__":
250    arguments = docopt(__doc__, version="Boxes 0.13")
251
252    if arguments["--page-size"]:
253        p_size = [int(x) for x in arguments["--page-size"].split("x")]
254    else:
255        p_size = (30, 50)
256
257    if arguments["--separation"]:
258        separation = float(arguments["--separation"])
259    else:
260        separation = 0.05
261
262    convert(
263        input=arguments["<input>"],
264        output=arguments["<output>"],
265        page_size=p_size,
266        separation=separation,
267    )
268