fix: address review of AcroForm text extraction

Follow-up to 8ad92a3 / d9ee62e, reworking the flatten-based approach
after review.

Why flattening at all, rather than reading field values? The parser
already exposes `page.form_fields()` with names, values and rects, and
synthesizing text items from those would be a fraction of this code.
It is wrong in both directions, though: a field can carry a /V that no
appearance paints (an unpainted default is not visible text, and would
be injected), and a painted appearance carries layout that the value
alone loses — line breaks, comb spacing, and the widget's own font
metrics. Flattening asks PDFium for what is actually painted, which is
the same question the text layer answers everywhere else. Both cases
are pinned by fixtures.

Correctness:

- FPDFPage_Flatten and FPDFAnnot_SetFlags now load via load_fn_opt.
  Loading them with load_fn! made them mandatory, so any pdfium build
  without fpdf_flatten.h failed PdfiumBindings::load outright and no
  document parsed at all. They follow the SignatureApi pattern instead,
  and a build without them just skips flattening.

- The widget appearance walk descends into nested form XObjects.
  Acrobat and several server-side fillers emit `/Tx BMC q /Fm0 Do Q EMC`,
  where the top-level object is a form, not text; those filled fields
  looked empty and were never flattened.

- has_annotation_text keeps its original HIDDEN-only semantics. The
  shared helper had widened it with INVISIBLE and NOVIEW, which silently
  changed the AnnotationText complexity signal — i.e. OCR routing — for
  non-widget annotations. The stricter mask now lives only on the
  form-widget path.

- Page text that flattening suppressed is restored. PDFium's text layer
  emits only one of two runs starting at essentially the same point, so
  a flattened appearance can knock out page text it lands on. Where the
  strings match (a producer that wrote the value into both the content
  stream and the appearance) that is the dedup a partially flattened
  file needs; where they differ it is data loss, and the pre-flatten
  copy is put back.

Cost:

- Documents with no AcroForm catalog cost one form_type() call and never
  reach the annotation walk.

- is_complex no longer reopens the input. Complexity now runs on the
  flattened document deliberately: AnnotationText means "the text is
  there, just outside the extractable surface", which stops being true
  once the value is in the content stream. Routing such a page to OCR
  re-derives text the parser already returned.

- parse reopens the input only when OCR *and* render_form_fields are on,
  the one consumer that needs live widgets (it initializes the form
  environment to run document actions and paint computed appearances).
  Plain OCR rendering does not: a flattened page rasterizes the same.

- Recovering suppressed text needs the page's text twice, so it is
  gated on a bounds-only probe for a text object overlapping a widget
  rect. The usual form page, whose widgets sit over blank space, pays a
  page-object walk instead of a second extraction.

Also: the 4-tuple return is now ExtractedPages; the flatten/reload dance
moved behind Document::flatten_form_widgets so no caller can hold a
stale page handle; and the fixture is generated by a checked-in script
documenting what each widget exercises, so its exact counts are
auditable.

