Repository: Kozea/WeasyPrint
Pull request #870 (2019-05-17 22:01:58)

Implement bookmark-state support
Addresses #648 

randomstuff: Tests are currently broken. I think this is because the bookmarks are now all opened by default (as per the `bookmark-state` spec). I'm looking at it.
randomstuff: I fixed the tests. The notable ugly hack is the `BookmarkSubtree` which tries to be API-compatible with the tuples which where used before.

Patch:
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index d9d9dc3f..5f9a795d 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -198,10 +198,11 @@ See the :ref:`python-api` for details. A few random examples:
     #         2. CSS 2.1 addressing model (page 7)
     #       4. CSS design principles (page 8)
     def print_outline(bookmarks, indent=0):
-        for i, (label, (page, _, _), children) in enumerate(bookmarks, 1):
+        for i, bookmark in enumerate(bookmarks, 1):
+            page = bookmark.destination[0]
             print('%s%d. %s (page %d)' % (
-                ' ' * indent, i, label.lstrip('0123456789. '), page))
-            print_outline(children, indent + 2)
+                ' ' * indent, i, bookmark.label.lstrip('0123456789. '), page))
+            print_outline(bookmark.children, indent + 2)
     print_outline(document.make_bookmark_tree())
 
 .. code-block:: python
diff --git a/weasyprint/css/properties.py b/weasyprint/css/properties.py
index 2354e464..a2c2e8f1 100644
--- a/weasyprint/css/properties.py
+++ b/weasyprint/css/properties.py
@@ -131,6 +131,7 @@ INITIAL_VALUES = {
     # Generated Content 3 (WD): https://www.w3.org/TR/css-content-3/
     'bookmark_label': (('content', 'text'),),
     'bookmark_level': 'none',
+    'bookmark_state': 'open',
     'content': 'normal',
     'quotes': list('“”‘’'),  # chosen by the user agent
     'string_set': 'none',
diff --git a/weasyprint/css/validation/properties.py b/weasyprint/css/validation/properties.py
index caacaca8..88226442 100644
--- a/weasyprint/css/validation/properties.py
+++ b/weasyprint/css/validation/properties.py
@@ -1321,6 +1321,15 @@ def bookmark_level(token):
         return 'none'
 
 
+@property(unstable=True)
+@single_token
+def bookmark_state(token):
+    """Validation for ``bookmark-state``."""
+    state = get_keyword(token)
+    if state == "open" or state == "closed":
+        return state
+
+
 @property(unstable=True, wants_base_url=True)
 @comma_separated_list
 def string_set(tokens, base_url):
diff --git a/weasyprint/document.py b/weasyprint/document.py
index 65d1231f..714cb2da 100644
--- a/weasyprint/document.py
+++ b/weasyprint/document.py
@@ -108,6 +108,7 @@ def _gather_links_and_bookmarks(box, bookmarks, links, anchors, matrix):
         bookmark_level = None
     else:
         bookmark_level = box.style['bookmark_level']
+    state = box.style['bookmark_state']
     link = box.style['link']
     anchor_name = box.style['anchor']
     has_bookmark = bookmark_label and bookmark_level
@@ -136,7 +137,8 @@ def _gather_links_and_bookmarks(box, bookmarks, links, anchors, matrix):
         if matrix and (has_bookmark or has_anchor):
             pos_x, pos_y = matrix.transform_point(pos_x, pos_y)
         if has_bookmark:
-            bookmarks.append((bookmark_level, bookmark_label, (pos_x, pos_y)))
+            bookmarks.append((bookmark_level, bookmark_label,
+                              (pos_x, pos_y), state))
         if has_anchor:
             anchors[anchor_name] = pos_x, pos_y
 
@@ -328,6 +330,44 @@ class DocumentMetadata(object):
         self.attachments = attachments or []
 
 
+class BookmarkSubtree:
+
+    def __init__(self, label, destination, children, state):
+        self.label = label
+        self.destination = destination
+        self.children = children
+        self.state = state
+
+    # Pass as a tuple for compatibility:
+    def __len__(self):
+        return 3
+
+    def _tuple(self):
+        return (self.label, self.destination, self.children)
+
+    def __iter__(self):
+        return iter(self._tuple())
+
+    def __repr__(self):
+        return repr(self._tuple())
+
+    def __str__(self):
+        return str(self._tuple())
+
+    def __getitem__(self, i):
+        return self._tuple()[i]
+
+    def __eq__(self, other):
+        if isinstance(other, BookmarkSubtree):
+            other = (other.label, other.destination,
+                     other.children, other.state)
+        if len(other) == 3:
+            return self._tuple() == other
+        else:
+            return (self.label, self.destination,
+                    self.children, self.state) == other
+
+
 class Document(object):
     """A rendered document ready to be painted on a cairo surface.
 
@@ -487,7 +527,7 @@ class Document(object):
         last_by_depth = [root]
         previous_level = 0
         for page_number, page in enumerate(self.pages):
-            for level, label, (point_x, point_y) in page.bookmarks:
+            for level, label, (point_x, point_y), state in page.bookmarks:
                 if level > previous_level:
                     # Example: if the previous bookmark is a <h2>, the next
                     # depth "should" be for <h3>. If now we get a <h6> we’re
@@ -507,7 +547,9 @@ class Document(object):
                 assert depth >= 1
 
                 children = []
-                subtree = label, (page_number, point_x, point_y), children
+                subtree = BookmarkSubtree(label,
+                                          (page_number, point_x, point_y),
+                                          children, state)
                 last_by_depth[depth - 1].append(subtree)
                 del last_by_depth[depth:]
                 last_by_depth.append(children)
@@ -635,7 +677,11 @@ class Document(object):
             bookmarks = self.make_bookmark_tree()
             levels = [cairo.PDF_OUTLINE_ROOT] * len(bookmarks)
             while bookmarks:
-                title, destination, children = bookmarks.pop(0)
+                bookmark = bookmarks.pop(0)
+                title = bookmark.label
+                destination = bookmark.destination
+                children = bookmark.children
+                state = bookmark.state
                 page, x, y = destination
 
                 # We round floats to avoid locale problems, see
@@ -645,7 +691,8 @@ class Document(object):
                     int(round(y * scale)))
 
                 outline = surface.add_outline(
-                    levels.pop(), title, link_attribs, 0)
+                    levels.pop(), title, link_attribs,
+                    cairo.PDF_OUTLINE_FLAG_OPEN if state == "open" else 0)
                 levels.extend([outline] * len(children))
                 bookmarks = children + bookmarks
 


Test patch:
diff --git a/weasyprint/tests/test_api.py b/weasyprint/tests/test_api.py
index b3869a2e..fcac6217 100644
--- a/weasyprint/tests/test_api.py
+++ b/weasyprint/tests/test_api.py
@@ -128,8 +128,9 @@ def _round_meta(pages):
                                     round(width, 6), round(height, 6)))
             links[i] = link
         bookmarks = page.bookmarks
-        for i, (level, label, (pos_x, pos_y)) in enumerate(bookmarks):
-            bookmarks[i] = level, label, (round(pos_x, 6), round(pos_y, 6))
+        for i, (level, label, (pos_x, pos_y), state) in enumerate(bookmarks):
+            bookmarks[i] = (level, label,
+                            (round(pos_x, 6), round(pos_y, 6)), state)
 
 
 @assert_no_logs
@@ -515,8 +516,9 @@ def test_low_level_api():
         <h2>d</h2>
         <h1>e</h1>
     ''', [
-        [(1, 'a', (0, 0)), (4, 'b', (0, 10))],
-        [(3, 'c', (3, 2)), (2, 'd', (0, 10)), (1, 'e', (0, 20))],
+        [(1, 'a', (0, 0), "open"), (4, 'b', (0, 10), "open")],
+        [(3, 'c', (3, 2), "open"), (2, 'd', (0, 10), "open"),
+         (1, 'e', (0, 20), "open")],
     ], [
         ('a', (0, 0, 0), [
             ('b', (0, 0, 10), []),
@@ -542,18 +544,18 @@ def test_low_level_api():
         <h2>Title 11</h2>
     ''', [
         [
-            (1, 'Title 1', (0, 0)),
-            (1, 'Title 2', (0, 100)),
-            (2, 'Title 3', (20, 200)),
-            (2, 'Title 4', (0, 300)),
-            (3, 'Title 5', (0, 400))
+            (1, 'Title 1', (0, 0), "open"),
+            (1, 'Title 2', (0, 100), "open"),
+            (2, 'Title 3', (20, 200), "open"),
+            (2, 'Title 4', (0, 300), "open"),
+            (3, 'Title 5', (0, 400), "open")
         ], [
-            (2, 'Title 6', (0, 100)),
-            (1, 'Title 7', (0, 200)),
-            (2, 'Title 8', (0, 300)),
-            (3, 'Title 9', (0, 400)),
-            (1, 'Title 10', (0, 500)),
-            (2, 'Title 11', (0, 600))
+            (2, 'Title 6', (0, 100), "open"),
+            (1, 'Title 7', (0, 200), "open"),
+            (2, 'Title 8', (0, 300), "open"),
+            (3, 'Title 9', (0, 400), "open"),
+            (1, 'Title 10', (0, 500), "open"),
+            (2, 'Title 11', (0, 600), "open")
         ],
     ], [
         ('Title 1', (0, 0, 0), []),
@@ -576,11 +578,11 @@ def test_low_level_api():
         <h3>D</h3> <p>depth 2</p>
         <h4>E</h4> <p>depth 3</p>
     ''', [[
-        (2, 'A', (0, 0)),
-        (4, 'B', (0, 20)),
-        (2, 'C', (0, 40)),
-        (3, 'D', (0, 60)),
-        (4, 'E', (0, 80)),
+        (2, 'A', (0, 0), "open"),
+        (4, 'B', (0, 20), "open"),
+        (2, 'C', (0, 40), "open"),
+        (3, 'D', (0, 60), "open"),
+        (4, 'E', (0, 80), "open"),
     ]], [
         ('A', (0, 0, 0), [
             ('B', (0, 0, 20), [])]),
@@ -600,15 +602,15 @@ def test_low_level_api():
         <h4>H</h4> <p>h4 depth 3</p>
         <h1>I</h1> <p>h1 depth 1</p>
     ''', [[
-        (2, 'A', (0, 0)),
-        (4, 'B', (0, 20)),
-        (3, 'C', (0, 40)),
-        (5, 'D', (0, 60)),
-        (1, 'E', (0, 70)),
-        (2, 'F', (0, 90)),
-        (2, 'G', (0, 110)),
-        (4, 'H', (0, 130)),
-        (1, 'I', (0, 150)),
+        (2, 'A', (0, 0), "open"),
+        (4, 'B', (0, 20), "open"),
+        (3, 'C', (0, 40), "open"),
+        (5, 'D', (0, 60), "open"),
+        (1, 'E', (0, 70), "open"),
+        (2, 'F', (0, 90), "open"),
+        (2, 'G', (0, 110), "open"),
+        (4, 'H', (0, 130), "open"),
+        (1, 'I', (0, 150), "open"),
     ]], [
         ('A', (0, 0, 0), [
             ('B', (0, 0, 20), []),
@@ -620,25 +622,26 @@ def test_low_level_api():
                 ('H', (0, 0, 130), [])])]),
         ('I', (0, 0, 150), []),
     ], False),
-    ('<h1>é', [[(1, 'é', (0, 0))]], [('é', (0, 0, 0), [])], False),
+    ('<h1>é', [[(1, 'é', (0, 0), "open")]], [('é', (0, 0, 0), [])], False),
     ('''
         <h1 style="transform: translateX(50px)">!
-    ''', [[(1, '!', (50, 0))]], [('!', (0, 50, 0), [])], False),
+    ''', [[(1, '!', (50, 0), "open")]], [('!', (0, 50, 0), [])], False),
     ('''
         <style>
           img { display: block; bookmark-label: attr(alt); bookmark-level: 1 }
         </style>
         <img src="%s" alt="Chocolate" />
     ''' % path2url(resource_filename('pattern.png')),
-     [[(1, 'Chocolate', (0, 0))]], [('Chocolate', (0, 0, 0), [])], False),
+     [[(1, 'Chocolate', (0, 0), "open")]],
+     [('Chocolate', (0, 0, 0), [])], False),
     ('''
         <h1 style="transform-origin: 0 0;
                    transform: rotate(90deg) translateX(50px)">!
-    ''', [[(1, '!', (0, 50))]], [('!', (0, 0, 50), [])], True),
+    ''', [[(1, '!', (0, 50), "open")]], [('!', (0, 0, 50), [])], True),
     ('''
         <body style="transform-origin: 0 0; transform: rotate(90deg)">
         <h1 style="transform: translateX(50px)">!
-    ''', [[(1, '!', (0, 50))]], [('!', (0, 0, 50), [])], True),
+    ''', [[(1, '!', (0, 50), "open")]], [('!', (0, 0, 50), [])], True),
 ))
 @assert_no_logs
 def test_assert_bookmarks(html, expected_by_page, expected_tree, round):
diff --git a/weasyprint/tests/test_pdf.py b/weasyprint/tests/test_pdf.py
index 284d76e7..20b5897e 100644
--- a/weasyprint/tests/test_pdf.py
+++ b/weasyprint/tests/test_pdf.py
@@ -87,7 +87,7 @@ def test_bookmarks_1():
     pdf_file = pdf.PDFFile(fileobj)
     outlines = pdf_file.catalog.get_indirect_dict('Outlines', pdf_file)
     assert outlines.get_type() == 'Outlines'
-    assert outlines.get_value('Count', '(.*)') == b'-2'
+    assert outlines.get_value('Count', '(.*)') == b'-5'
     o1 = outlines.get_indirect_dict('First', pdf_file)
     assert o1.get_value('Title', '(.*)') == b'(a)'
     o11 = o1.get_indirect_dict('First', pdf_file)
@@ -157,32 +157,32 @@ def test_bookmarks_4():
     pdf_file = pdf.PDFFile(fileobj)
     outlines = pdf_file.catalog.get_indirect_dict('Outlines', pdf_file)
     assert outlines.get_type() == 'Outlines'
-    assert outlines.get_value('Count', '(.*)') == b'-4'
+    assert outlines.get_value('Count', '(.*)') == b'-11'
     o1 = outlines.get_indirect_dict('First', pdf_file)
     assert o1.get_value('Title', '(.*)') == b'(Title 1)'
     o2 = o1.get_indirect_dict('Next', pdf_file)
     assert o2.get_value('Title', '(.*)') == b'(Title 2)'
-    assert o2.get_value('Count', '(.*)') == b'-3'
+    assert o2.get_value('Count', '(.*)') == b'4'
     o3 = o2.get_indirect_dict('First', pdf_file)
     assert o3.get_value('Title', '(.*)') == b'(Title 3)'
     o4 = o3.get_indirect_dict('Next', pdf_file)
     assert o4.get_value('Title', '(.*)') == b'(Title 4)'
-    assert o4.get_value('Count', '(.*)') == b'-1'
+    assert o4.get_value('Count', '(.*)') == b'1'
     o5 = o4.get_indirect_dict('First', pdf_file)
     assert o5.get_value('Title', '(.*)') == b'(Title 5)'
     o6 = o4.get_indirect_dict('Next', pdf_file)
     assert o6.get_value('Title', '(.*)') == b'(Title 6)'
     o7 = o2.get_indirect_dict('Next', pdf_file)
     assert o7.get_value('Title', '(.*)') == b'(Title 7)'
-    assert o7.get_value('Count', '(.*)') == b'-1'
+    assert o7.get_value('Count', '(.*)') == b'2'
     o8 = o7.get_indirect_dict('First', pdf_file)
     assert o8.get_value('Title', '(.*)') == b'(Title 8)'
-    assert o8.get_value('Count', '(.*)') == b'-1'
+    assert o8.get_value('Count', '(.*)') == b'1'
     o9 = o8.get_indirect_dict('First', pdf_file)
     assert o9.get_value('Title', '(.*)') == b'(Title 9)'
     o10 = o7.get_indirect_dict('Next', pdf_file)
     assert o10.get_value('Title', '(.*)') == b'(Title 10)'
-    assert o10.get_value('Count', '(.*)') == b'-1'
+    assert o10.get_value('Count', '(.*)') == b'1'
     o11 = o10.get_indirect_dict('First', pdf_file)
     assert o11.get_value('Title', '(.*)') == b'(Title 11)'
 
@@ -206,7 +206,7 @@ def test_bookmarks_5():
     pdf_file = pdf.PDFFile(fileobj)
     outlines = pdf_file.catalog.get_indirect_dict('Outlines', pdf_file)
     assert outlines.get_type() == 'Outlines'
-    assert outlines.get_value('Count', '(.*)') == b'-2'
+    assert outlines.get_value('Count', '(.*)') == b'-5'
     o1 = outlines.get_indirect_dict('First', pdf_file)
     assert o1.get_value('Title', '(.*)') == b'(1)'
     o2 = o1.get_indirect_dict('First', pdf_file)
@@ -246,7 +246,7 @@ def test_bookmarks_6():
     pdf_file = pdf.PDFFile(fileobj)
     outlines = pdf_file.catalog.get_indirect_dict('Outlines', pdf_file)
     assert outlines.get_type() == 'Outlines'
-    assert outlines.get_value('Count', '(.*)') == b'-3'
+    assert outlines.get_value('Count', '(.*)') == b'-9'
     o1 = outlines.get_indirect_dict('First', pdf_file)
     assert o1.get_value('Title', '(.*)') == b'(1)'
     o2 = o1.get_indirect_dict('First', pdf_file)