Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/text-slot-chain-hoist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@betteroffice/rust-crates": patch
---

Resolve each font slot's fallback chain once per run during measurement.
46 changes: 41 additions & 5 deletions crates/ooxml-text/src/measure/prepare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ use crate::word_metrics::{kern_enabled, kern_features};
use super::input::{MeasureInput, RunIn, validate_pt_size};
use super::{MAX_RUN_TEXT_BYTES, MeasureError, pt_to_px};

/// Longest fallback chain a run keeps resolved per slot. Chains past it are
/// rebuilt per character instead, so keeping them can never cost more memory
/// than resolving them did. Not a limit on input: nothing is refused.
const MAX_KEPT_CHAIN_IDS: usize = 64;

/// One indivisible shaped cluster of a run. A cluster may cover several
/// source characters (ligature or combining sequence), so every downstream
/// wrap/hit boundary is cluster-safe by construction.
Expand Down Expand Up @@ -620,6 +625,18 @@ fn prepare_text_run(
Cs,
}

impl FontSlot {
/// Index into a run's per-slot resolved chains.
fn index(self) -> usize {
match self {
FontSlot::Ascii => 0,
FontSlot::HAnsi => 1,
FontSlot::EastAsia => 2,
FontSlot::Cs => 3,
}
}
}

fn is_complex(ch: char) -> bool {
matches!(ch as u32,
0x0590..=0x08ff | 0x0900..=0x0dff | 0xfb1d..=0xfdff | 0xfe70..=0xfeff)
Expand Down Expand Up @@ -694,6 +711,13 @@ fn prepare_text_run(
let mut plan: Vec<PlanChar> = Vec::new();
let mut utf16_offset: u32 = 0;
let mut previous_slot = FontSlot::HAnsi;
// Family and style are fixed per slot within a run, so each of the four
// resolves at most once instead of once per character. Keeping a chain
// trades memory for the next character's lookup, so a chain too large for
// that trade is rebuilt per character into `oversized` instead, one at a
// time — never more resident than resolving it per character already was.
let mut slot_chains: [Option<Vec<FontId>>; 4] = [None, None, None, None];
let mut oversized: Vec<FontId> = Vec::new();
for (char_index, ch) in text.chars().enumerate() {
let slot = if run.complex_script || is_complex(ch) {
FontSlot::Cs
Expand Down Expand Up @@ -741,11 +765,23 @@ fn prepare_text_run(
default_size_pt
};
let (font_size_pt, baseline_shift_px) = script_metrics(base_size_pt, run);
let family = family_for_slot(run, slot, &input.defaults.font_family);
let chain = input.chain_for(family, bold, italic)?;
validate_chain(store, &chain)?;
let index = slot.index();
if slot_chains[index].is_none() {
let family = family_for_slot(run, slot, &input.defaults.font_family);
// Release the previous rebuilt chain before allocating the next, so
// a run never holds two of them at once.
oversized = Vec::new();
let resolved = input.chain_for(family, bold, italic)?;
validate_chain(store, &resolved)?;
if resolved.len() <= MAX_KEPT_CHAIN_IDS {
slot_chains[index] = Some(resolved);
} else {
oversized = resolved;
}
}
let chain = slot_chains[index].as_deref().unwrap_or(&oversized);
let first = shaped[0];
let Some(mut font) = resolve_with_fallback(store, &chain, first) else {
let Some(mut font) = resolve_with_fallback(store, chain, first) else {
return Err(MeasureError::Unsupported("empty font chain".to_string()));
};
let mut features = run.kerning_min_pt.map_or_else(Vec::new, |threshold| {
Expand All @@ -758,7 +794,7 @@ fn prepare_text_run(
&& run.small_caps
&& !run.all_caps
&& ch.is_lowercase()
&& let Some(original_font) = resolve_with_fallback(store, &chain, ch)
&& let Some(original_font) = resolve_with_fallback(store, chain, ch)
&& supports_smcp(
store,
original_font,
Expand Down
156 changes: 156 additions & 0 deletions crates/ooxml-text/tests/measure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2105,3 +2105,159 @@ fn float_zone_input_validation() {
.unwrap_err();
assert!(err.starts_with("UNSUPPORTED"), "segment count: {err:?}");
}

// ---- 15. font slot routing --------------------------------------------------

const CALADEA: &[u8] = include_bytes!("../../../packages/fonts/assets/Caladea-Regular.ttf");

/// Each `w:rFonts` slot must resolve through its own family: ASCII, high-ANSI,
/// East Asian and complex-script characters each pick the slot they belong to
/// and no other. Swapping one slot's family to a face with different metrics
/// must change the measurement of exactly the characters that slot owns —
/// texts that mix slots pin that a run never reuses one slot's face for
/// another's characters.
#[test]
fn each_font_slot_resolves_through_its_own_family() {
const SLOTS: [&str; 4] = ["ascii", "hAnsi", "eastAsia", "cs"];
// Text, and which slots its characters belong to, in SLOTS order.
const CASES: [(&str, [bool; 4]); 10] = [
("A", [true, false, false, false]),
("é", [false, true, false, false]),
("日", [false, false, true, false]),
("א", [false, false, false, true]),
("Aé", [true, true, false, false]),
("A日", [true, false, true, false]),
("Aא", [true, false, false, true]),
("é日", [false, true, true, false]),
("éא", [false, true, false, true]),
("Aé日א", [true, true, true, true]),
];

let mut store = FontStore::new();
store.register(FIXTURE.to_vec()).expect("base registers");
store.register(CALADEA.to_vec()).expect("alt registers");

// `pad` runs the same matrix with chains too long for a run to keep, so a
// rebuilt-per-character chain must route exactly like a kept one.
let measure_slots = |text: &str, alt: Option<usize>, pad: (usize, usize)| -> String {
let slots: serde_json::Map<String, Value> = SLOTS
.iter()
.enumerate()
.map(|(i, name)| {
let family = if Some(i) == alt { "alt" } else { "base" };
((*name).to_string(), json!(family))
})
.collect();
let chain = |head: usize, pad: usize| {
let mut ids = vec![head];
ids.resize(1 + pad, head);
ids
};
let input = json!({
"block": { "kind": "paragraph", "runs": [
{ "kind": "text", "text": text, "fontSlots": Value::Object(slots) }
] },
"maxWidth": 500.0,
"fontChains": { "base|0|0": chain(0, pad.0), "alt|0|0": chain(1, pad.1) },
"defaults": { "fontSize": 12.0, "fontFamily": "base" }
});
measure_paragraph_json(&store, &input.to_string()).expect("measures")
};

// Both short, both oversized, and each mixed with the other, so a run can
// hold a kept chain for one slot and a rebuilt one for another.
for pad in [(0usize, 0usize), (200, 200), (0, 200), (200, 0)] {
for (text, used) in CASES {
let baseline = measure_slots(text, None, pad);
assert_eq!(
baseline,
measure_slots(text, None, (0, 0)),
"{text:?} must measure the same with a padded chain"
);
for (probe, is_used) in used.iter().enumerate() {
let swapped = measure_slots(text, Some(probe), pad);
if *is_used {
assert_ne!(
baseline, swapped,
"{text:?} must measure through the {} slot (pad {pad:?})",
SLOTS[probe]
);
} else {
assert_eq!(
baseline, swapped,
"{text:?} must ignore the {} slot (pad {pad:?})",
SLOTS[probe]
);
}
}
}
}
}

/// `w:hint="eastAsia"` moves ambiguous high-ANSI characters to the East Asian
/// slot; ASCII and complex-script characters stay where they are.
#[test]
fn east_asia_hint_moves_only_ambiguous_characters() {
let mut store = FontStore::new();
store.register(FIXTURE.to_vec()).expect("base registers");
store.register(CALADEA.to_vec()).expect("alt registers");

let measure_hinted = |text: &str, hint: &str| -> String {
let input = json!({
"block": { "kind": "paragraph", "runs": [{
"kind": "text", "text": text,
"fontSlots": { "ascii": "base", "hAnsi": "base", "eastAsia": "alt",
"cs": "base", "hint": hint }
}] },
"maxWidth": 500.0,
"fontChains": { "base|0|0": [0], "alt|0|0": [1] },
"defaults": { "fontSize": 12.0, "fontFamily": "base" }
});
measure_paragraph_json(&store, &input.to_string()).expect("measures")
};

assert_ne!(
measure_hinted("é", "default"),
measure_hinted("é", "eastAsia"),
"an ambiguous high-ANSI character follows the hint"
);
assert_eq!(
measure_hinted("A", "default"),
measure_hinted("A", "eastAsia"),
"ASCII stays in the ASCII slot"
);
assert_eq!(
measure_hinted("א", "default"),
measure_hinted("א", "eastAsia"),
"complex script stays in the CS slot"
);
}

/// A chain longer than a run keeps resolved is rebuilt per character; it must
/// still measure exactly as the short chain it resolves to.
#[test]
fn an_oversized_fallback_chain_measures_like_its_head() {
let mut store = FontStore::new();
store.register(FIXTURE.to_vec()).expect("base registers");
store.register(CALADEA.to_vec()).expect("alt registers");

let measure_chain = |ids: Vec<usize>| -> String {
let input = json!({
"block": { "kind": "paragraph", "runs": [
{ "kind": "text", "text": "Aé日א mixed slots twice Aé日א", "fontFamily": "fam",
"fontSlots": { "ascii": "fam", "hAnsi": "fam", "eastAsia": "fam", "cs": "fam" } }
] },
"maxWidth": 500.0,
"fontChains": { "fam|0|0": ids },
"defaults": { "fontSize": 12.0, "fontFamily": "fam" }
});
measure_paragraph_json(&store, &input.to_string()).expect("measures")
};

let short = measure_chain(vec![0, 1]);
for len in [65usize, 200, 1000] {
let mut ids = vec![0, 1];
ids.resize(len, 1);
assert_eq!(short, measure_chain(ids), "chain of {len} ids");
}
}
Loading