Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions crates/rofd-core/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,19 @@ struct DocumentInner {
bookmarks: OnceLock<crate::navigation::BookmarkData>,
bookmark_initialization: Mutex<()>,
navigation_warning_state: Mutex<NavigationWarningState>,
/// Font-resolved per-scalar advances for runs without explicit deltas, when the
/// loader supplied a provider (see [`crate::TextAdvanceProvider`]).
text_advances: Option<TextAdvances>,
}

/// Debug-friendly owner of the optional text advance provider.
#[derive(Clone)]
struct TextAdvances(Arc<dyn crate::TextAdvanceProvider>);

impl std::fmt::Debug for TextAdvances {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("TextAdvances(<provider>)")
}
}

#[derive(Debug, Default)]
Expand Down Expand Up @@ -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);
Expand All @@ -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))
}

Expand Down Expand Up @@ -339,16 +358,40 @@ impl Page {
impl Document {
/// Opens an OFD document from a host file path.
pub fn open(path: impl AsRef<Path>, options: LoadOptions) -> Result<Self> {
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<Path>,
options: LoadOptions,
advances: Option<Arc<dyn crate::TextAdvanceProvider>>,
) -> Result<Self> {
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<u8>, options: LoadOptions) -> Result<Self> {
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<u8>,
options: LoadOptions,
advances: Option<Arc<dyn crate::TextAdvanceProvider>>,
) -> Result<Self> {
let strictness = options.strictness;
let limits = options.limits;
let container = Container::from_bytes(bytes, limits.clone())?;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions crates/rofd-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
70 changes: 60 additions & 10 deletions crates/rofd-core/src/semantic.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<f64>>;
}

/// Options for literal search in canonical page text.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -462,8 +480,11 @@ fn union_rectangles(mut rectangles: impl Iterator<Item = Rect>) -> Result<Option
})
}

