Repository: Kozea/WeasyPrint
Pull request #2192 (2024-06-24 15:14:20)

Adding annotations to the PDF to link back its content to its source.
Hello!

Before anything, a bit of context: this PR is a work in progress, and it is not ready to be merged as such. It will require some more work in order to be eventually added to the main branch, as discussed beforehand with @liZe and @grewn0uille. The idea behind this first draft is to allow WeasyPrint to embed metadata in the PDF for each HTMLElement with an `id` attribute it converts by adding new `\Annot` PDF objects that can then be accessed in the PDF readers.

What it allowed me to do for now is this:

![ezgif-6-1a414276b9](https://github.com/Kozea/WeasyPrint/assets/25752940/f0709a36-7d0a-4707-b5c9-2799e983ef9d)

On the left, you have a webpage; and on the right, you have the PDF produced by this fork of WeasyPrint, previewed with PDF.js. A few event listeners were added to bidirectionally "synchronize" the two visualisations. This is just a proof-of-concept, but from there we basically have what we need to build powerful interfaces that take into account the content of the PDF as semantic data that can be linked back to its source.

We talked about adding a PDF variant for debugging that could be accessible through an option like `--pdf-variant debug`. For now, nothing has been done in this direction, the code I propose here is just "hardcoded" into the default behaviour of WeasyPrint. I guess it will need some cleanup also, as I'm not sure if I understood the spec totally right.

Anyway, I'd be really interested in working with you on this and going in a direction that suits the philosophy of the project. If you feel like I could be of help, please share your thoughts here so that we can discuss what would be the best way to proceed, and how I could contribute further!

I can also share on demand the code of the interface I'm building, even though it is not ready to be made totally public for now, so don't hesitate to ask :)

Thanks for the great job!

liZe: Hi @yanntrividic!

I’ve just pushed a `debug` branch that provides a `--pdf-variant=debug` option. The result is a bit different because I’ve changed the way it works, but I think that the result includes enough data to work. Tell me if anything’s wrong or missing!
yanntrividic: Hello @liZe!

Summer has done its work, I am finally in a situation where I can have a look at this again. Thanks for taking the time to make this seed of an idea into actual code :) it's really nice to study how you integrated it properly with the rest of the code base.

I was able to integrate your branch in my app, I had to change a few lines in my app's code to make it compliant with your new logics, but it worked out easily. I just had to add one line in your proposition; you're right when you say that the result includes enough data to work with, but it would be sufficient only if we were building the PDF renderer ourselves. In my case, I use PDFjs, and PDFjs needs a Dest key to actually render the data in the HTML content, otherwise it is just not there -- the metadata associated with the T key is not present in the HTML code.

To my understanding, the easiest way to pass the `id` attribute to PDFjs is by turning the annotation into an anchor. Otherwise it is just ignored. Maybe there is another way that I don't know about? We face similar problems with other renderers such as PDFium. 

When I try to read through the [standard](https://www.pdfa-inc.org/product/iso-32000-22020-document-management-portable-document-format-part-2-pdf-2-0-sponsored/), I don't see many solutions. It's possible to embed an action into an annotation, and it might be a lead for a solution there, but we would face the same issue regarding interoperability in the end.

Any thoughts on this? :)
yanntrividic: On another note, I have a problem with the annotation your code generated after meeting a `col` element that spans over several pages. The rectangles of all the `col` elements take the shape and position of the `col` element of the last page, even though those are different shapes and positions.

Here is an example on one page (on the last page, the shape frames perfectly the element):

