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 {
715715 .map(|c| c.to_bits())
716716 .collect();
717717 let color = glyph_run.style().brush.color;
718 let rise = glyph_run.style().brush.rise;
718719 for glyph in glyph_run.positioned_glyphs() {
719720 let x = glyph.x * scale + origin[0];
720 let y =
721 (glyph.y + line_box.baseline - line.metrics().baseline) * scale + origin[1];
721 let y = (glyph.y + line_box.baseline - line.metrics().baseline - rise) * scale
722 + origin[1];
722723 // Quantization affects raster coverage only, never advances or line breaks.
723724 let x = (x * 4.0).round() * 0.25;
724725 let y = (y * 4.0).round() * 0.25;
......@@ -830,7 +831,8 @@ impl Renderer {
830831 ] {
831832 if let Some(decoration) = decoration {
832833 let x = glyph_run.offset() * scale + origin[0];
833 let y = (line_box.baseline - decoration.offset.unwrap_or(offset)) * scale
834 let y = (line_box.baseline - rise - decoration.offset.unwrap_or(offset))
835 * scale
834836 + origin[1];
835837 self.quad(
836838 viewport,
crates/canvas/src/layout.rs+63-1
......@@ -23,10 +23,18 @@ pub struct TextEngine {
2323 arial_substitutes: BTreeSet<u64>,
2424}
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)]
2732pub struct TextBrush {
2833 pub color: u32,
2934 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,
3038}
3139
3240#[derive(Clone, Debug)]
......@@ -140,6 +148,18 @@ impl TextEngine {
140148 if !size.is_finite() || size <= 0.0 {
141149 return Err(LayoutError::InvalidFontSize);
142150 }
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 };
143163 let properties = [
144164 StyleProperty::FontFamily(FontFamily::List(
145165 [
......@@ -176,6 +196,7 @@ impl TextEngine {
176196 _ => 0,
177197 },
178198 highlight: format.highlight.filter(|color| *color != 0xff000000),
199 rise,
179200 }),
180201 StyleProperty::Underline(format.underline.unwrap_or(false) || link),
181202 StyleProperty::Strikethrough(format.strike.unwrap_or(false)),
......@@ -568,6 +589,47 @@ mod tests {
568589 assert_eq!(fonts.len(), 1);
569590 }
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
571633 #[test]
572634 fn links_draw_blue_and_underlined_unless_coloured() {
573635 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
4949
5050Checkboxes 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
5256## Equations
5357
5458A 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.