Source for: __init__.py [raw]

  1# Boxes, a SVG text layout engine.
  2
  3# Copyright © 2018 Roberto Alsina.
  4
  5# Permission is hereby granted, free of charge, to any
  6# person obtaining a copy of this software and associated
  7# documentation files (the "Software"), to deal in the
  8# Software without restriction, including without limitation
  9# the rights to use, copy, modify, merge, publish,
 10# distribute, sublicense, and/or sell copies of the
 11# Software, and to permit persons to whom the Software is
 12# furnished to do so, subject to the following conditions:
 13#
 14# The above copyright notice and this permission notice
 15# shall be included in all copies or substantial portions of
 16# the Software.
 17#
 18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
 19# KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
 20# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
 21# PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
 22# OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
 23# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
 24# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
 25# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 26
 27"""Boxes, a SVG text layout engine."""
 28
 29from collections import deque
 30
 31from boxes.fonts import adjust_widths_by_letter
 32from boxes.hyphen import insert_soft_hyphens
 33
 34import svgwrite
 35
 36__version__ = "0.14"
 37
 38
 39class Box:
 40
 41    def __init__(self, x=0, y=0, w=1, h=1, stretchy=False, letter="x"):
 42        """Accept arguments to define our box, and store them."""
 43        self.x = x
 44        self.y = y
 45        self.w = w
 46        self.h = h
 47        self.stretchy = stretchy
 48        self.letter = letter
 49
 50    def __repr__(self):
 51        return 'Box(%s, %s, %s, %s, "%s")' % (
 52            self.x, self.y, self.w, self.h, self.letter
 53        )
 54
 55
 56def hyphenbox():
 57    b = Box(letter="-")
 58    adjust_widths_by_letter([b])
 59    return b
 60
 61
 62def badness(page_width, row):
 63    """Calculate how 'bad' a position to break is.
 64
 65    bigger is worse.
 66    """
 67    # Yes, this is suboptimal. It's easier to optimize working code
 68    # than fixing fast code.
 69    row_width = (row[-1].x + row[-1].w) - row[0].x
 70    slack = page_width - row_width
 71    stretchies = [b for b in row if b.stretchy]
 72    if len(stretchies) > 0:
 73        stretchies_width = sum(s.w for s in stretchies)
 74        # More stetchy space is good. More slack is bad.
 75        badness = slack / stretchies_width
 76    else:  # Nothing to stretch. Not good.
 77        badness = 1000
 78    if slack < 0:
 79        # Arbitrary fudge factor, negative slack is THIS much worse
 80        badness *= -2
 81    return badness
 82
 83
 84def justify_row(row, page, separation):
 85    """Given a row and a page, adjust position of elements in the row
 86    so it fits the page width.
 87
 88    It modifies the contents of the row in place, so returns nothing.
 89    """
 90
 91    # Remove all right-margin spaces
 92    while row[-1].letter == " ":
 93        row.pop()
 94
 95    # If the line ends in newline, and is underfull, do nothing.
 96    if row[-1].letter == "\n" and (row[-1].x + row[-1].w) < (
 97        page.x + page.w
 98    ):
 99        return
