From d882fc8e32ef95ff35e432575eff0165a97f92b8 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Mon, 13 Mar 2023 23:50:46 +0100 Subject: [PATCH] pdf: add support for combo fields (#75373) --- passerelle/apps/pdf/forms.py | 25 +++++++++++++++++++++++-- passerelle/apps/pdf/models.py | 29 +++++++++++++++++++++++++++++ passerelle/utils/pdf.py | 30 ++++++++++++++++++++++++++++-- tests/test_utils_pdf.py | 13 +++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/passerelle/apps/pdf/forms.py b/passerelle/apps/pdf/forms.py index 28e92e17..bf79723d 100644 --- a/passerelle/apps/pdf/forms.py +++ b/passerelle/apps/pdf/forms.py @@ -15,6 +15,8 @@ # along with this program. If not, see . from django import forms +from django.urls import reverse +from django.utils.html import mark_safe from django.utils.translation import gettext_lazy as _ from passerelle.utils.forms import ConditionField, TemplateField @@ -44,11 +46,30 @@ class FieldsMappingEditForm(forms.ModelForm): values = ', '.join('"%s"' % value for value in field.radio_possible_values) help_text = _('text template, possibles values %s') % values field_class = TemplateField + elif field.widget_type in ('list', 'combo'): + ds_url = ( + reverse( + 'generic-endpoint', + kwargs={ + 'connector': 'pdf', + 'endpoint': 'field-values', + 'slug': self.instance.slug, + }, + ) + + '?digest_id=' + + field.digest_id + ) + help_text = mark_safe( + _('text template, possibles values data source') % ds_url + ) + field_class = TemplateField else: continue - label = _('field {number} ({help_text})').format(number=i + 1, help_text=help_text) + label = _('field {number}').format(number=i + 1) initial = fields_mapping.get(name, '') - self.fields[name] = field_class(label=label, required=False, initial=initial) + self.fields[name] = field_class( + label=label, required=False, initial=initial, help_text=help_text + ) self.fields[name].page_number = page.page_number self.fields[name].widget.attrs['tabindex'] = '0' self.fields[name].widget.attrs['class'] = '0' diff --git a/passerelle/apps/pdf/models.py b/passerelle/apps/pdf/models.py index cd8bad56..76e652c2 100644 --- a/passerelle/apps/pdf/models.py +++ b/passerelle/apps/pdf/models.py @@ -133,6 +133,7 @@ class Resource(BaseResource): description=_('Returns the assembly of received PDF files'), perm='can_access', methods=['post'], + display_order=0, post={ 'request_body': {'schema': {'application/json': ASSEMBLE_SCHEMA}}, 'input_example': { @@ -207,6 +208,7 @@ class Resource(BaseResource): description=_('Fills the input PDF form with fields applying mappings to the received payload'), perm='can_access', methods=['post'], + display_order=1, parameters={ 'filename': {'description': _('file name')}, 'flatten': {'description': _('remove PDF fields, keep only the drawed values')}, @@ -252,6 +254,8 @@ class Resource(BaseResource): value = evaluate_template(mapping_template, post_data) elif field.widget_type == 'radio': value = evaluate_template(mapping_template, post_data) + elif field.widget_type in ('combo', 'list'): + value = evaluate_template(mapping_template, post_data) self.logger.info('field=%r value=%r', field, value) else: raise NotImplementedError @@ -261,3 +265,28 @@ class Resource(BaseResource): response['Content-Disposition'] = 'attachment; filename="%s"' % filename pdf.write(response, flatten=flatten_pdf) return response + + @endpoint( + name='field-values', + description=_('Return possible values for PDF\'s combo or list form fields'), + perm='can_access', + parameters={ + 'digest_id': {'description': _('Identifier of the field')}, + }, + ) + def field_values(self, request, digest_id): + if not self.fill_form_file: + raise APIError('not PDF file configured') + + with self.fill_form_file.open() as fd: + pdf_content = fd.read() + + pdf = PDF(pdf_content) + fields = [field for page in pdf.pages for field in page.fields if field.digest_id == digest_id] + if not fields: + raise APIError(f'unknown digest-id {digest_id!r}') + field = fields[0] + if field.widget_type not in ('list', 'combo'): + raise APIError(f'wrong field type for digest-id {digest_id!r}: {field.widget_type}') + + return {'data': [{'id': value, 'text': value} for _, value in field.combo_possible_values]} diff --git a/passerelle/utils/pdf.py b/passerelle/utils/pdf.py index 36edbd0e..240a036f 100644 --- a/passerelle/utils/pdf.py +++ b/passerelle/utils/pdf.py @@ -25,8 +25,9 @@ import typing import pdfrw -RADIO_FLAG = 2**15 -PUSH_BUTTON_FLAG = 2**16 +RADIO_FLAG = 1 << 15 # bit 16 +PUSH_BUTTON_FLAG = 1 << 16 # bit 17 +LIST_FLAG = 1 << 17 # bit 18 class Rect(typing.NamedTuple): @@ -65,6 +66,10 @@ class FieldFlags(int): def is_push_button(self): return self & PUSH_BUTTON_FLAG + @property + def is_list(self): + return self & LIST_FLAG + @dataclasses.dataclass(frozen=True) class Widget: @@ -104,6 +109,11 @@ class Widget: return 'checkbox' elif self.field_type == pdfrw.PdfName.Tx: return 'text' + elif self.field_type == pdfrw.PdfName.Ch: + if self.field_flags.is_list: + return 'list' + else: + return 'combo' else: raise NotImplementedError @@ -156,6 +166,11 @@ class Widget: assert self.widget_type == 'radio' return list(list(kid.AP.N.keys())[0][1:] for kid in self.kids_ordered_by_rect if kid.AP and kid.AP.N) + @property + def combo_possible_values(self): + assert self.widget_type in ('list', 'combo') + return [(option[0].decode(), option[1].decode()) for option in self.annotation.Opt] + @property def value(self): if self.widget_type == 'text': @@ -166,6 +181,8 @@ class Widget: return self.annotation.V == self.checkbox_true_value elif self.widget_type == 'radio': return self.annotation.V.lstrip('/') if self.annotation.V else None + elif self.widget_type in ('list', 'combo'): + return self.annotation.V.decode() if self.annotation.V is not None else None def set(self, value): # allow rendering of values in Acrobat Reader @@ -186,6 +203,15 @@ class Widget: else: kid.update(pdfrw.PdfDict(AS=pdfrw.PdfName.Off)) self.annotation.update(pdfrw.PdfDict(V=radio_value)) + elif self.widget_type in ('list', 'combo'): + for export, combo_value in self.combo_possible_values: + if combo_value == value: + self.annotation.update( + pdfrw.PdfDict( + V=pdfrw.PdfString.from_unicode(export), AS=pdfrw.PdfString.from_unicode(export) + ) + ) + break @classmethod def from_pdf_widget(cls, page, pdf_widget): diff --git a/tests/test_utils_pdf.py b/tests/test_utils_pdf.py index 8289e9ef..dd13f9ad 100644 --- a/tests/test_utils_pdf.py +++ b/tests/test_utils_pdf.py @@ -104,3 +104,16 @@ def test_radio_button(): assert radio.radio_possible_values == ['H', 'F'] radio.set('H') assert radio.value == 'H' + + +def test_combo_box(): + with open('tests/data/cerfa_14011-02.pdf', 'rb') as fd: + pdf = PDF(content=fd) + combo = [field for field in pdf.page(0).fields if field.name == 'topmostSubform[0].Page1[0].Pays[0]'] + assert len(combo) == 1 + combo = combo[0] + assert len(combo.combo_possible_values) == 235 + combo.set('X') + assert combo.value is None + combo.set('FRANCE') + assert combo.value == 'FRANCE'