From 7e1dcceb460c2e9e39313326ec0d23d1c63cf0d3 Mon Sep 17 00:00:00 2001 From: Yangtse Su Date: Mon, 19 Oct 2026 16:23:51 +0800 Subject: [PATCH 1/2] feat(core): span a character box with the font's metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rofd's page text geometry built every character box from the font size - one size above the baseline, stopping at it - while a PDF text box is built from the font's own metrics (poppler's `TextWord::setInitialBounds`: `yMin = y - ascent`, `yMax = y - descent`, the ascent/descent of the face). The mismatch cut the bottom off CJK glyphs and Latin descenders, and it left the vertical extent of a box dependent on the glyph inside it, so punctuation and Latin did not line up with CJK in one line. Measured on a real government document (2464 glyphs over six embedded fonts, sizes 4.9-7.8 mm): 86% of the scalars reach below the baseline - CJK overshoot 0.098-0.129 em, Latin descenders up to 0.188 em - by a mean of 0.50 mm and up to 1.46 mm, while the box top sat 1.45 mm above the painted top on average. The provider seam that already resolves per-scalar advances now answers the whole scalar geometry: `TextScalarMetrics` carries the advance of the glyph the positioner selected plus the *face's* typographic ascent and descent, which are the same for every glyph of that face at one size, so the boxes of a run share one band and cover the glyphs. `ResolvedFont` reads that pair once from FreeType while validating the face, `GlyphMetrics` exposes it beside the advance, and a face that declares no usable metrics keeps the font-size footprint - as does a document loaded without a provider. The provider is asked for every text object, even one whose runs all declare their own deltas: the vertical extents come from the resolved font whatever a scalar declares. 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 (the export set is unchanged). The pixel-level tests fail against the previous model and pass with this one - `crates/rofd-render/tests/cairo_text.rs::semantic_character_boxes_cover_the_rendered_ink` (the two scalars of one run now share top and bottom, and the band is strictly taller than the ink) and `crates/rofd-ffi/tests/semantic.rs::text_layout_rectangles_cover_the_rendered_ink_below_the_baseline` (same band for A and 中) - and `crates/rofd-core/tests/semantic_text.rs` pins the aligned band. --- crates/rofd-core/src/document.rs | 69 ++--- crates/rofd-core/src/lib.rs | 4 +- crates/rofd-core/src/semantic.rs | 104 ++++--- crates/rofd-core/tests/semantic_text.rs | 118 ++++++-- crates/rofd-ffi/include/rofd.h | 9 +- crates/rofd-ffi/src/document.rs | 10 +- crates/rofd-ffi/src/renderer.rs | 29 +- crates/rofd-ffi/tests/semantic.rs | 197 +++++++++++++ crates/rofd-render/src/fonts.rs | 258 +++++++++++++----- crates/rofd-render/src/lib.rs | 4 +- crates/rofd-render/tests/cairo_text.rs | 84 +++++- crates/rofd-render/tests/fonts.rs | 77 ++++-- ...-09-poppler-style-c-semantic-api-design.md | 14 +- 13 files changed, 758 insertions(+), 219 deletions(-) diff --git a/crates/rofd-core/src/document.rs b/crates/rofd-core/src/document.rs index 0ac4ac65..22c5168b 100644 --- a/crates/rofd-core/src/document.rs +++ b/crates/rofd-core/src/document.rs @@ -123,18 +123,18 @@ 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, + /// Font-resolved per-scalar geometry for the page text index, when the loader + /// supplied a provider (see [`crate::TextMetricsProvider`]). + text_metrics: Option, } -/// Debug-friendly owner of the optional text advance provider. +/// Debug-friendly owner of the optional text metrics provider. #[derive(Clone)] -struct TextAdvances(Arc); +struct TextMetricsSource(Arc); -impl std::fmt::Debug for TextAdvances { +impl std::fmt::Debug for TextMetricsSource { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("TextAdvances()") + formatter.write_str("TextMetricsSource()") } } @@ -242,15 +242,17 @@ impl Page { &self.data.layers } - /// Returns flattened source text and conservative character geometry in - /// physical-page millimetres, in effective paint order. + /// Returns flattened source text and per-character geometry in 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 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. + /// text objects are separated by synthesized newlines. Geometry uses validated OFD + /// positioning; a scalar whose run declares no `DeltaX` advances by the advance of + /// a provider's font when the document was loaded with one, and its box spans that + /// font's ascent and descent around the baseline, so it covers the painted glyph. + /// Without a provider the font-size footprint stays in place. 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); @@ -261,12 +263,12 @@ impl Page { if let Some(text) = self.data.semantic_text.get() { return Ok(text); } - let advances = self + let metrics = self ._document - .text_advances + .text_metrics .as_ref() - .map(|advances| advances.0.as_ref() as &dyn crate::TextAdvanceProvider); - let text = crate::semantic::build_page_text(self, advances)?; + .map(|metrics| metrics.0.as_ref() as &dyn crate::TextMetricsProvider); + let text = crate::semantic::build_page_text(self, metrics)?; Ok(self.data.semantic_text.get_or_init(|| text)) } @@ -358,39 +360,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) + Self::open_with_metrics(path, options, None) } - /// Opens an OFD document from a host file path, resolving text advances through - /// `advances`. - pub fn open_with_advances( + /// Opens an OFD document from a host file path, resolving per-scalar text + /// geometry through `metrics`. + pub fn open_with_metrics( path: impl AsRef, options: LoadOptions, - advances: Option>, + metrics: 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_with_advances(bytes, options, advances) + Self::from_bytes_with_metrics(bytes, options, metrics) } /// Opens an OFD document from owned bytes. pub fn from_bytes(bytes: Vec, options: LoadOptions) -> Result { - Self::from_bytes_with_advances(bytes, options, None) + Self::from_bytes_with_metrics(bytes, options, None) } - /// Opens an OFD document from owned bytes, resolving text advances through - /// `advances`. + /// Opens an OFD document from owned bytes, resolving per-scalar text geometry + /// through `metrics`. /// - /// 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( + /// Without a provider, text geometry keeps the font-size footprint. With one, a + /// scalar whose run declares no `DeltaX` advances by the font's advance and its + /// box spans the font's extent around the baseline, which is what a renderer + /// paints. + pub fn from_bytes_with_metrics( bytes: Vec, options: LoadOptions, - advances: Option>, + metrics: Option>, ) -> Result { let strictness = options.strictness; let limits = options.limits; @@ -534,7 +537,7 @@ impl Document { container, document_path, limits, - text_advances: advances.map(TextAdvances), + text_metrics: metrics.map(TextMetricsSource), 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 027a3440..7f6435c5 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, TextAdvanceProvider, TextChar, TextCharFlags, - TextGeometryPrecision, TextMatch, TextSelection, + FindOptions, PageText, SelectionStyle, TextChar, TextCharFlags, TextGeometryPrecision, + TextMatch, TextMetricsProvider, TextScalarMetrics, 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 c7ee1959..8de2e11f 100644 --- a/crates/rofd-core/src/semantic.rs +++ b/crates/rofd-core/src/semantic.rs @@ -2,22 +2,46 @@ use std::ops::Range; 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. +/// Font-resolved geometry of one scalar of a text object, in millimetres. /// -/// 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 +/// Every value is relative to the scalar's pen position, which is the baseline +/// origin the OFD text model positions and a renderer paints from. The advance is the +/// glyph's; the extents are its face's typographic ascent and descent, so the scalars +/// of one face at one size share them and the boxes of different scripts and of +/// punctuation stay aligned, the way a PDF text box built from font metrics does. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TextScalarMetrics { + /// Horizontal advance the pen moves by after the scalar is painted. + pub advance_mm: f64, + /// Extent the face declares above the baseline. + pub ascent_mm: f64, + /// Extent the face declares below the baseline. + pub descent_mm: f64, +} + +/// Supplies the font-resolved geometry of each scalar of one text object. +/// +/// A scalar without an explicit `DeltaX` is positioned by its font's advance, and it +/// is painted at the font's own extent around the baseline; 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) whenever +/// page geometry has to match what a renderer draws. A document loaded without a +/// provider keeps the font-size footprint, which stays a valid conservative +/// approximation. +pub trait TextMetricsProvider: Send + Sync { + /// Returns one metrics entry 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>; + /// A scalar without a resolved glyph of its own keeps the conservative font-size + /// footprint: `size_mm` above the baseline and nothing below it. That covers both + /// a scalar no face owns and one whose glyph paints nothing, such as a space. + fn metrics_mm( + &self, + page: &Page, + text: &TextObject, + size_mm: f64, + ) -> Option>; } /// Options for literal search in canonical page text. @@ -482,9 +506,9 @@ fn union_rectangles(mut rectangles: impl Iterator) -> Result