1
1
mirror of https://github.com/Kozea/WeasyPrint.git synced 2024-10-04 07:57:52 +03:00

fix: don't output footnotes before their call sites

If a footnote has been output but we then decide to cancel the line it
was output on, we should cancel the output of the footnote itself.

This requires a bit of extra bookkeeping, so we know which footnotes are
relevant, but otherwise largely works using the existing
remove_placeholders code.

Includes a minor refactor of footnote area management in LayoutContext,
and a new testing utility: `tree_position`.

Closes #1564.
This commit is contained in:
aschmitz 2022-02-10 01:02:21 -06:00
parent 9003fd0490
commit f953fb8de0
5 changed files with 112 additions and 27 deletions

View File

@ -8,7 +8,7 @@
import pytest
from ..testing_utils import assert_no_logs, render_pages
from ..testing_utils import assert_no_logs, render_pages, tree_position
@assert_no_logs
@ -338,6 +338,44 @@ def test_reported_footnote_3():
assert footnote3.children[0].children[1].text == '3'
@assert_no_logs
@pytest.mark.parametrize('css, tail', (
('p { break-inside: avoid }', '<br>e<br>f'),
('p { widows: 4 }', '<br>e<br>f'),
('p + p { break-before: avoid }', '</p><p>e<br>f'),
))
def test_footnote_area_after_call(css, tail):
pages = render_pages('''
<style>
@font-face {src: url(weasyprint.otf); font-family: weasyprint}
@page {
size: 9px 10px;
background: white;
margin: 0;
}
body {
font-family: weasyprint;
font-size: 2px;
line-height: 1;
orphans: 2;
widows: 2;
margin: 0;
}
span {
float: footnote;
}
%s
</style>
<div>a<br>b</div>
<p>c<br>d<span>x</span>%s</p>''' % (css, tail))
footnote_call = tree_position(
pages, lambda box: box.element_tag == 'p::footnote-call')
footnote_area = tree_position(
pages, lambda box: type(box).__name__ == 'FootnoteAreaBox')
assert footnote_call < footnote_area
@assert_no_logs
def test_footnote_display_inline():
page, = render_pages('''

View File

@ -158,6 +158,27 @@ def serialize(box_list):
for box in box_list]
def tree_position(box_list, matcher):
"""Return a list identifying the first matching box's tree position.
Given a list of Boxes, this function returns a list containing the first
(depth-first) Box that the matcher function identifies. This list can then
be compared to another similarly-obtained list to assert that one Box is in
the document tree before or after another.
box_list: a list of Box objects, possibly PageBoxes
matcher: a function that takes a Box and returns truthy when it matches
"""
for i, box in enumerate(box_list):
if matcher(box):
return [i]
elif hasattr(box, 'children'):
position = tree_position(box.children, matcher)
if position:
return [i] + position
def _parse_base(html_content, base_url=BASE_URL):
document = FakeHTML(string=html_content, base_url=base_url)
counter_style = CounterStyle()

View File

@ -323,23 +323,30 @@ class LayoutContext:
return store[name][previous_page][-1]
def layout_footnote(self, footnote):
"""Add a footnote to the layout for this page."""
self.footnotes.remove(footnote)
self.current_page_footnotes.append(footnote)
if self.current_footnote_area.height != 'auto':
self.page_bottom += self.current_footnote_area.margin_height()
self.current_footnote_area.children = self.current_page_footnotes
footnote_area = build.create_anonymous_boxes(
self.current_footnote_area.deepcopy())
footnote_area, _, _, _, _ = block_level_layout(
self, footnote_area, -inf, None, self.current_footnote_area.page,
True, [], [], [], False)
self.current_footnote_area.height = footnote_area.height
self.page_bottom -= footnote_area.margin_height()
self._update_footnote_area()
def unlayout_footnote(self, footnote):
"""Remove a footnote from the layout and return it to the waitlist."""
self.footnotes.append(footnote)
if footnote in self.current_page_footnotes:
self.current_page_footnotes.remove(footnote)
else:
self.reported_footnotes.remove(footnote)
self._update_footnote_area()
def report_footnote(self, footnote):
"""Mark a footnote as being moved to the next page."""
self.current_page_footnotes.remove(footnote)
self.reported_footnotes.append(footnote)
self.page_bottom += self.current_footnote_area.margin_height()
self._update_footnote_area()
def _update_footnote_area(self):
"""Update the page bottom size and our footnote area height."""
if self.current_footnote_area.height != 'auto':
self.page_bottom += self.current_footnote_area.margin_height()
self.current_footnote_area.children = self.current_page_footnotes
if self.current_footnote_area.children:
footnote_area = build.create_anonymous_boxes(

View File

@ -251,7 +251,7 @@ def _out_of_flow_layout(context, box, index, child, new_children,
resume_at = {index: None}
if new_children and page_break in ('avoid', 'avoid-page'):
result = find_earlier_page_break(
new_children, absolute_boxes, fixed_boxes)
context, new_children, absolute_boxes, fixed_boxes)
if result:
new_children, resume_at = result
stop = True
@ -294,6 +294,7 @@ def _linebox_layout(context, box, index, child, new_children, page_is_empty,
draw_bottom_decoration):
abort = stop = False
resume_at = None
new_footnotes = []
assert len(box.children) == 1, 'line box with siblings before layout'
@ -347,6 +348,7 @@ def _linebox_layout(context, box, index, child, new_children, page_is_empty,
if descendant.footnote in context.footnotes)
for footnote in footnotes:
context.layout_footnote(footnote)
new_footnotes.append(footnote)
overflow = context.reported_footnotes or (
new_position_y + offset_y >
context.page_bottom - bottom_space)
@ -376,7 +378,7 @@ def _linebox_layout(context, box, index, child, new_children, page_is_empty,
if new_children:
resume_at = {index: new_children[-1].resume_at}
return abort, stop, resume_at, position_y
return abort, stop, resume_at, position_y, new_footnotes
def _in_flow_layout(context, box, index, child, new_children, page_is_empty,
@ -479,13 +481,15 @@ def _in_flow_layout(context, box, index, child, new_children, page_is_empty,
not page_is_empty_with_no_children):
# The child content overflows the page area, display it on the
# next page.
remove_placeholders([new_child], absolute_boxes, fixed_boxes)
remove_placeholders(
context, [new_child], absolute_boxes, fixed_boxes)
new_child = None
elif (new_position_y > context.page_bottom - bottom_space and
not page_is_empty_with_no_children):
# The child border/padding overflows the page area, do the
# layout again with a higher bottom_space value.
remove_placeholders([new_child], absolute_boxes, fixed_boxes)
remove_placeholders(
context, [new_child], absolute_boxes, fixed_boxes)
bottom_space += (
new_child.padding_bottom + new_child.border_bottom_width)
(new_child, resume_at, next_page, next_adjoining_margins,
@ -513,7 +517,7 @@ def _in_flow_layout(context, box, index, child, new_children, page_is_empty,
if page_break in ('avoid', 'avoid-page'):
# TODO: fill the blank space at the bottom of the page
result = find_earlier_page_break(
new_children, absolute_boxes, fixed_boxes)
context, new_children, absolute_boxes, fixed_boxes)
if result:
new_children, resume_at = result
stop = True
@ -537,7 +541,8 @@ def _in_flow_layout(context, box, index, child, new_children, page_is_empty,
# This box has only rendered absolute children, keep them
# for the next page. This is for example useful for list
# markers.
remove_placeholders(new_children, absolute_boxes, fixed_boxes)
remove_placeholders(
context, new_children, absolute_boxes, fixed_boxes)
new_children = []
if new_children:
@ -611,6 +616,7 @@ def block_container_layout(context, box, bottom_space, skip_stack,
new_children = []
next_page = {'break': 'any', 'page': None}
all_footnotes = []
last_in_flow_child = None
@ -624,6 +630,7 @@ def block_container_layout(context, box, bottom_space, skip_stack,
for index, child in enumerate(box.children[skip:], start=(skip or 0)):
child.position_x = position_x
child.position_y = position_y # doesnt count adjoining_margins
new_footnotes = []
if not child.is_in_normal_flow():
abort = False
@ -636,13 +643,15 @@ def block_container_layout(context, box, bottom_space, skip_stack,
(child, box, out_of_flow_resume_at))
elif isinstance(child, boxes.LineBox):
abort, stop, resume_at, position_y = _linebox_layout(
(abort, stop, resume_at, position_y,
new_footnotes) = _linebox_layout(
context, box, index, child, new_children, page_is_empty,
absolute_boxes, fixed_boxes, adjoining_margins, bottom_space,
position_y, skip_stack, first_letter_style,
draw_bottom_decoration)
draw_bottom_decoration |= resume_at is None
adjoining_margins = []
all_footnotes += new_footnotes
else:
(abort, stop, resume_at, position_y, adjoining_margins,
@ -656,6 +665,8 @@ def block_container_layout(context, box, bottom_space, skip_stack,
if abort:
page = child.page_values()[0]
for footnote in new_footnotes:
context.unlayout_footnote(footnote)
return None, None, {'break': 'any', 'page': page}, [], False
elif stop:
break
@ -670,6 +681,8 @@ def block_container_layout(context, box, bottom_space, skip_stack,
if (box_is_fragmented and
box.style['break_inside'] in ('avoid', 'avoid-page') and
not page_is_empty):
for footnote in all_footnotes:
context.unlayout_footnote(footnote)
return (
None, None, {'break': 'any', 'page': None}, [], False)
@ -852,7 +865,7 @@ def block_level_page_name(sibling_before, sibling_after):
return after_page
def find_earlier_page_break(children, absolute_boxes, fixed_boxes):
def find_earlier_page_break(context, children, absolute_boxes, fixed_boxes):
"""Find the last possible page break in ``children``.
Because of a `page-break-before: avoid` or a `page-break-after: avoid` we
@ -874,7 +887,8 @@ def find_earlier_page_break(children, absolute_boxes, fixed_boxes):
return None
new_children = children[:index]
resume_at = {0: new_children[-1].resume_at}
remove_placeholders(children[index:], absolute_boxes, fixed_boxes)
remove_placeholders(
context, children[index:], absolute_boxes, fixed_boxes)
return new_children, resume_at
previous_in_flow = None
@ -904,7 +918,7 @@ def find_earlier_page_break(children, absolute_boxes, fixed_boxes):
boxes.BlockBox, boxes.TableBox, boxes.TableRowGroupBox)
if isinstance(child, breakable_box_types):
result = find_earlier_page_break(
child.children, absolute_boxes, fixed_boxes)
context, child.children, absolute_boxes, fixed_boxes)
if result:
new_grand_children, resume_at = result
new_child = child.copy_with_children(new_grand_children)
@ -925,7 +939,7 @@ def find_earlier_page_break(children, absolute_boxes, fixed_boxes):
return None
# TODO: dont remove absolute and fixed placeholders found in table footers
remove_placeholders(children[index:], absolute_boxes, fixed_boxes)
remove_placeholders(context, children[index:], absolute_boxes, fixed_boxes)
return new_children, resume_at
@ -941,7 +955,7 @@ def reversed_enumerate(seq):
return zip(reversed(range(len(seq))), reversed(seq))
def remove_placeholders(box_list, absolute_boxes, fixed_boxes):
def remove_placeholders(context, box_list, absolute_boxes, fixed_boxes):
"""Remove placeholders from absolute and fixed lists.
For boxes that have been removed in find_earlier_page_break(), remove the
@ -950,9 +964,12 @@ def remove_placeholders(box_list, absolute_boxes, fixed_boxes):
"""
for box in box_list:
if isinstance(box, boxes.ParentBox):
remove_placeholders(box.children, absolute_boxes, fixed_boxes)
remove_placeholders(
context, box.children, absolute_boxes, fixed_boxes)
if box.style['position'] == 'absolute' and box in absolute_boxes:
# box is not in absolute_boxes if its parent has position: relative
absolute_boxes.remove(box)
elif box.style['position'] == 'fixed':
fixed_boxes.remove(box)
if box.footnote:
context.unlayout_footnote(box.footnote)

View File

@ -301,7 +301,8 @@ def table_layout(context, table, bottom_space, skip_stack, containing_block,
page_break = block_level_page_break(previous_row, row)
if page_break == 'avoid':
earlier_page_break = find_earlier_page_break(
new_group_children, absolute_boxes, fixed_boxes)
context, new_group_children, absolute_boxes,
fixed_boxes)
if earlier_page_break:
new_group_children, resume_at = earlier_page_break
break
@ -390,7 +391,8 @@ def table_layout(context, table, bottom_space, skip_stack, containing_block,
page_break = block_level_page_break(previous_group, group)
if page_break == 'avoid':
earlier_page_break = find_earlier_page_break(
new_table_children, absolute_boxes, fixed_boxes)
context, new_table_children, absolute_boxes,
fixed_boxes)
if earlier_page_break is not None:
new_table_children, resume_at = earlier_page_break
break