From df7db169c1e00da7b7b214029daad14aa47e3bf4 Mon Sep 17 00:00:00 2001 From: Yao You Date: Thu, 11 Jun 2026 16:53:41 -0500 Subject: [PATCH 1/4] fix: render filled PDF form field values 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. pdfium only draws widget annotation appearances after the form-fill environment is initialized; without it, filled field values were silently dropped from the rendered image (and thus from downstream OCR/hi_res). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 + .../inference/test_pdf_image_forms.py | 123 ++++++++++++++++++ unstructured_inference/__version__.py | 2 +- unstructured_inference/inference/pdf_image.py | 5 + 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 test_unstructured_inference/inference/test_pdf_image_forms.py 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/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..bd0f8bbc --- /dev/null +++ b/test_unstructured_inference/inference/test_pdf_image_forms.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import numpy as np +import pypdfium2 as pdfium +from pypdf import PdfWriter +from pypdf.generic import ( + ArrayObject, + DecodedStreamObject, + DictionaryObject, + NameObject, + NumberObject, + TextStringObject, +) + +from unstructured_inference.inference import pdf_image + +# Page geometry and the single form field used by the synthetic fixture below. +PAGE_WIDTH, PAGE_HEIGHT = 612, 792 +# Widget rectangle in PDF user space (origin bottom-left): x1, y1, x2, y2. +FIELD_RECT = (40, 700, 320, 724) +RENDER_DPI = 200 + + +def _build_acroform_pdf(path: str) -> None: + """Write a 1-page PDF whose only mark is a filled text form field. + + The page content stream is empty; the field's value is drawn solely by the widget + annotation's appearance stream (``/AP /N``). pdfium only paints widget appearances + after the form environment is initialized, so this fixture renders blank unless + ``convert_pdf_to_image`` calls ``init_forms()``. + """ + writer = PdfWriter() + writer.add_blank_page(width=PAGE_WIDTH, height=PAGE_HEIGHT) + page = writer.pages[0] + + # Helvetica, referenced by both the appearance stream and the AcroForm default resources. + font = DictionaryObject() + font[NameObject("/Type")] = NameObject("/Font") + font[NameObject("/Subtype")] = NameObject("/Type1") + font[NameObject("/BaseFont")] = NameObject("/Helvetica") + font_ref = writer._add_object(font) + fonts = DictionaryObject() + fonts[NameObject("/Helv")] = font_ref + resources = DictionaryObject() + resources[NameObject("/Font")] = fonts + + # Appearance stream that draws the field value inside the widget box. + rect_w, rect_h = FIELD_RECT[2] - FIELD_RECT[0], FIELD_RECT[3] - FIELD_RECT[1] + appearance = DecodedStreamObject() + appearance.set_data(b"/Tx BMC BT /Helv 14 Tf 0 g 2 6 Td (FORMVALUE777) Tj ET EMC") + appearance[NameObject("/Type")] = NameObject("/XObject") + appearance[NameObject("/Subtype")] = NameObject("/Form") + appearance[NameObject("/BBox")] = ArrayObject( + [NumberObject(0), NumberObject(0), NumberObject(rect_w), NumberObject(rect_h)] + ) + appearance[NameObject("/Resources")] = resources + appearance_ref = writer._add_object(appearance) + appearance_dict = DictionaryObject() + appearance_dict[NameObject("/N")] = appearance_ref + + widget = DictionaryObject() + widget[NameObject("/Type")] = NameObject("/Annot") + widget[NameObject("/Subtype")] = NameObject("/Widget") + widget[NameObject("/FT")] = NameObject("/Tx") + widget[NameObject("/T")] = TextStringObject("name") + widget[NameObject("/V")] = TextStringObject("FORMVALUE777") + widget[NameObject("/Rect")] = ArrayObject([NumberObject(c) for c in FIELD_RECT]) + widget[NameObject("/AP")] = appearance_dict + widget_ref = writer._add_object(widget) + page[NameObject("/Annots")] = ArrayObject([widget_ref]) + + acro_form = DictionaryObject() + acro_form[NameObject("/Fields")] = ArrayObject([widget_ref]) + default_resources = DictionaryObject() + default_resources[NameObject("/Font")] = fonts + acro_form[NameObject("/DR")] = default_resources + writer._root_object[NameObject("/AcroForm")] = writer._add_object(acro_form) + + with open(path, "wb") as f: + writer.write(f) + + +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(tmp_path): + """Filled form-field values are painted into the rendered page image.""" + pdf_path = str(tmp_path / "form.pdf") + _build_acroform_pdf(pdf_path) + + img = pdf_image.convert_pdf_to_image(filename=pdf_path, 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(tmp_path, 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). + """ + pdf_path = str(tmp_path / "form.pdf") + _build_acroform_pdf(pdf_path) + + monkeypatch.setattr(pdfium.PdfDocument, "init_forms", lambda self, *a, **k: None) + img = pdf_image.convert_pdf_to_image(filename=pdf_path, 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..202cbe9d 100644 --- a/unstructured_inference/inference/pdf_image.py +++ b/unstructured_inference/inference/pdf_image.py @@ -166,6 +166,11 @@ 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(). + pdf.init_forms() n_pages = len(pdf) # Pre-scan page rotations so the (heavier) text-orientation pass only runs on the From 3e82abe23f1388d4b957c32379cc3b60404fa9f2 Mon Sep 17 00:00:00 2001 From: Yao You Date: Thu, 11 Jun 2026 17:00:02 -0500 Subject: [PATCH 2/4] test: use committed form-field.pdf asset instead of building with pypdf pypdf is not a declared dependency of unstructured-inference; save the synthetic AcroForm PDF as a sample-docs asset and load it like the other rendering test fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) --- sample-docs/form-field.pdf | Bin 0 -> 1019 bytes .../inference/test_pdf_image_forms.py | 91 ++---------------- 2 files changed, 10 insertions(+), 81 deletions(-) create mode 100644 sample-docs/form-field.pdf diff --git a/sample-docs/form-field.pdf b/sample-docs/form-field.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f4d45f22cc9574d63f54b30e9ab86d1fe424818d GIT binary patch literal 1019 zcmZ`&&2F1O5WeqI%*B#@Xcn*mTZ$stj!~<|wMA%Jsp?@33&nC+Ygt!LU$Te3LGmJ< zArNESA|bTH&i6Avz4dMI&b}J5-t)`vKgMQWhq6=$9U}sY$LoA4lU&Ty5oK6cR`q(e1OCK8K}e=0N^RJRLKzb5 z6Wf_!_ku4_g<<^uurZZ&5`m6qqLlOp_>^Gr855i_ zz9Uk5W{y`h)FNU65*QK{nZA=nW`2M>rc-IO6bKm|VYZyZEPB-j_6VXBvof^rL6ks0 zScS`n>D}jr@B0IYwy=m`v7A%CYmmD0RG;(ePpNdRa(u~?OkyBFw&;lD&@aTJ95DXS z;UQbGp((Mzw=5Vs_ None: - """Write a 1-page PDF whose only mark is a filled text form field. - - The page content stream is empty; the field's value is drawn solely by the widget - annotation's appearance stream (``/AP /N``). pdfium only paints widget appearances - after the form environment is initialized, so this fixture renders blank unless - ``convert_pdf_to_image`` calls ``init_forms()``. - """ - writer = PdfWriter() - writer.add_blank_page(width=PAGE_WIDTH, height=PAGE_HEIGHT) - page = writer.pages[0] - - # Helvetica, referenced by both the appearance stream and the AcroForm default resources. - font = DictionaryObject() - font[NameObject("/Type")] = NameObject("/Font") - font[NameObject("/Subtype")] = NameObject("/Type1") - font[NameObject("/BaseFont")] = NameObject("/Helvetica") - font_ref = writer._add_object(font) - fonts = DictionaryObject() - fonts[NameObject("/Helv")] = font_ref - resources = DictionaryObject() - resources[NameObject("/Font")] = fonts - - # Appearance stream that draws the field value inside the widget box. - rect_w, rect_h = FIELD_RECT[2] - FIELD_RECT[0], FIELD_RECT[3] - FIELD_RECT[1] - appearance = DecodedStreamObject() - appearance.set_data(b"/Tx BMC BT /Helv 14 Tf 0 g 2 6 Td (FORMVALUE777) Tj ET EMC") - appearance[NameObject("/Type")] = NameObject("/XObject") - appearance[NameObject("/Subtype")] = NameObject("/Form") - appearance[NameObject("/BBox")] = ArrayObject( - [NumberObject(0), NumberObject(0), NumberObject(rect_w), NumberObject(rect_h)] - ) - appearance[NameObject("/Resources")] = resources - appearance_ref = writer._add_object(appearance) - appearance_dict = DictionaryObject() - appearance_dict[NameObject("/N")] = appearance_ref - - widget = DictionaryObject() - widget[NameObject("/Type")] = NameObject("/Annot") - widget[NameObject("/Subtype")] = NameObject("/Widget") - widget[NameObject("/FT")] = NameObject("/Tx") - widget[NameObject("/T")] = TextStringObject("name") - widget[NameObject("/V")] = TextStringObject("FORMVALUE777") - widget[NameObject("/Rect")] = ArrayObject([NumberObject(c) for c in FIELD_RECT]) - widget[NameObject("/AP")] = appearance_dict - widget_ref = writer._add_object(widget) - page[NameObject("/Annots")] = ArrayObject([widget_ref]) - - acro_form = DictionaryObject() - acro_form[NameObject("/Fields")] = ArrayObject([widget_ref]) - default_resources = DictionaryObject() - default_resources[NameObject("/Font")] = fonts - acro_form[NameObject("/DR")] = default_resources - writer._root_object[NameObject("/AcroForm")] = writer._add_object(acro_form) - - with open(path, "wb") as f: - writer.write(f) - - def _field_region_dark_pixels(img) -> int: """Count dark pixels inside the form field's rectangle in the rendered image.""" gray = img.convert("L") @@ -97,27 +32,21 @@ def _field_region_dark_pixels(img) -> int: return int(np.count_nonzero(crop < 128)) -def test_convert_pdf_to_image_renders_acroform_field_value(tmp_path): +def test_convert_pdf_to_image_renders_acroform_field_value(): """Filled form-field values are painted into the rendered page image.""" - pdf_path = str(tmp_path / "form.pdf") - _build_acroform_pdf(pdf_path) - - img = pdf_image.convert_pdf_to_image(filename=pdf_path, dpi=RENDER_DPI)[0] + 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(tmp_path, monkeypatch): +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). """ - pdf_path = str(tmp_path / "form.pdf") - _build_acroform_pdf(pdf_path) - monkeypatch.setattr(pdfium.PdfDocument, "init_forms", lambda self, *a, **k: None) - img = pdf_image.convert_pdf_to_image(filename=pdf_path, dpi=RENDER_DPI)[0] + 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" From 0352b7985c47a100ac9ac7a88bac9e53095736b8 Mon Sep 17 00:00:00 2001 From: Yao You Date: Thu, 11 Jun 2026 17:15:13 -0500 Subject: [PATCH 3/4] Update unstructured_inference/inference/pdf_image.py Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- unstructured_inference/inference/pdf_image.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/unstructured_inference/inference/pdf_image.py b/unstructured_inference/inference/pdf_image.py index 202cbe9d..bb86004a 100644 --- a/unstructured_inference/inference/pdf_image.py +++ b/unstructured_inference/inference/pdf_image.py @@ -170,7 +170,11 @@ def _in_range(page_num: int) -> bool: # (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(). - pdf.init_forms() + try: + pdf.init_forms() + except pdfium.PdfiumError: + # Fall back to page rendering without form appearances when form env init fails. + pass n_pages = len(pdf) # Pre-scan page rotations so the (heavier) text-orientation pass only runs on the From 6ecb102faab1465d8659fd0e2a4f4deb1110258e Mon Sep 17 00:00:00 2001 From: Yao You Date: Thu, 11 Jun 2026 17:17:21 -0500 Subject: [PATCH 4/4] lint --- unstructured_inference/inference/pdf_image.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/unstructured_inference/inference/pdf_image.py b/unstructured_inference/inference/pdf_image.py index bb86004a..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 @@ -170,11 +171,9 @@ def _in_range(page_num: int) -> bool: # (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(). - try: + # Fall back to page rendering without form appearances when form env init fails. + with contextlib.suppress(pdfium.PdfiumError): pdf.init_forms() - except pdfium.PdfiumError: - # Fall back to page rendering without form appearances when form env init fails. - pass n_pages = len(pdf) # Pre-scan page rotations so the (heavier) text-orientation pass only runs on the