diff --git a/.gitignore b/.gitignore
index f651cd4de..fd147b20b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -455,3 +455,10 @@ r/*.tar.gz
/elixir/deps/
/elixir/priv/
/elixir/mix.lock
+
+# Viewer build artifacts (never commit generated output)
+viewer/dotnet/**/bin/
+viewer/dotnet/**/obj/
+viewer/dist-browser/
+viewer/**/node_modules/
+viewer/**/.gradle/
diff --git a/cpp/include/pdf_oxide/pdf_oxide.hpp b/cpp/include/pdf_oxide/pdf_oxide.hpp
index 96f00a733..5e44dca21 100644
--- a/cpp/include/pdf_oxide/pdf_oxide.hpp
+++ b/cpp/include/pdf_oxide/pdf_oxide.hpp
@@ -176,6 +176,11 @@ struct Element {
std::string type;
std::string text;
Bbox rect;
+ /// ISO 32000-1 §9.10.2 mapping-provenance label ("to_unicode"/"encoding"/
+ /// "predefined_cmap"/"embedded_cmap"/"actual_text"/"fallback"), or empty
+ /// when unknown. "fallback" means the text is a fabricated glyph-index echo,
+ /// not read from the file.
+ std::string provenance;
};
/// A single interactive AcroForm field (PHASE-8 forms).
@@ -899,6 +904,11 @@ class Document {
code = 0;
e.text = detail::take_string(pdf_oxide_element_get_text(list, i, &code),
code, "Document::page_get_elements");
+ code = 0;
+ if (char *p = pdf_oxide_element_get_provenance(list, i, &code)) {
+ e.provenance = detail::take_string(p, code,
+ "Document::page_get_elements");
+ }
Bbox b{0, 0, 0, 0};
pdf_oxide_element_get_rect(list, i, &b.x, &b.y, &b.width, &b.height,
&code);
diff --git a/csharp/PdfOxide/Internal/NativeMethods.cs b/csharp/PdfOxide/Internal/NativeMethods.cs
index 88a20f0f8..d6714d3e4 100644
--- a/csharp/PdfOxide/Internal/NativeMethods.cs
+++ b/csharp/PdfOxide/Internal/NativeMethods.cs
@@ -7096,6 +7096,18 @@ public static partial IntPtr pdf_oxide_element_get_text(
int index,
out int errorCode);
+ ///
+ /// Gets the §9.10.2 mapping-provenance label of an element
+ /// ("to_unicode"/"encoding"/"predefined_cmap"/"embedded_cmap"/
+ /// "actual_text"/"fallback"), or null when unknown.
+ ///
+ [LibraryImport(LibName, EntryPoint = "pdf_oxide_element_get_provenance", StringMarshalling = StringMarshalling.Utf8)]
+ [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
+ public static partial IntPtr pdf_oxide_element_get_provenance(
+ NativeHandle elements,
+ int index,
+ out int errorCode);
+
///
/// Gets the bounding rectangle of an element.
///
diff --git a/dart/lib/pdf_oxide.dart b/dart/lib/pdf_oxide.dart
index ca7a2cfa1..da44118cd 100644
--- a/dart/lib/pdf_oxide.dart
+++ b/dart/lib/pdf_oxide.dart
@@ -1169,6 +1169,8 @@ class _Native {
.lookupFunction<_ListStrC, _ListStrD>('pdf_oxide_element_get_type'),
elementGetText = lib
.lookupFunction<_ListStrC, _ListStrD>('pdf_oxide_element_get_text'),
+ elementGetProvenance = lib
+ .lookupFunction<_ListStrC, _ListStrD>('pdf_oxide_element_get_provenance'),
elementGetRect = lib.lookupFunction<_ElemRectC, _ElemRectD>(
'pdf_oxide_element_get_rect'),
elementsFree = lib
@@ -1680,6 +1682,7 @@ class _Native {
final _ElemCountD elementCount;
final _ListStrD elementGetType;
final _ListStrD elementGetText;
+ final _ListStrD elementGetProvenance;
final _ElemRectD elementGetRect;
final _ListFreeD elementsFree;
final _ElemJsonD elementsToJson;
@@ -7311,7 +7314,7 @@ class OcrEngine implements Finalizable {
/// A single layout element read from an [ElementList].
class Element {
- const Element(this.type, this.text, this.rect);
+ const Element(this.type, this.text, this.rect, [this.provenance = '']);
/// The element type label (e.g. `Text`, `Image`, `Table`).
final String type;
@@ -7322,6 +7325,12 @@ class Element {
/// The element's bounding box in page user-space points.
final Bbox rect;
+ /// ISO 32000-1 §9.10.2 mapping-provenance label (`to_unicode`/`encoding`/
+ /// `predefined_cmap`/`embedded_cmap`/`actual_text`/`fallback`), or `''` when
+ /// unknown. `fallback` means the text is a fabricated glyph-index echo, not
+ /// read from the file.
+ final String provenance;
+
@override
String toString() => 'Element($type, $rect)';
}
@@ -7372,6 +7381,8 @@ class ElementList implements Finalizable {
_check();
final type = _str(_n.elementGetType, index, 'elementGetType');
final text = _str(_n.elementGetText, index, 'elementGetText');
+ final provenance =
+ _str(_n.elementGetProvenance, index, 'elementGetProvenance');
final x = calloc();
final y = calloc();
final w = calloc();
@@ -7380,7 +7391,8 @@ class ElementList implements Finalizable {
try {
_n.elementGetRect(_handle, index, x, y, w, h, code);
if (code.value != 0) throw PdfOxideError(code.value, 'elementGetRect');
- return Element(type, text, Bbox(x.value, y.value, w.value, h.value));
+ return Element(
+ type, text, Bbox(x.value, y.value, w.value, h.value), provenance);
} finally {
calloc.free(x);
calloc.free(y);
diff --git a/go/pdf_oxide.go b/go/pdf_oxide.go
index 769c0fb56..612d3efb8 100644
--- a/go/pdf_oxide.go
+++ b/go/pdf_oxide.go
@@ -133,6 +133,7 @@ extern void* pdf_page_get_elements(void* handle, int32_t page_index, int* error_
extern int32_t pdf_oxide_element_count(const void* elements);
extern char* pdf_oxide_element_get_type(const void* elements, int32_t index, int* error_code);
extern char* pdf_oxide_element_get_text(const void* elements, int32_t index, int* error_code);
+extern char* pdf_oxide_element_get_provenance(const void* elements, int32_t index, int* error_code);
extern void pdf_oxide_element_get_rect(const void* elements, int32_t index, float* x, float* y, float* width, float* height, int* error_code);
extern void pdf_oxide_elements_free(void* handle);
diff --git a/go/types.go b/go/types.go
index 3ab8fdd43..323d935e4 100644
--- a/go/types.go
+++ b/go/types.go
@@ -282,6 +282,11 @@ type Element struct {
Y float32 `json:"y"`
Width float32 `json:"width"`
Height float32 `json:"height"`
+ // Provenance is the ISO 32000-1 §9.10.2 mapping tier the span's font
+ // offered ("to_unicode"/"encoding"/"predefined_cmap"/"embedded_cmap"/
+ // "actual_text"/"fallback"), or "" when unknown. "fallback" means the
+ // text is a fabricated glyph-index echo, not read from the file.
+ Provenance string `json:"provenance,omitempty"`
}
// ─── DocumentBuilder write-side value types ──────────────────────────────
diff --git a/include/pdf_oxide_c/pdf_oxide.h b/include/pdf_oxide_c/pdf_oxide.h
index 9e67ae80f..6c5e5751d 100644
--- a/include/pdf_oxide_c/pdf_oxide.h
+++ b/include/pdf_oxide_c/pdf_oxide.h
@@ -1465,6 +1465,19 @@ char *pdf_oxide_element_get_text(const FfiElementList *elements,
int32_t *error_code);
#endif
+#if !defined(PDF_OXIDE_TARGET_WASM32)
+/**
+ * The span's Unicode-mapping provenance label (ISO 32000-1 §9.10.2 tier):
+ * `"to_unicode"`, `"encoding"`, `"predefined_cmap"`, `"embedded_cmap"`,
+ * `"actual_text"`, or `"fallback"` (the text is a fabricated glyph-index echo,
+ * not read from the file). Returns null when the font could not be resolved.
+ * Free the returned string with `pdf_oxide_free_string`.
+ */
+char *pdf_oxide_element_get_provenance(const FfiElementList *elements,
+ int32_t index,
+ int32_t *error_code);
+#endif
+
#if !defined(PDF_OXIDE_TARGET_WASM32)
void pdf_oxide_element_get_rect(const FfiElementList *elements,
int32_t index,
diff --git a/objc/include/POXPdfOxide.h b/objc/include/POXPdfOxide.h
index 561e30e19..7530f9858 100644
--- a/objc/include/POXPdfOxide.h
+++ b/objc/include/POXPdfOxide.h
@@ -691,6 +691,11 @@ typedef struct {
- (nullable NSString*)typeAtIndex:(int32_t)index error:(NSError**)error;
/// The element text at `index` (nil on error).
- (nullable NSString*)textAtIndex:(int32_t)index error:(NSError**)error;
+/// The element ISO 32000-1 §9.10.2 mapping-provenance label at `index`
+/// (@"to_unicode"/@"encoding"/@"predefined_cmap"/@"embedded_cmap"/
+/// @"actual_text"/@"fallback"), or nil when unknown. @"fallback" means the text
+/// is a fabricated glyph-index echo, not read from the file.
+- (nullable NSString*)provenanceAtIndex:(int32_t)index error:(NSError**)error;
/// The element bounding box at `index`.
- (POXBbox)rectAtIndex:(int32_t)index error:(NSError**)error;
/// Serialize the whole list to JSON (nil on error).
diff --git a/objc/src/POXPdfOxide.m b/objc/src/POXPdfOxide.m
index 084b9ce13..2b751aa29 100644
--- a/objc/src/POXPdfOxide.m
+++ b/objc/src/POXPdfOxide.m
@@ -4498,6 +4498,12 @@ - (NSString*)textAtIndex:(int32_t)index error:(NSError**)error {
@"elementText", error);
}
+- (NSString*)provenanceAtIndex:(int32_t)index error:(NSError**)error {
+ int32_t code = 0;
+ return POXTakeString(pdf_oxide_element_get_provenance(_handle, index, &code), code,
+ @"elementProvenance", error);
+}
+
- (POXBbox)rectAtIndex:(int32_t)index error:(NSError**)error {
int32_t code = 0;
float x = 0, y = 0, w = 0, h = 0;
diff --git a/php/include/pdf_oxide.h b/php/include/pdf_oxide.h
index 05fba52f8..119b112fd 100644
--- a/php/include/pdf_oxide.h
+++ b/php/include/pdf_oxide.h
@@ -502,6 +502,10 @@ char *pdf_oxide_element_get_text(const FfiElementList *elements,
int32_t index,
int32_t *error_code);
+char *pdf_oxide_element_get_provenance(const FfiElementList *elements,
+ int32_t index,
+ int32_t *error_code);
+
void pdf_oxide_element_get_rect(const FfiElementList *elements,
int32_t index,
float *x,
diff --git a/src/bin/corpus_sig.rs b/src/bin/corpus_sig.rs
new file mode 100644
index 000000000..a34883a02
--- /dev/null
+++ b/src/bin/corpus_sig.rs
@@ -0,0 +1,84 @@
+//! Native Rust regression-sweep signature tool.
+//!
+//! Prints one line per PDF in a corpus directory:
+//!
+//! ```text
+//! \t\t\t\t
+//! ```
+//!
+//! Build once per version and diff the outputs to find extraction regressions:
+//!
+//! ```text
+//! 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 dbb65a180..1ce74ba5c 100644
--- a/src/document.rs
+++ b/src/document.rs
@@ -8759,6 +8759,7 @@ impl PdfDocument {
};
spans.push(TextSpan {
+ provenance: None,
artifact_type: None,
text,
bbox: rect,
@@ -8929,6 +8930,7 @@ impl PdfDocument {
};
spans.push(TextSpan {
+ provenance: None,
artifact_type: None,
text,
bbox: rect,
@@ -17409,6 +17411,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,
@@ -19214,6 +19217,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,
@@ -24379,6 +24383,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(),
@@ -24910,6 +24915,7 @@ mod tests {
font_size: f32,
) -> TextSpan {
TextSpan {
+ provenance: None,
text_rise: 0.0,
text: text.to_string(),
bbox: crate::geometry::Rect {
@@ -28914,6 +28920,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(),
@@ -28982,6 +28989,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(),
@@ -29675,6 +29683,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(),
@@ -29749,6 +29758,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(),
@@ -29822,6 +29832,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/ffi.rs b/src/ffi.rs
index 3a4b26e4a..f8060e77a 100644
--- a/src/ffi.rs
+++ b/src/ffi.rs
@@ -3146,6 +3146,33 @@ pub extern "C" fn pdf_oxide_element_get_text(
to_c_string(&list.spans[index as usize].text)
}
+/// The span's Unicode-mapping provenance label (ISO 32000-1 §9.10.2 tier):
+/// `"to_unicode"`, `"encoding"`, `"predefined_cmap"`, `"embedded_cmap"`,
+/// `"actual_text"`, or `"fallback"` (the text is a fabricated glyph-index echo,
+/// not read from the file). Returns null when the font could not be resolved.
+/// Free the returned string with `pdf_oxide_free_string`.
+#[no_mangle]
+pub extern "C" fn pdf_oxide_element_get_provenance(
+ elements: *const FfiElementList,
+ index: i32,
+ error_code: *mut i32,
+) -> *mut c_char {
+ if elements.is_null() || index < 0 {
+ set_error(error_code, ERR_INVALID_ARG);
+ return ptr::null_mut();
+ }
+ let list = handle_ref(elements);
+ if (index as usize) >= list.spans.len() {
+ set_error(error_code, ERR_INVALID_PAGE);
+ return ptr::null_mut();
+ }
+ set_error(error_code, ERR_SUCCESS);
+ match list.spans[index as usize].provenance {
+ Some(p) => to_c_string(p.as_str()),
+ None => ptr::null_mut(),
+ }
+}
+
#[no_mangle]
pub extern "C" fn pdf_oxide_element_get_rect(
elements: *const FfiElementList,
@@ -8765,6 +8792,9 @@ struct JsonElement<'a> {
y: f32,
width: f32,
height: f32,
+ /// §9.10.2 mapping-provenance label; omitted when the font is unresolved.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ provenance: Option<&'static str>,
}
#[derive(Serialize)]
@@ -8947,6 +8977,7 @@ pub extern "C" fn pdf_oxide_elements_to_json(
y: s.bbox.y,
width: s.bbox.width,
height: s.bbox.height,
+ provenance: s.provenance.map(|p| p.as_str()),
})
.collect();
match serde_json::to_string(&items) {
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.
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/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/fonts/provenance.rs b/src/fonts/provenance.rs
new file mode 100644
index 000000000..bdd9ac280
--- /dev/null
+++ b/src/fonts/provenance.rs
@@ -0,0 +1,153 @@
+//! 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.
+///
+/// Serializes as the same stable lowercase labels [`Self::as_str`] returns, so
+/// every serde-based binding (WASM/JSON) and every explicit accessor agree.
+#[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.
+ #[serde(rename = "actual_text")]
+ ActualText,
+ /// The font's `/ToUnicode` CMap (§9.10.3) — the authoritative per-font map.
+ #[serde(rename = "to_unicode")]
+ ToUnicode,
+ /// The font `/Encoding` → glyph name → Adobe Glyph List path (§9.10.2, the
+ /// simple-font branch).
+ #[serde(rename = "encoding")]
+ EncodingName,
+ /// A predefined CID→Unicode CMap for a known character collection (§9.10.2,
+ /// e.g. `Adobe-Japan1-UCS2`).
+ #[serde(rename = "predefined_cmap")]
+ PredefinedCMap,
+ /// Inversion of the embedded font program's own `cmap` table — the
+ /// recoverable byte-as-GID / Identity subset shape.
+ #[serde(rename = "embedded_cmap")]
+ 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**.
+ #[serde(rename = "fallback")]
+ 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)
+ }
+
+ /// 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]
+ 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);
+ }
+}
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..ce103d325 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_serializing_if = "Option::is_none", default)]
+ 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,
}
}
}
@@ -820,6 +832,28 @@ impl TextLine {
mod tests {
use super::*;
+ // The provenance fact reaches every JSON/serde binding (WASM, Go, Ruby,
+ // Java's structured extraction, ...) through span serialization: present as
+ // a stable label when known, omitted when absent so existing output is
+ // byte-identical.
+ #[test]
+ fn provenance_serializes_as_stable_label_and_omits_when_absent() {
+ let mut span = TextSpan {
+ text: "x".to_string(),
+ ..TextSpan::default()
+ };
+ span.provenance = Some(crate::fonts::MappingProvenance::Fallback);
+ let json = serde_json::to_string(&span).unwrap();
+ assert!(json.contains("\"provenance\":\"fallback\""), "got {json}");
+
+ let plain = TextSpan {
+ text: "y".to_string(),
+ ..TextSpan::default()
+ };
+ let json = serde_json::to_string(&plain).unwrap();
+ assert!(!json.contains("provenance"), "absent provenance must be omitted: {json}");
+ }
+
fn mock_char(c: char, x: f32, y: f32) -> TextChar {
let bbox = Rect::new(x, y, 10.0, 12.0);
TextChar {
diff --git a/src/ocr/engine.rs b/src/ocr/engine.rs
index 89c469d2a..d9430ffd4 100644
--- a/src/ocr/engine.rs
+++ b/src/ocr/engine.rs
@@ -57,6 +57,7 @@ impl OcrSpan {
let font_size = self.estimate_font_size(height_pixels, scale);
TextSpan {
+ provenance: None,
artifact_type: None,
text: self.text.clone(),
bbox,
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/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)]
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(),
diff --git a/swift/Sources/PdfOxide/PdfOxide.swift b/swift/Sources/PdfOxide/PdfOxide.swift
index 006366d05..3ebf984a7 100644
--- a/swift/Sources/PdfOxide/PdfOxide.swift
+++ b/swift/Sources/PdfOxide/PdfOxide.swift
@@ -3406,6 +3406,11 @@ public struct Element {
public let type: String
public let text: String
public let rect: Bbox
+ /// ISO 32000-1 §9.10.2 mapping-provenance label ("to_unicode"/"encoding"/
+ /// "predefined_cmap"/"embedded_cmap"/"actual_text"/"fallback"), or nil when
+ /// unknown. "fallback" means the text is a fabricated glyph-index echo, not
+ /// read from the file.
+ public let provenance: String?
}
/// An opaque list of page elements (`FfiElementList`), freed in `deinit`/`close()`.
@@ -3435,9 +3440,14 @@ public final class ElementList {
pdf_oxide_element_get_text(h, idx, &code), code, "ElementList.text")
var x: Float = 0, y: Float = 0, w: Float = 0, hgt: Float = 0
pdf_oxide_element_get_rect(h, idx, &x, &y, &w, &hgt, &code)
+ var pcode: Int32 = 0
+ let provPtr = pdf_oxide_element_get_provenance(h, idx, &pcode)
+ let provenance: String? =
+ provPtr != nil ? try takeString(provPtr, pcode, "ElementList.provenance") : nil
return Element(
type: type, text: text,
- rect: Bbox(x: Double(x), y: Double(y), width: Double(w), height: Double(hgt))
+ rect: Bbox(x: Double(x), y: Double(y), width: Double(w), height: Double(hgt)),
+ provenance: provenance
)
}
diff --git a/tests/test_pipeline_html_converter.rs b/tests/test_pipeline_html_converter.rs
index 929f8d1c0..e1f300f80 100644
--- a/tests/test_pipeline_html_converter.rs
+++ b/tests/test_pipeline_html_converter.rs
@@ -23,6 +23,7 @@ use pdf_oxide::pipeline::{OrderedTextSpan, TextPipelineConfig};
fn make_span(text: &str, x: f32, y: f32, font_size: f32, weight: FontWeight) -> OrderedTextSpan {
OrderedTextSpan::new(
TextSpan {
+ provenance: None,
artifact_type: None,
text: text.to_string(),
bbox: Rect::new(x, y, 50.0, font_size),
@@ -64,6 +65,7 @@ fn make_span_with_color(
) -> OrderedTextSpan {
OrderedTextSpan::new(
TextSpan {
+ provenance: None,
artifact_type: None,
text: text.to_string(),
bbox: Rect::new(x, y, 50.0, font_size),
@@ -105,6 +107,7 @@ fn make_span_italic(
) -> OrderedTextSpan {
OrderedTextSpan::new(
TextSpan {
+ provenance: None,
artifact_type: None,
text: text.to_string(),
bbox: Rect::new(x, y, 50.0, font_size),
diff --git a/tests/test_spacing_integration.rs b/tests/test_spacing_integration.rs
index 0572ef4c4..50fbd2779 100644
--- a/tests/test_spacing_integration.rs
+++ b/tests/test_spacing_integration.rs
@@ -33,6 +33,7 @@ fn create_test_span(
_tj_space_signal: bool,
) -> TextSpan {
TextSpan {
+ provenance: None,
artifact_type: None,
text: text.to_string(),
bbox: Rect::new(x, y, width, height),
diff --git a/tests/test_spacing_spec_compliant.rs b/tests/test_spacing_spec_compliant.rs
index 816295e5c..7e3108613 100644
--- a/tests/test_spacing_spec_compliant.rs
+++ b/tests/test_spacing_spec_compliant.rs
@@ -22,6 +22,7 @@ use pdf_oxide::layout::{Color, FontWeight, TextSpan};
/// Create a test text span with specified position and dimensions.
fn create_test_span(text: &str, x: f32, y: f32, width: f32, height: f32) -> TextSpan {
TextSpan {
+ provenance: None,
artifact_type: None,
text: text.to_string(),
bbox: Rect::new(x, y, width, height),
diff --git a/zig/lib/pdf_oxide.zig b/zig/lib/pdf_oxide.zig
index d763dba2c..b1e4264a3 100644
--- a/zig/lib/pdf_oxide.zig
+++ b/zig/lib/pdf_oxide.zig
@@ -4073,6 +4073,17 @@ pub const ElementList = struct {
return takeString(alloc, c.pdf_oxide_element_get_text(h, index, &code), code);
}
+ /// Element ISO 32000-1 §9.10.2 mapping-provenance label at `index`
+ /// ("to_unicode"/"encoding"/"predefined_cmap"/"embedded_cmap"/"actual_text"/
+ /// "fallback"), or empty when unknown; caller owns the returned slice.
+ /// "fallback" means the text is a fabricated glyph-index echo, not read from
+ /// the file.
+ pub fn getProvenance(self: ElementList, alloc: std.mem.Allocator, index: i32) Error![]u8 {
+ const h = try self.live();
+ var code: i32 = 0;
+ return takeString(alloc, c.pdf_oxide_element_get_provenance(h, index, &code), code);
+ }
+
/// Element bounding box at `index`.
pub fn getRect(self: ElementList, index: i32) Error!Bbox {
const h = try self.live();