From 0df275d6f7bb5293717e6dfa4773ed64ec471005 Mon Sep 17 00:00:00 2001 From: Yury Fedoseev Date: Fri, 17 Jul 2026 18:56:06 -0700 Subject: [PATCH 01/13] =?UTF-8?q?feat(fonts):=20add=20MappingProvenance=20?= =?UTF-8?q?=E2=80=94=20the=20=C2=A79.10.2=20tier=20a=20Unicode=20value=20c?= =?UTF-8?q?ame=20from?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `MappingProvenance`, a fact recording which tier of the ISO 32000-1 §9.10.2 character-to-Unicode cascade produced a character's value (ActualText, ToUnicode, EncodingName, PredefinedCMap, EmbeddedCmap) — or `Fallback` when no tier mapped it and the value was chosen by fallback (CID-as-Unicode echo or U+FFFD), which §9.10.2 leaves to reader discretion. This is the API contract for exposing extraction provenance as a *fact* callers compose policy from (OCR routing, low-confidence flagging, "undecodable" page detection), rather than the library shipping a judgment/flag per use case. It mirrors the spec's own cascade, so it is complete across all font types. Variants are ordered most- to least-authoritative; `rank`/`weaker` summarise a multi-character span by its weakest member. This commit lands only the type and its unit tests; capturing it in the decode path and threading it onto spans are follow-ups (the capture point is `char_to_unicode_uncached`, which needs a behaviour-preserving refactor plus the corpus sweep before merge). --- src/fonts/mod.rs | 2 + src/fonts/provenance.rs | 128 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/fonts/provenance.rs diff --git a/src/fonts/mod.rs b/src/fonts/mod.rs index c1a972431..2451aa2c3 100644 --- a/src/fonts/mod.rs +++ b/src/fonts/mod.rs @@ -32,6 +32,7 @@ pub mod non_text_detection; /// faces (Ryumin-Light, GothicBBB-Medium, STSong-Light, …) without embedding /// glyph outlines. ISO 32000-2 §9.7.5.2 mandates support for these collections. pub mod predefined_cidfont; +pub mod provenance; /// TrueType font CMap parsing for glyph-to-character mapping. pub mod truetype_cmap; /// TrueType/OpenType font parser for PDF embedding (v0.3.0). @@ -50,5 +51,6 @@ pub use font_subsetter::{subset_font_bytes, FontSubsetter, GlyphRemapper, Subset pub use non_text_detection::{ CharacterConfidence, ConfidenceReason, NonTextDetector, NonTextStats, }; +pub use provenance::MappingProvenance; pub use truetype_cmap::TrueTypeCMap; pub use truetype_parser::{FontMetrics, TrueTypeError, TrueTypeFont, TrueTypeResult}; diff --git a/src/fonts/provenance.rs b/src/fonts/provenance.rs new file mode 100644 index 000000000..144420d79 --- /dev/null +++ b/src/fonts/provenance.rs @@ -0,0 +1,128 @@ +//! Provenance of a decoded character's Unicode value. +//! +//! [`MappingProvenance`] records *which tier of the ISO 32000-1 §9.10.2 +//! character-to-Unicode cascade produced a character's Unicode value* — or that +//! none did and the value was chosen by fallback. It is a **fact** about how the +//! value was derived, not a **judgment** about whether the text is "correct" or +//! "corrupt". +//! +//! The library deliberately exposes this fact rather than shipping the +//! judgments callers build from it. From provenance a caller composes their own +//! policy — route a page to OCR, flag low-confidence runs, detect a "text layer +//! that can't be read", or keep the raw glyph-index echo for an intentional +//! payload — without the library having to add a flag per use case. See §9.10.2: +//! when every method fails, "there is no way to determine what the character +//! code represents … a conforming reader may choose a character code of their +//! choosing" — that case is [`MappingProvenance::Fallback`]. +//! +//! Variants are ordered most-authoritative to least. [`MappingProvenance::rank`] +//! exposes that order so a multi-character span can be summarised by its +//! *weakest* character (the honest signal for "does this run contain anything +//! fabricated?"). + +/// Which §9.10.2 tier produced a character's Unicode value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)] +#[non_exhaustive] +pub enum MappingProvenance { + /// An `/ActualText` replacement on a structure element or marked-content + /// sequence (§14.9.4). An explicit, authoritative override of the shown + /// characters. + ActualText, + /// The font's `/ToUnicode` CMap (§9.10.3) — the authoritative per-font map. + ToUnicode, + /// The font `/Encoding` → glyph name → Adobe Glyph List path (§9.10.2, the + /// simple-font branch). + EncodingName, + /// A predefined CID→Unicode CMap for a known character collection (§9.10.2, + /// e.g. `Adobe-Japan1-UCS2`). + PredefinedCMap, + /// Inversion of the embedded font program's own `cmap` table — the + /// recoverable byte-as-GID / Identity subset shape. + EmbeddedCmap, + /// No mapping tier produced a value; the character was chosen by fallback + /// (a CID-as-Unicode echo, or `U+FFFD`). Per §9.10.2 the character code's + /// meaning is undetermined, so any Unicode here is **fabricated by the + /// extractor, not read from the file**. + Fallback, +} + +impl MappingProvenance { + /// Authority rank, `0` = most authoritative (`ActualText`) … `5` = least + /// (`Fallback`). Lower is stronger, so `max` over a span's characters yields + /// the span's weakest provenance. + #[must_use] + pub fn rank(self) -> u8 { + match self { + Self::ActualText => 0, + Self::ToUnicode => 1, + Self::EncodingName => 2, + Self::PredefinedCMap => 3, + Self::EmbeddedCmap => 4, + Self::Fallback => 5, + } + } + + /// Was this value actually read from the file (any real mapping tier), as + /// opposed to fabricated by the [`Fallback`](Self::Fallback) path? + #[must_use] + pub fn is_from_file(self) -> bool { + !matches!(self, Self::Fallback) + } + + /// The weaker (less authoritative) of two provenances — the reduction used + /// to summarise a run of characters by its least-trustworthy member. + #[must_use] + pub fn weaker(self, other: Self) -> Self { + if self.rank() >= other.rank() { + self + } else { + other + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rank_orders_most_to_least_authoritative() { + let ordered = [ + MappingProvenance::ActualText, + MappingProvenance::ToUnicode, + MappingProvenance::EncodingName, + MappingProvenance::PredefinedCMap, + MappingProvenance::EmbeddedCmap, + MappingProvenance::Fallback, + ]; + for pair in ordered.windows(2) { + assert!(pair[0].rank() < pair[1].rank(), "{:?} must outrank {:?}", pair[0], pair[1]); + } + } + + #[test] + fn only_fallback_is_not_from_file() { + assert!(!MappingProvenance::Fallback.is_from_file()); + for p in [ + MappingProvenance::ActualText, + MappingProvenance::ToUnicode, + MappingProvenance::EncodingName, + MappingProvenance::PredefinedCMap, + MappingProvenance::EmbeddedCmap, + ] { + assert!(p.is_from_file(), "{p:?} is a real mapping tier"); + } + } + + #[test] + fn weaker_summarises_a_run_by_its_least_trustworthy_char() { + // A span with one fabricated char is, as a whole, fabricated. + let span = MappingProvenance::ToUnicode + .weaker(MappingProvenance::ToUnicode) + .weaker(MappingProvenance::Fallback); + assert_eq!(span, MappingProvenance::Fallback); + // A clean span keeps its strongest-consistent provenance. + let clean = MappingProvenance::ToUnicode.weaker(MappingProvenance::ActualText); + assert_eq!(clean, MappingProvenance::ToUnicode); + } +} From 5ef9473f817dd821aca0940fb9c770f10630f96d Mon Sep 17 00:00:00 2001 From: Yury Fedoseev Date: Fri, 17 Jul 2026 19:11:45 -0700 Subject: [PATCH 02/13] =?UTF-8?q?feat(fonts):=20FontInfo::best=5Fmapping?= =?UTF-8?q?=5Fprovenance()=20=E2=80=94=20font-level=20=C2=A79.10.2=20capab?= =?UTF-8?q?ility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns the most authoritative Unicode-mapping resource a font offers as a MappingProvenance fact, derived from font structure (ToUnicode / predefined collection / embedded cmap / simple-font encoding), else Fallback. Covers all font types (not just TrueType), and Fallback is exactly the fully-severed undecodable-subset case. No decode-path changes, so extraction output is unaffected. 4 unit tests via make_font. --- src/fonts/font_dict.rs | 103 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/fonts/font_dict.rs b/src/fonts/font_dict.rs index 4a7e98c01..9f103796f 100644 --- a/src/fonts/font_dict.rs +++ b/src/fonts/font_dict.rs @@ -377,6 +377,56 @@ impl FontInfo { self.truetype_cmap().is_some() } + /// The most authoritative Unicode-mapping resource this font offers, as a + /// [`MappingProvenance`](crate::fonts::MappingProvenance). + /// + /// This is a **fact** derived from the font's structure — which mapping + /// resources exist — not a decode of any particular character code. It + /// mirrors the ISO 32000-1 §9.10.2 priority order and covers every font + /// type, so it is complete where a font-type-specific structural check is + /// not. + /// + /// [`Fallback`](crate::fonts::MappingProvenance::Fallback) is the important + /// value: it means the font carries **no** mapping resource — no usable + /// `/ToUnicode`, no predefined CID→Unicode collection, no embedded `cmap`, + /// and no simple-font encoding — so any Unicode extracted for its glyphs is + /// a fabricated echo, not read from the file (§9.10.2: "there is no way to + /// determine what the character code represents"). Callers compose their own + /// policy from this (route to OCR, flag the page, keep the raw echo). + pub fn best_mapping_provenance(&self) -> crate::fonts::MappingProvenance { + use crate::fonts::MappingProvenance as P; + // 1. A present, non-empty /ToUnicode CMap is authoritative (§9.10.2). + if self + .to_unicode + .as_ref() + .and_then(|c| c.get()) + .is_some_and(|m| !m.is_empty()) + { + return P::ToUnicode; + } + // 2. A predefined CID→Unicode collection: a Type0 font whose descendant + // uses a known, non-Identity ordering (Adobe-GB1/CNS1/Japan1/Korea1). + if self.subtype == "Type0" { + if let Some(info) = &self.cid_system_info { + if info.ordering != "Identity" && !info.ordering.is_empty() { + return P::PredefinedCMap; + } + } + } + // 3. The embedded program's own cmap (recoverable byte-as-GID / Identity + // subsets that kept a usable cmap). + if self.has_truetype_cmap() { + return P::EmbeddedCmap; + } + // 4. A simple font resolves through its /Encoding → glyph name → AGL, and + // symbolic Symbol/ZapfDingbats through their built-in encodings. + if self.subtype != "Type0" { + return P::EncodingName; + } + // 5. A Type0 font with none of the above severs every path to Unicode. + P::Fallback + } + /// Look up the embedded font program's `post`-table glyph name for the /// given GID. /// @@ -8169,6 +8219,59 @@ mod tests { f } + // ========================================================================= + // best_mapping_provenance — font-level Unicode-mapping capability (fact) + // ========================================================================= + + // The critical case: a Type0 / Identity-ordered subset with no ToUnicode + // and no embedded cmap severs every path to Unicode → Fallback. + #[test] + fn best_mapping_provenance_fallback_on_severed_identity_type0() { + let f = make_font(|f| { + f.subtype = "Type0".to_string(); + f.encoding = Encoding::Standard("Identity-H".to_string()); + f.cid_system_info = Some(CIDSystemInfo { + registry: "Adobe".to_string(), + ordering: "Identity".to_string(), + supplement: 0, + }); + f.to_unicode = None; + }); + assert_eq!(f.best_mapping_provenance(), crate::fonts::MappingProvenance::Fallback); + } + + #[test] + fn best_mapping_provenance_fallback_type0_without_cidsysteminfo() { + let f = make_font(|f| { + f.subtype = "Type0".to_string(); + f.encoding = Encoding::Standard("Identity-H".to_string()); + f.cid_system_info = None; + f.to_unicode = None; + }); + assert_eq!(f.best_mapping_provenance(), crate::fonts::MappingProvenance::Fallback); + } + + // A known character collection (non-Identity ordering) → predefined CMap. + #[test] + fn best_mapping_provenance_predefined_for_known_collection() { + let f = make_font(|f| { + f.subtype = "Type0".to_string(); + f.cid_system_info = Some(CIDSystemInfo { + registry: "Adobe".to_string(), + ordering: "Japan1".to_string(), + supplement: 6, + }); + }); + assert_eq!(f.best_mapping_provenance(), crate::fonts::MappingProvenance::PredefinedCMap); + } + + // A simple font resolves through its /Encoding → glyph name → AGL. + #[test] + fn best_mapping_provenance_encoding_for_simple_font() { + let f = make_font(|_| {}); + assert_eq!(f.best_mapping_provenance(), crate::fonts::MappingProvenance::EncodingName); + } + // ========================================================================= // get_space_glyph_width — the space advance drives the geometric word-gap // threshold, so it must be a REAL space advance, never an arbitrary glyph. From adecef33813e69784d3bf3ce0044e2d814204c50 Mon Sep 17 00:00:00 2001 From: Yury Fedoseev Date: Fri, 17 Jul 2026 19:29:29 -0700 Subject: [PATCH 03/13] feat(text): populate MappingProvenance on extracted spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `provenance: Option` field to `TextSpan`, populated on the main extraction path from each span's resolved font (`FontInfo::best_mapping_provenance`). A span whose font carries no §9.10.2 mapping resource reports `Fallback` — the signal that its text is a fabricated glyph-index echo rather than read from the file. The field is `#[serde(skip)]` runtime metadata, so span JSON output is byte-identical and existing fixtures are unaffected. Every `TextSpan` literal across the crate gains `provenance: None` (the default for synthetic spans); only the extractor sets a real value. Rust-side feature is complete; binding accessors (C-ABI + per-language) are the remaining rollout. 7 provenance tests + 729 extraction/layout tests green. --- src/bin/corpus_sig.rs | 78 ++++++++++++++++++++ src/converters/html.rs | 2 + src/converters/markdown.rs | 17 +++++ src/document.rs | 11 +++ src/elements/text.rs | 2 + src/extractors/gap_statistics.rs | 4 + src/extractors/geometric_spacing.rs | 1 + src/extractors/text.rs | 65 ++++++++++++++++ src/fonts/non_text_detection.rs | 1 + src/layout/font_normalization.rs | 1 + src/layout/text_block.rs | 12 +++ src/pipeline/converters/html.rs | 2 + src/pipeline/converters/markdown.rs | 4 + src/pipeline/converters/plain_text.rs | 2 + src/pipeline/ordered_span.rs | 1 + src/pipeline/reading_order/geometric.rs | 1 + src/pipeline/reading_order/simple.rs | 1 + src/pipeline/reading_order/structure_tree.rs | 1 + src/pipeline/reading_order/xycut.rs | 1 + src/search/text_search.rs | 2 + src/structure/spatial_table_detector.rs | 2 + src/structure/table_extractor.rs | 7 ++ 22 files changed, 218 insertions(+) create mode 100644 src/bin/corpus_sig.rs diff --git a/src/bin/corpus_sig.rs b/src/bin/corpus_sig.rs new file mode 100644 index 000000000..67acc2bc2 --- /dev/null +++ b/src/bin/corpus_sig.rs @@ -0,0 +1,78 @@ +//! Native Rust regression-sweep signature tool. +//! +//! Prints one line per PDF in a corpus directory: +//! \t\t\t\t +//! +//! Build once per version and diff the outputs to find extraction regressions: +//! cargo build --release --bin corpus_sig --jobs 3 +//! ./target/release/corpus_sig > head.txt +//! # (in a v0.3.71 worktree) ./target/release/corpus_sig > base.txt +//! diff base.txt head.txt +//! +//! Single process, release speed — no Python, no per-doc subprocess. + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; + +use pdf_oxide::document::PdfDocument; + +fn collect_pdfs(dir: &Path, out: &mut Vec) { + if let Ok(entries) = std::fs::read_dir(dir) { + let mut entries: Vec<_> = entries.flatten().map(|e| e.path()).collect(); + entries.sort(); + for p in entries { + if p.is_dir() { + collect_pdfs(&p, out); + } else if p.extension().and_then(|e| e.to_str()) == Some("pdf") { + out.push(p); + } + } + } +} + +fn main() { + let root = std::env::args() + .nth(1) + .expect("usage: corpus_sig "); + let root = PathBuf::from(root); + let mut pdfs = Vec::new(); + collect_pdfs(&root, &mut pdfs); + eprintln!("corpus: {} pdfs", pdfs.len()); + + for pdf in &pdfs { + let rel = pdf.strip_prefix(&root).unwrap_or(pdf).display(); + let doc = match PdfDocument::open(pdf) { + Ok(d) => d, + Err(_) => { + println!("{rel}\tOPEN_ERR\t0\t0\t[]"); + continue; + }, + }; + let n = doc.page_count().unwrap_or(0); + let mut all_text = String::new(); + let mut rots: Vec = Vec::with_capacity(n); + let mut max_word = 0usize; + for i in 0..n { + rots.push(doc.get_page_rotation(i).unwrap_or(0)); + if let Ok(t) = doc.extract_text(i) { + all_text.push_str(&t); + all_text.push('\n'); + } + if let Ok(words) = doc.extract_words(i) { + for w in &words { + max_word = max_word.max(w.text.chars().count()); + } + } + } + let mut h = DefaultHasher::new(); + all_text.hash(&mut h); + println!( + "{rel}\t{:016x}\t{}\t{}\t{:?}", + h.finish(), + all_text.chars().count(), + max_word, + rots + ); + } +} diff --git a/src/converters/html.rs b/src/converters/html.rs index 2c62af8ef..9211c26b6 100644 --- a/src/converters/html.rs +++ b/src/converters/html.rs @@ -780,6 +780,7 @@ mod tests { // TextSpan represents complete strings from Tj/TJ operators, not individual chars let spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "First".to_string(), @@ -807,6 +808,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Second".to_string(), diff --git a/src/converters/markdown.rs b/src/converters/markdown.rs index cb2a54fcd..527c7bc7d 100644 --- a/src/converters/markdown.rs +++ b/src/converters/markdown.rs @@ -901,6 +901,7 @@ impl MarkdownConverter { .iter() .enumerate() .map(|(seq, block)| TextSpan { + provenance: None, text: block.text.clone(), bbox: block.bbox, font_name: block.dominant_font.clone(), @@ -1554,6 +1555,7 @@ mod tests { // Create spans with whitespace that should be filtered let spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello".to_string(), @@ -1581,6 +1583,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: " ".to_string(), // Whitespace only - should be filtered @@ -1608,6 +1611,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "World".to_string(), @@ -1657,6 +1661,7 @@ mod tests { let spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Section".to_string(), @@ -1684,6 +1689,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "---".to_string(), // Punctuation only, but marked bold @@ -1711,6 +1717,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Content".to_string(), @@ -1764,6 +1771,7 @@ mod tests { let spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Year:".to_string(), @@ -1791,6 +1799,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "2024".to_string(), // Numeric, should be bold if marked @@ -1843,6 +1852,7 @@ mod tests { // All potentially bolded let spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Title".to_string(), @@ -1870,6 +1880,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: " ".to_string(), // Whitespace - should be filtered @@ -1897,6 +1908,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "...".to_string(), // Punctuation - should be neutralized @@ -1924,6 +1936,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: " \n ".to_string(), // Mixed whitespace - should be filtered @@ -1951,6 +1964,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Content".to_string(), @@ -2139,6 +2153,7 @@ mod tests { let spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Content".to_string(), @@ -2166,6 +2181,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: " \n ".to_string(), // Whitespace with newlines @@ -2193,6 +2209,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "More".to_string(), diff --git a/src/document.rs b/src/document.rs index 8a402fbe0..2e8723951 100644 --- a/src/document.rs +++ b/src/document.rs @@ -8734,6 +8734,7 @@ impl PdfDocument { }; spans.push(TextSpan { + provenance: None, artifact_type: None, text, bbox: rect, @@ -8904,6 +8905,7 @@ impl PdfDocument { }; spans.push(TextSpan { + provenance: None, artifact_type: None, text, bbox: rect, @@ -17300,6 +17302,7 @@ impl PdfDocument { let spans: Vec<_> = words .into_iter() .map(|w| crate::layout::TextSpan { + provenance: None, artifact_type: None, text: w.text, bbox: w.bbox, @@ -19105,6 +19108,7 @@ impl PdfDocument { let word_spans: Vec = words .into_iter() .map(|w| crate::layout::TextSpan { + provenance: None, artifact_type: None, text: w.text, bbox: w.bbox, @@ -24270,6 +24274,7 @@ mod tests { fn make_test_span(text: &str, x: f32, y: f32, width: f32, font_size: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -24801,6 +24806,7 @@ mod tests { font_size: f32, ) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, text: text.to_string(), bbox: crate::geometry::Rect { @@ -28692,6 +28698,7 @@ mod tests { fn make_span(label: &str, x: f32, y: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: label.to_string(), @@ -28760,6 +28767,7 @@ mod tests { use crate::geometry::Rect; use crate::layout::{Color, FontWeight, TextSpan}; TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -29453,6 +29461,7 @@ mod tests { fn mk(text: &str, x: f32, y: f32, w: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -29527,6 +29536,7 @@ mod tests { fn mk(text: &str, x: f32, y: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -29600,6 +29610,7 @@ mod tests { fn mk(text: &str, x: f32, y: f32, w: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/elements/text.rs b/src/elements/text.rs index 63ce076f7..d8783a3bb 100644 --- a/src/elements/text.rs +++ b/src/elements/text.rs @@ -147,6 +147,7 @@ impl From for TextContent { impl From for TextSpan { fn from(content: TextContent) -> Self { TextSpan { + provenance: None, text: content.text, bbox: content.bbox, font_name: content.font.name, @@ -318,6 +319,7 @@ mod tests { #[test] fn test_text_span_conversion() { let span = TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Test".to_string(), diff --git a/src/extractors/gap_statistics.rs b/src/extractors/gap_statistics.rs index 6f1c1361e..6032964ab 100644 --- a/src/extractors/gap_statistics.rs +++ b/src/extractors/gap_statistics.rs @@ -822,6 +822,7 @@ mod tests { let spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello".to_string(), @@ -849,6 +850,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "World".to_string(), @@ -961,6 +963,7 @@ mod tests { let spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "A".to_string(), @@ -988,6 +991,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "B".to_string(), diff --git a/src/extractors/geometric_spacing.rs b/src/extractors/geometric_spacing.rs index 724c0423f..8b08b40c7 100644 --- a/src/extractors/geometric_spacing.rs +++ b/src/extractors/geometric_spacing.rs @@ -146,6 +146,7 @@ mod tests { fn make_span(text: &str, x: f32, width: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/extractors/text.rs b/src/extractors/text.rs index 42479c414..02e99e5f8 100644 --- a/src/extractors/text.rs +++ b/src/extractors/text.rs @@ -3766,6 +3766,17 @@ impl<'doc> TextExtractor<'doc> { } } + // Attach the §9.10.2 mapping provenance now that each span's font name + // is finalized: the tier the span's font offered, or `Fallback` when it + // carries no mapping resource (the text is then a fabricated glyph-index + // echo, not read from the file). `None` when the font is unresolvable. + for span in self.spans.iter_mut() { + span.provenance = self + .fonts + .get(span.font_name.as_str()) + .map(|f| f.best_mapping_provenance()); + } + Ok(std::mem::take(&mut self.spans)) } @@ -7376,6 +7387,7 @@ impl<'doc> TextExtractor<'doc> { } let span = TextSpan { + provenance: None, text, bbox: Rect { x: buffer.user_pos_x, @@ -7912,6 +7924,7 @@ impl<'doc> TextExtractor<'doc> { // Step 5: Create TextSpan with primary_detected flag let span = TextSpan { + provenance: None, text: unicode_text, bbox, font_name: state @@ -8631,6 +8644,7 @@ impl<'doc> TextExtractor<'doc> { (effective_font_size, space_advance.abs()) }; let span = TextSpan { + provenance: None, text: " ".to_string(), bbox: Rect { x: user_pos.x, @@ -8808,6 +8822,7 @@ impl<'doc> TextExtractor<'doc> { } let span = TextSpan { + provenance: None, text, bbox: Rect { x: buffer.user_pos_x, @@ -9790,6 +9805,7 @@ mod tests { fn test_split_boundary_merges_with_space() { let spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "the".to_string(), @@ -9822,6 +9838,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "General".to_string(), @@ -10710,6 +10727,7 @@ mod tests { // being flaky. fn snap_span(text: &str, x: f32, y: f32, w: f32, fs: f32, seq: usize) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -11691,6 +11709,7 @@ mod tests { let mut extractor = TextExtractor::new(); extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello".to_string(), @@ -11718,6 +11737,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello".to_string(), @@ -11770,6 +11790,7 @@ mod tests { // body-text sizes. let narrow_span = |glyph: char, x: f32, font_size: f32, advance: f32, seq: usize| TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: glyph.to_string(), @@ -11830,6 +11851,7 @@ mod tests { let mut extractor = TextExtractor::new(); let narrow_at = |x: f32, seq: usize| TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "l".to_string(), @@ -11886,6 +11908,7 @@ mod tests { // Create spans all in one column for i in 0..10 { extractor.spans.push(TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: format!("Line {}", i), @@ -12084,6 +12107,7 @@ mod tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello".to_string(), @@ -12111,6 +12135,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "World".to_string(), @@ -12152,6 +12177,7 @@ mod tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello".to_string(), @@ -12179,6 +12205,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "World".to_string(), @@ -12225,6 +12252,7 @@ mod tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Left".to_string(), @@ -12252,6 +12280,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Right".to_string(), @@ -12291,6 +12320,7 @@ mod tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello".to_string(), @@ -12318,6 +12348,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: " ".to_string(), @@ -12345,6 +12376,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "World".to_string(), @@ -14778,6 +14810,7 @@ mod tests { let mut extractor = TextExtractor::new(); extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello World".to_string(), // >= 5 chars @@ -14805,6 +14838,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello World".to_string(), // Same text, overlapping position @@ -14842,6 +14876,7 @@ mod tests { let mut extractor = TextExtractor::new(); extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello World".to_string(), @@ -14869,6 +14904,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello World".to_string(), // Same text but far apart @@ -14966,6 +15002,7 @@ mod tests { fn test_split_fused_words_camelcase() { let mut extractor = TextExtractor::new(); extractor.spans = vec![TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "theGeneral".to_string(), @@ -15004,6 +15041,7 @@ mod tests { fn test_split_fused_words_no_split() { let mut extractor = TextExtractor::new(); extractor.spans = vec![TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "hello".to_string(), @@ -15189,6 +15227,7 @@ mod tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Right Col".to_string(), @@ -15216,6 +15255,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Left Col".to_string(), @@ -15299,6 +15339,7 @@ mod tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello ".to_string(), // ends with space @@ -15326,6 +15367,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: " World".to_string(), // starts with space @@ -15629,6 +15671,7 @@ mod tests { let mut extractor = TextExtractor::new(); extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Line2".to_string(), @@ -15656,6 +15699,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Line1".to_string(), @@ -15865,6 +15909,7 @@ mod tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello".to_string(), @@ -15892,6 +15937,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: " ".to_string(), // offset_semantic space @@ -16436,6 +16482,7 @@ mod profile_based_space_tests { // Second value starts at x=131, creating a 1pt gap (100 + 30 = 130, gap = 1pt) extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "$0.00".to_string(), @@ -16463,6 +16510,7 @@ mod profile_based_space_tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "$0.00".to_string(), @@ -16509,6 +16557,7 @@ mod profile_based_space_tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "100".to_string(), @@ -16536,6 +16585,7 @@ mod profile_based_space_tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "200".to_string(), @@ -16582,6 +16632,7 @@ mod profile_based_space_tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hel".to_string(), @@ -16609,6 +16660,7 @@ mod profile_based_space_tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "lo".to_string(), @@ -16661,6 +16713,7 @@ mod profile_based_space_tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "123456".to_string(), @@ -16688,6 +16741,7 @@ mod profile_based_space_tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "72".to_string(), @@ -16732,6 +16786,7 @@ mod profile_based_space_tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "50".to_string(), @@ -16759,6 +16814,7 @@ mod profile_based_space_tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "00".to_string(), @@ -16800,6 +16856,7 @@ mod profile_based_space_tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello".to_string(), @@ -16827,6 +16884,7 @@ mod profile_based_space_tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "72".to_string(), @@ -16872,6 +16930,7 @@ mod profile_based_space_tests { extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "123456".to_string(), @@ -16899,6 +16958,7 @@ mod profile_based_space_tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "723".to_string(), @@ -16939,6 +16999,7 @@ mod profile_based_space_tests { /// Compact TextSpan builder for the intervening-ink decimal tests. fn digit_test_span(text: &str, bbox: Rect, font_size: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -17070,6 +17131,7 @@ mod profile_based_space_tests { // gap = 114.5 - (100.0 + 3.5) = 11.0pt -> 11.0 / 7.0 = 1.57x font size. extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "1".to_string(), @@ -17097,6 +17159,7 @@ mod profile_based_space_tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "0".to_string(), @@ -17151,6 +17214,7 @@ mod profile_based_space_tests { // gap = 238.4 - (200.0 + 24.0) = 14.4pt -> 14.4 / 12.0 = 1.2x font size. extractor.spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "1234".to_string(), @@ -17178,6 +17242,7 @@ mod profile_based_space_tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "56".to_string(), diff --git a/src/fonts/non_text_detection.rs b/src/fonts/non_text_detection.rs index 6299b4ca1..bede98387 100644 --- a/src/fonts/non_text_detection.rs +++ b/src/fonts/non_text_detection.rs @@ -415,6 +415,7 @@ mod tests { use crate::geometry::Rect; use crate::layout::{Color, FontWeight, TextSpan}; TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/layout/font_normalization.rs b/src/layout/font_normalization.rs index bda2f26a6..b4f666d28 100644 --- a/src/layout/font_normalization.rs +++ b/src/layout/font_normalization.rs @@ -112,6 +112,7 @@ mod tests { fn make_span(text: &str, bold: bool) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/layout/text_block.rs b/src/layout/text_block.rs index 401b1eac9..874bce40c 100644 --- a/src/layout/text_block.rs +++ b/src/layout/text_block.rs @@ -148,6 +148,17 @@ pub struct TextSpan { /// both use base-form characters (no presentation forms, no `/ReversedChars`). #[serde(skip_serializing_if = "is_false", default)] pub rtl_draw_logical: bool, + /// Provenance of this span's Unicode text — which ISO 32000-1 §9.10.2 + /// mapping tier the font offered, as a + /// [`MappingProvenance`](crate::fonts::MappingProvenance). `None` when the + /// span was not produced by the extractor (synthetic/test spans). A + /// [`Fallback`](crate::fonts::MappingProvenance::Fallback) value means the + /// font carried no mapping resource, so the text is a fabricated glyph-index + /// echo, not read from the file. Runtime metadata only — deliberately not + /// serialized (kept out of span JSON so existing output is byte-identical); + /// bindings surface it through explicit accessors. + #[serde(skip)] + pub provenance: Option, } /// serde skip helper: omit a `false` flag (the common case) from serialized output. @@ -195,6 +206,7 @@ impl Default for TextSpan { wmode: 0, text_rise: 0.0, rtl_draw_logical: false, + provenance: None, } } } diff --git a/src/pipeline/converters/html.rs b/src/pipeline/converters/html.rs index a6d0578d4..7877739dd 100644 --- a/src/pipeline/converters/html.rs +++ b/src/pipeline/converters/html.rs @@ -758,6 +758,7 @@ mod tests { ) -> OrderedTextSpan { OrderedTextSpan::new( TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -1151,6 +1152,7 @@ mod tests { italic: bool, ) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/pipeline/converters/markdown.rs b/src/pipeline/converters/markdown.rs index feb1f7264..4003b4891 100644 --- a/src/pipeline/converters/markdown.rs +++ b/src/pipeline/converters/markdown.rs @@ -4147,6 +4147,7 @@ mod tests { ) -> OrderedTextSpan { OrderedTextSpan::new( TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -4186,6 +4187,7 @@ mod tests { ) -> OrderedTextSpan { OrderedTextSpan::new( TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -4938,6 +4940,7 @@ mod tests { ) -> OrderedTextSpan { let mut s = OrderedTextSpan::new( TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -5711,6 +5714,7 @@ mod tests { #[test] fn test_issue8_table_cell_renders_bold_marker() { let bold_span = TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Critical".to_string(), diff --git a/src/pipeline/converters/plain_text.rs b/src/pipeline/converters/plain_text.rs index 940dd7fb6..5bb88b5ec 100644 --- a/src/pipeline/converters/plain_text.rs +++ b/src/pipeline/converters/plain_text.rs @@ -667,6 +667,7 @@ mod tests { fn make_span(text: &str, x: f32, y: f32) -> OrderedTextSpan { OrderedTextSpan::new( TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -855,6 +856,7 @@ mod tests { fn make_span_with_width(text: &str, x: f32, y: f32, width: f32) -> OrderedTextSpan { OrderedTextSpan::new( TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/pipeline/ordered_span.rs b/src/pipeline/ordered_span.rs index 7ea5196b6..a64336058 100644 --- a/src/pipeline/ordered_span.rs +++ b/src/pipeline/ordered_span.rs @@ -379,6 +379,7 @@ mod tests { fn make_span(text: &str, x: f32, y: f32, w: f32, h: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/pipeline/reading_order/geometric.rs b/src/pipeline/reading_order/geometric.rs index d3a17ca1d..ea10b32f3 100644 --- a/src/pipeline/reading_order/geometric.rs +++ b/src/pipeline/reading_order/geometric.rs @@ -330,6 +330,7 @@ mod tests { fn make_span(text: &str, x: f32, y: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/pipeline/reading_order/simple.rs b/src/pipeline/reading_order/simple.rs index 46470c628..b37d00d96 100644 --- a/src/pipeline/reading_order/simple.rs +++ b/src/pipeline/reading_order/simple.rs @@ -53,6 +53,7 @@ mod tests { fn make_span(text: &str, x: f32, y: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/pipeline/reading_order/structure_tree.rs b/src/pipeline/reading_order/structure_tree.rs index dabc2e9a8..910da06fe 100644 --- a/src/pipeline/reading_order/structure_tree.rs +++ b/src/pipeline/reading_order/structure_tree.rs @@ -225,6 +225,7 @@ mod tests { fn make_span(text: &str, x: f32, y: f32, mcid: Option) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/pipeline/reading_order/xycut.rs b/src/pipeline/reading_order/xycut.rs index 3d4c47a2f..d4ea1fa90 100644 --- a/src/pipeline/reading_order/xycut.rs +++ b/src/pipeline/reading_order/xycut.rs @@ -2140,6 +2140,7 @@ mod tests { use crate::layout::{Color, FontWeight}; TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/search/text_search.rs b/src/search/text_search.rs index ad76419c7..2e831a7d6 100644 --- a/src/search/text_search.rs +++ b/src/search/text_search.rs @@ -316,6 +316,7 @@ mod tests { fn test_build_text_with_positions() { let spans = vec![ TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "Hello".to_string(), @@ -347,6 +348,7 @@ mod tests { rtl_draw_logical: false, }, TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: "World".to_string(), diff --git a/src/structure/spatial_table_detector.rs b/src/structure/spatial_table_detector.rs index 068ec7c46..0e25cb3e5 100644 --- a/src/structure/spatial_table_detector.rs +++ b/src/structure/spatial_table_detector.rs @@ -4600,6 +4600,7 @@ mod tests { fn create_test_span(text: &str, x: f32, y: f32, width: f32, height: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -7107,6 +7108,7 @@ mod tests { /// rule actually reads: bbox, font_size, and text. fn ts(text: &str, x: f32, y: f32, width: f32, fs: f32) -> TextSpan { TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), diff --git a/src/structure/table_extractor.rs b/src/structure/table_extractor.rs index 339a3a441..4d9b2c067 100644 --- a/src/structure/table_extractor.rs +++ b/src/structure/table_extractor.rs @@ -794,6 +794,7 @@ fn extract_cell( block.text.clone() }; cell_spans.push(TextSpan { + provenance: None, artifact_type: None, text: span_text, bbox: block.bbox, @@ -1152,6 +1153,7 @@ mod tests { use crate::layout::text_block::{Color, FontWeight}; crate::layout::TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: text.to_string(), @@ -1347,6 +1349,7 @@ mod tests { // "(" x=353.83 w=10.56 end=364.39 gap=-0.18 (overlap → no space) // "peu/d" x=364.39 w=25.24 gap=0.00 (touching → no space) let base = crate::layout::TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: String::new(), @@ -1424,6 +1427,7 @@ mod tests { table_elem.add_child(StructChild::StructElem(Box::new(tr))); let base = crate::layout::TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: String::new(), @@ -1500,6 +1504,7 @@ mod tests { table_elem.add_child(StructChild::StructElem(Box::new(tr))); let base = crate::layout::TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: String::new(), @@ -1602,6 +1607,7 @@ mod tests { table_elem.add_child(StructChild::StructElem(Box::new(tr))); let base = crate::layout::TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: String::new(), @@ -1687,6 +1693,7 @@ mod tests { table_elem.add_child(StructChild::StructElem(Box::new(tr))); let base = crate::layout::TextSpan { + provenance: None, text_rise: 0.0, artifact_type: None, text: String::new(), From f8560834549d89cdbcf9ff371f8f68ee6d15a4f7 Mon Sep 17 00:00:00 2001 From: Yury Fedoseev Date: Fri, 17 Jul 2026 19:41:50 -0700 Subject: [PATCH 04/13] feat(python): expose span mapping provenance Adds MappingProvenance::as_str() (shared stable labels for all bindings) and a `provenance` getter on the Python TextSpan, returning "to_unicode" / "encoding" / "predefined_cmap" / "embedded_cmap" / "actual_text" / "fallback", or None when the font is unresolvable. Compiles under --features python. --- src/fonts/provenance.rs | 16 ++++++++++++++++ src/python.rs | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/src/fonts/provenance.rs b/src/fonts/provenance.rs index 144420d79..f1d6f30d1 100644 --- a/src/fonts/provenance.rs +++ b/src/fonts/provenance.rs @@ -69,6 +69,22 @@ impl MappingProvenance { !matches!(self, Self::Fallback) } + /// A stable, lowercase label for bindings and serialized surfaces. Shared + /// so every language binding exposes the same strings: + /// `"actual_text"`, `"to_unicode"`, `"encoding"`, `"predefined_cmap"`, + /// `"embedded_cmap"`, `"fallback"`. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::ActualText => "actual_text", + Self::ToUnicode => "to_unicode", + Self::EncodingName => "encoding", + Self::PredefinedCMap => "predefined_cmap", + Self::EmbeddedCmap => "embedded_cmap", + Self::Fallback => "fallback", + } + } + /// The weaker (less authoritative) of two provenances — the reduction used /// to summarise a run of characters by its least-trustworthy member. #[must_use] diff --git a/src/python.rs b/src/python.rs index daece3773..f96b89b5e 100644 --- a/src/python.rs +++ b/src/python.rs @@ -4205,6 +4205,15 @@ impl PyTextSpan { fn sequence(&self) -> usize { self.inner.sequence } + /// Which ISO 32000-1 §9.10.2 mapping tier the span's font offered: + /// `"to_unicode"`, `"encoding"`, `"predefined_cmap"`, `"embedded_cmap"`, + /// `"actual_text"`, or `"fallback"` — the last meaning the font carried no + /// mapping resource, so the text is a fabricated glyph-index echo rather + /// than read from the file. `None` when the font could not be resolved. + #[getter] + fn provenance(&self) -> Option<&'static str> { + self.inner.provenance.map(|p| p.as_str()) + } } #[pyclass(module = "pdf_oxide.pdf_oxide", name = "TextWord", skip_from_py_object)] From eb27acef4de4b985c4e8d334c3eb9e604a31f2a4 Mon Sep 17 00:00:00 2001 From: Yury Fedoseev Date: Fri, 17 Jul 2026 19:46:52 -0700 Subject: [PATCH 05/13] feat(bindings): expose span provenance via serde + C-ABI accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reach every language binding with the mapping-provenance fact: - Serialize TextSpan.provenance as "provenance": "