éditeur de gabarit pour le remplissage de pdf (#74797) #122
|
@ -13,7 +13,9 @@ Homepage: https://dev.entrouvert.org/projects/passerelle
|
|||
|
||||
Package: python3-passerelle
|
||||
Architecture: all
|
||||
Depends: pdftk,
|
||||
Depends: ghostscript,
|
||||
pdftk,
|
||||
poppler-utils,
|
||||
python3-cmislib,
|
||||
python3-dateutil,
|
||||
python3-distutils,
|
||||
|
|
|
@ -36,6 +36,11 @@ LOGGING['loggers']['paramiko.transport'] = {
|
|||
'propagate': True,
|
||||
}
|
||||
|
||||
# silence pdfrw
|
||||
LOGGING['loggers']['pdfrw'] = {
|
||||
'propagate': False,
|
||||
}
|
||||
|
||||
exec(open('/etc/%s/settings.py' % PROJECT_NAME).read())
|
||||
|
||||
# run additional settings snippets
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
# passerelle - uniform access to multiple data sources and services
|
||||
# Copyright (C) 2020 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from passerelle.utils.forms import ConditionField, TemplateField
|
||||
from passerelle.utils.pdf import PDF
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class FieldsMappingEditForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.instance.fill_form_file:
|
||||
return
|
||||
fields_mapping = self.instance.fields_mapping or {}
|
||||
with self.instance.fill_form_file as fd:
|
||||
pdf = PDF(fd)
|
||||
for page in pdf.pages:
|
||||
for i, field in enumerate(page.fields):
|
||||
name = f'field_{field.digest_id}'
|
||||
if field.widget_type == 'checkbox':
|
||||
help_text = _('boolean expression')
|
||||
field_class = ConditionField
|
||||
elif field.widget_type == 'text':
|
||||
help_text = _('text template')
|
||||
field_class = TemplateField
|
||||
else:
|
||||
continue
|
||||
|
||||
label = _('field {number} ({help_text})').format(number=i + 1, help_text=help_text)
|
||||
initial = fields_mapping.get(name, '')
|
||||
self.fields[name] = field_class(label=label, required=False, initial=initial)
|
||||
self.fields[name].page_number = page.page_number
|
||||
self.fields[name].widget.attrs['tabindex'] = '0'
|
||||
self.fields[name].widget.attrs['class'] = '0'
|
||||
|
||||
def save(self, commit=True):
|
||||
fields_mapping = {}
|
||||
for name in self.fields:
|
||||
value = self.cleaned_data.get(name)
|
||||
if value:
|
||||
fields_mapping[name] = value
|
||||
self.instance.fields_mapping = fields_mapping
|
||||
return super().save(commit=commit)
|
||||
|
||||
class Meta:
|
||||
model = models.Resource
|
||||
fields = ()
|
|
@ -18,11 +18,11 @@ class Migration(migrations.Migration):
|
|||
name='fill_form_file',
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
help_text='PDF file, used if not input-form in fill-form payload',
|
||||
help_text='PDF file',
|
||||
null=True,
|
||||
upload_to=passerelle.utils.models.resource_file_upload_to,
|
||||
validators=[passerelle.apps.pdf.models.validate_pdf],
|
||||
verbose_name='Fill Form default input file',
|
||||
verbose_name='Fill Form input file',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 3.2.18 on 2023-03-01 16:48
|
||||
|
||||
from django.contrib.postgres.fields.jsonb import JSONField
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pdf', '0002_resource_fill_form_file'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='fields_mapping',
|
||||
field=JSONField(null=True, verbose_name='Field mapping', blank=True),
|
||||
),
|
||||
]
|
|
@ -18,10 +18,10 @@ import base64
|
|||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.fields.jsonb import JSONField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.http.response import HttpResponse
|
||||
|
@ -31,6 +31,8 @@ from passerelle.base.models import BaseResource
|
|||
from passerelle.utils.api import endpoint
|
||||
from passerelle.utils.jsonresponse import APIError
|
||||
from passerelle.utils.models import resource_file_upload_to
|
||||
from passerelle.utils.pdf import PDF
|
||||
from passerelle.utils.templates import evaluate_condition, evaluate_template
|
||||
|
||||
PDF_FILE_OBJECT = {
|
||||
'type': 'object',
|
||||
|
@ -80,50 +82,36 @@ ASSEMBLE_SCHEMA = {
|
|||
),
|
||||
}
|
||||
|
||||
FILL_FORM_SCHEMA = {
|
||||
'$schema': 'http://json-schema.org/draft-04/schema#',
|
||||
'title': '',
|
||||
'description': '',
|
||||
'type': 'object',
|
||||
'required': ['filename', 'fields'],
|
||||
'unflatten': True,
|
||||
'properties': OrderedDict(
|
||||
{
|
||||
'filename': {
|
||||
'description': _('output PDF filename'),
|
||||
'type': 'string',
|
||||
},
|
||||
'input-form': PDF_FILE_OBJECT,
|
||||
'fields': {
|
||||
'description': _('hierarchical dictionary of fields'),
|
||||
'type': 'object',
|
||||
},
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def validate_pdf(fieldfile):
|
||||
fieldfile.open()
|
||||
if fieldfile.read(5) != b'%PDF-':
|
||||
raise ValidationError(
|
||||
_('%(value)s is not a PDF file'),
|
||||
params={'value': fieldfile},
|
||||
)
|
||||
to_close = fieldfile.closed
|
||||
try:
|
||||
if fieldfile.read(5) != b'%PDF-':
|
||||
raise ValidationError(
|
||||
_('%(value)s is not a PDF file'),
|
||||
params={'value': fieldfile},
|
||||
)
|
||||
finally:
|
||||
if to_close:
|
||||
fieldfile.close()
|
||||
|
||||
|
||||
class Resource(BaseResource):
|
||||
category = _('Misc')
|
||||
|
||||
fill_form_file = models.FileField(
|
||||
_('Fill Form default input file'),
|
||||
_('Fill Form input file'),
|
||||
upload_to=resource_file_upload_to,
|
||||
help_text=_('PDF file, used if not input-form in fill-form payload'),
|
||||
help_text=_('PDF file'),
|
||||
validators=[validate_pdf],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
fields_mapping = JSONField(verbose_name=_('Field mapping'), null=True, blank=True)
|
||||
|
||||
hide_description_fields = ['fields_mapping']
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('PDF')
|
||||
|
||||
|
@ -182,77 +170,86 @@ class Resource(BaseResource):
|
|||
response['Content-Disposition'] = 'attachment; filename="%s"' % filename
|
||||
return response
|
||||
|
||||
FILL_FORM_SCHEMA = {
|
||||
'$schema': 'http://json-schema.org/draft-04/schema#',
|
||||
'title': '',
|
||||
'description': _('content of the form to map on PDF fields'),
|
||||
'unflatten': True,
|
||||
'type': 'object',
|
||||
'properties': OrderedDict(
|
||||
{
|
||||
'extra': {
|
||||
'type': 'object',
|
||||
'properties': OrderedDict(
|
||||
{
|
||||
'filename': {
|
||||
'type': 'string',
|
||||
'description': _('file name'),
|
||||
},
|
||||
'flatten': {
|
||||
'description': _('remove PDF fields, keep only the drawed values'),
|
||||
'type': 'boolean',
|
||||
},
|
||||
}
|
||||
),
|
||||
}
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
@endpoint(
|
||||
name='fill-form',
|
||||
description=_('Fills the input PDF form with fields'),
|
||||
description=_('Fills the input PDF form with fields applying mappings to the received payload'),
|
||||
perm='can_access',
|
||||
methods=['post'],
|
||||
parameters={
|
||||
'filename': {'description': _('file name')},
|
||||
'flatten': {'description': _('remove PDF fields, keep only the drawed values')},
|
||||
},
|
||||
post={
|
||||
'request_body': {'schema': {'application/json': FILL_FORM_SCHEMA}},
|
||||
'input_example': {
|
||||
'filename': 'filled.pdf',
|
||||
'fields/Page1[0]/FirstName[0]': 'John',
|
||||
'fields/Page1[0]/LastName[0]': 'Doe',
|
||||
'fields/Page2[0]/Checkbox[0]': '0',
|
||||
'fields/Page2[0]/Checkbox[1]': '1',
|
||||
'extra': {
|
||||
'filename': 'filled.pdf',
|
||||
'flatten': True,
|
||||
},
|
||||
'prenom': 'Jean',
|
||||
'nom': 'Dupont',
|
||||
},
|
||||
},
|
||||
)
|
||||
def fill_form(self, request, post_data):
|
||||
filename = post_data.pop('filename')
|
||||
fields = post_data.pop('fields')
|
||||
def fill_form(self, request, post_data, flatten=None, filename=None):
|
||||
extra = post_data.pop('extra', {})
|
||||
filename = filename or extra.get('filename') or post_data.get('filename') or 'form.pdf'
|
||||
flatten_pdf = str(flatten or extra.get('flatten') or post_data.get('flatten')).lower() in (
|
||||
'1',
|
||||
'on',
|
||||
'yes',
|
||||
'true',
|
||||
)
|
||||
|
||||
xfdf_root = ET.Element('xfdf')
|
||||
xfdf_root.attrib['xmlns'] = 'http://ns.adobe.com/xfdf/'
|
||||
xfdf_root.attrib['xml:space'] = 'preserve'
|
||||
xfdf_f = ET.SubElement(xfdf_root, 'f')
|
||||
xfdf_fields = ET.SubElement(xfdf_root, 'fields')
|
||||
|
||||
def add_fields(element, fields):
|
||||
if isinstance(fields, dict):
|
||||
for key in fields:
|
||||
field = ET.SubElement(element, 'field')
|
||||
field.attrib['name'] = key
|
||||
add_fields(field, fields[key])
|
||||
else:
|
||||
value = ET.SubElement(element, 'value')
|
||||
value.text = str(fields)
|
||||
|
||||
add_fields(xfdf_fields, fields)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='passerelle-pdftk-%s-fill-form-' % self.id) as tmpdir:
|
||||
if isinstance(post_data.get('input-form'), dict) and post_data['input-form'].get('content'):
|
||||
input_filename = os.path.join(tmpdir, 'input-form.pdf')
|
||||
with open(input_filename, mode='wb') as fd:
|
||||
fd.write(base64.b64decode(post_data['input-form']['content']))
|
||||
elif self.fill_form_file:
|
||||
input_filename = self.fill_form_file.path
|
||||
else:
|
||||
raise APIError("missing or bad 'input-form' property", http_status=400)
|
||||
# create xfdf
|
||||
xfdf_filename = os.path.join(tmpdir, 'fields.xfdf')
|
||||
xfdf_f.attrib['href'] = input_filename
|
||||
with open(xfdf_filename, mode='wb') as fd:
|
||||
ET.indent(xfdf_root)
|
||||
ET.ElementTree(xfdf_root).write(fd, encoding='UTF-8', xml_declaration=True)
|
||||
|
||||
# call pdftk fill_form
|
||||
pdf_content = self.run_pdftk(args=[input_filename, 'fill_form', xfdf_filename])
|
||||
|
||||
response = HttpResponse(pdf_content, content_type='application/pdf')
|
||||
response['Content-Disposition'] = 'attachment; filename="%s"' % filename
|
||||
return response
|
||||
|
||||
def pdftk_dump_data_fields_utf8(self):
|
||||
if not self.fill_form_file:
|
||||
return
|
||||
try:
|
||||
dump = self.run_pdftk(args=[self.fill_form_file.path, 'dump_data_fields_utf8']).decode()
|
||||
except APIError as apierror:
|
||||
return 'Error: %r' % apierror
|
||||
unflatten_separated = ''
|
||||
for line in dump.splitlines():
|
||||
unflatten_separated += '<br>%s' % line
|
||||
if line.startswith('FieldName: '):
|
||||
unflatten_separated += ' → <b>fields/%s</b>' % line[11:].replace('.', '/')
|
||||
return unflatten_separated
|
||||
raise APIError('not PDF file configured')
|
||||
fields_mapping = self.fields_mapping
|
||||
if not fields_mapping:
|
||||
raise APIError('no fields mapping configured')
|
||||
|
||||
with self.fill_form_file.open() as fd:
|
||||
pdf = PDF(fd)
|
||||
for page in pdf.pages:
|
||||
for field in page.fields:
|
||||
mapping_template = fields_mapping.get(f'field_{field.digest_id}')
|
||||
if not mapping_template:
|
||||
continue
|
||||
if field.widget_type == 'checkbox':
|
||||
value = evaluate_condition(mapping_template, post_data)
|
||||
elif field.widget_type == 'text':
|
||||
value = evaluate_template(mapping_template, post_data)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
if value is not None:
|
||||
field.set(value)
|
||||
response = HttpResponse(content_type='application/pdf')
|
||||
response['Content-Disposition'] = 'attachment; filename="%s"' % filename
|
||||
pdf.write(response, flatten=flatten_pdf)
|
||||
return response
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
{% extends "passerelle/manage/resource_child_base.html" %}
|
||||
{% load i18n gadjo %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<a href="{% url 'manage-home' %}">{% trans 'Web Services' %}</a>
|
||||
<a href="{{ object.get_absolute_url }}">PDF — {{ object.title }}</a>
|
||||
<a href="#">{% trans "Edit fields mapping" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block appbar %}
|
||||
<h2>
|
||||
{% trans "Edit fields mapping" %}
|
||||
</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data" class="pdf-fields-mapping-edit-form">
|
||||
{% csrf_token %}
|
||||
<div class="buttons pdf-fields-mapping-edit-form--buttons">
|
||||
<button class="submit-button">{% trans "Save" %}</button>
|
||||
<a class="cancel" href="{{ object.get_absolute_url }}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
{% if form.errors %}
|
||||
<div class="errornotice" tabindex="-1" autofocus>
|
||||
<p>{% trans "There were errors processing your form." %}</p>
|
||||
{% for error in form.non_field_errors %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% for field in form %}
|
||||
{% if field.is_hidden and field.errors %}
|
||||
<p>
|
||||
{% for error in field.errors %}
|
||||
{% blocktrans with name=field.name %}(Hidden field {{name}}) {{ error }}{% endblocktrans %}
|
||||
{% if not forloop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for page_number, image_map in pages %}
|
||||
<h3>{% blocktrans with number=page_number|add:1 %}Page {{number}}{% endblocktrans %}</h3>
|
||||
<div class="pdf-fields-mapping-edit-form--page">
|
||||
<div class="pdf-fields-mapping-edit-form--thumbnail">
|
||||
<div>
|
||||
<map name="map-page-{{ page_number }}">
|
||||
{{ image_map|safe }}
|
||||
</map>
|
||||
<img src="{% url "pdf-page-thumbnail" connector="pdf" slug=object.slug page_number=page_number %}" usemap="#map-page-{{ page_number }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf-fields-mapping-edit-form--fields">
|
||||
{% for field in form %}
|
||||
{% if field.field.page_number == page_number %}
|
||||
{% include "gadjo/widget.html" with field=field %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="buttons pdf-fields-mapping-edit-form--buttons">
|
||||
<button class="submit-button">{% trans "Save" %}</button>
|
||||
<a class="cancel" href="{{ object.get_absolute_url }}">{% trans 'Cancel' %}</a>
|
||||
</div>
|
||||
</form>
|
||||
tnoel
commented
Autre petit manque sur l'i18n ici, il faudrait plutôt
Autre petit manque sur l'i18n ici, il faudrait plutôt
`<h3>{% blocktrans with number=page_number|add:1 %}Page {{number}}{% endblocktrans %}</h3>`
bdauvergne
commented
ok c'est fait. ok c'est fait.
|
||||
<script>
|
||||
$(document).on('click', 'area', function (event) {
|
||||
var $target = $(event.target);
|
||||
var href = $target.attr('href');
|
||||
$('.pdf-fields-mapping-edit-form--fields').scrollTop = $(href).offsetTop;
|
||||
$(href + ' input').focus();
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -1,20 +1,8 @@
|
|||
{% extends "passerelle/manage/service_view.html" %}
|
||||
{% load i18n passerelle %}
|
||||
|
||||
{% block extra-tab-buttons %}
|
||||
{% if user.is_staff and object.fill_form_file %}
|
||||
<button role="tab" aria-selected="false" aria-controls="panel-dumpfields" id="tab-dumpfields"
|
||||
tabindex="-1">{% trans "Fill Form default PDF Fields" %}</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra-tab-panels %}
|
||||
{% if user.is_staff and object.fill_form_file %}
|
||||
<div id="panel-dumpfields" role="tabpanel" tabindex="-1" aria-labelledby="tab-dumpfields" hidden>
|
||||
<div>
|
||||
<p>{% blocktrans with file=object.fill_form_file %}PDFtk {{ file }} dump_data_fields_utf8 output{% endblocktrans %}</p>
|
||||
<p>{{ object.pdftk_dump_data_fields_utf8|safe }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% block actions %}
|
||||
{% if object|can_edit:request.user %}
|
||||
<a href="{% url 'pdf-fields-mapping-edit' connector='pdf' slug=object.slug %}">{% trans 'Fill form: Edit fields mapping' %}</a>
|
||||
tnoel
commented
Peut-être nommer le bouton "Fill Form: Edit field mapping" pour préciser que c'est un bouton lié au système de remplissage, on traduira par "Formulaire : configuration des champs" Peut-être nommer le bouton "Fill Form: Edit field mapping" pour préciser que c'est un bouton lié au système de remplissage, on traduira par "Formulaire : configuration des champs"
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
# passerelle - uniform access to multiple data sources and services
|
||||
# Copyright (C) 2019 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.urls import re_path
|
||||
|
||||
from . import views
|
||||
|
||||
management_urlpatterns = [
|
||||
re_path(
|
||||
r'^(?P<slug>[\w,-]+)/fields-mapping/edit/$',
|
||||
views.FieldsMappingEditView.as_view(),
|
||||
name='pdf-fields-mapping-edit',
|
||||
),
|
||||
re_path(
|
||||
r'^(?P<slug>[\w,-]+)/page/(?P<page_number>[0-9]+)/$',
|
||||
views.PageThumbnailView.as_view(),
|
||||
name='pdf-page-thumbnail',
|
||||
),
|
||||
]
|
|
@ -0,0 +1,94 @@
|
|||
# passerelle - uniform access to multiple data sources and services
|
||||
# Copyright (C) 2019 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
|
||||
import PIL.Image
|
||||
import PIL.ImageDraw
|
||||
from django.http import Http404, HttpResponse, HttpResponseNotModified
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import UpdateView
|
||||
|
||||
from passerelle.base.views import ResourceView
|
||||
from passerelle.utils.pdf import PDF
|
||||
|
||||
from . import forms, models
|
||||
|
||||
|
||||
class FieldsMappingEditView(ResourceView, UpdateView):
|
||||
template_name = 'pdf/fields_mapping_edit.html'
|
||||
model = models.Resource
|
||||
form_class = forms.FieldsMappingEditForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
resource = self.get_object()
|
||||
with resource.fill_form_file as fd:
|
||||
pdf = PDF(fd)
|
||||
pages = []
|
||||
for page in pdf.pages:
|
||||
pages.append((page.page_number, page.fields_image_map(id_prefix='id_field_', id_suffix='_p')))
|
||||
context_data['pages'] = pages
|
||||
return context_data
|
||||
|
||||
def get_success_url(self):
|
||||
return super().get_success_url() + '#'
|
||||
|
||||
fpeters
commented
Cette partie n'est absolument pas couverte par les tests : https://jenkins.entrouvert.org/job/gitea/job/passerelle/job/wip%252F74797-connecteur-PDF-gabarit-de-xfdf-p/15/Coverage_20Report_20_28native_29/d_1f676f23cbeaa486_views_py.html Cette partie n'est absolument pas couverte par les tests : https://jenkins.entrouvert.org/job/gitea/job/passerelle/job/wip%252F74797-connecteur-PDF-gabarit-de-xfdf-p/15/Coverage_20Report_20_28native_29/d_1f676f23cbeaa486_views_py.html
bdauvergne
commented
J'ai rajouté la couverture de ce code dans le test. J'ai rajouté la couverture de ce code dans le test.
|
||||
|
||||
class PageThumbnailView(ResourceView):
|
||||
model = models.Resource
|
||||
|
||||
def make_thumbnail(self, page):
|
||||
# produce a thumbnail and add
|
||||
# * red rectangle over field's rectangles
|
||||
# * enumerated field names
|
||||
thumbnail = page.thumbnail_png()
|
||||
image = PIL.Image.open(io.BytesIO(thumbnail))
|
||||
draw = PIL.ImageDraw.Draw(image, 'RGBA')
|
||||
for i, (field, area_rect) in enumerate(page.thumbnail_field_rects()):
|
||||
draw.rectangle(area_rect, fill=(255, 0, 0, 50))
|
||||
x = area_rect.x1
|
||||
y = (area_rect.y1 + area_rect.y2) / 2 - 5
|
||||
if field.widget_type == 'checkbox':
|
||||
tnoel
commented
i18n ici encore, un i18n ici encore, un `_('field')` qui manque (idéalement un `_(''field %d)` mais à toi de voir)
bdauvergne
commented
Avec la police non vectorielle par défaut de pillow je ne sais pas si on peut avoir de l'UTF-8 (je vois bien qu'on traduirait par "champ {i + 1}" mais je ne suis pas convaincu de l'utilité ici de traduire, ça reste un écran technique ou personne n'ira à part un admin fonctionnel. Avec la police non vectorielle par défaut de pillow je ne sais pas si on peut avoir de l'UTF-8 (je vois bien qu'on traduirait par "champ {i + 1}" mais je ne suis pas convaincu de l'utilité ici de traduire, ça reste un écran technique ou personne n'ira à part un admin fonctionnel.
|
||||
y -= 10
|
||||
draw.text((x, y), str(_('field %s') % (i + 1)), anchor='lb', fill=(0, 0, 0, 255))
|
||||
del draw
|
||||
output = io.BytesIO()
|
||||
image.save(output, 'PNG')
|
||||
return output.getvalue()
|
||||
|
||||
def get(self, request, page_number, **kwargs):
|
||||
with self.get_object().fill_form_file as fd:
|
||||
pdf_content = fd.read()
|
||||
|
||||
etag = hashlib.md5(pdf_content).hexdigest()
|
||||
if_none_match = request.headers.get('If-None-Match', '').split(',')
|
||||
if etag in if_none_match:
|
||||
# use browser cache
|
||||
response = HttpResponseNotModified()
|
||||
else:
|
||||
# produce the thumbnail
|
||||
pdf = PDF(pdf_content)
|
||||
try:
|
||||
page = pdf.page(int(page_number))
|
||||
except IndexError:
|
||||
raise Http404
|
||||
thumbnail_content = self.make_thumbnail(page)
|
||||
response = HttpResponse(thumbnail_content, content_type='image/png')
|
||||
response['ETag'] = etag
|
||||
response['Cache-Control'] = 'max-age=3600'
|
||||
return response
|
|
@ -407,3 +407,31 @@ ul.get-params li {
|
|||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* passerelle/apps/pdf/templates/pdf/field_mapping_edit.html */
|
||||
.pdf-fields-mapping-edit-form--page {
|
||||
display: flex;
|
||||
height: max-content;
|
||||
}
|
||||
|
||||
.pdf-fields-mapping-edit-form--fields {
|
||||
height: 1132px;
|
||||
overflow-y: scroll;
|
||||
> .widget textarea, > .widget input {
|
||||
width: 100%;
|
||||
}
|
||||
.widget:target {
|
||||
border: 2px dashed #FFAAAA;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.pdf-fields-mapping-edit-form--thumbnail {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.pdf-fields-mapping-edit-form--thumbnail, .pdf-fields-mapping-edit-form--fields {
|
||||
margin-right: 1em;
|
||||
flex: 1;
|
||||
}
|
||||
|
|
|
@ -15,8 +15,34 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django import forms
|
||||
from django.core import validators
|
||||
from django.core import exceptions, validators
|
||||
from django.template import Template, TemplateSyntaxError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class LDAPURLField(forms.URLField):
|
||||
default_validators = [validators.URLValidator(schemes=['ldap', 'ldaps'])]
|
||||
|
||||
|
||||
def validate_condition_template(condition_template):
|
||||
real_template = f'{{% if {condition_template} %}}OK{{% endif %}}'
|
||||
try:
|
||||
Template(real_template)
|
||||
except (TemplateSyntaxError, OverflowError) as e:
|
||||
raise exceptions.ValidationError(_('syntax error: %s') % e, code='syntax-error')
|
||||
|
||||
|
||||
class ConditionField(forms.CharField):
|
||||
default_validators = [validate_condition_template]
|
||||
|
||||
|
||||
def validate_template(template):
|
||||
try:
|
||||
Template(template)
|
||||
except (TemplateSyntaxError, OverflowError) as e:
|
||||
raise exceptions.ValidationError(_('syntax error: %s') % e, code='syntax-error')
|
||||
|
||||
|
||||
class TemplateField(forms.CharField):
|
||||
default_validators = [validate_template]
|
||||
widget = forms.Textarea
|
||||
|
|
|
@ -0,0 +1,249 @@
|
|||
# passerelle - uniform access to multiple data sources and services
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import functools
|
||||
import hashlib
|
||||
import io
|
||||
import subprocess
|
||||
import tempfile
|
||||
import typing
|
||||
|
||||
import pdfrw
|
||||
|
||||
|
||||
class Rect(typing.NamedTuple):
|
||||
x1: float
|
||||
y1: float
|
||||
x2: float
|
||||
y2: float
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Widget:
|
||||
page: 'Page' = dataclasses.field(compare=False, repr=False)
|
||||
name: str
|
||||
widget_type: str = dataclasses.field(compare=False)
|
||||
rect: Rect = dataclasses.field(compare=False)
|
||||
on_value: str = dataclasses.field(compare=False, default=pdfrw.PdfName.On)
|
||||
annotation: pdfrw.PdfDict = dataclasses.field(default=None, repr=False)
|
||||
fpeters
commented
Je ne suis vraiment pas à l'aise avec cette ligne (et un peu celles d'avant); le gain (?) ne vaut pour moi certainement pas l'introduction de cette partie qui pourra plus difficilement être maintenue; je préférerais vraiment que ça devienne du Python plus commun. ```
annotation: typing.Optional[pdfrw.PdfDict] = dataclasses.field(default=None, repr=False)
```
Je ne suis vraiment pas à l'aise avec cette ligne (et un peu celles d'avant); le gain (?) ne vaut pour moi certainement pas l'introduction de cette partie qui pourra plus difficilement être maintenue; je préférerais vraiment que ça devienne du Python plus commun.
bdauvergne
commented
Il y a déjà du code équivalent dans chrono et lingo donc non. Il y a déjà du code équivalent dans chrono et lingo donc non.
fpeters
commented
Vraiment 1/ je sais que j'ai déjà vu du code ainsi passer, 2/ j'ai vérifié dans chrono avant d'écrire mon commentaire et le code n'y a pas de ligne aussi incompréhensible. Vraiment 1/ je sais que j'ai déjà vu du code ainsi passer, 2/ j'ai vérifié dans chrono avant d'écrire mon commentaire et le code n'y a pas de ligne aussi incompréhensible.
bdauvergne
commented
Si tu peux me pointer les lignes de https://docs.python.org/3/library/dataclasses.html qu´on peut utiliser ou pas, ça m'aidera. ```
chrono/agendas/models.py:@dataclasses.dataclass(frozen=True)
chrono/agendas/models.py-class SharedCustodySlot:
chrono/agendas/models.py- guardian: Person = dataclasses.field(compare=False)
chrono/agendas/models.py- date: datetime.date
chrono/agendas/models.py- label: str = dataclasses.field(compare=False, default='')
```
Si tu peux me pointer les lignes de https://docs.python.org/3/library/dataclasses.html qu´on peut utiliser ou pas, ça m'aidera.
fpeters
commented
Tu peux éventuellement réagir en décidant de poser un commentaire explicatif, ou je ne sais quoi d'autre j'ai aucune idée du pourquoi/comment de cette ligne. Mais perso s'il faut une règle je dirais bien que l'import du module typing serait mon niveau de blocage. (inutile aussi de me pointer le ```
annotation: typing.Optional[pdfrw.PdfDict] = dataclasses.field(default=None, repr=False)
```
Tu peux éventuellement réagir en décidant de poser un commentaire explicatif, ou je ne sais quoi d'autre j'ai aucune idée du pourquoi/comment de cette ligne.
Mais perso s'il faut une règle je dirais bien que l'import du module typing serait mon niveau de blocage. (inutile aussi de me pointer le `from typing import List` dans lingo).
bdauvergne
commented
Si le probl Si le probl
fpeters
commented
(le message n'est pas passé) (le message n'est pas passé)
bdauvergne
commented
J'ai viré le typing.Optional. J'ai viré le typing.Optional.
|
||||
|
||||
@property
|
||||
def digest_id(self):
|
||||
if not self.name:
|
||||
return ''
|
||||
name_bytes = self.name.encode()
|
||||
digest_algo = hashlib.md5(name_bytes)
|
||||
digest = digest_algo.digest()
|
||||
b32_encoded = base64.b32encode(digest).decode()
|
||||
return b32_encoded.strip('=').upper()
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
if self.widget_type == 'text':
|
||||
if self.annotation[pdfrw.PdfName.V]:
|
||||
return self.annotation[pdfrw.PdfName.V].decode()
|
||||
return ''
|
||||
elif self.widget_type == 'checkbox':
|
||||
return self.annotation[pdfrw.PdfName.V] == self.on_value
|
||||
|
||||
def set(self, value):
|
||||
# allow rendering of values in Acrobat Reader
|
||||
self.page.pdf._pdf_reader.Root.AcroForm.update(pdfrw.PdfDict(NeedAppearances=pdfrw.PdfObject('true')))
|
||||
if self.widget_type == 'text':
|
||||
str_value = str(value)
|
||||
self.annotation.update(pdfrw.PdfDict(V=str_value, AS=str_value))
|
||||
elif self.widget_type == 'checkbox':
|
||||
bool_value = self.on_value if value else pdfrw.PdfName.Off
|
||||
self.annotation.update(pdfrw.PdfDict(V=bool_value, AS=bool_value))
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Page:
|
||||
pdf: object
|
||||
page_number: object
|
||||
|
||||
THUMBNAIL_DEFAULT_WIDTH = 800
|
||||
|
||||
@property
|
||||
def page(self):
|
||||
return self.pdf._pdf_reader.pages[self.page_number]
|
||||
|
||||
@property
|
||||
def fields(self):
|
||||
fields = []
|
||||
for annotation in self.page[pdfrw.PdfName.Annots] or ():
|
||||
if annotation[pdfrw.PdfName.Subtype] != pdfrw.PdfName.Widget:
|
||||
continue
|
||||
if not annotation[pdfrw.PdfName.T]:
|
||||
continue
|
||||
name = annotation[pdfrw.PdfName.T].decode()
|
||||
parent = annotation[pdfrw.PdfName.Parent]
|
||||
while parent and parent[pdfrw.PdfName.T]:
|
||||
name = f'{parent[pdfrw.PdfName.T].decode()}.{name}'
|
||||
parent = parent[pdfrw.PdfName.Parent]
|
||||
if not annotation[pdfrw.PdfName.FT]:
|
||||
continue
|
||||
pdf_field_type = annotation[pdfrw.PdfName.FT]
|
||||
pdf_field_flags = annotation[pdfrw.PdfName.Ff] or 0
|
||||
RADIO_FLAG = 2**16
|
||||
PUSH_BUTTON_FLAG = 2**17
|
||||
if (
|
||||
pdf_field_type == pdfrw.PdfName.Btn
|
||||
and not (pdf_field_flags & RADIO_FLAG)
|
||||
and not (pdf_field_flags & PUSH_BUTTON_FLAG)
|
||||
):
|
||||
widget_type = 'checkbox'
|
||||
elif pdf_field_type == pdfrw.PdfName.Tx:
|
||||
widget_type = 'text'
|
||||
else:
|
||||
continue
|
||||
on_value = None
|
||||
if widget_type == 'checkbox':
|
||||
try:
|
||||
on_values = list(annotation[pdfrw.PdfName.AP][pdfrw.PdfName.N].keys())
|
||||
except KeyError:
|
||||
on_value = pdfrw.PdfName.On
|
||||
else:
|
||||
if pdfrw.PdfName.Off in on_values:
|
||||
on_values.remove(pdfrw.PdfName.Off)
|
||||
on_value = on_values[0]
|
||||
fields.append(
|
||||
Widget(
|
||||
name=name,
|
||||
widget_type=widget_type,
|
||||
rect=Rect(*map(float, annotation[pdfrw.PdfName.Rect])),
|
||||
on_value=on_value,
|
||||
page=self,
|
||||
annotation=annotation,
|
||||
)
|
||||
)
|
||||
fields.sort(key=lambda field: (-field.rect[1], field.rect[0]))
|
||||
return fields
|
||||
|
||||
@property
|
||||
def media_box(self):
|
||||
return Rect(*map(float, self.page[pdfrw.PdfName.MediaBox]))
|
||||
|
||||
def thumbnail_png(self, width=None):
|
||||
width = width or self.THUMBNAIL_DEFAULT_WIDTH
|
||||
|
||||
fp = io.BytesIO(
|
||||
subprocess.check_output(
|
||||
[
|
||||
'pdftoppm',
|
||||
'-png',
|
||||
'-scale-to-x',
|
||||
str(width or '-1'),
|
||||
'-scale-to-y',
|
||||
'-1',
|
||||
'-f',
|
||||
str(self.page_number + 1),
|
||||
'-l',
|
||||
str(self.page_number + 1),
|
||||
'-',
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
input=self.pdf.content,
|
||||
)
|
||||
)
|
||||
return fp.getvalue()
|
||||
|
||||
def thumbnail_field_rects(self, width=None):
|
||||
'''Transform coordinates of fields to coordindates in thumbnail image.'''
|
||||
width = width or self.THUMBNAIL_DEFAULT_WIDTH
|
||||
media_box = self.media_box
|
||||
media_width = media_box.x2 - media_box.x1
|
||||
media_height = media_box.y2 - media_box.y1
|
||||
height = int(width / media_width * media_height)
|
||||
|
||||
for field in self.fields:
|
||||
field_rect = field.rect
|
||||
yield field, Rect(
|
||||
# PDF coordinates origin is in the bottom-left corner but img
|
||||
# tag origin is in the top-left corner
|
||||
x1=int((field_rect.x1 - media_box.x1) / media_width * width),
|
||||
y1=int((media_box.y2 - field_rect.y1) / media_height * height),
|
||||
x2=int((field_rect.x2 - media_box.x1) / media_width * width),
|
||||
y2=int((media_box.y2 - field_rect.y2) / media_height * height),
|
||||
)
|
||||
|
||||
def fields_image_map(self, width=None, sep='\n', id_prefix='', id_suffix=''):
|
||||
tags = []
|
||||
for field, area_rect in self.thumbnail_field_rects(width=width):
|
||||
coords = ','.join(map(str, area_rect))
|
||||
tags.append(
|
||||
f'<area shape="rect" '
|
||||
f'href="#{id_prefix}{field.digest_id}{id_suffix}" '
|
||||
f'coords="{coords}">'
|
||||
)
|
||||
return sep.join(tags)
|
||||
|
||||
|
||||
class PDF:
|
||||
def __init__(self, content):
|
||||
if hasattr(content, 'read'):
|
||||
content = content.read()
|
||||
self.content = content
|
||||
|
||||
@functools.cached_property
|
||||
def _pdf_reader(self):
|
||||
return pdfrw.PdfReader(fdata=self.content)
|
||||
|
||||
@property
|
||||
def number_of_pages(self):
|
||||
return len(self._pdf_reader.pages)
|
||||
|
||||
def page(self, page_number):
|
||||
return Page(pdf=self, page_number=page_number)
|
||||
|
||||
@property
|
||||
def pages(self):
|
||||
for i in range(self.number_of_pages):
|
||||
yield self.page(i)
|
||||
|
||||
def write(self, file_object, flatten=False):
|
||||
assert hasattr(file_object, 'write')
|
||||
if not flatten:
|
||||
pdfrw.PdfWriter().write(file_object, self._pdf_reader)
|
||||
else:
|
||||
fpeters
commented
Cette partie (write avec flatten à True) n'est pas testée du tout. (je préfèrerais qu'on n'introduise pas trop de code inutilisé). Cette partie (write avec flatten à True) n'est pas testée du tout. (je préfèrerais qu'on n'introduise pas trop de code inutilisé).
bdauvergne
commented
J'ai rajouté la couverture de ce code dans le test. J'ai rajouté la couverture de ce code dans le test.
|
||||
with io.BytesIO() as fd:
|
||||
pdfrw.PdfWriter().write(fd, self._pdf_reader)
|
||||
original_content = fd.getvalue()
|
||||
|
||||
with tempfile.NamedTemporaryFile() as output:
|
||||
try:
|
||||
subprocess.check_output(
|
||||
[
|
||||
'gs',
|
||||
'-dSAFER',
|
||||
'-dBATCH',
|
||||
'-dNOPAUSE',
|
||||
'-dNOCACHE',
|
||||
'-sDEVICE=pdfwrite',
|
||||
'-dPreserveAnnots=false',
|
||||
f'-sOutputFile={output.name}',
|
||||
'-',
|
||||
],
|
||||
stderr=subprocess.DEVNULL,
|
||||
input=original_content,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise Exception(f'gs error={e.returncode} output={e.output}')
|
||||
output.seek(0)
|
||||
new_content = output.read()
|
||||
file_object.write(new_content)
|
|
@ -20,7 +20,7 @@ Disable autoescaping.
|
|||
'''
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.template import TemplateSyntaxError
|
||||
from django.template import Context, Template, TemplateSyntaxError
|
||||
from django.template.backends.django import DjangoTemplates
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
@ -46,3 +46,15 @@ def validate_template(template_string):
|
|||
make_template(template_string)
|
||||
except TemplateSyntaxError as e:
|
||||
raise ValidationError(_('Invalid template: %s') % e)
|
||||
|
||||
|
||||
def evaluate_condition(condition_template, context_dict):
|
||||
template = Template(f'{{% if {condition_template} %}}OK{{% endif %}}')
|
||||
context = Context(context_dict)
|
||||
return template.render(context) == 'OK'
|
||||
|
||||
|
||||
def evaluate_template(template, context_dict):
|
||||
template = Template(template)
|
||||
context = Context(context_dict)
|
||||
return template.render(context)
|
||||
|
|
|
@ -17,17 +17,17 @@
|
|||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files import File
|
||||
from django.urls import reverse
|
||||
from django.core.files.base import ContentFile
|
||||
from pdfrw import PdfReader
|
||||
|
||||
from passerelle.apps.pdf.models import Resource
|
||||
from passerelle.utils.pdf import PDF
|
||||
from tests.test_manager import login
|
||||
from tests.utils import generic_endpoint_url, setup_access_rights
|
||||
|
||||
|
@ -139,145 +139,6 @@ def test_pdf_real_pdftk_assemble(app, pdf, settings):
|
|||
assert PdfReader(fdata=resp.content).numPages == 2
|
||||
|
||||
|
||||
@mock.patch('subprocess.check_output')
|
||||
def test_pdf_fill_form(mocked_check_output, app, pdf):
|
||||
endpoint = generic_endpoint_url('pdf', 'fill-form', slug=pdf.slug)
|
||||
|
||||
def check_xml(args, **kwargs):
|
||||
# check XML FDF file
|
||||
xfdf = ET.parse(args[3]).getroot()
|
||||
assert xfdf.tag == '{http://ns.adobe.com/xfdf/}xfdf'
|
||||
assert xfdf.find('{http://ns.adobe.com/xfdf/}f').attrib['href'].endswith('.pdf')
|
||||
field = xfdf.find('{http://ns.adobe.com/xfdf/}fields').find('{http://ns.adobe.com/xfdf/}field')
|
||||
assert field.attrib['name'] == 'fname'
|
||||
assert field.find('{http://ns.adobe.com/xfdf/}value').text == 'John'
|
||||
|
||||
payload = {
|
||||
'filename': 'foo.pdf',
|
||||
'fields/fname': 'John',
|
||||
'input-form': {'content': acroform_b64content},
|
||||
}
|
||||
mocked_check_output.side_effect = check_xml
|
||||
resp = app.post_json(endpoint, params=payload, status=200)
|
||||
assert resp.headers['content-type'] == 'application/pdf'
|
||||
assert resp.headers['content-disposition'] == 'attachment; filename="foo.pdf"'
|
||||
assert mocked_check_output.call_count == 1
|
||||
pdftk_call = mocked_check_output.call_args.args[0]
|
||||
assert len(pdftk_call) == 6
|
||||
assert pdftk_call[0] == '/usr/bin/pdftk'
|
||||
assert pdftk_call[1].endswith('/input-form.pdf')
|
||||
assert pdftk_call[2] == 'fill_form'
|
||||
assert pdftk_call[3].endswith('/fields.xfdf')
|
||||
assert pdftk_call[4] == 'output'
|
||||
assert pdftk_call[5] == '-'
|
||||
assert mocked_check_output.call_args.kwargs['timeout'] == 20
|
||||
|
||||
pdf.fill_form_file = File(BytesIO(acroform_content), 'default.pdf')
|
||||
pdf.save()
|
||||
payload = {
|
||||
'filename': 'bar.pdf',
|
||||
'fields/fname': 'John',
|
||||
}
|
||||
mocked_check_output.reset_mock()
|
||||
resp = app.post_json(endpoint, params=payload, status=200)
|
||||
assert resp.headers['content-type'] == 'application/pdf'
|
||||
assert resp.headers['content-disposition'] == 'attachment; filename="bar.pdf"'
|
||||
assert mocked_check_output.call_count == 1
|
||||
pdftk_call = mocked_check_output.call_args.args[0]
|
||||
assert len(pdftk_call) == 6
|
||||
assert pdftk_call[0] == '/usr/bin/pdftk'
|
||||
assert pdftk_call[1].endswith('media/pdf/test/default.pdf')
|
||||
assert pdftk_call[2] == 'fill_form'
|
||||
assert pdftk_call[3].endswith('/fields.xfdf')
|
||||
assert pdftk_call[4] == 'output'
|
||||
assert pdftk_call[5] == '-'
|
||||
assert mocked_check_output.call_args.kwargs['timeout'] == 20
|
||||
|
||||
# pdftk errors (faked)
|
||||
payload = {
|
||||
'filename': 'foo.pdf',
|
||||
'fields/fname': 'Bill',
|
||||
'input-form': {'content': acroform_b64content},
|
||||
}
|
||||
mocked_check_output.reset_mock()
|
||||
mocked_check_output.side_effect = subprocess.TimeoutExpired(cmd=[], timeout=20)
|
||||
resp = app.post_json(endpoint, params=payload, status=200)
|
||||
assert mocked_check_output.call_count == 1
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'].startswith('pdftk timed out after 20 seconds')
|
||||
|
||||
mocked_check_output.reset_mock()
|
||||
mocked_check_output.side_effect = subprocess.CalledProcessError(cmd=[], returncode=42, output='ooops')
|
||||
resp = app.post_json(endpoint, params=payload, status=200)
|
||||
assert mocked_check_output.call_count == 1
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'].startswith('pdftk returned non-zero exit status 42')
|
||||
assert 'ooops' in resp.json['err_desc']
|
||||
|
||||
# bad calls errors
|
||||
resp = app.post(endpoint, status=400)
|
||||
assert resp.headers['content-type'].startswith('application/json')
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'].startswith('could not decode body to json')
|
||||
|
||||
payload = {}
|
||||
resp = app.post_json(endpoint, params=payload, status=400)
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'] == "'filename' is a required property"
|
||||
|
||||
payload = {'filename': 'out.pdf'}
|
||||
resp = app.post_json(endpoint, params=payload, status=400)
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'] == "'fields' is a required property"
|
||||
|
||||
payload = {'filename': 'out.pdf', 'fields': 'not-a-dict'}
|
||||
resp = app.post_json(endpoint, params=payload, status=400)
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'] == "fields: 'not-a-dict' is not of type 'object'"
|
||||
|
||||
pdf.fill_form_file = None # no default PDF form
|
||||
pdf.save()
|
||||
payload = {
|
||||
'filename': 'bar.pdf',
|
||||
'fields/fname': 'Alice',
|
||||
}
|
||||
resp = app.post_json(endpoint, params=payload, status=400)
|
||||
assert resp.json['err'] == 1
|
||||
assert resp.json['err_desc'] == "missing or bad 'input-form' property"
|
||||
|
||||
resp = app.get(endpoint, status=405)
|
||||
|
||||
|
||||
def test_pdf_real_pdftk_fillform(admin_user, app, pdf, settings):
|
||||
if not os.path.exists(settings.PDFTK_PATH):
|
||||
pytest.skip('pdftk (%s) not found' % settings.PDFTK_PATH)
|
||||
|
||||
endpoint = generic_endpoint_url('pdf', 'fill-form', slug=pdf.slug)
|
||||
payload = {
|
||||
'filename': 'filled.pdf',
|
||||
'fields/fname': 'ThisIsMyFirstName',
|
||||
'input-form': {'content': acroform_b64content},
|
||||
}
|
||||
resp = app.post_json(endpoint, params=payload, status=200)
|
||||
assert resp.headers['content-type'] == 'application/pdf'
|
||||
assert resp.headers['content-disposition'] == 'attachment; filename="filled.pdf"'
|
||||
assert PdfReader(fdata=resp.content).numPages == 1
|
||||
assert resp.content[:5] == b'%PDF-'
|
||||
# TODO: found an easy way to verify 'ThisIsMyFirstName' in resp.content
|
||||
|
||||
# dump fields in manager view
|
||||
pdf.fill_form_file = File(BytesIO(acroform_content), 'pdf-form.pdf')
|
||||
pdf.save()
|
||||
manage_url = reverse('view-connector', kwargs={'connector': 'pdf', 'slug': pdf.slug})
|
||||
resp = app.get(manage_url)
|
||||
assert 'panel-dumpfields' not in resp.text
|
||||
assert '<b>fields/fname</b>' not in resp.text
|
||||
app = login(app)
|
||||
resp = app.get(manage_url)
|
||||
assert 'panel-dumpfields' in resp.text
|
||||
assert '<b>fields/fname</b>' in resp.text
|
||||
|
||||
|
||||
def test_pdf_validator(pdf):
|
||||
pdf.fill_form_file = File(BytesIO(pdf_content), 'default.pdf')
|
||||
pdf.save()
|
||||
|
@ -291,3 +152,64 @@ def test_pdf_validator(pdf):
|
|||
pdf.save()
|
||||
with pytest.raises(ValidationError):
|
||||
pdf.full_clean()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cerfa_content():
|
||||
with open('tests/data/cerfa_10072-02.pdf', 'rb') as fd:
|
||||
return fd.read()
|
||||
|
||||
|
||||
def test_fill_form_no_pdf(app, admin_user, pdf):
|
||||
resp = app.post_json('/pdf/test/fill-form/', params={'a': 1})
|
||||
assert resp.json == {
|
||||
'data': None,
|
||||
'err': 1,
|
||||
'err_class': 'passerelle.utils.jsonresponse.APIError',
|
||||
'err_desc': 'not PDF file configured',
|
||||
}
|
||||
|
||||
|
||||
def test_fill_form_no_fields_mapping(app, admin_user, pdf, cerfa_content):
|
||||
pdf.fill_form_file.save('form.pdf', ContentFile(cerfa_content))
|
||||
resp = app.post_json('/pdf/test/fill-form/', params={'a': 1})
|
||||
assert resp.json == {
|
||||
'data': None,
|
||||
'err': 1,
|
||||
'err_class': 'passerelle.utils.jsonresponse.APIError',
|
||||
'err_desc': 'no fields mapping configured',
|
||||
}
|
||||
|
||||
|
||||
def test_fill_form_ok(app, admin_user, pdf, cerfa_content):
|
||||
pdf.fill_form_file.save('form.pdf', ContentFile(cerfa_content))
|
||||
app = login(app)
|
||||
resp = app.get('/pdf/test/')
|
||||
resp = resp.click('Fill form: Edit fields mapping')
|
||||
img_tags = resp.pyquery('img')
|
||||
image_resp = app.get(img_tags[0].attrib['src'])
|
||||
assert b'PNG' in image_resp.content
|
||||
pdf_ = PDF(cerfa_content)
|
||||
page = pdf_.page(0)
|
||||
checkbox_field = [field for field in page.fields if field.widget_type == 'checkbox'][0]
|
||||
text_field = [field for field in page.fields if field.widget_type == 'text'][0]
|
||||
assert checkbox_field.value is False
|
||||
assert text_field.value == ''
|
||||
resp.form.set(f'field_{checkbox_field.digest_id}', 'testme == "a"')
|
||||
resp.form.set(f'field_{text_field.digest_id}', '{{ prenom }} {{ nom }}')
|
||||
resp.form.submit().follow()
|
||||
|
||||
resp = app.post_json('/pdf/test/fill-form/', params={'testme': 'a', 'prenom': 'Jean', 'nom': 'Dupont'})
|
||||
pdf_ = PDF(resp.content)
|
||||
page = pdf_.page(0)
|
||||
checkbox_field = [field for field in page.fields if field.widget_type == 'checkbox'][0]
|
||||
text_field = [field for field in page.fields if field.widget_type == 'text'][0]
|
||||
assert checkbox_field.value is True
|
||||
assert text_field.value == 'Jean Dupont'
|
||||
|
||||
resp = app.post_json(
|
||||
'/pdf/test/fill-form/?flatten=1', params={'testme': 'a', 'prenom': 'Jean', 'nom': 'Dupont'}
|
||||
)
|
||||
pdf_ = PDF(resp.content)
|
||||
page = pdf_.page(0)
|
||||
assert not page.fields
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
# passerelle - uniform access to multiple data sources and services
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from passerelle.utils.forms import ConditionField, TemplateField
|
||||
|
||||
|
||||
def test_condition_field():
|
||||
field = ConditionField()
|
||||
with pytest.raises(ValidationError):
|
||||
field.clean('x ==')
|
||||
assert field.clean('x == 1') == 'x == 1'
|
||||
|
||||
|
||||
def test_template_field():
|
||||
field = TemplateField()
|
||||
with pytest.raises(ValidationError):
|
||||
field.clean('{% if foo %}bar')
|
||||
assert field.clean('{% if foo %}bar{% endif %}') == '{% if foo %}bar{% endif %}'
|
|
@ -0,0 +1,95 @@
|
|||
# passerelle - uniform access to multiple data sources and services
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import io
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from passerelle.utils.pdf import PDF
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pdf():
|
||||
with open('tests/data/cerfa_10072-02.pdf', 'rb') as fd:
|
||||
return PDF(content=fd)
|
||||
|
||||
|
||||
def test_number_of_pages(pdf):
|
||||
assert pdf.number_of_pages == 5
|
||||
|
||||
|
||||
def test_page(pdf):
|
||||
assert pdf.page(0) is not None
|
||||
assert pdf.page(0).media_box == (0, 0, 595.32, 841.92)
|
||||
|
||||
|
||||
def test_page_len_fields(pdf):
|
||||
assert len(list(pdf.page(0).fields)) == 53
|
||||
|
||||
|
||||
def test_page_fields(pdf):
|
||||
page = pdf.page(0)
|
||||
field = page.fields[0]
|
||||
assert field.name == 'topmostSubform[0].Page1[0].Case_à_cocher1[2]'
|
||||
assert field.widget_type == 'checkbox'
|
||||
assert field.rect == (550.292, 691.02, 558.292, 699.02)
|
||||
assert all(field.digest_id == field.digest_id.upper() for field in page.fields)
|
||||
assert all(len(field.digest_id) >= 25 for field in page.fields)
|
||||
# digests are unique
|
||||
assert len(page.fields) == len({field.digest_id for field in page.fields})
|
||||
assert page.fields[0] != page.fields[1]
|
||||
assert page.fields[0] == page.fields[0]
|
||||
|
||||
|
||||
def test_thumbnail_png(pdf):
|
||||
png = pdf.page(0).thumbnail_png()
|
||||
assert png[:10] == b'\x89PNG\r\n\x1a\n\x00\x00'
|
||||
image = Image.open(io.BytesIO(png))
|
||||
assert (image.width, image.height) == (800, 1132)
|
||||
|
||||
|
||||
def test_fields_image_map(pdf):
|
||||
image_map = pdf.page(0).fields_image_map()
|
||||
assert len(list(re.findall('area', image_map))) == 53
|
||||
|
||||
|
||||
def test_field_set(pdf):
|
||||
for field in pdf.page(0).fields:
|
||||
if field.name == 'topmostSubform[0].Page1[0].Champ_de_texte1[0]':
|
||||
field.set('coucou')
|
||||
elif field.name == 'topmostSubform[0].Page1[0].Case_à_cocher1[0]':
|
||||
field.set(True)
|
||||
with io.BytesIO() as fd:
|
||||
pdf.write(fd)
|
||||
new_pdf = PDF(fd.getvalue())
|
||||
new_page = new_pdf.page(0)
|
||||
check = set()
|
||||
for field in new_page.fields:
|
||||
if field.name == 'topmostSubform[0].Page1[0].Champ_de_texte1[0]':
|
||||
check.add(1)
|
||||
assert field.value == 'coucou'
|
||||
elif field.name == 'topmostSubform[0].Page1[0].Case_à_cocher1[0]':
|
||||
check.add(2)
|
||||
assert field.value is True
|
||||
elif field.widget_type == 'checkbox':
|
||||
assert field.value is False
|
||||
elif field.widget_type == 'text':
|
||||
assert field.value == ''
|
||||
else:
|
||||
raise NotImplementedError
|
||||
assert check == {1, 2}
|
|
@ -0,0 +1,26 @@
|
|||
# passerelle - uniform access to multiple data sources and services
|
||||
# Copyright (C) 2023 Entr'ouvert
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from passerelle.utils.templates import evaluate_condition, evaluate_template
|
||||
|
||||
|
||||
def test_evaluate_condition():
|
||||
assert evaluate_condition('x == 1', {'x': 1}) is True
|
||||
assert evaluate_condition('x == 0', {'x': 1}) is False
|
||||
|
||||
|
||||
def test_evaluate_template():
|
||||
assert evaluate_template('{% if foo %}bar{% endif %}', {'foo': True}) == 'bar'
|
Pépin de i18n ici, il faut qu'on ait au moins la traduction de _('field')
il faut que ça corresponde à ce qu'il y a dans la miniature du PDF annotée avec pillow donc non.