❭ code/lesson10/boxes.py
12345  6789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135  136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178  179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
"""
Usage:
    boxes <input> <output> [--page-size=<WxH>] [--separation=<sep>]
    boxes --version
"""


from fonts import adjust_widths_by_letter
from hyphen import insert_soft_hyphens

import svgwrite
from docopt import docopt


class Box():

    def __init__(self, x=0, y=0, w=1, h=1, stretchy=False, letter="x"):
        """Accept arguments to define our box, and store them."""
        self.x = x
        self.y = y
        self.w = w
        self.h = h
        self.stretchy = stretchy
        self.letter = letter

    def __repr__(self):
        return 'Box(%s, %s, %s, %s, "%s")' % (
            self.x, self.y, self.w, self.h, self.letter
        )


def hyphenbox():
    b = Box(letter="-")
    adjust_widths_by_letter([b])
    return b


def badness(page_width, row):
    """Calculate how 'bad' a position to break is.

    bigger is worse.
    """
    # Yes, this is suboptimal. It's easier to optimize working code
    # than fixing fast code.
    row_width = (row[-1].x + row[-1].w) - row[0].x
    slack = page_width - row_width
    stretchies = [b for b in row if b.stretchy]
    if len(stretchies) > 0:
        stretchies_width = sum(s.w for s in stretchies)
        # More stetchy space is good. More slack is bad.
        badness = slack / stretchies_width
    else:  # Nothing to stretch. Not good.
        badness = 1000
    if slack < 0:
        # Arbitrary fudge factor, negative slack is THIS much worse
        badness *= -2
    return badness


def justify_row(row, page, separation):
    """Given a row and a page, adjust position of elements in the row
    so it fits the page width.

    It modifies the contents of the row in place, so returns nothing.
    """

    # Remove all right-margin spaces
    while row[-1].letter == " ":
        row.pop()

    # If the line ends in newline, and is underfull, do nothing.
    if row[-1].letter == "\n" and (row[-1].x + row[-1].w) < (
        page.x + page.w
    ):
        return

    # If the line ends with a soft-hyphen, replace it with a real hyphen
    elif row[-1].letter == "\xad":
        # This looks pretty bad, doesn't it?
        hyphen = hyphenbox()
        row[-1].letter = hyphen.letter
        row[-1].w = hyphen.w

    slack = (page.x + page.w) - (row[-1].x + row[-1].w)
    # Get a list of all the ones that are stretchy
    stretchies = [b for b in row if b.stretchy]
    if not stretchies:
        # Nothing stretches, spread slack on everything
        stretchies = row
    bump = (slack / len(stretchies))
    # Each stretchy gets wider
    for b in stretchies:
        b.w += bump
    # And we put each thing next to the previous one
    for j, b in enumerate(row[1:], 1):
        b.x = row[j - 1].x + row[j - 1].w + separation


BREAKING_CHARS = (" ", "\xad")


def is_breaking(box, page):
    """Decide if 'box' is a good candidate to be the end of a row
    in the page."""
    # If it's a newline
    if box.letter == "\n":
        return True

    # If we are too much to the right
    if (box.x + box.w) > (page.x + page.w):
        # And it's a breaking character:
        if box.letter in BREAKING_CHARS:
            return True

    return False


def fill_row(boxes, page, separation):
    """Fill a row with elements removed from boxes.

    The elements put in the row should be a good fit for laying out on
    page considering separation.
    """

    # Calculate initial breaking point
    row = []
    x = page.x
    while boxes:
        b = boxes.pop(0)
        row.append(b)
        b.x = x
        if is_breaking(b, page):
            break

        x = x + b.w + separation
    # Calculate badness for previous breaking points

    badnesses = {}
    for i in range(1, len(row)):
        _row = row[:i + 1]
        if _row[-1].letter in [" ", "\xad", "\n"]:
            how_bad = badness(page.w, _row)
            badnesses[how_bad] = _row
    if badnesses:
        # Find minimum badness
        min_bad = min(badnesses.keys())
        _row = badnesses[min_bad]
        # Put leftover letters back in boxes
        for b in row[:len(_row) - 1:-1]:
            boxes.insert(0, b)
    else:
        _row = row
    return _row


def layout(_boxes, _pages, separation):
    """Layout boxes along pages."""

    # We modify these lists, so use copies
    boxes = _boxes[:]
    pages = _pages[:]

    # Start in page 0
    current_page = pages.pop(0)
    y = current_page.y

    # If we run out of boxes or pages, stop
    while boxes and pages:
        # If this row would end below the page, advance to next page
        if (y + boxes[0].h) > (current_page.y + current_page.h):
            current_page = pages.pop(0)
            y = current_page.y
        # Put "enough" letters into row and out of boxes
        row = fill_row(boxes, current_page, separation)
        # Adjust box positions to fill the page width
        justify_row(row, current_page, separation)
        # Put all the letters in the right vertical position
        y = y + row[0].h + separation
        for b in row:
            b.y = y

    # Remove unused pages
    del (_pages[-len(pages):])


