Skip to content

Commit 2af82fe

Browse files
committed
chore(pdf): add hidden-text-layer diagnostic (debug_text_objects)
Investigating the reported code-block duplication (e.g. dotnet-csharp-language- reference.pdf), the most likely cause is a hidden/invisible duplicate text layer that the char-level text extraction pulls in — common in web-generated PDFs whose syntax-highlighted code carries a plain-text copy for copy-paste. The clean fix (skip text render mode 3) is blocked for now: pdfium-render 0.8's default `pdfium_latest` binding does not expose the per-character FPDFText_GetTextRenderMode (it is feature-gated to pdfium <= 6569). The object-level FPDFTextObj_GetTextRenderMode *is* available, so add: - `debug_text_objects(bytes, page)` — every text object with its visibility (render mode 3), bounding box, and text; and - the `dump_render_modes` example — find the page containing a needle and list its text objects, so we can confirm on the actual PDF whether the duplicate is an invisible layer (and at what geometry) before implementing the filter. No pipeline behavior changes; this only adds a diagnostic accessor + example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H3AUFjF71HP2YY9cD32wx8
1 parent 2ae1f25 commit 2af82fe

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//! Diagnose a duplicated / hidden PDF text layer.
2+
//!
3+
//! Finds the first page whose text objects contain `needle` and lists that page's
4+
//! text objects, each tagged visible or INVISIBLE (text render mode 3), with their
5+
//! bounding box and text. A hidden duplicate layer — e.g. the plain-text copy
6+
//! web exporters stash behind a syntax-highlighted code block — shows up as
7+
//! INVISIBLE objects repeating the visible text, usually at a different position.
8+
//!
9+
//! Usage:
10+
//! cargo run -p fleischwolf-pdf --example dump_render_modes -- file.pdf "LangVersion"
11+
12+
use fleischwolf_pdf::pdfium_backend::{debug_text_objects, page_count};
13+
14+
fn main() {
15+
let path = std::env::args()
16+
.nth(1)
17+
.expect("usage: dump_render_modes <pdf> <needle>");
18+
let needle = std::env::args().nth(2).expect("needle substring required");
19+
let bytes = std::fs::read(&path).expect("read pdf");
20+
let pages = page_count(&bytes, None).expect("page count");
21+
22+
for p in 0..pages as i32 {
23+
let objs = debug_text_objects(&bytes, p);
24+
if !objs.iter().any(|o| o.text.contains(&needle)) {
25+
continue;
26+
}
27+
let visible = objs.iter().filter(|o| !o.invisible).count();
28+
let invisible = objs.iter().filter(|o| o.invisible).count();
29+
println!(
30+
"page {p}: {} text object(s) — {visible} visible, {invisible} INVISIBLE\n",
31+
objs.len()
32+
);
33+
for o in &objs {
34+
let tag = if o.invisible {
35+
"INVISIBLE"
36+
} else {
37+
"visible "
38+
};
39+
let text: String = o.text.chars().take(70).collect();
40+
println!(
41+
" [{tag}] l={:8.2} b={:8.2} r={:8.2} t={:8.2} {text:?}",
42+
o.l, o.b, o.r, o.t
43+
);
44+
}
45+
return;
46+
}
47+
println!("needle {needle:?} not found in any text object across {pages} page(s)");
48+
}

crates/fleischwolf-pdf/src/pdfium_backend.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,87 @@ pub fn debug_glyphs(bytes: &[u8], index: i32) -> Vec<(char, f32, f32)> {
411411
out
412412
}
413413

