Files
wordcraft/crates/layout/src/para/builder.rs
T
Aleksander HelgakerandClaude Opus 5.5 ba58e70cc8 Flag document fonts that aren't installed (#4770)
* Gap 4: flag document fonts that aren't installed

The font box (ribbon, mini toolbar, both Font dialog boxes) shows a
warning mark when the current font isn't installed; its hover note
names the font drawn instead. The status bar says "N fonts not
installed" and lists each with the faces drawn instead, plus Add Font….
Agents get `font.missing` and `format.state.fontShownAs`.

Layout records, where it picks a face per text segment, the fonts the
shown text asks for that aren't installed and the faces drawn instead
(ParaLayout::substituted, DocLayout::substituted_fonts), so hidden
text, unused styles and orphaned stories never count; list labels,
field results and East Asian and complex-script text do, and so does a
missing roman font drawn in Cambria. Metric-compatible substitutes and
symbol fonts drawn as Unicode aren't flagged. Bounded for hostile
files: 64 pairs a paragraph, 1024 a document, names kept to 256
characters, 20 rows in the popup.

Ported from alehel/wordcraft#1 onto current main; English strings only
(translations belong in storytold/craft-i18n).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: missing-font review fixes

- Judge a padded font name by the face layout drew for it, and skip blank names.
- Name the face CJK text is drawn in for a missing East Asian font, not Cambria.
- Keep the missing-fonts list per window, so several windows don't rebuild it every frame.
- Expose the font box's warning to screen readers as its description.
- Count missing fonts with craft-i18n plurals (`i18n::tn`).
- One table of metric twins (`fonts::word::METRIC_TWINS`): a missing font is drawn in an
  installed twin first, and the warning reads the same table.
- Find the status-bar item by part of its label in the click test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: warn for a font picked at the caret; drop fonts of hidden list items

- At a collapsed caret the font box warns for a missing font typing will use, also at the end
  of text in another font (it waited for the first letter).
