diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index b6be28d86ce9186148a217007cecc4172d4c842b..0ef75fa75f41b10e3a736db69564588420aabd62 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -715,10 +715,11 @@ impl Renderer { .map(|c| c.to_bits()) .collect(); let color = glyph_run.style().brush.color; + let rise = glyph_run.style().brush.rise; for glyph in glyph_run.positioned_glyphs() { let x = glyph.x * scale + origin[0]; - let y = - (glyph.y + line_box.baseline - line.metrics().baseline) * scale + origin[1]; + let y = (glyph.y + line_box.baseline - line.metrics().baseline - rise) * scale + + origin[1]; // Quantization affects raster coverage only, never advances or line breaks. let x = (x * 4.0).round() * 0.25; let y = (y * 4.0).round() * 0.25; @@ -830,7 +831,8 @@ impl Renderer { ] { if let Some(decoration) = decoration { let x = glyph_run.offset() * scale + origin[0]; - let y = (line_box.baseline - decoration.offset.unwrap_or(offset)) * scale + let y = (line_box.baseline - rise - decoration.offset.unwrap_or(offset)) + * scale + origin[1]; self.quad( viewport, diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index 2ac2784b6d02bd18350bec80c18927d76886dbd4..c9b8615806ea6ae55474b13a14ae471da821a284 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -23,10 +23,18 @@ pub struct TextEngine { arial_substitutes: BTreeSet, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +/// Superscripts and subscripts draw at this fraction of their run's size. +const SCRIPT_SCALE: f32 = 2.0 / 3.0; +const SUPERSCRIPT_RISE: f32 = 1.0 / 3.0; +const SUBSCRIPT_DROP: f32 = 0.08; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct TextBrush { pub color: u32, pub highlight: Option, + /// Points the run draws above the line's baseline: positive for a superscript, + /// negative for a subscript. Line metrics and hit-testing keep the baseline. + pub rise: f32, } #[derive(Clone, Debug)] @@ -140,6 +148,18 @@ impl TextEngine { if !size.is_finite() || size <= 0.0 { return Err(LayoutError::InvalidFontSize); } + let rise = if format.superscript == Some(true) { + SUPERSCRIPT_RISE * size + } else if format.subscript == Some(true) { + -SUBSCRIPT_DROP * size + } else { + 0.0 + }; + let size = if rise == 0.0 { + size + } else { + size * SCRIPT_SCALE + }; let properties = [ StyleProperty::FontFamily(FontFamily::List( [ @@ -176,6 +196,7 @@ impl TextEngine { _ => 0, }, highlight: format.highlight.filter(|color| *color != 0xff000000), + rise, }), StyleProperty::Underline(format.underline.unwrap_or(false) || link), StyleProperty::Strikethrough(format.strike.unwrap_or(false)), @@ -568,6 +589,47 @@ mod tests { assert_eq!(fonts.len(), 1); } + #[test] + fn scripts_shrink_and_shift_without_moving_the_baseline() { + let mut engine = TextEngine::default(); + let script = |superscript, subscript| Format { + superscript, + subscript, + ..Format::default() + }; + let layout = engine + .layout( + &Paragraph::from_runs([ + ("x".into(), Format::default()), + ("2".into(), script(Some(true), None)), + ("i".into(), script(None, Some(true))), + ]), + 300.0, + ) + .unwrap(); + let runs: Vec<_> = layout + .lines() + .flat_map(|(line, _)| line.items().collect::>()) + .filter_map(|item| match item { + PositionedLayoutItem::GlyphRun(run) => { + Some((run.run().font_size(), run.style().brush.rise)) + } + _ => None, + }) + .collect(); + let third = 11.0 / 3.0; + assert_eq!(runs[0], (11.0, 0.0)); + assert!((runs[1].0 - 2.0 * third).abs() < 0.001 && (runs[1].1 - third).abs() < 0.001); + assert!(runs[2].1 < 0.0); + let plain = engine + .layout(&Paragraph::new("x2i".into(), Format::default()), 300.0) + .unwrap(); + assert_eq!( + layout.lines().next().unwrap().1.baseline, + plain.lines().next().unwrap().1.baseline + ); + } + #[test] fn links_draw_blue_and_underlined_unless_coloured() { let mut engine = TextEngine::default(); diff --git a/tools/canvas/README.md b/tools/canvas/README.md index d6f95eaaf07a211d396fb54bdbebd3344cfcad85..2de1bb0b9ed015febd39ae49d881102a6dee014b 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -49,6 +49,10 @@ Paragraph layout retains checkbox (shape 3), question mark (15), exclamation (17 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. +## Run formatting + +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. + ## Equations 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.