![Screenshot of the bug](https://github.com/user-attachments/assets/db6c449b-bf4d-43a0-9bc6-39e8336e2cc2)

Patch:
diff --git a/weasyprint/anchors.py b/weasyprint/anchors.py
index ab18e53f..499d85bf 100644
--- a/weasyprint/anchors.py
+++ b/weasyprint/anchors.py
@@ -113,13 +113,17 @@ def gather_anchors(box, anchors, links, bookmarks, forms, parent_matrix=None,
             links.append((link_type, target, rectangle, box))
         if is_input:
             forms[parent_form].append((box.element, box.style, rectangle))
-        if matrix and (has_bookmark or has_anchor):
-            pos_x, pos_y = matrix.transform_point(pos_x, pos_y)
         if has_bookmark:
+            if matrix:
+                pos_x, pos_y = matrix.transform_point(pos_x, pos_y)
             bookmark = (bookmark_level, bookmark_label, (pos_x, pos_y), state)
             bookmarks.append(bookmark)
         if has_anchor:
-            anchors[anchor_name] = pos_x, pos_y
+            pos_x1, pos_y1, pos_x2, pos_y2 = pos_x, pos_y, pos_x + width, pos_y + height
+            if matrix:
+                pos_x1, pos_y1 = matrix.transform_point(pos_x1, pos_y1)
+                pos_x2, pos_y2 = matrix.transform_point(pos_x2, pos_y2)
+            anchors[anchor_name] = (pos_x1, pos_y1, pos_x2, pos_y2)
 
     for child in box.all_children():
         gather_anchors(child, anchors, links, bookmarks, forms, matrix, parent_form)
diff --git a/weasyprint/pdf/__init__.py b/weasyprint/pdf/__init__.py
index 15d12e81..66f38ae4 100644
--- a/weasyprint/pdf/__init__.py
+++ b/weasyprint/pdf/__init__.py
@@ -8,7 +8,7 @@ from .. import VERSION, Attachment
 from ..html import W3C_DATE_RE
 from ..logger import LOGGER, PROGRESS_LOGGER
 from ..matrix import Matrix
-from . import pdfa, pdfua
+from . import debug, pdfa, pdfua
 from .fonts import build_fonts_dictionary
 from .stream import Stream
 
@@ -17,7 +17,7 @@ from .anchors import (  # isort:skip
     write_pdf_attachment)
 
 VARIANTS = {
-    name: data for variants in (pdfa.VARIANTS, pdfua.VARIANTS)
+    name: data for variants in (pdfa.VARIANTS, pdfua.VARIANTS, debug.VARIANTS)
     for (name, data) in variants.items()}
 
 
diff --git a/weasyprint/pdf/anchors.py b/weasyprint/pdf/anchors.py
index 9cbd0f3e..c2b60bd0 100644
--- a/weasyprint/pdf/anchors.py
+++ b/weasyprint/pdf/anchors.py
@@ -406,7 +406,7 @@ def resolve_links(pages):
     paged_anchors = []
     for i, page in enumerate(pages):
         paged_anchors.append([])
-        for anchor_name, (point_x, point_y) in page.anchors.items():
+        for anchor_name, (point_x, point_y, _, _) in page.anchors.items():
             if anchor_name not in anchors:
                 paged_anchors[-1].append((anchor_name, point_x, point_y))
                 anchors.add(anchor_name)
diff --git a/weasyprint/pdf/debug.py b/weasyprint/pdf/debug.py
new file mode 100644
index 00000000..9059e047
--- /dev/null
+++ b/weasyprint/pdf/debug.py
@@ -0,0 +1,43 @@
+"""PDF generation with debug information."""
+
+import pydyf
+
+from ..matrix import Matrix
+
+
+def debug(pdf, metadata, document, page_streams, attachments, compress):
+    """Set debug PDF metadata."""
+
+    # Add links on ids.
+    pages = zip(pdf.pages['Kids'][::3], document.pages, page_streams)
+    for pdf_page_number, document_page, stream in pages:
+        if not document_page.anchors:
+            continue
+
+        page = pdf.objects[pdf_page_number]
+        if 'Annots' not in page:
+            page['Annots'] = pydyf.Array()
+
+        for id, (x1, y1, x2, y2) in document_page.anchors.items():
+            # TODO: handle zoom correctly.
+            matrix = Matrix(0.75, 0, 0, 0.75) @ stream.ctm
+            x1, y1 = matrix.transform_point(x1, y1)
+            x2, y2 = matrix.transform_point(x2, y2)
+            annotation = pydyf.Dictionary({
+                'Type': '/Annot',
+                'Subtype': '/Link',
+                'Rect': pydyf.Array([x1, y1, x2, y2]),
+                'BS': pydyf.Dictionary({'W': 0}),
+                'P': page.reference,
+                'T': pydyf.String(id),  # id added as metadata
+            })
+
+            # The next line makes all of this relevent to use
+            # with PDFjs
+            annotation['Dest'] = pydyf.String(id)
+
+            pdf.add_object(annotation)
+            page['Annots'].append(annotation.reference)
+
+
+VARIANTS = {'debug': (debug, {})}


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 63ee8caa..48b3efe6 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -127,8 +127,10 @@ def _round_meta(pages):
     """Eliminate errors of floating point arithmetic for metadata."""
     for page in pages:
         anchors = page.anchors
-        for anchor_name, (pos_x, pos_y) in anchors.items():
-            anchors[anchor_name] = round(pos_x, 6), round(pos_y, 6)
+        for anchor_name, (x1, y1, x2, y2) in anchors.items():
+            anchors[anchor_name] = (
+                round(x1, 6), round(y1, 6),
+                round(x2, 6), round(y2, 6))
         links = page.links
         for i, link in enumerate(links):
             link_type, target, rectangle, box = link
@@ -884,8 +886,8 @@ def test_links_1():
         ],
         [('internal', 'hello', (0, 0, 200, 30))],
     ], [
-        {'hello': (0, 200)},
-        {'lipsum': (0, 0)}
+        {'hello': (0, 200, 200, 290)},
+        {'lipsum': (0, 0, 200, 90)}
     ], [
         (
             [
@@ -966,7 +968,7 @@ def test_links_6():
         ''', [[
             ('internal', 'lipsum', (5, 10, 195, 10)),
             ('external', 'https://weasyprint.org/', (0, 10, 200, 10))]],
-        [{'lipsum': (5, 10)}],
+        [{'lipsum': (5, 10, 195, 10)}],
         [([('internal', 'lipsum', (5, 10, 195, 10)),
            ('external', 'https://weasyprint.org/', (0, 10, 200, 10))],
           [('lipsum', 5, 10)])],
@@ -982,7 +984,7 @@ def test_links_7():
                         margin: 10px 5px" id="lipsum">
         ''',
         [[('internal', 'lipsum', (5, 10, 195, 10))]],
-        [{'lipsum': (5, 10)}],
+        [{'lipsum': (5, 10, 195, 10)}],
         [([('internal', 'lipsum', (5, 10, 195, 10))], [('lipsum', 5, 10)])],
         base_url=None)
 
@@ -998,7 +1000,7 @@ def test_links_8():
         ''',
         [[('internal', 'lipsum', (0, 0, 200, 15)),
           ('internal', 'missing', (0, 15, 200, 30))]],
-        [{'lipsum': (0, 15)}],
+        [{'lipsum': (0, 15, 200, 30)}],
         [([('internal', 'lipsum', (0, 0, 200, 15))], [('lipsum', 0, 15)])],
         base_url=None,
         warnings=[
@@ -1014,7 +1016,7 @@ def test_links_9():
                 transform: rotate(90deg) scale(2)">
         ''',
         [[('internal', 'lipsum', (30, 10, 70, 210))]],
-        [{'lipsum': (70, 10)}],
+        [{'lipsum': (70, 10, 30, 210)}],
         [([('internal', 'lipsum', (30, 10, 70, 210))], [('lipsum', 70, 10)])],
         round=True)