- A list item that shows nothing (#3982) reports no missing font: its number was shaped, and
  its font recorded, before layout dropped it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: say where Add Font… is, and that it needs the font file

The font box's note said "Add Font… installs it": Add Font… opens a file picker at the end of
the list, so it only helps with a font file at hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: docs say the popup lists 20, and when the font box warns ahead

CodeRabbit review: the status-bar popup lists the first 20 missing fonts, then a count; the font
box also warns at a collapsed caret for a font no shown text uses yet; `fontShownAs` is the
font's substitute, fallback faces aside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: the Font dialog warns for a missing font over a selection

CodeRabbit review: the dialog's boxes show the fonts OK applies, which no shown text may use
yet (an Asian text font over Latin text, a font typed into the dialog), so they name the
substitute whatever is selected. The ribbon's box over a selection still reads the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: the Asian font box names the face CJK text gets

CodeRabbit review: the status bar's list is by font name, so for a font Latin text also uses
it named the Latin text's substitute. The Asian text box now always looks up the face layout
draws Chinese, Japanese and Korean text in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: gaps.md status on the current line, missing-font scope and twin wording

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: gaps.md says which missing fonts are flagged and what font.missing lists

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 4: font.missing lists what the current view lays out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:48:48 +01:00

738 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::bidi::{Dir, bidi_levels, strong_rtl, visual_order};
use super::chars::{
SegKey, caps_expands, context_digit_dirs, draws_something, is_khmer, is_mark, is_numeral_digit, ligature_features, lookup_family, remap_range,
reported_font_name, thai_run_face, upper_char,
};
use super::grid::east_asian;
use super::model::{ClKind, Cluster, Enclosed, Glyph, ParaEnv, RubyLayout, StyleRun, SymbolRun};
use super::{Arc, Document, FaceRef, NumeralMode, ResolvedChar, RubyAlign, SOFT_HYPHEN};
pub(super) struct Builder<'a> {
pub(super) env: &'a ParaEnv<'a>,
pub(super) styles: Vec<StyleRun>,
/// Hash of a style's format → (index in `styles`, small caps), see `style`.
pub(super) style_index: std::collections::HashMap<u64, Vec<(u32, bool)>>,
pub(super) glyphs: Vec<Glyph>,
pub(super) clusters: Vec<Cluster>,
/// Bidi level per byte of the paragraph text (empty: all left to right).
pub(super) levels: Vec<u8>,
/// Text drawn by clusters that stand for an object, by cluster index (see `ParaLayout::shown`).
pub(super) shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)>,
/// Fonts asked for that aren't installed, and the faces drawn instead (see
/// `ParaLayout::substituted`).
pub(super) substituted: Vec<(String, FaceRef)>,
}
/// The fonts a run asks for that aren't installed (see [`Builder::note_missing_fonts`]).
struct MissingFonts<'a> {
/// The Latin font.
latin: Option<&'a str>,
/// The complex-script font.
complex: Option<&'a str>,
/// The East Asian font the run names for its CJK text.
east_asian: Option<&'a str>,
/// The run names an East Asian font for its CJK text, installed or not.
names_east_asian: bool,
}
struct SegmentInput<'a> {
text: &'a str,
rc: &'a ResolvedChar,
rc_cs: Option<&'a Arc<ResolvedChar>>,
primary: FaceRef,
primary_cs: Option<FaceRef>,
primary_ea: Option<FaceRef>,
run_face: Option<FaceRef>,
ea_face: Option<FaceRef>,
sym: Option<SymbolRun<'a>>,
levels: &'a [u8],
lbase: usize,
use_levels: bool,
context: &'a [bool],
}
impl<'a> Builder<'a> {
/// Displayed form of `c` at run-relative byte `at` (Word › Numeral): one char in, one
/// char out. `rtl` is the shaping direction; in Context mode `context` (from
/// [`context_digit_dirs`], empty otherwise) gives each digit's surroundings, since European
/// numbers take an even bidi level.
fn numeral_char(&self, context: &[bool], at: usize, c: char, rtl: bool) -> char {
let dir = if !context.is_empty() && is_numeral_digit(c) { context.get(at).copied().unwrap_or(false) } else { rtl };
self.env.numeral.map(c, dir)
}
fn segment_keys(&self, input: SegmentInput<'_>) -> Vec<(usize, usize, SegKey)> {
let SegmentInput { text, rc, rc_cs, primary, primary_cs, primary_ea, run_face, ea_face, sym, levels, lbase, use_levels, context } = input;
let show = |c: char| sym.map_or(c, |m| m.show(c));
let mut seg_start = 0;
let mut cur: Option<SegKey> = None;
let mut segs = Vec::new();
for (i, c) in text.char_indices() {
let rtl = use_levels && levels.get(lbase + i).is_some_and(|l| l % 2 == 1);
let c = show(c);
let dc = self.numeral_char(context, i, c, rtl);
let mark = is_mark(c);
let complex = match cur {
Some(k) if mark => k.complex,
_ => rc_cs.is_some() && rc.uses_complex(dc),
};
let primary = if complex { primary_cs.unwrap_or(primary) } else { primary };
let face = match cur {
Some(k) if mark => k.face,
_ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,
_ if !c.is_control() && c != SOFT_HYPHEN && run_face.is_some_and(|f| f.covers(dc)) => run_face,
_ if primary.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
Some(SegKey { face: Some(face), .. }) if face.covers(dc) => Some(face),
_ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,
_ => {
let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };
wordcraft_fonts::FontDb::global().fallback_styled(dc, primary.id(), bold, italic).map(|f| FaceRef::of(&f))
}
};
let key = SegKey { face, small: rc.small_caps && !rc.caps && c.is_lowercase(), complex, rtl };
match cur {
Some(previous) if previous.same(&key) => {}
Some(previous) => {
segs.push((seg_start, i, previous));
seg_start = i;
cur = Some(key);
}
None => cur = Some(key),
}
}
if let Some(previous) = cur {
segs.push((seg_start, text.len(), previous));
}
segs
}
/// Style index for a resolved char in `face` (caps-scaled `size` override for small caps).
pub(super) fn style(&mut self, rc: &Arc<ResolvedChar>, face_override: Option<FaceRef>, small: bool) -> u32 {
let r = wordcraft_fonts::word::resolve(lookup_family(self.env.doc, &rc.font), rc.bold, rc.italic);
let face = face_override.unwrap_or(r.face);
// The display list reads every run's format (link target, strike kind, underline colour,
// baseline shift…) from its StyleRun, so runs share one only when their formats are equal.
// Keyed by a hash of the commonly varying fields (no allocation: this runs for every shaped
// piece), then confirmed by comparing whole formats within the bucket.
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(face.id(), small, &rc.font, rc.size.to_bits(), rc.bold, rc.italic, rc.color, rc.highlight, rc.shading).hash(&mut h);
(rc.underline_color, rc.strike, rc.double_strike, &rc.link, rc.ins, rc.del, rc.hidden).hash(&mut h);
h.finish()
};
let bucket = self.style_index.entry(key).or_default();
// Formats that never compare equal (a NaN from a hostile file) get their own styles; a capped
// scan keeps such a bucket from turning quadratic.
for &(i, s) in bucket.iter().rev().take(64) {
if s == small
&& let Some(st) = self.styles.get(i as usize)
&& st.face.id() == face.id()
&& (Arc::ptr_eq(&st.rc, rc) || *st.rc == **rc)
{
return i;
}
}
let size = rc.draw_size() * if small { 0.8 } else { 1.0 };
// A symbol font that isn't installed, or is installed one em tall (Apple's Symbol), takes
// the height Word gives it.
let (a, d) = match wordcraft_fonts::word::symbol_line(&rc.font, &r) {
Some((a, d)) => (a * face.upem, d * face.upem),
None => wordcraft_fonts::word::line_metrics(&face),
};
let k = size as f64 / face.upem.max(1.0);
// A fallback face (letters the requested font lacks) is emboldened or slanted when it has
// no bold or italic of its own.
let (synth_bold, synth_italic) = match face_override {
None => (r.synth_bold, r.synth_italic),
Some(f) => (rc.bold && f.get().weight < 600.0, rc.italic && !f.get().italic),
};
let st = StyleRun {
face,
size,
synth_bold,
synth_italic,
rc: rc.clone(),
ascent: (a * k) as f32,
descent: (d * k) as f32,
shift: rc.baseline_shift(),
};
// A `u32` index (like the glyph ranges) counts more distinct formats than a paragraph can
// hold in memory, so every format keeps its own style.
let i = u32::try_from(self.styles.len()).unwrap_or(u32::MAX);
self.styles.push(st);
self.style_index.entry(key).or_default().push((i, small));
i
}
/// Record that text asking for `font` (not installed) was drawn in `drawn`, once per pair;
/// at most 64 pairs a paragraph, so a hostile file can't grow the list without bound. Symbol
/// fonts (Symbol, Wingdings…) are drawn as their Unicode equivalents on purpose: not worth
/// reporting (most Word bullets would be). A blank name (a file's `w:ascii=" "`) names no font
/// to report.
fn note_substituted(&mut self, font: &str, drawn: FaceRef) {
if font.trim().is_empty() || wordcraft_doc::symbol_fonts::is_symbol_font(font) {
return;
}
let font = reported_font_name(font);
if !self.substituted.iter().any(|(f, d)| d.id() == drawn.id() && f == font) && self.substituted.len() < 64 {
self.substituted.push((font.to_string(), drawn));
}
}
/// Record the `missing` fonts the segments of `text` ask for, with the faces they are drawn
/// in (`primary`, the complex-script and the East Asian font's faces, or a segment's own).
/// Segments that draw nothing ([`draws_something`]) ask for no font.
fn note_missing_fonts(
&mut self,
text: &str,
segs: &[(usize, usize, SegKey)],
(primary, primary_cs, primary_ea): (FaceRef, Option<FaceRef>, Option<FaceRef>),
missing: &MissingFonts,
) {
if missing.latin.is_none() && missing.complex.is_none() && missing.east_asian.is_none() {
return;
}
for &(a, b, key) in segs {
let Some(sub) = text.get(a..b).filter(|t| t.chars().any(draws_something)) else { continue };
let drawn = key.face.unwrap_or(if key.complex { primary_cs.unwrap_or(primary) } else { primary });
if key.complex {
if let Some(cs) = missing.complex {
self.note_substituted(cs, drawn);
}
continue;
}
// The segment's CJK characters drawn in the installed East Asian font ask for nothing
// missing; other characters a fallback put in that same face still ask for the Latin
// font.
let in_ea_font = key.face.zip(primary_ea).is_some_and(|(f, ea)| f.id() == ea.id());
let latin_chars = sub.chars().any(|c| draws_something(c) && !east_asian(c));
// CJK text asks for the East Asian font when the run names one; the Latin font only
// for the other characters, or for CJK text when no East Asian font is named.
let asks_latin = latin_chars || (!missing.names_east_asian && !in_ea_font);
if let Some(f) = missing.latin.filter(|_| asks_latin) {
self.note_substituted(f, drawn);
}
if let Some(f) = missing.east_asian.filter(|_| !in_ea_font && sub.chars().any(east_asian)) {
self.note_substituted(f, drawn);
}
}
}
/// Shape `text` (a piece of the paragraph starting at byte `base`) in one style and append clusters.
pub(super) fn shape(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>) {
self.shape_with(text, base, rc, kind_override, Dir::Para);
}
/// `dir`: the bidi levels `text` is shaped by (see [`Dir`]).
pub(super) fn shape_with(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>, dir: Dir<'_>) {
if text.is_empty() {
return;
}
// Complex-script characters (Persian, Arabic…) use the run's complex-script font, size,
// bold and italic.
let rc_cs: Option<Arc<ResolvedChar>> = text.chars().any(|c| rc.uses_complex(c)).then(|| Arc::new(rc.complex()));
let family = lookup_family(self.env.doc, &rc.font);
let resolved = wordcraft_fonts::word::resolve(family, rc.bold, rc.italic);
let primary = resolved.face;
// Symbol-font characters are laid out as their Unicode equivalents.
let sym = SymbolRun::of(&rc.font, &resolved);
let show = |c: char| sym.map_or(c, |m| m.show(c));
// A font isn't installed when it was substituted, or looked up as a missing roman font.
let resolved_cs = rc_cs.as_ref().map(|r| {
let family = lookup_family(self.env.doc, &r.font);
let resolved = wordcraft_fonts::word::resolve(family, r.bold, r.italic);
(resolved.face, resolved.substituted || family != r.font)
});
let primary_cs = resolved_cs.map(|(face, _)| face);
// Chinese, Japanese and Korean characters use the run's East Asian font (`w:eastAsia`) when
// it is installed and has them.
let wanted_ea = rc
.font_ea
.as_deref()
.filter(|f| !f.eq_ignore_ascii_case(&rc.font) && text.chars().any(east_asian))
.map(|f| (f, wordcraft_fonts::word::resolve(f, rc.bold, rc.italic)));
let primary_ea = wanted_ea.filter(|(_, r)| !r.substituted).map(|(_, r)| r.face);
// The levels by byte of `text`, from `lbase` on.
let (levels, lbase): (&[u8], usize) = match dir {
Dir::Para => (&self.levels, base),
Dir::Own(l) => (l, 0),
Dir::Guess => (&[], 0),
};
let use_levels = !levels.is_empty();
let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, levels, lbase) } else { Vec::new() };
// A missing Thai font: one face for the run's Thai and Latin text (#1549). A run with its
// own complex-script font keeps that font for the Thai letters.
let run_face: Option<FaceRef> = if primary_cs.is_none_or(|f| f.id() == primary.id()) {
thai_run_face(&rc.font, resolved.substituted, primary, text, rc.bold, rc.italic)
} else {
None
};
// One fallback face for the run's East Asian characters: the first face in fallback order
// covering every character the primary font lacks, so the run draws in a single CJK face
// instead of mixing faces per character (e.g. a bundled Japanese Mincho for JIS-covered
// ideographs beside a Simplified-Chinese sans for the rest). Whitespace and characters the
// primary covers don't constrain the choice. `None` keeps the per-character fallback.
let ea_face: Option<FaceRef> = {
let ea: Vec<char> = text
.chars()
.filter(|c| crate::kinsoku::is_east_asian(*c) && !primary.covers(*c) && !c.is_whitespace() && !c.is_control() && *c != SOFT_HYPHEN)
.collect();
if ea.is_empty() {
None
} else {
wordcraft_fonts::FontDb::global().fallback_for_run(&ea, primary.id(), rc.bold, rc.italic).map(|f| FaceRef::of(&f))
}
};
let segs = self.segment_keys(SegmentInput {
text,
rc,
rc_cs: rc_cs.as_ref(),
primary,
primary_cs,
primary_ea,
run_face,
ea_face,
sym,
levels,
lbase,
use_levels,
context: &context,
});
let missing = MissingFonts {
latin: (resolved.substituted || family != rc.font).then_some(rc.font.as_str()),
complex: rc_cs.as_ref().filter(|_| resolved_cs.is_some_and(|(_, missing)| missing)).map(|r| r.font.as_str()),
east_asian: wanted_ea.filter(|(_, r)| r.substituted).map(|(f, _)| f),
names_east_asian: wanted_ea.is_some(),
};
self.note_missing_fonts(text, &segs, (primary, primary_cs, primary_ea), &missing);
let no_kern = [wordcraft_fonts::feature_value(b"kern", 0)];
for (a, b, key) in segs {
let Some(sub) = text.get(a..b) else { continue };
let rc = match (&rc_cs, key.complex) {
(Some(cs), true) => cs.clone(),
_ => rc.clone(),
};
let rc = &rc;
let small = key.small;
let si = self.style(rc, key.face, small);
let Some(st) = self.styles.get(si as usize).cloned() else { continue };
let upper = rc.caps || small;
// All Caps and Small Caps draw a letter's full capital, which can be several
// characters (ß → SS, ff → FF, ʼn → ʼN), as Word does (#1581). A symbol-font run
// draws its codes as symbols, never as expanded capitals.
let expands = sym.is_none() && upper && sub.chars().any(caps_expands);
// Displayed text: numeral substitution is 1 char → 1 char and a capital may be
// several; `map_starts` holds where each document char's displayed form starts and
// `orig_of` maps it back to the char's original byte offset (+ a `sub.len()` sentinel).
let substituted = expands || sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c);
let mut mapped = String::new();
let mut map_starts: Vec<usize> = Vec::new();
let mut orig_of: Vec<usize> = Vec::new();
if substituted {
mapped = String::with_capacity(sub.len().saturating_add(8));
for (i, c) in sub.char_indices() {
map_starts.push(mapped.len());
orig_of.push(i);
let d = self.numeral_char(&context, a + i, c, key.rtl);
if expands {
mapped.extend(d.to_uppercase());
} else {
mapped.push(d);
}
}
orig_of.push(sub.len());
}
let shape_text = if substituted { mapped.as_str() } else { sub };
let map = |c: char| {
let d = show(c);
if d != c {
d
} else if c == '\u{a0}' {
// Word sets a no-break space as wide as a space; some fonts draw their own
// U+00A0 glyph wider. Only the shaping changes: line breaking reads the text.
' '
} else if upper {
upper_char(c)
} else {
c
}
};
// The shaper kerns by default; Word kerns only when Kerning for fonts covers the size.
// Without `w14:ligatures` the shaper's default (standard and contextual) stays.
let [kern_off] = no_kern;
// Khmer shaping builds its syllables (pre-base vowels, stacked signs) in `liga` and
// `clig`, so a run's ligature setting never reaches Khmer text: Word ignores it there.
let ligatures = rc.ligatures.filter(|_| !shape_text.chars().any(is_khmer));
let all = match ligatures {
Some(l) => {
let [a, b, c, d] = ligature_features(l);
[kern_off, a, b, c, d]
}
None => [kern_off; 5],
};
let from = usize::from(rc.kerned());
let to = if ligatures.is_some() { all.len() } else { 1 };
let features: &[wordcraft_fonts::Feature] = all.get(from..to).unwrap_or_default();
let shaped = if use_levels {
wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl)
} else {
wordcraft_fonts::shape(&st.face, shape_text, features, map)
};
let k = st.size / st.face.upem.max(1.0) as f32;
let hscale = rc.scale / 100.0;
// Group glyphs by cluster byte offset; graphemes may span several shaper clusters.
// Complex-script syllables the graphemes split (Myanmar vowel signs, stacked
// consonants) stay one cluster: the caret never stops inside them.
let mut bounds: Vec<usize> = wordcraft_doc::cluster::cluster_starts(shape_text);
if expands {
// The capitals drawn for one character (the two S of ß) are one cluster: the
// caret, selection and line breaking never stop inside the character.
bounds.retain(|b| map_starts.binary_search(b).is_ok());
}
let seg_c0 = self.clusters.len();
let mut gi = 0usize;
for (bi, &gs) in bounds.iter().enumerate() {
let ge = bounds.get(bi + 1).copied().unwrap_or(shape_text.len());
let g0 = self.glyphs.len() as u32;
let mut adv = 0.0f32;
while let Some(g) = shaped.get(gi) {
if g.cluster >= ge {
break;
}
self.glyphs.push(Glyph { gid: g.gid, dx: adv + g.x_offset as f32 * k * hscale, dy: g.y_offset as f32 * k });
adv += g.x_advance as f32 * k * hscale;
gi += 1;
}
let g1 = self.glyphs.len() as u32;
let s = shape_text.get(gs..ge).unwrap_or("");
let ch = s.chars().next().unwrap_or(' ');
let kind = kind_override.unwrap_or(if ch == ' ' || ch == '\u{3000}' {
ClKind::Space
} else if ch == SOFT_HYPHEN {
ClKind::Marker
} else {
ClKind::Text
});
let adv = if kind == ClKind::Marker { 0.0 } else { adv + rc.spacing };
// Clusters index the document text, never the displayed text.
let (cs, ce) = if substituted { remap_range(&map_starts, &orig_of, sub.len(), gs, ge) } else { (gs, ge) };
self.clusters.push(Cluster {
start: base + a + cs,
end: base + a + ce,
adv,
kind,
style: si,
g0,
g1: if kind == ClKind::Marker { g0 } else { g1 },
break_after: false,
obj_h: 0.0,
obj_d: 0.0,
dot: s == "." || s == ",",
});
}
if matches!(dir, Dir::Para) {
self.split_ligature_advances(seg_c0, rc.spacing, key.rtl);
}
}
}
/// A ligature glyph (fi, ffi, lam-alef) sits on the first cluster of the letters it draws and
/// the clusters after it have no glyph and no width, so the caret, clicks and selection edges
/// inside it all land after the whole glyph. Give each letter an even share of the glyph's
/// advance, as Word and browsers do (#1974). The glyph stays on the first cluster; in
/// right-to-left text that cluster is the rightmost, so the glyph moves back by the other
/// letters' shares to start at the ligature's left edge. Clusters are grapheme clusters, so a
/// letter with a combining mark is never split.
fn split_ligature_advances(&mut self, from: usize, spacing: f32, rtl: bool) {
let mut i = from;
while let Some(head) = self.clusters.get(i) {
if head.kind != ClKind::Text || head.g1 <= head.g0 {
i += 1;
continue;
}
let n =
self.clusters.get(i + 1..).unwrap_or(&[]).iter().take_while(|c| c.kind == ClKind::Text && c.g1 <= c.g0 && c.adv == spacing).count();
if n == 0 {
i += 1;
continue;
}
// The letters after the head are glyphless and carry only their character spacing, so
// the ligature's width is the glyph's advance (spacing included) plus those spacings.
let unit = (head.adv + spacing * n as f32) / (n + 1) as f32;
let (g0, g1) = (head.g0 as usize, head.g1 as usize);
if unit.is_finite() {
for c in self.clusters.get_mut(i..=i + n).unwrap_or(&mut []) {
c.adv = unit;
}
if rtl {
for g in self.glyphs.get_mut(g0..g1).unwrap_or(&mut []) {
g.dx -= unit * n as f32;
}
}
}
i += n + 1;
}
}
/// One cluster for a whole object with its display text (fields, note references).
pub(super) fn shape_atomic(&mut self, text: &str, start: usize, end: usize, rc: &Arc<ResolvedChar>) {
// Right-to-left text in the result reads like ordinary text (UAX #9, #1670): the result is
// an isolate whose direction is its first strong letter's (else the text's around the
// field), and each level run is shaped (mirrored, joined) and placed in that direction.
let around = self.levels.get(start).is_some_and(|l| l % 2 == 1);
let rtl_base = text.chars().find_map(strong_rtl).unwrap_or(around);
let lv = bidi_levels(text, rtl_base);
let before = self.clusters.len();
self.shape_with(text, start, rc, None, if lv.is_empty() { Dir::Guess } else { Dir::Own(&lv) });
let added: Vec<Cluster> = self.clusters.drain(before..).collect();
let Some(first) = added.first() else {
let si = self.style(rc, None, false);
let g = self.glyphs.len() as u32;
self.clusters.push(Cluster {
start,
end,
adv: 0.0,
kind: ClKind::Marker,
style: si,
g0: g,
g1: g,
break_after: false,
obj_h: 0.0,
obj_d: 0.0,
dot: false,
});
return;
};
let order = if lv.is_empty() {
(0..added.len()).collect()
} else {
let cl_lv: Vec<u8> = added.iter().map(|c| lv.get(c.start.saturating_sub(start)).copied().unwrap_or(u8::from(rtl_base))).collect();
visual_order(&cl_lv)
};
// Merge into one cluster: rebase glyph dx onto the first cluster drawn, and keep each
// glyph's text (a ligature's glyph takes the letters of the clusters after it that have none).
let mut x = 0.0;
for c in order.iter().filter_map(|&i| added.get(i)) {
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
g.dx += x;
}
x += c.adv;
}
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
for c in &added {
let (a, b) = (c.start.saturating_sub(start), c.end.saturating_sub(start));
if c.g1 <= c.g0 {
let from = ranges.last().map(|r| r.start);
for r in ranges.iter_mut().rev().take_while(|r| Some(r.start) == from) {
r.end = b;
}
}
ranges.extend((c.g0..c.g1).map(|_| a..b));
}
let g0 = first.g0;
let style = first.style;
let g1 = added.last().map(|c| c.g1).unwrap_or(g0);
self.shown.push((self.clusters.len(), text.to_string(), ranges));
self.clusters.push(Cluster { start, end, adv: x, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, obj_d: 0.0, dot: false });
}
/// Shape `texts` on top of one another as one cluster for `start..end`: each aligned within
/// the cluster (at least `width` wide) and raised `dy`. Returns the cluster's index.
fn shape_overstruck(
&mut self,
texts: &[&str],
(start, end): (usize, usize),
rc: &Arc<ResolvedChar>,
align: wordcraft_doc::eq::OAlign,
(width, dy): (f32, f32),
) -> Option<usize> {
use wordcraft_doc::eq::OAlign;
let (c0, s0) = (self.clusters.len(), self.shown.len());
for t in texts {
self.shape_atomic(t, start, end, rc);
}
let added: Vec<Cluster> = self.clusters.drain(c0..).collect();
let shown: Vec<(usize, String, Vec<std::ops::Range<usize>>)> = self.shown.drain(s0..).collect();
let adv = added.iter().filter(|c| c.kind == ClKind::Text).map(|c| c.adv).fold(width.max(0.0), f32::max);
let (mut text, mut ranges) = (String::new(), Vec::new());
let (mut g0, mut g1) = (u32::MAX, 0u32);
for (ci, t, r) in &shown {
let Some(c) = added.get(ci.saturating_sub(c0)) else { continue };
let dx = match align {
OAlign::Left => 0.0,
OAlign::Center => (adv - c.adv) / 2.0,
OAlign::Right => adv - c.adv,
};
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
g.dx += dx;
g.dy += dy;
}
let off = text.len();
ranges.extend(r.iter().map(|x| x.start + off..x.end + off));
text.push_str(t);
g0 = g0.min(c.g0);
g1 = g1.max(c.g1);
}
let g = self.glyphs.len() as u32;
let (g0, g1) = if g0 <= g1 { (g0, g1) } else { (g, g) };
let style = match added.iter().find(|c| c.kind == ClKind::Text) {
Some(c) => c.style,
None => self.style(rc, None, false),
};
let k = self.clusters.len();
if !text.is_empty() {
self.shown.push((k, text, ranges));
}
self.clusters.push(Cluster { start, end, adv, kind: ClKind::Text, style, g0, g1, break_after: false, obj_h: 0.0, obj_d: 0.0, dot: false });
Some(k)
}
}
/// Lay out an `EQ \o` field (ECMA-376 Part 1 §17.16.5.20): its arguments drawn on top of one
/// another, as one cluster. An enclosure symbol among them (○ □ △ ◇, see
/// [`wordcraft_doc::eq::EncloseShape`]) is drawn as a shape around the text rather than as a
/// glyph, so it never depends on the font: "shrink text" scales the text to fit inside a shape
/// one em high, "enlarge symbol" grows the shape around text at its own size.
pub(super) fn overstrike(
b: &mut Builder,
o: &wordcraft_doc::eq::Overstrike,
instr: &str,
start: usize,
end: usize,
rc: &Arc<ResolvedChar>,
enclosures: &mut Vec<(usize, Enclosed)>,
) {
use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure};
use wordcraft_doc::para::ShapeKind;
let shapes: Vec<EncloseShape> = o.args.iter().filter_map(|a| EncloseShape::from_symbol(a)).collect();
let texts: Vec<&str> = o.args.iter().filter(|a| !a.is_empty() && EncloseShape::from_symbol(a).is_none()).map(String::as_str).collect();
let Some(shape) = shapes.first().copied() else {
b.shape_overstruck(&texts, (start, end), rc, o.align, (0.0, 0.0));
return;
};
let style = Enclosure::parse(instr).map(|e| e.style).unwrap_or_default();
let s = rc.size.clamp(1.0, 1638.0);
// The widest text at the run's own size.
let (c0, g0, s0) = (b.clusters.len(), b.glyphs.len(), b.shown.len());
for t in &texts {
b.shape_atomic(t, start, end, rc);
}
let w0 = b.clusters.get(c0..).map(|cs| cs.iter().map(|c| c.adv).fold(0.0f32, f32::max)).unwrap_or(0.0).max(0.0);
b.clusters.truncate(c0);
b.glyphs.truncate(g0);
b.shown.truncate(s0);
// The shape's box (`bw` × `bh`, centred `cy` above the baseline) and the text's size.
let cy = s * 0.34;
let (bw, bh, size) = match style {
EncloseStyle::Shrink => {
// Room for the text inside each shape: (height, width) as fractions of the box.
let (fh, fw) = match shape {
EncloseShape::Circle => (0.62, 0.7),
EncloseShape::Square => (0.68, 0.78),
EncloseShape::Triangle => (0.45, 0.5),
EncloseShape::Diamond => (0.5, 0.55),
};
let bh = s * 1.05;
let mut t = s * fh;
if w0 > 0.0 && w0 * t / s > bh * fw {
t = bh * fw * s / w0;
}
(bh, bh, t.max(s * 0.2))
}
EncloseStyle::Enlarge => {
let (kh, kw) = match shape {
EncloseShape::Circle => (1.4, 1.4),
EncloseShape::Square => (1.3, 1.3),
EncloseShape::Triangle => (1.75, 2.0),
EncloseShape::Diamond => (1.7, 1.8),
};
let bh = s * kh;
(bh.max(w0 * kw), bh, s)
}
};
// A triangle's middle is low; the text sits there.
let ty = if shape == EncloseShape::Triangle { cy - bh * 0.13 } else { cy };
let trc = if (size - rc.size).abs() > 0.01 {
let mut r = (**rc).clone();
r.size = size;
Arc::new(r)
} else {
rc.clone()
};
let pad = s * 0.05;
let Some(k) = b.shape_overstruck(&texts, (start, end), &trc, o.align, (bw + 2.0 * pad, ty - size * 0.34)) else { return };
let adv = b.clusters.get(k).map(|c| c.adv).unwrap_or(bw);
let kind = match shape {
EncloseShape::Circle => ShapeKind::Ellipse,
EncloseShape::Square => ShapeKind::Rectangle,
EncloseShape::Triangle => ShapeKind::Triangle,
EncloseShape::Diamond => ShapeKind::Diamond,
};
let stroke = (s * 0.06).clamp(0.4, 4.0);
enclosures.push((k, Enclosed { kind, x: (adv - bw) / 2.0, top: cy + bh / 2.0, w: bw, h: bh, stroke }));
}
impl Builder<'_> {
/// One cluster for ruby `base` text (an [`InlineObject::Ruby`] at `start..end`), as wide as the
/// wider of the base and the ruby, with the ruby text laid out over it by `align`. The line
/// grows to fit the ruby through the cluster's `obj_h`.
pub(super) fn ruby(
&mut self,
base: &str,
ruby: &str,
align: RubyAlign,
raise: f32,
(start, end): (usize, usize),
(rc, ruby_rc): (&Arc<ResolvedChar>, &Arc<ResolvedChar>),
) -> Option<RubyLayout> {
self.shape_atomic(base, start, end, rc);
let ci = self.clusters.len().checked_sub(1)?;
let (bc, bg) = (self.clusters.len(), self.glyphs.len());
self.shape_with(ruby, 0, ruby_rc, None, Dir::Guess);
let cl: Vec<Cluster> = self.clusters.drain(bc..).collect();
let ruby_w: f32 = cl.iter().map(|c| c.adv).sum();
let base_w = self.clusters.get(ci).map_or(0.0, |c| c.adv);
let w = base_w.max(ruby_w);
let n = cl.len().max(1) as f32;
// Where the narrower text starts, and the extra gap between its letters (ruby only).
let lead = |room: f32| match align {
RubyAlign::Left => 0.0,
RubyAlign::Right | RubyAlign::RightVertical => room,
_ => room / 2.0,
};
let (x0, gap) = match align {
RubyAlign::DistributeLetter if cl.len() > 1 => (0.0, (w - ruby_w) / (n - 1.0)),
RubyAlign::DistributeSpace => ((w - ruby_w) / n / 2.0, (w - ruby_w) / n),
_ => (lead(w - ruby_w), 0.0),
};
let mut glyphs = Vec::new();
let mut ranges = Vec::new();
let mut x = x0;
for c in &cl {
for g in self.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
glyphs.push((c.style, Glyph { dx: g.dx + x, ..*g }));
ranges.push(c.start..c.end);
}
x += c.adv + gap;
}
self.glyphs.truncate(bg);
// The base text centres (or aligns) under a wider ruby.
let shift = lead(w - base_w);
let ascent = cl.iter().filter_map(|c| self.styles.get(c.style as usize)).map(|s| s.ascent).fold(0.0f32, f32::max);
let c = self.clusters.get_mut(ci)?;
for g in self.glyphs.get_mut(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
g.dx += shift;
}
c.adv = w;
c.obj_h = raise + ascent;
Some(RubyLayout { cluster: ci, glyphs, text: ruby.to_string(), ranges, raise })
}
}
/// Whether text with this formatting is left out of the layout: hidden text unless it is shown, and
/// tracked deletions in the final text. It takes no room, draws nothing and is no place to break.
pub(crate) fn left_out(doc: &Document, rc: &ResolvedChar, show_hidden: bool, hide_deleted: crate::HideDeleted) -> bool {
(rc.hidden && !show_hidden) || hide_deleted.hides(doc, rc.del)
}