Repository: Kozea/WeasyPrint
Pull request #2341 (2025-01-07 21:46:18)

Fix page groups


Patch:
diff --git a/weasyprint/css/__init__.py b/weasyprint/css/__init__.py
index b03af9e8..9ed1c694 100644
--- a/weasyprint/css/__init__.py
+++ b/weasyprint/css/__init__.py
@@ -200,11 +200,19 @@ class StyleFor:
             return False
         if page_selector_type.index is not None:
             a, b, name = page_selector_type.index
-            if name is not None and name != page_type.name:
+            if name is None:
+                index = page_type.index
+                offset = index + 1 - b
+                return offset == 0 if a == 0 else (offset / a >= 0 and not offset % a)
+            if name != page_type.name:
                 return False
-            index = page_type.index if name is None else page_type.group_index
-            offset = index + 1 - b
-            return offset == 0 if a == 0 else (offset / a >= 0 and not offset % a)
+            for group_name, index in page_type.groups:
+                if name != group_name:
+                    continue
+                offset = index + 1 - b
+                if (offset == 0 if a == 0 else (offset / a >= 0 and not offset % a)):
+                    return True
+            return False
         return True
 
 
@@ -859,6 +867,8 @@ def parse_page_selectors(rule):
                     # TODO: specificity is not specified yet
                     # https://github.com/w3c/csswg-drafts/issues/3524
                     types['specificity'][1] += 1
+                    if group:
+                        types['specificity'][0] += 1
                     continue
 
                 return None
diff --git a/weasyprint/layout/page.py b/weasyprint/layout/page.py
index a5f93eaa..29172c79 100644
--- a/weasyprint/layout/page.py
+++ b/weasyprint/layout/page.py
@@ -14,7 +14,7 @@ from .min_max import handle_min_max_height, handle_min_max_width
 from .percent import resolve_percentages
 from .preferred import max_content_width, min_content_width
 
-PageType = namedtuple('PageType', ['side', 'blank', 'name', 'index', 'group_index'])
+PageType = namedtuple('PageType', ['side', 'blank', 'name', 'index', 'groups'])
 
 
 class OrientedBox:
@@ -800,7 +800,70 @@ def set_page_type_computed_styles(page_type, html, style_for):
                 base_url=html.base_url)
 
 
-def remake_page(index, group_index, context, root_box, html):
+def _includes_resume_at(resume_at, page_group_resume_at):
+    (page_child_index, page_child_resume_at), = page_group_resume_at.items()
+    if resume_at is None or page_child_index not in resume_at:
+        return False
+    if page_child_resume_at is None:
+        return True
+    return _includes_resume_at(resume_at[page_child_index], page_child_resume_at)
+
+
+def _update_page_groups(page_groups, resume_at, next_page, root_box):
+    # https://www.w3.org/TR/css-gcpm-3/#document-sequence-selectors
+
+    # Remove or increment page groups.
+    page_groups_length = len(page_groups)
+    for i, page_group in enumerate(page_groups[:]):
+        if _includes_resume_at(resume_at, page_group[2]):
+            page_group[1] += 1
+        else:
+            page_groups.pop(i - page_groups_length)
+
+    # Add page groups.
+    if next_page['break'] == 'any' or not next_page['page']:
+        # We don’t have a forced page break or a named page.
+        return
+    if page_groups and page_groups[-1][0] == next_page['page']:
+        # We’re already in an element whose page name is the next page name.
+        return
+
+    # Find the box that has the named page. It is a first in-flow child of the
+    # element corresponding to resume_at.
+
+    # Find element corrensponding to resume_at.
+    page_group_resume_at = copy.deepcopy(resume_at)
+    current_resume_at = page_group_resume_at
+    current_element = root_box
+    while True:
+        child_index, child_resume_at = tuple(current_resume_at.items())[-1]
+        current_element = current_element.children[child_index]
+        if child_resume_at is None:
+            break
+        current_resume_at = child_resume_at
+
+    # Find the descendant with named page.
+    while True:
+        if current_element.style['page'] == next_page['page']:
+            page_groups.append([next_page['page'], 0, page_group_resume_at])
+            return
+        if not isinstance(current_element, boxes.ParentBox):
+            # Shouldn’t happen.
+            return
+        for i, child in enumerate(current_element.children):
+            if not child.is_in_normal_flow():
+                continue
+            current_resume_at[child_index] = {i: None}
+            current_resume_at = current_resume_at[child_index]
+            child_index = i
+            current_element = child
+            break
+        else:
+            # Shouldn’t happen.
+            return
+
+
+def remake_page(index, page_groups, context, root_box, html):
     """Return one laid out page without margin boxes.
 
     Start with the initial values from ``context.page_maker[index]``.
@@ -813,43 +876,37 @@ def remake_page(index, group_index, context, root_box, html):
 
     """
     page_maker = context.page_maker
