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::{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    if is_diff(self.encoding.as_ref(), other.encoding.as_ref()) {
1268      result.insert(
1269        "encoding".to_string(),
1270        (self.encoding.as_ref().unwrap().to_string(), Font {
1271          encoding: self.encoding.clone(),
1272          ..Font::default()
1273        }),
1274      );
1275    }
1276    if is_diff(self.language.as_ref(), other.language.as_ref()) {
1277      result.insert(
1278        "xml:lang".to_string(),
1279        (self.language.as_ref().unwrap().to_string(), Font {
1280          language: self.language.clone(),
1281          ..Font::default()
1282        }),
1283      );
1284    }
1285    // Emph: (!$mstyle && $flags && ($flags & $FLAG_EMPH) && (!$oflags || !($oflags & $FLAG_EMPH))
1286    let flags = self.flags.unwrap_or(0);
1287    let oflags = other.flags.unwrap_or(0);
1288    if self.mathstyle.is_none()
1289      && flags != 0
1290      && (flags & FLAG_EMPH) != 0
1291      && (oflags == 0 || (oflags & FLAG_EMPH) == 0)
1292    {
1293      result.insert(
1294        "element".to_string(),
1295        ("ltx:emph".to_string(), Font::default()),
1296      );
1297    }
1298    // We do NOT want mathstyle showing up automatically in the attributes
1299    result
1300  }
1301
1302  pub fn purestyle_changes(&self, other: &Font) -> Font {
1303    let mathstyle = self.get_mathstyle();
1304    let othermathstyle = other.get_mathstyle();
1305    let othercolor = other.get_color();
1306    let mut changes = Font {
1307      scale: Some(other.get_size().unwrap() / self.get_size().unwrap()),
1308      bg: other.bg,
1309      opacity: other.opacity.clone(), // should multiply or replace?
1310      ..Font::default()
1311    };
1312    if is_diff_font_color(othercolor, Some(&DEFCOLOR)) {
1313      changes.color = Some(othercolor.copied().unwrap_or(DEFCOLOR));
1314    }
1315
1316    if let Some(ms) = mathstyle
1317      && let Some(os) = othermathstyle
1318    {
1319      let ms_str: &str = ms;
1320      let os_str: &str = os;
1321      changes.mathstylestep = Some(*MATH_STYLE_STEP.get(ms_str).unwrap().get(os_str).unwrap());
1322    }
1323    changes
1324  }
1325
1326  /// Find a Font Metric corresponding to this font's family_series_shape_size
1327  /// that contains the given `char`, if given.
1328  /// Try to find a fallback metric if `char` is not in the current Font.
1329  /// Perl: getMetric
1330  pub fn get_metric(&self, c_opt: Option<char>) -> &MetricData {
1331    let family = self.family.as_deref().unwrap_or("serif");
1332    let series = self.series.as_deref().unwrap_or("medium");
1333    let shape = self.shape.as_deref().unwrap_or("upright");
1334    let size = self.size.unwrap_or_else(defsize) as i64;
1335    // Stack buffer for char→&str lookup key, reused across paths. Avoids
1336    // one String allocation per character per get_metric call (which is
1337    // called per-character inside compute_string_size).
1338    let mut ch_buf = [0u8; 4];
1339    let ch_key = c_opt.map(|c| c.encode_utf8(&mut ch_buf) as &str);
1340    if let Some(name) = lookup_metric_name(family, series, shape) {
1341      let fullname = format!("{name}{size}");
1342      if let Some(metric) = STDMETRICS.get(fullname.as_str())
1343        && ch_key.is_none_or(|k| metric.sizes.contains_key(k))
1344      {
1345        return metric;
1346      }
1347      // Try base name fallback
1348      let metric = get_metric_for_name(name);
1349      if ch_key.is_none_or(|k| metric.sizes.contains_key(k)) {
1350        return metric;
1351      }
1352    }
1353    // Look for a fallback metric if char given
1354    if let Some(k) = ch_key {
1355      for name in METRIC_FALLBACKS {
1356        let fullname = format!("{name}{size}");
1357        let metric = STDMETRICS
1358          .get(fullname.as_str())
1359          .unwrap_or_else(|| get_metric_for_name(name));
1360        if metric.sizes.contains_key(k) {
1361          return metric;
1362        }
1363      }
1364    }
1365    get_metric_for_name("cmr")
1366  }
1367
1368  pub fn get_em_width(&self) -> i64 {
1369    let size = self.get_size().unwrap_or_else(defsize);
1370    let m = self.get_metric(None);
1371    (size * m.emwidth).trunc() as i64
1372  }
1373  pub fn get_ex_height(&self) -> i64 {
1374    let size = self.get_size().unwrap_or_else(defsize);
1375    let m = self.get_metric(None);
1376    (size * m.exheight).trunc() as i64
1377  }
1378  pub fn get_mu_width(&self) -> i64 {
1379    let size = self.get_size().unwrap_or_else(defsize);
1380    let m = self.get_metric(None);
1381    (size * m.emwidth / 18.0).trunc() as i64
1382  }
1383
1384  pub fn compute_string_size(
1385    &self,
1386    text: &str,
1387    _options: SymHashMap<Stored>,
1388  ) -> (Dimension, Dimension, Dimension) {
1389    if text.is_empty()
1390      || self
1391        .get_family()
1392        .map(|fam| fam == "nullfont")
1393        .unwrap_or(false)
1394    {
1395      return (
1396        Dimension::default(),
1397        Dimension::default(),
1398        Dimension::default(),
1399      );
1400    }
1401    let size = self.get_size().unwrap_or_else(defsize);
1402    let ismath = self.get_family().map(|fam| fam == "math").unwrap_or(false);
1403    let (mut w, mut h, mut d) = (0, 0, 0);
1404    // Iterate via Peekable — no intermediate Vec<char> allocation,
1405    // and we get O(1) lookahead for kerning between consecutive chars.
1406    let mut chars_iter = text.chars().peekable();
1407    // Stack buffers for char→&str and char+char→&str lookups, avoiding
1408    // String::to_string() + String::format!() heap allocations inside
1409    // the per-character hot loop. encode_utf8 writes directly to the
1410    // buffer and returns a borrowed &str slice — no allocation.
1411    let mut ch_buf = [0u8; 4];
1412    let mut kern_buf = [0u8; 8];
1413    while let Some(ch) = chars_iter.next() {
1414      let metric = self.get_metric(Some(ch));
1415      let ch_key = ch.encode_utf8(&mut ch_buf);
1416      let entry_opt = metric.sizes.get(ch_key);
1417      let (cw, ch_sz, cd, ci) = if let Some(entry) = entry_opt {
1418        *entry
1419      } else {
1420        (0.75 * UNITY_F64, 0.7 * UNITY_F64, 0.2 * UNITY_F64, 0.0)
1421      };
1422      w += (cw * size).trunc() as i64;
1423      // Kerning: check kern between this char and next.
1424      if let Some(&next_ch) = chars_iter.peek() {
1425        let first_len = ch.encode_utf8(&mut kern_buf).len();
1426        let second_len = next_ch.encode_utf8(&mut kern_buf[first_len..]).len();
1427        let kern_key = std::str::from_utf8(&kern_buf[..first_len + second_len]).unwrap();
1428        if let Some(kern) = metric.kerns.get(kern_key) {
1429          w += (size * kern).trunc() as i64;
1430        }
1431      }
1432      // Italic correction in math
1433      if ismath && ci != 0.0 {
1434        w += (size * ci).trunc() as i64;
1435      }
1436      h = max(h, (ch_sz * size).trunc() as i64);
1437      d = max(d, (cd * size).trunc() as i64);
1438    }
1439    // The 1 is so that any actual glyph appears to be non-empty.
1440    // This is presumably only necessary to deal with the flawed emptiness heiristics in Alignment?
1441    if w == 0 {
1442      w = 1;
1443    }
1444    (Dimension::new(w), Dimension::new(h), Dimension::new(d))
1445  }
1446
1447  /// Get nominal width, height base ?
1448  /// Probably should be using data from FontMetric ???
1449  pub fn get_nominal_size(&self) -> (Dimension, Dimension, Dimension) {
1450    let size = self.get_size().unwrap_or_else(defsize);
1451    let u = size * UNITY_F64;
1452    (
1453      Dimension::new_f64(0.75 * u),
1454      Dimension::new_f64(0.7 * u),
1455      Dimension::new_f64(0.2 * u),
1456    )
1457  }
1458
1459  // Here's where I avoid trying to emulate Knuth's line-breaking...
1460  // Mostly for List & Whatsit: compute the size of a list of boxes.
1461  // Options _SHOULD_ include:
1462  //   width:  if given, pretend to simulate line breaking to that width
1463  //   height,depth : ?
1464  //   vattach : top, bottom, center, baseline (...?) affects how the height & depth are
1465  //      allocated when there are multiple lines.
1466  //   layout : horizontal or vertical !!!
1467  // Boxes that arent a Core Box, List, Whatsit or a string are IGNORED
1468  //
1469  // The big problem with width is to have it propogate down from where
1470  // it may have been specified to the actual nested box that will get wrapped!
1471  // Try to mask this (temporarily) by unlisting, and (pretending to ) breaking up too wide items
1472  //
1473  // Another issue; SVG needs (sometimes) real sizes, even if the programmer
1474  // set some dimensions to 0 (eg.)   We may need to distinguish & store
1475  // requested vs real sizes?
1476  /// Perl: Font.pm sub computeBoxesSize (L635-680)
1477  /// Compute the size of a List of boxes, dispatching to helpers based on layout mode.
1478  pub fn compute_boxes_size(
1479    &self,
1480    boxes: &[Digested],
1481    options: SymHashMap<Stored>,
1482  ) -> Result<(Dimension, Dimension, Dimension)> {
1483    // Perl L646-647: `elsif ($ref =~ /^LaTeXML::Core::(?:Box|Whatsit|Alignment)$/) {
1484    // return $boxes->getSize; }` — a single bare Box/Whatsit/Alignment (NOT a
1485    // List) returns its getSize directly, short-circuiting ahead of the
1486    // List/split_words logic. This is essential for an isSpace box carrying an
1487    // explicit height/depth (e.g. `\phantom`'s XMHint): split_words keeps only
1488    // its width as inter-word space and discards height/depth, but getSize honors
1489    // the full width/height/depth. In Perl the dispatch is on the type of the
1490    // measured object (a bare-box body, not a List); a single-element slice here
1491    // is the faithful analogue (a List-of-one would still be a List in Perl).
1492    if let [single] = boxes
1493      && !matches!(single.data(), DigestedData::List(_))
1494    {
1495      let mut bx_clone = single.clone();
1496      let (w, h, d, ..) = bx_clone.get_size(None)?;
1497      return Ok((w, h, d));
1498    }
1499    // Perl L638: my $mode = $boxes->getProperty('mode') || 'restricted_horizontal';
1500    let mode_str = match options.get("mode") {
1501      Some(Stored::String(s)) => arena::with(*s, |s| s.to_string()),
1502      _ => "restricted_horizontal".to_string(),
1503    };
1504    // Perl: $vattach = $boxes->getProperty('vattach') || $options{vattach} || 'baseline'
1505    let vattach = match options.get("vattach") {
1506      Some(Stored::String(s)) => arena::with(*s, |s| s.to_string()),
1507      _ => "baseline".to_string(),
1508    };
1509    // Perl #2798: `elsif (my $width = ($mode =~ /horizontal$/) && $boxes->getProperty('width'))`
1510    // — a horizontal list is formatted as a paragraph IFF an explicit width is
1511    // supplied (recorded by S4's repack_horizontal). NO `\hsize` fallback: a
1512    // horizontal List without a width property is restricted_horizontal (a single
1513    // line, no wrapping), matching Perl.
1514    let para_width: Option<i64> = if mode_str.ends_with("horizontal") {
1515      match options.get("width") {
1516        Some(Stored::Dimension(d)) => Some(d.value_of()),
1517        Some(Stored::Int(i)) => Some(*i),
1518        _ => None,
1519      }
1520    } else {
1521      None
1522    };
1523    // Perl #2798: baseline (sp) — from List property (S4) / option, default 12pt.
1524    let baseline: i64 = match options.get("baseline") {
1525      Some(Stored::Dimension(d)) => d.value_of(),
1526      Some(Stored::Int(i)) => *i,
1527      _ => 12 * UNITY,
1528    };
1529    let mut maxwidth: i64 = 0;
1530    // Perl #2798: lines are now [baseline, wd, ht, dp] (per-line baseline; -1 = no
1531    // inter-line adjustment, e.g. \vskip / \hrule).
1532    let mut lines: Vec<[i64; 4]> = Vec::new();
1533    if mode_str.ends_with("vertical") {
1534      // Perl: For vertical, ALL boxes are lines. (Rust makes the box-list match
1535      // Perl's structure BEFORE this point: a text-mode `{...}` group that
1536      // breaks a paragraph repacks the outer paragraph at digestion — see
1537      // `tex_box.rs` `{` primitive R2 / OXIDIZED_DESIGN #100 — so no run of loose
1538      // characters ever reaches here to be mis-counted one-line-per-glyph.)
1539      for bx in boxes {
1540        if bx.has_property("isEmpty") {
1541          continue;
1542        }
1543        // Perl: a horizontal sub-List WITH a width is formatted as a paragraph.
1544        if matches!(bx.data(), DigestedData::List(_))
1545          && bx
1546            .get_property("mode")
1547            .map(|v| v.to_string())
1548            .unwrap_or_default()
1549            == "horizontal"
1550          && let Some(w) = bx.get_property("width").and_then(|v| match &*v {
1551            Stored::Dimension(d) => Some(d.value_of()),
1552            Stored::Int(i) => Some(*i),
1553            _ => None,
1554          })
1555        {
1556          if w > maxwidth {
1557            maxwidth = w;
1558          }
1559          let sub_baseline = bx
1560            .get_property("baseline")
1561            .and_then(|v| match &*v {
1562              Stored::Dimension(d) => Some(d.value_of()),
1563              Stored::Int(i) => Some(*i),
1564              _ => None,
1565            })
1566            .unwrap_or(baseline);
1567          lines.extend(self.linebreak_paragraph(&bx.unlist(), w, sub_baseline)?);
1568          continue;
1569        }
1570        // Perl: single box → one line, with baseline (or -1 for vskip/rule).
1571        // DIVERGENCE from Perl #2798 (upstream candidate): Perl folds vskips
1572        // and rules into one `-1` flag and RESETS prevdepth for both, so any
1573        // glue item between lines silently disables \baselineskip accounting
1574        // — a stack of N verbatim lines interleaved with fancyvrb's interline
1575        // vspace measures as Σ(h+d) ≈ N×6pt instead of N×\baselineskip
1576        // (witness 2605.00468: 49-line Prompt boxes budgeted at half their
1577        // TeX height; content spilled through every following box). TeX truth
1578        // (tex.web vpack): \prevdepth is TRANSPARENT to glue — only a BOX
1579        // updates it (to its depth), and only \hrule disables it (sentinel
1580        // \prevdepth = -1000pt). Encode vskip as -1 (transparent) and rule
1581        // as -2 (reset) so the stack can honor both.
1582        let (w, h, d) = self.compute_boxes_size_box(bx)?;
1583        let bs = if bx.get_property_bool("isHorizontalRule") {
1584          -2
1585        } else if bx.get_property_bool("isVerticalSpace") {
1586          -1
1587        } else {
1588          baseline
1589        };
1590        if w != 0 || h != 0 || d != 0 {
1591          lines.push([bs, w, h, d]);
1592        }
1593      }
1594    } else if let Some(w) = para_width {
1595      // Perl: proper paragraph — flatten, split into words, break into lines.
1596      if w > maxwidth {
1597        maxwidth = w;
1598      }
1599      let flat: Vec<&Digested> = boxes
1600        .iter()
1601        .filter(|b| !b.has_property("isEmpty"))
1602        .collect();
1603      let flat_owned: Vec<Digested> = flat.into_iter().cloned().collect();
1604      lines = self.linebreak_paragraph(&flat_owned, w, baseline)?;
1605    } else {
1606      // Perl: restricted_horizontal or math — one line, no wrapping.
1607      let filtered: Vec<&Digested> = boxes
1608        .iter()
1609        .filter(|b| !b.has_property("isEmpty"))
1610        .collect();
1611      let words = self.compute_boxes_size_words(&filtered)?;
1612      lines = Self::compute_boxes_size_lines(None, baseline, &words);
1613    }
1614    // Perl: stack up the multiple lines; mathaxis = size/4.
1615    let size = self.get_size().unwrap_or_else(defsize) as i64;
1616    let mathaxis = size * UNITY / 4;
1617    static SIZE_TRACE: std::sync::LazyLock<bool> =
1618      std::sync::LazyLock::new(|| std::env::var("LXML_SIZE_TRACE").is_ok());
1619    if *SIZE_TRACE {
1620      eprintln!(
1621        "SIZE mode={mode_str} vattach={vattach} baseline={} nboxes={} lines={:?}",
1622        baseline as f64 / 65536.0,
1623        boxes.len(),
1624        lines
1625          .iter()
1626          .map(|l| [
1627            l[0] as f64 / 65536.0,
1628            l[1] as f64 / 65536.0,
1629            l[2] as f64 / 65536.0,
1630            l[3] as f64 / 65536.0
1631          ])
1632          .collect::<Vec<_>>()
1633      );
1634    }
1635    let (mut wd, mut ht, mut dp) = Self::compute_boxes_size_stack(&vattach, mathaxis, &lines);
1636    // Perl: $wd = $maxwidth if $wd && $maxwidth (set to fill width, unless empty).
1637    if wd != 0 && maxwidth != 0 {
1638      wd = maxwidth;
1639    }
1640    // Perl: divide up totalheight, if requested.
1641    if let Some(th) = options.get("totalheight").and_then(|v| match v {
1642      Stored::Dimension(d) => Some(d.value_of()),
1643      Stored::Int(i) => Some(*i),
1644      _ => None,
1645    }) {
1646      let diff = th - ht - dp;
1647      if diff > 0 {
1648        match vattach.as_str() {
1649          "bottom" => ht += diff,
1650          "middle" => {
1651            ht += diff / 2;
1652            dp += diff / 2;
1653          },
1654          _ => dp += diff,
1655        }
1656      }
1657    }
1658    Ok((Dimension::new(wd), Dimension::new(ht), Dimension::new(dp)))
1659  }
1660
1661  /// Perl #2798: linebreak_paragraph — format a horizontal list (with width) as
1662  /// a paragraph: flatten nested horizontal Lists + sizing-flattenable Whatsits,
1663  /// split into words, then break into lines. Returns [baseline, wd, ht, dp] lines.
1664  fn linebreak_paragraph(
1665    &self,
1666    boxes: &[Digested],
1667    width: i64,
1668    baseline: i64,
1669  ) -> Result<Vec<[i64; 4]>> {
1670    let flat = Self::flatten_paragraph(boxes);
1671    let flat_refs: Vec<&Digested> = flat.iter().collect();
1672    let words = self.compute_boxes_size_words(&flat_refs)?;
1673    Ok(Self::compute_boxes_size_lines(
1674      Some(width),
1675      baseline,
1676      &words,
1677    ))
1678  }
1679
1680  /// Perl #2798: flatten_paragraph — open up contained horizontal Lists, and any
1681  /// Whatsits that format AS IF embedded paragraph material (e.g. `\emph`), so
1682  /// they participate in line-breaking.
1683  fn flatten_paragraph(boxes: &[Digested]) -> Vec<Digested> {
1684    let mut queue: std::collections::VecDeque<Digested> = boxes.iter().cloned().collect();
1685    let mut out: Vec<Digested> = Vec::new();
1686    while let Some(bx) = queue.pop_front() {
1687      if matches!(bx.data(), DigestedData::List(_))
1688        && bx
1689          .get_property("mode")
1690          .map(|v| v.to_string())
1691          .unwrap_or_default()
1692          == "horizontal"
1693      {
1694        for ib in bx.unlist().into_iter().rev() {
1695          queue.push_front(ib);
1696        }
1697      } else if matches!(bx.data(), DigestedData::Whatsit(_))
1698        && let Some(repl) = flatten_for_sizing(&bx)
1699      {
1700        for ib in repl.into_iter().rev() {
1701          queue.push_front(ib);
1702        }
1703      } else {
1704        out.push(bx);
1705      }
1706    }
1707    out
1708  }
1709
1710  /// Perl: Font.pm sub computeBoxesSize_box (L683-702)
1711  /// Compute the size of a single box, returning (w, h, d) in sp.
1712  fn compute_boxes_size_box(&self, bx: &Digested) -> Result<(i64, i64, i64)> {
1713    // Clone to avoid caching side effects on the original box
1714    let mut bx_clone = bx.clone();
1715    let (w, h, d, ..) = bx_clone.get_size(None)?;
1716    Ok((w.value_of(), h.value_of(), d.value_of()))
1717  }
1718
1719  /// Perl: Font.pm sub computeBoxesSize_words (L705-746)
1720  /// Compute a list of sizes of space-delimited "words" within a NON-vertical list.
1721  /// Returns Vec of [prevspace, wd, ht, dp] where prevspace=-1 means line break.
1722  fn compute_boxes_size_words(&self, boxes: &[&Digested]) -> Result<Vec<[f64; 4]>> {
1723    let mut words: Vec<[f64; 4]> = Vec::new();
1724    let mut prevbox: Option<&Digested> = None;
1725    let mut prevspace: f64 = 0.0;
1726    // Perl L711: my $size = int($self->getSize || DEFSIZE() || 10);
1727    let size = self.get_size().unwrap_or_else(defsize) as i64;
1728    let (mut wd, mut ht, mut dp): (f64, i64, i64) = (0.0, 0, 0);
1729    for bx in boxes {
1730      let (w, h, d) = self.compute_boxes_size_box(bx)?;
1731      // Perl L716-721: Check for possible line-break points
1732      if bx.get_property_bool("isBreak") {
1733        // Perl: vertical space (isBreak + isVerticalSpace) contributes height
1734        // even though it acts as a line break. Include its h/d in the word
1735        // so alignment row spacing accounts for \noalign{\vskip X}.
1736        if bx.get_property_bool("isVerticalSpace") {
1737          ht = max(ht, h);
1738          dp = max(dp, d);
1739        }
1740        if wd != 0.0 || ht != 0 || dp != 0 || prevspace > 0.0 {
1741          words.push([prevspace, wd, ht as f64, dp as f64]);
1742          wd = 0.0;
1743          ht = 0;
1744          dp = 0;
1745          prevspace = -1.0;
1746        } else {
1747          prevspace = -1.0;
1748        }
1749      }
1750      // Perl L723-728: isSpace (but not isVerticalSpace) — word boundary
1751      else if bx.get_property_bool("isSpace") && !bx.get_property_bool("isVerticalSpace") {
1752        if wd != 0.0 || ht != 0 || dp != 0 || prevspace < 0.0 {
1753          words.push([prevspace, wd, ht as f64, dp as f64]);
1754          wd = 0.0;
1755          ht = 0;
1756          dp = 0;
1757          prevspace = w as f64;
1758        } else {
1759          prevspace += w as f64;
1760        }
1761      }
1762      // Perl #2798: an ideographic (CJK) char is itself a word.
1763      else if bx.get_property_bool("isIdeographic") {
1764        if wd != 0.0 {
1765          words.push([prevspace, wd, ht as f64, dp as f64]);
1766        }
1767        words.push([0.0, w as f64, h as f64, d as f64]);
1768        wd = 0.0;
1769        ht = 0;
1770        dp = 0;
1771        prevspace = 0.0;
1772      }
1773      // Perl L729-741: Else accumulate into "word"
1774      else {
1775        wd += w as f64;
1776        ht = max(ht, h);
1777        dp = max(dp, d);
1778        // Perl L734-741: Kern HACK for lists of individual Box's
1779        if let Some(pb) = prevbox
1780          && matches!(pb.data(), DigestedData::TBox(_))
1781          && matches!(bx.data(), DigestedData::TBox(_))
1782        {
1783          let prevchar = pb.get_string()?.chars().last();
1784          let curchar = bx.get_string()?.chars().next();
1785          let metric = self.get_metric(curchar);
1786          // Perl L738-739: math bearing
1787          if let Some(family) = self.get_family()
1788            && family == "math"
1789          {
1790            wd += self.math_bearing(bx, pb);
1791          }
1792          // Perl L740-741: kerning
1793          if let Some(prevc) = prevchar
1794            && let Some(curc) = curchar
1795          {
1796            let kern_key = String::from(prevc) + &String::from(curc);
1797            if let Some(kern) = metric.kerns.get(kern_key.as_str()) {
1798              wd += size as f64 * kern;
1799            }
1800          }
1801        }
1802      }
1803      // Perl L743: $prevbox = $box
1804      prevbox = Some(bx);
1805    }
1806    // Perl L744-745: be sure to get last bit
1807    if wd != 0.0 || ht != 0 || dp != 0 || prevspace != 0.0 {
1808      words.push([prevspace, wd, ht as f64, dp as f64]);
1809    }
1810    Ok(words)
1811  }
1812
1813  /// Perl #2798: collect_lines — break words into lines per `wrapwidth` (if any)
1814  /// or explicit breaks. Each line is `[baseline, wd, ht, dp]`; the per-line
1815  /// `baseline` drives inter-line spacing in `compute_boxes_size_stack`.
1816  fn compute_boxes_size_lines(
1817    wrapwidth: Option<i64>,
1818    baseline: i64,
1819    words: &[[f64; 4]],
1820  ) -> Vec<[i64; 4]> {
1821    let mut lines: Vec<[i64; 4]> = Vec::new();
1822    let fuzz = UNITY as f64; // 1pt
1823    let (mut wd, mut ht, mut dp): (f64, i64, i64) = (0.0, 0, 0);
1824    for item in words {
1825      let (space, w, h, d) = (item[0], item[1], item[2] as i64, item[3] as i64);
1826      // Forced linebreak (space == -1) or wrapped linebreak.
1827      if space == -1.0 || wrapwidth.is_some_and(|ww| wd + space * 0.5 + w > ww as f64 + fuzz) {
1828        if wd != 0.0 {
1829          lines.push([baseline, kround(wd), ht, dp]);
1830        }
1831        wd = w;
1832        ht = h;
1833        dp = d;
1834      } else {
1835        wd += space + w;
1836        ht = max(ht, h);
1837        dp = max(dp, d);
1838      }
1839    }
1840    if wd != 0.0 || ht != 0 || dp != 0 {
1841      lines.push([baseline, kround(wd), ht, dp]);
1842    }
1843    lines
1844  }
1845
1846  /// Perl #2798: stack_lines — sum a stack of `[baseline, wd, ht, dp]` lines:
1847  /// `wd` is the max, inter-line spacing uses each line's `baseline` (`bs < 0` =
1848  /// no adjustment), and `ht`/`dp` are split per `vattach` (`mathaxis` = size/4).
1849  fn compute_boxes_size_stack(vattach: &str, mathaxis: i64, lines: &[[i64; 4]]) -> (i64, i64, i64) {
1850    let nlines = lines.len();
1851    if nlines == 0 {
1852      return (0, 0, 0);
1853    }
1854    if nlines == 1 {
1855      let [_bs, w, h, d] = lines[0];
1856      return (w, h, d);
1857    }
1858    // Perl: $lineskip = lookupDefinition('\lineskip')->valueOf->valueOf
1859    let lineskip = lookup_definition(&T_CS!("\\lineskip"))
1860      .ok()
1861      .flatten()
1862      .and_then(|def| def.value_of(Vec::new()))
1863      .map(|v| v.value_of())
1864      .unwrap_or(0);
1865    let mut wd: i64 = 0;
1866    let mut prevdepth: i64 = -99999;
1867    let mut th: i64 = 0;
1868    for line in lines {
1869      let [bs, w, h, d] = *line;
1870      wd = max(w, wd);
1871      th += h + d;
1872      if prevdepth >= 0 && bs >= 0 {
1873        if prevdepth + h < bs {
1874          th += bs - prevdepth - h;
1875        } else {
1876          th += lineskip;
1877        }
1878      }
1879      // TeX vpack \prevdepth discipline (divergence from Perl #2798 — see
1880      // the vertical branch above): boxes set prevdepth to their depth;
1881      // glue (bs == -1) is TRANSPARENT (prevdepth unchanged, so the next
1882      // box still receives \baselineskip accounting across the skip);
1883      // rules (bs == -2) disable it (TeX's \prevdepth = -1000pt sentinel).
1884      prevdepth = if bs >= 0 {
1885        d
1886      } else if bs == -1 {
1887        prevdepth
1888      } else {
1889        -99999
1890      };
1891    }
1892    let (ht, dp) = match vattach {
1893      "middle" => (th / 2 + mathaxis, th / 2 - mathaxis),
1894      "bottom" => {
1895        let d = lines[nlines - 1][3];
1896        (th - d, d)
1897      },
1898      // else (baseline / top): align to baseline of top row.
1899      _ => {
1900        let h = lines[0][2];
1901        (h, th - h)
1902      },
1903    };
1904    (wd, ht, dp)
1905  }
1906}
1907
1908/// Perl #2798: `Whatsit->flattenForSizing` — a horizontal Whatsit whose sizer is
1909/// a pure `#arg`/`#prop` reference can be flattened so its content participates
1910/// in paragraph line-breaking (e.g. `\emph`). STUB: returns `None` (no
1911/// flattening) for now — refine to parse the sizer spec. None of the current
1912/// sizing fixtures depend on this; only `\emph`-style line-wrapping differs.
1913fn flatten_for_sizing(_w: &Digested) -> Option<Vec<Digested>> { None }
1914
1915fn is_diff(x: Option<&Cow<str>>, y: Option<&Cow<str>>) -> bool {
1916  x.is_some() && (y.is_none() || (x != y))
1917}
1918
1919fn is_diff_opt_str(x: Option<&str>, y: Option<&str>) -> bool {
1920  x.is_some() && (y.is_none() || (x != y))
1921}
1922
1923fn is_diff_f64(x: Option<f64>, y: Option<f64>) -> bool { x.is_some() && (y.is_none() || (x != y)) }
1924
1925fn is_diff_color(x: Option<&Color>, y: Option<&Color>) -> bool {
1926  x.is_some() && (y.is_none() || (x != y))
1927}
1928
1929/// Like is_diff_color but treats None as DEFCOLOR (for the `color` field).
1930/// Visual comparison: Gray(0) == Rgb(0,0,0) since both are black.
1931fn is_diff_font_color(x: Option<&Color>, y: Option<&Color>) -> bool {
1932  let cx = x.unwrap_or(&DEFCOLOR);
1933  let cy = y.unwrap_or(&DEFCOLOR);
1934  if cx == cy {
1935    return false;
1936  }
1937  cx.to_rgb() != cy.to_rgb()
1938}
1939
1940/// Reference-style comparison for color field: treats None as DEFCOLOR.
1941/// Unlike is_diff_font_color, does NOT fall back to visual to_rgb() comparison.
1942/// Cmyk(0,0,0,1) IS different from Rgb(0,0,0) even though both are visually black.
1943/// This matches Perl's `ne` reference equality: two Color objects at different
1944/// addresses are "different" even if they represent the same visual color.
1945/// In our model, different Color variants = different Perl references.
1946fn is_diff_font_color_ref(x: Option<&Color>, y: Option<&Color>) -> bool {
1947  let cx = x.unwrap_or(&DEFCOLOR);
1948  let cy = y.unwrap_or(&DEFCOLOR);
1949  cx != cy
1950}
1951
1952/// Matches fonts when both are converted to toString strings.
1953/// Uses regex caching for repeated lookups.
1954/// Perl: match_font
1955pub fn match_font(font1: &str, font2: &str) -> bool {
1956  // Build a regex from font1 where '*' components become wildcards
1957  if let Some(inner) = font1
1958    .strip_prefix("Font[")
1959    .and_then(|s| s.strip_suffix(']'))
1960  {
1961    let comps: Vec<&str> = inner.split(',').collect();
1962    let re_str = format!(
1963      "^Font\\[{}\\]$",
1964      comps
1965        .iter()
1966        .map(|c| if *c == "*" {
1967          "[^,]+".to_string()
1968        } else {
1969          regex::escape(c)
1970        })
1971        .collect::<Vec<_>>()
1972        .join(",")
1973    );
1974    if let Ok(re) = Regex::new(&re_str) {
1975      return re.is_match(font2);
1976    }
1977  }
1978  false
1979}
1980
1981/// Generate XPath fragments for font matching.
1982/// Perl: font_match_xpaths
1983pub fn font_match_xpaths(font: &str) -> String {
1984  if let Some(inner) = font.strip_prefix("Font[").and_then(|s| s.strip_suffix(']')) {
1985    let comps: Vec<&str> = inner.split(',').collect();
1986    // Only check family, series, shape (indices 0, 1, 2)
1987    let mut frags: Vec<String> = Vec::new();
1988    if !comps.is_empty() && comps[0] != "*" {
1989      frags.push(format!("[{},", comps[0]));
1990    }
1991    if comps.len() > 1 && comps[1] != "*" {
1992      frags.push(format!(",{},", comps[1]));
1993    }
1994    if comps.len() > 2 && comps[2] != "*" {
1995      frags.push(format!(",{},", comps[2]));
1996    }
1997    let mut parts: Vec<String> = vec!["@_font".to_string()];
1998    for frag in frags {
1999      parts.push(format!("contains(@_font,'{frag}')"));
2000    }
2001    parts.join(" and ")
2002  } else {
2003    "@_font".to_string()
2004  }
2005}
2006
2007/// Decode a codepoint using the fontmap for a given font and/or fontencoding.
2008///
2009/// If `encoding` not provided, then lookup according to the current font's
2010/// encoding; the font family may also be used to choose the fontmap (think tt fonts!).
2011/// When `implicit` is false, we are "explicitly" asking for a decoding, such as
2012/// with \char, \mathchar, \symbol, DeclareTextSymbol and such cases.
2013/// In such cases, only codepoints specifically within the map are covered; the rest are undef.
2014/// If `implicit` is true, we'll decode token content that has made it to the stomach:
2015/// We're going to assume that SOME sort of handling of input encoding is taking place,
2016/// so that if anything above 128 comes in, it must already be Unicode!.
2017/// The lower half plane still needs to go through decoding, though, to deal
2018/// with TeX's rearrangement of ASCII...
2019/// Push a fontmap character to a string, handling known multi-char entries.
2020fn push_fontmap_char(result: &mut String, c: char, _code: u8) {
2021  // T1 position 223: "SS" (capital sharp S as two chars)
2022  if c == '\u{1E9E}' {
2023    result.push_str("SS");
2024    return;
2025  }
2026  // For standalone combining characters (Unicode Mn category), prepend NBSP as base.
2027  // Perl fontmaps encode these as UTF(0xA0)."\x{combining}" (two-char strings).
2028  if is_combining_mark(c) {
2029    result.push('\u{00A0}');
2030  }
2031  result.push(c);
2032}
2033
2034/// Check if a character is a Unicode combining mark (category Mn).
2035fn is_combining_mark(c: char) -> bool {
2036  matches!(c as u32,
2037    0x0300..=0x036F   // Combining Diacritical Marks
2038    | 0x1AB0..=0x1AFF // Combining Diacritical Marks Extended
2039    | 0x1DC0..=0x1DFF // Combining Diacritical Marks Supplement
2040    | 0x20D0..=0x20FF // Combining Diacritical Marks for Symbols
2041    | 0xFE20..=0xFE2F // Combining Half Marks
2042  )
2043}
2044
2045/// Decode a codepoint, returning a SymStr that may contain multiple characters.
2046/// This handles Perl font map entries like `UTF(0xA0)."\x{0335}"` (OT1 pos 32).
2047pub fn decode_str(code: u8, encoding_opt: Option<String>, implicit: bool) -> Option<SymStr> {
2048  // First, check for multi-char overrides (for entries that can't fit in Option<char>)
2049  if let Some(s) = lookup_multichar_override(code, encoding_opt.as_deref()) {
2050    return Some(arena::pin(s));
2051  }
2052  if let Some(c) = decode(code, encoding_opt, implicit) {
2053    // T1 position 223: "SS" (capital sharp S as two chars)
2054    if c == '\u{1E9E}' {
2055      return Some(pin!("SS"));
2056    }
2057    // For standalone combining characters, prepend NBSP as base character
2058    // (Perl fontmaps encode these as UTF(0xA0)."\x{combining}")
2059    if is_combining_mark(c) {
2060      return Some(arena::pin(format!("\u{00A0}{c}")));
2061    }
2062    Some(arena::pin_char(c))
2063  } else {
2064    None
2065  }
2066}
2067
2068/// Look up multi-char override for a given encoding position.
2069/// Returns Some(String) if a multi-char override exists.
2070fn lookup_multichar_override(code: u8, encoding_opt: Option<&str>) -> Option<String> {
2071  let encoding = match encoding_opt {
2072    Some(enc) if !enc.is_empty() => enc.to_string(),
2073    _ => {
2074      let font = lookup_font();
2075      font.and_then(|f| f.get_encoding().map(|e| e.to_string()))?
2076    },
2077  };
2078  if encoding.is_empty() {
2079    return None;
2080  }
2081  // The multichar table ships in the same binding as the map array, so it is
2082  // absent until that binding is loaded. This lookup runs BEFORE `decode` —
2083  // which is what triggers the load — so without preloading here, the very
2084  // first decode for an encoding misses the table and silently falls back to
2085  // the single-char array value.
2086  //
2087  // That is not merely a lost first call: `\DeclareTextSymbol` bakes the
2088  // decoded result into a primitive body at DECLARATION time, in the preamble,
2089  // before any `\fontencoding{T2B}` has loaded the map. So T2B slot 128 was
2090  // frozen as `Ӷ` (U+04F6) instead of `Ӷ̶` (U+04F6 U+0336) — a different
2091  // letter, its stroke dropped — for the rest of the document. Perl has no
2092  // such hazard: `FontDecode` calls `LoadFontMap` first and then indexes one
2093  // map whose slot already holds the whole string.
2094  let _ = preload_font_map(&encoding);
2095  let mapname = format!("{encoding}_fontmap_multichar");
2096  with_value(&mapname, |val_opt| {
2097    if let Some(Stored::HashString(map)) = val_opt {
2098      map.get(&code.to_string()).cloned()
2099    } else {
2100      None
2101    }
2102  })
2103}
2104
2105pub fn decode(code: u8, encoding_opt: Option<String>, implicit: bool) -> Option<char> {
2106  let mut font = None;
2107  let encoding = match encoding_opt {
2108    Some(enc) => Cow::Owned(enc),
2109    None => {
2110      // Perl `FontDecode`: `$encoding = $font->getEncoding || 'OT1'`
2111      // (Package.pm L2877). The `|| 'OT1'` is NOT shared with
2112      // `FontDecodeString` (L2906), whose port is `decode_string` below and
2113      // which deliberately keeps the empty fallback — do not "align" the two.
2114      //
2115      // This branch is the one `\char` reaches (via `decode_str`, encoding
2116      // `None`). It matters in MATH mode, where `Font::math_default()` sets
2117      // `encoding: None` on purpose: without the default, the lookup ran
2118      // against the empty encoding and `$\char65$` decoded to NOTHING where
2119      // Perl gives `A`. The default lives here, rather than at the call site,
2120      // so the font stays in scope for the `<enc>_<family>_fontmap`
2121      // refinement below — resolving the encoding earlier and passing it in
2122      // would silently drop `\ttfamily`'s `OT1_typewriter` variant.
2123      font = lookup_font();
2124      if let Some(ref font) = font {
2125        match font.get_encoding() {
2126          None => Cow::Borrowed("OT1"),
2127          Some(encoding) => encoding.clone(),
2128        }
2129      } else {
2130        Cow::Borrowed("OT1")
2131      }
2132    },
2133  };
2134
2135  let mut map: Option<Fontmap> = None;
2136  if !encoding.is_empty() {
2137    let _ = preload_font_map(&encoding); // infallible in practice; swallow Result
2138    if let Some(encmap) = load_font_map(&encoding) {
2139      // OK got some map.
2140      map = Some(encmap);
2141      if let Some(ref font) = font
2142        && let Some(family) = (*font).get_family()
2143      {
2144        with_value(&s!("{encoding}_{family}_fontmap"), |fmap_opt| {
2145          if let Some(fmap) = fmap_opt {
2146            map = fmap.into(); // Use the family specific map, if any.
2147          }
2148        });
2149      }
2150    }
2151  }
2152
2153  if implicit {
2154    if let Some(map) = map {
2155      if code < 128 {
2156        match map.get(code as usize) {
2157          None => None,
2158          Some(c) => *c,
2159        }
2160      } else {
2161        Some(code.into())
2162      }
2163    } else {
2164      Some(code.into())
2165    }
2166  } else if let Some(map) = map {
2167    match map.get(code as usize) {
2168      None => None,
2169      Some(c) => *c,
2170    }
2171  } else {
2172    None
2173  }
2174}
2175
2176pub fn decode_string(string: SymStr, encoding_opt: Option<&str>, implicit: bool) -> SymStr {
2177  let empty_sym = pin!("");
2178  if string == empty_sym {
2179    return empty_sym;
2180  }
2181  let mut font = None;
2182  let encoding = match encoding_opt {
2183    None => {
2184      font = lookup_font();
2185      if let Some(ref font) = font {
2186        font.get_encoding().unwrap_or(&Cow::Borrowed(""))
2187      } else {
2188        ""
2189      }
2190    },
2191    Some(encoding) => encoding,
2192  };
2193
2194  let mut map: Option<Fontmap> = None;
2195  if !encoding.is_empty() {
2196    let _ = preload_font_map(encoding); // infallible in practice; swallow Result
2197    if let Some(encmap) = load_font_map(encoding) {
2198      // OK got some map.
2199      map = Some(encmap);
2200      if let Some(ref font) = font
2201        && let Some(family) = (*font).get_family()
2202      {
2203        with_value(&s!("{}_{}_fontmap", encoding, family), |fmap_opt| {
2204          if let Some(fmap) = fmap_opt {
2205            map = fmap.into(); // Use the family specific map, if any.
2206          }
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    let mapname = format!("{encoding}_fontmap_multichar");
2215    with_value(&mapname, |val_opt| {
2216      if let Some(Stored::HashString(m)) = val_opt {
2217        Some(m.clone())
2218      } else {
2219        None
2220      }
2221    })
2222  } else {
2223    None
2224  };
2225
2226  let mut result_string: String = String::new();
2227  arena::with(string, |str| {
2228    for c in str.chars() {
2229      if implicit {
2230        if let Some(ref map_ref) = map {
2231          let code = c as u16; // u16, so that Unicode chars get cast correctly
2232          if code < 128 {
2233            // Check multi-char override first
2234            if let Some(ref mc) = multichar_map
2235              && let Some(mc_str) = mc.get(&(code as u8).to_string())
2236            {
2237              result_string.push_str(mc_str);
2238              continue;
2239            }
2240            if let Some(Some(mapc_val)) = map_ref.get(code as usize) {
2241              push_fontmap_char(&mut result_string, *mapc_val, code as u8);
2242            }
2243          } else {
2244            result_string.push(c);
2245          }
2246        } else {
2247          result_string.push(c)
2248        }
2249      } else if let Some(ref map_ref) = map {
2250        let code = c as u8;
2251        // Check multi-char override first
2252        if let Some(ref mc) = multichar_map
2253          && let Some(mc_str) = mc.get(&code.to_string())
2254        {
2255          result_string.push_str(mc_str);
2256          continue;
2257        }
2258        if let Some(Some(mapc_val)) = map_ref.get(code as usize) {
2259          push_fontmap_char(&mut result_string, *mapc_val, code);
2260        }
2261      }
2262    }
2263  });
2264  arena::pin(result_string)
2265}
2266
2267/// Convert stanard font size names, such as `tiny`, `Huge`, etc to f64
2268pub fn rationalize_font_size(size: &str) -> f64 {
2269  if let Some(symbolic) = FONT_SIZE.get(size) {
2270    *symbolic * defsize()
2271  } else {
2272    // Perl: return $size — if not a symbolic name, return the numeric value as-is
2273    size.parse::<f64>().unwrap_or_else(|_| defsize())
2274  }
2275}
2276
2277/// convert size to percent
2278pub fn relative_font_size(newsize: f64, oldsize: f64) -> String {
2279  s!("{}%", (0.5 + 100.0 * newsize / oldsize).floor())
2280}
2281
2282#[cfg(test)]
2283mod tests {
2284  use super::*;
2285
2286  #[test]
2287  fn relative_font_size_same_is_100() {
2288    assert_eq!(relative_font_size(10.0, 10.0), "100%");
2289    assert_eq!(relative_font_size(12.0, 12.0), "100%");
2290  }
2291
2292  #[test]
2293  fn relative_font_size_doubled_is_200() {
2294    assert_eq!(relative_font_size(20.0, 10.0), "200%");
2295  }
2296
2297  #[test]
2298  fn relative_font_size_half_is_50() {
2299    assert_eq!(relative_font_size(5.0, 10.0), "50%");
2300  }
2301
2302  /// #542: NOMINAL_FONT_SIZE is a float, not an integer — the `11pt` class option
2303  /// is `10.95` (LaTeX's `\@xipt`), which the reader must not truncate. Perl
2304  /// `DEFSIZE` reads `lookupValue('NOMINAL_FONT_SIZE')` directly as a float
2305  /// (`Common/Font.pm:44`); the Rust reader used to go through `lookup_int`.
2306  #[test]
2307  fn defsize_preserves_fractional_nominal_font_size() {
2308    use crate::{
2309      common::float::Float,
2310      state::{State, StateOptions, assign_value, set_state},
2311    };
2312    set_state(State::new(StateOptions::default()));
2313    // Unset → default 10.
2314    assert_eq!(defsize(), 10.0, "defsize defaults to 10 when unset");
2315    // 11pt → 10.95, the fractional value the old lookup_int truncated to 10.
2316    assign_value("NOMINAL_FONT_SIZE", Float(10.95), None);
2317    assert_eq!(
2318      defsize(),
2319      10.95,
2320      "defsize preserves the fractional 11pt size"
2321    );
2322    // An integral float still reads back cleanly.
2323    assign_value("NOMINAL_FONT_SIZE", Float(12.0), None);
2324    assert_eq!(defsize(), 12.0, "12pt reads back as 12.0");
2325  }
2326
2327  #[test]
2328  fn match_font_exact_wildcard_tail() {
2329    // Font[family,series,shape,size,...] — '*' matches any single
2330    // component.
2331    // match_font(f1, f2) returns true iff f2 matches the pattern f1.
2332    // f1 with all-wildcards should match any well-formed Font[...].
2333    assert!(match_font("Font[*,*,*,*]", "Font[rm,med,up,10]"));
2334  }
2335
2336  #[test]
2337  fn match_font_exact_match() {
2338    assert!(match_font("Font[rm,med,up,10]", "Font[rm,med,up,10]"));
2339    assert!(!match_font("Font[rm,med,up,10]", "Font[sf,med,up,10]"));
2340  }
2341
2342  #[test]
2343  fn match_font_partial_wildcard() {
2344    // First position wildcard matches rm, sf, tt, etc.
2345    assert!(match_font("Font[*,med,up,10]", "Font[rm,med,up,10]"));
2346    assert!(match_font("Font[*,med,up,10]", "Font[sf,med,up,10]"));
2347    // But a non-wildcard in series must match.
2348    assert!(!match_font("Font[*,bold,up,10]", "Font[rm,med,up,10]"));
2349  }
2350
2351  #[test]
2352  fn match_font_malformed_input() {
2353    // Missing Font[...] wrapper → false.
2354    assert!(!match_font("not_a_font", "Font[rm,med,up,10]"));
2355  }
2356
2357  #[test]
2358  fn font_match_xpaths_all_wildcards_is_attr_only() {
2359    let xp = font_match_xpaths("Font[*,*,*,*]");
2360    // All wildcards → just @_font, no contains(...) fragments.
2361    assert_eq!(xp, "@_font");
2362  }
2363
2364  #[test]
2365  fn font_match_xpaths_includes_specified_components() {
2366    let xp = font_match_xpaths("Font[rm,bold,*,*]");
2367    // Family and series specified; shape/size wildcarded.
2368    assert!(xp.contains("@_font"));
2369    assert!(xp.contains("contains"));
2370    assert!(xp.contains("rm"));
2371    assert!(xp.contains("bold"));
2372  }
2373
2374  #[test]
2375  fn font_match_xpaths_malformed_is_empty_or_fallback() {
2376    let xp = font_match_xpaths("garbage");
2377    // Not a Font[...] format → some minimal/fallback output.
2378    // Implementation detail: we don't over-constrain, just verify
2379    // it doesn't panic.
2380    let _ = xp;
2381  }
2382}