Skip to content

Commit e2b0ce8

Browse files
committed
refactor: Update italic correction handling for subscript nodes in layout engine
1 parent 62f5923 commit e2b0ce8

6 files changed

Lines changed: 83 additions & 20 deletions

File tree

crates/ratex-layout/src/engine.rs

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,10 +1213,22 @@ fn layout_supsub(
12131213
// (KaTeX `margin-right: italic`), so we must not add `glyph_italic` again here.
12141214
let italic_correction = 0.0;
12151215

1216-
// KaTeX `supsub.js`: for SymbolNode bases, subscripts get `margin-left: -base.italic` so they
1217-
// are not shifted by the base's italic correction (e.g. ∫_{A_1}).
1216+
// KaTeX `supsub.js`: only a direct `SymbolNode` base (plus the synthetic `\oiint` /
1217+
// `\oiiint` operators) gets `margin-left: -base.italic`. In particular, spans such as
1218+
// `\text{\textit{CPI}}` and `{x}` must not inherit the italic correction of their last glyph.
12181219
let sub_h_kern = if sub_box.is_some() && !center_scripts {
1219-
-glyph_italic(&base_box)
1220+
let direct_italic = direct_glyph_italic(&base_box);
1221+
let is_oiint = matches!(
1222+
base,
1223+
Some(ParseNode::Op {
1224+
name: Some(name),
1225+
..
1226+
}) if name == "\\oiint" || name == "\\oiiint"
1227+
);
1228+
let base_italic = direct_italic
1229+
.or_else(|| is_oiint.then(|| first_glyph_italic(&base_box)).flatten())
1230+
.unwrap_or(0.0);
1231+
-base_italic
12201232
} else {
12211233
0.0
12221234
};
@@ -1782,24 +1794,28 @@ fn layout_operatorname(body: &[ParseNode], options: &LayoutOptions) -> LayoutBox
17821794
/// `\vec` KaTeX SVG: nudge slightly right to match KaTeX reference.
17831795
const VEC_SKEW_EXTRA_RIGHT_EM: f64 = 0.018;
17841796

1785-
/// Extract the italic correction of the base glyph.
1786-
/// Used by superscripts: KaTeX adds margin-right = italic_correction to italic math characters,
1787-
/// so the superscript starts at advance_width + italic_correction (not just advance_width).
1788-
fn glyph_italic(lb: &LayoutBox) -> f64 {
1789-
let mut current = lb;
1790-
loop {
1791-
match &current.content {
1792-
BoxContent::Glyph { font_id, char_code } => {
1793-
return get_char_metrics(*font_id, *char_code)
1794-
.map(|m| m.italic)
1795-
.unwrap_or(0.0);
1796-
}
1797-
BoxContent::HBox(children) => match children.last() {
1798-
Some(last) => current = last,
1799-
None => return 0.0,
1800-
},
1801-
_ => return 0.0,
1797+
/// Extract the italic correction only when this box itself is a glyph.
1798+
///
1799+
/// This distinction mirrors KaTeX's `base instanceof SymbolNode` check. Recursing through an
1800+
/// `HBox` would incorrectly treat text/group spans as symbols and pull their subscripts left.
1801+
fn direct_glyph_italic(lb: &LayoutBox) -> Option<f64> {
1802+
match &lb.content {
1803+
BoxContent::Glyph { font_id, char_code } => {
1804+
get_char_metrics(*font_id, *char_code).map(|m| m.italic)
18021805
}
1806+
_ => None,
1807+
}
1808+
}
1809+
1810+
/// Find the first glyph inside a box.
1811+
///
1812+
/// KaTeX special-cases `\oiint` and `\oiiint`: their overlay makes the base a span rather than a
1813+
/// `SymbolNode`, but the underlying integral glyph's italic correction still applies.
1814+
fn first_glyph_italic(lb: &LayoutBox) -> Option<f64> {
1815+
match &lb.content {
1816+
BoxContent::Glyph { .. } => direct_glyph_italic(lb),
1817+
BoxContent::HBox(children) => children.iter().find_map(first_glyph_italic),
1818+
_ => None,
18031819
}
18041820
}
18051821

crates/ratex-layout/tests/layout_vs_katex.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,3 +701,48 @@ fn mathrm_mm_squared_both_m_upright() {
701701
"both m should be MainRegular, got {m_fonts:?}"
702702
);
703703
}
704+
705+
#[test]
706+
fn subscript_italic_kern_only_applies_to_symbol_nodes() {
707+
use ratex_layout::layout_box::{BoxContent, LayoutBox};
708+
709+
fn sub_h_kern(lb: &LayoutBox) -> Option<f64> {
710+
match &lb.content {
711+
BoxContent::SupSub { sub_h_kern, .. } => Some(*sub_h_kern),
712+
BoxContent::HBox(children) => children.iter().find_map(sub_h_kern),
713+
_ => None,
714+
}
715+
}
716+
717+
fn kern_for(expr: &str) -> f64 {
718+
let ast = parse(expr).expect("parse");
719+
let lbox = layout(&ast, &LayoutOptions::default());
720+
sub_h_kern(&lbox).expect("supsub box")
721+
}
722+
723+
assert!(
724+
kern_for(r"f_i") < 0.0,
725+
"a direct math symbol keeps its italic kern"
726+
);
727+
assert!(
728+
kern_for(r"\mathit{f}_i") < 0.0,
729+
"a single-glyph font node builds a direct symbol"
730+
);
731+
assert!(
732+
kern_for(r"\oiint_i") < 0.0,
733+
"KaTeX special-cases synthetic multi-integral operators"
734+
);
735+
736+
for expr in [
737+
r"{f}_i",
738+
r"\mathit{ff}_i",
739+
r"\text{\textit{CPI}}_t",
740+
r"\color{red}{f}_i",
741+
] {
742+
assert_eq!(
743+
kern_for(expr),
744+
0.0,
745+
"span/group base must not inherit a nested glyph's italic correction: {expr}"
746+
);
747+
}
748+
}

tests/golden/fixtures/1051.png

16.2 KB
Loading

tests/golden/output/1051.png

26.7 KB
Loading

tests/golden/output_svg/1051.svg

Lines changed: 1 addition & 0 deletions
Loading

tests/golden/test_cases.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,3 +1048,4 @@ a · b
10481048
⨆_{n} Q_n
10491049
\textcolor{#ff000050}{x}
10501050
○\div□=5\quad□\div○=5
1051+
{\text{\textit{Inflation}}} = 100 * \left({\text{\textit{CPI}}_{t} - \text{\textit{CPI}}_{t-1} \over \text{\textit{CPI}}_{t-1}}\right)

0 commit comments

Comments
 (0)