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 text.rs
Raw

native/mead_nif/src/text.rs

//! Parley-based text engine: shaping (harfrust), Unicode line breaking,
//! and alignment, replacing the spike's greedy word-per-word wrapper.
//!
//! Fonts are registered into a fontique `Collection` when the `FontStore`
//! is built, under their declared family/weight/style (via
//! `FontInfoOverride`, so our explicit registry wins over name tables).
//! Each laid-out run maps back to the matching krilla font by blob id,
//! and glyphs are drawn with krilla's positioned-glyph API — measurement
//! and drawing share one shaping pass, so they can never disagree.
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::ops::Range;
use std::sync::{Arc, Mutex};
use parley::fontique::{Blob, FontInfoOverride};
use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
Layout, LayoutContext, LineHeight, StyleProperty,
};
pub const DEFAULT_FAMILY: &str = "default";
/// Complete shaping-input key for the session shape memo. Completeness
/// is the correctness argument: the FontSet is immutable after
/// construction, so equal inputs can never resolve to different glyphs
/// later. Anything less than the full input set reintroduces the
/// measure/draw-disagreement bug class (see DESIGN-2026-07-24 §1).
/// Per span: content, family, weight bits, italic, size bits, color.
type SpanKey = (String, Option<String>, u32, bool, u32, (u8, u8, u8));
#[derive(PartialEq, Eq, Hash)]
struct ShapeKey {
spans: Vec<SpanKey>,
line_height: u32,
font_size: u32,
align: u8,
/// Wrap-width bits; `u32::MAX` = unwrapped (max-content measure).
width_limit: u32,
}
impl ShapeKey {
fn new(spec: &TextSpec, max_width: Option<f32>) -> Self {
let align = match spec.align {
Alignment::Start => 0u8,
Alignment::End => 1,
Alignment::Left => 2,
Alignment::Center => 3,
Alignment::Right => 4,
Alignment::Justify => 5,
};
Self {
spans: spec
.spans
.iter()
.map(|s| {
(
s.content.clone(),
s.family.clone(),
s.weight.to_bits(),
s.italic,
s.font_size.to_bits(),
s.color,
)
})
.collect(),
line_height: spec.line_height.to_bits(),
font_size: spec.font_size.to_bits(),
align,
width_limit: max_width.map_or(u32::MAX, f32::to_bits),
}
}
}
/// Lock shards for the shape memo. A single Mutex measurably serialized
/// concurrent renders sharing one session (2.8x -> 1.5x on 8 tasks):
/// every paragraph is a cache op, and an LRU hit is a *write* (recency
/// reorder). Sharding by key hash cuts the contention ~16-fold.
const MEMO_SHARDS: usize = 16;
/// Session-owned content-keyed shape cache: one cache serves both
/// within-document repetition (repeated cell values, cloned headers) and
/// cross-render repetition (the same template across many documents).
/// Bounded LRU with inline eviction — no background thread, no timer.
/// A hit clones the `TextLines` (memcpy, far cheaper than shaping;
/// fragments mutate their own lines, so no sharing by reference).
///
/// Values are `Arc`ed so critical sections stay pointer-sized: deep
/// clones (hit and insert) and evicted-entry drops all happen outside
/// the shard lock.
pub struct ShapeMemo {
shards: Vec<Mutex<lru::LruCache<ShapeKey, Arc<TextLines>>>>,
}
impl ShapeMemo {
/// `cap` in entries (split across shards); `None` for a cap of 0
/// (memo disabled).
pub fn new(cap: usize) -> Option<Arc<Self>> {
if cap == 0 {
return None;
}
let per_shard = NonZeroUsize::new(cap.div_ceil(MEMO_SHARDS)).expect("nonzero shard cap");
Some(Arc::new(Self {
shards: (0..MEMO_SHARDS)
.map(|_| Mutex::new(lru::LruCache::new(per_shard)))
.collect(),
}))
}
fn shard(&self, key: &ShapeKey) -> &Mutex<lru::LruCache<ShapeKey, Arc<TextLines>>> {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut hasher);
&self.shards[(hasher.finish() as usize) % MEMO_SHARDS]
}
fn get(&self, key: &ShapeKey) -> Option<Arc<TextLines>> {
self.shard(key)
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(key)
.cloned()
}
fn put(&self, key: ShapeKey, lines: Arc<TextLines>) {
let evicted = self
.shard(&key)
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.put(key, lines);
// Deep drop of any evicted entry lands here, after unlock.
drop(evicted);
}
}
/// Stable identity of a face across renders and evictions.
///
/// `Declared` indexes the `FontSet`'s immutable font Vec. `Pool` names
/// a scanned pool file plus the face index inside it, resolved (and
/// reloaded from disk if evicted) at draw time. Paint lists and shape-
/// memo entries must carry this rather than blob identity: a pruned +
/// reloaded pool file gets a fresh fontique blob id, while `FaceId` is
/// a pure function of the immutable FontSet/pool declaration — so
/// eviction can never redirect a glyph to a different face.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum FaceId {
Declared(u32),
Pool { file: u32, index: u32 },
}
/// Locked per render: fontique collection + parley scratch/caches.
pub struct TextContexts {
pub fonts: FontContext,
pub layout: LayoutContext<[u8; 4]>,
/// fontique blob id -> `FontStore` entry index.
pub by_blob: HashMap<u64, usize>,
/// Lazy fallback pool shared with the owning `FontSet` (`None`
/// when no pool is declared). Used here to resolve pool blobs to
/// stable `FaceId`s after layout.
pub pool: Option<Arc<crate::pool::Pool>>,
/// Per-checkout cache of pool blob id -> pool file index, so each
/// distinct pool blob is fingerprinted at most once per render.
pub pool_by_blob: HashMap<u64, u32>,
/// Family names registered in the store (unknown families fall back
/// to the default family, like the spike's selector did).
pub families: std::collections::HashSet<String>,
/// Declared fallback chain: ordered family names tried per cluster
/// when the primary family lacks a glyph (DESIGN-2026-07-24 §4,
/// "declared fallback chain"). The default family is appended as the
/// terminal entry at stack-build time, so the chain only ever adds
/// coverage. Immutable after `FontSet` construction, hence invisible
/// to the shape-memo key.
pub fallback: Vec<String>,
/// Per-tree shaping caches, cleared at each `compute_tree` (taffy
/// node ids restart per tree). `measure_cache` dedupes repeated
/// intrinsic probes: (node, width-limit bits) -> measured size.
/// `lines_cache` holds each node's most recent *definite-width*
/// shape so the frag/introspection pass consumes it instead of
/// re-shaping (unwrapped max-content shapes are not reusable — line
/// alignment offsets depend on the final container width).
pub measure_cache: HashMap<(u64, u32), (f32, f32)>,
pub lines_cache: HashMap<u64, (f32, TextLines)>,
/// The owning session's shape memo, injected at checkout (`None` on
/// the FontSet prototype and when the memo is disabled).
pub memo: Option<Arc<ShapeMemo>>,
}
impl TextContexts {
pub fn new() -> Self {
Self {
fonts: FontContext::default(),
layout: LayoutContext::new(),
by_blob: HashMap::new(),
pool: None,
pool_by_blob: HashMap::new(),
families: std::collections::HashSet::new(),
fallback: Vec::new(),
measure_cache: HashMap::new(),
lines_cache: HashMap::new(),
memo: None,
}
}
/// A private copy for one render, so concurrent renders never share
/// mutable parley state. Cheap: fontique font data is `Arc`-backed
/// blobs (a clone copies family metadata, not font bytes), the
/// `LayoutContext` is fresh scratch, and the maps are tiny. If the
/// family-metadata clone ever shows up in profiles, fontique's
/// `CollectionOptions { shared: true }` moves it behind an `Arc`.
pub fn checkout(&self) -> Self {
Self {
fonts: self.fonts.clone(),
layout: LayoutContext::new(),
by_blob: self.by_blob.clone(),
pool: self.pool.clone(),
pool_by_blob: HashMap::new(),
families: self.families.clone(),
fallback: self.fallback.clone(),
measure_cache: HashMap::new(),
lines_cache: HashMap::new(),
memo: None,
}
}
/// Registers one font under its declared family/weight/style and
/// records the blob id -> entry index mapping.
pub fn register(
&mut self,
entry_index: usize,
family: &str,
weight: f32,
italic: bool,
data: Vec<u8>,
) {
let blob = Blob::new(std::sync::Arc::new(data));
self.by_blob.insert(blob.id(), entry_index);
self.families.insert(family.to_string());
self.fonts.collection.register_fonts(
blob,
Some(FontInfoOverride {
family_name: Some(family),
weight: Some(FontWeight::new(weight)),
style: Some(if italic {
FontStyle::Italic
} else {
FontStyle::Normal
}),
width: None,
axes: None,
}),
);
}
}
/// One styled run of a paragraph (inheritance- and whitespace-
/// normalized by the Elixir side; a plain text node is a single span).
pub struct SpanSpec {
pub content: String,
pub family: Option<String>,
pub weight: f32,
pub italic: bool,
pub font_size: f32,
pub color: (u8, u8, u8),
}
/// A paragraph to lay out: styled spans plus paragraph-level properties.
pub struct TextSpec<'a> {
pub spans: &'a [SpanSpec],
/// Unitless CSS line-height applied to every span (a larger span
/// makes its line box taller, like the CSS ratio-inheritance model).
pub line_height: f32,
/// Node-level font size: sizes the line box of an empty paragraph.
pub font_size: f32,
pub align: Alignment,
}
/// One laid-out line, positions relative to the text box.
#[derive(Clone)]
pub struct LineFrag {
/// Line text with trailing whitespace trimmed (box-tree output).
pub text: String,
/// Top of the line, relative to the box top.
pub y: f32,
pub height: f32,
/// Baseline offset relative to the line top.
pub baseline: f32,
pub runs: Vec<RunFrag>,
}
/// A same-style run within a line, krilla-ready.
#[derive(Clone)]
pub struct RunFrag {
pub face: FaceId,
pub font_size: f32,
pub color: (u8, u8, u8),
/// Source text of the run (glyph ranges index into this).
pub text: String,
/// Run origin relative to the box left edge.
pub x: f32,
pub glyphs: Vec<PosGlyph>,
}
/// Glyph metrics normalized to em units (krilla's convention).
#[derive(Clone)]
pub struct PosGlyph {
pub id: u32,
pub x_offset: f32,
pub y_offset: f32,
pub x_advance: f32,
/// Byte range into the run's text.
pub range: Range<usize>,
}
#[derive(Clone)]
pub struct TextLines {
pub lines: Vec<LineFrag>,
/// Widest line advance (measurement).
pub width: f32,
/// Total stacked height.
pub height: f32,
}
/// Collapses whitespace runs to single spaces while preserving hard
/// line breaks (CSS `white-space: pre-line`): every `\n` survives, with
/// other whitespace around it absorbed, and leading/trailing spaces
/// dropped. Parley turns the newline cluster into an explicit line
/// break (`BreakReason::Explicit`), so no further engine work is needed.
pub fn collapse_ws(content: &str) -> String {
let mut out = String::with_capacity(content.len());
let mut spaces = false;
let mut newlines = 0usize;
for ch in content.chars() {
if ch == '\n' {
newlines += 1;
} else if ch.is_whitespace() {
spaces = true;
} else {
for _ in 0..newlines {
out.push('\n');
}
if newlines == 0 && spaces && !out.is_empty() {
out.push(' ');
}
spaces = false;
newlines = 0;
out.push(ch);
}
}
// Trailing newlines are deliberate empty lines; trailing spaces drop.
for _ in 0..newlines {
out.push('\n');
}
out
}
/// Parley stores cluster offsets within a shaping run as `u16`
/// (`ClusterData::text_offset`, truncated with `as u16`), so a single run
/// over 64 KiB silently wraps and corrupts every text range derived from it.
/// Runs only break on font-size/script/bidi/feature changes — same-style
/// spans merge — so the only safe, deterministic guard is per paragraph.
const MAX_PARAGRAPH_BYTES: usize = u16::MAX as usize;
/// Lays out a paragraph: shape, break at `max_width` (None = no wrap),
/// and extract krilla-ready line fragments. Paragraphs beyond parley's
/// 64 KiB per-run ceiling are laid out in chunks transparently.
///
/// When the checked-out contexts carry a session shape memo, the full
/// input set is looked up first — a hit skips shaping entirely and
/// returns a clone of the memoized lines.
pub fn layout_text(
cx: &mut TextContexts,
spec: &TextSpec,
max_width: Option<f32>,
) -> Result<TextLines, String> {
match cx.memo.clone() {
Some(memo) => {
let key = ShapeKey::new(spec, max_width);
if let Some(hit) = memo.get(&key) {
// Deep clone outside the shard lock.
return Ok((*hit).clone());
}
let lines = layout_text_uncached(cx, spec, max_width)?;
memo.put(key, Arc::new(lines.clone()));
Ok(lines)
}
None => layout_text_uncached(cx, spec, max_width),
}
}
fn layout_text_uncached(
cx: &mut TextContexts,
spec: &TextSpec,
max_width: Option<f32>,
) -> Result<TextLines, String> {
let total: usize = spec.spans.iter().map(|s| s.content.len()).sum();
if total <= MAX_PARAGRAPH_BYTES {
return layout_paragraph(cx, spec, max_width).map(|(lines, _)| lines);
}
layout_chunked(cx, spec, max_width, total)
}
/// Chunked layout for oversized paragraphs. Greedy line breaking makes
/// every line except the last final (a line depends only on the text from
/// its own start onward), so: lay out ≤64 KiB, keep all but the chunk's
/// provisional last line, and resume the next chunk at the dropped line's
/// start — the seam then breaks exactly where an unchunked pass would.
/// A chunk that is one unbreakable line is accepted whole (forced break),
/// which also guarantees progress.
fn layout_chunked(
cx: &mut TextContexts,
spec: &TextSpec,
max_width: Option<f32>,
total: usize,
) -> Result<TextLines, String> {
// Global byte offset of each span's start, for per-chunk span slicing.
let mut span_starts = Vec::with_capacity(spec.spans.len());
let mut at = 0usize;
for span in spec.spans {
span_starts.push(at);
at += span.content.len();
}
let mut lines = Vec::new();
let mut width = 0.0f32;
let mut tallest_chunk = 0.0f32;
let mut y_off = 0.0f32;
let mut pos = 0usize;
while pos < total {
let end = chunk_end(spec.spans, &span_starts, pos, total);
let sub_spans = slice_spans(spec.spans, &span_starts, pos, end);
let sub_spec = TextSpec {
spans: &sub_spans,
line_height: spec.line_height,
font_size: spec.font_size,
align: spec.align,
};
let (chunk, last_start) = layout_paragraph(cx, &sub_spec, max_width)?;
let more = end < total;
let n = chunk.lines.len();
let (keep, consumed, kept_height) = if more && n >= 2 && last_start > 0 {
(n - 1, last_start, chunk.lines[n - 1].y)
} else {
(n, end - pos, chunk.height)
};
for mut line in chunk.lines.into_iter().take(keep) {
line.y += y_off;
lines.push(line);
}
y_off += kept_height;
width = if max_width.is_some() {
width.max(chunk.width)
} else {
// Unwrapped chunks are one line each; max-content width is the
// concatenation. The seam whitespace advance is dropped — an
// undercount of one space per 64 KiB, fine for measurement.
width + chunk.width
};
tallest_chunk = tallest_chunk.max(chunk.height);
pos += consumed;
}
// max_width None happens only while measuring max-content size: report
// one line's height, not the stacked chunks (the lines themselves are
// never drawn on that path — the draw pass always has a definite width).
let height = if max_width.is_some() {
y_off
} else {
tallest_chunk
};
Ok(TextLines {
lines,
width,
height,
})
}
/// Largest chunk end ≤ `pos + MAX_PARAGRAPH_BYTES` that falls on a char
/// boundary (span edges always are; interior cuts back up ≤3 bytes).
fn chunk_end(spans: &[SpanSpec], span_starts: &[usize], pos: usize, total: usize) -> usize {
let end = (pos + MAX_PARAGRAPH_BYTES).min(total);
if end == total {
return end;
}
let idx = match span_starts.binary_search(&end) {
Ok(_) => return end,
Err(i) => i - 1,
};
let content = &spans[idx].content;
let mut local = end - span_starts[idx];
while !content.is_char_boundary(local) {
local -= 1;
}
span_starts[idx] + local
}
/// Owned copies of the spans clipped to the global byte range `start..end`.
fn slice_spans(
spans: &[SpanSpec],
span_starts: &[usize],
start: usize,
end: usize,
) -> Vec<SpanSpec> {
let mut out = Vec::new();
for (span, &at) in spans.iter().zip(span_starts) {
if at >= end {
break;
}
let a = start.saturating_sub(at);
let b = (end - at).min(span.content.len());
if a >= b {
continue;
}
out.push(SpanSpec {
content: span.content[a..b].to_string(),
family: span.family.clone(),
weight: span.weight,
italic: span.italic,
font_size: span.font_size,
color: span.color,
});
}
out
}
/// The font stack for one span: primary family, then the declared
/// fallback chain, with the default family as the terminal entry.
/// Parley walks the stack per cluster on glyph coverage (its
/// `FontSelector` queries each family in order), so a chain face is
/// only consulted for glyphs the earlier faces lack — this is the
/// declared-fallback-chain semantics from DESIGN-2026-07-24 §4.
fn font_stack(primary: &str, fallback: &[String]) -> FontFamily<'static> {
let mut names: Vec<FontFamilyName<'static>> = Vec::with_capacity(fallback.len() + 2);
let mut push = |name: &str| {
if !names
.iter()
.any(|n| matches!(n, FontFamilyName::Named(existing) if existing == name))
{
names.push(FontFamilyName::Named(std::borrow::Cow::Owned(
name.to_string(),
)));
}
};
push(primary);
for name in fallback {
push(name);
}
push(DEFAULT_FAMILY);
FontFamily::List(std::borrow::Cow::Owned(names))
}
/// Single-chunk layout (all callers guarantee ≤ `MAX_PARAGRAPH_BYTES`).
/// Also returns the byte offset of the last line's start within this
/// paragraph's content, so chunked layout can rewind to it.
fn layout_paragraph(
cx: &mut TextContexts,
spec: &TextSpec,
max_width: Option<f32>,
) -> Result<(TextLines, usize), String> {
let mut content = String::new();
let mut ranges = Vec::with_capacity(spec.spans.len());
for span in spec.spans {
let start = content.len();
content.push_str(&span.content);
ranges.push(start..content.len());
}
debug_assert!(content.len() <= MAX_PARAGRAPH_BYTES);
let mut builder = cx
.layout
.ranged_builder(&mut cx.fonts, &content, 1.0, false);
builder.push_default(StyleProperty::LineHeight(LineHeight::FontSizeRelative(
spec.line_height,
)));
for (span, range) in spec.spans.iter().zip(&ranges) {
let family = match span.family.as_deref() {
Some(name) if cx.families.contains(name) => name,
_ => DEFAULT_FAMILY,
};
builder.push(
StyleProperty::FontFamily(font_stack(family, &cx.fallback)),
range.clone(),
);
builder.push(StyleProperty::FontSize(span.font_size), range.clone());
builder.push(
StyleProperty::FontWeight(FontWeight::new(span.weight)),
range.clone(),
);
builder.push(
StyleProperty::FontStyle(if span.italic {
FontStyle::Italic
} else {
FontStyle::Normal
}),
range.clone(),
);
builder.push(
StyleProperty::Brush([span.color.0, span.color.1, span.color.2, 255]),
range.clone(),
);
}
let mut layout: Layout<[u8; 4]> = builder.build(&content);
layout.break_all_lines(max_width);
// Aligns within the max_advance passed to break_all_lines (the box
// width during the final layout pass; a no-op while measuring).
layout.align(spec.align, AlignmentOptions::default());
extract_lines(cx, spec, &content, &layout)
}
/// Maps a laid-out run's font (blob + collection index) to its stable
/// `FaceId`: declared entries by blob id, pool faces by file
/// fingerprint (cached per checkout — blob ids are only stable within
/// a render, see `FaceId`).
fn resolve_face(cx: &mut TextContexts, font: &parley::FontData) -> Result<FaceId, String> {
let blob_id = font.data.id();
if let Some(&entry) = cx.by_blob.get(&blob_id) {
return Ok(FaceId::Declared(entry as u32));
}
if let Some(&file) = cx.pool_by_blob.get(&blob_id) {
return Ok(FaceId::Pool {
file,
index: font.index,
});
}
let pool = cx
.pool
.as_ref()
.ok_or("parley selected a font that is not in the store")?;
let file = pool
.file_for(font.data.as_ref())
.ok_or("parley selected a pool font with no matching pool file")?;
cx.pool_by_blob.insert(blob_id, file);
Ok(FaceId::Pool {
file,
index: font.index,
})
}
fn extract_lines(
cx: &mut TextContexts,
spec: &TextSpec,
content: &str,
layout: &Layout<[u8; 4]>,
) -> Result<(TextLines, usize), String> {
// Parley synthesizes an out-of-range line for empty text; an empty
// paragraph still occupies one line box (spike behavior).
if content.is_empty() {
let height = spec.font_size * spec.line_height;
return Ok((
TextLines {
lines: vec![LineFrag {
text: String::new(),
y: 0.0,
height,
baseline: height,
runs: Vec::new(),
}],
width: 0.0,
height,
},
0,
));
}
let mut lines = Vec::new();
let mut width = 0.0f32;
let mut top = 0.0f32;
let mut last_line_start = 0usize;
for line in layout.lines() {
last_line_start = line.text_range().start;
let metrics = line.metrics();
let height = metrics.line_height;
// metrics.baseline is absolute within the layout; make it
// line-relative (verified by the block-font unit test below).
let baseline = metrics.baseline - top;
width = width.max(metrics.advance - metrics.trailing_whitespace);
let mut runs = Vec::new();
let mut pen_x = metrics.offset;
for run in line.runs() {
let font = run.font();
let face = resolve_face(cx, font)?;
let size = run.font_size();
let run_range = run.text_range();
let run_text = content
.get(run_range.clone())
.ok_or("parley produced an invalid run text range")?
.to_string();
let mut glyphs = Vec::new();
let mut color = (0u8, 0u8, 0u8);
for cluster in run.clusters() {
let cluster_range = cluster.text_range();
let (rel_start, rel_end) = cluster_range
.start
.checked_sub(run_range.start)
.zip(cluster_range.end.checked_sub(run_range.start))
.ok_or("parley produced an invalid cluster text range")?;
let relative = rel_start..rel_end;
for glyph in cluster.glyphs() {
let brush = layout.styles()[glyph.style_index()].brush;
color = (brush[0], brush[1], brush[2]);
glyphs.push(PosGlyph {
id: glyph.id,
// Cluster-relative offsets; parley y is
// down-positive, krilla wants up-positive.
x_offset: glyph.x / size,
y_offset: -glyph.y / size,
x_advance: glyph.advance / size,
range: relative.clone(),
});
}
}
runs.push(RunFrag {
face,
font_size: size,
color,
text: run_text,
x: pen_x,
glyphs,
});
pen_x += run.advance();
}
lines.push(LineFrag {
text: content
.get(line.text_range())
.ok_or("parley produced an invalid line text range")?
.trim_end()
.to_string(),
y: top,
height,
baseline,
runs,
});
top += height;
}
Ok((
TextLines {
lines,
width,
height: top,
},
last_line_start,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> Vec<u8> {
std::fs::read(format!(
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../test/support/fixtures/{}"
),
name
))
.expect("test font present")
}
fn block_font_contexts() -> TextContexts {
let mut cx = TextContexts::new();
cx.register(0, DEFAULT_FAMILY, 400.0, false, fixture("weasyprint.otf"));
cx
}
/// Entry 0 = full block font as default, entry 1 = ascii-only
/// subset, entry 2 = greek-only subset (all same metrics).
fn subset_font_contexts() -> TextContexts {
let mut cx = block_font_contexts();
cx.register(1, "ascii", 400.0, false, fixture("weasyprint-ascii.otf"));
cx.register(2, "greek", 400.0, false, fixture("weasyprint-greek.otf"));
cx
}
fn span(content: &str, font_size: f32) -> SpanSpec {
SpanSpec {
content: content.to_string(),
family: None,
weight: 400.0,
italic: false,
font_size,
color: (0, 0, 0),
}
}
fn spec<'a>(spans: &'a [SpanSpec], font_size: f32, line_height: f32) -> TextSpec<'a> {
TextSpec {
spans,
line_height,
font_size,
align: Alignment::Left,
}
}
// Block font: every glyph is a 1em square with a 1em advance
// (ascent 819 + descent 205 = upem 1024), so all metrics are exact.
#[test]
fn block_font_wraps_and_measures_exactly() {
let mut cx = block_font_contexts();
let spans = [span("aa aa aa", 10.0)];
let spec = spec(&spans, 10.0, 1.0);
let lines = layout_text(&mut cx, &spec, Some(50.0)).unwrap();
assert_eq!(
lines
.lines
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>(),
vec!["aa aa", "aa"]
);
assert_eq!(lines.height, 20.0);
assert_eq!(lines.width, 50.0);
let first = &lines.lines[0];
assert_eq!(first.height, 10.0);
assert_eq!(first.y, 0.0);
// Half-leading: line box == ascent+descent here, so baseline is
// the scaled ascent: 819/1024 * 10.
assert!(
(first.baseline - 7.998).abs() < 0.01,
"baseline {}",
first.baseline
);
let second = &lines.lines[1];
assert_eq!(second.y, 10.0);
assert!((second.baseline - 7.998).abs() < 0.01);
let run = &first.runs[0];
assert_eq!(run.face, FaceId::Declared(0));
assert_eq!(run.font_size, 10.0);
// 1em advances, no offsets.
for glyph in &run.glyphs {
assert!((glyph.x_advance - 1.0).abs() < 1e-4);
assert!(glyph.x_offset.abs() < 1e-4 && glyph.y_offset.abs() < 1e-4);
}
}
// A glyph the primary face lacks falls through to the terminal
// default entry of the stack — per cluster, so the covered text
// keeps its primary face. Metrics stay exact (all subsets share the
// block font's 1em squares).
#[test]
fn missing_glyph_falls_back_to_default_family() {
let mut cx = subset_font_contexts();
let spans = [SpanSpec {
family: Some("ascii".to_string()),
..span("aa π", 10.0)
}];
let spec = spec(&spans, 10.0, 1.0);
let lines = layout_text(&mut cx, &spec, None).unwrap();
assert_eq!(lines.lines.len(), 1);
let entries: Vec<FaceId> = lines.lines[0].runs.iter().map(|r| r.face).collect();
assert_eq!(
entries,
vec![FaceId::Declared(1), FaceId::Declared(0)],
"ascii run, then default for π"
);
assert_eq!(lines.width, 40.0, "fallback glyph keeps block metrics");
}
// A declared chain face wins over the terminal default when both
// cover the character (order is what the chain declares).
#[test]
fn declared_chain_takes_priority_over_default() {
let mut cx = subset_font_contexts();
cx.fallback = vec!["greek".to_string()];
let spans = [SpanSpec {
family: Some("ascii".to_string()),
..span("aa π", 10.0)
}];
let spec = spec(&spans, 10.0, 1.0);
let lines = layout_text(&mut cx, &spec, None).unwrap();
let entries: Vec<FaceId> = lines.lines[0].runs.iter().map(|r| r.face).collect();
assert_eq!(
entries,
vec![FaceId::Declared(1), FaceId::Declared(2)],
"π resolves to the chain's greek face"
);
}
// A chain face is only consulted for glyphs earlier faces lack: a
// char the chain also misses still lands on the terminal default.
#[test]
fn chain_miss_continues_to_default() {
let mut cx = subset_font_contexts();
cx.fallback = vec!["greek".to_string()];
let spans = [SpanSpec {
family: Some("ascii".to_string()),
..span("aa é", 10.0) // é: not in ascii, not in greek
}];
let spec = spec(&spans, 10.0, 1.0);
let lines = layout_text(&mut cx, &spec, None).unwrap();
let entries: Vec<FaceId> = lines.lines[0].runs.iter().map(|r| r.face).collect();
assert_eq!(
entries,
vec![FaceId::Declared(1), FaceId::Declared(0)],
"é falls through greek to default"
);
}
#[test]
fn unknown_family_falls_back_to_default() {
let mut cx = block_font_contexts();
let spans = [SpanSpec {
family: Some("nope".to_string()),
..span("aa", 8.0)
}];
let spec = spec(&spans, 8.0, 1.5);
let lines = layout_text(&mut cx, &spec, None).unwrap();
assert_eq!(lines.lines.len(), 1);
assert_eq!(lines.height, 12.0);
assert_eq!(lines.width, 16.0);
}
// Over the parley u16 cluster-offset ceiling (panicked before the
// fix): chunked layout must reproduce exactly what an unchunked
// greedy pass would — no lost/duplicated text at seams, no
// artificial line breaks, contiguous line geometry.
#[test]
fn oversized_paragraph_chunks_transparently() {
let mut cx = block_font_contexts();
let content = "word ".repeat(14_000); // 70 000 bytes, two chunks
let spans = [span(&content, 10.0)];
let spec = spec(&spans, 10.0, 1.0);
let lines = layout_text(&mut cx, &spec, Some(500.0)).unwrap();
// 10 "word " per 500pt line at 10pt block glyphs.
assert_eq!(lines.lines.len(), 1400);
for (i, line) in lines.lines.iter().enumerate() {
assert_eq!(line.y, i as f32 * 10.0, "line {i} not contiguous");
assert_eq!(line.height, 10.0);
}
assert_eq!(lines.height, 14_000.0);
// Seam integrity: rejoining the trimmed lines reconstructs the
// whole input with nothing dropped or doubled.
let joined = lines
.lines
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join(" ");
assert_eq!(joined, content.trim_end());
}
// Just under the ceiling takes the single-chunk path — this size
// panicked before the fix.
#[test]
fn near_limit_paragraph_lays_out() {
let mut cx = block_font_contexts();
let spans = [span(&"word ".repeat(13_000), 10.0)]; // 65 000 bytes
let spec = spec(&spans, 10.0, 1.0);
let lines = layout_text(&mut cx, &spec, Some(500.0)).unwrap();
assert_eq!(lines.lines.len(), 1300);
}
// Span styles must survive chunking: a red span that starts past the
// first chunk boundary keeps its color, and every byte is retained.
#[test]
fn oversized_rich_text_keeps_span_styles_across_chunks() {
let mut cx = block_font_contexts();
let black = "a ".repeat(40_000); // 80 000 bytes -> boundary in span 1
let red = "b ".repeat(1_000);
let spans = [
span(&black, 10.0),
SpanSpec {
color: (255, 0, 0),
..span(&red, 10.0)
},
];
let spec = spec(&spans, 10.0, 1.0);
let lines = layout_text(&mut cx, &spec, Some(500.0)).unwrap();
let joined = lines
.lines
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join(" ");
assert_eq!(joined.len(), black.len() + red.len() - 1);
let last = lines.lines.last().unwrap();
assert_eq!(last.runs.last().unwrap().color, (255, 0, 0));
assert_eq!(lines.lines[0].runs[0].color, (0, 0, 0));
}
#[test]
fn empty_text_occupies_one_line_box() {
let mut cx = block_font_contexts();
let spec = spec(&[], 10.0, 2.0);
let lines = layout_text(&mut cx, &spec, Some(100.0)).unwrap();
assert_eq!(lines.lines.len(), 1);
assert_eq!(lines.height, 20.0);
}
// Mixed sizes: a line's box is as tall as its tallest span (CSS
// ratio-inheritance line-height), and colors ride per run.
#[test]
fn mixed_spans_share_lines_with_per_run_styles() {
let mut cx = block_font_contexts();
let spans = [
span("aa ", 4.0),
SpanSpec {
color: (255, 0, 0),
..span("a", 8.0)
},
];
let spec = spec(&spans, 4.0, 1.0);
let lines = layout_text(&mut cx, &spec, Some(100.0)).unwrap();
assert_eq!(lines.lines.len(), 1);
let line = &lines.lines[0];
assert_eq!(line.text, "aa a");
assert_eq!(line.height, 8.0);
assert_eq!(lines.width, 20.0);
assert_eq!(line.runs.len(), 2);
assert_eq!(line.runs[0].font_size, 4.0);
assert_eq!(line.runs[0].color, (0, 0, 0));
assert_eq!(line.runs[1].font_size, 8.0);
assert_eq!(line.runs[1].color, (255, 0, 0));
// Second run starts after "aa " at 4pt glyphs.
assert!((line.runs[1].x - 12.0).abs() < 1e-3);
}
// Spaceless Thai wraps at dictionary word boundaries (parley's
// `complex-scripts` feature: ICU4X dictionary segmenters). Without
// the feature this text has zero break opportunities and renders as
// a single overflowing 161.85pt line (verified by toggling the
// feature). Segmentation is text-driven, so the pinned break points
// are stable for a pinned parley/ICU4X version.
#[test]
fn thai_wraps_at_dictionary_word_boundaries() {
let mut cx = block_font_contexts();
cx.register(1, "thai", 400.0, false, fixture("noto-thai-subset.ttf"));
let spans = [SpanSpec {
family: Some("thai".to_string()),
..span("ภาษาไทยเป็นภาษาราชการของประเทศไทย", 10.0)
}];
let spec = spec(&spans, 10.0, 1.0);
let lines = layout_text(&mut cx, &spec, Some(60.0)).unwrap();
let texts: Vec<&str> = lines.lines.iter().map(|l| l.text.as_str()).collect();
// ภาษา|ไทย|เป็น / ภาษา|ราชการ / ของ / ประเทศ|ไทย — every break
// falls on a word boundary, none mid-word.
assert_eq!(texts, vec!["ภาษาไทยเป็น", "ภาษาราชการ", "ของ", "ประเทศไทย"]);
for line in &lines.lines {
assert_eq!(
line.runs[0].face,
FaceId::Declared(1),
"Thai glyphs from the Thai face"
);
}
}
}