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