-    (initial_resume_at, initial_next_page, right_page, initial_page_state,
-     remake_state) = page_maker[index]
+    resume_at, next_page, right_page, page_state, _ = page_maker[index]
 
     # PageType for current page, values for page_maker[index + 1].
     # Don't modify actual page_maker[index] values!
-    # TODO: should we store (and reuse) page_type in the page_maker?
-    page_state = copy.deepcopy(initial_page_state)
-    if initial_next_page['break'] in ('left', 'right'):
-        next_page_side = initial_next_page['break']
-    elif initial_next_page['break'] in ('recto', 'verso'):
+    page_state = copy.deepcopy(page_state)
+    if next_page['break'] in ('left', 'right'):
+        next_page_side = next_page['break']
+    elif next_page['break'] in ('recto', 'verso'):
         direction_ltr = root_box.style['direction'] == 'ltr'
-        break_verso = initial_next_page['break'] == 'verso'
+        break_verso = next_page['break'] == 'verso'
         next_page_side = 'right' if direction_ltr ^ break_verso else 'left'
     else:
         next_page_side = None
     blank = bool(
         (next_page_side == 'left' and right_page) or
         (next_page_side == 'right' and not right_page) or
-        (context.reported_footnotes and initial_resume_at is None))
-    name = '' if blank else initial_next_page['page']
-    if name not in group_index:
-        group_index.clear()
-        group_index[name] = 0
+        (context.reported_footnotes and resume_at is None))
+    name = '' if blank else next_page['page']
     side = 'right' if right_page else 'left'
-    page_type = PageType(side, blank, name, index, group_index[name])
+    _update_page_groups(page_groups, resume_at, next_page, root_box)
+    groups = tuple((name, index) for name, index, _ in page_groups)
+    page_type = PageType(side, blank, name, index, groups)
     set_page_type_computed_styles(page_type, html, context.style_for)
-    group_index[name] += 1
 
-    context.forced_break = (
-        initial_next_page['break'] != 'any' or initial_next_page['page'])
+    context.forced_break = (next_page['break'] != 'any' or next_page['page'])
     context.margin_clearance = False
 
     # make_page wants a page_number of index + 1
     page_number = index + 1
     page, resume_at, next_page = make_page(
-        context, root_box, page_type, initial_resume_at, page_number,
-        page_state)
+        context, root_box, page_type, resume_at, page_number, page_state)
     assert next_page
     right_page = not right_page
 
@@ -859,9 +916,8 @@ def remake_page(index, group_index, context, root_box, html):
         page_maker_next_changed = True
     else:
         # Check whether something changed