def draw_boxes(boxes, pages, fname, size, hide_boxes=False):
    dwg = svgwrite.Drawing(fname, profile="full", size=size)
    # Draw the pages
    for page in pages:
        dwg.add(
            dwg.rect(
                insert=(f"{page.x}cm", f"{page.y}cm"),
                size=(f"{page.w}cm", f"{page.h}cm"),
                fill="lightblue",
            )
        )
    # Draw all the boxes
    for box in boxes:
        # The box color depends on its features
        color = "green" if box.stretchy else "red"
        # Make the colored boxes optional
        if not hide_boxes:
            dwg.add(
                dwg.rect(
                    insert=(f"{box.x}cm", f"{box.y}cm"),
                    size=(f"{box.w}cm", f"{box.h}cm"),
                    fill=color,
                )
            )
        # Display the letter in the box
        if box.letter:
            dwg.add(
                dwg.text(
                    box.letter,
                    insert=(f"{box.x}cm", f"{box.y + box.h}cm"),
                    font_size=f"{box.h}cm",
                    font_family="Arial",
                )
            )
    dwg.save()


def create_text_boxes(input_file):
    p_and_p = open(input_file).read()
    p_and_p = insert_soft_hyphens(p_and_p)  # Insert invisible hyphens
    if p_and_p[-1] != "\n":
        p_and_p += "\n"
    text_boxes = []
    for letter in p_and_p:
        text_boxes.append(Box(letter=letter, stretchy=letter == " "))
    adjust_widths_by_letter(text_boxes)
    return text_boxes


def create_pages(page_size):
    # A few pages all the same size
    w, h = page_size
    pages = [Box(i * (w + 5), 0, w, h) for i in range(1000)]
    return pages


def convert(input, output, page_size=(30, 50), separation=0.05):
    pages = create_pages(page_size)
    text_boxes = create_text_boxes(input)
    layout(text_boxes, pages, separation)
    draw_boxes(
        text_boxes,
        pages,
        output,
        (f"{pages[-1].w + pages[-1].x}cm", f"{pages[-1].h}cm"),
        True,
    )


if __name__ == "__main__":
    arguments = docopt(__doc__, version="Boxes 0.13")

    if arguments["--page-size"]:
        p_size = [int(x) for x in arguments["--page-size"].split("x")]
    else:
        p_size = (30, 50)

    if arguments["--separation"]:
        separation = float(arguments["--separation"])
    else:
        separation = 0.05

    convert(
        input=arguments["<input>"],
        output=arguments["<output>"],
        page_size=p_size,
        separation=separation,
    )

❭ code/lesson11.1/boxes.py
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150    151152153154155156157158159160161162163164165166167168169170171172173174175  176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
"""
Usage:
    boxes <input> <output> [--page-size=<WxH>] [--separation=<sep>]
    boxes --version
"""
from collections import deque

from fonts import adjust_widths_by_letter
from hyphen import insert_soft_hyphens

import svgwrite
from docopt import docopt


class Box():

    def __init__(self, x=0, y=0, w=1, h=1, stretchy=False, letter="x"):
        """Accept arguments to define our box, and store them."""
        self.x = x
        self.y = y
        self.w = w
        self.h = h
        self.stretchy = stretchy
        self.letter = letter

    def __repr__(self):
        return 'Box(%s, %s, %s, %s, "%s")' % (
            self.x, self.y, self.w, self.h, self.letter
        )


def hyphenbox():
    b = Box(letter="-")
    adjust_widths_by_letter([b])
    return b


def badness(page_width, row):
    """Calculate how 'bad' a position to break is.

    bigger is worse.
    """
    # Yes, this is suboptimal. It's easier to optimize working code
    # than fixing fast code.
    row_width = (row[-1].x + row[-1].w) - row[0].x
    slack = page_width - row_width
    stretchies = [b for b in row if b.stretchy]
    if len(stretchies) > 0:
        stretchies_width = sum(s.w for s in stretchies)
        # More stetchy space is good. More slack is bad.
        badness = slack / stretchies_width
    else:  # Nothing to stretch. Not good.
        badness = 1000
    if slack < 0:
        # Arbitrary fudge factor, negative slack is THIS much worse
        badness *= -2
    return badness


def justify_row(row, page, separation):
    """Given a row and a page, adjust position of elements in the row
    so it fits the page width.

    It modifies the contents of the row in place, so returns nothing.
    """

    # Remove all right-margin spaces
    while row[-1].letter == " ":
        row.pop()

    # If the line ends in newline, and is underfull, do nothing.
    if row[-1].letter == "\n" and (row[-1].x + row[-1].w) < (
        page.x + page.w
    ):
        return

    # If the line ends with a soft-hyphen, replace it with a real hyphen
    elif row[-1].letter == "\xad":
        # This looks pretty bad, doesn't it?
        hyphen = hyphenbox()
        row[-1].letter = hyphen.letter
        row[-1].w = hyphen.w

    slack = (page.x + page.w) - (row[-1].x + row[-1].w)
    # Get a list of all the ones that are stretchy
    stretchies = [b for b in row if b.stretchy]
    if not stretchies:
        # Nothing stretches, spread slack on everything
        stretchies = row
    bump = (slack / len(stretchies))
    # Each stretchy gets wider
    for b in stretchies:
        b.w += bump
    # And we put each thing next to the previous one
    for j in range(1, len(row)):
        row[j].x = row[j - 1].x + row[j - 1].w + separation