Verified each fix fails the suite when reverted individually.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TXb7LewB9rTYJY1hY9Dbz
This commit is contained in:
Logan Markewich
2026-08-05 11:26:55 -06:00
parent d9ee62ec9d
commit c3b61b805a
8 changed files with 703 additions and 91 deletions
+107 -15
View File
@@ -59,15 +59,25 @@ pub(crate) fn extract_pages_from_document(
None,
ExtractionOutputOptions::default(),
)?
.0)
.pages)
}
/// Output of [`extract_pages_and_images`].
pub(crate) struct ExtractedPages {
pub pages: Vec<LitePage>,
/// Empty unless `output_options.extract_images` was set.
pub images: Vec<ExtractedImage>,
pub image_error_count: u32,
/// Whether any page was flattened to recover form-widget text. Flattening
/// mutates the open PDFium document, so a caller that still needs the
/// original widget annotations must reopen the input.
pub flattened_form_widgets: bool,
}
/// Same as `extract_pages_from_document` but optionally also renders every
/// raster image object to bytes (when `output_options.extract_images` is true). Returned
/// `ExtractedImage`s carry the same ids the markdown emitter will reference,
/// so callers can match them up by id. When image extraction is disabled the
/// returned image vec is always empty. The final boolean reports whether a
/// page was flattened, which mutates the open PDFium document.
/// so callers can match them up by id.
pub(crate) fn extract_pages_and_images(
document: &Document,
target_pages: Option<&[u32]>,
@@ -75,13 +85,16 @@ pub(crate) fn extract_pages_and_images(
extract_links: bool,
glyph_resolver: Option<&dyn crate::GlyphResolver>,
output_options: ExtractionOutputOptions,
) -> Result<(Vec<LitePage>, Vec<ExtractedImage>, u32, bool), LiteParseError> {
) -> Result<ExtractedPages, LiteParseError> {
let page_count = document.page_count();
let mut pages = Vec::new();
let mut images: Vec<ExtractedImage> = Vec::new();
let mut image_cache = ImageCache::default();
let mut image_error_count = 0u32;
let mut flattened_form_widgets = false;
// One FFI call keeps the per-page annotation walk off the hot path for
// every document without an AcroForm catalog, which is nearly all of them.
let document_has_form = document.form_type() != 0;
let form_environment = output_options
.extract_form_fields
.then(|| document.form_environment())
@@ -198,6 +211,10 @@ pub(crate) fn extract_pages_and_images(
// those widget appearances into page content and reload before text
// extraction. Non-widget annotations are excluded, and this does not
// initialize the form environment or execute document JS.
//
// `widget_text_rects` is empty for the overwhelming majority of pages —
// documents with no AcroForm catalog never even reach the annotation
// walk — so the whole path costs one `form_type()` call for most files.
let extract_text = |page: &Page| -> Result<Vec<TextItem>, LiteParseError> {
let text_page = page.text()?;
extract_page_text_items(
@@ -209,15 +226,37 @@ pub(crate) fn extract_pages_and_images(
output_options.extract_text_metadata,
)
};
let mut text_items =
if page.has_form_widget_text() && page.flatten_form_widgets_for_display() {
flattened_form_widgets = true;
drop(page);
let flattened_page = document.page(page_index)?;
extract_text(&flattened_page)?
} else {
extract_text(&page)?
};
let widget_text_rects = if document_has_form {
page.form_widget_text_rects(&view_box)
} else {
Vec::new()
};
let mut text_items = if widget_text_rects.is_empty() {
extract_text(&page)?
} else {
// PDFium's text layer keeps only one of two runs that start at
// essentially the same point, so a flattened appearance can
// suppress page text it lands on. Usually widget rects sit over
// blank space, and this bounds-only probe says so without touching
// the text API; only when a widget really does cover existing text
// do we extract twice and put back what was suppressed.
let overlaps_existing_text = page.text_objects_overlap(&view_box, &widget_text_rects);
let before = overlaps_existing_text
.then(|| extract_text(&page))
.transpose()?;
drop(page);
match document.flatten_form_widgets(page_index)? {
Some(flattened_page) => {
flattened_form_widgets = true;
let mut items = extract_text(&flattened_page)?;
if let Some(before) = before {
restore_flattened_over_text(&mut items, before, &widget_text_rects);
}
items
}
None => extract_text(&document.page(page_index)?)?,
}
};
assign_links(&mut text_items, &links);
assign_strikethrough(&mut text_items, &graphics);
@@ -240,7 +279,60 @@ pub(crate) fn extract_pages_and_images(
});
}
Ok((pages, images, image_error_count, flattened_form_widgets))
Ok(ExtractedPages {
pages,
images,
image_error_count,
flattened_form_widgets,
})
}
/// Put back page text that flattening suppressed.
///
/// PDFium's text layer emits only one of two text runs that start at
/// essentially the same point, so a flattened widget appearance can knock out
/// page text it lands on. When the two carry the *same* string — a producer
/// that wrote the value into both the content stream and the appearance — that
/// is precisely the dedup a partially flattened file needs, and matching on
/// trimmed text leaves it alone. When they differ, such as a pre-printed label
/// sitting where the value is typed, dropping one is pure data loss, so the
/// pre-flatten copy is restored.
///
/// Only called for the page where a widget rect actually covers existing text;
/// `before` is the pre-flatten extraction of the same page.
///
/// Note this recovers one direction only. If the collision goes the other way
/// PDFium can suppress the *appearance* text instead, and the field value is
/// lost with no pre-flatten copy to restore it from.
fn restore_flattened_over_text(
items: &mut Vec<TextItem>,
before: Vec<TextItem>,
widget_rects: &[RectF],
) {
let surviving: std::collections::HashSet<&str> = items
.iter()
.map(|item| item.text.trim())
.filter(|text| !text.is_empty())
.collect();
let mut restored: Vec<TextItem> = before
.iter()
.filter(|item| {
let text = item.text.trim();
!text.is_empty()
&& !surviving.contains(text)
&& widget_rects
.iter()
.any(|rect| rect_contains_center(rect, item))
})
.cloned()
.collect();
items.append(&mut restored);
}
fn rect_contains_center(rect: &RectF, item: &TextItem) -> bool {
let cx = item.x + item.width / 2.0;
let cy = item.y + item.height / 2.0;
cx >= rect.left && cx <= rect.right && cy >= rect.top && cy <= rect.bottom
}
#[derive(Debug, Clone, Copy, Default)]
+50 -39
View File
@@ -283,21 +283,21 @@ impl LiteParse {
let lib = Library::init();
let document = extract::load_document_from_input(&lib, &validated_input, password)?;
let (pages, _, _, flattened_form_widgets) = extract::extract_pages_and_images(
// Complexity deliberately runs against the flattened document: once
// widget text lives in the content stream it is genuinely within
// PDFium's reach, so it should count toward the page's text budget
// instead of routing the page to OCR to recover text we already
// have. `AnnotationText` still fires for the non-widget appearance
// text it was introduced for.
let pages = extract::extract_pages_and_images(
&document,
target_pages.as_deref(),
self.config.max_pages,
false, // extract_links: irrelevant for complexity stats
self.glyph_resolver.as_deref(),
extract::ExtractionOutputOptions::default(),
)?;
// Form flattening promotes appearances into page content by
// mutating the PDFium document. Complexity still needs the
// original annotations, so reopen the input when that happened.
let pristine_document = flattened_form_widgets
.then(|| extract::load_document_from_input(&lib, &validated_input, password))
.transpose()?;
let complexity_document = pristine_document.as_ref().unwrap_or(&document);
)?
.pages;
let t_extract = web_time::Instant::now();
log(&format!(
"[liteparse] extract: {:.1}ms ({} pages)",
@@ -308,7 +308,7 @@ impl LiteParse {
let page_complexities = pages
.iter()
.map(|page| {
let page_obj = complexity_document.page((page.page_number - 1) as i32)?;
let page_obj = document.page((page.page_number - 1) as i32)?;
ocr_merge::calculate_page_complexity(page, &page_obj)
})
.collect::<Result<Vec<_>, _>>()?;
@@ -506,35 +506,46 @@ impl LiteParse {
.collect::<Vec<_>>()
});
let outline = extract::extract_outline(&document);
let (pages, images, image_error_count, flattened_form_widgets) =
extract::extract_pages_and_images(
&document,
target_pages.as_deref(),
self.config.max_pages,
self.config.extract_links
&& self.config.output_format == crate::config::OutputFormat::Markdown,
self.glyph_resolver.as_deref(),
extract::ExtractionOutputOptions {
extract_content_bounds: self.config.extract_content_bounds,
extract_images: self.config.effective_extract_images(),
// The markdown table detector splits PDFium's merged
// multi-cell runs on real word geometry, so it needs word
// boxes even when the caller didn't ask for them.
emit_word_boxes: self.config.emit_word_boxes
|| self.config.output_format == crate::config::OutputFormat::Markdown,
extract_text_metadata: self.config.extract_text_metadata,
extract_vector_graphics: self.config.extract_vector_graphics,
extract_annotations: self.config.extract_annotations,
extract_form_fields: self.config.extract_form_fields,
extract_structure_tree: self.config.extract_structure_tree,
},
)?;
// Flattening is deliberately limited to text extraction. OCR and
// complexity must retain the original annotations/widgets so the
// opt-in form renderer can still run document actions and paint
// computed field appearances.
let needs_pristine_document = flattened_form_widgets
&& (self.config.ocr_enabled || self.config.include_complexity);
let extracted = extract::extract_pages_and_images(
&document,
target_pages.as_deref(),
self.config.max_pages,
self.config.extract_links
&& self.config.output_format == crate::config::OutputFormat::Markdown,
self.glyph_resolver.as_deref(),
extract::ExtractionOutputOptions {
extract_content_bounds: self.config.extract_content_bounds,
extract_images: self.config.effective_extract_images(),
// The markdown table detector splits PDFium's merged
// multi-cell runs on real word geometry, so it needs word
// boxes even when the caller didn't ask for them.
emit_word_boxes: self.config.emit_word_boxes
|| self.config.output_format == crate::config::OutputFormat::Markdown,
extract_text_metadata: self.config.extract_text_metadata,
extract_vector_graphics: self.config.extract_vector_graphics,
extract_annotations: self.config.extract_annotations,
extract_form_fields: self.config.extract_form_fields,
extract_structure_tree: self.config.extract_structure_tree,
},
)?;
let extract::ExtractedPages {
pages,
images,
image_error_count,
flattened_form_widgets,
} = extracted;
// Reopening the input costs a full parse, so it is confined to the
// one consumer that genuinely needs live widget annotations: the
// opt-in form renderer, which initializes the form environment to
// run document actions and paint computed field appearances that
// have no appearance stream to flatten.
//
// Plain OCR rendering does not need it — flattening promotes the
// widget appearances into page content, so the raster is the same
// either way. Complexity likewise runs on the flattened document
// by design (see `is_complex`).
let needs_pristine_document =
flattened_form_widgets && self.config.ocr_enabled && self.config.render_form_fields;
let pristine_document = needs_pristine_document
.then(|| extract::load_document_from_input(&lib, document_input, password))
.transpose()?;
+49 -7
View File
@@ -303,18 +303,27 @@ async fn test_filled_acroform_values_are_extracted_as_text() {
.await
.expect("filled form should parse");
for expected in ["ACROFORM-CUSTOMER-7319", "2026-07-28", "50.00"] {
// See scripts/generate_filled_acroform_fixture.py for what each widget
// is meant to exercise.
for (expected, case) in [
(
"ACROFORM-CUSTOMER-7319",
"painted directly by the appearance",
),
("2026-07-28", "painted through a nested form XObject"),
("50.00", "painted by the appearance and the content stream"),
] {
assert_eq!(
parsed.text.matches(expected).count(),
1,
"visible form value should appear exactly once: {expected}"
"visible form value should appear exactly once ({case}): {expected}"
);
assert!(
parsed.pages[0]
.text_items
.iter()
.any(|item| item.text.contains(expected)),
"form value should be a positioned text item: {expected}"
"form value should be a positioned text item ({case}): {expected}"
);
}
assert!(
@@ -325,10 +334,34 @@ async fn test_filled_acroform_values_are_extracted_as_text() {
!parsed.text.contains("ANNOTATION-ONLY-SHOULD-NOT-APPEAR"),
"non-widget annotation appearances must not become page text"
);
assert!(
!parsed.text.contains("HIDDEN-SHOULD-NOT-APPEAR"),
"a hidden widget is never rendered, so its value is not visible text"
);
// Flattening replaces the page content under a widget rect with that
// widget's appearance, so page text drawn there is dropped unless it is
// put back. This label sits inside the `amount` rect and no appearance
// reproduces it.
assert_eq!(
parsed.text.matches("PREPRINTED-LABEL").count(),
1,
"page text under a widget rect must survive flattening exactly once"
);
assert!(
parsed.pages[0].form_fields.is_none(),
"default text extraction must not enable structured form metadata"
);
// Page 3's only annotation paints its value through a nested form XObject.
// Nothing else on that page would trigger a flatten, so this fails unless
// the appearance walk descends into form objects.
assert!(
parsed.pages[2]
.text_items
.iter()
.any(|item| item.text.contains("NESTED-ONLY-VALUE")),
"a widget whose value is painted only through a nested form XObject \
must still be detected and flattened"
);
let with_metadata = LiteParse::new(LiteParseConfig {
ocr_enabled: false,
@@ -378,7 +411,16 @@ async fn test_filled_acroform_values_are_extracted_as_text() {
Some("complexity_sentinel")
);
assert_eq!(second_page_fields[0].value.as_deref(), Some("OK"));
let third_page_fields = with_metadata.pages[2].form_fields.as_ref().unwrap();
assert_eq!(third_page_fields.len(), 1);
assert_eq!(third_page_fields[0].name.as_deref(), Some("nested_only"));
// Complexity sees the flattened text. `AnnotationText` means "the text is
// there, just outside the extractable surface" — once a widget value has
// been promoted into page content that no longer holds, so the reason must
// not fire and the page must not be routed to OCR to recover text the
// parser already returned. `test_annotation_text_complexity_reason` covers
// the non-widget appearance text the reason still exists for.
let complexity = LiteParse::new(LiteParseConfig {
ocr_enabled: false,
..Default::default()
@@ -387,12 +429,12 @@ async fn test_filled_acroform_values_are_extracted_as_text() {
"../../integration_tests_data/filled_acroform.pdf".into(),
))
.await
.expect("complexity analysis should use an unmodified document");
assert_eq!(complexity.len(), 2);
.expect("complexity analysis should run on the flattened document");
assert_eq!(complexity.len(), 3);
assert!(
complexity[1]
!complexity[1]
.reasons
.contains(&ComplexityReason::AnnotationText),
"the pristine second page must retain its widget annotation"
"widget text is extractable after flattening, so it is not annotation-only text"
);
}
+5 -4
View File
@@ -117,7 +117,7 @@ pub struct PdfiumBindings {
pub FPDF_GetPageBoundingBox: unsafe extern "C" fn(FPDF_PAGE, *mut FS_RECTF) -> FPDF_BOOL,
pub FPDFPage_GetRotation: unsafe extern "C" fn(FPDF_PAGE) -> std::os::raw::c_int,
pub FPDFPage_Flatten:
unsafe extern "C" fn(FPDF_PAGE, std::os::raw::c_int) -> std::os::raw::c_int,
Option<unsafe extern "C" fn(FPDF_PAGE, std::os::raw::c_int) -> std::os::raw::c_int>,
pub FPDF_PageToDevice: unsafe extern "C" fn(
FPDF_PAGE,
std::os::raw::c_int,
@@ -396,7 +396,8 @@ pub struct PdfiumBindings {
unsafe extern "C" fn(FPDF_ANNOTATION, FPDF_BYTESTRING) -> FPDF_ANNOTATION,
pub FPDFAnnot_GetObjNum: unsafe extern "C" fn(FPDF_ANNOTATION) -> std::os::raw::c_int,
pub FPDFAnnot_GetFlags: unsafe extern "C" fn(FPDF_ANNOTATION) -> std::os::raw::c_int,
pub FPDFAnnot_SetFlags: unsafe extern "C" fn(FPDF_ANNOTATION, std::os::raw::c_int) -> FPDF_BOOL,
pub FPDFAnnot_SetFlags:
Option<unsafe extern "C" fn(FPDF_ANNOTATION, std::os::raw::c_int) -> FPDF_BOOL>,
pub FPDFAnnot_GetObjectCount: unsafe extern "C" fn(FPDF_ANNOTATION) -> std::os::raw::c_int,
pub FPDFAnnot_GetObject:
unsafe extern "C" fn(FPDF_ANNOTATION, std::os::raw::c_int) -> FPDF_PAGEOBJECT,
@@ -573,7 +574,7 @@ impl PdfiumBindings {
FPDF_GetPageHeightF: load_fn!(lib, "FPDF_GetPageHeightF"),
FPDF_GetPageBoundingBox: load_fn!(lib, "FPDF_GetPageBoundingBox"),
FPDFPage_GetRotation: load_fn!(lib, "FPDFPage_GetRotation"),
FPDFPage_Flatten: load_fn!(lib, "FPDFPage_Flatten"),
FPDFPage_Flatten: load_fn_opt!(lib, "FPDFPage_Flatten"),
FPDF_PageToDevice: load_fn!(lib, "FPDF_PageToDevice"),
FPDFPage_CountObjects: load_fn!(lib, "FPDFPage_CountObjects"),
FPDFPage_GetObject: load_fn!(lib, "FPDFPage_GetObject"),
@@ -671,7 +672,7 @@ impl PdfiumBindings {
FPDFAnnot_GetLinkedAnnot: load_fn!(lib, "FPDFAnnot_GetLinkedAnnot"),
FPDFAnnot_GetObjNum: load_fn!(lib, "FPDFAnnot_GetObjNum"),
FPDFAnnot_GetFlags: load_fn!(lib, "FPDFAnnot_GetFlags"),
FPDFAnnot_SetFlags: load_fn!(lib, "FPDFAnnot_SetFlags"),
FPDFAnnot_SetFlags: load_fn_opt!(lib, "FPDFAnnot_SetFlags"),
FPDFAnnot_GetObjectCount: load_fn!(lib, "FPDFAnnot_GetObjectCount"),
FPDFAnnot_GetObject: load_fn!(lib, "FPDFAnnot_GetObject"),
FPDFAnnot_GetFormFieldFlags: load_fn!(lib, "FPDFAnnot_GetFormFieldFlags"),
+18
View File
@@ -138,6 +138,24 @@ impl<'lib> Document<'lib> {
})
}
/// Flatten the visible form-widget appearances on `index` into the page
/// content stream and hand back a freshly loaded page reflecting them.
///
/// Flattening mutates this document in place and invalidates the page
/// handle it ran on, so the load/flatten/reload sequence lives here rather
/// than at call sites where a stale handle would be easy to keep using.
/// Returns `Ok(None)` when nothing was flattened — the caller should keep
/// using its existing page.
pub fn flatten_form_widgets(&self, index: i32) -> Result<Option<Page<'_, 'lib>>, PdfiumError> {
{
let page = self.page(index)?;
if !page.flatten_form_widgets_for_display() {
return Ok(None);
}
}
self.page(index).map(Some)
}
/// Read one entry from the document's `/Info` metadata dictionary
/// (e.g. `"Creator"`, `"Producer"`, `"Title"`). Returns `None` when the
/// tag is absent or empty.
+181 -26
View File
@@ -909,8 +909,11 @@ impl<'doc, 'lib: 'doc> Page<'doc, 'lib> {
continue;
}
let subtype = unsafe { ffi!(FPDFAnnot_GetSubtype(annot)) };
let found =
subtype != pdfium_sys::FPDF_ANNOT_POPUP as i32 && annotation_paints_text(annot);
let flags = unsafe { ffi!(FPDFAnnot_GetFlags(annot)) };
let hidden = flags & pdfium_sys::FPDF_ANNOT_FLAG_HIDDEN as i32 != 0;
let found = !hidden
&& subtype != pdfium_sys::FPDF_ANNOT_POPUP as i32
&& annotation_paints_text_shallow(annot);
unsafe { ffi!(FPDFPage_CloseAnnot(annot)) };
if found {
return true;
@@ -919,20 +922,92 @@ impl<'doc, 'lib: 'doc> Page<'doc, 'lib> {
false
}
/// Whether a visible AcroForm widget paints text through its appearance.
/// PDFium's page text API omits these glyphs until the page is flattened.
pub fn has_form_widget_text(&self) -> bool {
/// Viewport rects of the visible AcroForm widgets that paint text through
/// their appearance streams. Empty when the page has no such widget, which
/// is the signal not to flatten.
///
/// PDFium's page text API omits these glyphs until the page is flattened,
/// so the rects double as the only regions where flattening can introduce
/// text — callers use them to scope duplicate detection instead of
/// rescanning the whole page.
pub fn form_widget_text_rects(&self, view_box: &RectF) -> Vec<RectF> {
let mut rects = Vec::new();
let count = unsafe { ffi!(FPDFPage_GetAnnotCount(self.handle)) };
for index in 0..count {
let annot = unsafe { ffi!(FPDFPage_GetAnnot(self.handle, index)) };
if annot.is_null() {
continue;
}
let found = unsafe { ffi!(FPDFAnnot_GetSubtype(annot)) }
== pdfium_sys::FPDF_ANNOT_WIDGET as i32
&& annotation_paints_text(annot);
if unsafe { ffi!(FPDFAnnot_GetSubtype(annot)) } == pdfium_sys::FPDF_ANNOT_WIDGET as i32
&& annotation_paints_text_deep(annot)
{
let mut rect = pdfium_sys::FS_RECTF::default();
if unsafe { ffi!(FPDFAnnot_GetRect(annot, &mut rect)) } != 0 {
rects.push(self.bounds_to_viewport(
view_box,
&RectF {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
},
));
}
}
unsafe { ffi!(FPDFPage_CloseAnnot(annot)) };
if found {
}
rects
}
/// Whether any text object already in the page content stream overlaps one
/// of `rects`.
///
/// Flattening replaces the page content under a widget's rect with that
/// widget's appearance, so text already drawn there is lost. This is the
/// cheap probe for that situation: it walks page-object bounding boxes
/// only — no text page, no glyph decoding — so the common form page (whose
/// widget rects sit over blank space) pays a bounds walk instead of a
/// second full text extraction.
pub fn text_objects_overlap(&self, view_box: &RectF, rects: &[RectF]) -> bool {
if rects.is_empty() {
return false;
}
let count = unsafe { ffi!(FPDFPage_CountObjects(self.handle)) };
for i in 0..count {
let obj = unsafe { ffi!(FPDFPage_GetObject(self.handle, i)) };
if obj.is_null()
|| unsafe { ffi!(FPDFPageObj_GetType(obj)) } != pdfium_sys::FPDF_PAGEOBJ_TEXT as i32
{
continue;
}
let (mut left, mut bottom, mut right, mut top) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
if unsafe {
ffi!(FPDFPageObj_GetBounds(
obj,
&mut left,
&mut bottom,
&mut right,
&mut top
))
} == 0
{
continue;
}
let bounds = self.bounds_to_viewport(
view_box,
&RectF {
left,
top,
right,
bottom,
},
);
if rects.iter().any(|rect| {
bounds.left < rect.right
&& bounds.right > rect.left
&& bounds.top < rect.bottom
&& bounds.bottom > rect.top
}) {
return true;
}
}
@@ -948,10 +1023,16 @@ impl<'doc, 'lib: 'doc> Page<'doc, 'lib> {
/// extraction document first; callers snapshot annotation metadata and
/// reopen the pristine input for any later rendering work.
///
/// Returns true only when PDFium changed the page. If suppression or
/// flattening fails, restores any changed flags and leaves extraction on
/// the original page content.
/// Returns true only when PDFium changed the page. Returns false — leaving
/// extraction on the original page content — when the pdfium build omits
/// the flatten API, or when suppression or flattening fails, in which case
/// any changed flags are restored first.
pub fn flatten_form_widgets_for_display(&self) -> bool {
// `fpdf_flatten.h` is an optional pdfium API; trimmed builds omit it.
// Missing it costs form-value text, not the whole parse.
let Some(api) = FlattenApi::load() else {
return false;
};
let mut suppressed = Vec::new();
let count = unsafe { ffi!(FPDFPage_GetAnnotCount(self.handle)) };
for index in 0..count {
@@ -964,10 +1045,10 @@ impl<'doc, 'lib: 'doc> Page<'doc, 'lib> {
let flags = unsafe { ffi!(FPDFAnnot_GetFlags(annot)) };
if flags & pdfium_sys::FPDF_ANNOT_FLAG_HIDDEN as i32 == 0 {
let hidden_flags = flags | pdfium_sys::FPDF_ANNOT_FLAG_HIDDEN as i32;
let changed = unsafe { ffi!(FPDFAnnot_SetFlags(annot, hidden_flags)) } != 0;
let changed = unsafe { (api.set_flags)(annot, hidden_flags) } != 0;
unsafe { ffi!(FPDFPage_CloseAnnot(annot)) };
if !changed {
restore_annotation_flags(self.handle, &suppressed);
restore_annotation_flags(&api, self.handle, &suppressed);
return false;
}
suppressed.push((index, flags));
@@ -977,14 +1058,9 @@ impl<'doc, 'lib: 'doc> Page<'doc, 'lib> {
unsafe { ffi!(FPDFPage_CloseAnnot(annot)) };
}
let result = unsafe {
ffi!(FPDFPage_Flatten(
self.handle,
pdfium_sys::FLAT_NORMALDISPLAY as i32
))
};
let result = unsafe { (api.flatten)(self.handle, pdfium_sys::FLAT_NORMALDISPLAY as i32) };
if result != pdfium_sys::FLATTEN_SUCCESS as i32 {
restore_annotation_flags(self.handle, &suppressed);
restore_annotation_flags(&api, self.handle, &suppressed);
}
result == pdfium_sys::FLATTEN_SUCCESS as i32
}
@@ -1141,7 +1217,60 @@ impl<'doc, 'lib: 'doc> Page<'doc, 'lib> {
}
}
fn annotation_paints_text(annot: pdfium_sys::FPDF_ANNOTATION) -> bool {
/// The optional page-flatten API, resolved together so a build missing either
/// half degrades to "no flattening" rather than failing the whole pdfium load.
struct FlattenApi {
flatten:
unsafe extern "C" fn(pdfium_sys::FPDF_PAGE, std::os::raw::c_int) -> std::os::raw::c_int,
set_flags: unsafe extern "C" fn(
pdfium_sys::FPDF_ANNOTATION,
std::os::raw::c_int,
) -> pdfium_sys::FPDF_BOOL,
}
impl FlattenApi {
#[cfg(not(target_arch = "wasm32"))]
fn load() -> Option<Self> {
let bindings = pdfium_sys::dynamic::pdfium();
Some(Self {
flatten: bindings.FPDFPage_Flatten?,
set_flags: bindings.FPDFAnnot_SetFlags?,
})
}
#[cfg(target_arch = "wasm32")]
fn load() -> Option<Self> {
Some(Self {
flatten: pdfium_sys::FPDFPage_Flatten,
set_flags: pdfium_sys::FPDFAnnot_SetFlags,
})
}
}
/// Whether the annotation's appearance paints text at its top level.
///
/// Deliberately shallow and HIDDEN-agnostic: this backs the long-standing
/// `AnnotationText` complexity signal, and widening it would silently reroute
/// pages to OCR. [`annotation_paints_text_deep`] is the form-widget variant.
fn annotation_paints_text_shallow(annot: pdfium_sys::FPDF_ANNOTATION) -> bool {
let object_count = unsafe { ffi!(FPDFAnnot_GetObjectCount(annot)) };
(0..object_count).any(|object_index| {
let object = unsafe { ffi!(FPDFAnnot_GetObject(annot, object_index)) };
!object.is_null()
&& unsafe { ffi!(FPDFPageObj_GetType(object)) } == pdfium_sys::FPDF_PAGEOBJ_TEXT as i32
})
}
/// Whether a widget's appearance paints text, descending into nested form
/// XObjects.
///
/// PDFium parses an `/AP /N` stream into top-level objects, so a producer that
/// wraps variable text in `/Tx BMC ... /Fm0 Do EMC` (Acrobat and several
/// server-side fillers do) yields a form object, not a text object. Without the
/// descent those filled fields look empty and never get flattened.
fn annotation_paints_text_deep(annot: pdfium_sys::FPDF_ANNOTATION) -> bool {
// Invisible/hidden/noview widgets are not painted, so flattening them would
// introduce text the reader never sees.
let flags = unsafe { ffi!(FPDFAnnot_GetFlags(annot)) };
let suppressed = pdfium_sys::FPDF_ANNOT_FLAG_INVISIBLE
| pdfium_sys::FPDF_ANNOT_FLAG_HIDDEN
@@ -1153,19 +1282,45 @@ fn annotation_paints_text(annot: pdfium_sys::FPDF_ANNOTATION) -> bool {
let object_count = unsafe { ffi!(FPDFAnnot_GetObjectCount(annot)) };
(0..object_count).any(|object_index| {
let object = unsafe { ffi!(FPDFAnnot_GetObject(annot, object_index)) };
!object.is_null()
&& unsafe { ffi!(FPDFPageObj_GetType(object)) } == pdfium_sys::FPDF_PAGEOBJ_TEXT as i32
!object.is_null() && object_paints_text(object, 0)
})
}
fn restore_annotation_flags(page: pdfium_sys::FPDF_PAGE, originals: &[(i32, i32)]) {
/// Depth-bounded search for a text object, following form XObjects.
fn object_paints_text(object: pdfium_sys::FPDF_PAGEOBJECT, depth: u32) -> bool {
// Appearance nesting is shallow in practice; the cap only guards against
// pathological or cyclic documents.
const MAX_DEPTH: u32 = 8;
match unsafe { ffi!(FPDFPageObj_GetType(object)) } as u32 {
pdfium_sys::FPDF_PAGEOBJ_TEXT => true,
pdfium_sys::FPDF_PAGEOBJ_FORM if depth < MAX_DEPTH => {
let count = unsafe { ffi!(FPDFFormObj_CountObjects(object)) };
(0..count).any(|index| {
let child = unsafe {
ffi!(FPDFFormObj_GetObject(
object,
index as std::os::raw::c_ulong
))
};
!child.is_null() && object_paints_text(child, depth + 1)
})
}
_ => false,
}
}
fn restore_annotation_flags(
api: &FlattenApi,
page: pdfium_sys::FPDF_PAGE,
originals: &[(i32, i32)],
) {
for &(index, flags) in originals {
let annot = unsafe { ffi!(FPDFPage_GetAnnot(page, index)) };
if annot.is_null() {
continue;
}
unsafe {
ffi!(FPDFAnnot_SetFlags(annot, flags));
(api.set_flags)(annot, flags);
ffi!(FPDFPage_CloseAnnot(annot));
}
}
Binary file not shown.
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""Generate `integration_tests_data/filled_acroform.pdf`.
The fixture backs `test_filled_acroform_values_are_extracted_as_text`, which
asserts exact annotation and field counts. Writing the PDF by hand (rather than
checking in an opaque blob from some authoring tool) keeps those numbers
auditable and lets each widget target one specific behaviour:
Page 1 — 6 annotations, 5 form fields
1. `customer_name` value painted directly by the widget's /AP /N.
The base case: PDFium's text API cannot see it
until the page is flattened.
2. `invoice_date` value painted through a *nested* form XObject
(`/Tx BMC q /Fm0 Do Q EMC`), the shape Acrobat and
several server-side fillers emit. Detecting it
requires descending into form objects rather than
only inspecting the appearance's top-level objects.
3. `amount` value painted by the /AP *and* drawn into the page
content stream at the same spot, as partially
flattened files do. Must be extracted once, not
twice. Its rect also covers a `PREPRINTED-LABEL`
that only the content stream draws: flattening
replaces the content under a widget rect, so that
label must be restored rather than lost.
4. `default_only_choice` /V is set but no appearance paints it. An unpainted
default is not visible text and must stay out of
the text layer (it remains available as structured
form metadata).
5. `hidden_note` /AP paints text but the annotation carries the
Hidden flag, so it is never rendered and must not
reach the text layer.
6. (freetext annotation) a non-widget annotation whose appearance paints
text. Flattening must not promote it.
Page 2 — 1 annotation, 1 form field
`complexity_sentinel` a short value ("OK") on an otherwise empty page,
so the page stays under the "almost no text"
complexity threshold after flattening.
Page 3 — 1 annotation, 1 form field
`nested_only` the nested-XObject case again, but as the only
annotation on the page. Page 1's `invoice_date`
rides along with text-painting neighbours that
would trigger the flatten anyway; here nothing
else does, so the value is recovered only if the
appearance walk descends into form XObjects.
Usage: python3 scripts/generate_filled_acroform_fixture.py
"""
import pathlib
OUT = (
pathlib.Path(__file__).resolve().parent.parent
/ "integration_tests_data"
/ "filled_acroform.pdf"
)
PAGE_W, PAGE_H = 612, 792
# Annotation flag bits (PDF 32000-1 table 165).
F_PRINT = 4
F_HIDDEN = 2
class Pdf:
"""Minimal PDF writer: objects are 1-indexed in insertion order."""
def __init__(self):
self.objects = [None] # index 0 unused so object numbers start at 1
def reserve(self):
self.objects.append(None)
return len(self.objects) - 1
def put(self, num, body):
self.objects[num] = body
return num
def add(self, body):
return self.put(self.reserve(), body)
def stream(self, dict_body, content):
data = content.encode("latin-1")
return self.add(
f"<< {dict_body} /Length {len(data)} >>\nstream\n".encode("latin-1")
+ data
+ b"\nendstream"
)
def build(self):
out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
offsets = [0] * len(self.objects)
for num in range(1, len(self.objects)):
body = self.objects[num]
assert body is not None, f"object {num} was reserved but never filled"
if isinstance(body, str):
body = body.encode("latin-1")
offsets[num] = len(out)
out += f"{num} 0 obj\n".encode("latin-1") + body + b"\nendobj\n"
xref_at = len(out)
count = len(self.objects)
out += f"xref\n0 {count}\n".encode("latin-1")
out += b"0000000000 65535 f \n"
for num in range(1, count):
out += f"{offsets[num]:010d} 00000 n \n".encode("latin-1")
out += (
f"trailer\n<< /Size {count} /Root 1 0 R >>\nstartxref\n{xref_at}\n".encode(
"latin-1"
)
+ b"%%EOF\n"
)
return bytes(out)
def text_ops(text, font_res, size, x, y):
return f"q BT /{font_res} {size} Tf 0 g {x} {y} Td ({text}) Tj ET Q"
def main():
pdf = Pdf()
catalog = pdf.reserve()
pages = pdf.reserve()
page1 = pdf.reserve()
page2 = pdf.reserve()
page3 = pdf.reserve()
helv = pdf.add(
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
)
font_res = f"<< /Font << /Helv {helv} 0 R >> >>"
def widget(name, value, rect, ap_stream, flags=F_PRINT, page=None, extra=""):
left, bottom, right, top = rect
return pdf.add(
f"<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) "
f"/Rect [{left} {bottom} {right} {top}] /F {flags} /P {page or page1} 0 R "
f"/DA (/Helv 10 Tf 0 g) /AP << /N {ap_stream} 0 R >> {extra}>>"
)
def ap(width, height, content, resources=None):
return pdf.stream(
f"/Type /XObject /Subtype /Form /BBox [0 0 {width} {height}] "
f"/Resources {resources or font_res}",
content,
)
# 1. Value painted directly by the appearance stream.
customer = widget(
"customer_name",
"ACROFORM-CUSTOMER-7319",
(72, 700, 300, 720),
ap(228, 20, f"/Tx BMC {text_ops('ACROFORM-CUSTOMER-7319', 'Helv', 10, 2, 6)} EMC"),
)
# 2. Value painted through a nested form XObject.
inner = ap(228, 20, text_ops("2026-07-28", "Helv", 10, 2, 6))
date = widget(
"invoice_date",
"2026-07-28",
(72, 660, 300, 680),
ap(
228,
20,
f"/Tx BMC q /Fm0 Do Q EMC",
resources=f"<< /XObject << /Fm0 {inner} 0 R >> >>",
),
)
# 3. Value in the appearance *and* in the page content stream.
amount = widget(
"amount",
"50.00",
(72, 620, 300, 640),
ap(228, 20, f"/Tx BMC {text_ops('50.00', 'Helv', 10, 2, 6)} EMC"),
)
# 4. Value set, but the appearance paints only a border.
default_only = widget(
"default_only_choice",
"DEFAULT-ONLY-SHOULD-NOT-APPEAR",
(72, 580, 300, 600),
ap(228, 20, "q 0.5 w 0 0 228 20 re S Q"),
)
# 5. Appearance paints text, but the annotation is hidden.
hidden = widget(
"hidden_note",
"HIDDEN-SHOULD-NOT-APPEAR",
(72, 540, 300, 560),
ap(228, 20, f"/Tx BMC {text_ops('HIDDEN-SHOULD-NOT-APPEAR', 'Helv', 10, 2, 6)} EMC"),
flags=F_HIDDEN,
)
# 6. Non-widget annotation that paints text through its appearance.
freetext = pdf.add(
f"<< /Type /Annot /Subtype /FreeText /Rect [72 500 300 520] /F {F_PRINT} "
f"/P {page1} 0 R /Contents (ANNOTATION-ONLY-SHOULD-NOT-APPEAR) "
f"/DA (/Helv 10 Tf 0 g) /AP << /N "
f"{ap(228, 20, text_ops('ANNOTATION-ONLY-SHOULD-NOT-APPEAR', 'Helv', 10, 2, 6))} 0 R >> >>"
)
# Page 3 carries a nested-XObject widget and nothing else, so the page is
# only flattened if the appearance walk descends into form XObjects. On
# page 1 the equivalent widget rides along with its text-painting
# neighbours and would be flattened either way.
nested_inner = ap(228, 20, text_ops("NESTED-ONLY-VALUE", "Helv", 10, 2, 6))
nested_only = widget(
"nested_only",
"NESTED-ONLY-VALUE",
(72, 700, 300, 720),
ap(
228,
20,
"/Tx BMC q /Fm0 Do Q EMC",
resources=f"<< /XObject << /Fm0 {nested_inner} 0 R >> >>",
),
page=page3,
)
sentinel_ap = ap(228, 20, f"/Tx BMC {text_ops('OK', 'Helv', 10, 2, 6)} EMC")
sentinel = pdf.add(
f"<< /Type /Annot /Subtype /Widget /FT /Tx /T (complexity_sentinel) /V (OK) "
f"/Rect [72 700 300 720] /F {F_PRINT} /P {page2} 0 R "
f"/DA (/Helv 10 Tf 0 g) /AP << /N {sentinel_ap} 0 R >> >>"
)
# Page 1 content, all of it drawn *before* any widget appearance:
# - a plain title, well clear of every widget rect;
# - the `amount` value, at the exact origin its appearance paints it too;
# - a pre-printed label at the exact origin of the `customer_name`
# appearance, which no appearance reproduces.
#
# PDFium's text layer suppresses one of two runs that start at essentially
# the same point, so both of these collide with a flattened appearance. The
# first collision is between identical strings and is exactly the dedup a
# partially flattened file needs — the value must come out once. The second
# is between different strings, where suppression is pure data loss, so the
# label has to be restored.
page1_content = pdf.stream(
"",
"\n".join(
[
text_ops("Invoice", "Helv", 14, 72, 750),
text_ops("50.00", "Helv", 10, 74, 626),
text_ops("PREPRINTED-LABEL", "Helv", 10, 74, 706),
]
),
)
# Pages 2 and 3 stay empty so each page's widget is the only text on it.
page2_content = pdf.stream("", "")
page3_content = pdf.stream("", "")
page1_annots = [customer, date, amount, default_only, hidden, freetext]
pdf.put(
page1,
f"<< /Type /Page /Parent {pages} 0 R /MediaBox [0 0 {PAGE_W} {PAGE_H}] "
f"/Resources {font_res} /Contents {page1_content} 0 R "
f"/Annots [{' '.join(f'{n} 0 R' for n in page1_annots)}] >>",
)
pdf.put(
page2,
f"<< /Type /Page /Parent {pages} 0 R /MediaBox [0 0 {PAGE_W} {PAGE_H}] "
f"/Resources {font_res} /Contents {page2_content} 0 R "
f"/Annots [{sentinel} 0 R] >>",
)
pdf.put(
page3,
f"<< /Type /Page /Parent {pages} 0 R /MediaBox [0 0 {PAGE_W} {PAGE_H}] "
f"/Resources {font_res} /Contents {page3_content} 0 R "
f"/Annots [{nested_only} 0 R] >>",
)
pdf.put(
pages,
f"<< /Type /Pages /Kids [{page1} 0 R {page2} 0 R {page3} 0 R] /Count 3 >>",
)
fields = page1_annots[:5] + [sentinel, nested_only]
pdf.put(
catalog,
f"<< /Type /Catalog /Pages {pages} 0 R /AcroForm << "
f"/Fields [{' '.join(f'{n} 0 R' for n in fields)}] "
f"/DA (/Helv 10 Tf 0 g) /DR {font_res} >> >>",
)
OUT.write_bytes(pdf.build())
print(f"wrote {OUT} ({OUT.stat().st_size} bytes)")
if __name__ == "__main__":
main()