414+
/// One text object on a page, for the hidden-layer diagnostic.
415+
#[derive(Debug, Clone)]
416+
pub struct DebugTextObject {
417+
/// True when the object is drawn invisibly (text render mode 3) — the marker of
418+
/// a hidden duplicate text layer.
419+
pub invisible: bool,
420+
/// Bounding box in native PDF points (bottom-left origin).
421+
pub l: f32,
422+
pub b: f32,
423+
pub r: f32,
424+
pub t: f32,
425+
/// The object's text (best-effort; empty if it could not be read).
426+
pub text: String,
427+
}
428+
429+
/// Diagnostic: every text object on page `index`, each tagged visible/invisible
430+
/// (via the object-level [`FPDFTextObj_GetTextRenderMode`], which — unlike the
431+
/// per-character render-mode API — is available on the default pdfium binding).
432+
/// A hidden duplicate text layer shows up as invisible objects repeating the
433+
/// visible text. Used by the `dump_render_modes` example.
434+
///
435+
/// [`FPDFTextObj_GetTextRenderMode`]: pdfium_render::prelude::PdfiumLibraryBindings::FPDFTextObj_GetTextRenderMode
436+
pub fn debug_text_objects(bytes: &[u8], index: i32) -> Vec<DebugTextObject> {
437+
let Ok(pdfium) = bind() else {
438+
return Vec::new();
439+
};
440+
let ffi = FfiText::load(pdfium.bindings(), bytes, None);
441+
if ffi.doc.is_null() {
442+
return Vec::new();
443+
}
444+
let b = ffi.bindings;
445+
let page = b.FPDF_LoadPage(ffi.doc, index);
446+
if page.is_null() {
447+
return Vec::new();
448+
}
449+
let tp = b.FPDFText_LoadPage(page);
450+
let mut out = Vec::new();
451+
let n = b.FPDFPage_CountObjects(page);
452+
for i in 0..n {
453+
let obj = b.FPDFPage_GetObject(page, i);
454+
if obj.is_null() || b.FPDFPageObj_GetType(obj) != FPDF_PAGEOBJ_TEXT as i32 {
455+
continue;
456+
}
457+
let (mut l, mut bot, mut r, mut top) = (0f32, 0f32, 0f32, 0f32);
458+
if b.FPDFPageObj_GetBounds(obj, &mut l, &mut bot, &mut r, &mut top) == 0 {
459+
continue;
460+
}
461+
let invisible = b.FPDFTextObj_GetTextRenderMode(obj) == INVISIBLE_RENDER_MODE;
462+
let text = if tp.is_null() {
463+
String::new()
464+
} else {
465+
// FPDFTextObj_GetText returns the count of UTF-16 code units, including
466+
// the trailing NUL; call once for the size, once to fill.
467+
let need = b.FPDFTextObj_GetText(obj, tp, std::ptr::null_mut(), 0);
468+
if need <= 1 {
469+
String::new()
470+
} else {
471+
let mut buf = vec![0u16; need as usize];
472+
b.FPDFTextObj_GetText(obj, tp, buf.as_mut_ptr(), need);
473+
if let Some(&0) = buf.last() {
474+
buf.pop();
475+
}
476+
String::from_utf16_lossy(&buf)
477+
}
478+
};
479+
out.push(DebugTextObject {
480+
invisible,
481+
l,
482+
b: bot,
483+
r,
484+
t: top,
485+
text,
486+
});
487+
}
488+
if !tp.is_null() {
489+
b.FPDFText_ClosePage(tp);
490+
}
491+
b.FPDF_ClosePage(page);
492+
out
493+
}
494+
414495
/// Hash a glyph's PDF font name + flags, for `enforce_same_font`. 0 if unavailable.
415496
fn font_hash(b: &dyn PdfiumLibraryBindings, tp: FPDF_TEXTPAGE, i: i32) -> u64 {
416497
use std::hash::{Hash, Hasher};
@@ -433,6 +514,12 @@ fn font_hash(b: &dyn PdfiumLibraryBindings, tp: FPDF_TEXTPAGE, i: i32) -> u64 {
433514
h.finish()
434515
}
435516

517+
/// pdfium text render mode 3: the glyph is drawn with neither fill nor stroke —
518+
/// an invisible glyph. Web-to-PDF exporters put a hidden plain-text copy of
519+
/// syntax-highlighted code (and other "copy"/accessibility layers) in this mode,
520+
/// which the char-level text API then extracts as a duplicate of the visible text.
521+
const INVISIBLE_RENDER_MODE: i32 = 3;
522+
436523
fn glyphs(b: &dyn PdfiumLibraryBindings, tp: FPDF_TEXTPAGE, fetch_font: bool) -> Vec<Glyph> {
437524
let n = b.FPDFText_CountChars(tp);
438525
let mut out = Vec::with_capacity(n.max(0) as usize);

0 commit comments

Comments
 (0)