-        # TODO: Find what we need to compare. Is resume_at enough?
-        (next_resume_at, next_next_page, next_right_page,
-         next_page_state, _) = page_maker[index + 1]
+        next_resume_at, next_next_page, next_right_page, next_page_state, _ = (
+            page_maker[index + 1])
         page_maker_next_changed = (
             next_resume_at != resume_at or
             next_next_page != next_page or
@@ -880,7 +936,6 @@ def remake_page(index, group_index, context, root_box, html):
         # If resume_at is None (last page) it must be False to prevent endless
         # loops and list index out of range (see #794).
         remake_state['content_changed'] = resume_at is not None
-        # page_state is already a deepcopy
         item = resume_at, next_page, right_page, page_state, remake_state
         if index + 1 >= len(page_maker):
             page_maker.append(item)
@@ -898,7 +953,7 @@ def make_all_pages(context, root_box, html, pages):
     """
     i = 0
     reported_footnotes = None
-    group_i = {'': 0}
+    page_groups = []
     while True:
         remake_state = context.page_maker[i][-1]
         if (len(pages) == 0 or
@@ -910,7 +965,7 @@ def make_all_pages(context, root_box, html, pages):
             remake_state['pages_wanted'] = False
             remake_state['anchors'] = []
             remake_state['content_lookups'] = []
-            page, resume_at = remake_page(i, group_i, context, root_box, html)
+            page, resume_at = remake_page(i, page_groups, context, root_box, html)
             reported_footnotes = context.reported_footnotes
             yield page
         else:


Test patch:
diff --git a/tests/css/test_pages.py b/tests/css/test_pages.py
index 87a4bd61..f09e6fbf 100644
--- a/tests/css/test_pages.py
+++ b/tests/css/test_pages.py
@@ -26,7 +26,7 @@ def test_page():
           }
         ''')])
 
-    page_type = PageType(side='left', blank=False, name='', index=0, group_index=0)
+    page_type = PageType(side='left', blank=False, name='', index=0, groups=())
     set_page_type_computed_styles(page_type, document, style_for)
     style = style_for(page_type)
     assert style['margin_top'] == (5, 'px')
@@ -34,7 +34,7 @@ def test_page():
     assert style['margin_bottom'] == (10, 'px')
     assert style['color'] == (1, 0, 0, 1)  # red, inherited from html
 
-    page_type = PageType(side='right', blank=False, name='', index=0, group_index=0)
+    page_type = PageType(side='right', blank=False, name='', index=0, groups=())
     set_page_type_computed_styles(page_type, document, style_for)
     style = style_for(page_type)
     assert style['margin_top'] == (5, 'px')
@@ -42,7 +42,7 @@ def test_page():
     assert style['margin_bottom'] == (16, 'px')
     assert style['color'] == (0, 0, 1, 1)  # blue
 
-    page_type = PageType(side='left', blank=False, name='', index=1, group_index=1)
+    page_type = PageType(side='left', blank=False, name='', index=1, groups=())
     set_page_type_computed_styles(page_type, document, style_for)
     style = style_for(page_type)
     assert style['margin_top'] == (10, 'px')
@@ -50,7 +50,7 @@ def test_page():
     assert style['margin_bottom'] == (10, 'px')
     assert style['color'] == (1, 0, 0, 1)  # red, inherited from html
 
-    page_type = PageType(side='right', blank=False, name='', index=1, group_index=1)
+    page_type = PageType(side='right', blank=False, name='', index=1, groups=())
     set_page_type_computed_styles(page_type, document, style_for)
     style = style_for(page_type)
     assert style['margin_top'] == (10, 'px')
@@ -58,18 +58,18 @@ def test_page():
     assert style['margin_bottom'] == (16, 'px')
     assert style['color'] == (0, 0, 1, 1)  # blue
 
-    page_type = PageType(side='left', blank=False, name='', index=0, group_index=0)
+    page_type = PageType(side='left', blank=False, name='', index=0, groups=())
     set_page_type_computed_styles(page_type, document, style_for)
     style = style_for(page_type, '@top-left')
     assert style is None
 
-    page_type = PageType(side='right', blank=False, name='', index=0, group_index=0)
+    page_type = PageType(side='right', blank=False, name='', index=0, groups=())
     set_page_type_computed_styles(page_type, document, style_for)
     style = style_for(page_type, '@top-left')
     assert style['font_size'] == 20  # inherited from @page
     assert style['width'] == (200, 'px')
 
-    page_type = PageType(side='right', blank=False, name='', index=0, group_index=0)
+    page_type = PageType(side='right', blank=False, name='', index=0, groups=())
     set_page_type_computed_styles(page_type, document, style_for)
     style = style_for(page_type, '@top-right')
     assert style['font_size'] == 10
diff --git a/tests/layout/test_page.py b/tests/layout/test_page.py
index 88ab4aa6..617e2358 100644
--- a/tests/layout/test_page.py
+++ b/tests/layout/test_page.py
@@ -891,6 +891,84 @@ def test_page_names_10():
     assert article.element_tag == 'article'
 
 
+@assert_no_logs
+def test_page_groups():
+    pages = render_pages('''
+      <style>
+        @page { size: 200px 200px }
+        @page small { size: 100px 100px }
+        @page :nth(1 of small) { size: 50px 50px }
+        section { page: small }
+        div, div section { break-after: page }
+      </style>
+      <div></div>
+      <article></article>
+      <section>
+        <div></div>
+        <div></div>
+      </section>
+      <section>
+      </section>
+      <div></div>
+      <div></div>
+      <section>
+        <div></div>
+      </section>
+      <div>
+        <section></section>
+        <section></section>
+      </div>
+    ''')
+    page1, page2, page3, page4, page5, page6, page7, page8, page9 = pages
+
+    assert (page1.width, page1.height) == (200, 200)
+    div, = page1.children[0].children[0].children
+    assert div.element_tag == 'div'
+
+    assert (page2.width, page2.height) == (200, 200)
+    article, = page2.children[0].children[0].children
+    assert article.element_tag == 'article'
+
+    assert (page3.width, page3.height) == (50, 50)
+    section, = page3.children[0].children[0].children
+    assert section.element_tag == 'section'
+    div, = section.children
+    assert div.element_tag == 'div'
+
+    assert (page4.width, page4.height) == (100, 100)
+    section, = page4.children[0].children[0].children
+    assert section.element_tag == 'section'
+    div, = section.children
+    assert div.element_tag == 'div'
+
+    assert (page5.width, page5.height) == (50, 50)
+    section, div = page5.children[0].children[0].children
+    assert section.element_tag == 'section'
+    assert div.element_tag == 'div'
+
+    assert (page6.width, page6.height) == (200, 200)
+    div, = page6.children[0].children[0].children
+    assert div.element_tag == 'div'
+
+    assert (page7.width, page7.height) == (50, 50)
+    section, = page7.children[0].children[0].children
+    assert section.element_tag == 'section'
+    div, = section.children
+    assert div.element_tag == 'div'
+
+    assert (page8.width, page8.height) == (50, 50)
+    div, = page8.children[0].children[0].children
+    assert div.element_tag == 'div'
+    section, = div.children
+    assert section.element_tag == 'section'
+
+    assert (page9.width, page9.height) == (50, 50)
+    div, = page9.children[0].children[0].children
+    assert div.element_tag == 'div'
+    section, = div.children
+    assert section.element_tag == 'section'
+
+
 @assert_no_logs
 @pytest.mark.parametrize('style, line_counts', (
     ('orphans: 2; widows: 2', [4, 3]),
diff --git a/tests/test_boxes.py b/tests/test_boxes.py
index ff52edde..32efcfb1 100644
--- a/tests/test_boxes.py
+++ b/tests/test_boxes.py
@@ -248,14 +248,14 @@ def test_whitespace():
 
 @assert_no_logs
 @pytest.mark.parametrize('page_type, top, right, bottom, left', (
-    (PageType('left', False, '', 0, 0), 20, 3, 3, 10),
-    (PageType('right', False, '', 0, 0), 20, 10, 3, 3),
-    (PageType('left', False, '', 1, 1), 10, 3, 3, 10),
-    (PageType('right', False, '', 1, 1), 10, 10, 3, 3),
-    (PageType('right', False, 'name', 1, 0), 5, 10, 3, 15),
-    (PageType('right', False, 'name', 1, 1), 5, 10, 4, 15),
-    (PageType('right', False, 'name', 2, 0), 5, 10, 1, 15),
-    (PageType('right', False, 'name', 8, 8), 5, 10, 2, 15),
+    (PageType('left', False, '', 0, ()), 20, 3, 3, 10),
+    (PageType('right', False, '', 0, ()), 20, 10, 3, 3),
+    (PageType('left', False, '', 1, ()), 10, 3, 3, 10),
+    (PageType('right', False, '', 1, ()), 10, 10, 3, 3),
+    (PageType('right', False, 'name', 1, (('name', 0),)), 5, 10, 3, 15),
+    (PageType('right', False, 'name', 1, (('name', 1),)), 5, 10, 4, 15),
+    (PageType('right', False, 'name', 2, (('name', 0),)), 5, 10, 1, 15),
+    (PageType('right', False, 'name', 8, (('name', 8),)), 5, 10, 2, 15),
 ))
 def test_page_style(page_type, top, right, bottom, left):
     document = FakeHTML(string='''