Packages

Native PDF generation for Elixir: react-pdf-style ~PDF templates, flexbox layout, and a pure-Rust render backend. No browser, no external binaries.

Current section

Files

Jump to
mead_pdf native mead_nif src pool.rs
Raw

native/mead_nif/src/pool.rs

//! Lazy fallback pool: the hybrid store's tail (DESIGN-2026-07-24 §3
//! addendum).
//!
//! Declared fonts stay immutable and always win; a pool of on-disk
//! fonts (e.g. all of Noto) provides per-script fallback with memory
//! proportional to what documents actually use:
//!
//! - **Metadata eager.** Pool files are scanned once at `FontSet`
//! build: fontique registers them with path-backed sources (no
//! resident bytes), and a charmap probe against fontique's own
//! per-script sample strings routes each script to the pool families
//! that cover it (`append_fallbacks` — the same key parley queries
//! with). The fallback decision is a fixed pure function from t=0:
//! no generation counter, no transient tofu.
//! - **Data lazy.** Parley loads face bytes through the shared
//! `SourceCache` only when a query consults a face whose script
//! claims the cluster; the `Session` prunes unused blobs at context
//! checkin. Draw-side krilla fonts are materialized on demand into a
//! bounded LRU here; an evicted face reloads from disk on next use —
//! pure latency, never a different glyph (see `text::FaceId`).
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use parley::fontique::{FallbackKey, FamilyId, GenericFamily, Script, ScriptExt, SourceKind};
use crate::text::TextContexts;
/// Materialized krilla fonts kept resident (each pins its file's
/// bytes; CJK faces run tens of MB). Small on purpose: documents
/// rarely draw from more than a couple of pool files, and a miss costs
/// one disk read + font parse.
const FACE_CACHE_CAP: usize = 4;
/// (file length, hash of first+last 4KB): tells pool files apart
/// without hashing whole multi-MB fonts; stable across reloads, unlike
/// fontique blob ids.
type Fingerprint = (u64, u64);
pub struct Pool {
/// Scanned pool files in declaration order (directories expanded
/// with their entries sorted), the `file` half of `FaceId::Pool`.
files: Vec<PathBuf>,
fingerprints: HashMap<Fingerprint, u32>,
/// (file, face index) -> materialized krilla font, LRU-bounded.
faces: Mutex<lru::LruCache<(u32, u32), krilla::text::Font>>,
}
impl Pool {
/// Scans `paths` (font files or directories) and wires the pool
/// into the prototype contexts: fontique path registration, shared
/// source cache, and per-script fallback routing. Must run before
/// the first checkout so every render sees the same collection.
pub fn build(cx: &mut TextContexts, paths: &[String]) -> Result<Self, String> {
let files = expand_paths(paths)?;
if files.is_empty() {
return Err("font pool matched no font files (.ttf/.otf/.ttc/.otc)".to_string());
}
cx.fonts.collection.load_fonts_from_paths(&files);
// One shared blob store: concurrent checkouts load a pool file
// at most once between prunes.
cx.fonts.source_cache.make_shared();
let by_path: HashMap<&Path, u32> = files
.iter()
.enumerate()
.map(|(i, p)| (p.as_path(), i as u32))
.collect();
// Pool faces straight from fontique's registry (family id +
// face index + source path), ordered by (file, face) so script
// routing follows pool declaration order.
let names: Vec<String> = cx
.fonts
.collection
.family_names()
.map(str::to_string)
.collect();
let mut faces: Vec<(u32, u32, FamilyId)> = Vec::new();
for name in &names {
let Some(id) = cx.fonts.collection.family_id(name) else {
continue;
};
let Some(info) = cx.fonts.collection.family(id) else {
continue;
};
for font in info.fonts() {
if let SourceKind::Path(path) = &font.source().kind {
if let Some(&file) = by_path.get(path.as_ref() as &Path) {
faces.push((file, font.index(), id));
}
}
}
}
faces.sort_unstable_by_key(|&(file, index, _)| (file, index));
// Coverage probe: fontique's per-script sample strings against
// each face's cmap (the exact keys parley's selector queries
// with; `!= 0` mirrors its coverage hack). One read per file,
// discarded after scanning — no resident pool bytes.
let mut fingerprints: HashMap<Fingerprint, u32> = HashMap::new();
let mut per_script: HashMap<Script, Vec<FamilyId>> = HashMap::new();
let mut emoji: Vec<FamilyId> = Vec::new();
let mut current: Option<(u32, Vec<u8>)> = None;
for &(file, index, family) in &faces {
if current.as_ref().map(|(f, _)| *f) != Some(file) {
let path = &files[file as usize];
let bytes = std::fs::read(path)
.map_err(|e| format!("font pool file {}: {e}", path.display()))?;
fingerprints.insert(fingerprint(&bytes), file);
current = Some((file, bytes));
}
let bytes = &current.as_ref().expect("file bytes just loaded").1;
let Ok(font) = skrifa::FontRef::from_index(bytes, index) else {
continue; // unreadable face: fontique will skip it too
};
use skrifa::MetadataProvider;
let charmap = font.charmap();
let covers = |ch: char| charmap.map(ch).is_some_and(|g| g.to_u32() != 0);
for (script, sample) in Script::all_samples() {
if sample.chars().any(covers) {
let fams = per_script.entry(*script).or_default();
if !fams.contains(&family) {
fams.push(family);
}
}
}
if covers('😀') && !emoji.contains(&family) {
emoji.push(family);
}
}
for (script, fams) in per_script {
cx.fonts
.collection
.append_fallbacks(FallbackKey::new(script, None), fams.into_iter());
}
if !emoji.is_empty() {
// Parley appends the Emoji generic for emoji clusters.
cx.fonts
.collection
.append_generic_families(GenericFamily::Emoji, emoji.into_iter());
}
Ok(Self {
files,
fingerprints,
faces: Mutex::new(lru::LruCache::new(
NonZeroUsize::new(FACE_CACHE_CAP).expect("nonzero cap"),
)),
})
}
/// Which pool file these font bytes are, by fingerprint. Called by
/// `text::resolve_face` for blobs that are not declared entries.
pub fn file_for(&self, bytes: &[u8]) -> Option<u32> {
self.fingerprints.get(&fingerprint(bytes)).copied()
}
/// The materialized krilla font for a pool face, loading (or
/// reloading, after eviction) from disk on a cache miss. The disk
/// read + parse happens outside the lock; a rare concurrent double
/// load is benign (last write wins, both fonts are valid).
pub fn face(&self, file: u32, index: u32) -> Result<krilla::text::Font, String> {
let mut lru = self
.faces
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(font) = lru.get(&(file, index)) {
return Ok(font.clone());
}
drop(lru);
let path = self
.files
.get(file as usize)
.ok_or("pool file index out of range")?;
let bytes =
std::fs::read(path).map_err(|e| format!("font pool file {}: {e}", path.display()))?;
let font = krilla::text::Font::new(krilla::Data::from(bytes), index)
.ok_or_else(|| format!("font pool face {}#{index} failed to parse", path.display()))?;
let evicted = self
.faces
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.put((file, index), font.clone());
drop(evicted); // evicted font (and its bytes) dropped after unlock
Ok(font)
}
}
fn fingerprint(bytes: &[u8]) -> Fingerprint {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let head = &bytes[..bytes.len().min(4096)];
let tail = &bytes[bytes.len().saturating_sub(4096)..];
head.hash(&mut hasher);
tail.hash(&mut hasher);
(bytes.len() as u64, hasher.finish())
}
/// Files and directories (one level of recursion per nesting, sorted
/// within each directory) -> deduped font file list in declaration
/// order.
fn expand_paths(paths: &[String]) -> Result<Vec<PathBuf>, String> {
let mut files = Vec::new();
let mut seen = std::collections::HashSet::new();
for path in paths {
walk(Path::new(path), &mut files, &mut seen)?;
}
Ok(files)
}
fn walk(
path: &Path,
out: &mut Vec<PathBuf>,
seen: &mut std::collections::HashSet<PathBuf>,
) -> Result<(), String> {
let meta =
std::fs::metadata(path).map_err(|e| format!("font pool path {}: {e}", path.display()))?;
if meta.is_dir() {
let mut entries: Vec<PathBuf> = std::fs::read_dir(path)
.map_err(|e| format!("font pool path {}: {e}", path.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.collect();
entries.sort();
for entry in entries {
let is_dir = entry.is_dir();
if is_dir || is_font_file(&entry) {
walk(&entry, out, seen)?;
}
}
} else if is_font_file(path) && seen.insert(path.to_path_buf()) {
out.push(path.to_path_buf());
} else if !is_font_file(path) {
// Explicitly listed non-font files are an error; silently
// skipping them would hide typos in pool declarations.
return Err(format!(
"font pool path {} is not a font file (.ttf/.otf/.ttc/.otc)",
path.display()
));
}
Ok(())
}
fn is_font_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("ttf" | "otf" | "ttc" | "otc")
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text::{layout_text, FaceId, SpanSpec, TextSpec, DEFAULT_FAMILY};
use parley::Alignment;
fn fixture_path(name: &str) -> String {
format!(
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../test/support/fixtures/{}"
),
name
)
}
/// Default family = ascii-only block subset (no Thai, no Greek);
/// pool dir, sorted = greek Noto subset (file 0, covers fontique's
/// archaic-Greek sample chars) + thai Noto subset (file 1).
fn pooled_contexts() -> (TextContexts, Pool) {
let mut cx = TextContexts::new();
let ascii = std::fs::read(fixture_path("weasyprint-ascii.otf")).unwrap();
cx.register(0, DEFAULT_FAMILY, 400.0, false, ascii);
let pool = Pool::build(&mut cx, &[fixture_path("pool")]).unwrap();
(cx, pool)
}
fn spec_for<'a>(spans: &'a [SpanSpec]) -> TextSpec<'a> {
TextSpec {
spans,
line_height: 1.0,
font_size: 10.0,
align: Alignment::Left,
}
}
fn span(content: &str) -> SpanSpec {
SpanSpec {
content: content.to_string(),
family: None,
weight: 400.0,
italic: false,
font_size: 10.0,
color: (0, 0, 0),
}
}
// A script no declared face covers routes to the pool face that
// claims it, and runs come back with stable pool face ids and real
// (non-notdef) glyphs.
#[test]
fn uncovered_script_resolves_to_pool_face() {
let (mut cx, pool) = pooled_contexts();
cx.pool = Some(std::sync::Arc::new(pool));
let spans = [span("ภาษาไทย")];
let lines = layout_text(&mut cx, &spec_for(&spans), None).unwrap();
let run = &lines.lines[0].runs[0];
assert_eq!(
run.face,
FaceId::Pool { file: 1, index: 0 },
"Thai routes to the thai pool file"
);
assert!(run.glyphs.iter().all(|g| g.id != 0), "no notdef glyphs");
}
// Text the declared set covers never touches the pool, and pool
// files are picked per script (greek subset for Greek).
#[test]
fn pool_is_script_routed() {
let (mut cx, pool) = pooled_contexts();
cx.pool = Some(std::sync::Arc::new(pool));
let spans = [span("aa πΔ")];
let lines = layout_text(&mut cx, &spec_for(&spans), None).unwrap();
let faces: Vec<FaceId> = lines.lines[0].runs.iter().map(|r| r.face).collect();
assert_eq!(
faces,
vec![FaceId::Declared(0), FaceId::Pool { file: 0, index: 0 }],
"ascii stays declared; Greek goes to the greek pool file"
);
let greek = &lines.lines[0].runs[1];
assert!(greek.glyphs.iter().all(|g| g.id != 0), "no notdef glyphs");
}
// Draw-side materialization: faces load, and a reload after
// eviction hands back an equally valid font (pure latency).
#[test]
fn face_materializes_and_survives_eviction() {
let (_cx, pool) = pooled_contexts();
let first = pool.face(0, 0).unwrap();
drop(first);
// Force the entry out, then reload it.
pool.faces.lock().unwrap().clear();
let reloaded = pool.face(0, 0);
assert!(reloaded.is_ok(), "evicted face reloads from disk");
assert!(pool.face(1, 0).is_ok());
assert!(pool.face(9, 0).is_err(), "out-of-range file errors");
}
// Fingerprints resolve loaded pool bytes back to their file.
#[test]
fn fingerprints_roundtrip() {
let (_cx, pool) = pooled_contexts();
let greek = std::fs::read(fixture_path("pool/noto-greek-subset.ttf")).unwrap();
let thai = std::fs::read(fixture_path("pool/noto-thai-subset.ttf")).unwrap();
assert_eq!(pool.file_for(&greek), Some(0));
assert_eq!(pool.file_for(&thai), Some(1));
assert_eq!(pool.file_for(b"not a font"), None);
}
}