diff --git a/crates/pdf-text-engine/src/pdf_text_layout.rs b/crates/pdf-text-engine/src/pdf_text_layout.rs index 5195243d..f4186167 100644 --- a/crates/pdf-text-engine/src/pdf_text_layout.rs +++ b/crates/pdf-text-engine/src/pdf_text_layout.rs @@ -316,7 +316,7 @@ fn resolve_glyph( excluded_faces: &excluded, })?; for candidate in candidates { - let Ok(face) = system.load_with_metadata(candidate.source, candidate.metadata) else { + let Ok(face) = system.load_fallback_face(candidate) else { continue; }; if let Some(glyph_id) = face.glyph_for_char(character) { diff --git a/crates/pdf-text-engine/src/system.rs b/crates/pdf-text-engine/src/system.rs index 7bf916a0..29e1372a 100644 --- a/crates/pdf-text-engine/src/system.rs +++ b/crates/pdf-text-engine/src/system.rs @@ -35,22 +35,61 @@ //! let _system = FontSystem::new(registry, Arc::new(EmptyFallback)); //! ``` -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; use crate::error::TextError; use crate::text::{PdfTextRun, PositionedGlyph, TextLayout, TextVector}; use pdf_font::fallback::WholeFontFallbackRequest; use pdf_font::pdf_font_handle::PdfFontHandle; use pdf_font::{ - FallbackProvider, FontFace, FontMetadata, FontRegistry, FontSource, PdfFontSpec, Type3FontSpec, + FallbackCandidate, FallbackProvider, FontFace, FontMetadata, FontProgramFormat, FontRegistry, + FontSource, PdfFontSpec, Type3FontSpec, }; +/// Identity of an in-memory fallback program: its buffer address and length, format, and face. +/// +/// Keying by address avoids hashing large bundled programs. Each cache entry retains its source, +/// so the buffer stays alive and its address cannot be reused by another program. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct FallbackSourceKey { + address: usize, + length: usize, + format: FontProgramFormat, + face_index: u32, +} + +impl FallbackSourceKey { + /// Returns the key of an in-memory source, or `None` for sources that are not cached. + fn from_source(source: &FontSource) -> Option { + let FontSource::Memory { + data, + format, + face_index, + } = source + else { + return None; + }; + Some(Self { + address: data.as_ptr().addr(), + length: data.len(), + format: *format, + face_index: *face_index, + }) + } +} + +/// Glyph-fallback faces loaded so far, with the sources that keep their buffers alive. +type FallbackFaces = HashMap)>; + /// Thread-safe façade for loading PDF fonts and producing positioned glyph layouts. pub struct FontSystem { /// Ordered set of drivers used to turn owned font sources into faces. registry: FontRegistry, /// Application policy for whole-font and per-glyph substitution. fallback: Arc, + /// Glyph-fallback faces, loaded once so runs and pages share faces and their glyph caches. + fallback_faces: RwLock, } impl FontSystem { @@ -60,7 +99,11 @@ impl FontSystem { /// allows callers to reuse an immutable policy without another wrapper type. #[must_use] pub fn new(registry: FontRegistry, fallback: Arc) -> Self { - Self { registry, fallback } + Self { + registry, + fallback, + fallback_faces: RwLock::new(HashMap::new()), + } } /// Returns the configured font driver registry. @@ -137,6 +180,37 @@ impl FontSystem { self.registry.load(source, metadata_hint) } + /// Loads a glyph-fallback candidate, reusing the face loaded earlier for the same program. + /// + /// In-memory candidates are cached for the lifetime of the system; other sources load anew. + /// Fallback face metadata only describes the substitute, so one face serves every request. + pub(crate) fn load_fallback_face( + &self, + candidate: FallbackCandidate, + ) -> Result, pdf_font::FontError> { + let Some(key) = FallbackSourceKey::from_source(&candidate.source) else { + return self.load_with_metadata(candidate.source, candidate.metadata); + }; + // Entries are inserted only after their face has loaded, so a poisoned map is still valid. + let cached = match self.fallback_faces.read() { + Ok(faces) => faces.get(&key).map(|(_, face)| Arc::clone(face)), + Err(poisoned) => poisoned + .into_inner() + .get(&key) + .map(|(_, face)| Arc::clone(face)), + }; + if let Some(face) = cached { + return Ok(face); + } + let face = self.load_with_metadata(candidate.source.clone(), candidate.metadata)?; + let entry = (candidate.source, Arc::clone(&face)); + match self.fallback_faces.write() { + Ok(mut faces) => faces.insert(key, entry), + Err(poisoned) => poisoned.into_inner().insert(key, entry), + }; + Ok(face) + } + /// Returns the first loadable whole-font fallback candidate in provider order. /// /// A candidate-specific load error does not stop the search because later candidates may use a