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 |
|
| expression | ascent | descent | scale |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `x^2`, `\alpha x` | 13.27 | 0.18 | 1.000 |
|
| `x^2`, `\alpha x` | 13.27 | 0.18 | 1.000 |
|
||||||
| `\frac{a}{b}` | 11.57 | 6.40 | **0.732** |
|
| `\frac{a}{b}` | 10.57 | 5.40 | **0.867** |
|
||||||
| `\frac{x^2}{y}` | 15.91 | 5.75 | 0.814 |
|
| `\frac{x^2}{y}` | 14.91 | 4.75 | 0.986 |
|
||||||
| nesting depth 2 | — | — | 0.744 |
|
| nesting depth 2 | — | — | 0.872 |
|
||||||
| nesting depth 3 | — | — | **0.580** |
|
| nesting depth 4 | — | — | 0.613 |
|
||||||
|
| nesting depth 5 | — | — | **0.540** |
|
||||||
|
|
||||||
So **B6 holds** — the flagship fraction renders at 0.732 — and the
|
So **B6 holds** — the flagship fraction renders at 0.867 — and the
|
||||||
fallback case is **depth 3**, not the doubly-nested one rev 3 guessed.
|
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
|
Round 2 predicted exactly this trap. Two things worth keeping: depth 2
|
||||||
scores *higher* than depth 1 because the binding constraint flips from
|
scores *higher* than depth 1 because the binding constraint flips from
|
||||||
descent to ascent as nesting grows asymmetrically, so "deeper is always
|
descent to ascent as nesting grows asymmetrically, so "deeper is always
|
||||||
|
|
|
||||||
|
|
@ -7743,9 +7743,6 @@ struct ProjectedRun {
|
||||||
source: ChunkSource,
|
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)]
|
#[cfg(test)]
|
||||||
mod math_chunk_tests {
|
mod math_chunk_tests {
|
||||||
use super::*;
|
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, 0), Some(0));
|
||||||
assert_eq!(source_to_projected(&chunks, 2), Some(2));
|
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, 4), Some(2));
|
||||||
assert_eq!(source_to_projected(&chunks, 7), Some(2));
|
assert_eq!(source_to_projected(&chunks, 6), Some(2));
|
||||||
// The first byte after the span lands past the reserved width.
|
assert_eq!(source_to_projected(&chunks, 7), Some(5), "end is exclusive");
|
||||||
assert_eq!(source_to_projected(&chunks, 8), Some(6));
|
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
|
/// A math chunk carries generated spacer text, so tab expansion must
|
||||||
/// leave it alone rather than treating a space as a source tab.
|
/// leave it alone rather than treating a space as a source tab.
|
||||||
#[test]
|
#[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>) {
|
fn build_hit_runs(chunks: &[RichChunk]) -> (Vec<ProjectedRun>, Vec<u64>) {
|
||||||
let mut runs = Vec::with_capacity(chunks.len());
|
let mut runs = Vec::with_capacity(chunks.len());
|
||||||
let mut line_starts = vec![0u64];
|
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::Source { start } => Some(start + within),
|
||||||
ChunkSource::SourceTab { start } => Some(start + u64::from(within > 0)),
|
ChunkSource::SourceTab { start } => Some(start + u64::from(within > 0)),
|
||||||
ChunkSource::Adornment { anchor } => Some(anchor),
|
ChunkSource::Adornment { anchor } => Some(anchor),
|
||||||
// Q#MS4: the box has no interior byte map, so every boundary inside
|
// Q#MS4: interior boundaries snap to the span start, since the box
|
||||||
// it snaps to the span start rather than inventing a sub-position.
|
// has no interior byte map. The TRAILING boundary is not interior —
|
||||||
ChunkSource::MathBox { start, .. } => Some(start),
|
// 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 } => {
|
ChunkSource::MathBox { start, end } => {
|
||||||
// Anywhere within the suppressed range maps to the box's
|
// `end` is EXCLUSIVE: it is the first byte AFTER the span, so
|
||||||
// left edge; past it, the box's full reserved width applies.
|
// it must NOT claim the box's left edge. Letting it fall
|
||||||
if source <= start {
|
// through gives it the position past the reserved width —
|
||||||
return Some(projected);
|
// which also keeps caret geometry continuous and stops a
|
||||||
}
|
// search match starting at `end` from washing a box it does
|
||||||
if source <= end {
|
// not intersect (Q#MS11).
|
||||||
|
if source < end {
|
||||||
return Some(projected);
|
return Some(projected);
|
||||||
}
|
}
|
||||||
|
let _ = start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
projected += len;
|
projected += len;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use ttf_parser::Face;
|
||||||
|
|
||||||
/// Bundled math font (GUST Font License — see `fonts/GUST-FONT-LICENSE.txt`).
|
/// 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).
|
/// 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");
|
pub const LATIN_MODERN_MATH: &[u8] = include_bytes!("../fonts/latinmodern-math.otf");
|
||||||
|
|
||||||
|
|
@ -36,6 +36,10 @@ pub struct MathConstants {
|
||||||
pub subscript_shift_down: i16,
|
pub subscript_shift_down: i16,
|
||||||
/// Thickness of the fraction rule.
|
/// Thickness of the fraction rule.
|
||||||
pub fraction_rule_thickness: i16,
|
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.
|
/// Why the bundled font could not supply math metrics.
|
||||||
|
|
@ -51,6 +55,12 @@ pub enum MathFontError {
|
||||||
NoMathTable,
|
NoMathTable,
|
||||||
/// MATH table present but missing a constant the subset needs.
|
/// MATH table present but missing a constant the subset needs.
|
||||||
MissingConstant(&'static str),
|
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 {
|
impl MathConstants {
|
||||||
|
|
@ -72,6 +82,8 @@ impl MathConstants {
|
||||||
superscript_shift_up: constants.superscript_shift_up().value,
|
superscript_shift_up: constants.superscript_shift_up().value,
|
||||||
subscript_shift_down: constants.subscript_shift_down().value,
|
subscript_shift_down: constants.subscript_shift_down().value,
|
||||||
fraction_rule_thickness: constants.fraction_rule_thickness().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),
|
'a'..='z' => 0x1D44E + (ch as u32 - 'a' as u32),
|
||||||
// Lowercase Greek α..ω → MATHEMATICAL ITALIC SMALL ALPHA..OMEGA.
|
// Lowercase Greek α..ω → MATHEMATICAL ITALIC SMALL ALPHA..OMEGA.
|
||||||
'\u{3B1}'..='\u{3C9}' => 0x1D6FC + (ch as u32 - 0x3B1),
|
'\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.
|
// Uppercase Greek, digits, operators: upright, per TeX.
|
||||||
_ => return ch,
|
_ => 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).
|
/// The smallest uniform scale the slice will apply before giving up (Q#MS10).
|
||||||
pub const MIN_FIT_SCALE: f32 = 0.6;
|
pub const MIN_FIT_SCALE: f32 = 0.6;
|
||||||
|
|
||||||
|
|
@ -296,8 +334,15 @@ impl<'a> MathLayout<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lay `node` out at `size_px`.
|
/// 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;
|
use crate::math_parse::MathNode;
|
||||||
match node {
|
match node {
|
||||||
MathNode::Char(ch) => self.layout_char(*ch, size_px),
|
MathNode::Char(ch) => self.layout_char(*ch, size_px),
|
||||||
|
|
@ -305,19 +350,21 @@ impl<'a> MathLayout<'a> {
|
||||||
let mut out = MathBox::empty();
|
let mut out = MathBox::empty();
|
||||||
let mut pen = 0.0;
|
let mut pen = 0.0;
|
||||||
for child in children {
|
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);
|
out.absorb(&child_box, pen, 0.0);
|
||||||
pen += child_box.width;
|
pen += child_box.width;
|
||||||
}
|
}
|
||||||
out.width = pen;
|
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),
|
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 presented = math_italic(ch);
|
||||||
let upem = f32::from(self.constants.units_per_em.max(1));
|
let upem = f32::from(self.constants.units_per_em.max(1));
|
||||||
let (advance, ascent, descent) = self
|
let (advance, ascent, descent) = self
|
||||||
|
|
@ -347,8 +394,10 @@ impl<'a> MathLayout<'a> {
|
||||||
);
|
);
|
||||||
(adv, asc.max(0.0), desc.max(0.0))
|
(adv, asc.max(0.0), desc.max(0.0))
|
||||||
})
|
})
|
||||||
.unwrap_or((0.0, 0.0, 0.0));
|
// F3: no glyph means no honest box. Emitting a zero-width item
|
||||||
MathBox {
|
// would draw tofu on top of the next character.
|
||||||
|
.ok_or(MathFontError::UncoverableGlyph(ch))?;
|
||||||
|
Ok(MathBox {
|
||||||
width: advance,
|
width: advance,
|
||||||
ascent,
|
ascent,
|
||||||
descent,
|
descent,
|
||||||
|
|
@ -358,23 +407,23 @@ impl<'a> MathLayout<'a> {
|
||||||
baseline: 0.0,
|
baseline: 0.0,
|
||||||
size_px,
|
size_px,
|
||||||
}],
|
}],
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn layout_script(
|
fn layout_script(
|
||||||
&self,
|
&self,
|
||||||
base: &crate::math_parse::MathNode,
|
base: &crate::math_parse::MathNode,
|
||||||
sub: &Option<Box<crate::math_parse::MathNode>>,
|
sub: Option<&crate::math_parse::MathNode>,
|
||||||
sup: &Option<Box<crate::math_parse::MathNode>>,
|
sup: Option<&crate::math_parse::MathNode>,
|
||||||
size_px: f32,
|
size_px: f32,
|
||||||
) -> MathBox {
|
) -> Result<MathBox, MathFontError> {
|
||||||
let base_box = self.layout(base, size_px);
|
let base_box = self.layout(base, size_px)?;
|
||||||
let script_px = size_px * self.constants.script_scale();
|
let script_px = size_px * self.constants.script_scale();
|
||||||
let mut out = MathBox::empty();
|
let mut out = MathBox::empty();
|
||||||
out.absorb(&base_box, 0.0, 0.0);
|
out.absorb(&base_box, 0.0, 0.0);
|
||||||
let mut widest = base_box.width;
|
let mut widest = base_box.width;
|
||||||
if let Some(sup) = sup {
|
if let Some(sup) = sup {
|
||||||
let sup_box = self.layout(sup, script_px);
|
let sup_box = self.layout(sup, script_px)?;
|
||||||
let shift = self
|
let shift = self
|
||||||
.constants
|
.constants
|
||||||
.to_px(self.constants.superscript_shift_up, size_px);
|
.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);
|
widest = widest.max(base_box.width + sup_box.width);
|
||||||
}
|
}
|
||||||
if let Some(sub) = sub {
|
if let Some(sub) = sub {
|
||||||
let sub_box = self.layout(sub, script_px);
|
let sub_box = self.layout(sub, script_px)?;
|
||||||
let shift = self
|
let shift = self
|
||||||
.constants
|
.constants
|
||||||
.to_px(self.constants.subscript_shift_down, size_px);
|
.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);
|
widest = widest.max(base_box.width + sub_box.width);
|
||||||
}
|
}
|
||||||
out.width = widest;
|
out.width = widest;
|
||||||
out
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn layout_fraction(
|
fn layout_fraction(
|
||||||
|
|
@ -398,25 +447,36 @@ impl<'a> MathLayout<'a> {
|
||||||
num: &crate::math_parse::MathNode,
|
num: &crate::math_parse::MathNode,
|
||||||
den: &crate::math_parse::MathNode,
|
den: &crate::math_parse::MathNode,
|
||||||
size_px: f32,
|
size_px: f32,
|
||||||
) -> MathBox {
|
) -> Result<MathBox, MathFontError> {
|
||||||
// TeX sets an inline \frac's operands one style down, which is also
|
// 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
|
// 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.
|
// for Q#MS10: full-size operands would not fit the line at all.
|
||||||
let operand_px = size_px * self.constants.script_scale();
|
let operand_px = size_px * self.constants.script_scale();
|
||||||
let num_box = self.layout(num, operand_px);
|
let num_box = self.layout(num, operand_px)?;
|
||||||
let den_box = self.layout(den, operand_px);
|
let den_box = self.layout(den, operand_px)?;
|
||||||
let axis = self.constants.to_px(self.constants.axis_height, size_px);
|
let axis = self.constants.to_px(self.constants.axis_height, size_px);
|
||||||
let thickness = self
|
let thickness = self
|
||||||
.constants
|
.constants
|
||||||
.to_px(self.constants.fraction_rule_thickness, size_px)
|
.to_px(self.constants.fraction_rule_thickness, size_px)
|
||||||
.max(1.0);
|
.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 width = num_box.width.max(den_box.width);
|
||||||
let mut out = MathBox::empty();
|
let mut out = MathBox::empty();
|
||||||
// Numerator sits above the bar, denominator below it.
|
// Numerator sits above the bar, denominator below it.
|
||||||
let num_baseline = axis + thickness / 2.0 + gap + num_box.descent;
|
let num_baseline = axis + thickness / 2.0 + num_gap + num_box.descent;
|
||||||
let den_baseline = axis - thickness / 2.0 - gap - den_box.ascent;
|
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(&num_box, (width - num_box.width) / 2.0, num_baseline);
|
||||||
out.absorb(&den_box, (width - den_box.width) / 2.0, den_baseline);
|
out.absorb(&den_box, (width - den_box.width) / 2.0, den_baseline);
|
||||||
out.items.push(MathItem::Rule {
|
out.items.push(MathItem::Rule {
|
||||||
|
|
@ -428,7 +488,7 @@ impl<'a> MathLayout<'a> {
|
||||||
out.ascent = out.ascent.max(axis + thickness / 2.0);
|
out.ascent = out.ascent.max(axis + thickness / 2.0);
|
||||||
out.descent = out.descent.max(-(axis - thickness / 2.0));
|
out.descent = out.descent.max(-(axis - thickness / 2.0));
|
||||||
out.width = width;
|
out.width = width;
|
||||||
out
|
Ok(out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -456,6 +516,22 @@ mod tests {
|
||||||
|
|
||||||
use crate::math_parse::parse;
|
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]
|
#[test]
|
||||||
fn spacer_quantizes_up_to_whole_advances() {
|
fn spacer_quantizes_up_to_whole_advances() {
|
||||||
// Exactly two advances stays two; a sliver over rounds up, so the
|
// 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 {
|
fn lay(src: &str, size: f32) -> MathBox {
|
||||||
let node = parse(src).expect("parses");
|
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
|
/// Framing acceptance 3, including its bite: the MATH constant must be
|
||||||
|
|
@ -517,11 +607,11 @@ mod tests {
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|i| match *i {
|
.find_map(|i| match *i {
|
||||||
MathItem::Glyph {
|
MathItem::Glyph {
|
||||||
ch,
|
ch: '2',
|
||||||
baseline,
|
baseline,
|
||||||
size_px,
|
size_px,
|
||||||
..
|
..
|
||||||
} if ch == '2' => Some((baseline, size_px)),
|
} => Some((baseline, size_px)),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.expect("the 2 is emitted");
|
.expect("the 2 is emitted");
|
||||||
|
|
@ -613,11 +703,11 @@ mod tests {
|
||||||
// (JetBrains Mono at BASE_CODE_FONT_SIZE inside BASE_CODE_LINE_HEIGHT),
|
// (JetBrains Mono at BASE_CODE_FONT_SIZE inside BASE_CODE_LINE_HEIGHT),
|
||||||
// NOT where the math font's own metrics would.
|
// NOT where the math font's own metrics would.
|
||||||
let code = Face::parse(crate::JETBRAINS_MONO, 0).expect("code face");
|
let code = Face::parse(crate::JETBRAINS_MONO, 0).expect("code face");
|
||||||
let code_upem = f32::from(code.units_per_em());
|
let (asc_budget, desc_budget) = line_box_budget(
|
||||||
let baseline_from_top = f32::from(code.ascender()) * crate::BASE_CODE_FONT_SIZE / code_upem;
|
&code,
|
||||||
let margin = 1.0;
|
crate::BASE_CODE_FONT_SIZE,
|
||||||
let asc_budget = baseline_from_top - margin;
|
crate::BASE_CODE_LINE_HEIGHT,
|
||||||
let desc_budget = crate::BASE_CODE_LINE_HEIGHT - baseline_from_top - margin;
|
);
|
||||||
assert!(
|
assert!(
|
||||||
asc_budget > 0.0 && desc_budget > 0.0,
|
asc_budget > 0.0 && desc_budget > 0.0,
|
||||||
"budget must be positive: {asc_budget} / {desc_budget}"
|
"budget must be positive: {asc_budget} / {desc_budget}"
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,8 @@ const GREEK: &[(&str, char)] = &[
|
||||||
("beta", 'β'),
|
("beta", 'β'),
|
||||||
("gamma", 'γ'),
|
("gamma", 'γ'),
|
||||||
("delta", 'δ'),
|
("delta", 'δ'),
|
||||||
("epsilon", 'ε'),
|
// TeX's \epsilon is LUNATE (U+03F5); U+03B5 is \varepsilon.
|
||||||
|
("epsilon", '\u{3F5}'),
|
||||||
("zeta", 'ζ'),
|
("zeta", 'ζ'),
|
||||||
("eta", 'η'),
|
("eta", 'η'),
|
||||||
("theta", 'θ'),
|
("theta", 'θ'),
|
||||||
|
|
@ -75,7 +76,8 @@ const GREEK: &[(&str, char)] = &[
|
||||||
("sigma", 'σ'),
|
("sigma", 'σ'),
|
||||||
("tau", 'τ'),
|
("tau", 'τ'),
|
||||||
("upsilon", 'υ'),
|
("upsilon", 'υ'),
|
||||||
("phi", 'φ'),
|
// TeX's \phi is U+03D5; U+03C6 is \varphi.
|
||||||
|
("phi", '\u{3D5}'),
|
||||||
("chi", 'χ'),
|
("chi", 'χ'),
|
||||||
("psi", 'ψ'),
|
("psi", 'ψ'),
|
||||||
("omega", 'ω'),
|
("omega", 'ω'),
|
||||||
|
|
@ -229,7 +231,19 @@ impl Parser {
|
||||||
fn parse_scripts(&mut self, base: MathNode) -> Result<MathNode, MathParseError> {
|
fn parse_scripts(&mut self, base: MathNode) -> Result<MathNode, MathParseError> {
|
||||||
let mut sub: Option<Box<MathNode>> = None;
|
let mut sub: Option<Box<MathNode>> = None;
|
||||||
let mut sup: 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;
|
self.pos += 1;
|
||||||
let slot = self.parse_script_operand()?;
|
let slot = self.parse_script_operand()?;
|
||||||
match marker {
|
match marker {
|
||||||
|
|
@ -256,9 +270,9 @@ impl Parser {
|
||||||
self.pos += 1;
|
self.pos += 1;
|
||||||
}
|
}
|
||||||
match self.peek() {
|
match self.peek() {
|
||||||
None => Err(MathParseError::MalformedScript("script with no operand")),
|
None | Some('^' | '_' | '}') => {
|
||||||
Some('^' | '_') => Err(MathParseError::MalformedScript("script with no operand")),
|
Err(MathParseError::MalformedScript("script with no operand"))
|
||||||
Some('}') => Err(MathParseError::MalformedScript("script with no operand")),
|
}
|
||||||
Some(_) => self.parse_atom(),
|
Some(_) => self.parse_atom(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -440,6 +454,27 @@ mod tests {
|
||||||
assert_eq!(mixed.len(), 1, "{mixed:?}");
|
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]
|
#[test]
|
||||||
fn plain_characters_parse_in_order() {
|
fn plain_characters_parse_in_order() {
|
||||||
assert_eq!(parse("x+1"), Ok(group(vec![ch('x'), ch('+'), ch('1')])));
|
assert_eq!(parse("x+1"), Ok(group(vec![ch('x'), ch('+'), ch('1')])));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue