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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion crates/ratex-font/src/data/symbols_data.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Auto-generated from KaTeX symbols.ts - DO NOT EDIT
// Auto-generated from KaTeX symbols.ts and RaTeX extensions - DO NOT EDIT

/// Symbol definition: (name, mode, font, group, codepoint)
/// mode: 0 = math, 1 = text
Expand Down Expand Up @@ -1890,4 +1890,14 @@ pub static SYMBOLS: &[SymbolEntry] = &[
("\\@urcorner", 0, 1, "close", Some('\u{2510}')),
("\\@llcorner", 0, 1, "open", Some('\u{2514}')),
("\\@lrcorner", 0, 1, "close", Some('\u{2518}')),
// RaTeX-specific symbol aliases
("\u{25CB}", 0, 0, "textord", Some('\u{25EF}')),
];

/// RaTeX-specific glyph fitting: (name, mode, target font, target glyph)
pub type SymbolRenderSpecEntry = (&'static str, u8, u8, char);

pub static SYMBOL_RENDER_SPECS: &[SymbolRenderSpecEntry] = &[
// Fit replacement glyphs to their semantic peers' metric boxes.
("\u{25CB}", 0, 1, '\u{25A1}'),
];
3 changes: 2 additions & 1 deletion crates/ratex-font/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ pub use metrics::{
get_global_metrics, CharMetrics, MathConstants, MATH_CONSTANTS_BY_SIZE,
};
pub use symbols::{
get_math_symbol, get_symbol, get_text_symbol, Group, Mode, SymbolFont, SymbolInfo,
get_math_symbol, get_symbol, get_symbol_render_spec, get_text_symbol, Group, Mode, SymbolFont,
SymbolInfo, SymbolRenderSpec,
};
61 changes: 61 additions & 0 deletions crates/ratex-font/src/symbols.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ pub struct SymbolInfo {
pub codepoint: Option<char>,
}

/// Presentation metadata for a symbol whose bundled replacement glyph must be
/// fitted to another symbol's metric box.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SymbolRenderSpec {
pub target_font: SymbolFont,
pub target_codepoint: char,
}

/// (mode, name) → index into SYMBOLS array
type SymbolIndex = HashMap<(u8, &'static str), usize>;
/// (mode, codepoint) → index into SYMBOLS array
Expand Down Expand Up @@ -174,6 +182,25 @@ pub fn get_symbol_by_codepoint(ch: char, mode: Mode) -> Option<SymbolInfo> {
.map(|&idx| entry_to_info(idx, mode))
}

/// Look up optional data-driven glyph fitting metadata for a symbol.
pub fn get_symbol_render_spec(name: &str, mode: Mode) -> Option<SymbolRenderSpec> {
let mode_val: u8 = match mode {
Mode::Math => 0,
Mode::Text => 1,
};
symbols_data::SYMBOL_RENDER_SPECS
.iter()
.find(|&&(entry_name, entry_mode, _, _)| entry_name == name && entry_mode == mode_val)
.map(|&(_, _, target_font, target_codepoint)| SymbolRenderSpec {
target_font: if target_font == 0 {
SymbolFont::Main
} else {
SymbolFont::Ams
},
target_codepoint,
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -294,4 +321,38 @@ mod tests {
assert_eq!(sym.group, Group::MathOrd);
assert_eq!(sym.codepoint, Some('α'));
}

#[test]
fn white_circle_uses_large_circle_glyph_as_textord() {
let sym = get_symbol("○", Mode::Math).unwrap();
assert_eq!(sym.name, "○");
assert_eq!(sym.group, Group::TextOrd);
assert_eq!(sym.font, SymbolFont::Main);
assert_eq!(sym.codepoint, Some('◯'));
assert_eq!(
get_symbol_render_spec("○", Mode::Math),
Some(SymbolRenderSpec {
target_font: SymbolFont::Ams,
target_codepoint: '□',
})
);

let bigcirc = get_math_symbol("\\bigcirc").unwrap();
assert_eq!(bigcirc.group, Group::Bin);
assert_eq!(bigcirc.codepoint, Some('◯'));

// U+25EF LARGE CIRCLE remains the raw-Unicode spelling of \bigcirc.
// The U+25CB alias is appended after KaTeX symbols specifically so it
// cannot take precedence in the replacement-codepoint index.
let raw_bigcirc = get_symbol("◯", Mode::Math).unwrap();
assert_eq!(raw_bigcirc.name, "\\bigcirc");
assert_eq!(raw_bigcirc.group, Group::Bin);
assert_eq!(raw_bigcirc.codepoint, Some('◯'));
}

#[test]
fn white_circle_extension_is_math_mode_only() {
assert!(get_symbol("○", Mode::Text).is_none());
assert!(get_symbol_render_spec("○", Mode::Text).is_none());
}
}
103 changes: 103 additions & 0 deletions crates/ratex-layout/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,9 @@ fn layout_symbol(text: &str, mode: Mode, options: &LayoutOptions) -> LayoutBox {
if text == "\\textunderscore" {
return make_textunderscore_box(options);
}
if let Some(fitted) = layout_symbol_from_render_spec(text, mode, options, None) {
return fitted;
}

// Synthetic symbols not present in any KaTeX font; built from SVG paths.
match ch as u32 {
Expand Down Expand Up @@ -801,6 +804,101 @@ fn layout_symbol(text: &str, mode: Mode, options: &LayoutOptions) -> LayoutBox {
}
}

/// Fit a bundled replacement glyph into another symbol's metric box.
///
/// This keeps the input symbol's TeX atom class independent from presentation:
/// U+25CB WHITE CIRCLE remains an ordinary atom, while its bundled U+25EF glyph
/// is uniformly scaled and centered in U+25A1 WHITE SQUARE's metric box.
fn layout_symbol_from_render_spec(
text: &str,
mode: Mode,
options: &LayoutOptions,
preferred_source_font: Option<FontId>,
) -> Option<LayoutBox> {
let symbol_mode = match mode {
Mode::Math => ratex_font::Mode::Math,
Mode::Text => ratex_font::Mode::Text,
};
let spec = ratex_font::get_symbol_render_spec(text, symbol_mode)?;
let symbol = ratex_font::get_symbol(text, symbol_mode)?;
let source_codepoint = symbol.codepoint? as u32;
let default_source_font = symbol_font_id(symbol.font);
let target_font = symbol_font_id(spec.target_font);

let (source_font, source) = preferred_source_font
.and_then(|font_id| {
get_char_metrics(font_id, source_codepoint).map(|metrics| (font_id, metrics))
})
.or_else(|| {
get_char_metrics(default_source_font, source_codepoint)
.map(|metrics| (default_source_font, metrics))
})?;
let target = get_char_metrics(target_font, spec.target_codepoint as u32)?;

let source_width = math_glyph_advance_em(&source, mode);
let source_total = source.height + source.depth;
let target_width = math_glyph_advance_em(&target, mode);
let target_total = target.height + target.depth;
if source_width <= 0.0 || source_total <= 0.0 || target_width <= 0.0 || target_total <= 0.0 {
return None;
}

// Preserve the replacement glyph's aspect ratio and contain it in the
// target box. Any spare space is divided equally on the corresponding axis.
let glyph_scale = (target_width / source_width).min(target_total / source_total);
let scaled_width = source_width * glyph_scale;
let side_padding = ((target_width - scaled_width) / 2.0).max(0.0);
let source_center = (source.height - source.depth) * glyph_scale / 2.0;
let target_center = (target.height - target.depth) / 2.0;
let raise = target_center - source_center;

let glyph = LayoutBox {
width: source_width,
height: source.height,
depth: source.depth,
content: BoxContent::Glyph {
font_id: source_font,
char_code: source_codepoint,
},
color: options.color,
};
let scaled = LayoutBox {
width: scaled_width,
height: source.height * glyph_scale,
depth: source.depth * glyph_scale,
content: BoxContent::Scaled {
body: Box::new(glyph),
child_scale: glyph_scale,
},
color: options.color,
};
let centered = LayoutBox {
width: scaled_width,
height: target.height,
depth: target.depth,
content: BoxContent::RaiseBox {
body: Box::new(scaled),
shift: raise,
},
color: options.color,
};

let mut result = make_hbox(vec![
LayoutBox::new_kern(side_padding),
centered,
LayoutBox::new_kern(side_padding),
]);
result.color = options.color;
Some(result)
}

fn symbol_font_id(font: ratex_font::SymbolFont) -> FontId {
match font {
ratex_font::SymbolFont::Main => FontId::MainRegular,
ratex_font::SymbolFont::Ams => FontId::AmsRegular,
}
}

/// Resolve a symbol name to its actual character.
fn resolve_symbol_char(text: &str, mode: Mode) -> char {
let font_mode = match mode {
Expand Down Expand Up @@ -3566,6 +3664,11 @@ fn layout_with_font(node: &ParseNode, font_id: FontId, options: &LayoutOptions)
ParseNode::MathOrd { text, mode, .. }
| ParseNode::TextOrd { text, mode, .. }
| ParseNode::Atom { text, mode, .. } => {
if let Some(fitted) =
layout_symbol_from_render_spec(text, *mode, options, Some(font_id))
{
return fitted;
}
let ch = resolve_symbol_char(text, *mode);
let char_code = ch as u32;
let metric_cp = ratex_font::font_and_metric_for_mathematical_alphanumeric(char_code)
Expand Down
71 changes: 71 additions & 0 deletions crates/ratex-layout/tests/layout_vs_katex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,77 @@ fn binary_op_a_plus_b() {
check("a+b", 0.69444, 0.08333);
}

#[test]
fn unicode_white_circle_matches_square_metric_box() {
let circle = layout_with_style("○", MathStyle::Display);
let square = layout_with_style("□", MathStyle::Display);
let bigcirc = layout_with_style("\\bigcirc", MathStyle::Display);
let circle_expr = layout_with_style("○\\div□=5", MathStyle::Display);
let square_expr = layout_with_style("□\\div□=5", MathStyle::Display);
let bold_circle = layout_with_style("\\mathbf{○}", MathStyle::Display);

assert!((circle.width - square.width).abs() < TOLERANCE);
assert!((circle.height - square.height).abs() < TOLERANCE);
assert!((circle.depth - square.depth).abs() < TOLERANCE);
assert!(bigcirc.width > circle.width);
assert!((circle_expr.width - square_expr.width).abs() < TOLERANCE);
assert!((bold_circle.width - square.width).abs() < TOLERANCE);
assert!((bold_circle.height - square.height).abs() < TOLERANCE);
assert!((bold_circle.depth - square.depth).abs() < TOLERANCE);

let circle_display = to_display_list(&circle);
let square_display = to_display_list(&square);
let glyph_bounds = |display: &DisplayList| {
display
.items
.iter()
.find_map(|item| match item {
DisplayItem::GlyphPath {
x,
y,
scale,
font,
char_code,
..
} => {
let font_id = ratex_font::FontId::parse(font).unwrap();
let metrics = ratex_font::get_char_metrics(font_id, *char_code).unwrap();
Some((
*x,
x + metrics.width * scale,
y - metrics.height * scale,
y + metrics.depth * scale,
*scale,
font_id,
*char_code,
))
}
_ => None,
})
.unwrap()
};
let circle_glyph = glyph_bounds(&circle_display);
let square_glyph = glyph_bounds(&square_display);

assert_eq!(circle_glyph.5, ratex_font::FontId::MainRegular);
assert_eq!(circle_glyph.6, '◯' as u32);
assert!(circle_glyph.4 < 1.0);
assert!((circle_glyph.0 + circle_glyph.1 - square_glyph.0 - square_glyph.1).abs() < TOLERANCE);
assert!((circle_glyph.2 - square_glyph.2).abs() < TOLERANCE);
assert!((circle_glyph.3 - square_glyph.3).abs() < TOLERANCE);

let bold_display = to_display_list(&bold_circle);
assert!(bold_display.items.iter().any(|item| matches!(
item,
DisplayItem::GlyphPath {
font,
char_code,
scale,
..
} if font == "Main-Bold" && *char_code == '◯' as u32 && *scale < 1.0
)));
}

#[test]
fn relational_eq() {
check("a+b=c", 0.69444, 0.08333);
Expand Down
14 changes: 14 additions & 0 deletions crates/ratex-parser/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ mod core_parsing {
assert_eq!(ast[0].symbol_text(), Some("5"));
}

#[test]
fn unicode_white_circle_is_math_textord() {
let ast = parse("○").unwrap();
assert_eq!(ast.len(), 1);
assert!(matches!(
&ast[0],
ParseNode::TextOrd {
mode: crate::Mode::Math,
text,
..
} if text == "○"
));
}

#[test]
fn empty_input() {
let ast = parse("").unwrap();
Expand Down
Binary file added tests/golden/output/1050.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading