Skip to main content

latexml_core/common/
font.rs

1use std::{
2  borrow::Cow,
3  cmp::max,
4  fmt,
5  hash::{Hash, Hasher},
6  rc::Rc,
7};
8
9use once_cell::sync::Lazy;
10/// Note that this has evolved way beynond just "font",
11/// but covers text properties (or even display properties) in general
12/// including basic font information, color & background color
13/// as well as encoding and language information.
14///
15/// NOTE: This is now in Common that it may evolve to be useful in Post processing...
16use regex::Regex;
17use rustc_hash::FxHashMap as HashMap;
18
19use crate::{
20  BoxOps, Digested, DigestedData, Result,
21  binding::content::{fontmap_family_key_sym, fontmap_key_syms, load_font_map, preload_font_map},
22  common::{
23    arena::{self, SymHashMap, SymStr},
24    color::{self, Color},
25    dimension::Dimension,
26    numeric_ops::{NumericOps, UNITY, UNITY_F64, kround},
27    store::Stored,
28  },
29  state::*,
30};
31
32pub mod standard_metrics;
33use standard_metrics::{MetricData, STDMETRICS};
34
35use crate::pin;
36
37pub type Fontmap = Rc<[Option<char>]>;
38
39static DEFFAMILY: &str = "serif";
40static DEFSERIES: &str = "medium";
41static DEFSHAPE: &str = "upright";
42/// Perl: $DEFCOLOR = Black = Color::rgb(0,0,0)
43static DEFCOLOR: Color = color::BLACK;
44// Perl: $DEFBACKGROUND = undef (transparent), $DEFLANGUAGE = undef
45// These are intentionally None in text_default/math_default.
46static DEFOPACITY: &str = "1";
47static DEFENCODING: &str = "OT1";
48/// Perl: sub DEFSIZE() { return $STATE->lookupValue('NOMINAL_FONT_SIZE') || 10; }
49/// Reads NOMINAL_FONT_SIZE from state, defaulting to 10.0. Perl uses the value
50/// directly as a float (`Common/Font.pm:44`) — the `11pt` class option is
51/// `10.95` (LaTeX's `\@xipt`), so this must NOT truncate via `lookup_int` (#542).
52fn defsize() -> f64 {
53  let v = lookup_float("NOMINAL_FONT_SIZE").map_or(0.0, |f| f.0);
54  if v > 0.0 { v } else { 10.0 }
55}
56
57pub const TEXT_FONTS: [&str; 6] = ["cmr", "cmm", "cmsy", "cmex", "amsa", "amsb"];
58pub const MATH_FONTS: [&str; 6] = ["cmm", "cmsy", "cmex", "amsa", "amsb", "cmr"];
59
60pub const FLAG_FORCE_FAMILY: u8 = 0x1;
61pub const FLAG_FORCE_SERIES: u8 = 0x2;
62pub const FLAG_FORCE_SHAPE: u8 = 0x4;
63pub const FLAG_EMPH: u8 = 0x10;
64
65pub static FONT_TEXT_DEFAULT: Lazy<Font> = Lazy::new(Font::text_default);
66static LATIN_LETTER_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[\p{Latin}&&\pL]$").unwrap());
67static GREEK_LETTER_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[\p{Greek}&&\pL]$").unwrap());
68static UPPER_LETTER_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[\p{Lu}]$").unwrap());
69static DIGIT_LETTER_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[\p{N}]$").unwrap());
70#[rustfmt::skip]
71static FONT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(xylubt|xyluat|xydash|xycmbt|xycmat|xycirc|xybtip|xybsql|xyatip|ul9|ugq|uaq|txtt|txsyb|txsya|txss|txr|txmi|pzd|pzc|pxsyb|pxsya|pxr|pxmi|put|ptm|psy|ppl|pnc|phv|pcr|pbk|pag|msy|msx|msb|msa|manfnt|linew|line|lcirclew|lcircle|futs|futmi|futm|eus|eur|euf|euex|cmvtt|cmtt|cmtl|cmt|cmsy|cmssqi|cmssq|cmss|cmsltt|cmr|cmmib|cmm|cmfr|cmfib|cmex|cmdunh|cmdh|cmu|cmbsy|cmbrs|cmbrm|cmbr|cm|ccy|ccr|ccm|ccitt|bch|bbold|bbmss|bbm)(sbc|sb|mc|m|bx|bm|bc|b|)(sl|sc|n|it|i|csc|)(\d*)$").unwrap());
72//======================================================================
73// Mappings from various forms of names or component names in TeX
74// Given a font, we'd like to map it to the "logical" names derived from LaTeX,
75// (w/ loss of fine grained control).
76// I'd like to use Karl Berry's font naming scheme
77// (See http://www.tug.org/fontname/html/)
78// but it seems to be a one-way mapping, and moreover, doesn't even fit CM fonts!
79// We'll assume a sloppier version:
80//   family + series + variant + size
81// NOTE: This probably doesn't really belong in here...
82
83static FONT_FAMILY: Lazy<HashMap<&'static str, Font>> = Lazy::new(|| {
84  raw_map!(
85    "cmr"  => fontmap!(family => "serif"),      "cmss"  => fontmap!(family => "sansserif"),
86    "cmtt" => fontmap!(family => "typewriter"), "cmvtt" => fontmap!(family => "typewriter"),
87    "cmt"  => fontmap!(family => "serif"),
88    "cmsltt" => fontmap!(family => "typewriter", shape => "slanted"),
89    "cmssq" => fontmap!(family => "sansserif"),
90    "cmssqi" => fontmap!(family => "sansserif", shape => "italic"),
91    "cmdunh" => fontmap!(family => "serif"),
92    "cmu"   => fontmap!(family => "serif"),
93    "cmfib" => fontmap!(family => "serif"),      "cmfr"  => fontmap!(family => "serif"),
94    "cmdh"  => fontmap!(family => "serif"),      "cm"    => fontmap!(family => "serif"),
95    "ptm"   => fontmap!(family => "serif"),      "ppl"   => fontmap!(family => "serif"),
96    "pnc"   => fontmap!(family => "serif"),      "pbk"   => fontmap!(family => "serif"),
97    "phv"   => fontmap!(family => "sansserif"),  "pag"   => fontmap!(family => "serif"),
98    "pcr"   => fontmap!(family => "typewriter"), "pzc"   => fontmap!(family => "script"),
99    "put"   => fontmap!(family => "serif"),      "bch"   => fontmap!(family => "serif"),
100    "psy"   => fontmap!(family => "symbol"),     "pzd"   => fontmap!(family => "dingbats"),
101    "ccr"   => fontmap!(family => "serif"),      "ccy"   => fontmap!(family => "symbol"),
102    // Computer Concrete text (Perl Common/Font.pm L92). Sits with `ccr` here
103    // rather than beside its Perl neighbours `ccm`/`ccitt` below, which this
104    // table groups as math fonts.
105    "cct"   => fontmap!(family => "serif"),
106    "cmbr"  => fontmap!(family => "sansserif"),  "cmtl"  => fontmap!(family => "typewriter"),
107    "cmbrs" => fontmap!(family => "symbol"),     "ul9"   => fontmap!(family => "typewriter"),
108    "txr"   => fontmap!(family => "serif"),      "txss"  => fontmap!(family => "sansserif"),
109    "txtt"  => fontmap!(family => "typewriter"),
110    // Modern family codes ABSENT from Perl's %font_family (Common/Font.pm) —
111    // candidate to upstream. Without them, `\fontfamily{\ttdefault}
112    // \selectfont` (fancyvrb's font setup) LOSES the abstract family when a
113    // font package repoints \ttdefault: colm2026_conference loads
114    // `inconsolata` (\ttdefault = zi4), so boxed Verbatim prompts dropped
115    // ltx_font_typewriter and the browser painted full-size serif prose
116    // inside frames TeX measured as \small monospace — border collisions
117    // and text rivers (witness 2605.00468, Prompts 1-7).
118    // Latin Modern:
119    "lmr"   => fontmap!(family => "serif"),      "lmss"  => fontmap!(family => "sansserif"),
120    "lmtt"  => fontmap!(family => "typewriter"), "lmvtt" => fontmap!(family => "typewriter"),
121    // TeX Gyre:
122    "qpl"   => fontmap!(family => "serif"),      "qtm"   => fontmap!(family => "serif"),
123    "qbk"   => fontmap!(family => "serif"),      "qcs"   => fontmap!(family => "serif"),
124    "qhv"   => fontmap!(family => "sansserif"),  "qag"   => fontmap!(family => "sansserif"),
125    "qcr"   => fontmap!(family => "typewriter"), "qzc"   => fontmap!(family => "script"),
126    // inconsolata (zi4), Bera Mono (fvm), Bera Serif/Sans (fve/fvs),
127    // DejaVu Mono (DejaVuSansMono-TLF is fontspec-era; the NFSS code):
128    "zi4"   => fontmap!(family => "typewriter"), "fi4"   => fontmap!(family => "typewriter"),
129    "fvm"   => fontmap!(family => "typewriter"), "fve"   => fontmap!(family => "serif"),
130    "fvs"   => fontmap!(family => "sansserif"),
131    // Source Code/Sans/Serif Pro, Fira:
132    "zsourcecodepro" => fontmap!(family => "typewriter"),
133    "SourceCodePro-TLF" => fontmap!(family => "typewriter"),
134    "FiraMono-TLF" => fontmap!(family => "typewriter"),
135    "FiraSans-TLF" => fontmap!(family => "sansserif"),
136    "txsya" => fontmap!(encoding => "AMSa"),     "txsyb" => fontmap!(encoding => "AMSb"),
137    "pxr"   => fontmap!(family => "serif"),
138    "pxsya" => fontmap!(encoding => "AMSa"),     "pxsyb" => fontmap!(encoding => "AMSb"),
139    "futs"  => fontmap!(family => "serif"),
140    "uaq"   => fontmap!(family => "serif"),      "ugq"   => fontmap!(family => "sansserif"),
141    // Pretend to recognize plain & latex's extra fonts
142    "manfnt"  => fontmap!(family => "graphic", encoding => "manfnt"),
143    "line"    => fontmap!(family => "graphic", encoding => "line"),
144    "linew"   => fontmap!(family => "graphic", encoding => "line", series => "bold"),
145    "lcircle" => fontmap!(family => "graphic", encoding => "lcircle"),
146    "lcirclew" => fontmap!(family => "graphic", encoding => "lcircle", series => "bold"),
147    // Pretend to recognize xy's fonts
148    "xydash" => fontmap!(family => "graphic"), "xyatip" => fontmap!(family => "graphic"),
149    "xybtip" => fontmap!(family => "graphic"), "xybsql" => fontmap!(family => "graphic"),
150    "xycirc" => fontmap!(family => "graphic"), "xycmat" => fontmap!(family => "graphic"),
151    "xycmbt" => fontmap!(family => "graphic"), "xyluat" => fontmap!(family => "graphic"),
152    "xylubt" => fontmap!(family => "graphic"),
153    "eur"   => fontmap!(family => "serif"),      "eus"   => fontmap!(family => "script"),
154    "euf"   => fontmap!(family => "fraktur"),    "euex"  => fontmap!(encoding => "OMX"),
155    // The following are actually math fonts.
156    "ccm"   => fontmap!(family => "serif", shape => "italic"),
157    "cmm"   => fontmap!(family => "math", shape => "italic", encoding => "OML"),
158    "cmex"  => fontmap!(encoding => "OMX"),
159    "cmsy"  => fontmap!(encoding => "OMS"),
160    "ccitt" => fontmap!(family => "typewriter", shape => "italic"),
161    "cmbrm" => fontmap!(family => "sansserif", shape => "italic"),
162    "futm"  => fontmap!(family => "serif", shape => "italic"),
163    "futmi" => fontmap!(family => "serif", shape => "italic"),
164    "txmi"  => fontmap!(family => "serif", shape => "italic"),
165    "pxmi"  => fontmap!(family => "serif", shape => "italic"),
166    // cmmib already in Perl
167    "bbm"   => fontmap!(family => "blackboard"),
168    "bbold" => fontmap!(family => "blackboard"),
169    "bbmss" => fontmap!(family => "blackboard"),
170    // some ams fonts
171    "cmmib" => fontmap!(family => "italic", series   => "bold"),
172    "cmbsy" => fontmap!(series => "bold", encoding => "OMS"),
173    "msa"   => fontmap!(encoding => "AMSa"),
174    "msb"   => fontmap!(encoding => "AMSb"),
175    // Are these really the same?
176    "msx" => fontmap!(encoding => "AMSa"),
177    "msy" => fontmap!(encoding => "AMSb")
178  )
179});
180/// Maps the "series code" to an abstract font series name
181static FONT_SERIES: Lazy<HashMap<&'static str, Font>> = Lazy::new(|| {
182  raw_map!(
183    "" => Font::default(), "m" => fontmap!(series => "medium"),
184      "mc" => fontmap!(series => "medium"),
185    "b"  => fontmap!(series => "bold"),   "bc"  => fontmap!(series => "bold"),
186      "bx" => fontmap!(series => "bold"),
187    "sb" => fontmap!(series => "bold"),   "sbc" => fontmap!(series => "bold"),
188      "bm" => fontmap!(series => "bold")
189  )
190});
191
192/// Maps the "shape code" to an abstract font shape name.
193static FONT_SHAPE: Lazy<HashMap<&'static str, Font>> = Lazy::new(|| {
194  raw_map!(
195    "" => Font::default(), "n" => fontmap!(shape => "upright"),
196      "i" => fontmap!(shape => "italic"), "it" => fontmap!(shape => "italic"),
197      "sl" => fontmap!(shape => "slanted"),
198      "sc" => fontmap!(shape => "smallcaps"), "csc" => fontmap!(shape => "smallcaps")
199  )
200});
201
202/// Symbolic font sizes, relative to the NOMINAL_FONT_SIZE (often 10)
203/// extended logical font sizes, based on nominal document size of 10pts
204/// Possibly should simply use absolute font point sizes, as declared in class...
205static FONT_SIZE: Lazy<HashMap<&'static str, f64>> = Lazy::new(|| {
206  raw_map!(
207"tiny"   => 0.5,   "SMALL" => 0.7, "Small" => 0.8,  "small" => 0.9,
208"normal" => 1.0,   "large" => 1.2, "Large" => 1.44, "LARGE" => 1.728,
209"huge"   => 2.074, "Huge"  => 2.488,
210"big"    => 1.2,   "Big"   => 1.6, "bigg" => 2.1, "Bigg" => 2.6)
211});
212
213static SCRIPT_STYLE_MAP: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
214  raw_map!(
215  "display" => "script", "text" => "script",
216  "script" => "scriptscript", "scriptscript" => "scriptscript")
217});
218
219static FRAC_STYLE_MAP: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
220  raw_map!(
221  "display" => "text", "text" => "script",
222  "script" => "scriptscript", "scriptscript" => "scriptscript")
223});
224
225static STYLE_SIZE: Lazy<HashMap<&'static str, usize>> = Lazy::new(|| {
226  raw_map!(
227  "display" => 10, "text" => 10, "script" => 7, "scriptscript" => 5)
228});
229
230// Note: Perl's Font.pm has a %mathstylesize table used in specialize()
231// font-size scaling, but the path is commented out in Perl too. We don't
232// implement this yet — restore from git history if/when specialize wants
233// math-style-based size adjustment.
234
235/// A special form of merge when copying/moving nodes to a new context,
236/// particularly math which become scripts or such.
237static MATH_STYLE_STEP: Lazy<HashMap<&'static str, HashMap<&'static str, i32>>> = Lazy::new(|| {
238  raw_map!(
239  "display" => raw_map!(
240    "display" => 0, "text" => 1, "script" => 2, "scriptscript" => 3),
241  "text"=> raw_map!("display" => -1, "text" => 0, "script" => 1, "scriptscript" => 2),
242  "script"=> raw_map!("display" => -2, "text" => -1, "script" => 0, "scriptscript" => 1),
243  "scriptscript" => raw_map!("display" => -3, "text" => -2, "script" => -1, "scriptscript" => 0))
244});
245static STEP_MATH_STYLE: Lazy<HashMap<&'static str, HashMap<i32, &'static str>>> = Lazy::new(|| {
246  raw_map!(
247"display" => raw_map!(-3 => "display", -2 => "display", -1 => "display",
248  0 => "display", 1 => "text", 2 => "script", 3 => "scriptscript"),
249"text" => raw_map!(-3 => "display", -2 => "display", -1 => "display",
250  0 => "text", 1 => "script", 2 => "scriptscript", 3 => "scriptscript"),
251"script" => raw_map!(-3 => "display", -2 => "display", -1 => "text",
252  0 => "script", 1 => "scriptscript", 2 => "scriptscript", 3 => "scriptscript"),
253"scriptscript" => raw_map!(-3 => "display", -2 => "text", -1 => "script",
254  0 => "scriptscript", 1 => "scriptscript", 2 => "scriptscript", 3 => "scriptscript"))
255});
256
257/// Map Font (family, series, shape) to a TeX fontname (tfm).
258/// Returns `None` if the combo isn't recognized. Matching on a tuple
259/// of `&str` lets callers skip allocating an intermediate
260/// `format!("{family}_{series}_{shape}")` key per lookup. Callers use
261/// `lookup_metric_name(family, series, shape)` instead of the former
262/// `METRIC_MAP.get(&format!(…))` pattern.
263fn lookup_metric_name(family: &str, series: &str, shape: &str) -> Option<&'static str> {
264  match (family, series, shape) {
265    ("serif", "medium", "upright") => Some("cmr"),
266    ("serif", "medium", "slanted") => Some("cmsl"),
267    ("serif", "medium", "italic") => Some("cmti"),
268    ("serif", "medium", "uprightitalic") => Some("cmu"),
269    ("serif", "bold", "upright") => Some("cmbx"),
270    ("serif", "medum", "smallcaps") => Some("cmcsc"), // typo preserved from Perl
271    ("sansserif", "medium", "upright") => Some("cmss"),
272    ("sansserif", "medium", "italic") => Some("cmssi"),
273    ("sansserif", "bold", "upright") => Some("cmssbx"),
274    ("typewriter", "medium", "upright") => Some("cmtt"),
275    ("typewriter", "medium", "slanted") => Some("cmsltt"),
276    ("math", "medium", "italic") => Some("cmmi"),
277    ("math", "medium", "upright") => Some("cmr"),
278    ("math", "bold", "italic") => Some("cmmib"),
279    _ => None,
280  }
281}
282
283// Fallback fontnames for looking up random Unicode,
284// when they're not in the indicated FontMap
285// Perl #2845 (Common/Font.pm L530): `ifgeo` was appended for the lozenge/
286// diamond glyphs. Until the `ifgeo` TFM is folded into STDMETRICS (a deferred
287// StandardMetrics.pm regen — generated data), this entry resolves to the `cmr`
288// ultimate fallback and is a harmless no-op, matching Perl's list order.
289static METRIC_FALLBACKS: [&str; 7] = ["cmr", "cmmi", "cmsy", "cmex", "msam", "msbm", "ifgeo"];
290
291// Math bearing atom types
292// 0=Ord, 1=Op, 2=Bin, 3=Rel, 4=Open, 5=Close, 6=Punct, 7=Inner
293#[rustfmt::skip]
294static MATH_ATOM_TYPE: Lazy<HashMap<&'static str, usize>> = Lazy::new(|| {
295  raw_map!(
296    "ID" => 0,
297    "BIGOP" => 1, "SUMOP" => 1, "INTOP" => 1, "OPERATOR" => 1, "LIMITOP" => 1, "DIFFOP" => 1,
298    "ADDOP" => 2, "MULOP" => 2, "BINOP" => 2, "COMPOSEOP" => 2, "MIDDLE" => 2, "VERTBAR" => 2,
299    "RELOP" => 3, "METARELOP" => 3, "ARROW" => 3,
300    "OPEN" => 4, "CLOSE" => 5,
301    "PUNCT" => 6, "PERIOD" => 6,
302    "ARRAY" => 7, "MODIFIER" => 7
303  )
304});
305
306// Math bearing table: [prev_type][cur_type] => bearing level
307// 0=none, positive=thin(1)/med(2)/thick(3) in display/text,
308// negative: same but suppressed in script/scriptscript
309#[rustfmt::skip]
310static MATH_BEARINGS: [[i8; 8]; 8] = [
311  [ 0,  1, -2, -3,  0,  0,  0, -1],
312  [ 1,  1,  0, -3,  0,  0,  0, -1],
313  [-2, -2,  0,  0, -2,  0,  0, -2],
314  [-3, -3,  0,  0, -3,  0,  0, -3],
315  [ 0,  0,  0,  0,  0,  0,  0,  0],
316  [ 0,  1, -2, -3,  0,  0,  0, -1],
317  [-1, -1,  0, -1, -1, -1, -1, -1],
318  [-1,  1, -2, -3, -1,  0, -1, -1],
319];
320
321// (Perl Font.pm %baseline_map removed with the #2798 S6 sizing rewrite:
322// `compute_boxes_size_stack` now uses the per-line baseline threaded from the
323// List's `\baselineskip` property (recorded by S4 in repack_horizontal),
324// which is the faithful #2798 source — not a static font-size→baseline map.)
325
326/// Global auxiliary for font family lookup
327pub fn lookup_font_family(code: &str) -> Option<&Font> { FONT_FAMILY.get(code) }
328
329/// Global auxiliary for font series lookup
330pub fn lookup_font_series(code: &str) -> Option<&Font> { FONT_SERIES.get(code) }
331
332/// Global auxiliary for font shape lookup
333pub fn lookup_font_shape(code: &str) -> Option<&Font> { FONT_SHAPE.get(code) }
334
335/// Combine family/series/shape lookups into a single Font (Perl lookupTeXFont)
336pub fn lookup_tex_font(fontname: &str, seriescode: &str, shapecode: &str) -> Font {
337  let mut props = Font::default();
338  if let Some(ffam) = lookup_font_family(fontname) {
339    props = props.merge_ref(ffam);
340  }
341  if let Some(fser) = lookup_font_series(seriescode) {
342    props = props.merge_ref(fser);
343  }
344  if let Some(fsh) = lookup_font_shape(shapecode) {
345    props = props.merge_ref(fsh);
346  }
347  props
348}
349
350/// Find a Font Metric for a given fontname, fallback to 10pt or cmr as needed.
351/// Perl: getMetricForName
352pub fn get_metric_for_name(name: &str) -> &'static MetricData {
353  let base = if let Some(idx) = name.find(|c: char| c.is_ascii_digit()) {
354    &name[..idx]
355  } else {
356    name
357  };
358  // Try exact name first (e.g. "cmr10")
359  if let Some(m) = STDMETRICS.get(name) {
360    return m;
361  }
362  // Try base without size (e.g. "cmr" from "cmr10")
363  if let Some(m) = STDMETRICS.get(base) {
364    return m;
365  }
366  // Try base + "10". Stack-buffer concat avoids the per-call `format!`
367  // heap alloc — `get_metric_for_name` is reached through the
368  // per-character `get_metric` loop, so this is a real allocation
369  // site. Font basenames are short (≤ ~20 ASCII bytes); 32 bytes is
370  // ample padding.
371  let base_bytes = base.as_bytes();
372  if base_bytes.len() + 2 <= 32 {
373    let mut buf = [0u8; 32];
374    buf[..base_bytes.len()].copy_from_slice(base_bytes);
375    buf[base_bytes.len()] = b'1';
376    buf[base_bytes.len() + 1] = b'0';
377    if let Ok(s) = std::str::from_utf8(&buf[..base_bytes.len() + 2])
378      && let Some(m) = STDMETRICS.get(s)
379    {
380      return m;
381    }
382  }
383  // Ultimate fallback to "cmr"
384  STDMETRICS
385    .get("cmr")
386    .expect("STDMETRICS must contain 'cmr'")
387}
388
389pub fn decode_fontname(name: &str, at_opt: Option<f64>, scaled_opt: Option<f64>) -> Option<Font> {
390  if let Some(cap) = FONT_RE.captures(name) {
391    // Perl: my %props = (series => 'medium', shape => 'upright', encoding => 'OT1');
392    let mut props = Font {
393      series: Some(Cow::Borrowed(DEFSERIES)),
394      shape: Some(Cow::Borrowed(DEFSHAPE)),
395      encoding: Some(Cow::Borrowed("OT1")),
396      ..Font::default()
397    };
398    let fam = cap.get(1).map_or("", |m| m.as_str());
399    let ser = cap.get(2).map_or("", |m| m.as_str());
400    let shp = cap.get(3).map_or("", |m| m.as_str());
401    let size_str = cap.get(4).map_or("", |m| m.as_str());
402    if let Some(ffam) = lookup_font_family(fam) {
403      props = props.merge_ref(ffam);
404    }
405    if let Some(fser) = lookup_font_series(ser) {
406      props = props.merge_ref(fser);
407    }
408    if let Some(fsh) = lookup_font_shape(shp) {
409      props = props.merge_ref(fsh);
410    }
411    let mut size = if let Some(at) = at_opt {
412      at
413    } else {
414      let size_f64 = size_str.parse::<f64>().unwrap_or(1.0);
415      if size_f64 == 0.0 { 1.0 } else { size_f64 } // Yes, also if 0, "" (from regexp)
416    };
417    if let Some(scaled) = scaled_opt {
418      size *= scaled;
419    }
420    props.size = Some(size);
421    // Experimental Hack !?!?!?
422    if props.encoding.is_none() {
423      props.encoding = Some(Cow::Borrowed("OT1"));
424    }
425    // TODO: What is this field for?
426    // if let Some(at) = at_opt {
427    //   props.at = Some(s!("{at}pt"));
428    // }
429    Some(props)
430  } else {
431    None
432  }
433}
434
435/// A data structure containing Font information, but also related textual properties (such as
436/// color)
437///
438/// This struct is a little interesting, as we want to pass overrides that partially modify (via a
439/// merge) the current font, in each definitional binding. To accommodate that with this struct,
440/// every single field needs to be an Option, in order to unambiguously tell the "intend" of
441/// override (Some) vs no intent (None).
442#[derive(Clone, Default)]
443pub struct Font {
444  pub family:        Option<Cow<'static, str>>,
445  pub series:        Option<Cow<'static, str>>,
446  pub shape:         Option<Cow<'static, str>>,
447  pub size:          Option<f64>,
448  pub color:         Option<Color>,
449  pub bg:            Option<Color>,
450  pub opacity:       Option<Cow<'static, str>>,
451  pub encoding:      Option<Cow<'static, str>>,
452  pub language:      Option<Cow<'static, str>>,
453  pub mathstyle:     Option<Cow<'static, str>>,
454  pub mathstylestep: Option<i32>,
455  pub name:          Option<Cow<'static, str>>,
456  pub emph:          Option<bool>,
457  pub scripted:      Option<bool>,
458  pub fraction:      Option<bool>,
459  // Note: forcefamily, forceseries, forceshape (& forcebold for compatibility)
460  // are only useful for fonts in math; See the specialize method below.
461  pub forceseries:   Option<bool>,
462  pub forcefamily:   Option<bool>,
463  pub forceshape:    Option<bool>,
464  pub forcebold:     Option<bool>,
465  pub scale:         Option<f64>,
466  pub flags:         Option<u8>,
467}
468
469impl Hash for Font {
470  // We need to implement hash since we have to tell Rust how to hash `f64` values
471  // for now I have decided to go for a precision of 4 digits after the decimal point,
472  // so multiplying by 1000
473  fn hash<H: Hasher>(&self, hasher: &mut H) {
474    self.family.hash(hasher);
475    self.series.hash(hasher);
476    self.shape.hash(hasher);
477    self.size.map(|size| (size * 1000.0) as i64).hash(hasher);
478    // None color hashes same as Some(DEFCOLOR)
479    Some(self.color.unwrap_or(DEFCOLOR)).hash(hasher);
480    self.bg.hash(hasher);
481    self.opacity.hash(hasher);
482    self.encoding.hash(hasher);
483    self.language.hash(hasher);
484    self.mathstyle.hash(hasher);
485    self.mathstylestep.hash(hasher);
486    self.name.hash(hasher);
487    self.emph.hash(hasher);
488    self.scripted.hash(hasher);
489    self.forceseries.hash(hasher);
490    self.forcefamily.hash(hasher);
491    self.forceshape.hash(hasher);
492    self.scale.map(|scale| (scale * 1000.0) as i64).hash(hasher);
493    self.flags.hash(hasher);
494  }
495}
496impl PartialEq for Font {
497  fn eq(&self, other: &Self) -> bool {
498    self.family == other.family
499      && self.series == other.series
500      && self.shape == other.shape
501      && self.size == other.size
502      && !is_diff_font_color(self.color.as_ref(), other.color.as_ref())
503      && self.bg == other.bg
504      && self.opacity == other.opacity
505      && self.encoding == other.encoding
506      && self.language == other.language
507      && self.mathstyle == other.mathstyle
508      && self.mathstylestep == other.mathstylestep
509      && self.name == other.name
510      && self.emph == other.emph
511      && self.scripted == other.scripted
512      && self.fraction == other.fraction
513      && self.forceseries == other.forceseries
514      && self.forcefamily == other.forcefamily
515      && self.forceshape == other.forceshape
516      && self.forcebold == other.forcebold
517      && self.scale == other.scale
518      && self.flags == other.flags
519  }
520}
521impl Eq for Font {}
522// display is used often for attributes in binding replacements,
523// as in font="#font"
524impl fmt::Display for Font {
525  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
526    write!(f, "{}", self.family.as_ref().unwrap_or(&Cow::Borrowed("")))
527  }
528}
529
530impl fmt::Debug for Font {
531  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
532    let star = Cow::Borrowed("*");
533    write!(f, "Font[")?;
534    write!(f, "{}", self.family.as_ref().unwrap_or(&star))?;
535    write!(f, ",")?;
536    write!(f, "{}", self.series.as_ref().unwrap_or(&star))?;
537    write!(f, ",")?;
538    write!(f, "{}", self.shape.as_ref().unwrap_or(&star))?;
539    write!(f, ",")?;
540    let size_str = self
541      .size
542      .as_ref()
543      .map(|x| x.to_string())
544      .unwrap_or_else(|| String::from('*'));
545    write!(f, "{}", size_str)?;
546    write!(f, ",")?;
547    if let Some(ref c) = self.color {
548      write!(f, "{c}")?;
549    } else {
550      write!(f, "*")?;
551    }
552    write!(f, ",")?;
553    if let Some(ref b) = self.bg {
554      write!(f, "{b}")?;
555    } else {
556      write!(f, "*")?;
557    }
558    write!(f, ",")?;
559    write!(f, "{}", self.opacity.as_ref().unwrap_or(&star))?;
560    write!(f, ",")?;
561    let scale_str = self
562      .scale
563      .as_ref()
564      .map(|x| x.to_string())
565      .unwrap_or_else(|| String::from('*'));
566    write!(f, "{}", scale_str)?;
567    write!(f, ",")?;
568    write!(f, "{}", self.mathstyle.as_ref().unwrap_or(&star))?;
569    // TODO? LaTeXML doesn't seem to emit these
570    // if let Some(ref encoding) = self.encoding {
571    //   parts.push(s!("encoding: {:?}", encoding))
572    // }
573    // if let Some(ref language) = self.language {
574    //   parts.push(s!("language: {:?}", language))
575    // }
576    // if let Some(ref mathstylestep) = self.mathstyle {
577    //   parts.push(s!("mathstylestep: {:?}", mathstylestep))
578    // }
579    // if let Some(ref forceseries) = self.forceseries {
580    //   parts.push(s!("forceseries: {:?}", forceseries))
581    // }
582    // if let Some(ref forcefamily) = self.forcefamily {
583    //   parts.push(s!("forcefamily: {:?}", forcefamily))
584    // }
585    // if let Some(ref forceshape) = self.forceshape {
586    //   parts.push(s!("forceshape: {:?}", forceshape))
587    // }
588    // if let Some(ref scripted) = self.scripted {
589    //   parts.push(s!("scripted: {:?}", scripted))
590    // }
591    write!(f, "]")
592  }
593}
594
595impl Font {
596  pub fn text_default() -> Self {
597    Font {
598      family:        Some(Cow::Borrowed(DEFFAMILY)),
599      series:        Some(Cow::Borrowed(DEFSERIES)),
600      shape:         Some(Cow::Borrowed(DEFSHAPE)),
601      size:          Some(defsize()),
602      color:         None, // None = inherited default (DEFCOLOR); Some = explicitly set
603      bg:            None, // Perl: $DEFBACKGROUND = undef (transparent)
604      opacity:       Some(Cow::Borrowed(DEFOPACITY)),
605      encoding:      Some(Cow::Borrowed(DEFENCODING)),
606      language:      None, // Perl: $DEFLANGUAGE = undef
607      mathstyle:     None,
608      mathstylestep: None,
609      emph:          None,
610      name:          None,
611      scripted:      None,
612      fraction:      None,
613      forceseries:   None,
614      forcefamily:   None,
615      forceshape:    None,
616      forcebold:     None,
617      scale:         None,
618      flags:         None,
619    }
620  }
621  pub fn math_default() -> Self {
622    Font {
623      family:        Some(Cow::Borrowed("math")),
624      series:        Some(Cow::Borrowed(DEFSERIES)),
625      shape:         Some(Cow::Borrowed("italic")),
626      size:          Some(defsize()),
627      color:         None, // None = inherited default (DEFCOLOR); Some = explicitly set
628      bg:            None, // Perl: $DEFBACKGROUND = undef
629      opacity:       Some(Cow::Borrowed(DEFOPACITY)),
630      encoding:      None, // Perl has 'OT1' but Rust char decoding uses encoding differently
631      language:      None, // Perl: $DEFLANGUAGE = undef
632      mathstyle:     Some(Cow::Borrowed("text")),
633      mathstylestep: None,
634      emph:          None,
635      name:          None,
636      scripted:      None,
637      fraction:      None,
638      forceseries:   None,
639      forcefamily:   None,
640      forceshape:    None,
641      forcebold:     None,
642      scale:         None,
643      flags:         None,
644    }
645  }
646
647  pub fn to_hashable(&self) -> u64 {
648    // MUST be deterministic: equal Fonts must hash equal, stably across calls
649    // AND across process runs. `set_node_font`/`get_node_font` use this as the
650    // `_font` key and `node_fonts` map key, so a randomized seed
651    // (`RandomState::new()`, used here previously) gave the same Font a
652    // different id on every call — breaking font dedup and making the document
653    // build run-to-run non-deterministic (intermittent locked-frame/mode
654    // FATALs, e.g. 1510.04473). `FxHasher` has a fixed seed.
655    let mut hasher = rustc_hash::FxHasher::default();
656    Hash::hash(self, &mut hasher);
657    hasher.finish()
658  }
659
660  /// Condensed string showing only non-default components.
661  /// Perl: stringify
662  pub fn stringify(&self) -> String {
663    let fam = self
664      .family
665      .as_deref()
666      .map(|f| if f == "math" { "serif" } else { f });
667    let mut parts: Vec<&str> = Vec::new();
668    if let Some(f) = fam
669      && f != DEFFAMILY
670    {
671      parts.push(f);
672    }
673    if let Some(ref ser) = self.series
674      && ser.as_ref() != DEFSERIES
675    {
676      parts.push(ser);
677    }
678    if let Some(ref shp) = self.shape
679      && shp.as_ref() != DEFSHAPE
680    {
681      parts.push(shp);
682    }
683    // Size: use temporary string for formatting
684    let size_str;
685    if let Some(siz) = self.size
686      && (siz - defsize()).abs() > 0.001
687    {
688      size_str = siz.to_string();
689      parts.push(&size_str);
690    }
691    let color_str;
692    if let Some(ref col) = self.color
693      && *col != DEFCOLOR
694    {
695      color_str = col.to_attribute();
696      parts.push(&color_str);
697    }
698    let bg_str;
699    if let Some(ref bkg) = self.bg {
700      // Perl: $DEFBACKGROUND = undef, so any set bg is non-default
701      bg_str = bkg.to_attribute();
702      parts.push(&bg_str);
703    }
704    if let Some(ref opa) = self.opacity
705      && opa.as_ref() != DEFOPACITY
706    {
707      parts.push(opa);
708    }
709    if let Some(ref ms) = self.mathstyle {
710      parts.push(ms);
711    }
712    let flags_str;
713    if let Some(flags) = self.flags
714      && flags != 0
715    {
716      flags_str = flags.to_string();
717      parts.push(&flags_str);
718    }
719    format!("Font[{}]", parts.join(","))
720  }
721
722  /// Wildcard font matching: if any components are defined in both fonts,
723  /// they must be equal. Perl: match
724  pub fn font_match(&self, other: &Font) -> bool {
725    fn check<T: PartialEq>(a: &Option<T>, b: &Option<T>) -> bool {
726      !(a.is_some() && b.is_some() && a != b)
727    }
728    check(&self.family, &other.family)
729      && check(&self.series, &other.series)
730      && check(&self.shape, &other.shape)
731      && check(&self.size, &other.size)
732      // For color: None = DEFCOLOR, so compare effective colors
733      && !is_diff_font_color(self.color.as_ref(), other.color.as_ref())
734      && check(&self.bg, &other.bg)
735      && check(&self.opacity, &other.opacity)
736      && check(&self.encoding, &other.encoding)
737      && check(&self.language, &other.language)
738      && check(&self.mathstyle, &other.mathstyle)
739  }
740
741  /// Fill in undefined fields from a concrete font.
742  /// Perl: makeConcrete
743  pub fn make_concrete(&self, concrete: &Font) -> Self {
744    Font {
745      family:        self.family.clone().or_else(|| concrete.family.clone()),
746      series:        self.series.clone().or_else(|| concrete.series.clone()),
747      shape:         self.shape.clone().or_else(|| concrete.shape.clone()),
748      size:          self.size.or(concrete.size),
749      color:         self.color.or(concrete.color),
750      bg:            self.bg.or(concrete.bg),
751      opacity:       self.opacity.clone().or_else(|| concrete.opacity.clone()),
752      encoding:      self.encoding.clone().or_else(|| concrete.encoding.clone()),
753      language:      self.language.clone().or_else(|| concrete.language.clone()),
754      mathstyle:     self
755        .mathstyle
756        .clone()
757        .or_else(|| concrete.mathstyle.clone()),
758      flags:         Some(self.flags.unwrap_or(0) | concrete.flags.unwrap_or(0)),
759      mathstylestep: self.mathstylestep.or(concrete.mathstylestep),
760      name:          self.name.clone().or_else(|| concrete.name.clone()),
761      emph:          self.emph.or(concrete.emph),
762      scripted:      self.scripted.or(concrete.scripted),
763      fraction:      self.fraction.or(concrete.fraction),
764      forceseries:   self.forceseries.or(concrete.forceseries),
765      forcefamily:   self.forcefamily.or(concrete.forcefamily),
766      forceshape:    self.forceshape.or(concrete.forceshape),
767      forcebold:     self.forcebold.or(concrete.forcebold),
768      scale:         self.scale.or(concrete.scale),
769    }
770  }
771
772  /// Apply pure style changes (from purestyleChanges) to this font.
773  /// Perl: mergePurestyle
774  pub fn merge_purestyle(&self, changes: &Font) -> Self {
775    let mut new = self.clone();
776    if let Some(scale) = changes.scale
777      && let Some(ref mut sz) = new.size
778    {
779      *sz *= scale;
780    }
781    if changes.color.is_some() {
782      new.color.clone_from(&changes.color);
783    }
784    if changes.bg.is_some() {
785      new.bg.clone_from(&changes.bg);
786    }
787    if changes.opacity.is_some() {
788      new.opacity.clone_from(&changes.opacity);
789    }
790    if let Some(step) = changes.mathstylestep {
791      let cur_style: &str = new.mathstyle.as_deref().unwrap_or("display");
792      if let Some(step_map) = STEP_MATH_STYLE.get(cur_style)
793        && let Some(new_style) = step_map.get(&step)
794      {
795        new.mathstyle = Some(Cow::Borrowed(new_style));
796      }
797    }
798    new
799  }
800
801  /// Compute math bearing (inter-atom spacing) between two boxes.
802  /// Perl: math_bearing
803  pub fn math_bearing(&self, thisbox: &Digested, prevbox: &Digested) -> f64 {
804    let r0 = prevbox
805      .get_property("role")
806      .and_then(|s| match s.into_owned() {
807        Stored::String(sym) => Some(arena::with(sym, |s| s.to_string())),
808        _ => None,
809      })
810      .unwrap_or_else(|| "ID".to_string());
811    let r1 = thisbox
812      .get_property("role")
813      .and_then(|s| match s.into_owned() {
814        Stored::String(sym) => Some(arena::with(sym, |s| s.to_string())),
815        _ => None,
816      })
817      .unwrap_or_else(|| "ID".to_string());
818    let t0 = *MATH_ATOM_TYPE.get(r0.as_str()).unwrap_or(&0);
819    let t1 = *MATH_ATOM_TYPE.get(r1.as_str()).unwrap_or(&0);
820    let bearing = MATH_BEARINGS[t0][t1];
821    let style = self
822      .get_mathstyle()
823      .map(|s| s.to_string())
824      .unwrap_or_else(|| "text".to_string());
825    if bearing == 0 || (bearing < 0 && style != "display" && style != "text") {
826      return 0.0;
827    }
828    // Look up the bearing register: 1=thinmuskip, 2=medmuskip, 3=thickmuskip
829    let reg_cs = match bearing.unsigned_abs() {
830      1 => T_CS!("\\thinmuskip"),
831      2 => T_CS!("\\medmuskip"),
832      3 => T_CS!("\\thickmuskip"),
833      _ => return 0.0,
834    };
835    if let Ok(Some(def)) = lookup_definition(&reg_cs)
836      && let Some(val) = def.value_of(Vec::new())
837    {
838      // Perl: $STATE->lookupDefinition(...)->valueOf->spValue
839      // MuGlue->spValue = fixpoint($skip/UNITY, font->getMUWidth)
840      //                 = kround((skip/UNITY) * MUWidth)
841      // The raw skip is in mu*UNITY units; convert to sp via MUWidth.
842      let skip = val.value_of();
843      let mu_width = self.get_mu_width() as f64;
844      return (skip as f64 / UNITY_F64 * mu_width).trunc();
845    }
846    0.0
847  }
848
849  pub fn is_sticky(&self) -> bool {
850    if let Some(ref family) = self.family {
851      family == "serif" || family == "sansserif" || family == "typewriter"
852    } else {
853      false
854    }
855  }
856
857  // Accessors
858  pub fn get_family(&self) -> Option<&Cow<'_, str>> { self.family.as_ref() }
859  pub fn get_series(&self) -> Option<&Cow<'_, str>> { self.series.as_ref() }
860  pub fn get_shape(&self) -> Option<&Cow<'_, str>> { self.shape.as_ref() }
861  pub fn get_size(&self) -> Option<f64> { self.size }
862  pub fn get_color(&self) -> Option<&Color> { self.color.as_ref() }
863  pub fn get_background(&self) -> Option<&Color> { self.bg.as_ref() }
864  pub fn get_opacity(&self) -> Option<&Cow<'_, str>> { self.opacity.as_ref() }
865  pub fn get_encoding(&self) -> Option<&Cow<'_, str>> { self.encoding.as_ref() }
866  pub fn get_language(&self) -> Option<&Cow<'_, str>> { self.language.as_ref() }
867  pub fn get_mathstyle(&self) -> Option<&Cow<'_, str>> { self.mathstyle.as_ref() }
868  pub fn get_flags(&self) -> Option<u8> { self.flags }
869
870  // NOTE: In math, NORMALLY, setting any one of
871  //    family, series or shape
872  // will, usually, automatically reset the others to thier defaults!
873  // You must arrange this in the calls....
874  pub fn merge(&self, other: Font) -> Self { self.merge_ref(&other) }
875
876  /// Like `merge` but borrows `other` to avoid requiring callers to own or
877  /// clone a Font just to pass into merge. Clones only the retained fields
878  /// from `other` (cheap — Option<Cow<'static,str>> clones are free for
879  /// Borrowed variants, and most Font fields are None in typical uses).
880  pub fn merge_ref(&self, other: &Font) -> Self {
881    // Handle forcebold for compatibility (Perl lines 873-874)
882    let mut series = other.series.clone();
883    let mut force_series = other.forceseries;
884    if other.forcebold == Some(true) {
885      series = Some(Cow::Borrowed("bold"));
886      force_series = Some(true);
887    }
888
889    // Build flags from force options
890    let mut flags: u8 = 0;
891    if other.forcefamily == Some(true) {
892      flags |= FLAG_FORCE_FAMILY;
893    }
894    if force_series == Some(true) {
895      flags |= FLAG_FORCE_SERIES;
896    }
897    if other.forceshape == Some(true) {
898      flags |= FLAG_FORCE_SHAPE;
899    }
900
901    let oflags = self.flags.unwrap_or(0);
902    // Perl: fallback to self if not overridden, or if force-flags on self prevent override
903    let family = if other.family.is_none() || (oflags & FLAG_FORCE_FAMILY != 0) {
904      self.family.clone()
905    } else {
906      other.family.clone()
907    };
908    let series = if series.is_none() || (oflags & FLAG_FORCE_SERIES != 0) {
909      self.series.clone()
910    } else {
911      series
912    };
913    let mut shape = if other.shape.is_none() || (oflags & FLAG_FORCE_SHAPE != 0) {
914      self.shape.clone()
915    } else {
916      other.shape.clone()
917    };
918    let mut size = other.size.or(self.size);
919    let color = other.color.or(self.color);
920    // Perl: $bg = $$self[5] if (!exists $options{background});
921    // Only override bg if `other` actually specifies one
922    let bg = if other.bg.is_some() {
923      other.bg
924    } else {
925      self.bg
926    };
927    let opacity = other.opacity.clone().or_else(|| self.opacity.clone());
928    let encoding = other.encoding.clone().or_else(|| self.encoding.clone());
929    let language = other.language.clone().or_else(|| self.language.clone());
930    let mut mathstyle = other.mathstyle.clone().or_else(|| self.mathstyle.clone());
931    flags |= self.flags.unwrap_or(0);
932
933    // Dynamic adjustment directives
934    if let Some(scale) = other.scale
935      && let Some(ref mut sz) = size
936    {
937      *sz *= scale;
938    }
939
940    // Scale factor for mathstyle-based sizing
941    let style_scale = if let Some(sz) = self.size {
942      let key: &str = self.mathstyle.as_deref().unwrap_or("display");
943      sz / *STYLE_SIZE.get(key).unwrap_or(&10) as f64
944    } else {
945      1.0
946    };
947
948    if other.size.is_some() {
949      // Explicitly requested size, use it
950    } else if other.mathstyle.is_some() {
951      // Set the size from mathstyle
952      let ms: &str = mathstyle.as_deref().unwrap_or("display");
953      size = Some(style_scale * *STYLE_SIZE.get(ms).unwrap_or(&10) as f64);
954    } else if other.scripted == Some(true) {
955      // Adjust both the mathstyle & size for scripts
956      let ms: &str = mathstyle.as_deref().unwrap_or("display");
957      mathstyle = SCRIPT_STYLE_MAP.get(ms).map(|c| Cow::Borrowed(*c));
958      let new_ms: &str = mathstyle.as_deref().unwrap_or("display");
959      size = Some(style_scale * *STYLE_SIZE.get(new_ms).unwrap_or(&10) as f64);
960    } else if other.fraction == Some(true) {
961      // Adjust both for fractions
962      let ms: &str = mathstyle.as_deref().unwrap_or("display");
963      mathstyle = FRAC_STYLE_MAP.get(ms).map(|c| Cow::Borrowed(*c));
964      let new_ms: &str = mathstyle.as_deref().unwrap_or("display");
965      size = Some(style_scale * *STYLE_SIZE.get(new_ms).unwrap_or(&10) as f64);
966    }
967
968    // Emphasis handling (Perl lines 909-912)
969    if other.emph == Some(true) {
970      shape = if shape.as_deref() == Some("italic") {
971        Some(Cow::Borrowed("upright"))
972      } else {
973        Some(Cow::Borrowed("italic"))
974      };
975      flags |= FLAG_EMPH;
976    }
977    // Disable emph in math (Perl: $flags &= ~$FLAG_EMPH if $mathstyle)
978    if mathstyle.is_some() {
979      flags &= !FLAG_EMPH;
980    }
981
982    let newfont = Font {
983      family,
984      series,
985      shape,
986      size,
987      color,
988      bg,
989      opacity,
990      encoding,
991      language,
992      mathstyle,
993      flags: Some(flags),
994      // Carry over fields that aren't part of Perl's merge:
995      mathstylestep: other.mathstylestep.or(self.mathstylestep),
996      name: other.name.clone().or_else(|| self.name.clone()),
997      emph: None,
998      scripted: None,
999      fraction: None,
1000      forceseries: if flags & FLAG_FORCE_SERIES != 0 {
1001        Some(true)
1002      } else {
1003        None
1004      },
1005      forcefamily: if flags & FLAG_FORCE_FAMILY != 0 {
1006        Some(true)
1007      } else {
1008        None
1009      },
1010      forceshape: if flags & FLAG_FORCE_SHAPE != 0 {
1011        Some(true)
1012      } else {
1013        None
1014      },
1015      forcebold: None,
1016      scale: None,
1017    };
1018    // Note: Perl's merge() has an optional `specialize` option that is passed
1019    // explicitly (e.g. merge(specialize => $text)). It's NOT keyed on the font name.
1020    // Specialize is called at TBox creation time (tbox.rs) with the actual text content.
1021    // Do NOT call specialize here with the font name — it corrupts font properties
1022    // (e.g. resetting series "bold" to "medium" for font names like "cmb10").
1023    newfont
1024  }
1025
1026  /// Instanciate the font for a particular class of symbols.
1027  /// NOTE: This works in `normal' latex, but probably needs some tunability.
1028  /// Depending on the fonts being used, the allowable combinations may be different.
1029  /// Getting the font right is important, since the author probably
1030  /// thinks of the identity of the symbols according to what they SEE in the printed
1031  /// document.  Even though the markup might seem to indicate something else...
1032  ///
1033  /// Use Unicode properties to determine font merging.
1034  pub fn specialize(&self, text: &str) -> Self {
1035    let mut new = self.clone();
1036    if text.is_empty() {
1037      return new; // ?
1038    }
1039    let deffamily = if self.forcefamily.unwrap_or(false) {
1040      self.family.clone().unwrap_or_else(|| DEFFAMILY.into())
1041    } else {
1042      DEFFAMILY.into()
1043    };
1044    let defseries = if self.forceseries.unwrap_or(false) {
1045      self.series.clone().unwrap_or_else(|| DEFSERIES.into())
1046    } else {
1047      DEFSERIES.into()
1048    };
1049    let defshape = if self.forceshape.unwrap_or(false) {
1050      self.shape.clone().unwrap_or_else(|| DEFSHAPE.into())
1051    } else {
1052      DEFSHAPE.into()
1053    };
1054    if LATIN_LETTER_RE.is_match(text) {
1055      // Latin Letter
1056      if new.shape.is_none() && new.family.is_none() {
1057        new.shape = Some("italic".into());
1058      }
1059    } else if GREEK_LETTER_RE.is_match(text) {
1060      // Single Greek character?
1061      if UPPER_LETTER_RE.is_match(text) {
1062        // Uppercase
1063        if new.family.is_none() || (new.family.as_ref().unwrap() == "math") {
1064          new.family = Some(deffamily);
1065          if new.shape.is_some() && (new.shape != Some(DEFSHAPE.into())) {
1066            new.shape = Some(defshape); // if ANY shape, must be default
1067          }
1068        }
1069      } else {
1070        // Lowercase
1071        if new.family.is_none() || (new.family.as_deref() != Some(DEFFAMILY)) {
1072          new.family = Some(deffamily);
1073        }
1074        // Perl: $shape = 'italic' if !$shape || !($flags & $FLAG_FORCE_SHAPE);
1075        if new.shape.is_none() || !self.forceshape.unwrap_or(false) {
1076          new.shape = Some("italic".into());
1077        }
1078        if new.series.is_some() && (new.series != Some(DEFSERIES.into())) {
1079          new.series = Some(defseries);
1080        }
1081      }
1082    } else if DIGIT_LETTER_RE.is_match(text) {
1083      // Digit
1084      if new.family.is_none() || (new.family.as_ref().unwrap() == "math") {
1085        new.family = Some(deffamily);
1086        new.shape = Some(defshape); // defaults, always.
1087      }
1088    } else {
1089      // Other Symbol
1090      new.family = Some(deffamily);
1091      new.shape = Some(defshape); // defaults, always.
1092      if new.series.is_some() && (new.series.as_ref().unwrap() != DEFSERIES) {
1093        new.series = Some(defseries);
1094      } // defaults, always.
1095    }
1096    new
1097  }
1098
1099  pub fn distance(&self, other: &Font) -> i8 {
1100    let mut distance: i8 = 0;
1101    // Normalize "math" → "serif" for comparison (Perl lines 436-437)
1102    let fam = self
1103      .family
1104      .as_deref()
1105      .map(|f| if f == "math" { "serif" } else { f });
1106    let ofam = other
1107      .family
1108      .as_deref()
1109      .map(|f| if f == "math" { "serif" } else { f });
1110    if is_diff_opt_str(fam, ofam) {
1111      distance += 1;
1112    }
1113    if is_diff_opt_str(self.series.as_deref(), other.series.as_deref()) {
1114      distance += 1;
1115    }
1116    if is_diff_opt_str(self.shape.as_deref(), other.shape.as_deref()) {
1117      distance += 1;
1118    }
1119    if is_diff_f64(self.size, other.size) {
1120      distance += 1;
1121    }
1122    // Color: use reference-style comparison (different Color variant = different).
1123    // Perl's isDiff uses object reference equality: Cmyk(0,0,0,1) ≠ Rgb(0,0,0)
1124    // even though both are visually black.
1125    if is_diff_font_color_ref(self.color.as_ref(), other.color.as_ref()) {
1126      distance += 1;
1127    }
1128    if is_diff_color(self.bg.as_ref(), other.bg.as_ref()) {
1129      distance += 1;
1130    }
1131    if is_diff_opt_str(self.opacity.as_deref(), other.opacity.as_deref()) {
1132      distance += 1;
1133    }
1134    // Perl does NOT count encoding differences
1135    // Perl does NOT count mathstyle differences
1136    if is_diff_opt_str(self.language.as_deref(), other.language.as_deref()) {
1137      distance += 1;
1138    }
1139    // Perl: ($flags & $FLAG_EMPH) ^ ($oflags & $FLAG_EMPH) ? 1 : 0
1140    let flags = self.flags.unwrap_or(0);
1141    let oflags = other.flags.unwrap_or(0);
1142    if (flags & FLAG_EMPH) ^ (oflags & FLAG_EMPH) != 0 {
1143      distance += 1;
1144    }
1145    distance
1146  }
1147
1148  /// This method compares 2 fonts, returning the differences between them.
1149  /// Returns the font attribute string (family/series/shape components that differ
1150  /// from text defaults), joined by spaces. E.g., "italic" for a math font.
1151  /// Used by cancel.sty to capture font state for XML attributes.
1152  pub fn font_attribute_string(&self) -> String {
1153    let mut parts = Vec::new();
1154    if let Some(ref fam) = self.family {
1155      let f = if fam == "math" { "serif" } else { fam.as_ref() };
1156      if f != DEFFAMILY {
1157        parts.push(f.to_string());
1158      }
1159    }
1160    if let Some(ref ser) = self.series
1161      && ser.as_ref() != DEFSERIES
1162    {
1163      parts.push(ser.to_string());
1164    }
1165    if let Some(ref shp) = self.shape
1166      && shp.as_ref() != DEFSHAPE
1167    {
1168      parts.push(shp.to_string());
1169    }
1170    parts.join(" ")
1171  }
1172
1173  /// Noting that the font-related attributes in the schema distill the
1174  /// font properties into fewer attributes (font,fontsize,color,background,opacity),
1175  /// the return value encodes both the attribute changes that would be needed to effect
1176  /// the font change, along with the font properties that differed
1177  /// Namely, the result is a hash keyed on the attribute name and whose value is a FontDiff
1178  ///    value      => "new_attribute_value"
1179  ///    properties => { %fontproperties }
1180  /// or (String, Font)
1181  pub fn relative_to(&self, other: &Font) -> HashMap<String, (String, Font)> {
1182    let family = match self.family {
1183      Some(ref fam) => {
1184        if fam == "math" {
1185          Some(Cow::Borrowed("serif"))
1186        } else {
1187          Some(fam.clone())
1188        }
1189      },
1190      None => None,
1191    };
1192    let other_family = match other.family {
1193      Some(ref fam) => {
1194        if fam == "math" {
1195          Some(Cow::Borrowed("serif"))
1196        } else {
1197          Some(fam.clone())
1198        }
1199      },
1200      None => None,
1201    };
1202    let mut diffs = vec![];
1203    let mut font_properties = Font::default();
1204    if is_diff(family.as_ref(), other_family.as_ref()) {
1205      diffs.push(family.clone().unwrap());
1206      font_properties.family = family;
1207    }
1208    if is_diff(self.series.as_ref(), other.series.as_ref()) {
1209      let series = self.series.clone().unwrap();
1210      diffs.push(series);
1211      font_properties.series.clone_from(&self.series);
1212    }
1213    if is_diff(self.shape.as_ref(), other.shape.as_ref()) {
1214      let shape = self.shape.clone().unwrap();
1215      diffs.push(shape);
1216      font_properties.shape.clone_from(&self.shape);
1217    }
1218    let mut result = HashMap::default();
1219
1220    if !diffs.is_empty() {
1221      let font_value = diffs.join(" ");
1222      result.insert(s!("font"), (font_value, font_properties));
1223    }
1224
1225    if is_diff_f64(self.size.as_ref().copied(), other.size.as_ref().copied()) {
1226      result.insert(
1227        "fontsize".to_string(),
1228        (
1229          relative_font_size(self.size.unwrap(), other.size.unwrap()),
1230          Font {
1231            size: self.size,
1232            ..Font::default()
1233          },
1234        ),
1235      );
1236    }
1237    // Emit color when Color variants differ (reference-style comparison).
1238    // Perl's `ne` treats Cmyk(0,0,0,1) ≠ Rgb(0,0,0) even though both are black.
1239    if is_diff_font_color_ref(self.color.as_ref(), other.color.as_ref()) {
1240      let effective_color = self.color.unwrap_or(DEFCOLOR);
1241      result.insert(
1242        "color".to_string(),
1243        (effective_color.to_attribute(), Font {
1244          color: Some(effective_color),
1245          ..Font::default()
1246        }),
1247      );
1248    }
1249    if is_diff_color(self.bg.as_ref(), other.bg.as_ref()) {
1250      result.insert(
1251        "backgroundcolor".to_string(),
1252        (self.bg.as_ref().unwrap().to_attribute(), Font {
1253          bg: self.bg,
1254          ..Font::default()
1255        }),
1256      );
1257    }
1258    if is_diff(self.opacity.as_ref(), other.opacity.as_ref()) {
1259      result.insert(
1260        "opacity".to_string(),
1261        (self.opacity.as_ref().unwrap().to_string(), Font {
1262          opacity: self.opacity.clone(),
1263          ..Font::default()
1264        }),
1265      );
1266    }
1267    // #638: the font `encoding` is a FontMap-lookup key consumed DURING digestion
1268    // (unicode decoding), not a presentation property — it is meaningless as an
1269    // output attribute and no ltx element even declares it, so Perl emits no
1270    // `@encoding` anywhere. Emitting it here only ever surfaced (spuriously) on a
1271    // foreign `xhtml:*` element whose `attribute *` wildcard accepts anything.
1272    // Omit it from the relativized attribute set entirely (Perl `Font.pm` keeps
1273    // the analogous line, but the value never reaches real output there).
1274    if is_diff(self.language.as_ref(), other.language.as_ref()) {
1275      result.insert(
1276        "xml:lang".to_string(),
1277        (self.language.as_ref().unwrap().to_string(), Font {
1278          language: self.language.clone(),
1279          ..Font::default()
1280        }),
1281      );
1282    }
1283    // Emph: (!$mstyle && $flags && ($flags & $FLAG_EMPH) && (!$oflags || !($oflags & $FLAG_EMPH))
1284    let flags = self.flags.unwrap_or(0);
1285    let oflags = other.flags.unwrap_or(0);
1286    if self.mathstyle.is_none()
1287      && flags != 0
1288      && (flags & FLAG_EMPH) != 0
1289      && (oflags == 0 || (oflags & FLAG_EMPH) == 0)
1290    {
1291      result.insert(
1292        "element".to_string(),
1293        ("ltx:emph".to_string(), Font::default()),
1294      );
1295    }
1296    // We do NOT want mathstyle showing up automatically in the attributes
1297    result
1298  }
1299
1300  pub fn purestyle_changes(&self, other: &Font) -> Font {
1301    let mathstyle = self.get_mathstyle();
1302    let othermathstyle = other.get_mathstyle();
1303    let othercolor = other.get_color();
1304    let mut changes = Font {
1305      scale: Some(other.get_size().unwrap() / self.get_size().unwrap()),
1306      bg: other.bg,
1307      opacity: other.opacity.clone(), // should multiply or replace?
1308      ..Font::default()
1309    };
1310    if is_diff_font_color(othercolor, Some(&DEFCOLOR)) {
1311      changes.color = Some(othercolor.copied().unwrap_or(DEFCOLOR));
1312    }
1313
1314    if let Some(ms) = mathstyle
1315      && let Some(os) = othermathstyle
1316    {
1317      let ms_str: &str = ms;
1318      let os_str: &str = os;
1319      changes.mathstylestep = Some(*MATH_STYLE_STEP.get(ms_str).unwrap().get(os_str).unwrap());
1320    }
1321    changes
1322  }
1323
1324  /// Find a Font Metric corresponding to this font's family_series_shape_size
1325  /// that contains the given `char`, if given.
1326  /// Try to find a fallback metric if `char` is not in the current Font.
1327  /// Perl: getMetric
1328  pub fn get_metric(&self, c_opt: Option<char>) -> &MetricData {
1329    let family = self.family.as_deref().unwrap_or("serif");
1330    let series = self.series.as_deref().unwrap_or("medium");
1331    let shape = self.shape.as_deref().unwrap_or("upright");
1332    let size = self.size.unwrap_or_else(defsize) as i64;
1333    // Stack buffer for char→&str lookup key, reused across paths. Avoids
1334    // one String allocation per character per get_metric call (which is
1335    // called per-character inside compute_string_size).
1336    let mut ch_buf = [0u8; 4];
1337    let ch_key = c_opt.map(|c| c.encode_utf8(&mut ch_buf) as &str);
1338    if let Some(name) = lookup_metric_name(family, series, shape) {
1339      let fullname = format!("{name}{size}");
1340      if let Some(metric) = STDMETRICS.get(fullname.as_str())
1341        && ch_key.is_none_or(|k| metric.sizes.contains_key(k))
1342      {
1343        return metric;
1344      }
1345      // Try base name fallback
1346      let metric = get_metric_for_name(name);
1347      if ch_key.is_none_or(|k| metric.sizes.contains_key(k)) {
1348        return metric;
1349      }
1350    }
1351    // Look for a fallback metric if char given
1352    if let Some(k) = ch_key {
1353      for name in METRIC_FALLBACKS {
1354        let fullname = format!("{name}{size}");
1355        let metric = STDMETRICS
1356          .get(fullname.as_str())
1357          .unwrap_or_else(|| get_metric_for_name(name));
1358        if metric.sizes.contains_key(k) {
1359          return metric;
1360        }
1361      }
1362    }
1363    get_metric_for_name("cmr")
1364  }
1365
1366  pub fn get_em_width(&self) -> i64 {
1367    let size = self.get_size().unwrap_or_else(defsize);
1368    let m = self.get_metric(None);
1369    (size * m.emwidth).trunc() as i64
1370  }
1371  pub fn get_ex_height(&self) -> i64 {
1372    let size = self.get_size().unwrap_or_else(defsize);
1373    let m = self.get_metric(None);
1374    (size * m.exheight).trunc() as i64
1375  }
1376  pub fn get_mu_width(&self) -> i64 {
1377    let size = self.get_size().unwrap_or_else(defsize);
1378    let m = self.get_metric(None);
1379    (size * m.emwidth / 18.0).trunc() as i64
1380  }
1381
1382  pub fn compute_string_size(
1383    &self,
1384    text: &str,
1385    _options: SymHashMap<Stored>,
1386  ) -> (Dimension, Dimension, Dimension) {
1387    if text.is_empty()
1388      || self
1389        .get_family()
1390        .map(|fam| fam == "nullfont")
1391        .unwrap_or(false)
1392    {
1393      return (
1394        Dimension::default(),
1395        Dimension::default(),
1396        Dimension::default(),
1397      );
1398    }
1399    let size = self.get_size().unwrap_or_else(defsize);
1400    let ismath = self.get_family().map(|fam| fam == "math").unwrap_or(false);
1401    let (mut w, mut h, mut d) = (0, 0, 0);
1402    // Iterate via Peekable — no intermediate Vec<char> allocation,
1403    // and we get O(1) lookahead for kerning between consecutive chars.
1404    let mut chars_iter = text.chars().peekable();
1405    // Stack buffers for char→&str and char+char→&str lookups, avoiding
1406    // String::to_string() + String::format!() heap allocations inside
1407    // the per-character hot loop. encode_utf8 writes directly to the
1408    // buffer and returns a borrowed &str slice — no allocation.
1409    let mut ch_buf = [0u8; 4];
1410    let mut kern_buf = [0u8; 8];
1411    while let Some(ch) = chars_iter.next() {
1412      let metric = self.get_metric(Some(ch));
1413      let ch_key = ch.encode_utf8(&mut ch_buf);
1414      let entry_opt = metric.sizes.get(ch_key);
1415      let (cw, ch_sz, cd, ci) = if let Some(entry) = entry_opt {
1416        *entry
1417      } else {
1418        (0.75 * UNITY_F64, 0.7 * UNITY_F64, 0.2 * UNITY_F64, 0.0)
1419      };
1420      w += (cw * size).trunc() as i64;
1421      // Kerning: check kern between this char and next.
1422      if let Some(&next_ch) = chars_iter.peek() {
1423        let first_len = ch.encode_utf8(&mut kern_buf).len();
1424        let second_len = next_ch.encode_utf8(&mut kern_buf[first_len..]).len();
1425        let kern_key = std::str::from_utf8(&kern_buf[..first_len + second_len]).unwrap();
1426        if let Some(kern) = metric.kerns.get(kern_key) {
1427          w += (size * kern).trunc() as i64;
1428        }
1429      }
1430      // Italic correction in math
1431      if ismath && ci != 0.0 {
1432        w += (size * ci).trunc() as i64;
1433      }
1434      h = max(h, (ch_sz * size).trunc() as i64);
1435      d = max(d, (cd * size).trunc() as i64);
1436    }
1437    // The 1 is so that any actual glyph appears to be non-empty.
1438    // This is presumably only necessary to deal with the flawed emptiness heiristics in Alignment?
1439    if w == 0 {
1440      w = 1;
1441    }
1442    (Dimension::new(w), Dimension::new(h), Dimension::new(d))
1443  }
1444
1445  /// Get nominal width, height base ?
1446  /// Probably should be using data from FontMetric ???
1447  pub fn get_nominal_size(&self) -> (Dimension, Dimension, Dimension) {
1448    let size = self.get_size().unwrap_or_else(defsize);
1449    let u = size * UNITY_F64;
1450    (
1451      Dimension::new_f64(0.75 * u),
1452      Dimension::new_f64(0.7 * u),
1453      Dimension::new_f64(0.2 * u),
1454    )
1455  }
1456
1457  // Here's where I avoid trying to emulate Knuth's line-breaking...
1458  // Mostly for List & Whatsit: compute the size of a list of boxes.
1459  // Options _SHOULD_ include:
1460  //   width:  if given, pretend to simulate line breaking to that width
1461  //   height,depth : ?
1462  //   vattach : top, bottom, center, baseline (...?) affects how the height & depth are
1463  //      allocated when there are multiple lines.
1464  //   layout : horizontal or vertical !!!
1465  // Boxes that arent a Core Box, List, Whatsit or a string are IGNORED
1466  //
1467  // The big problem with width is to have it propogate down from where
1468  // it may have been specified to the actual nested box that will get wrapped!
1469  // Try to mask this (temporarily) by unlisting, and (pretending to ) breaking up too wide items
1470  //
1471  // Another issue; SVG needs (sometimes) real sizes, even if the programmer
1472  // set some dimensions to 0 (eg.)   We may need to distinguish & store
1473  // requested vs real sizes?
1474  /// Perl: Font.pm sub computeBoxesSize (L635-680)
1475  /// Compute the size of a List of boxes, dispatching to helpers based on layout mode.
1476  pub fn compute_boxes_size(
1477    &self,
1478    boxes: &[Digested],
1479    options: SymHashMap<Stored>,
1480  ) -> Result<(Dimension, Dimension, Dimension)> {
1481    // Perl L646-647: `elsif ($ref =~ /^LaTeXML::Core::(?:Box|Whatsit|Alignment)$/) {
1482    // return $boxes->getSize; }` — a single bare Box/Whatsit/Alignment (NOT a
1483    // List) returns its getSize directly, short-circuiting ahead of the
1484    // List/split_words logic. This is essential for an isSpace box carrying an
1485    // explicit height/depth (e.g. `\phantom`'s XMHint): split_words keeps only
1486    // its width as inter-word space and discards height/depth, but getSize honors
1487    // the full width/height/depth. In Perl the dispatch is on the type of the
1488    // measured object (a bare-box body, not a List); a single-element slice here
1489    // is the faithful analogue (a List-of-one would still be a List in Perl).
1490    if let [single] = boxes
1491      && !matches!(single.data(), DigestedData::List(_))
1492    {
1493      let mut bx_clone = single.clone();
1494      let (w, h, d, ..) = bx_clone.get_size(None)?;
1495      return Ok((w, h, d));
1496    }
1497    // Perl L638: my $mode = $boxes->getProperty('mode') || 'restricted_horizontal';
1498    let mode_str = match options.get("mode") {
1499      Some(Stored::String(s)) => arena::with(*s, |s| s.to_string()),
1500      _ => "restricted_horizontal".to_string(),
1501    };
1502    // Perl: $vattach = $boxes->getProperty('vattach') || $options{vattach} || 'baseline'
1503    let vattach = match options.get("vattach") {
1504      Some(Stored::String(s)) => arena::with(*s, |s| s.to_string()),
1505      _ => "baseline".to_string(),
1506    };
1507    // Perl #2798: `elsif (my $width = ($mode =~ /horizontal$/) && $boxes->getProperty('width'))`
1508    // — a horizontal list is formatted as a paragraph IFF an explicit width is
1509    // supplied (recorded by S4's repack_horizontal). NO `\hsize` fallback: a
1510    // horizontal List without a width property is restricted_horizontal (a single
1511    // line, no wrapping), matching Perl.
1512    let para_width: Option<i64> = if mode_str.ends_with("horizontal") {
1513      match options.get("width") {
1514        Some(Stored::Dimension(d)) => Some(d.value_of()),
1515        Some(Stored::Int(i)) => Some(*i),
1516        _ => None,
1517      }
1518    } else {
1519      None
1520    };
1521    // Perl #2798: baseline (sp) — from List property (S4) / option, default 12pt.
1522    let baseline: i64 = match options.get("baseline") {
1523      Some(Stored::Dimension(d)) => d.value_of(),
1524      Some(Stored::Int(i)) => *i,
1525      _ => 12 * UNITY,
1526    };
1527    let mut maxwidth: i64 = 0;
1528    // Perl #2798: lines are now [baseline, wd, ht, dp] (per-line baseline; -1 = no
1529    // inter-line adjustment, e.g. \vskip / \hrule).
1530    let mut lines: Vec<[i64; 4]> = Vec::new();
1531    if mode_str.ends_with("vertical") {
1532      // Perl: For vertical, ALL boxes are lines. (Rust makes the box-list match
1533      // Perl's structure BEFORE this point: a text-mode `{...}` group that
1534      // breaks a paragraph repacks the outer paragraph at digestion — see
1535      // `tex_box.rs` `{` primitive R2 / OXIDIZED_DESIGN #100 — so no run of loose
1536      // characters ever reaches here to be mis-counted one-line-per-glyph.)
1537      for bx in boxes {
1538        if bx.has_property("isEmpty") {
1539          continue;
1540        }
1541        // Perl: a horizontal sub-List WITH a width is formatted as a paragraph.
1542        if matches!(bx.data(), DigestedData::List(_))
1543          && bx
1544            .get_property("mode")
1545            .map(|v| v.to_string())
1546            .unwrap_or_default()
1547            == "horizontal"
1548          && let Some(w) = bx.get_property("width").and_then(|v| match &*v {
1549            Stored::Dimension(d) => Some(d.value_of()),
1550            Stored::Int(i) => Some(*i),
1551            _ => None,
1552          })
1553        {
1554          if w > maxwidth {
1555            maxwidth = w;
1556          }
1557          let sub_baseline = bx
1558            .get_property("baseline")
1559            .and_then(|v| match &*v {
1560              Stored::Dimension(d) => Some(d.value_of()),
1561              Stored::Int(i) => Some(*i),
1562              _ => None,
1563            })
1564            .unwrap_or(baseline);
1565          lines.extend(self.linebreak_paragraph(&bx.unlist(), w, sub_baseline)?);
1566          continue;
1567        }
1568        // Perl: single box → one line, with baseline (or -1 for vskip/rule).
1569        // DIVERGENCE from Perl #2798 (upstream candidate): Perl folds vskips
1570        // and rules into one `-1` flag and RESETS prevdepth for both, so any
1571        // glue item between lines silently disables \baselineskip accounting
1572        // — a stack of N verbatim lines interleaved with fancyvrb's interline
1573        // vspace measures as Σ(h+d) ≈ N×6pt instead of N×\baselineskip
1574        // (witness 2605.00468: 49-line Prompt boxes budgeted at half their
1575        // TeX height; content spilled through every following box). TeX truth
1576        // (tex.web vpack): \prevdepth is TRANSPARENT to glue — only a BOX
1577        // updates it (to its depth), and only \hrule disables it (sentinel
1578        // \prevdepth = -1000pt). Encode vskip as -1 (transparent) and rule
1579        // as -2 (reset) so the stack can honor both.
1580        let (w, h, d) = self.compute_boxes_size_box(bx)?;
1581        let bs = if bx.get_property_bool("isHorizontalRule") {
1582          -2
1583        } else if bx.get_property_bool("isVerticalSpace") {
1584          -1
1585        } else {
1586          baseline
1587        };
1588        if w != 0 || h != 0 || d != 0 {
1589          lines.push([bs, w, h, d]);
1590        }
1591      }
1592    } else if let Some(w) = para_width {
1593      // Perl: proper paragraph — flatten, split into words, break into lines.
1594      if w > maxwidth {
1595        maxwidth = w;
1596      }
1597      let flat: Vec<&Digested> = boxes
1598        .iter()
1599        .filter(|b| !b.has_property("isEmpty"))
1600        .collect();
1601      let flat_owned: Vec<Digested> = flat.into_iter().cloned().collect();
1602      lines = self.linebreak_paragraph(&flat_owned, w, baseline)?;
1603    } else {
1604      // Perl: restricted_horizontal or math — one line, no wrapping.
1605      let filtered: Vec<&Digested> = boxes
1606        .iter()
1607        .filter(|b| !b.has_property("isEmpty"))
1608        .collect();
1609      let words = self.compute_boxes_size_words(&filtered)?;
1610      lines = Self::compute_boxes_size_lines(None, baseline, &words);
1611    }
1612    // Perl: stack up the multiple lines; mathaxis = size/4.
1613    let size = self.get_size().unwrap_or_else(defsize) as i64;
1614    let mathaxis = size * UNITY / 4;
1615    static SIZE_TRACE: std::sync::LazyLock<bool> =
1616      std::sync::LazyLock::new(|| std::env::var("LXML_SIZE_TRACE").is_ok());
1617    if *SIZE_TRACE {
1618      eprintln!(
1619        "SIZE mode={mode_str} vattach={vattach} baseline={} nboxes={} lines={:?}",
1620        baseline as f64 / 65536.0,
1621        boxes.len(),
1622        lines
1623          .iter()
1624          .map(|l| [
1625            l[0] as f64 / 65536.0,
1626            l[1] as f64 / 65536.0,
1627            l[2] as f64 / 65536.0,
1628            l[3] as f64 / 65536.0
1629          ])
1630          .collect::<Vec<_>>()
1631      );
1632    }
1633    let (mut wd, mut ht, mut dp) = Self::compute_boxes_size_stack(&vattach, mathaxis, &lines);
1634    // Perl: $wd = $maxwidth if $wd && $maxwidth (set to fill width, unless empty).
1635    if wd != 0 && maxwidth != 0 {
1636      wd = maxwidth;
1637    }
1638    // Perl: divide up totalheight, if requested.
1639    if let Some(th) = options.get("totalheight").and_then(|v| match v {
1640      Stored::Dimension(d) => Some(d.value_of()),
1641      Stored::Int(i) => Some(*i),
1642      _ => None,
1643    }) {
1644      let diff = th - ht - dp;
1645      if diff > 0 {
1646        match vattach.as_str() {
1647          "bottom" => ht += diff,
1648          "middle" => {
1649            ht += diff / 2;
1650            dp += diff / 2;
1651          },
1652          _ => dp += diff,
1653        }
1654      }
1655    }
1656    Ok((Dimension::new(wd), Dimension::new(ht), Dimension::new(dp)))
1657  }
1658
1659  /// Perl #2798: linebreak_paragraph — format a horizontal list (with width) as
1660  /// a paragraph: flatten nested horizontal Lists + sizing-flattenable Whatsits,
1661  /// split into words, then break into lines. Returns [baseline, wd, ht, dp] lines.
1662  fn linebreak_paragraph(
1663    &self,
1664    boxes: &[Digested],
1665    width: i64,
1666    baseline: i64,
1667  ) -> Result<Vec<[i64; 4]>> {
1668    let flat = Self::flatten_paragraph(boxes);
1669    let flat_refs: Vec<&Digested> = flat.iter().collect();
1670    let words = self.compute_boxes_size_words(&flat_refs)?;
1671    Ok(Self::compute_boxes_size_lines(
1672      Some(width),
1673      baseline,
1674      &words,
1675    ))
1676  }
1677
1678  /// Perl #2798: flatten_paragraph — open up contained horizontal Lists, and any
1679  /// Whatsits that format AS IF embedded paragraph material (e.g. `\emph`), so
1680  /// they participate in line-breaking.
1681  fn flatten_paragraph(boxes: &[Digested]) -> Vec<Digested> {
1682    let mut queue: std::collections::VecDeque<Digested> = boxes.iter().cloned().collect();
1683    let mut out: Vec<Digested> = Vec::new();
1684    while let Some(bx) = queue.pop_front() {
1685      if matches!(bx.data(), DigestedData::List(_))
1686        && bx
1687          .get_property("mode")
1688          .map(|v| v.to_string())
1689          .unwrap_or_default()
1690          == "horizontal"
1691      {
1692        for ib in bx.unlist().into_iter().rev() {
1693          queue.push_front(ib);
1694        }
1695      } else if matches!(bx.data(), DigestedData::Whatsit(_))
1696        && let Some(repl) = flatten_for_sizing(&bx)
1697      {
1698        for ib in repl.into_iter().rev() {
1699          queue.push_front(ib);
1700        }
1701      } else {
1702        out.push(bx);
1703      }
1704    }
1705    out
1706  }
1707
1708  /// Perl: Font.pm sub computeBoxesSize_box (L683-702)
1709  /// Compute the size of a single box, returning (w, h, d) in sp.
1710  fn compute_boxes_size_box(&self, bx: &Digested) -> Result<(i64, i64, i64)> {
1711    // Clone to avoid caching side effects on the original box
1712    let mut bx_clone = bx.clone();
1713    let (w, h, d, ..) = bx_clone.get_size(None)?;
1714    Ok((w.value_of(), h.value_of(), d.value_of()))
1715  }
1716
1717  /// Perl: Font.pm sub computeBoxesSize_words (L705-746)
1718  /// Compute a list of sizes of space-delimited "words" within a NON-vertical list.
1719  /// Returns Vec of [prevspace, wd, ht, dp] where prevspace=-1 means line break.
1720  fn compute_boxes_size_words(&self, boxes: &[&Digested]) -> Result<Vec<[f64; 4]>> {
1721    let mut words: Vec<[f64; 4]> = Vec::new();
1722    let mut prevbox: Option<&Digested> = None;
1723    let mut prevspace: f64 = 0.0;
1724    // Perl L711: my $size = int($self->getSize || DEFSIZE() || 10);
1725    let size = self.get_size().unwrap_or_else(defsize) as i64;
1726    let (mut wd, mut ht, mut dp): (f64, i64, i64) = (0.0, 0, 0);
1727    for bx in boxes {
1728      let (w, h, d) = self.compute_boxes_size_box(bx)?;
1729      // Perl L716-721: Check for possible line-break points
1730      if bx.get_property_bool("isBreak") {
1731        // Perl: vertical space (isBreak + isVerticalSpace) contributes height
1732        // even though it acts as a line break. Include its h/d in the word
1733        // so alignment row spacing accounts for \noalign{\vskip X}.
1734        if bx.get_property_bool("isVerticalSpace") {
1735          ht = max(ht, h);
1736          dp = max(dp, d);
1737        }
1738        if wd != 0.0 || ht != 0 || dp != 0 || prevspace > 0.0 {
1739          words.push([prevspace, wd, ht as f64, dp as f64]);
1740          wd = 0.0;
1741          ht = 0;
1742          dp = 0;
1743          prevspace = -1.0;
1744        } else {
1745          prevspace = -1.0;
1746        }
1747      }
1748      // Perl L723-728: isSpace (but not isVerticalSpace) — word boundary
1749      else if bx.get_property_bool("isSpace") && !bx.get_property_bool("isVerticalSpace") {
1750        if wd != 0.0 || ht != 0 || dp != 0 || prevspace < 0.0 {
1751          words.push([prevspace, wd, ht as f64, dp as f64]);
1752          wd = 0.0;
1753          ht = 0;
1754          dp = 0;
1755          prevspace = w as f64;
1756        } else {
1757          prevspace += w as f64;
1758        }
1759      }
1760      // Perl #2798: an ideographic (CJK) char is itself a word.
1761      else if bx.get_property_bool("isIdeographic") {
1762        if wd != 0.0 {
1763          words.push([prevspace, wd, ht as f64, dp as f64]);
1764        }
1765        words.push([0.0, w as f64, h as f64, d as f64]);
1766        wd = 0.0;
1767        ht = 0;
1768        dp = 0;
1769        prevspace = 0.0;
1770      }
1771      // Perl L729-741: Else accumulate into "word"
1772      else {
1773        wd += w as f64;
1774        ht = max(ht, h);
1775        dp = max(dp, d);
1776        // Perl L734-741: Kern HACK for lists of individual Box's
1777        if let Some(pb) = prevbox
1778          && matches!(pb.data(), DigestedData::TBox(_))
1779          && matches!(bx.data(), DigestedData::TBox(_))
1780        {
1781          let prevchar = pb.get_string()?.chars().last();
1782          let curchar = bx.get_string()?.chars().next();
1783          let metric = self.get_metric(curchar);
1784          // Perl L738-739: math bearing
1785          if let Some(family) = self.get_family()
1786            && family == "math"
1787          {
1788            wd += self.math_bearing(bx, pb);
1789          }
1790          // Perl L740-741: kerning
1791          if let Some(prevc) = prevchar
1792            && let Some(curc) = curchar
1793          {
1794            let kern_key = String::from(prevc) + &String::from(curc);
1795            if let Some(kern) = metric.kerns.get(kern_key.as_str()) {
1796              wd += size as f64 * kern;
1797            }
1798          }
1799        }
1800      }
1801      // Perl L743: $prevbox = $box
1802      prevbox = Some(bx);
1803    }
1804    // Perl L744-745: be sure to get last bit
1805    if wd != 0.0 || ht != 0 || dp != 0 || prevspace != 0.0 {
1806      words.push([prevspace, wd, ht as f64, dp as f64]);
1807    }
1808    Ok(words)
1809  }
1810
1811  /// Perl #2798: collect_lines — break words into lines per `wrapwidth` (if any)
1812  /// or explicit breaks. Each line is `[baseline, wd, ht, dp]`; the per-line
1813  /// `baseline` drives inter-line spacing in `compute_boxes_size_stack`.
1814  fn compute_boxes_size_lines(
1815    wrapwidth: Option<i64>,
1816    baseline: i64,
1817    words: &[[f64; 4]],
1818  ) -> Vec<[i64; 4]> {
1819    let mut lines: Vec<[i64; 4]> = Vec::new();
1820    let fuzz = UNITY as f64; // 1pt
1821    let (mut wd, mut ht, mut dp): (f64, i64, i64) = (0.0, 0, 0);
1822    for item in words {
1823      let (space, w, h, d) = (item[0], item[1], item[2] as i64, item[3] as i64);
1824      // Forced linebreak (space == -1) or wrapped linebreak.
1825      if space == -1.0 || wrapwidth.is_some_and(|ww| wd + space * 0.5 + w > ww as f64 + fuzz) {
1826        if wd != 0.0 {
1827          lines.push([baseline, kround(wd), ht, dp]);
1828        }
1829        wd = w;
1830        ht = h;
1831        dp = d;
1832      } else {
1833        wd += space + w;
1834        ht = max(ht, h);
1835        dp = max(dp, d);
1836      }
1837    }
1838    if wd != 0.0 || ht != 0 || dp != 0 {
1839      lines.push([baseline, kround(wd), ht, dp]);
1840    }
1841    lines
1842  }
1843
1844  /// Perl #2798: stack_lines — sum a stack of `[baseline, wd, ht, dp]` lines:
1845  /// `wd` is the max, inter-line spacing uses each line's `baseline` (`bs < 0` =
1846  /// no adjustment), and `ht`/`dp` are split per `vattach` (`mathaxis` = size/4).
1847  fn compute_boxes_size_stack(vattach: &str, mathaxis: i64, lines: &[[i64; 4]]) -> (i64, i64, i64) {
1848    let nlines = lines.len();
1849    if nlines == 0 {
1850      return (0, 0, 0);
1851    }
1852    if nlines == 1 {
1853      let [_bs, w, h, d] = lines[0];
1854      return (w, h, d);
1855    }
1856    // Perl: $lineskip = lookupDefinition('\lineskip')->valueOf->valueOf
1857    let lineskip = lookup_definition(&T_CS!("\\lineskip"))
1858      .ok()
1859      .flatten()
1860      .and_then(|def| def.value_of(Vec::new()))
1861      .map(|v| v.value_of())
1862      .unwrap_or(0);
1863    let mut wd: i64 = 0;
1864    let mut prevdepth: i64 = -99999;
1865    let mut th: i64 = 0;
1866    for line in lines {
1867      let [bs, w, h, d] = *line;
1868      wd = max(w, wd);
1869      th += h + d;
1870      if prevdepth >= 0 && bs >= 0 {
1871        if prevdepth + h < bs {
1872          th += bs - prevdepth - h;
1873        } else {
1874          th += lineskip;
1875        }
1876      }
1877      // TeX vpack \prevdepth discipline (divergence from Perl #2798 — see
1878      // the vertical branch above): boxes set prevdepth to their depth;
1879      // glue (bs == -1) is TRANSPARENT (prevdepth unchanged, so the next
1880      // box still receives \baselineskip accounting across the skip);
1881      // rules (bs == -2) disable it (TeX's \prevdepth = -1000pt sentinel).
1882      prevdepth = if bs >= 0 {
1883        d
1884      } else if bs == -1 {
1885        prevdepth
1886      } else {
1887        -99999
1888      };
1889    }
1890    let (ht, dp) = match vattach {
1891      "middle" => (th / 2 + mathaxis, th / 2 - mathaxis),
1892      "bottom" => {
1893        let d = lines[nlines - 1][3];
1894        (th - d, d)
1895      },
1896      // else (baseline / top): align to baseline of top row.
1897      _ => {
1898        let h = lines[0][2];
1899        (h, th - h)
1900      },
1901    };
1902    (wd, ht, dp)
1903  }
1904}
1905
1906/// Perl #2798: `Whatsit->flattenForSizing` — a horizontal Whatsit whose sizer is
1907/// a pure `#arg`/`#prop` reference can be flattened so its content participates
1908/// in paragraph line-breaking (e.g. `\emph`). STUB: returns `None` (no
1909/// flattening) for now — refine to parse the sizer spec. None of the current
1910/// sizing fixtures depend on this; only `\emph`-style line-wrapping differs.
1911fn flatten_for_sizing(_w: &Digested) -> Option<Vec<Digested>> { None }
1912
1913fn is_diff(x: Option<&Cow<str>>, y: Option<&Cow<str>>) -> bool {
1914  x.is_some() && (y.is_none() || (x != y))
1915}
1916
1917fn is_diff_opt_str(x: Option<&str>, y: Option<&str>) -> bool {
1918  x.is_some() && (y.is_none() || (x != y))
1919}
1920
1921fn is_diff_f64(x: Option<f64>, y: Option<f64>) -> bool { x.is_some() && (y.is_none() || (x != y)) }
1922
1923fn is_diff_color(x: Option<&Color>, y: Option<&Color>) -> bool {
1924  x.is_some() && (y.is_none() || (x != y))
1925}
1926
1927/// Like is_diff_color but treats None as DEFCOLOR (for the `color` field).
1928/// Visual comparison: Gray(0) == Rgb(0,0,0) since both are black.
1929fn is_diff_font_color(x: Option<&Color>, y: Option<&Color>) -> bool {
1930  let cx = x.unwrap_or(&DEFCOLOR);
1931  let cy = y.unwrap_or(&DEFCOLOR);
1932  if cx == cy {
1933    return false;
1934  }
1935  cx.to_rgb() != cy.to_rgb()
1936}
1937
1938/// Reference-style comparison for color field: treats None as DEFCOLOR.
1939/// Unlike is_diff_font_color, does NOT fall back to visual to_rgb() comparison.
1940/// Cmyk(0,0,0,1) IS different from Rgb(0,0,0) even though both are visually black.
1941/// This matches Perl's `ne` reference equality: two Color objects at different
1942/// addresses are "different" even if they represent the same visual color.
1943/// In our model, different Color variants = different Perl references.
1944fn is_diff_font_color_ref(x: Option<&Color>, y: Option<&Color>) -> bool {
1945  let cx = x.unwrap_or(&DEFCOLOR);
1946  let cy = y.unwrap_or(&DEFCOLOR);
1947  cx != cy
1948}
1949
1950/// Matches fonts when both are converted to toString strings.
1951/// Uses regex caching for repeated lookups.
1952/// Perl: match_font
1953pub fn match_font(font1: &str, font2: &str) -> bool {
1954  // Build a regex from font1 where '*' components become wildcards
1955  if let Some(inner) = font1
1956    .strip_prefix("Font[")
1957    .and_then(|s| s.strip_suffix(']'))
1958  {
1959    let comps: Vec<&str> = inner.split(',').collect();
1960    let re_str = format!(
1961      "^Font\\[{}\\]$",
1962      comps
1963        .iter()
1964        .map(|c| if *c == "*" {
1965          "[^,]+".to_string()
1966        } else {
1967          regex::escape(c)
1968        })
1969        .collect::<Vec<_>>()
1970        .join(",")
1971    );
1972    if let Ok(re) = Regex::new(&re_str) {
1973      return re.is_match(font2);
1974    }
1975  }
1976  false
1977}
1978
1979/// Generate XPath fragments for font matching.
1980/// Perl: font_match_xpaths
1981pub fn font_match_xpaths(font: &str) -> String {
1982  if let Some(inner) = font.strip_prefix("Font[").and_then(|s| s.strip_suffix(']')) {
1983    let comps: Vec<&str> = inner.split(',').collect();
1984    // Only check family, series, shape (indices 0, 1, 2)
1985    let mut frags: Vec<String> = Vec::new();
1986    if !comps.is_empty() && comps[0] != "*" {
1987      frags.push(format!("[{},", comps[0]));
1988    }
1989    if comps.len() > 1 && comps[1] != "*" {
1990      frags.push(format!(",{},", comps[1]));
1991    }
1992    if comps.len() > 2 && comps[2] != "*" {
1993      frags.push(format!(",{},", comps[2]));
1994    }
1995    let mut parts: Vec<String> = vec!["@_font".to_string()];
1996    for frag in frags {
1997      parts.push(format!("contains(@_font,'{frag}')"));
1998    }
1999    parts.join(" and ")
2000  } else {
2001    "@_font".to_string()
2002  }
2003}
2004
2005/// Decode a codepoint using the fontmap for a given font and/or fontencoding.
2006///
2007/// If `encoding` not provided, then lookup according to the current font's
2008/// encoding; the font family may also be used to choose the fontmap (think tt fonts!).
2009/// When `implicit` is false, we are "explicitly" asking for a decoding, such as
2010/// with \char, \mathchar, \symbol, DeclareTextSymbol and such cases.
2011/// In such cases, only codepoints specifically within the map are covered; the rest are undef.
2012/// If `implicit` is true, we'll decode token content that has made it to the stomach:
2013/// We're going to assume that SOME sort of handling of input encoding is taking place,
2014/// so that if anything above 128 comes in, it must already be Unicode!.
2015/// The lower half plane still needs to go through decoding, though, to deal
2016/// with TeX's rearrangement of ASCII...
2017/// Push a fontmap character to a string, handling known multi-char entries.
2018fn push_fontmap_char(result: &mut String, c: char, _code: u8) {
2019  // T1 position 223: "SS" (capital sharp S as two chars)
2020  if c == '\u{1E9E}' {
2021    result.push_str("SS");
2022    return;
2023  }
2024  // For standalone combining characters (Unicode Mn category), prepend NBSP as base.
2025  // Perl fontmaps encode these as UTF(0xA0)."\x{combining}" (two-char strings).
2026  if is_combining_mark(c) {
2027    result.push('\u{00A0}');
2028  }
2029  result.push(c);
2030}
2031
2032/// Check if a character is a Unicode combining mark (category Mn).
2033fn is_combining_mark(c: char) -> bool {
2034  matches!(c as u32,
2035    0x0300..=0x036F   // Combining Diacritical Marks
2036    | 0x1AB0..=0x1AFF // Combining Diacritical Marks Extended
2037    | 0x1DC0..=0x1DFF // Combining Diacritical Marks Supplement
2038    | 0x20D0..=0x20FF // Combining Diacritical Marks for Symbols
2039    | 0xFE20..=0xFE2F // Combining Half Marks
2040  )
2041}
2042
2043/// Decode a codepoint, returning a SymStr that may contain multiple characters.
2044/// This handles Perl font map entries like `UTF(0xA0)."\x{0335}"` (OT1 pos 32).
2045pub fn decode_str(code: u8, encoding_opt: Option<String>, implicit: bool) -> Option<SymStr> {
2046  // First, check for multi-char overrides (for entries that can't fit in Option<char>)
2047  if let Some(s) = lookup_multichar_override(code, encoding_opt.as_deref()) {
2048    return Some(arena::pin(s));
2049  }
2050  if let Some(c) = decode(code, encoding_opt, implicit) {
2051    // T1 position 223: "SS" (capital sharp S as two chars)
2052    if c == '\u{1E9E}' {
2053      return Some(pin!("SS"));
2054    }
2055    // For standalone combining characters, prepend NBSP as base character
2056    // (Perl fontmaps encode these as UTF(0xA0)."\x{combining}")
2057    if is_combining_mark(c) {
2058      return Some(arena::pin(format!("\u{00A0}{c}")));
2059    }
2060    Some(arena::pin_char(c))
2061  } else {
2062    None
2063  }
2064}
2065
2066/// Look up multi-char override for a given encoding position.
2067/// Returns Some(String) if a multi-char override exists.
2068fn lookup_multichar_override(code: u8, encoding_opt: Option<&str>) -> Option<String> {
2069  let encoding = match encoding_opt {
2070    Some(enc) if !enc.is_empty() => enc.to_string(),
2071    _ => {
2072      let font = lookup_font();
2073      font.and_then(|f| f.get_encoding().map(|e| e.to_string()))?
2074    },
2075  };
2076  if encoding.is_empty() {
2077    return None;
2078  }
2079  // The multichar table ships in the same binding as the map array, so it is
2080  // absent until that binding is loaded. This lookup runs BEFORE `decode` —
2081  // which is what triggers the load — so without preloading here, the very
2082  // first decode for an encoding misses the table and silently falls back to
2083  // the single-char array value.
2084  //
2085  // That is not merely a lost first call: `\DeclareTextSymbol` bakes the
2086  // decoded result into a primitive body at DECLARATION time, in the preamble,
2087  // before any `\fontencoding{T2B}` has loaded the map. So T2B slot 128 was
2088  // frozen as `Ӷ` (U+04F6) instead of `Ӷ̶` (U+04F6 U+0336) — a different
2089  // letter, its stroke dropped — for the rest of the document. Perl has no
2090  // such hazard: `FontDecode` calls `LoadFontMap` first and then indexes one
2091  // map whose slot already holds the whole string.
2092  let _ = preload_font_map(&encoding);
2093  with_value_sym(fontmap_key_syms(&encoding).multichar, |val_opt| {
2094    if let Some(Stored::HashString(map)) = val_opt {
2095      map.get(&code.to_string()).cloned()
2096    } else {
2097      None
2098    }
2099  })
2100}
2101
2102pub fn decode(code: u8, encoding_opt: Option<String>, implicit: bool) -> Option<char> {
2103  let mut font = None;
2104  let encoding = match encoding_opt {
2105    Some(enc) => Cow::Owned(enc),
2106    None => {
2107      // Perl `FontDecode`: `$encoding = $font->getEncoding || 'OT1'`
2108      // (Package.pm L2877). The `|| 'OT1'` is NOT shared with
2109      // `FontDecodeString` (L2906), whose port is `decode_string` below and
2110      // which deliberately keeps the empty fallback — do not "align" the two.
2111      //
2112      // This branch is the one `\char` reaches (via `decode_str`, encoding
2113      // `None`). It matters in MATH mode, where `Font::math_default()` sets
2114      // `encoding: None` on purpose: without the default, the lookup ran
2115      // against the empty encoding and `$\char65$` decoded to NOTHING where
2116      // Perl gives `A`. The default lives here, rather than at the call site,
2117      // so the font stays in scope for the `<enc>_<family>_fontmap`
2118      // refinement below — resolving the encoding earlier and passing it in
2119      // would silently drop `\ttfamily`'s `OT1_typewriter` variant.
2120      font = lookup_font();
2121      if let Some(ref font) = font {
2122        match font.get_encoding() {
2123          None => Cow::Borrowed("OT1"),
2124          Some(encoding) => encoding.clone(),
2125        }
2126      } else {
2127        Cow::Borrowed("OT1")
2128      }
2129    },
2130  };
2131
2132  let mut map: Option<Fontmap> = None;
2133  if !encoding.is_empty() {
2134    // `load_font_map` preloads; keys are memoized (2026-08-23 audit R6).
2135    if let Some(encmap) = load_font_map(&encoding) {
2136      // OK got some map.
2137      map = Some(encmap);
2138      if let Some(ref font) = font
2139        && let Some(family) = (*font).get_family()
2140      {
2141        with_value_sym(fontmap_family_key_sym(&encoding, family), |fmap_opt| {
2142          if let Some(fmap) = fmap_opt {
2143            map = fmap.into(); // Use the family specific map, if any.
2144          }
2145        });
2146      }
2147    }
2148  }
2149
2150  if implicit {
2151    if let Some(map) = map {
2152      if code < 128 {
2153        match map.get(code as usize) {
2154          None => None,
2155          Some(c) => *c,
2156        }
2157      } else {
2158        Some(code.into())
2159      }
2160    } else {
2161      Some(code.into())
2162    }
2163  } else if let Some(map) = map {
2164    match map.get(code as usize) {
2165      None => None,
2166      Some(c) => *c,
2167    }
2168  } else {
2169    None
2170  }
2171}
2172
2173pub fn decode_string(string: SymStr, encoding_opt: Option<&str>, implicit: bool) -> SymStr {
2174  let empty_sym = pin!("");
2175  if string == empty_sym {
2176    return empty_sym;
2177  }
2178  let mut font = None;
2179  let encoding = match encoding_opt {
2180    None => {
2181      font = lookup_font();
2182      if let Some(ref font) = font {
2183        font.get_encoding().unwrap_or(&Cow::Borrowed(""))
2184      } else {
2185        ""
2186      }
2187    },
2188    Some(encoding) => encoding,
2189  };
2190
2191  // Memoized key syms — this runs per digested character run, and rebuilding
2192  // the "{encoding}_fontmap"-family key strings each call was ~1M allocations
2193  // on a 1.3 s paper (2026-08-23 audit R6). `load_font_map` preloads, so no
2194  // separate `preload_font_map` call is needed.
2195  let mut map: Option<Fontmap> = None;
2196  if !encoding.is_empty()
2197    && let Some(encmap) = load_font_map(encoding)
2198  {
2199    // OK got some map.
2200    map = Some(encmap);
2201    if let Some(ref font) = font
2202      && let Some(family) = (*font).get_family()
2203    {
2204      with_value_sym(fontmap_family_key_sym(encoding, family), |fmap_opt| {
2205        if let Some(fmap) = fmap_opt {
2206          map = fmap.into(); // Use the family specific map, if any.
2207        }
2208      });
2209    }
2210  }
2211
2212  // Load multi-char overrides if available
2213  let multichar_map: Option<HashMap<String, String>> = if !encoding.is_empty() {
2214    with_value_sym(fontmap_key_syms(encoding).multichar, |val_opt| {
2215      if let Some(Stored::HashString(m)) = val_opt {
2216        Some(m.clone())
2217      } else {
2218        None
2219      }
2220    })
2221  } else {
2222    None
2223  };
2224
2225  let mut result_string: String = String::new();
2226  arena::with(string, |str| {
2227    for c in str.chars() {
2228      if implicit {
2229        if let Some(ref map_ref) = map {
2230          let code = c as u16; // u16, so that Unicode chars get cast correctly
2231          if code < 128 {
2232            // Check multi-char override first
2233            if let Some(ref mc) = multichar_map
2234              && let Some(mc_str) = mc.get(&(code as u8).to_string())
2235            {
2236              result_string.push_str(mc_str);
2237              continue;
2238            }
2239            if let Some(Some(mapc_val)) = map_ref.get(code as usize) {
2240              push_fontmap_char(&mut result_string, *mapc_val, code as u8);
2241            }
2242          } else {
2243            result_string.push(c);
2244          }
2245        } else {
2246          result_string.push(c)
2247        }
2248      } else if let Some(ref map_ref) = map {
2249        let code = c as u8;
2250        // Check multi-char override first
2251        if let Some(ref mc) = multichar_map
2252          && let Some(mc_str) = mc.get(&code.to_string())
2253        {
2254          result_string.push_str(mc_str);
2255          continue;
2256        }
2257        if let Some(Some(mapc_val)) = map_ref.get(code as usize) {
2258          push_fontmap_char(&mut result_string, *mapc_val, code);
2259        }
2260      }
2261    }
2262  });
2263  arena::pin(result_string)
2264}
2265
2266/// Convert stanard font size names, such as `tiny`, `Huge`, etc to f64
2267pub fn rationalize_font_size(size: &str) -> f64 {
2268  if let Some(symbolic) = FONT_SIZE.get(size) {
2269    *symbolic * defsize()
2270  } else {
2271    // Perl: return $size — if not a symbolic name, return the numeric value as-is
2272    size.parse::<f64>().unwrap_or_else(|_| defsize())
2273  }
2274}
2275
2276/// convert size to percent
2277pub fn relative_font_size(newsize: f64, oldsize: f64) -> String {
2278  s!("{}%", (0.5 + 100.0 * newsize / oldsize).floor())
2279}
2280
2281#[cfg(test)]
2282mod tests {
2283  use super::*;
2284
2285  #[test]
2286  fn relative_font_size_same_is_100() {
2287    assert_eq!(relative_font_size(10.0, 10.0), "100%");
2288    assert_eq!(relative_font_size(12.0, 12.0), "100%");
2289  }
2290
2291  #[test]
2292  fn relative_font_size_doubled_is_200() {
2293    assert_eq!(relative_font_size(20.0, 10.0), "200%");
2294  }
2295
2296  #[test]
2297  fn relative_font_size_half_is_50() {
2298    assert_eq!(relative_font_size(5.0, 10.0), "50%");
2299  }
2300
2301  /// #542: NOMINAL_FONT_SIZE is a float, not an integer — the `11pt` class option
2302  /// is `10.95` (LaTeX's `\@xipt`), which the reader must not truncate. Perl
2303  /// `DEFSIZE` reads `lookupValue('NOMINAL_FONT_SIZE')` directly as a float
2304  /// (`Common/Font.pm:44`); the Rust reader used to go through `lookup_int`.
2305  #[test]
2306  fn defsize_preserves_fractional_nominal_font_size() {
2307    use crate::{
2308      common::float::Float,
2309      state::{State, StateOptions, assign_value, set_state},
2310    };
2311    set_state(State::new(StateOptions::default()));
2312    // Unset → default 10.
2313    assert_eq!(defsize(), 10.0, "defsize defaults to 10 when unset");
2314    // 11pt → 10.95, the fractional value the old lookup_int truncated to 10.
2315    assign_value("NOMINAL_FONT_SIZE", Float(10.95), None);
2316    assert_eq!(
2317      defsize(),
2318      10.95,
2319      "defsize preserves the fractional 11pt size"
2320    );
2321    // An integral float still reads back cleanly.
2322    assign_value("NOMINAL_FONT_SIZE", Float(12.0), None);
2323    assert_eq!(defsize(), 12.0, "12pt reads back as 12.0");
2324  }
2325
2326  /// #638: LaTeXML treats the font `encoding` three different ways, and this
2327  /// pins all three so the "omit encoding from `relative_to`" fix cannot drift
2328  /// into collapsing font *identity*. Two fonts that differ ONLY in encoding are:
2329  ///   - **unequal** (`eq`) and **distinctly hashable** — a T1 span is a
2330  ///     different font than an OT1 span, so `set_node_font`'s `_font` /
2331  ///     `node_fonts` keys keep them apart;
2332  ///   - **distance 0** — encoding alone never opens a font wrapper (Perl's
2333  ///     `distance` skips it explicitly, `Common/Font.pm`);
2334  ///   - **empty under `relative_to`** — encoding is a FontMap-lookup key consumed
2335  ///     at digestion, meaningless as output, so it is omitted from the relativized
2336  ///     attribute set (otherwise it leaks onto raw `xhtml:*`, whose `attribute *`
2337  ///     schema wildcard accepts it — the reported bug).
2338  #[test]
2339  fn encoding_is_font_identity_but_not_distance_or_output() {
2340    use crate::state::{State, StateOptions, set_state};
2341    set_state(State::new(StateOptions::default()));
2342
2343    let base = Font::default();
2344    let mut t1 = base.clone();
2345    t1.encoding = Some(Cow::Borrowed("T1"));
2346    let mut ot1 = base.clone();
2347    ot1.encoding = Some(Cow::Borrowed("OT1"));
2348
2349    // Identity: different encodings ⇒ different fonts.
2350    assert_ne!(
2351      t1, ot1,
2352      "fonts differing only in encoding must not be equal"
2353    );
2354    assert_ne!(
2355      t1.to_hashable(),
2356      ot1.to_hashable(),
2357      "differently-encoded fonts must get distinct `_font` ids"
2358    );
2359
2360    // Distance: encoding is not a font-switch factor (faithful to Perl).
2361    assert_eq!(
2362      t1.distance(&ot1),
2363      0,
2364      "encoding alone must not count as a font-switch distance"
2365    );
2366
2367    // Output: encoding is never emitted as a relativized attribute (#638). Since
2368    // the two fonts differ only in encoding, the relativized set is empty.
2369    let rel = t1.relative_to(&ot1);
2370    assert!(
2371      rel.is_empty(),
2372      "relative_to must not emit @encoding (it leaks onto foreign xhtml); got {rel:?}"
2373    );
2374  }
2375
2376  #[test]
2377  fn match_font_exact_wildcard_tail() {
2378    // Font[family,series,shape,size,...] — '*' matches any single
2379    // component.
2380    // match_font(f1, f2) returns true iff f2 matches the pattern f1.
2381    // f1 with all-wildcards should match any well-formed Font[...].
2382    assert!(match_font("Font[*,*,*,*]", "Font[rm,med,up,10]"));
2383  }
2384
2385  #[test]
2386  fn match_font_exact_match() {
2387    assert!(match_font("Font[rm,med,up,10]", "Font[rm,med,up,10]"));
2388    assert!(!match_font("Font[rm,med,up,10]", "Font[sf,med,up,10]"));
2389  }
2390
2391  #[test]
2392  fn match_font_partial_wildcard() {
2393    // First position wildcard matches rm, sf, tt, etc.
2394    assert!(match_font("Font[*,med,up,10]", "Font[rm,med,up,10]"));
2395    assert!(match_font("Font[*,med,up,10]", "Font[sf,med,up,10]"));
2396    // But a non-wildcard in series must match.
2397    assert!(!match_font("Font[*,bold,up,10]", "Font[rm,med,up,10]"));
2398  }
2399
2400  #[test]
2401  fn match_font_malformed_input() {
2402    // Missing Font[...] wrapper → false.
2403    assert!(!match_font("not_a_font", "Font[rm,med,up,10]"));
2404  }
2405
2406  #[test]
2407  fn font_match_xpaths_all_wildcards_is_attr_only() {
2408    let xp = font_match_xpaths("Font[*,*,*,*]");
2409    // All wildcards → just @_font, no contains(...) fragments.
2410    assert_eq!(xp, "@_font");
2411  }
2412
2413  #[test]
2414  fn font_match_xpaths_includes_specified_components() {
2415    let xp = font_match_xpaths("Font[rm,bold,*,*]");
2416    // Family and series specified; shape/size wildcarded.
2417    assert!(xp.contains("@_font"));
2418    assert!(xp.contains("contains"));
2419    assert!(xp.contains("rm"));
2420    assert!(xp.contains("bold"));
2421  }
2422
2423  #[test]
2424  fn font_match_xpaths_malformed_is_empty_or_fallback() {
2425    let xp = font_match_xpaths("garbage");
2426    // Not a Font[...] format → some minimal/fallback output.
2427    // Implementation detail: we don't over-constrain, just verify
2428    // it doesn't panic.
2429    let _ = xp;
2430  }
2431}