BREAKING_CHARS = (" ", "\xad", "\n")


def is_breaking(box, page):
    """Decide if 'box' is a good candidate to be the end of a row
    in the page."""
    # If it's a newline
    if box.letter == "\n":
        return True

    # If we are too much to the right
    if (box.x + box.w) > (page.x + page.w):
        # And it's a breaking character:
        if box.letter in BREAKING_CHARS:
            return True

    return False


def fill_row(boxes, page, separation):
    """Fill a row with elements removed from boxes.

    The elements put in the row should be a good fit for laying out on
    page considering separation.
    """

    # Calculate initial breaking point
    row = []
    x = page.x
    while boxes:
        b = boxes.popleft()
        row.append(b)
        b.x = x
        if is_breaking(b, page):
            break

        x = x + b.w + separation
    # Calculate badness for previous breaking points
    result = row
    badnesses = {}
    for i in range(1, len(row)):
        partial_row = row[:i + 1]
        if partial_row[-1].letter in BREAKING_CHARS:
            how_bad = badness(page.w, partial_row)
            badnesses[how_bad] = partial_row
    if badnesses:
        # Find minimum badness
        min_bad = min(badnesses.keys())
        partial_row = badnesses[min_bad]
        # Put leftover letters back in boxes
        boxes.extendleft(reversed(row[len(partial_row):]))
        result = partial_row


    return result


def layout(_boxes, _pages, separation):
    """Layout boxes along pages."""

    # We modify these lists, so use copies
    boxes = deque(_boxes)
    pages = _pages[:]

    # Start in page 0
    current_page = pages.pop(0)
    y = current_page.y

    # If we run out of boxes or pages, stop
    while boxes and pages:
        # If this row would end below the page, advance to next page
        if (y + boxes[0].h) > (current_page.y + current_page.h):
            current_page = pages.pop(0)
            y = current_page.y
        # Put "enough" letters into row and out of boxes
        row = fill_row(boxes, current_page, separation)
        # Adjust box positions to fill the page width
        justify_row(row, current_page, separation)
        # Put all the letters in the right vertical position

        for b in row:
            b.y = y
        y = y + row[0].h + separation
    # Remove unused pages
    del (_pages[-len(pages):])


def draw_boxes(boxes, pages, fname, size, hide_boxes=False):
    dwg = svgwrite.Drawing(fname, profile="full", size=size)
    # Draw the pages
    for page in pages:
        dwg.add(
            dwg.rect(
                insert=(f"{page.x}cm", f"{page.y}cm"),
                size=(f"{page.w}cm", f"{page.h}cm"),
                fill="lightblue",
            )
        )
    # Draw all the boxes
    for box in boxes:
        # The box color depends on its features
        color = "green" if box.stretchy else "red"
        # Make the colored boxes optional
        if not hide_boxes:
            dwg.add(
                dwg.rect(
                    insert=(f"{box.x}cm", f"{box.y}cm"),
                    size=(f"{box.w}cm", f"{box.h}cm"),
                    fill=color,
                )
            )
        # Display the letter in the box
        if box.letter:
            dwg.add(
                dwg.text(
                    box.letter,
                    insert=(f"{box.x}cm", f"{box.y + box.h}cm"),
                    font_size=f"{box.h}cm",
                    font_family="Arial",
                )
            )
    dwg.save()


def create_text_boxes(input_file):
    p_and_p = open(input_file).read()
    p_and_p = insert_soft_hyphens(p_and_p)  # Insert invisible hyphens
    if p_and_p[-1] != "\n":
        p_and_p += "\n"
    text_boxes = []
    for letter in p_and_p:
        text_boxes.append(Box(letter=letter, stretchy=letter == " "))
    adjust_widths_by_letter(text_boxes)
    return text_boxes


def create_pages(page_size):
    # A few pages all the same size
    w, h = page_size
    pages = [Box(i * (w + 5), 0, w, h) for i in range(1000)]
    return pages


def convert(input, output, page_size=(30, 50), separation=0.05):
    pages = create_pages(page_size)
    text_boxes = create_text_boxes(input)
    layout(text_boxes, pages, separation)
    draw_boxes(
        text_boxes,
        pages,
        output,
        (f"{pages[-1].w + pages[-1].x}cm", f"{pages[-1].h}cm"),
        True,
    )


if __name__ == "__main__":
    arguments = docopt(__doc__, version="Boxes 0.13")

    if arguments["--page-size"]:
        p_size = [int(x) for x in arguments["--page-size"].split("x")]
    else:
        p_size = (30, 50)

    if arguments["--separation"]:
        separation = float(arguments["--separation"])
    else:
        separation = 0.05

    convert(
        input=arguments["<input>"],
        output=arguments["<output>"],
        page_size=p_size,
        separation=separation,
    )