100
101    # If the line ends with a soft-hyphen, replace it with a real hyphen
102    elif row[-1].letter == "\xad":
103        # This looks pretty bad, doesn't it?
104        hyphen = hyphenbox()
105        row[-1].letter = hyphen.letter
106        row[-1].w = hyphen.w
107
108    slack = (page.x + page.w) - (row[-1].x + row[-1].w)
109    # Get a list of all the ones that are stretchy
110    stretchies = [b for b in row if b.stretchy]
111    if not stretchies:
112        # Nothing stretches, spread slack on everything
113        stretchies = row
114    bump = (slack / len(stretchies))
115    # Each stretchy gets wider
116    for b in stretchies:
117        b.w += bump
118    # And we put each thing next to the previous one
119    for j in range(1, len(row)):
120        row[j].x = row[j - 1].x + row[j - 1].w + separation
121
122
123BREAKING_CHARS = (" ", "\xad", "\n")
124
125
126def is_breaking(box, page):
127    """Decide if 'box' is a good candidate to be the end of a row
128    in the page."""
129    # If it's a newline
130    if box.letter == "\n":
131        return True
132
133    # If we are too much to the right
134    if (box.x + box.w) > (page.x + page.w):
135        # And it's a breaking character:
136        if box.letter in BREAKING_CHARS:
137            return True
138
139    return False
140
141
142def fill_row(boxes, page, separation):
143    """Fill a row with elements removed from boxes.
144
145    The elements put in the row should be a good fit for laying out on
146    page considering separation.
147    """
148
149    # Calculate initial breaking point
150    row = []
151    x = page.x
152    while boxes:
153        b = boxes.popleft()
154        row.append(b)
155        b.x = x
156        if is_breaking(b, page):
157            break
158
159        x = x + b.w + separation
160    # Calculate badness for previous breaking points
161    result = row
162    badnesses = {}
163    for i in range(1, len(row)):
164        partial_row = row[:i + 1]
165        if partial_row[-1].letter in BREAKING_CHARS:
166            how_bad = badness(page.w, partial_row)
167            badnesses[how_bad] = partial_row
168    if badnesses:
169        # Find minimum badness
170        min_bad = min(badnesses.keys())
171        partial_row = badnesses[min_bad]
172        # Put leftover letters back in boxes
173        boxes.extendleft(reversed(row[len(partial_row):]))
174        result = partial_row
175    return result
176
177
178def layout(_boxes, _pages, separation):
179    """Layout boxes along pages."""
180
181    # We modify these lists, so use copies
182    boxes = deque(_boxes)
183    pages = _pages[:]
184
185    # Start in page 0
186    current_page = pages.pop(0)
187    y = current_page.y
188
189    # If we run out of boxes or pages, stop
190    while boxes and pages:
191        # If this row would end below the page, advance to next page
192        if (y + boxes[0].h) > (current_page.y + current_page.h):
193            current_page = pages.pop(0)
194            y = current_page.y
195        # Put "enough" letters into row and out of boxes
196        row = fill_row(boxes, current_page, separation)
197        # Adjust box positions to fill the page width
198        justify_row(row, current_page, separation)
199        # Put all the letters in the right vertical position
200        for b in row:
201            b.y = y
202        y = y + row[0].h + separation
203    # Remove unused pages
204    del (_pages[-len(pages):])
205
206
207def draw_boxes(boxes, pages, fname, size, hide_boxes=False):
208    dwg = svgwrite.Drawing(fname, profile="full", size=size)
209    # Draw the pages
210    for page in pages:
211        dwg.add(
212            dwg.rect(
213                insert=(f"{page.x}cm", f"{page.y}cm"),
214                size=(f"{page.w}cm", f"{page.h}cm"),
215                fill="lightblue",
216            )
217        )
218    # Draw all the boxes
219    for box in boxes:
220        # The box color depends on its features
221        color = "green" if box.stretchy else "red"
222        # Make the colored boxes optional
223        if not hide_boxes:
224            dwg.add(
225                dwg.rect(
226                    insert=(f"{box.x}cm", f"{box.y}cm"),
227                    size=(f"{box.w}cm", f"{box.h}cm"),
228                    fill=color,
229                )
230            )
231        # Display the letter in the box
232        if box.letter:
233            dwg.add(
234                dwg.text(
235                    box.letter,
236                    insert=(f"{box.x}cm", f"{box.y + box.h}cm"),
237                    font_size=f"{box.h}cm",
238                    font_family="Arial",
239                )
240            )
241    dwg.save()
242
243
244def create_text_boxes(input_file):
245    p_and_p = open(input_file).read()
246    p_and_p = insert_soft_hyphens(p_and_p)  # Insert invisible hyphens
247    if p_and_p[-1] != "\n":
248        p_and_p += "\n"
249    text_boxes = []
250    for letter in p_and_p:
251        text_boxes.append(Box(letter=letter, stretchy=letter == " "))
252    adjust_widths_by_letter(text_boxes)
253    return text_boxes
254
255
256def create_pages(page_size):
257    # A few pages all the same size
258    w, h = page_size
259    pages = [Box(i * (w + 5), 0, w, h) for i in range(1000)]
260    return pages
261
262
263def convert(input, output, page_size=(30, 50), separation=0.05):
264    pages = create_pages(page_size)
265    text_boxes = create_text_boxes(input)
266    layout(text_boxes, pages, separation)
267    draw_boxes(
268        text_boxes,
269        pages,
270        output,
271        (f"{pages[-1].w + pages[-1].x}cm", f"{pages[-1].h}cm"),
272        True,
273    )
274