pub(crate) fn build_page_text(page: &crate::Page) -> Result<PageText> {
let mut builder = TextBuilder::new(page.resource_limits());
pub(crate) fn build_page_text(
page: &crate::Page,
advances: Option<&dyn TextAdvanceProvider>,
) -> Result<PageText> {
let mut builder = TextBuilder::new(page, advances);
for layer in page.layers() {
builder.objects(layer.objects(), Transform::IDENTITY)?;
}
Expand All @@ -475,18 +496,23 @@ pub(crate) fn build_page_text(page: &crate::Page) -> Result<PageText> {
Ok(builder.text)
}

struct TextBuilder {
struct TextBuilder<'a> {
text: PageText,
page: &'a Page,
advances: Option<&'a dyn TextAdvanceProvider>,
source_characters: usize,
metadata_entries: usize,
max_source_characters: usize,
max_metadata_entries: usize,
}

impl TextBuilder {
fn new(limits: &ResourceLimits) -> Self {
impl<'a> TextBuilder<'a> {
fn new(page: &'a Page, advances: Option<&'a dyn TextAdvanceProvider>) -> Self {
let limits = page.resource_limits();
Self {
text: PageText::default(),
page,
advances,
source_characters: 0,
metadata_entries: 0,
max_source_characters: limits.max_text_characters_per_page,
Expand Down Expand Up @@ -526,26 +552,50 @@ impl TextBuilder {
self.push_separator()?;
}
let size = object.font_size();
// One font-resolved advance per scalar of the whole object, in run order;
// absent when the provider has no font for it. Explicit deltas stay
// authoritative and are never replaced, and they are the only thing this
// geometry takes from the provider, so an object whose runs all declare their
// own deltas never asks it for a font at all.
let needs_advances = object.runs().iter().any(|run| !run.has_explicit_delta_x());
let resolved = if needs_advances {
self.advances
.and_then(|advances| advances.advances_mm(self.page, object, size))
} else {
None
};
let mut scalar = 0usize;
for run in object.runs() {
let (mut x, mut y) = (run.x(), run.y());
for (index, character) in run.text().chars().enumerate() {
let font_advance = resolved
.as_ref()
.and_then(|values| values.get(scalar))
.copied()
.filter(|value| value.is_finite() && *value > 0.0);
let dx = if run.has_explicit_delta_x() {
run.delta_x()[index]
} else {
size
font_advance.unwrap_or(size)
};
scalar += 1;
let dy = if run.has_explicit_delta_y() {
run.delta_y()[index]
} else {
0.0
};
let next_x = finite_add(x, dx)?;
let next_y = finite_add(y, dy)?;
// Bound the horizontal baseline sweep with a direction-independent
// font-size footprint. Explicit zero advances still receive
// A resolved glyph advance is the box width, so the boxes of a run
// tile it and a match rectangle hugs the matched text. Without one,
// bound the horizontal baseline sweep with a direction-independent
// font-size footprint instead; explicit zero advances still receive
// nonempty conservative geometry.
let left = x.min(next_x);
let right = finite_add(left, dx.abs().max(size))?;
let right = finite_add(
left,
font_advance.map_or_else(|| dx.abs().max(size), f64::abs),
)?;
let top = finite_add(y.min(next_y), -size)?;
let bottom = y.max(next_y);
let rect = transformed_box(left, top, right, bottom, transform)?;
Expand Down
131 changes: 130 additions & 1 deletion crates/rofd-core/tests/semantic_text.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
mod support;

use std::sync::Arc;

use rofd_core::{
Document, Error, FindOptions, LoadOptions, Rect, ResourceLimits, SelectionStyle, TextCharFlags,
TextGeometryPrecision, TextSelection,
TextGeometryPrecision, TextObject, TextSelection,
};

fn rect(x: f64, y: f64, width: f64, height: f64) -> Rect {
Expand Down Expand Up @@ -396,6 +398,133 @@ fn page_with_text(content: &str) -> rofd_core::Page {
document_with_page(&page_xml, "", "", &[]).page(0).unwrap()
}

/// A provider that advances every scalar by half the font size, so the geometry it
/// produces is distinguishable from both the font-size footprint and explicit deltas.
struct HalfSizeAdvances {
resolution_failure: bool,
}

impl rofd_core::TextAdvanceProvider for HalfSizeAdvances {
fn advances_mm(
&self,
_page: &rofd_core::Page,
text: &TextObject,
size_mm: f64,
) -> Option<Vec<f64>> {
if self.resolution_failure {
return None;
}
Some(
text.runs()
.iter()
.flat_map(|run| run.text().chars().map(|_| size_mm / 2.0))
.collect(),
)
}
}

fn document_with_page_and_advances(
page_xml: &str,
advances: Option<Arc<dyn rofd_core::TextAdvanceProvider>>,
) -> Document {
let document_xml = r#"<ofd:Document xmlns:ofd="http://www.ofdspec.org/2016"><ofd:CommonData>
<ofd:PageArea><ofd:PhysicalBox>0 0 210 297</ofd:PhysicalBox></ofd:PageArea>
<ofd:PublicRes>Res.xml</ofd:PublicRes></ofd:CommonData>
<ofd:Pages><ofd:Page ID="900" BaseLoc="Pages/Page_0/Content.xml"/></ofd:Pages></ofd:Document>"#;
let entries = [("Doc_0/Res.xml", FONT_CATALOG.as_bytes())];
Document::from_bytes_with_advances(
support::ofd_with_document_page_and_entries(document_xml, page_xml, &entries),
LoadOptions::default(),
advances,
)
.unwrap()
}

#[test]
fn advance_provider_supplies_scalar_steps_where_deltas_are_absent() {
let page_xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<ofd:Page xmlns:ofd="http://www.ofdspec.org/2016">
<ofd:Content><ofd:Layer ID="1">{}</ofd:Layer></ofd:Content>
</ofd:Page>"#,
text_object(2, "ABCD")
);

let without = document_with_page_and_advances(&page_xml, None)
.page(0)
.unwrap()
.text()
.unwrap()
.characters()
.to_vec();
let with = document_with_page_and_advances(
&page_xml,
Some(Arc::new(HalfSizeAdvances {
resolution_failure: false,
})),
)
.page(0)
.unwrap()
.text()
.unwrap()
.characters()
.to_vec();

// The fixture places run X at 1 and uses Size 4, so the second scalar starts at
// 5 without a provider and at 3 with one, and the same holds for every step.
let first = without[0].rect_mm().unwrap();
assert_eq!(without[1].rect_mm().unwrap().x - first.x, 4.0);
assert_eq!(with[1].rect_mm().unwrap().x - first.x, 2.0);
assert_eq!(with[3].rect_mm().unwrap().x - first.x, 6.0);
// A resolved advance is the box width (so the boxes tile the run), while an
// unresolved scalar keeps the conservative font-size footprint.
assert!((with[3].rect_mm().unwrap().width - 2.0).abs() < 1e-9);
assert!(without[3].rect_mm().unwrap().width >= 4.0);
let first_end = with[0].rect_mm().unwrap().x + with[0].rect_mm().unwrap().width;
assert!((first_end - with[1].rect_mm().unwrap().x).abs() < 1e-9);

// A provider that cannot resolve a font leaves the font-size footprint alone.
let failed = document_with_page_and_advances(
&page_xml,
Some(Arc::new(HalfSizeAdvances {
resolution_failure: true,
})),
)
.page(0)
.unwrap()
.text()
.unwrap()
.characters()
.to_vec();
assert_eq!(failed[1].rect_mm().unwrap().x - first.x, 4.0);
}

#[test]
fn explicit_deltas_stay_authoritative_with_a_provider() {
let page_xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<ofd:Page xmlns:ofd="http://www.ofdspec.org/2016">
<ofd:Content><ofd:Layer ID="1">{}</ofd:Layer></ofd:Content>
</ofd:Page>"#,
r#"<ofd:TextObject ID="2" Boundary="0 0 20 8" Font="10" Size="4"><ofd:TextCode X="1" Y="5" DeltaX="9 9 9 9">ABCD</ofd:TextCode></ofd:TextObject>"#
);
let text = document_with_page_and_advances(
&page_xml,
Some(Arc::new(HalfSizeAdvances {
resolution_failure: false,
})),
)
.page(0)
.unwrap()
.text()
.unwrap()
.characters()
.to_vec();

let first = text[0].rect_mm().unwrap();
assert_eq!(text[1].rect_mm().unwrap().x - first.x, 9.0);
}

#[test]
fn page_text_exposes_utf8_ranges_source_ids_and_conservative_geometry() {
const SYNTHESIZED_SEPARATOR_BITS: u32 = TextCharFlags::SYNTHESIZED_SEPARATOR.bits();
Expand Down
5 changes: 5 additions & 0 deletions crates/rofd-ffi/include/rofd.h
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,11 @@ void rofd_string_free(rofd_string_t *text);
/** Return an independently owned immutable snapshot, one entry per Unicode
* scalar in canonical page text, including synthesized separators. Free with
* rofd_text_layout_free. The snapshot survives page and document destruction.
* A scalar whose run declares no explicit delta is placed by its font's advance,
* resolved through the default font stack (the one a renderer created with NULL
* options uses); a renderer configured with other fallback families may draw
* slightly different advances. Scalars that cannot be resolved fall back to a
* conservative font-size box.
* page and layout are required; error is optional. All outputs must be aligned,
* writable, mutually disjoint and disjoint from live page storage. */
rofd_status_t rofd_page_get_text_layout(const rofd_page_t *page,
Expand Down
Loading
Loading