fix(math): review round 3 — mapping bug, whitespace defect, real MATH gaps
F1 was a real bug pinned by my own committed test. `end` in
ChunkSource::MathBox is EXCLUSIVE, so source position `end` is the first byte
AFTER the span — but the arm claimed it for the box's left edge, and the test
asserted that wrong value while calling the byte "interior". Consequences it
would have caused once overlays land: a search match starting just after a
span washes the whole box it does not intersect, violating Q#MS11; a peer
caret after the span draws at the box's left edge; caret geometry jumps
backwards. The same class existed in projected_to_source for a line-FINAL box,
where `within` clamps to the run length and the arm returned `start`
unconditionally, so a click past end-of-line landed on the span start. Both
committed hit tests put a chunk after the box, so that edge was never
exercised; there is now a test with the box last.
F2: parse_scripts peeked for the next marker without skipping whitespace, so
`x^2 _i` built a NESTED script — drawing the subscript displaced right by the
superscript's width — and `x^2 ^3` parsed where TeX errors, contradicting the
module's own "whitespace is insignificant" rule.
F3: layout is now fallible. A character the math font cannot draw used to
yield zero metrics and still emit a Glyph item, rendering tofu at zero advance
over its neighbour. Q#MS8's rule is "failure is always show the source", and
the draw pass needs a refusal signal — changed now, before that pass consumes
the API.
F4: the fraction gap was a hardcoded `thickness * 2.0` while the MATH table's
FractionNumeratorGapMin / FractionDenominatorGapMin went unread. Reading them
moved the flagship \frac{a}{b} from 0.732 to 0.867 and the fallback boundary
from depth 3 to depth 5. The round-2 review's hand-arithmetic estimate of
~0.85 was right; my 0.732 was inflated by the guess. The depth-SEARCHING test
absorbed the change without edits, which is the property it was written for.
F5: TeX's \epsilon and \phi are the lunate/symbol forms (U+03F5, U+03D5), not
U+03B5/U+03C6. Their italic mappings had to land with the seed change, since
both sit outside math_italic's U+03B1..03C9 run and would otherwise render
upright beside italic neighbours.
F7: the line-box budget derivation moved out of the test into
`line_box_budget`, so the draw pass and the acceptance test cannot compute
different splits while both stay green.
F8: the live-code clippy items are cleared. The 25 that remain are all
dead-code awaiting the draw pass.
189 pmacs-gpu tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8e4bc00015
commit
cbf7782726
|
|
@ -529,13 +529,20 @@ something reaches the screen runs on a real device through
|
|||
| expression | ascent | descent | scale |
|
||||
| --- | --- | --- | --- |
|
||||
| `x^2`, `\alpha x` | 13.27 | 0.18 | 1.000 |
|
||||
| `\frac{a}{b}` | 11.57 | 6.40 | **0.732** |
|
||||
| `\frac{x^2}{y}` | 15.91 | 5.75 | 0.814 |
|
||||
| nesting depth 2 | — | — | 0.744 |
|
||||
| nesting depth 3 | — | — | **0.580** |
|
||||
| `\frac{a}{b}` | 10.57 | 5.40 | **0.867** |
|
||||
| `\frac{x^2}{y}` | 14.91 | 4.75 | 0.986 |
|
||||
| nesting depth 2 | — | — | 0.872 |
|
||||
| nesting depth 4 | — | — | 0.613 |
|
||||
| nesting depth 5 | — | — | **0.540** |
|
||||
|
||||
So **B6 holds** — the flagship fraction renders at 0.732 — and the
|
||||
fallback case is **depth 3**, not the doubly-nested one rev 3 guessed.
|
||||
So **B6 holds** — the flagship fraction renders at 0.867 — and the
|
||||
fallback case is **depth 5**. Rev 3 guessed depth 2; the first
|
||||
measurement said depth 3 while the fraction gap was still a hardcoded
|
||||
`2 × thickness` guess; reading the MATH table's real
|
||||
`FractionNumeratorGapMin` / `FractionDenominatorGapMin` (round-3 F4)
|
||||
moved the flagship from 0.732 to 0.867 and the boundary to depth 5. The
|
||||
round-2 hand-arithmetic estimate of ~0.85 was right all along; the 0.732
|
||||
was inflated by the guessed gap.
|
||||
Round 2 predicted exactly this trap. Two things worth keeping: depth 2
|
||||
scores *higher* than depth 1 because the binding constraint flips from
|
||||
descent to ascent as nesting grows asymmetrically, so "deeper is always
|
||||
|
|
|
|||
|
|
@ -7743,9 +7743,6 @@ struct ProjectedRun {
|
|||
source: ChunkSource,
|
||||
}
|
||||
|
||||
/// Build the projected→source run map plus the projected text's line
|
||||
/// start table (cosmic-text reports hits as line + byte-within-line).
|
||||
|
||||
#[cfg(test)]
|
||||
mod math_chunk_tests {
|
||||
use super::*;
|
||||
|
|
@ -7812,13 +7809,38 @@ mod math_chunk_tests {
|
|||
];
|
||||
assert_eq!(source_to_projected(&chunks, 0), Some(0));
|
||||
assert_eq!(source_to_projected(&chunks, 2), Some(2));
|
||||
// Interior source bytes collapse onto the left edge.
|
||||
// Interior source bytes collapse onto the left edge. `end` (7) is
|
||||
// EXCLUSIVE and therefore NOT interior — an earlier revision of this
|
||||
// test asserted Some(2) for it and pinned the bug.
|
||||
assert_eq!(source_to_projected(&chunks, 4), Some(2));
|
||||
assert_eq!(source_to_projected(&chunks, 7), Some(2));
|
||||
// The first byte after the span lands past the reserved width.
|
||||
assert_eq!(source_to_projected(&chunks, 6), Some(2));
|
||||
assert_eq!(source_to_projected(&chunks, 7), Some(5), "end is exclusive");
|
||||
assert_eq!(source_to_projected(&chunks, 8), Some(6));
|
||||
}
|
||||
|
||||
/// A box at the END of a line has no following chunk to catch the
|
||||
/// trailing boundary, which is exactly where the interior-snap rule used
|
||||
/// to send a click back to the span start.
|
||||
#[test]
|
||||
fn a_line_final_math_box_maps_its_trailing_boundary_after_the_span() {
|
||||
let chunks = vec![
|
||||
RichChunk {
|
||||
text: "ab".to_owned(),
|
||||
color: None,
|
||||
source: ChunkSource::Source { start: 0 },
|
||||
},
|
||||
spacer_chunk(2, 7, 3),
|
||||
];
|
||||
let (runs, _) = build_hit_runs(&chunks);
|
||||
assert_eq!(projected_to_source(&runs, 2), Some(2), "interior snaps");
|
||||
assert_eq!(projected_to_source(&runs, 4), Some(2), "interior snaps");
|
||||
assert_eq!(
|
||||
projected_to_source(&runs, 5),
|
||||
Some(7),
|
||||
"the trailing boundary lands after the span, not on its start"
|
||||
);
|
||||
}
|
||||
|
||||
/// A math chunk carries generated spacer text, so tab expansion must
|
||||
/// leave it alone rather than treating a space as a source tab.
|
||||
#[test]
|
||||
|
|
@ -7834,6 +7856,8 @@ mod math_chunk_tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Build the projected→source run map plus the projected text's line
|
||||
/// start table (cosmic-text reports hits as line + byte-within-line).
|
||||
fn build_hit_runs(chunks: &[RichChunk]) -> (Vec<ProjectedRun>, Vec<u64>) {
|
||||
let mut runs = Vec::with_capacity(chunks.len());
|
||||
let mut line_starts = vec![0u64];
|
||||
|
|
@ -7872,9 +7896,11 @@ fn projected_to_source(runs: &[ProjectedRun], projected: u64) -> Option<u64> {
|
|||
ChunkSource::Source { start } => Some(start + within),
|
||||
ChunkSource::SourceTab { start } => Some(start + u64::from(within > 0)),
|
||||
ChunkSource::Adornment { anchor } => Some(anchor),
|
||||
// Q#MS4: the box has no interior byte map, so every boundary inside
|
||||
// it snaps to the span start rather than inventing a sub-position.
|
||||
ChunkSource::MathBox { start, .. } => Some(start),
|
||||
// Q#MS4: interior boundaries snap to the span start, since the box
|
||||
// has no interior byte map. The TRAILING boundary is not interior —
|
||||
// it maps after the span, matching `SourceTab`'s rule and keeping a
|
||||
// click past a line-final box off the span start.
|
||||
ChunkSource::MathBox { start, end } => Some(if within >= run.len { end } else { start }),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7910,14 +7936,16 @@ fn source_to_projected(chunks: &[RichChunk], source: u64) -> Option<u64> {
|
|||
}
|
||||
}
|
||||
ChunkSource::MathBox { start, end } => {
|
||||
// Anywhere within the suppressed range maps to the box's
|
||||
// left edge; past it, the box's full reserved width applies.
|
||||
if source <= start {
|
||||
return Some(projected);
|
||||
}
|
||||
if source <= end {
|
||||
// `end` is EXCLUSIVE: it is the first byte AFTER the span, so
|
||||
// it must NOT claim the box's left edge. Letting it fall
|
||||
// through gives it the position past the reserved width —
|
||||
// which also keeps caret geometry continuous and stops a
|
||||
// search match starting at `end` from washing a box it does
|
||||
// not intersect (Q#MS11).
|
||||
if source < end {
|
||||
return Some(projected);
|
||||
}
|
||||
let _ = start;
|
||||
}
|
||||
}
|
||||
projected += len;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use ttf_parser::Face;
|
|||
|
||||
/// Bundled math font (GUST Font License — see `fonts/GUST-FONT-LICENSE.txt`).
|
||||
///
|
||||
/// Distinct from `fonts/OFL.txt`, which covers JetBrains Mono only: Latin
|
||||
/// Distinct from `fonts/OFL.txt`, which covers `JetBrains` Mono only: Latin
|
||||
/// Modern Math is GFL, an LPPL-derived licence, not the SIL OFL (framing F6).
|
||||
pub const LATIN_MODERN_MATH: &[u8] = include_bytes!("../fonts/latinmodern-math.otf");
|
||||
|
||||
|
|
@ -36,6 +36,10 @@ pub struct MathConstants {
|
|||
pub subscript_shift_down: i16,
|
||||
/// Thickness of the fraction rule.
|
||||
pub fraction_rule_thickness: i16,
|
||||
/// Minimum gap between the numerator and the rule.
|
||||
pub fraction_numerator_gap_min: i16,
|
||||
/// Minimum gap between the rule and the denominator.
|
||||
pub fraction_denominator_gap_min: i16,
|
||||
}
|
||||
|
||||
/// Why the bundled font could not supply math metrics.
|
||||
|
|
@ -51,6 +55,12 @@ pub enum MathFontError {
|
|||
NoMathTable,
|
||||
/// MATH table present but missing a constant the subset needs.
|
||||
MissingConstant(&'static str),
|
||||
/// The math font cannot draw this codepoint (F3). Q#MS8's rule is
|
||||
/// "failure is always show the source", so layout REFUSES rather than
|
||||
/// emitting a zero-width item that would render tofu over its neighbour.
|
||||
/// Layout is fallible for this reason alone; the draw pass needs a
|
||||
/// refusal signal, and it must exist before that pass consumes the API.
|
||||
UncoverableGlyph(char),
|
||||
}
|
||||
|
||||
impl MathConstants {
|
||||
|
|
@ -72,6 +82,8 @@ impl MathConstants {
|
|||
superscript_shift_up: constants.superscript_shift_up().value,
|
||||
subscript_shift_down: constants.subscript_shift_down().value,
|
||||
fraction_rule_thickness: constants.fraction_rule_thickness().value,
|
||||
fraction_numerator_gap_min: constants.fraction_numerator_gap_min().value,
|
||||
fraction_denominator_gap_min: constants.fraction_denominator_gap_min().value,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +130,12 @@ pub fn math_italic(ch: char) -> char {
|
|||
'a'..='z' => 0x1D44E + (ch as u32 - 'a' as u32),
|
||||
// Lowercase Greek α..ω → MATHEMATICAL ITALIC SMALL ALPHA..OMEGA.
|
||||
'\u{3B1}'..='\u{3C9}' => 0x1D6FC + (ch as u32 - 0x3B1),
|
||||
// The SYMBOL forms TeX's \epsilon and \phi resolve to sit OUTSIDE
|
||||
// that run, so they need explicit italic mappings — without them the
|
||||
// seed map's correction would render them upright beside italic
|
||||
// neighbours, which is the defect it was fixing.
|
||||
'\u{3F5}' => 0x1D716, // ϵ lunate epsilon
|
||||
'\u{3D5}' => 0x1D719, // ϕ phi symbol
|
||||
// Uppercase Greek, digits, operators: upright, per TeX.
|
||||
_ => return ch,
|
||||
};
|
||||
|
|
@ -243,6 +261,26 @@ impl MathBox {
|
|||
}
|
||||
}
|
||||
|
||||
/// The line-box height budget a math box must fit (Q#MS10), as
|
||||
/// `(above_baseline, below_baseline)` pixels.
|
||||
///
|
||||
/// Extracted rather than left inside a test: the draw pass must compute the
|
||||
/// SAME split the acceptance test asserts, and a duplicated derivation is
|
||||
/// exactly how a renderer and its test drift apart while both stay green.
|
||||
///
|
||||
/// The baseline is placed by the CODE font, not the math font — using the
|
||||
/// math font's own metrics understates the descent budget badly enough to
|
||||
/// make a plain fraction appear not to fit.
|
||||
#[must_use]
|
||||
pub fn line_box_budget(code_font: &Face<'_>, font_size_px: f32, line_height_px: f32) -> (f32, f32) {
|
||||
const MARGIN_PX: f32 = 1.0;
|
||||
let upem = f32::from(code_font.units_per_em().max(1));
|
||||
let baseline_from_top = f32::from(code_font.ascender()) * font_size_px / upem;
|
||||
let above = (baseline_from_top - MARGIN_PX).max(0.0);
|
||||
let below = (line_height_px - baseline_from_top - MARGIN_PX).max(0.0);
|
||||
(above, below)
|
||||
}
|
||||
|
||||
/// The smallest uniform scale the slice will apply before giving up (Q#MS10).
|
||||
pub const MIN_FIT_SCALE: f32 = 0.6;
|
||||
|
||||
|
|
@ -296,8 +334,15 @@ impl<'a> MathLayout<'a> {
|
|||
}
|
||||
|
||||
/// Lay `node` out at `size_px`.
|
||||
#[must_use]
|
||||
pub fn layout(&self, node: &crate::math_parse::MathNode, size_px: f32) -> MathBox {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MathFontError::UncoverableGlyph`] when the math font has no glyph
|
||||
/// for a character, so the caller can fall back to source (Q#MS8).
|
||||
pub fn layout(
|
||||
&self,
|
||||
node: &crate::math_parse::MathNode,
|
||||
size_px: f32,
|
||||
) -> Result<MathBox, MathFontError> {
|
||||
use crate::math_parse::MathNode;
|
||||
match node {
|
||||
MathNode::Char(ch) => self.layout_char(*ch, size_px),
|
||||
|
|
@ -305,19 +350,21 @@ impl<'a> MathLayout<'a> {
|
|||
let mut out = MathBox::empty();
|
||||
let mut pen = 0.0;
|
||||
for child in children {
|
||||
let child_box = self.layout(child, size_px);
|
||||
let child_box = self.layout(child, size_px)?;
|
||||
out.absorb(&child_box, pen, 0.0);
|
||||
pen += child_box.width;
|
||||
}
|
||||
out.width = pen;
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
MathNode::Script { base, sub, sup } => {
|
||||
self.layout_script(base, sub.as_deref(), sup.as_deref(), size_px)
|
||||
}
|
||||
MathNode::Script { base, sub, sup } => self.layout_script(base, sub, sup, size_px),
|
||||
MathNode::Fraction { num, den } => self.layout_fraction(num, den, size_px),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_char(&self, ch: char, size_px: f32) -> MathBox {
|
||||
fn layout_char(&self, ch: char, size_px: f32) -> Result<MathBox, MathFontError> {
|
||||
let presented = math_italic(ch);
|
||||
let upem = f32::from(self.constants.units_per_em.max(1));
|
||||
let (advance, ascent, descent) = self
|
||||
|
|
@ -347,8 +394,10 @@ impl<'a> MathLayout<'a> {
|
|||
);
|
||||
(adv, asc.max(0.0), desc.max(0.0))
|
||||
})
|
||||
.unwrap_or((0.0, 0.0, 0.0));
|
||||
MathBox {
|
||||
// F3: no glyph means no honest box. Emitting a zero-width item
|
||||
// would draw tofu on top of the next character.
|
||||
.ok_or(MathFontError::UncoverableGlyph(ch))?;
|
||||
Ok(MathBox {
|
||||
width: advance,
|
||||
ascent,
|
||||
descent,
|
||||
|
|
@ -358,23 +407,23 @@ impl<'a> MathLayout<'a> {
|
|||
baseline: 0.0,
|
||||
size_px,
|
||||
}],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn layout_script(
|
||||
&self,
|
||||
base: &crate::math_parse::MathNode,
|
||||
sub: &Option<Box<crate::math_parse::MathNode>>,
|
||||
sup: &Option<Box<crate::math_parse::MathNode>>,
|
||||
sub: Option<&crate::math_parse::MathNode>,
|
||||
sup: Option<&crate::math_parse::MathNode>,
|
||||
size_px: f32,
|
||||
) -> MathBox {
|
||||
let base_box = self.layout(base, size_px);
|
||||
) -> Result<MathBox, MathFontError> {
|
||||
let base_box = self.layout(base, size_px)?;
|
||||
let script_px = size_px * self.constants.script_scale();
|
||||
let mut out = MathBox::empty();
|
||||
out.absorb(&base_box, 0.0, 0.0);
|
||||
let mut widest = base_box.width;
|
||||
if let Some(sup) = sup {
|
||||
let sup_box = self.layout(sup, script_px);
|
||||
let sup_box = self.layout(sup, script_px)?;
|
||||
let shift = self
|
||||
.constants
|
||||
.to_px(self.constants.superscript_shift_up, size_px);
|
||||
|
|
@ -382,7 +431,7 @@ impl<'a> MathLayout<'a> {
|
|||
widest = widest.max(base_box.width + sup_box.width);
|
||||
}
|
||||
if let Some(sub) = sub {
|
||||
let sub_box = self.layout(sub, script_px);
|
||||
let sub_box = self.layout(sub, script_px)?;
|
||||
let shift = self
|
||||
.constants
|
||||
.to_px(self.constants.subscript_shift_down, size_px);
|
||||
|
|
@ -390,7 +439,7 @@ impl<'a> MathLayout<'a> {
|
|||
widest = widest.max(base_box.width + sub_box.width);
|
||||
}
|
||||
out.width = widest;
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn layout_fraction(
|
||||
|
|
@ -398,25 +447,36 @@ impl<'a> MathLayout<'a> {
|
|||
num: &crate::math_parse::MathNode,
|
||||
den: &crate::math_parse::MathNode,
|
||||
size_px: f32,
|
||||
) -> MathBox {
|
||||
) -> Result<MathBox, MathFontError> {
|
||||
// TeX sets an inline \frac's operands one style down, which is also
|
||||
// what the parent framing's Tier 3 specifies (70%). It is load-bearing
|
||||
// for Q#MS10: full-size operands would not fit the line at all.
|
||||
let operand_px = size_px * self.constants.script_scale();
|
||||
let num_box = self.layout(num, operand_px);
|
||||
let den_box = self.layout(den, operand_px);
|
||||
let num_box = self.layout(num, operand_px)?;
|
||||
let den_box = self.layout(den, operand_px)?;
|
||||
let axis = self.constants.to_px(self.constants.axis_height, size_px);
|
||||
let thickness = self
|
||||
.constants
|
||||
.to_px(self.constants.fraction_rule_thickness, size_px)
|
||||
.max(1.0);
|
||||
let gap = thickness * 2.0;
|
||||
// F4: the gaps come from the MATH table, not a guess. An earlier
|
||||
// revision used `thickness * 2.0`, which made fractions roughly twice
|
||||
// as airy as the font specifies and inflated the height budget the
|
||||
// fit-to-line scale is measured against.
|
||||
let num_gap = self
|
||||
.constants
|
||||
.to_px(self.constants.fraction_numerator_gap_min, size_px)
|
||||
.max(thickness);
|
||||
let den_gap = self
|
||||
.constants
|
||||
.to_px(self.constants.fraction_denominator_gap_min, size_px)
|
||||
.max(thickness);
|
||||
|
||||
let width = num_box.width.max(den_box.width);
|
||||
let mut out = MathBox::empty();
|
||||
// Numerator sits above the bar, denominator below it.
|
||||
let num_baseline = axis + thickness / 2.0 + gap + num_box.descent;
|
||||
let den_baseline = axis - thickness / 2.0 - gap - den_box.ascent;
|
||||
let num_baseline = axis + thickness / 2.0 + num_gap + num_box.descent;
|
||||
let den_baseline = axis - thickness / 2.0 - den_gap - den_box.ascent;
|
||||
out.absorb(&num_box, (width - num_box.width) / 2.0, num_baseline);
|
||||
out.absorb(&den_box, (width - den_box.width) / 2.0, den_baseline);
|
||||
out.items.push(MathItem::Rule {
|
||||
|
|
@ -428,7 +488,7 @@ impl<'a> MathLayout<'a> {
|
|||
out.ascent = out.ascent.max(axis + thickness / 2.0);
|
||||
out.descent = out.descent.max(-(axis - thickness / 2.0));
|
||||
out.width = width;
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -456,6 +516,22 @@ mod tests {
|
|||
|
||||
use crate::math_parse::parse;
|
||||
|
||||
#[test]
|
||||
fn tex_symbol_greek_forms_are_italicised_too() {
|
||||
// F5's trap: correcting the seed map alone leaves these upright,
|
||||
// because they sit outside the U+03B1..03C9 run.
|
||||
assert_eq!(math_italic('\u{3F5}'), '\u{1D716}');
|
||||
assert_eq!(math_italic('\u{3D5}'), '\u{1D719}');
|
||||
let face = Face::parse(LATIN_MODERN_MATH, 0).expect("face");
|
||||
for ch in ['\u{3F5}', '\u{3D5}'] {
|
||||
assert!(
|
||||
face.glyph_index(math_italic(ch)).is_some(),
|
||||
"no glyph for the italic form of U+{:04X}",
|
||||
ch as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacer_quantizes_up_to_whole_advances() {
|
||||
// Exactly two advances stays two; a sliver over rounds up, so the
|
||||
|
|
@ -496,7 +572,21 @@ mod tests {
|
|||
|
||||
fn lay(src: &str, size: f32) -> MathBox {
|
||||
let node = parse(src).expect("parses");
|
||||
engine().layout(&node, size)
|
||||
engine().layout(&node, size).expect("lays out")
|
||||
}
|
||||
|
||||
/// F3 — a codepoint the math font cannot draw REFUSES, so the caller can
|
||||
/// fall back to source (Q#MS8) instead of drawing tofu at zero advance
|
||||
/// on top of the next character.
|
||||
#[test]
|
||||
fn an_uncoverable_character_refuses_layout_instead_of_emitting_a_void() {
|
||||
let node = parse("x日").expect("parses — coverage is layout's problem");
|
||||
assert_eq!(
|
||||
engine().layout(&node, 16.0),
|
||||
Err(MathFontError::UncoverableGlyph('日'))
|
||||
);
|
||||
// The covered neighbour on its own still lays out.
|
||||
assert!(engine().layout(&parse("x").unwrap(), 16.0).is_ok());
|
||||
}
|
||||
|
||||
/// Framing acceptance 3, including its bite: the MATH constant must be
|
||||
|
|
@ -517,11 +607,11 @@ mod tests {
|
|||
.iter()
|
||||
.find_map(|i| match *i {
|
||||
MathItem::Glyph {
|
||||
ch,
|
||||
ch: '2',
|
||||
baseline,
|
||||
size_px,
|
||||
..
|
||||
} if ch == '2' => Some((baseline, size_px)),
|
||||
} => Some((baseline, size_px)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the 2 is emitted");
|
||||
|
|
@ -613,11 +703,11 @@ mod tests {
|
|||
// (JetBrains Mono at BASE_CODE_FONT_SIZE inside BASE_CODE_LINE_HEIGHT),
|
||||
// NOT where the math font's own metrics would.
|
||||
let code = Face::parse(crate::JETBRAINS_MONO, 0).expect("code face");
|
||||
let code_upem = f32::from(code.units_per_em());
|
||||
let baseline_from_top = f32::from(code.ascender()) * crate::BASE_CODE_FONT_SIZE / code_upem;
|
||||
let margin = 1.0;
|
||||
let asc_budget = baseline_from_top - margin;
|
||||
let desc_budget = crate::BASE_CODE_LINE_HEIGHT - baseline_from_top - margin;
|
||||
let (asc_budget, desc_budget) = line_box_budget(
|
||||
&code,
|
||||
crate::BASE_CODE_FONT_SIZE,
|
||||
crate::BASE_CODE_LINE_HEIGHT,
|
||||
);
|
||||
assert!(
|
||||
asc_budget > 0.0 && desc_budget > 0.0,
|
||||
"budget must be positive: {asc_budget} / {desc_budget}"
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ const GREEK: &[(&str, char)] = &[
|
|||
("beta", 'β'),
|
||||
("gamma", 'γ'),
|
||||
("delta", 'δ'),
|
||||
("epsilon", 'ε'),
|
||||
// TeX's \epsilon is LUNATE (U+03F5); U+03B5 is \varepsilon.
|
||||
("epsilon", '\u{3F5}'),
|
||||
("zeta", 'ζ'),
|
||||
("eta", 'η'),
|
||||
("theta", 'θ'),
|
||||
|
|
@ -75,7 +76,8 @@ const GREEK: &[(&str, char)] = &[
|
|||
("sigma", 'σ'),
|
||||
("tau", 'τ'),
|
||||
("upsilon", 'υ'),
|
||||
("phi", 'φ'),
|
||||
// TeX's \phi is U+03D5; U+03C6 is \varphi.
|
||||
("phi", '\u{3D5}'),
|
||||
("chi", 'χ'),
|
||||
("psi", 'ψ'),
|
||||
("omega", 'ω'),
|
||||
|
|
@ -229,7 +231,19 @@ impl Parser {
|
|||
fn parse_scripts(&mut self, base: MathNode) -> Result<MathNode, MathParseError> {
|
||||
let mut sub: Option<Box<MathNode>> = None;
|
||||
let mut sup: Option<Box<MathNode>> = None;
|
||||
while let Some(marker @ ('^' | '_')) = self.peek() {
|
||||
loop {
|
||||
// Whitespace is insignificant, here too: without this skip
|
||||
// `x^2 _i` builds a nested Script instead of one merged double
|
||||
// script (drawing the subscript displaced right by the
|
||||
// superscript's width), and `x^2 ^3` parses where TeX errors.
|
||||
let resume = self.pos;
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
let Some(marker @ ('^' | '_')) = self.peek() else {
|
||||
self.pos = resume;
|
||||
break;
|
||||
};
|
||||
self.pos += 1;
|
||||
let slot = self.parse_script_operand()?;
|
||||
match marker {
|
||||
|
|
@ -256,9 +270,9 @@ impl Parser {
|
|||
self.pos += 1;
|
||||
}
|
||||
match self.peek() {
|
||||
None => Err(MathParseError::MalformedScript("script with no operand")),
|
||||
Some('^' | '_') => Err(MathParseError::MalformedScript("script with no operand")),
|
||||
Some('}') => Err(MathParseError::MalformedScript("script with no operand")),
|
||||
None | Some('^' | '_' | '}') => {
|
||||
Err(MathParseError::MalformedScript("script with no operand"))
|
||||
}
|
||||
Some(_) => self.parse_atom(),
|
||||
}
|
||||
}
|
||||
|
|
@ -440,6 +454,27 @@ mod tests {
|
|||
assert_eq!(mixed.len(), 1, "{mixed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_before_a_script_marker_still_merges_the_scripts() {
|
||||
// F2: without skipping whitespace in `parse_scripts`, `x^2 _i` built
|
||||
// a NESTED script and drew the subscript displaced right.
|
||||
assert_eq!(parse("x^2 _i"), parse("x^2_i"));
|
||||
assert_eq!(parse("x _i ^2"), parse("x_i^2"));
|
||||
// And a doubled script is still an error with space between.
|
||||
assert!(matches!(
|
||||
parse("x^2 ^3"),
|
||||
Err(MathParseError::MalformedScript(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_greek_seed_uses_tex_letter_forms() {
|
||||
// F5: TeX's \epsilon is lunate and \phi is the symbol form; the
|
||||
// U+03B5 / U+03C6 glyphs are \varepsilon / \varphi.
|
||||
assert_eq!(parse(r"\epsilon"), Ok(group(vec![ch('\u{3F5}')])));
|
||||
assert_eq!(parse(r"\phi"), Ok(group(vec![ch('\u{3D5}')])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_characters_parse_in_order() {
|
||||
assert_eq!(parse("x+1"), Ok(group(vec![ch('x'), ch('+'), ch('1')])));
|
||||
|
|
|
|||
Loading…
Reference in New Issue