Ctrl K
ComunidadGuías…

tests/test_pdftk.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)