1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
//! Text measurer — resolves text widths through a `FontRegistry`.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use rustc_hash::FxHashMap;
use skia_safe::Font;
use crate::render::dimension::Pt;
use crate::render::emoji::resolve::{EmojiFamily, EmojiResolver, EmojiTypeface, RegistryLookup};
use crate::render::emoji::shape::shape_text;
use crate::render::fonts::{self, FontRegistry, TypefaceEntry, TypefaceId};
use super::fragment::{FontProps, TextMetrics};
/// Per-font measurement memo: the font's metrics (constant for the font) plus
/// the raw Skia `measure_str` advance for each distinct word seen. Keyed inside
/// [`TextMeasurer`] by the font's stable slot index, so the family string is
/// never re-hashed; the inner map is looked up by `&str` (via `Box<str>:
/// Borrow<str>`) so a cache hit allocates nothing.
struct FontMeasureCache {
metrics: TextMetrics,
/// Raw `measure_str` width (before `text_scale` / `char_spacing`), which
/// depends only on (typeface, size, text) — so it is safe to reuse across
/// runs that differ only in scale or spacing.
widths: FxHashMap<Box<str>, f32>,
}
/// Measures text using Skia fonts resolved through a [`FontRegistry`].
/// Holds a per-instance `FontCache` for `Font` reuse across measurements.
///
/// Also owns the [`EmojiResolver`] for the same render — so emoji typeface
/// lookups dedupe through the same per-instance state, alongside the warn-
/// once dedup set for `Unavailable` clusters.
pub struct TextMeasurer<'r> {
registry: &'r FontRegistry,
font_cache: RefCell<fonts::FontCache>,
emoji_resolver: EmojiResolver<RegistryLookup<'r>>,
/// Per-render dedup set so we warn at most once per cluster about a
/// missing color emoji typeface.
warned_emoji: RefCell<HashSet<String>>,
/// Per-render cache of extracted typeface bytes for emoji measurement,
/// keyed by typeface id. `to_font_data()` copies the whole typeface
/// (~190 MB for Apple Color Emoji), so — mirroring the raster pipeline's
/// `font_bytes_for` — we extract once per typeface instead of once per
/// cluster. `None` records a typeface that refuses to expose bytes so the
/// failed extraction isn't retried per cluster.
emoji_font_bytes: RefCell<HashMap<TypefaceId, Option<Rc<Vec<u8>>>>>,
/// Per-render memo of GSUB-aware advances from `measure_with_typeface`,
/// keyed by (typeface id, size in raw-f32 bits, cluster text). Skips
/// re-parsing a rustybuzz `Face` and re-shaping identical clusters.
emoji_advance_cache: RefCell<HashMap<(TypefaceId, u32, String), Pt>>,
/// Per-render text-measurement memo, indexed by the `FontCache` slot. Skips
/// the Skia `measure_str` call (and per-call `font.metrics()`) for words
/// that repeat within a font — heavy in prose, where a handful of short
/// words dominate. Uses FxHash: an earlier std-hasher width cache was
/// net-negative on small docs due to per-word hashing overhead.
measure_cache: RefCell<FxHashMap<usize, FontMeasureCache>>,
}
impl<'r> TextMeasurer<'r> {
pub fn new(registry: &'r FontRegistry) -> Self {
Self {
registry,
font_cache: RefCell::new(fonts::FontCache::new()),
emoji_resolver: EmojiResolver::new(RegistryLookup { registry }),
warned_emoji: RefCell::new(HashSet::new()),
emoji_font_bytes: RefCell::new(HashMap::new()),
emoji_advance_cache: RefCell::new(HashMap::new()),
measure_cache: RefCell::new(FxHashMap::default()),
}
}
pub fn registry(&self) -> &'r FontRegistry {
self.registry
}
/// Measure a text string with the given font properties.
/// Returns (width, TextMetrics).
pub fn measure(
&self,
text: &str,
font_props: &FontProps,
) -> (Pt, super::fragment::TextMetrics) {
let mut font_cache = self.font_cache.borrow_mut();
let (slot, font) = font_cache.get_indexed(
self.registry,
&font_props.family,
font_props.size,
font_props.bold,
font_props.italic,
);
// Metrics depend only on the font; the raw `measure_str` width only on
// (font, text). Both are memoized per font slot so a repeated word
// costs a hash probe instead of a Skia call. On a hit, `widths.get`
// borrows the `&str` (via `Box<str>: Borrow<str>`) and allocates
// nothing; only a first-seen word allocates a `Box<str>` key.
let mut cache = self.measure_cache.borrow_mut();
let fc = cache.entry(slot).or_insert_with(|| {
let (_, m) = font.metrics();
FontMeasureCache {
metrics: TextMetrics {
ascent: Pt::new(-m.ascent),
descent: Pt::new(m.descent),
leading: Pt::new(m.leading.max(0.0)),
},
widths: FxHashMap::default(),
}
});
let text_metrics = fc.metrics;
let width = match fc.widths.get(text) {
Some(&w) => w,
None => {
let w = font.measure_str(text, None).0;
fc.widths.insert(Box::from(text), w);
w
}
};
// §17.3.2.45: scale the glyph advances horizontally per <w:w>.
// Applies to glyph widths only — character spacing (§17.3.2.35)
// is independent and is not scaled (the spec keeps the two
// separate so kerning in points is unchanged by character scale).
let scaled_width = Pt::new(width * font_props.text_scale);
// §17.3.2.35: include character spacing in the measured width so line
// fitting accounts for the extra inter-character space. Skip the
// char-count scan entirely in the common no-spacing case.
let spacing_extra = if font_props.char_spacing != Pt::ZERO {
font_props.char_spacing * (text.chars().count() as f32)
} else {
Pt::ZERO
};
(scaled_width + spacing_extra, text_metrics)
}
/// Query font metrics for underline positioning.
/// Returns (underline_position, underline_thickness) in points.
/// Position is positive below baseline per Skia convention.
pub fn underline_metrics(&self, font_props: &FontProps) -> (Pt, Pt) {
let mut cache = self.font_cache.borrow_mut();
let font = cache.get(
self.registry,
&font_props.family,
font_props.size,
font_props.bold,
font_props.italic,
);
let (_, metrics) = font.metrics();
// Skia: underline_position() returns a negative value (below baseline).
// We negate to get a positive offset below the baseline.
// If the font doesn't provide metrics, log a warning and use descent as fallback.
let raw_pos = metrics.underline_position();
let raw_thick = metrics.underline_thickness();
if raw_pos.is_none() || raw_thick.is_none() {
log::warn!(
"font '{}' ({:?}) missing underline metrics, using descent as fallback",
font_props.family,
font_props.size
);
}
let position = Pt::new(-raw_pos.unwrap_or(metrics.descent));
// Thickness fallback: 1pt (smallest visible line at 72dpi).
let thickness = Pt::new(raw_thick.unwrap_or(1.0));
(position, thickness)
}
/// Get line height for the default font (used for empty paragraphs).
/// §17.3.1.33: includes leading so Auto line spacing scales the full
/// font-recommended height.
pub fn default_line_height(&self, family: &str, size: Pt) -> Pt {
let mut cache = self.font_cache.borrow_mut();
let font = cache.get(self.registry, family, size, false, false);
let (_, metrics) = font.metrics();
Pt::new(-metrics.ascent + metrics.descent + metrics.leading.max(0.0))
}
// ─── Emoji pipeline integration ────────────────────────────────────────
/// Resolve a color emoji typeface via the per-render [`EmojiResolver`].
/// Cached: repeat calls with the same `requested` family are O(1).
pub fn resolve_emoji(&self, requested: Option<EmojiFamily>) -> EmojiTypeface {
self.emoji_resolver.resolve(requested)
}
/// Measure a cluster directly against a resolved [`TypefaceEntry`],
/// bypassing the family-name lookup path. Used by the emoji pipeline,
/// which has already resolved the typeface and needs Skia raster metrics
/// at the cluster's font size.
///
/// **Shapes via rustybuzz** (GSUB-aware) so multi-codepoint emoji
/// sequences measure to their *ligated* width, matching what the
/// rasterizer produces at paint time. Without this, the layout would
/// reserve `n × glyph_advance` for an `n`-codepoint sequence (cmap-
/// only) but the rasterizer would draw a ligated single glyph that's
/// narrower — the painter then stretches the image to fill the over-
/// sized rect, distorting the emoji.
///
/// Falls back to `font.measure_str` (cmap-only) if shaping fails — a
/// best-effort policy mirroring the rasterizer's fallback path.
pub fn measure_with_typeface(
&self,
text: &str,
typeface: &TypefaceEntry,
size: Pt,
) -> (Pt, super::fragment::TextMetrics) {
let font = Font::from_typeface(typeface.typeface.clone(), f32::from(size));
let (_, metrics) = font.metrics();
let text_metrics = super::fragment::TextMetrics {
ascent: Pt::new(-metrics.ascent),
descent: Pt::new(metrics.descent),
leading: Pt::new(metrics.leading.max(0.0)),
};
let advance = self.emoji_advance(text, typeface, size, &font);
(advance, text_metrics)
}
/// GSUB-aware advance for `text` against `typeface` at `size`, memoized
/// per render. Both the extracted font bytes and the shaped advance are
/// cached: `to_font_data()` copies the whole typeface (~190 MB for Apple
/// Color Emoji) and `shape_text` re-parses it, so doing either once per
/// cluster dominated layout for emoji-heavy documents. Falls back to the
/// cmap-only `measure_str` advance when bytes can't be extracted or
/// shaping fails — the same best-effort policy the rasterizer uses.
fn emoji_advance(&self, text: &str, typeface: &TypefaceEntry, size: Pt, font: &Font) -> Pt {
let id = TypefaceId::from(&typeface.typeface);
let key = (id, f32::from(size).to_bits(), text.to_owned());
if let Some(&cached) = self.emoji_advance_cache.borrow().get(&key) {
return cached;
}
let advance = self
.emoji_font_bytes(id, typeface)
.and_then(|bytes| shape_text(&bytes, text, f32::from(size)).ok())
.map(|run| run.total_advance)
.unwrap_or_else(|| Pt::new(font.measure_str(text, None).0));
self.emoji_advance_cache.borrow_mut().insert(key, advance);
advance
}
/// Extracted bytes for `typeface`, cached by id — mirrors the raster
/// pipeline's `font_bytes_for` so a ~190 MB emoji typeface is serialized
/// via `to_font_data()` at most once per render. A cached `None` marks a
/// typeface that exposes no bytes, so the failed probe isn't repeated.
fn emoji_font_bytes(&self, id: TypefaceId, typeface: &TypefaceEntry) -> Option<Rc<Vec<u8>>> {
if let Some(cached) = self.emoji_font_bytes.borrow().get(&id) {
return cached.clone();
}
let bytes = typeface.typeface.to_font_data().map(|(b, _)| Rc::new(b));
self.emoji_font_bytes.borrow_mut().insert(id, bytes.clone());
bytes
}
/// Log a warning once per cluster when no color emoji typeface is
/// available on the host. The `attempted` list lets operators know
/// which packages to install (e.g. `fonts-noto-color-emoji` on Debian).
pub fn warn_emoji_unavailable_once(&self, cluster: &str, attempted: &[EmojiFamily]) {
let inserted = self.warned_emoji.borrow_mut().insert(cluster.to_string());
if inserted {
log::warn!(
"no color emoji typeface available for cluster {:?}; \
tried {:?}. Install a color emoji font on the host \
(e.g. fonts-noto-color-emoji) to render this cluster correctly.",
cluster,
attempted
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::rc::Rc;
fn fp_at_scale(scale: f32) -> FontProps {
FontProps {
family: Rc::from("Helvetica"),
size: Pt::new(12.0),
bold: false,
italic: false,
underline: false,
char_spacing: Pt::ZERO,
text_scale: scale,
underline_position: Pt::ZERO,
underline_thickness: Pt::ZERO,
}
}
#[test]
fn measure_scaled_width_is_proportional_to_text_scale() {
// §17.3.2.45: glyph advances scale linearly with <w:w>. A run at 80%
// must measure to 0.8× the width of the same text at 100%.
let registry = FontRegistry::new(skia_safe::FontMgr::new());
let measurer = TextMeasurer::new(®istry);
let (w_100, _) = measurer.measure("scaling sample", &fp_at_scale(1.0));
let (w_80, _) = measurer.measure("scaling sample", &fp_at_scale(0.8));
let (w_150, _) = measurer.measure("scaling sample", &fp_at_scale(1.5));
// The relationship must hold even though the absolute value depends
// on which fallback font Skia picks on the test host.
if w_100.raw() <= 0.0 {
// Some headless CI hosts can't measure text — bail rather than
// assert; the text path is exercised by the integration tests.
return;
}
assert!(
(w_80.raw() / w_100.raw() - 0.8).abs() < 0.01,
"80% scale must produce 0.8× width: 100%={}, 80%={}",
w_100.raw(),
w_80.raw(),
);
assert!(
(w_150.raw() / w_100.raw() - 1.5).abs() < 0.01,
"150% scale must produce 1.5× width: 100%={}, 150%={}",
w_100.raw(),
w_150.raw(),
);
}
#[test]
fn measure_char_spacing_not_scaled_by_text_scale() {
// §17.3.2.45 + §17.3.2.35: w:spacing (inter-character spacing)
// is independent of w:w. Doubling text_scale must NOT double
// the spacing contribution.
let registry = FontRegistry::new(skia_safe::FontMgr::new());
let measurer = TextMeasurer::new(®istry);
let mut fp_scale_1 = fp_at_scale(1.0);
fp_scale_1.char_spacing = Pt::new(2.0);
let mut fp_scale_2 = fp_at_scale(2.0);
fp_scale_2.char_spacing = Pt::new(2.0);
let text = "abcde";
let (w1, _) = measurer.measure(text, &fp_scale_1);
let (w2, _) = measurer.measure(text, &fp_scale_2);
if w1.raw() <= 0.0 {
return;
}
// Spacing contribution = 5 chars × 2pt = 10pt at both scales.
// Glyph contribution doubles. So w2 - w1 should equal the glyph
// contribution at scale 1.0 (i.e. w1 minus the spacing extra).
let expected_glyph_w1 = w1.raw() - 10.0;
let observed_glyph_delta = w2.raw() - w1.raw();
assert!(
(observed_glyph_delta - expected_glyph_w1).abs() < 0.05,
"char_spacing must not be scaled by text_scale: \
w1={}, w2={}, expected glyph delta {}, observed {}",
w1.raw(),
w2.raw(),
expected_glyph_w1,
observed_glyph_delta,
);
}
}