Repository: Kozea/WeasyPrint
Pull request #2208 (2024-07-21 15:14:21)

Submit PDF forms
Allow submit buttons to send form content to given URL.

It currently works pretty well with Adobe Reader and Foxit. Other tested PDF open source renderers (including Poppler and browsers) don’t support the feature, there are related comments in their code.

Text included in the HTTP request (both name and value fields) only work with ASCII text. If anyone knows if it’s possible to handle Unicode, please add a comment!

Default selected radio buttons are not selected anymore with Adobe Reader. It’s probably a bug in Adobe Reader, as it works everywhere else, including Ghostscript!

Default rendering for form fields has been improved too.

Feedback is welcome!


Patch:
diff --git a/weasyprint/css/html5_ua.css b/weasyprint/css/html5_ua.css
index 67f84625..6b8c527b 100644
--- a/weasyprint/css/html5_ua.css
+++ b/weasyprint/css/html5_ua.css
@@ -161,14 +161,14 @@ input[type="reset"]:not([value])::before {
 }
 input[type="checkbox"],
 input[type="radio"] {
-  height: 1.2em;
-  width: 1.2em;
+  height: 0.7em;
+  vertical-align: -0.2em;
+  width: 0.7em;
 }
 input[type="checkbox"][checked]:before,
 input[type="radio"][checked]:before {
   background: black;
   content: "";
-  display: block;
   height: 100%;
 }
 input[type="radio"][checked]:before {
@@ -179,7 +179,6 @@ input[type="hidden"] {
 }
 input[type="radio"] {
   border-radius: 50%;
-  margin: 0.2em 0.2em 0 0.4em;
 }
 input[value]::before {
   content: attr(value);
@@ -191,6 +190,7 @@ input[value=""]::before,
 input[type="checkbox"]::before,
 input[type="radio"]::before {
   content: "";
+  display: block;
 }
 select {
   background: lightgrey;
diff --git a/weasyprint/css/html5_ua_form.css b/weasyprint/css/html5_ua_form.css
index 76f556c1..8e353b0e 100644
--- a/weasyprint/css/html5_ua_form.css
+++ b/weasyprint/css/html5_ua_form.css
@@ -3,3 +3,13 @@
 button, input, select, textarea {
   appearance: auto;
 }
+
+select option,
+select:not([multiple])::before,
+input:not([type="submit"])::before {
+  visibility: hidden;
+}
+
+textarea {
+  color: transparent;
+}
diff --git a/weasyprint/formatting_structure/build.py b/weasyprint/formatting_structure/build.py
index c0934552..4d66dccb 100644
--- a/weasyprint/formatting_structure/build.py
+++ b/weasyprint/formatting_structure/build.py
@@ -223,7 +223,7 @@ def element_to_box(element, style_for, get_image_from_uri, base_url,
         if not counter_values[name]:
             counter_values.pop(name)
 
-    box.children = children if style['appearance'] == 'none' else []
+    box.children = children
     process_whitespace(box)
     set_content_lists(
         element, box, style, counter_values, target_collector, counter_style)
diff --git a/weasyprint/pdf/anchors.py b/weasyprint/pdf/anchors.py
index 383a6d53..859e7f2b 100644
--- a/weasyprint/pdf/anchors.py
+++ b/weasyprint/pdf/anchors.py
@@ -3,7 +3,6 @@
 import collections
 import io
 import mimetypes
-from base64 import b64encode
 from hashlib import md5
 from os.path import basename
 from urllib.parse import unquote, urlsplit
@@ -117,6 +116,7 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map,
         for element, style, rectangle in inputs
     ]
     radio_groups = collections.defaultdict(dict)
+    forms = collections.defaultdict(dict)
     for i, (form, element, style, rectangle) in enumerate(inputs_with_forms):
         rectangle = (
             *matrix.transform_point(*rectangle[:2]),
@@ -130,24 +130,35 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map,
         font_size = style['font_size'] * 0.75
         field_stream = pydyf.Stream(compress=compress)
         field_stream.set_color_rgb(*style['color'][:3])
+        field = pydyf.Dictionary({
+            'Type': '/Annot',
+            'Subtype': '/Widget',
+            'Rect': pydyf.Array(rectangle),
+            'P': page.reference,
+            'F': 1 << (3 - 1),  # Print flag
+            'T': pydyf.String(input_name),
+        })
+
         if input_type in ('radio', 'checkbox'):
             if input_type == 'radio':
                 if input_name not in radio_groups[form]:
                     radio_groups[form][input_name] = group = pydyf.Dictionary({
                         'FT': '/Btn',
                         'Ff': (1 << (15 - 1)) + (1 << (16 - 1)),  # NoToggle & Radio
-                        'T': pydyf.String(f'{len(radio_groups)}'),
+                        'T': pydyf.String(input_name),
                         'V': '/Off',
                         'Kids': pydyf.Array(),
+                        'Opt': pydyf.Array(),
                     })
                     pdf.add_object(group)
                     pdf.catalog['AcroForm']['Fields'].append(group.reference)
                 group = radio_groups[form][input_name]
+                font_size = style['font_size'] * 0.5
                 character = 'l'  # Disc character in Dingbats
             else:
                 character = '4'  # Check character in Dingbats
 
-            # Create stream when input is checked
+            # Create stream when input is checked.
             width = rectangle[2] - rectangle[0]
             height = rectangle[1] - rectangle[3]
             checked_stream = pydyf.Stream(extra={
@@ -160,7 +171,7 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map,
             checked_stream.begin_text()
             checked_stream.set_color_rgb(*style['color'][:3])
             checked_stream.set_font_size('ZaDb', font_size)
-            # Center (let’s assume that Dingbat’s characters have a 0.75em size)
+            # Center (assuming that Dingbat’s characters have a 0.75em size).
             x = (width - font_size * 0.75) / 2
             y = (height - font_size * 0.75) / 2
             checked_stream.move_text_to(x, y)
@@ -169,33 +180,28 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map,
             checked_stream.pop_state()
             pdf.add_object(checked_stream)
 
-            checked = 'checked' in element.attrib
             field_stream.set_font_size('ZaDb', font_size)
-            key = b64encode(input_value.encode(), altchars=b"+-").decode()
-            field = pydyf.Dictionary({
-                'Type': '/Annot',
-                'Subtype': '/Widget',
-                'Rect': pydyf.Array(rectangle),
-                'FT': '/Btn',
-                'F': 1 << (3 - 1),  # Print flag
-                'P': page.reference,
-                'AS': f'/{key}' if checked else '/Off',
-                'AP': pydyf.Dictionary({'N': pydyf.Dictionary({
-                    key: checked_stream.reference})}),
-                'MK': pydyf.Dictionary({'CA': pydyf.String(character)}),
-                'DA': pydyf.String(b' '.join(field_stream.stream)),
-            })
+
+            checked = 'checked' in element.attrib
+            key = len(group['Kids']) if input_type == 'radio' else 'on'
+            appearance = pydyf.Dictionary({key: checked_stream.reference})
+            field['FT'] = '/Btn'
+            field['DA'] = pydyf.String(b' '.join(field_stream.stream))
+            field['AS'] = f'/{key}' if checked else '/Off'
+            field['AP'] = pydyf.Dictionary({'N': appearance})
+            field['MK'] = pydyf.Dictionary({'CA': pydyf.String(character)})
             pdf.add_object(field)
             if input_type == 'radio':
                 field['Parent'] = group.reference
                 if checked:
                     group['V'] = f'/{key}'
                 group['Kids'].append(field.reference)
+                group['Opt'].append(pydyf.String(input_value))
             else:
                 field['T'] = pydyf.String(input_name)
                 field['V'] = field['AS']
+
         elif element.tag == 'select':
-            # Select fields
             font_description = get_font_description(style)
             font = pango.pango_font_map_load_font(
                 font_map, context, font_description)
@@ -212,17 +218,9 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map,
                 if 'selected' in option.attrib:
                     selected_values.append(value)
 
-            field = pydyf.Dictionary({
-                'DA': pydyf.String(b' '.join(field_stream.stream)),
-                'F': 1 << (3 - 1),  # Print flag
-                'FT': '/Ch',
-                'Opt': pydyf.Array(options),
-                'P': page.reference,
-                'Rect': pydyf.Array(rectangle),
-                'Subtype': '/Widget',
-                'T': pydyf.String(input_name),
-                'Type': '/Annot',
-            })
+            field['FT'] = '/Ch'
+            field['DA'] = pydyf.String(b' '.join(field_stream.stream))
+            field['Opt'] = pydyf.Array(options)
             if 'multiple' in element.attrib:
                 field['Ff'] = 1 << (22 - 1)
                 field['V'] = pydyf.Array(selected_values)
@@ -232,8 +230,27 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map,
                     selected_values[-1] if selected_values
                     else pydyf.String(''))
             pdf.add_object(field)
+
+        elif input_type == 'submit' or element.tag == 'button':
+            flags = 1 << (3 - 1)  # HTML form format
+            if form.attrib.get('method', '').lower() != 'post':
+                flags += 1 << (4 - 1)  # GET method
+            fields = pydyf.Array((field.reference for field in forms[form].values()))
+            field['FT'] = '/Btn'
+            field['DA'] = pydyf.String(b' '.join(field_stream.stream))
+            field['V'] = pydyf.String(form.attrib.get('value', ''))
+            field['Ff'] = 1 << (17 - 1)  # Push-button
+            field['A'] = pydyf.Dictionary({
+                'Type': '/Action',
+                'S': '/SubmitForm',
+                'F': pydyf.String(form.attrib.get('action')),
+                'Fields': fields,
+                'Flags': flags,
+            })
+            pdf.add_object(field)
+
         else:
-            # Text, password, textarea, files, and unknown
+            # Text, password, textarea, files, and other unknown fields.
             font_description = get_font_description(style)
             font = pango.pango_font_map_load_font(
                 font_map, context, font_description)
@@ -241,34 +258,25 @@ def add_forms(forms, matrix, pdf, page, resources, stream, font_map,
             font.used_in_forms = True
 
             field_stream.set_font_size(font.hash, font_size)
-            value = (
-                element.text if element.tag == 'textarea'
-                else element.attrib.get('value', ''))
-            field = pydyf.Dictionary({
-                'Type': '/Annot',
-                'Subtype': '/Widget',
-                'Rect': pydyf.Array(rectangle),
-                'FT': '/Tx',
-                'F': 1 << (3 - 1),  # Print flag
-                'P': page.reference,
-                'T': pydyf.String(input_name),
-                'V': pydyf.String(value or ''),
-                'DA': pydyf.String(b' '.join(field_stream.stream)),
-            })
+            field['FT'] = '/Tx'
+            field['DA'] = pydyf.String(b' '.join(field_stream.stream))
+            field['V'] = pydyf.String(element.attrib.get('value', ''))
             if element.tag == 'textarea':
                 field['Ff'] = 1 << (13 - 1)
+                field['V'] = pydyf.String(element.text or '')
             elif input_type == 'password':
                 field['Ff'] = 1 << (14 - 1)
             elif input_type == 'file':
                 field['Ff'] = 1 << (21 - 1)
-
-            maxlength = element.get('maxlength')
-            if maxlength and maxlength.isdigit():
-                field['MaxLen'] = element.get('maxlength')
+            if (max_length := element.get('maxlength', '')).isdigit():
+                field['MaxLen'] = max_length
             pdf.add_object(field)
 
         page['Annots'].append(field.reference)
         pdf.catalog['AcroForm']['Fields'].append(field.reference)
+        if input_name not in forms:
+            forms[form][input_name] = field
+
 
 
 def add_annotations(links, matrix, document, pdf, page, annot_files, compress):


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index ad25c3a9..63ee8caa 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -538,10 +538,10 @@ def test_pdf_no_srgb():
     ('<input type="radio">',
      ['/Btn', '/V /Off', '/AS /Off', '/Ff 49152']),
     ('<input checked type="radio" name="foo" value="value">',
-     ['/Btn', '/T (1)', '/V /dmFsdWU=', '/AS /dmFsdWU=']),
+     ['/Btn', '/T (foo)', '/V /0', '/AS /0']),
     ('<form><input type="radio" name="foo" value="v0"></form>'
      '<form><input checked type="radio" name="foo" value="v1"></form>',
-     ['/Btn', '/AS /djE=', '/V /djE=', '/AS /Off', '/V /Off']),
+     ['/Btn', '/AS /0', '/V /0', '/AS /Off', '/V /Off']),
     ('<textarea></textarea>', ['/Tx', '/V ()']),
     ('<select><option value="a">A</option></select>', ['/Ch', '/Opt']),
     ('<select>'