❭ code/lesson7/boxes.py
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768 697071 72737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131 132 133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267 | """ 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, do nothing. if row[-1].letter == "\n": return 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 def add_hyphen(row, separation): """If the row requires a hyphen at the end, add it, respecting separation. Returns the added hyphen or None.""" h_b = None if row[-1].letter == "\xad": # Add a visible hyphen in the row h_b = hyphenbox() h_b.x = row[-2].x + row[-2].w + separation h_b.y = row[-2].y row.append(h_b) # So it's justified return h_b 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 layout(_boxes, pages, separation): """Layout boxes along pages. Keep in mind that this function modifies the boxes themselves, so you should be very careful about trying to call layout() more than once on the same boxes. Specifically, some spaces will become 0-width and not stretchy. """ # Because we modify the box list, we will work on a copy boxes = _boxes[:] # We start at page 0 page = 0 # The 1st box should be placed in the correct page previous = boxes.pop(0) previous.x = pages[page].x previous.y = pages[page].y row = [] while boxes: # We take the new 1st box box = boxes.pop(0) # And put it next to the other box.x = previous.x + previous.w + separation # At the same vertical location box.y = previous.y # Put the box in the row row.append(box) if is_breaking(box, pages[page]): h_b = add_hyphen(row, separation) if h_b: _boxes.append(h_b) # So it's drawn justify_row(row, pages[page], separation) # We start a new row row = [] # We go all the way left and a little down box.x = pages[page].x box.y = previous.y + previous.h + separation # But if we go too far down if box.y + box.h > pages[page].y + pages[page].h: # We go to the next page page += 1 # And put the box at the top-left box.x = pages[page].x box.y = pages[page].y # Collapse all left-margin space if all(b.letter == " " for b in row): box.w = 0 box.stretchy = False box.x = pages[page].x previous = box # Remove leftover boxes del (pages[page + 1:]) 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 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/lesson8/boxes.py
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495 96979899100101102103104105106107108109110111112113114115116117118119 120121122123124125126127128129130131132133134135136137138139140141142143144145146 147148149150151152153154 155156157158 159160161 162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249 | """ 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, do nothing. if row[-1].letter == "\n": 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. """ 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 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 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, ) |