authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 11:33:14-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
logdc52eff3f6dfc98dc61d48517051cac6ad04d9de
tree5d158547e4f845df536ff3b70545e53705eccaa3
parent7b577f2383c4ed4a5d5a84a8b564d088198a773e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: draw superscripts and subscripts as OneNote does

Superscript and subscript runs drew as plain text. They now draw at two thirds of their size, raised a third of it or lowered 8%, decorations included, while the line keeps its baseline for hit-testing. Matches OneNote 2010 on the page-model fixture's formatted runs. Assisted-by: claude-opus-5.5

3 files changed, 72 insertions(+), 4 deletions(-)

crates/canvas/src/gpu/mod.rs+5-3
...@@ -715,10 +715,11 @@ impl Renderer {...@@ -715,10 +715,11 @@ impl Renderer {
715 .map(|c| c.to_bits())715 .map(|c| c.to_bits())
716 .collect();716 .collect();
717 let color = glyph_run.style().brush.color;717 let color = glyph_run.style().brush.color;
718 let rise = glyph_run.style().brush.rise;
718 for glyph in glyph_run.positioned_glyphs() {719 for glyph in glyph_run.positioned_glyphs() {
719 let x = glyph.x * scale + origin[0];720 let x = glyph.x * scale + origin[0];
720 let y =721 let y = (glyph.y + line_box.baseline - line.metrics().baseline - rise) * scale
721 (glyph.y + line_box.baseline - line.metrics().baseline) * scale + origin[1];722 + origin[1];
722 // Quantization affects raster coverage only, never advances or line breaks.723 // Quantization affects raster coverage only, never advances or line breaks.
723 let x = (x * 4.0).round() * 0.25;724 let x = (x * 4.0).round() * 0.25;
724 let y = (y * 4.0).round() * 0.25;725 let y = (y * 4.0).round() * 0.25;
...@@ -830,7 +831,8 @@ impl Renderer {...@@ -830,7 +831,8 @@ impl Renderer {
830 ] {831 ] {
831 if let Some(decoration) = decoration {832 if let Some(decoration) = decoration {
832 let x = glyph_run.offset() * scale + origin[0];833 let x = glyph_run.offset() * scale + origin[0];
833 let y = (line_box.baseline - decoration.offset.unwrap_or(offset)) * scale834 let y = (line_box.baseline - rise - decoration.offset.unwrap_or(offset))
835 * scale
834 + origin[1];836 + origin[1];
835 self.quad(837 self.quad(
836 viewport,838 viewport,
crates/canvas/src/layout.rs+63-1
...@@ -23,10 +23,18 @@ pub struct TextEngine {...@@ -23,10 +23,18 @@ pub struct TextEngine {
23 arial_substitutes: BTreeSet<u64>,23 arial_substitutes: BTreeSet<u64>,
24}24}
2525
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]26/// Superscripts and subscripts draw at this fraction of their run's size.
27const SCRIPT_SCALE: f32 = 2.0 / 3.0;
28const SUPERSCRIPT_RISE: f32 = 1.0 / 3.0;
29const SUBSCRIPT_DROP: f32 = 0.08;
30
31#[derive(Clone, Copy, Debug, Default, PartialEq)]
27pub struct TextBrush {32pub struct TextBrush {
28 pub color: u32,33 pub color: u32,
29 pub highlight: Option<u32>,34 pub highlight: Option<u32>,
35 /// Points the run draws above the line's baseline: positive for a superscript,
36 /// negative for a subscript. Line metrics and hit-testing keep the baseline.
37 pub rise: f32,
30}38}
3139
32#[derive(Clone, Debug)]40#[derive(Clone, Debug)]
...@@ -140,6 +148,18 @@ impl TextEngine {...@@ -140,6 +148,18 @@ impl TextEngine {
140 if !size.is_finite() || size <= 0.0 {148 if !size.is_finite() || size <= 0.0 {
141 return Err(LayoutError::InvalidFontSize);149 return Err(LayoutError::InvalidFontSize);
142 }150 }
151 let rise = if format.superscript == Some(true) {
152 SUPERSCRIPT_RISE * size
153 } else if format.subscript == Some(true) {
154 -SUBSCRIPT_DROP * size
155 } else {
156 0.0
157 };
158 let size = if rise == 0.0 {
159 size
160 } else {
161 size * SCRIPT_SCALE
162 };
143 let properties = [163 let properties = [
144 StyleProperty::FontFamily(FontFamily::List(164 StyleProperty::FontFamily(FontFamily::List(
145 [165 [
...@@ -176,6 +196,7 @@ impl TextEngine {...@@ -176,6 +196,7 @@ impl TextEngine {
176 _ => 0,196 _ => 0,
177 },197 },
178 highlight: format.highlight.filter(|color| *color != 0xff000000),198 highlight: format.highlight.filter(|color| *color != 0xff000000),
199 rise,
179 }),200 }),
180 StyleProperty::Underline(format.underline.unwrap_or(false) || link),201 StyleProperty::Underline(format.underline.unwrap_or(false) || link),
181 StyleProperty::Strikethrough(format.strike.unwrap_or(false)),202 StyleProperty::Strikethrough(format.strike.unwrap_or(false)),
...@@ -568,6 +589,47 @@ mod tests {...@@ -568,6 +589,47 @@ mod tests {
568 assert_eq!(fonts.len(), 1);589 assert_eq!(fonts.len(), 1);
569 }590 }
570591
592 #[test]
593 fn scripts_shrink_and_shift_without_moving_the_baseline() {
594 let mut engine = TextEngine::default();
595 let script = |superscript, subscript| Format {
596 superscript,
597 subscript,
598 ..Format::default()
599 };
600 let layout = engine
601 .layout(
602 &Paragraph::from_runs([
603 ("x".into(), Format::default()),
604 ("2".into(), script(Some(true), None)),
605 ("i".into(), script(None, Some(true))),
606 ]),
607 300.0,
608 )
609 .unwrap();
610 let runs: Vec<_> = layout
611 .lines()
612 .flat_map(|(line, _)| line.items().collect::<Vec<_>>())
613 .filter_map(|item| match item {
614 PositionedLayoutItem::GlyphRun(run) => {
615 Some((run.run().font_size(), run.style().brush.rise))
616 }
617 _ => None,
618 })
619 .collect();
620 let third = 11.0 / 3.0;
621 assert_eq!(runs[0], (11.0, 0.0));
622 assert!((runs[1].0 - 2.0 * third).abs() < 0.001 && (runs[1].1 - third).abs() < 0.001);
623 assert!(runs[2].1 < 0.0);
624 let plain = engine
625 .layout(&Paragraph::new("x2i".into(), Format::default()), 300.0)
626 .unwrap();
627 assert_eq!(
628 layout.lines().next().unwrap().1.baseline,
629 plain.lines().next().unwrap().1.baseline
630 );
631 }
632
571 #[test]633 #[test]
572 fn links_draw_blue_and_underlined_unless_coloured() {634 fn links_draw_blue_and_underlined_unless_coloured() {
573 let mut engine = TextEngine::default();635 let mut engine = TextEngine::default();
tools/canvas/README.md+4
...@@ -49,6 +49,10 @@ Paragraph layout retains checkbox (shape 3), question mark (15), exclamation (17...@@ -49,6 +49,10 @@ Paragraph layout retains checkbox (shape 3), question mark (15), exclamation (17
4949
50Checkboxes display completion; noncheckable tags ignore the completion bit required by the file format. Disabled tags dim. Accessibility descriptions expose source labels and checkbox/disabled state without changing selectable text. Tag state is read-only; text edits and undo retain it. Clicking a tag gutter focuses its outline. Unsupported icon shapes and task tags return `UnsupportedContent` during layout.50Checkboxes display completion; noncheckable tags ignore the completion bit required by the file format. Disabled tags dim. Accessibility descriptions expose source labels and checkbox/disabled state without changing selectable text. Tag state is read-only; text edits and undo retain it. Clicking a tag gutter focuses its outline. Unsupported icon shapes and task tags return `UnsupportedContent` during layout.
5151
52## Run formatting
53
54Superscript and subscript runs draw at two thirds of their size, a third of the run's size above the baseline or 8% below it, with their underline and strikethrough; line metrics and hit-testing keep the paragraph's baseline. Links draw blue and underlined unless coloured. Paragraph alignment offsets lines within the wrap width.
55
52## Equations56## Equations
5357
54A paragraph stored as an equation draws in two dimensions from the tree `onestore::page::Math` parses: sub- and superscripts at 70%, fractions over a rule on the math axis, radicals drawn with the pen, n-ary operators at display size with limits above and below (integrals take them as scripts), stretched fences, accents and bars above, boxes, matrices and equation arrays. Latin letters draw in mathematical italic. Cambria Math falls back to STIX Two Math on macOS, and atoms are placed by their glyph ink rather than the math font's tall line box; an equation line is at least as tall as a line of text. The linear text stays the editable source, laid out in the body font so the caret keeps a text height; editing equations in two dimensions is a follow-up.58A paragraph stored as an equation draws in two dimensions from the tree `onestore::page::Math` parses: sub- and superscripts at 70%, fractions over a rule on the math axis, radicals drawn with the pen, n-ary operators at display size with limits above and below (integrals take them as scripts), stretched fences, accents and bars above, boxes, matrices and equation arrays. Latin letters draw in mathematical italic. Cambria Math falls back to STIX Two Math on macOS, and atoms are placed by their glyph ink rather than the math font's tall line box; an equation line is at least as tall as a line of text. The linear text stays the editable source, laid out in the body font so the caret keeps a text height; editing equations in two dimensions is a follow-up.