diff --git a/CHANGELOG.md b/CHANGELOG.md index 2610305e..d98f0cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.6.13 + +### Fixes +- **Render filled PDF form fields**: `convert_pdf_to_image` now calls `init_forms()` so AcroForm/XFA field values (text typed into fillable fields) are painted into the rendered page image instead of being silently dropped. + ## 1.6.12 ### Fixes diff --git a/sample-docs/form-field.pdf b/sample-docs/form-field.pdf new file mode 100644 index 00000000..f4d45f22 Binary files /dev/null and b/sample-docs/form-field.pdf differ diff --git a/test_unstructured_inference/inference/test_pdf_image_forms.py b/test_unstructured_inference/inference/test_pdf_image_forms.py new file mode 100644 index 00000000..41be6939 --- /dev/null +++ b/test_unstructured_inference/inference/test_pdf_image_forms.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import numpy as np +import pypdfium2 as pdfium + +from unstructured_inference.inference import pdf_image + +# sample-docs/form-field.pdf is a 1-page PDF with an empty content stream and a single +# filled text form field. The field value ("FORMVALUE777") is drawn only by the widget +# annotation's appearance stream, so it renders only when the form-fill environment is +# initialized (init_forms). Geometry below mirrors the fixture's widget rectangle. +FORM_PDF = "sample-docs/form-field.pdf" +PAGE_WIDTH, PAGE_HEIGHT = 612, 792 +FIELD_RECT = (40, 700, 320, 724) # x1, y1, x2, y2 in PDF user space (origin bottom-left) +RENDER_DPI = 200 + + +def _field_region_dark_pixels(img) -> int: + """Count dark pixels inside the form field's rectangle in the rendered image.""" + gray = img.convert("L") + scale_x = gray.width / PAGE_WIDTH + scale_y = gray.height / PAGE_HEIGHT + x0, y0, x1, y1 = FIELD_RECT + # PDF user space is bottom-up; image space is top-down. + box = ( + int(x0 * scale_x), + int((PAGE_HEIGHT - y1) * scale_y), + int(x1 * scale_x), + int((PAGE_HEIGHT - y0) * scale_y), + ) + crop = np.array(gray.crop(box)) + return int(np.count_nonzero(crop < 128)) + + +def test_convert_pdf_to_image_renders_acroform_field_value(): + """Filled form-field values are painted into the rendered page image.""" + img = pdf_image.convert_pdf_to_image(filename=FORM_PDF, dpi=RENDER_DPI)[0] + + assert _field_region_dark_pixels(img) > 100, "Expected the form field value to be rendered" + + +def test_convert_pdf_to_image_drops_form_field_without_init_forms(monkeypatch): + """Control: without init_forms() the widget appearance is not painted. + + Patching init_forms() to a no-op reproduces the pre-fix behavior and proves the + rendered field value in the test above comes specifically from initializing the + form-fill environment, not from the page content stream (which is empty here). + """ + monkeypatch.setattr(pdfium.PdfDocument, "init_forms", lambda self, *a, **k: None) + img = pdf_image.convert_pdf_to_image(filename=FORM_PDF, dpi=RENDER_DPI)[0] + + assert _field_region_dark_pixels(img) == 0, "Field should be blank without form init" diff --git a/unstructured_inference/__version__.py b/unstructured_inference/__version__.py index 1be475c7..9ae5e1d8 100644 --- a/unstructured_inference/__version__.py +++ b/unstructured_inference/__version__.py @@ -1 +1 @@ -__version__ = "1.6.12" # pragma: no cover +__version__ = "1.6.13" # pragma: no cover diff --git a/unstructured_inference/inference/pdf_image.py b/unstructured_inference/inference/pdf_image.py index 2f16e18c..b6c359f0 100644 --- a/unstructured_inference/inference/pdf_image.py +++ b/unstructured_inference/inference/pdf_image.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import math import os from functools import lru_cache @@ -166,6 +167,13 @@ def _in_range(page_num: int) -> bool: with _pdfium_lock: pdf = pdfium.PdfDocument(filename or file, password=password) + # Initialize the form-fill environment so AcroForm/XFA field values + # (e.g. text typed into fillable fields) are painted into the rendered + # image. Without this, pdfium silently drops widget annotation content + # even though may_draw_forms defaults to True on page.render(). + # Fall back to page rendering without form appearances when form env init fails. + with contextlib.suppress(pdfium.PdfiumError): + pdf.init_forms() n_pages = len(pdf) # Pre-scan page rotations so the (heavier) text-orientation pass only runs on the