| author | |
| committer | |
| log | dc52eff3f6dfc98dc61d48517051cac6ad04d9de |
| tree | 5d158547e4f845df536ff3b70545e53705eccaa3 |
| parent | 7b577f2383c4ed4a5d5a84a8b564d088198a773e |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.53 files changed, 72 insertions(+), 4 deletions(-)
crates/canvas/src/gpu/mod.rs+5-3| ... | ... | @@ -715,10 +715,11 @@ impl Renderer { |
| 715 | 715 | .map(|c| c.to_bits()) |
| 716 | 716 | .collect(); |
| 717 | 717 | let color = glyph_run.style().brush.color; |
| 718 | let rise = glyph_run.style().brush.rise; | |
| 718 | 719 | for glyph in glyph_run.positioned_glyphs() { |
| 719 | 720 | 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]; | |
| 722 | 723 | // Quantization affects raster coverage only, never advances or line breaks. |
| 723 | 724 | let x = (x * 4.0).round() * 0.25; |
| 724 | 725 | let y = (y * 4.0).round() * 0.25; |
| ... | ... | @@ -830,7 +831,8 @@ impl Renderer { |
| 830 | 831 | ] { |
| 831 | 832 | if let Some(decoration) = decoration { |
| 832 | 833 | 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 | |
| 834 | 836 | + origin[1]; |
| 835 | 837 | self.quad( |
| 836 | 838 | viewport, |
crates/canvas/src/layout.rs+63-1| ... | ... | @@ -23,10 +23,18 @@ pub struct TextEngine { |
| 23 | 23 | arial_substitutes: BTreeSet<u64>, |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 26 | /// Superscripts and subscripts draw at this fraction of their run's size. | |
| 27 | const SCRIPT_SCALE: f32 = 2.0 / 3.0; | |
| 28 | const SUPERSCRIPT_RISE: f32 = 1.0 / 3.0; | |
| 29 | const SUBSCRIPT_DROP: f32 = 0.08; | |
| 30 | ||
| 31 | #[derive(Clone, Copy, Debug, Default, PartialEq)] | |
| 27 | 32 | pub struct TextBrush { |
| 28 | 33 | pub color: u32, |
| 29 | 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 | } |
| 31 | 39 | |
| 32 | 40 | #[derive(Clone, Debug)] |
| ... | ... | @@ -140,6 +148,18 @@ impl TextEngine { |
| 140 | 148 | if !size.is_finite() || size <= 0.0 { |
| 141 | 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 | 163 | let properties = [ |
| 144 | 164 | StyleProperty::FontFamily(FontFamily::List( |
| 145 | 165 | [ |
| ... | ... | @@ -176,6 +196,7 @@ impl TextEngine { |
| 176 | 196 | _ => 0, |
| 177 | 197 | }, |
| 178 | 198 | highlight: format.highlight.filter(|color| *color != 0xff000000), |
| 199 | rise, | |
| 179 | 200 | }), |
| 180 | 201 | StyleProperty::Underline(format.underline.unwrap_or(false) || link), |
| 181 | 202 | StyleProperty::Strikethrough(format.strike.unwrap_or(false)), |
| ... | ... | @@ -568,6 +589,47 @@ mod tests { |
| 568 | 589 | assert_eq!(fonts.len(), 1); |
| 569 | 590 | } |
| 570 | 591 | |
| 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 | 633 | #[test] |
| 572 | 634 | fn links_draw_blue_and_underlined_unless_coloured() { |
| 573 | 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 | 49 | |
| 50 | 50 | Checkboxes 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. |
| 51 | 51 | |
| 52 | ## Run formatting | |
| 53 | ||
| 54 | Superscript 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 | 56 | ## Equations |
| 53 | 57 | |
| 54 | 58 | A 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. |