From d4e8d4ba6c3a15befd3c7ddee016a598e20ec23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vagner=20Jos=C3=A9=20Nicolodi?= Date: Fri, 3 Nov 2023 19:12:37 -0300 Subject: [PATCH 1/3] Added support for Select fields as combo boxes I've decoded two PDFs, one with the combo box and another blank and extracted the binary info necessary to render it and applied to the `anchors.add_inputs` function. --- docs/api_reference.rst | 4 +-- tests/test_api.py | 16 +++++++-- weasyprint/layout/block.py | 23 ++++++++++++ weasyprint/pdf/anchors.py | 73 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 1e11c758e..377c21566 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -765,7 +765,7 @@ The ``resize``, ``cursor``, ``caret-*`` and ``nav-*`` properties are **not** supported. The ``appearance`` property is supported. When set to ``auto``, it displays -form fields as PDF form fields (supported for text inputs, check boxes and -text areas only). +form fields as PDF form fields (supported for text inputs, check boxes, text +areas, and select only). The ``accent-color`` property is **not** supported. diff --git a/tests/test_api.py b/tests/test_api.py index 588731ac8..bad869612 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -487,12 +487,24 @@ def test_partial_pdf_custom_metadata(): (b'', [b'/Tx', b'/V ()']), (b'', [b'/Btn']), (b'', [b'/Tx', b'/V ()']), + (b'', [b'/Ch', b'/Opt']), + (b'' + , [b'/Ch', b'/Opt', b'/V (b)']), + # The selected values will be (b) and (c) in the PDF. + (b'' + , [b'/Ch', b'/Opt', b'[(b) (c)]']), )) def test_pdf_inputs(html, fields): stdout = _run('--pdf-forms --uncompressed-pdf - -', html) assert b'AcroForm' in stdout - for field in fields: - assert field in stdout + assert all(field in stdout for field in fields) stdout = _run('--uncompressed-pdf - -', html) assert b'AcroForm' not in stdout diff --git a/weasyprint/layout/block.py b/weasyprint/layout/block.py index 2e1dc9958..fffb1a82d 100644 --- a/weasyprint/layout/block.py +++ b/weasyprint/layout/block.py @@ -833,6 +833,29 @@ def block_container_layout(context, box, bottom_space, skip_stack, if next_page['page'] is None: next_page['page'] = new_box.page_values()[1] + if ( + box.element and + box.element.tag == 'select' + ): + if ( + box.element.attrib.get('multiple') is not None and + not box.element.attrib.get('height') and + not box.element.attrib.get('max-height') + ): + # Overriding the height when the select has the multiple attribute + # and no height attribute + options_number = len([child for child in box.element]) + new_box.height = min(box.height * 4, box.height * options_number) + + if ( + (padding_right:=box.style.get("padding_right")) and + (padding_left:=box.style.get("padding_left")) and + padding_right > padding_left + ): + # Overriding padding right because it's greater due to the select + # arrow + new_box.style["padding_right"] = padding_left + return ( new_box, resume_at, next_page, adjoining_margins, collapsing_through, max_lines) diff --git a/weasyprint/pdf/anchors.py b/weasyprint/pdf/anchors.py index 58e03d057..ec2839e09 100644 --- a/weasyprint/pdf/anchors.py +++ b/weasyprint/pdf/anchors.py @@ -160,6 +160,79 @@ def add_inputs(inputs, matrix, pdf, page, resources, stream, font_map, 'AS': '/Yes' if checked else '/Off', 'DA': pydyf.String(b' '.join(field_stream.stream)), }) + elif element.tag == "select": + # Text, password, textarea, files, and unknown + font_description = get_font_description(style) + font = pango.pango_font_map_load_font( + font_map, context, font_description) + font = stream.add_font(font) + font.used_in_forms = True + + field_stream.set_font_size(font.hash, font_size) + multiple = element.attrib.get("multiple") is not None + options = [] + selected_values = [] + for option in element: + options.append( + pydyf.Array( + [f'({option.attrib.get("value")})', f'({option.text})'] + ) + ) + if option.attrib.get("selected") is not None: + selected_values.append( + pydyf.String(option.attrib.get("value")) + ) + + if multiple: + field = pydyf.Dictionary({ + 'DA': pydyf.String(b' '.join(field_stream.stream)), + 'F': 2 ** (3 - 1), # Print flag + 'FT': '/Ch', + # To be a multiselect list we need to set the 21st bit to 1 + # outputing 2097152: + # https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf # noqa: E501 + # This specification can be found on the link above on page + # 445. + 'Ff': (1 << 21), + 'Opt': pydyf.Array(options), + 'P': page.reference, + 'Rect': pydyf.Array(rectangle), + 'Subtype': '/Widget', + 'T': pydyf.String(input_name), + 'Type': '/Annot', + # The select value is a list of selected values that come + # from the options with the selected property. If there are + # no selected values, then the value is an empty string. + 'V': pydyf.Array(selected_values) + if len(selected_values) + else pydyf.String(''), + }) + else: + field = pydyf.Dictionary({ + 'DA': pydyf.String(b' '.join(field_stream.stream)), + 'F': 2 ** (3 - 1), # Print flag + 'FT': '/Ch', + # To be a combo box we need to set the 17th bit to 1 + # outputing 131072: + # https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf # noqa: E501 + # This specification can be found on the link above on page + # 444. + 'Ff': (1 << 17), + 'Opt': pydyf.Array(options), + 'P': page.reference, + 'Rect': pydyf.Array(rectangle), + 'Subtype': '/Widget', + 'T': pydyf.String(input_name), + 'Type': '/Annot', + # Get the last value in the list when there are multiple + # selected values in a single select box (this is the same + # approach used by browsers). + # If there are no selected values, then the value is an + # empty string. + 'V': selected_values[-1] + if len(selected_values) + else pydyf.String(''), + }) else: # Text, password, textarea, files, and unknown font_description = get_font_description(style) From 7a0bc534e87fc14e5a3b18f0ff4a5e94e73c0ca0 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Wed, 6 Dec 2023 17:22:48 +0100 Subject: [PATCH 2/3] Use CSS instead of Python to set select boxes style --- weasyprint/css/html5_ua.css | 16 +++++++++++----- weasyprint/layout/block.py | 23 ----------------------- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/weasyprint/css/html5_ua.css b/weasyprint/css/html5_ua.css index 96443ef54..9bc5b0c9e 100644 --- a/weasyprint/css/html5_ua.css +++ b/weasyprint/css/html5_ua.css @@ -363,23 +363,29 @@ input[value=""]::before { select { background: lightgrey; border-radius: 0.25em 0.25em; - padding-right: 1.5em; position: relative; white-space: normal; } -select::before { +select[multiple] { + height: 3.6em; +} +select:not([multiple])::before { content: "˅"; position: absolute; right: 0; text-align: center; width: 1.5em; } -option { - display: none; +select option { + padding-right: 1.5em; white-space: nowrap; } +select:not([multiple]) option { + display: none; +} +select[multiple] option, select:not(:has(option[selected])) option:first-of-type, -option[selected]:not(option[selected] ~ option[selected]) { +select option[selected]:not(option[selected] ~ option[selected]) { display: block; overflow: hidden; } diff --git a/weasyprint/layout/block.py b/weasyprint/layout/block.py index fffb1a82d..2e1dc9958 100644 --- a/weasyprint/layout/block.py +++ b/weasyprint/layout/block.py @@ -833,29 +833,6 @@ def block_container_layout(context, box, bottom_space, skip_stack, if next_page['page'] is None: next_page['page'] = new_box.page_values()[1] - if ( - box.element and - box.element.tag == 'select' - ): - if ( - box.element.attrib.get('multiple') is not None and - not box.element.attrib.get('height') and - not box.element.attrib.get('max-height') - ): - # Overriding the height when the select has the multiple attribute - # and no height attribute - options_number = len([child for child in box.element]) - new_box.height = min(box.height * 4, box.height * options_number) - - if ( - (padding_right:=box.style.get("padding_right")) and - (padding_left:=box.style.get("padding_left")) and - padding_right > padding_left - ): - # Overriding padding right because it's greater due to the select - # arrow - new_box.style["padding_right"] = padding_left - return ( new_box, resume_at, next_page, adjoining_margins, collapsing_through, max_lines) From 2a7ced54b9bb379aca6e51272ef3d92f77fd10f9 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Wed, 6 Dec 2023 21:05:23 +0100 Subject: [PATCH 3/3] Clean and simplify code for select fields --- tests/test_api.py | 7 +-- weasyprint/pdf/anchors.py | 104 ++++++++++++-------------------------- 2 files changed, 33 insertions(+), 78 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index bad869612..16def4b69 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -491,15 +491,12 @@ def test_partial_pdf_custom_metadata(): (b'' - , [b'/Ch', b'/Opt', b'/V (b)']), - # The selected values will be (b) and (c) in the PDF. + b'', [b'/Ch', b'/Opt', b'/V (b)']), (b'' - , [b'/Ch', b'/Opt', b'[(b) (c)]']), + b'', [b'/Ch', b'/Opt', b'[(b) (c)]']), )) def test_pdf_inputs(html, fields): stdout = _run('--pdf-forms --uncompressed-pdf - -', html) diff --git a/weasyprint/pdf/anchors.py b/weasyprint/pdf/anchors.py index ec2839e09..ac489957b 100644 --- a/weasyprint/pdf/anchors.py +++ b/weasyprint/pdf/anchors.py @@ -150,7 +150,7 @@ def add_inputs(inputs, matrix, pdf, page, resources, stream, font_map, 'Subtype': '/Widget', 'Rect': pydyf.Array(rectangle), 'FT': '/Btn', - 'F': 2 ** (3 - 1), # Print flag + 'F': 1 << (3 - 1), # Print flag 'P': page.reference, 'T': pydyf.String(input_name), 'V': '/Yes' if checked else '/Off', @@ -160,8 +160,8 @@ def add_inputs(inputs, matrix, pdf, page, resources, stream, font_map, 'AS': '/Yes' if checked else '/Off', 'DA': pydyf.String(b' '.join(field_stream.stream)), }) - elif element.tag == "select": - # Text, password, textarea, files, and unknown + elif element.tag == 'select': + # Select fields font_description = get_font_description(style) font = pango.pango_font_map_load_font( font_map, context, font_description) @@ -169,70 +169,34 @@ def add_inputs(inputs, matrix, pdf, page, resources, stream, font_map, font.used_in_forms = True field_stream.set_font_size(font.hash, font_size) - multiple = element.attrib.get("multiple") is not None options = [] selected_values = [] for option in element: - options.append( - pydyf.Array( - [f'({option.attrib.get("value")})', f'({option.text})'] - ) - ) - if option.attrib.get("selected") is not None: - selected_values.append( - pydyf.String(option.attrib.get("value")) - ) - - if multiple: - field = pydyf.Dictionary({ - 'DA': pydyf.String(b' '.join(field_stream.stream)), - 'F': 2 ** (3 - 1), # Print flag - 'FT': '/Ch', - # To be a multiselect list we need to set the 21st bit to 1 - # outputing 2097152: - # https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf # noqa: E501 - # This specification can be found on the link above on page - # 445. - 'Ff': (1 << 21), - 'Opt': pydyf.Array(options), - 'P': page.reference, - 'Rect': pydyf.Array(rectangle), - 'Subtype': '/Widget', - 'T': pydyf.String(input_name), - 'Type': '/Annot', - # The select value is a list of selected values that come - # from the options with the selected property. If there are - # no selected values, then the value is an empty string. - 'V': pydyf.Array(selected_values) - if len(selected_values) - else pydyf.String(''), - }) + value = pydyf.String(option.attrib.get('value', '')) + text = pydyf.String(option.text) + options.append(pydyf.Array([value, text])) + 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', + }) + if 'multiple' in element.attrib: + field['Ff'] = 1 << (22 - 1) + field['V'] = pydyf.Array(selected_values) else: - field = pydyf.Dictionary({ - 'DA': pydyf.String(b' '.join(field_stream.stream)), - 'F': 2 ** (3 - 1), # Print flag - 'FT': '/Ch', - # To be a combo box we need to set the 17th bit to 1 - # outputing 131072: - # https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf # noqa: E501 - # This specification can be found on the link above on page - # 444. - 'Ff': (1 << 17), - 'Opt': pydyf.Array(options), - 'P': page.reference, - 'Rect': pydyf.Array(rectangle), - 'Subtype': '/Widget', - 'T': pydyf.String(input_name), - 'Type': '/Annot', - # Get the last value in the list when there are multiple - # selected values in a single select box (this is the same - # approach used by browsers). - # If there are no selected values, then the value is an - # empty string. - 'V': selected_values[-1] - if len(selected_values) - else pydyf.String(''), - }) + field['Ff'] = 1 << (18 - 1) + field['V'] = ( + selected_values[-1] if selected_values + else pydyf.String('')) else: # Text, password, textarea, files, and unknown font_description = get_font_description(style) @@ -250,24 +214,18 @@ def add_inputs(inputs, matrix, pdf, page, resources, stream, font_map, 'Subtype': '/Widget', 'Rect': pydyf.Array(rectangle), 'FT': '/Tx', - 'F': 2 ** (3 - 1), # Print flag + 'F': 1 << (3 - 1), # Print flag 'P': page.reference, 'T': pydyf.String(input_name), - # Previously if the input had no value or the value was an - # empty string, the V key was filled with a pydyf.String(None) - # object. This caused the PDF input/textarea to be filled with - # the string "None". Now if the input has no value or the - # value is an empty string, the V key is filled with a - # pydyf.String('') object. 'V': pydyf.String(value or ''), 'DA': pydyf.String(b' '.join(field_stream.stream)), }) if element.tag == 'textarea': - field['Ff'] = 2 ** (13 - 1) + field['Ff'] = 1 << (13 - 1) elif input_type == 'password': - field['Ff'] = 2 ** (14 - 1) + field['Ff'] = 1 << (14 - 1) elif input_type == 'file': - field['Ff'] = 2 ** (21 - 1) + field['Ff'] = 1 << (21 - 1) pdf.add_object(field) page['Annots'].append(field.reference)