From 8db94f95559b2ee5b150b95046dadcd2274132a3 Mon Sep 17 00:00:00 2001 From: Yangtse Su Date: Thu, 15 Oct 2026 01:57:21 +0800 Subject: [PATCH 1/2] feat(core): resolve missing text deltas through font advances An OFD text run that declares no `DeltaX` for a scalar is positioned by that scalar's font advance, which is what the renderer draws. The semantic text index, however, advanced such scalars by the font size, so page geometry drifted away from the glyphs: on a 22-character Latin line at size 3 mm the character boxes spread over 63 mm while the drawn text was 28 mm wide, and a search rectangle for a match late in the line fell to the right of the text it belonged to. Consumers that draw from this geometry - search highlights, selection, caret and hit-testing - therefore looked misplaced on any document whose producers rely on font metrics instead of explicit deltas. The core crate still does not touch fonts. `TextAdvanceProvider` lets a caller supply the per-scalar advances, `Document::from_bytes_with_advances` / `open_with_advances` carry a provider into the page text index, and without one the previous font-size footprint stays in place (and stays flagged conservative). rofd-render implements the provider over exactly the resolution and advance code the positioner uses, exposing the advance each positioned glyph contributed so the two layers cannot drift apart. rofd-ffi loads documents with the default font stack, which is what a renderer created with NULL options uses. The provider is asked only when a run actually declares no `DeltaX`: an object whose runs all carry their own deltas never loads a font for its geometry. Verified: cargo fmt --all -- --check; cargo clippy -p rofd-core -p rofd-render -p rofd-ffi --all-targets -- -D warnings; cargo test for the three crates; crates/rofd-ffi/tests/run_c_tests.sh (symbol list, C/C++ headers, smoke). New tests cover the provider seam in the core builder (including explicit deltas staying authoritative and a provider that cannot resolve a font), the provider/positioner agreement on an embedded subset font, and the ABI's layout and search rectangles following font metrics. --- crates/rofd-core/src/document.rs | 56 +++++++- crates/rofd-core/src/lib.rs | 4 +- crates/rofd-core/src/semantic.rs | 59 +++++++- crates/rofd-core/tests/semantic_text.rs | 127 +++++++++++++++++- crates/rofd-ffi/include/rofd.h | 5 + crates/rofd-ffi/src/document.rs | 8 +- crates/rofd-ffi/src/renderer.rs | 20 +++ crates/rofd-ffi/tests/semantic.rs | 93 +++++++++++++ crates/rofd-render/src/fonts.rs | 79 +++++++++-- crates/rofd-render/src/lib.rs | 4 +- crates/rofd-render/tests/fonts.rs | 61 ++++++++- ...-09-poppler-style-c-semantic-api-design.md | 8 ++ 12 files changed, 496 insertions(+), 28 deletions(-) diff --git a/crates/rofd-core/src/document.rs b/crates/rofd-core/src/document.rs index 7cee168c..0ac4ac65 100644 --- a/crates/rofd-core/src/document.rs +++ b/crates/rofd-core/src/document.rs @@ -123,6 +123,19 @@ struct DocumentInner { bookmarks: OnceLock, bookmark_initialization: Mutex<()>, navigation_warning_state: Mutex, + /// Font-resolved per-scalar advances for runs without explicit deltas, when the + /// loader supplied a provider (see [`crate::TextAdvanceProvider`]). + text_advances: Option, +} + +/// Debug-friendly owner of the optional text advance provider. +#[derive(Clone)] +struct TextAdvances(Arc); + +impl std::fmt::Debug for TextAdvances { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("TextAdvances()") + } } #[derive(Debug, Default)] @@ -233,10 +246,11 @@ impl Page { /// physical-page millimetres, in effective paint order. /// /// Visible annotation appearances follow the page layers. Distinct nonempty - /// text objects are separated by synthesized newlines. Geometry uses only - /// validated OFD positioning and font size, without font lookup or shaping. - /// The complete result is cached and shared by cloned page handles. Failed - /// builds are not cached and may be retried on a later call. + /// text objects are separated by synthesized newlines. Geometry uses validated + /// OFD positioning; a scalar whose run declares no `DeltaX` uses the advance of a + /// provider's font when the document was loaded with one, and the font size + /// otherwise. The complete result is cached and shared by cloned page handles. + /// Failed builds are not cached and may be retried on a later call. pub fn text(&self) -> crate::Result<&crate::PageText> { if let Some(text) = self.data.semantic_text.get() { return Ok(text); @@ -247,7 +261,12 @@ impl Page { if let Some(text) = self.data.semantic_text.get() { return Ok(text); } - let text = crate::semantic::build_page_text(self)?; + let advances = self + ._document + .text_advances + .as_ref() + .map(|advances| advances.0.as_ref() as &dyn crate::TextAdvanceProvider); + let text = crate::semantic::build_page_text(self, advances)?; Ok(self.data.semantic_text.get_or_init(|| text)) } @@ -339,16 +358,40 @@ impl Page { impl Document { /// Opens an OFD document from a host file path. pub fn open(path: impl AsRef, options: LoadOptions) -> Result { + Self::open_with_advances(path, options, None) + } + + /// Opens an OFD document from a host file path, resolving text advances through + /// `advances`. + pub fn open_with_advances( + path: impl AsRef, + options: LoadOptions, + advances: Option>, + ) -> Result { let path = path.as_ref(); let bytes = fs::read(path).map_err(|source| Error::Io { path: path.to_owned(), source, })?; - Self::from_bytes(bytes, options) + Self::from_bytes_with_advances(bytes, options, advances) } /// Opens an OFD document from owned bytes. pub fn from_bytes(bytes: Vec, options: LoadOptions) -> Result { + Self::from_bytes_with_advances(bytes, options, None) + } + + /// Opens an OFD document from owned bytes, resolving text advances through + /// `advances`. + /// + /// Without a provider, runs that carry no explicit `DeltaX` keep the font-size + /// footprint; with one they use the font's advances, which is what a renderer + /// draws. + pub fn from_bytes_with_advances( + bytes: Vec, + options: LoadOptions, + advances: Option>, + ) -> Result { let strictness = options.strictness; let limits = options.limits; let container = Container::from_bytes(bytes, limits.clone())?; @@ -491,6 +534,7 @@ impl Document { container, document_path, limits, + text_advances: advances.map(TextAdvances), metadata: Metadata { document_id: info.document_id, title: info.title, diff --git a/crates/rofd-core/src/lib.rs b/crates/rofd-core/src/lib.rs index 31d3b5ec..027a3440 100644 --- a/crates/rofd-core/src/lib.rs +++ b/crates/rofd-core/src/lib.rs @@ -36,8 +36,8 @@ pub use paint::{Color, LineCap, LineJoin, StrokeStyle}; pub use path_data::{PathCommand, PathData}; pub use resources::{FontResource, ImageFormat, ImageResource, ResourceIdentity, ResourceKind}; pub use semantic::{ - FindOptions, PageText, SelectionStyle, TextChar, TextCharFlags, TextGeometryPrecision, - TextMatch, TextSelection, + FindOptions, PageText, SelectionStyle, TextAdvanceProvider, TextChar, TextCharFlags, + TextGeometryPrecision, TextMatch, TextSelection, }; pub use signature::{SealPicture, SealPictureKind, StampAnnotation}; pub use text::{CharacterGlyphMap, GlyphTransform, TextCode, TextObject}; diff --git a/crates/rofd-core/src/semantic.rs b/crates/rofd-core/src/semantic.rs index 8aaf5c8c..592d3f25 100644 --- a/crates/rofd-core/src/semantic.rs +++ b/crates/rofd-core/src/semantic.rs @@ -1,6 +1,24 @@ use std::ops::Range; -use crate::{Error, PageObject, Point, Rect, ResourceLimits, Result, TextObject, Transform}; +use crate::{Error, Page, PageObject, Point, Rect, Result, TextObject, Transform}; + +/// Supplies the horizontal advance of one scalar when a text run declares no +/// `DeltaX` for it. +/// +/// The OFD text model positions such a scalar by its font's advance, so geometry +/// that resolves fonts matches what a renderer draws; this crate parses and indexes +/// text without font access, which is why a provider is injected from outside +/// (`rofd-render` implements it over the same font stack the renderer uses). A +/// document loaded without a provider keeps the font-size footprint for those runs, +/// which stays a valid conservative approximation. +pub trait TextAdvanceProvider: Send + Sync { + /// Returns one advance in millimetres per scalar of `text`, concatenating its + /// runs in order, or `None` when the object's font cannot be resolved (the + /// builder then keeps the font-size footprint). + /// + /// Non-finite or negative values are ignored per scalar. + fn advances_mm(&self, page: &Page, text: &TextObject, size_mm: f64) -> Option>; +} /// Options for literal search in canonical page text. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -462,8 +480,11 @@ fn union_rectangles(mut rectangles: impl Iterator) -> Result