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
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
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
- Full source code for this lesson boxes.py
- Difference with code from last lesson