BOXES v0.7

So far in our previous lessons we have worked in an abstract world of boxes. Some hints of a direction were visible, like organizing our boxes in pages and trying to achieve a justified layout among others.

So, let's just say it, we are going to be doing text layout. But not the easy one. No, sir. No monospaced fonts for us. We want to do the whole enchilada, we are going to have variable-width fonts with kerning, and multi-page, fully-justified text layouts with hyphenation.

OK, perhaps about 50% of the enchilada, because it will have no bidirectional support, only work in English, only read UTF-8 encoded files, and so on a lot of things. But it's still a lot of Mexican food!

And we are going to do that in lessons not much longer than the ones you have been seeing so far. Except this one. This one is much longer. So let's get started.

Clearly, we want our boxes to have letters. And our "stretchy" boxes are special because they have things like spaces. In fact, let's just say they have spaces.

We will now expand our Box class to support letters inside the boxes.

 1class Box():
 2
 3    def __init__(self, x=0, y=0, w=1, h=1, stretchy=False, letter="x"):
 4        """Accept arguments to define our box, and store them."""
 5        self.x = x
 6        self.y = y
 7        self.w = w
 8        self.h = h
 9        self.stretchy = stretchy
10        self.letter = letter
11
12    def __repr__(self):
13        return 'Box(%s, %s, %s, %s, "%s")' % (
14            self.x, self.y, self.w, self.h, self.letter
15        )
16
17
18# Many boxes, all the same width, with an x in them
19text_boxes = [Box() for i in range(5000)]
20
21# A few pages all the same size
22pages = [Box(i * 35, 0, 30, 50) for i in range(10)]

We can keep using the exact same layout function, so no need to show it here.

The drawing function needs tweaking to show us letters, and to make the colored boxes optional.

89import svgwrite
90
91
92def draw_boxes(boxes, fname, size, hide_boxes=False):
93    dwg = svgwrite.Drawing(fname, profile="full", size=size)
94    # Draw the pages
95    for page in pages:
96        dwg.add(
97            dwg.rect(
98                insert=(f"{page.x}cm", f"{page.y}cm"),
99                size=(f"{page.w}cm", f"{page.h}cm"),
100                fill="lightblue",
101            )
102        )
103    # Draw all the boxes
104    for box in boxes:
105        # The box color depends on its features
106        color = "green" if box.stretchy else "red"
107        # Make the colored boxes optional
108        if not hide_boxes:
109            dwg.add(
110                dwg.rect(
111                    insert=(f"{box.x}cm", f"{box.y}cm"),
112                    size=(f"{box.w}cm", f"{box.h}cm"),
113                    fill=color,
114                )
115            )
116        # Display the letter in the box
117        if box.letter:
118            dwg.add(
119                dwg.text(
120                    box.letter,
121                    insert=(f"{box.x}cm", f"{box.y + box.h}cm"),
122                    font_size=f"{box.h}cm",
123                    font_family="Arial",
124                )
125            )
126    dwg.save()
127
128
129draw_boxes(text_boxes, "lesson7.svg", ("30cm", "20cm"))

lesson7.svg

Of course this is very boring, so we need to spice up our data a little. We can use different letters, and then make the right ones stretchy. That is easy!

132from random import choice
133
134for box in text_boxes:
135    # More than one space so they appear often
136    box.letter = choice("     abcdefghijklmnopqrstuvwxyz")
137    if box.letter == " ":  # Spaces are stretchy
138        box.stretchy = True
139
140layout(text_boxes)
141draw_boxes(
142    text_boxes, "lesson7_different_letters.svg", ("30cm", "20cm")
143)

lesson7_different_letters.svg

As you can see, there are very minor horizontal shifts and stretches, since all boxes are the same size.

But as a text layout engine we have a major failure: we are ignoring the size of the letters we are laying out!

This is a very complex thing to do called text shaping. You need to understand the content of the font you are using to display the text, and more subtle things like what happens if you put specific letters next to each other (kerning) and much more.

The good news is that it's already done for us, in libraries called Harfbuzz and Freetype.

This paragraph is perhaps the most important one in this book. I am about to show you some obscure code. And I will tell you the secret of how it got here: I copied it from the documentation for the libraries I am using. Sometimes you will need to do something complicated only once in your life. It's perfectly OK to just google how to do it. And as long as you are confident you can find it again if needed, it's OK to just forget about it.

I will show you this code, and then put it in a separate file called fonts.py and from now on I will not show it in the lessons, because we are not going to change it, ever.

 1import harfbuzz as hb
 2import freetype2 as ft
 3
 4
 5def adjust_widths_by_letter(boxes):
 6    """Takes a list of boxes as arguments, and uses harfbuzz to
 7    adjust the width of each box to match the harfbuzz text shaping."""
 8    buf = hb.Buffer.create()
 9    buf.add_str("".join(b.letter for b in boxes))
10    buf.guess_segment_properties()
11    font_lib = ft.get_default_lib()
12    face = font_lib.find_face("Arial")
13    face.set_char_size(size=1, resolution=64)
14    font = hb.Font.ft_create(face)
15    hb.shape(font, buf)
16    # at this point buf.glyph_positions has all the data we need
17    for box, position in zip(boxes, buf.glyph_positions):
18        box.w = position.x_advance

And now we will pretend we know what that does, based on its docstring and use it.

146from code import fonts
147
148
149separation = .05
150fonts.adjust_widths_by_letter(text_boxes)
151layout(text_boxes)
152draw_boxes(text_boxes, "lesson7_adjusted_letters.svg", ("30cm", "20cm"))

lesson7_adjusted_letters.svg

And nicer, without the boxes:

155fonts.adjust_widths_by_letter(text_boxes)
156layout(text_boxes)
157draw_boxes(
158    text_boxes,
159    "lesson7_adjusted_letters_no_boxes.svg",
160    ("30cm", "20cm"),
161    hide_boxes=True,
162)

lesson7_adjusted_letters_no_boxes.svg

And of course, we can just load text there instead of random letters. For example, here we load what is going to be our example test from now on, Jane Austen's Pride and Prejudice from Project Gutenberg

165p_and_p = open("pride-and-prejudice.txt").read()
166text_boxes = []
167for l in p_and_p:
168    text_boxes.append(Box(letter=l, stretchy=l == " "))
169fonts.adjust_widths_by_letter(text_boxes)
170layout(text_boxes)
171draw_boxes(
172    text_boxes,
173    "lesson7_pride_and_prejudice.svg",
174    ("30cm", "20cm"),
175    hide_boxes=True,
176)

lesson7_pride_and_prejudice.svg

And that is ... maybe disappointing? While we spent a lot of time on things like justifying text, we have not even looked at newlines!

Also, spaces at the end of lines make the line appear ragged again, now that they are not boxes.

So, we know what to hit in the next lesson.


Further references:

results matching ""

    No results matching ""