import shutil import subprocess import sys from pathlib import Path import pikepdf import pytest from PIL import Image, ImageChops, ImageDraw from reportlab.lib.pagesizes import letter from reportlab.pdfgen.canvas import Canvas sys.path.insert(0, str(Path(__file__).parents[1] / 'docassemble_base')) from docassemble.base.pdftk import fill_template, prepare_accessible_flatten from docassemble.base.config import daconfig @pytest.mark.parametrize('width,height', [(120, 40), (40, 120)]) def test_signature_padding_does_not_render_a_colored_border(tmp_path, monkeypatch, width, height): template = tmp_path / 'signature.pdf' canvas = Canvas(str(template), pagesize=letter) canvas.drawString(36, 740, 'Signature') canvas.acroForm.textfield(name='signature', x=36, y=500, width=width, height=height) canvas.save() with pikepdf.Pdf.open(str(template), allow_overwriting_input=True) as pdf: field = pdf.Root.AcroForm.Fields[0] field.FT = pikepdf.Name('/Sig') for key in ['/AP', '/MK', '/DA', '/BS', '/V', '/DV', '/MaxLen']: if key in field: del field[key] pdf.save(template) # Opaque white signature images must retain their white background and # should render only black, white, and gray when padded to fit a PDF field. signature = tmp_path / 'signature.png' image = Image.new('RGB', (192, 96), 'white') ImageDraw.Draw(image).line([(20, 76), (80, 20), (172, 76)], fill='black', width=4) image.save(signature) original_run = subprocess.run def run_without_imagemagick(args, **kwargs): if args[0] == daconfig.get('imagemagick', 'convert'): # Use the fixture directly to keep the test independent of # ImageMagick while exercising PDF embedding and page rendering. shutil.copyfile(args[1], args[-1]) return subprocess.CompletedProcess(args, 0) return original_run(args, **kwargs) monkeypatch.setattr(subprocess, 'run', run_without_imagemagick) output = fill_template( str(template), images=[('signature', {'fullpath': str(signature)})], editable=True, ) try: # Check the encoded RGB values too: a viewer may expose otherwise # invisible colors while interpolating the image and its alpha mask. with pikepdf.Pdf.open(output) as pdf: for _, form in pdf.pages[0].Resources.XObject.items(): for _, image_object in form.Resources.XObject.items(): embedded = pikepdf.PdfImage(image_object).as_pil_image().convert('RGB') red, green, blue = embedded.split() assert ImageChops.difference(red, green).getbbox() is None assert ImageChops.difference(red, blue).getbbox() is None prefix = tmp_path / 'rendered' subprocess.run( ['pdftoppm', '-r', '144', '-singlefile', '-png', output, str(prefix)], check=True, capture_output=True, ) with Image.open(prefix.with_suffix('.png')) as rendered: assert all(r == g == b for r, g, b in rendered.convert('RGB').getdata()) finally: Path(output).unlink(missing_ok=True) def make_form(filename): canvas = Canvas(str(filename), pagesize=letter) canvas.drawString(36, 580, 'Comments') canvas.acroForm.textfield( name='comments', x=36, y=500, width=180, height=60, fieldFlags='multiline', fontName='Helvetica', fontSize=10, ) canvas.acroForm.checkbox(name='agree', x=36, y=450, buttonStyle='check') canvas.acroForm.checkbox(name='decline', x=36, y=420, buttonStyle='check') canvas.save() def tag_widget(filename, field_name, struct_parent=42, tooltip=None): """Give one reportlab widget the minimal valid Form/OBJR structure anchor.""" with pikepdf.Pdf.open(str(filename), allow_overwriting_input=True) as pdf: widget = next( annot for page in pdf.pages for annot in page.Annots if str(annot.get('/T')) == field_name ) page = next(page for page in pdf.pages if widget in page.Annots) root = pdf.make_indirect(pikepdf.Dictionary(Type=pikepdf.Name('/StructTreeRoot'))) element = pdf.make_indirect(pikepdf.Dictionary( Type=pikepdf.Name('/StructElem'), S=pikepdf.Name('/Form'), P=root, )) element.K = pikepdf.Dictionary( Type=pikepdf.Name('/OBJR'), Pg=page.obj, Obj=widget, ) root.K = pikepdf.Array([element]) root.ParentTree = pikepdf.Dictionary( Nums=pikepdf.Array([struct_parent, element]) ) root.ParentTreeNextKey = struct_parent + 1 widget.StructParent = struct_parent if tooltip: widget.TU = pikepdf.String(tooltip) pdf.Root.StructTreeRoot = root pdf.Root.MarkInfo = pikepdf.Dictionary(Marked=True) pdf.save() def printfield_element(pdf): return pdf.Root.StructTreeRoot.K[0] def form_xobjects(pdf): for page in pdf.pages: for xobject in page.Resources.get('/XObject', {}).values(): yield xobject.read_bytes() def test_flattened_multiline_field_uses_wrapping(tmp_path): template = tmp_path / 'template.pdf' make_form(template) output = fill_template( str(template), data_strings=[('comments', 'This is a long value that should wrap automatically across multiple lines.')], data_names={}, editable=False, images=[], use_pdftk=False, ) try: with pikepdf.Pdf.open(output) as pdf: assert not any( annot.get('/Subtype') == '/Widget' for page in pdf.pages for annot in page.get('/Annots', []) ) acroform = pdf.Root.get('/AcroForm') assert acroform is None or not acroform.get('/Fields') content = subprocess.check_output(['pdftotext', '-layout', output, '-']).decode() assert 'This is a long value that should wrap' in content assert 'automatically across multiple lines.' in content finally: Path(output).unlink(missing_ok=True) def test_flattened_checkbox_labels_follow_checked_state(tmp_path): template = tmp_path / 'template.pdf' make_form(template) output = fill_template( str(template), data_strings=[('agree', 'Yes'), ('decline', 'Off')], data_names={}, editable=False, images=[], use_pdftk=False, flattened_checkbox_label='[X]', flattened_checkbox_unselected_label='[ ]', ) try: with pikepdf.Pdf.open(output) as pdf: content = b'\n'.join(form_xobjects(pdf)) assert b'[X]' in content assert b'[ ]' in content finally: Path(output).unlink(missing_ok=True) def test_flattened_checkbox_actual_text_supports_unicode(tmp_path): template = tmp_path / 'template.pdf' make_form(template) output = fill_template( str(template), data_strings=[('agree', 'Yes'), ('decline', 'Off')], data_names={}, editable=False, images=[], flattened_checkbox_label='case à cocher, cochée', flattened_checkbox_unselected_label='case à cocher, non cochée', ) try: content = subprocess.check_output(['pdftotext', '-layout', output, '-']).decode() assert 'case à cocher, cochée' in content assert 'case à cocher, non cochée' in content finally: Path(output).unlink(missing_ok=True) def test_tagged_checkbox_becomes_printfield_on_the_painted_xobject(tmp_path): template = tmp_path / 'template.pdf' make_form(template) tag_widget(template, 'agree', tooltip='I agree to the terms') output = fill_template( str(template), data_strings=[('comments', 'Two lines\nof accessible text'), ('agree', 'Yes'), ('decline', 'Off')], data_names={}, editable=False, images=[], ) try: with pikepdf.Pdf.open(output) as pdf: element = printfield_element(pdf) appearance = element.K.Stm assert element.A.O == '/PrintField' assert element.A.Role == '/cb' assert element.A.get('/checked') == '/on' assert str(element.A.Desc) == 'I agree to the terms' assert appearance.get('/StructParents') == 42 assert appearance.get('/StructParent') is None assert element.K.Type == '/MCR' assert element.K.MCID == 0 assert element.K.Stm.objgen == appearance.objgen assert element.K.get('/Obj') is None assert pdf.Root.StructTreeRoot.ParentTree.Nums[1][0].objgen == element.objgen assert b'/MCID 0' in appearance.read_bytes() assert any( item.objgen == appearance.objgen for item in pdf.pages[0].Resources.XObject.values() ) assert not any( annot.get('/Subtype') == '/Widget' for page in pdf.pages for annot in page.get('/Annots', []) ) finally: Path(output).unlink(missing_ok=True) def test_tagged_unchecked_checkbox_uses_off_state_without_fallback_text(tmp_path): template = tmp_path / 'template.pdf' make_form(template) # Some real court forms draw the empty box in the page content and provide # only an on-state appearance for the widget. with pikepdf.Pdf.open(template, allow_overwriting_input=True) as pdf: agree = next(annot for annot in pdf.pages[0].Annots if str(annot.get('/T')) == 'agree') del agree.AP.N[pikepdf.Name('/Off')] pdf.save() tag_widget(template, 'agree') output = fill_template( str(template), data_strings=[('agree', 'Off'), ('decline', 'Off')], data_names={}, editable=False, images=[], ) try: with pikepdf.Pdf.open(output) as pdf: assert printfield_element(pdf).A.get('/checked') == '/off' content = subprocess.check_output(['pdftotext', output, '-']).decode() # Only the untagged second checkbox needs text fallback. assert content.count('checkbox, unchecked') == 1 finally: Path(output).unlink(missing_ok=True) def test_shared_appearance_xobjects_are_given_separate_structure_identity(tmp_path): template = tmp_path / 'template.pdf' make_form(template) with pikepdf.Pdf.open(template, allow_overwriting_input=True) as pdf: widgets = [ annot for annot in pdf.pages[0].Annots if str(annot.get('/T')) in ('agree', 'decline') ] for widget in widgets: widget.AS = pikepdf.Name('/Yes') widgets[1].AP.N[pikepdf.Name('/Yes')] = widgets[0].AP.N[pikepdf.Name('/Yes')] root = pdf.make_indirect(pikepdf.Dictionary(Type=pikepdf.Name('/StructTreeRoot'))) elements = [] numbers = pikepdf.Array() for index, widget in enumerate(widgets, start=42): element = pdf.make_indirect(pikepdf.Dictionary( Type=pikepdf.Name('/StructElem'), S=pikepdf.Name('/Form'), P=root, )) element.K = pikepdf.Dictionary( Type=pikepdf.Name('/OBJR'), Pg=pdf.pages[0].obj, Obj=widget ) widget.StructParent = index elements.append(element) numbers.extend([index, element]) root.K = pikepdf.Array(elements) root.ParentTree = pikepdf.Dictionary(Nums=numbers) pdf.Root.StructTreeRoot = root prepare_accessible_flatten(pdf) appearances = [widget.AP.N[widget.AS] for widget in widgets] assert appearances[0].objgen != appearances[1].objgen assert [appearance.StructParents for appearance in appearances] == [42, 43] assert [element.K.Stm.objgen for element in elements] == [ appearance.objgen for appearance in appearances ] assert [element.K.MCID for element in elements] == [0, 0] assert [root.ParentTree.Nums[index][0].objgen for index in (1, 3)] == [ element.objgen for element in elements ] def test_pdftk_flatten_preserves_non_widget_annotations(tmp_path): template = tmp_path / 'template.pdf' canvas = Canvas(str(template), pagesize=letter) canvas.drawString(36, 700, 'Website') canvas.linkURL('https://example.com', (36, 695, 100, 715), relative=0) canvas.acroForm.checkbox(name='agree', x=36, y=650, buttonStyle='check') canvas.save() output = fill_template( str(template), data_strings=[('agree', 'Yes')], data_names={}, editable=False, images=[], ) try: with pikepdf.Pdf.open(output) as pdf: annotations = list(pdf.pages[0].get('/Annots', [])) assert [annot.get('/Subtype') for annot in annotations] == ['/Link'] assert str(annotations[0].A.URI) == 'https://example.com' finally: Path(output).unlink(missing_ok=True) def make_button_form(filename): """A form with a radio group and a checkbox whose export value is /On.""" canvas = Canvas(str(filename), pagesize=letter) canvas.drawString(36, 700, 'Choices') canvas.acroForm.checkbox(name='onbox', x=36, y=650, buttonStyle='check') canvas.acroForm.radio(name='color', value='red', selected=True, x=36, y=600, buttonStyle='circle') canvas.acroForm.radio(name='color', value='green', selected=False, x=66, y=600, buttonStyle='circle') canvas.acroForm.radio(name='color', value='blue', selected=False, x=96, y=600, buttonStyle='circle') canvas.save() with pikepdf.Pdf.open(str(filename), allow_overwriting_input=True) as pdf: for page in pdf.pages: for annot in page.Annots: if annot.get('/T') is not None and str(annot.T) == 'onbox': states = annot.AP.N states[pikepdf.Name('/On')] = states[pikepdf.Name('/Yes')] del states[pikepdf.Name('/Yes')] # The default appearance an authoring tool gives a checkbox names # the font the check mark is drawn in, which cannot render a label. annot.DA = pikepdf.String('/ZaDb 0 Tf 0 g') pdf.save() def flattened_content(filename): with pikepdf.Pdf.open(filename) as pdf: return b'\n'.join(form_xobjects(pdf)) def test_flattened_radio_group_labels_only_the_selected_option(tmp_path): template = tmp_path / 'template.pdf' make_button_form(template) output = fill_template( str(template), data_strings=[('color', 'green'), ('onbox', 'Off')], data_names={}, editable=False, images=[], use_pdftk=False, flattened_checkbox_label='[X]', flattened_checkbox_unselected_label='[ ]', ) try: content = subprocess.check_output(['pdftotext', '-layout', output, '-']).decode() assert content.count('radio button, selected') == 1 assert content.count('radio button, unselected') == 2 assert content.count('[ ]') == 1 finally: Path(output).unlink(missing_ok=True) def test_flattened_checkbox_with_on_export_value_reads_as_unchecked(tmp_path): template = tmp_path / 'template.pdf' make_button_form(template) output = fill_template( str(template), data_strings=[('onbox', 'No'), ('color', 'Off')], data_names={}, editable=False, images=[], use_pdftk=False, ) try: content = flattened_content(output) assert b'checkbox, unchecked' in content assert b'checkbox, checked' not in content # ZapfDingbats cannot draw the label, so the widget's own default appearance # must not be what the label is rendered with. assert b'/ZaDb' not in content finally: Path(output).unlink(missing_ok=True) def test_editable_output_asks_the_viewer_to_regenerate_appearances(tmp_path): template = tmp_path / 'template.pdf' make_form(template) output = fill_template( str(template), data_strings=[('comments', 'Ünïcødé — beyond what qpdf can render')], data_names={}, editable=True, images=[], use_pdftk=False, ) try: with pikepdf.Pdf.open(output) as pdf: assert pdf.Root.AcroForm.get('/NeedAppearances') is True finally: Path(output).unlink(missing_ok=True) def test_flattened_checkbox_label_is_invisible_and_does_not_need_to_fit(tmp_path): template = tmp_path / 'template.pdf' canvas = Canvas(str(template), pagesize=letter) canvas.drawString(36, 700, 'Small') canvas.acroForm.checkbox(name='tiny', x=36, y=650, size=9, buttonStyle='check') canvas.save() output = fill_template( str(template), data_strings=[('tiny', 'Yes')], data_names={}, editable=False, images=[], use_pdftk=False, ) try: content = flattened_content(output) assert b'checkbox, checked' in content assert b'3 Tr' in content finally: Path(output).unlink(missing_ok=True)