Boxes v0.17

In the previous lesson we added tests for our justify_row() function, allowing us to have some confidence that we will not break it if we play with it.

In the lesson before that we complained that our layout() function was too complex, and that was why we created justify_row() in the first place.

So, let's continue to take things out of layout()...

Look at these fragments of code:

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
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

What is happening is that we can break_line for two reasons, and thus are setting a "flag" which we then use to do the actual line breaking. That is because if we break because of a newline, we don't justify the line.

So, if we gave justify_row() the skill of handling lines ended in newline, then that distinction goes away and layout() is simpler.

The good thing is that we already have a test for it! It's the one we marked as "expected failure" in the previous lesson, so if we make it pass... we are on the right path.

How do we want to handle it? Let's look at our test, and remember that we are defining the desired behavior there.

55@pytest.mark.xfail  # FIXME: justify doesn't handle newlines yet!
56def test_justify_ends_with_newline():
57    """Test a use case with a newline."""
58    row = [boxes.Box(x=i, w=1, h=1, letter="a") for i in range(10)]
59    row[-1].letter = "\n"
60    page = boxes.Box(w=50, h=50)
61    separation = .1
62
63    assert len(row) == 10
64    assert row[-1].x + row[-1].w == 10
65
66    boxes.justify_row(row, page, separation)
67
68    # Should have the same number of elements
69    assert len(row) == 10
70    # The first element should be flushed-left
71    assert row[0].x == page.x
72    # The last element should NOT be flushed-right
73    assert row[-1].x + row[-1].w == 10

It looks easy enough: just don't spread the slack if we are ending on a newline. That change is simple:

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    # If the line ends in newline, do nothing.
70    if row[-1].letter == "\n":
71        return

And run the test suite.

$ env PYTHONPATH=. pytest
============================= test session starts ==============================
platform linux -- Python 3.6.2, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
rootdir: code/lesson6, inifile:
collected 5 items

tests/test_justify_row.py ..X..                                   [100%]

===================== 4 passed, 1 xpassed in 0.25 seconds ======================

And now our test "xpasses". That means we marked as "expected to fail" and it unexpectedly passes.

So, we can remove that mark now, and the test suite passes. Let's change layout() to make it do the right thing by removing code and changing the if in line 133:

132        # If it's a newline or if it's too far to the right, and is a
133        # good place to break the line...
134        if (box.letter == "\n") or (box.x + box.w) > (
135            pages[page].x + pages[page].w
136        ) and box.letter in (
137            " ", "\xad"
138        ):
139            h_b = add_hyphen(row, separation)
140            if h_b:
141                _boxes.append(h_b)  # So it's drawn
142            justify_row(row, pages[page], separation)
143            # We start a new row
144            row = []
145            # We go all the way left and a little down
146            box.x = pages[page].x
147            box.y = previous.y + previous.h + separation
148
149        # But if we go too far down
150        if box.y + box.h > pages[page].y + pages[page].h:
151            # We go to the next page
152            page += 1
153            # And put the box at the top-left
154            box.x = pages[page].x
155            box.y = pages[page].y
156
157        # Put the box in the row
158        row.append(box)

But ... we are changing layout(). And layout() has no tests. We need to manually test it.

$ python boxes.py pride-and-prejudice.txt lesson6.svg

lesson6.svg

Oh, no, it's broken!

Can you guess what happened?

In the lines that end in newlines (like the first one), we are still spreading the slack! That's because we are only adding the current box to the row after we justify it, in line 158!

If we move that up, before the if that decides whether to break the line, then it works:

129        # Put the box in the row
130        row.append(box)
131
132        # If it's a newline or if it's too far to the right, and is a
133        # good place to break the line...
134        if (box.letter == "\n") or (box.x + box.w) > (
135            pages[page].x + pages[page].w
136        ) and box.letter in (
137            " ", "\xad"
138        ):
$ python ../lesson6.1/boxes.py pride-and-prejudice.txt lesson6.1.svg

lesson6.1.svg

While we are here, let's change one more thing. Look at this code in justify_row():

76    if not stretchies:  # Nothing stretches do as before.
77        bump = slack / (len(row) - 1)
78        # The 1st box gets 0 bumps, the 2nd gets 1 and so on
79        for i, b in enumerate(row):
80            b.x += bump * i
81    else:
82        bump = slack / len(stretchies)
83        # Each stretchy gets wider
84        for b in stretchies:
85            b.w += bump
86        # And we put each thing next to the previous one
87        for j, b in enumerate(row[1:], 1):
88            b.x = row[j - 1].x + row[j - 1].w + separation
  • If we have stretchies then we make them wider.
  • If we don't, then we move every box a little to the right.

Why are we doing this, which is the same thing, "spreading the slack" in two different ways?

No reason.

So, because we have tests for justify_row() we can make the code more uniform and less complex.

76    if not stretchies:
77        # Nothing stretches, spread slack on everything
78        stretchies = row
79    bump = slack / len(stretchies)
80    # Each stretchy gets wider
81    for b in stretchies:
82        b.w += bump
83    # And we put each thing next to the previous one
84    for j, b in enumerate(row[1:], 1):
85        b.x = row[j - 1].x + row[j - 1].w + separation
$ env PYTHONPATH=. pytest

============================= test session starts ==============================
platform linux -- Python 3.6.2, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
rootdir: code/lesson6.1, inifile:
collected 5 items
tests/test_justify_row.py F..F.                                           [100%]

=================================== FAILURES ===================================
_____________________________ test_justify_simple ______________________________

[snip]
        # The last element should be flushed-right
>       assert row[-1].x + row[-1].w == page.x + page.w
E       assert (45.900000000000006 + 5.0) == (0 + 50)
E        +  where 45.900000000000006 = Box(45.900000000000006, 0, 5.0, 0, "a").x
E        +  and   5.0 = Box(45.900000000000006, 0, 5.0, 0, "a").w
E        +  and   0 = Box(0, 0, 50, 0, "x").x
E        +  and   50 = Box(0, 0, 50, 0, "x").w

tests/test_justify_row.py:27: AssertionError
_________________________ test_justify_trailing_spaces _________________________

[snip]
        # The last element should be flushed-right
>       assert row[-1].x + row[-1].w == page.x + page.w
E       assert (44.45 + 6.25) == (0 + 50)
E        +  where 44.45 = Box(44.45, 0, 6.25, 0, "a").x
E        +  and   6.25 = Box(44.45, 0, 6.25, 0, "a").w
E        +  and   0 = Box(0, 0, 50, 0, "x").x
E        +  and   50 = Box(0, 0, 50, 0, "x").w

tests/test_justify_row.py:93: AssertionError
====================== 2 failed, 3 passed in 0.21 seconds ======================

Annnnnd it fails horribly. That is good. What did you expect? That we could just change the code and everything would be fine?

No, reader, that is not how this works. This is good because our tests found a bug we just introduced. We went in with an ax, we broke something. We now fix it. Hakuna matata, the circle of coding.

Turns out the problem was a bug in our tests. We were creating the rows like this:

1    row = [boxes.Box(x=i, w=1, h=1, letter="a") for i in range(10)]
2    page = boxes.Box(w=50, h=50)
3    separation = .1

And that is wrong, because we are saying they have a separation of .1 and are not separating them.

The fix is easy:

9    separation = .1
10    row = [
11        boxes.Box(x=i + i * separation, w=1, h=1, letter="a")
12        for i in range(10)
13    ]

You think about it :-)

And, does it look good in real life?

$ python ../lesson6.2/boxes.py pride-and-prejudice.txt lesson6.2.svg

lesson6.2.svg


results matching ""

    No results matching ""