authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-24 11:09:12-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:21-07:00
log5b135a22151997c7b480c1e65db0c2d85ad41712
tree7af31b3e2044ebe60131b2cbdd668ee5c58866d2
parent73142aea074afbea99f51a217ef30bbc6bbe4863
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: draw equations in two dimensions as OneNote does

Equation paragraphs drew their stored linear text, control characters and all. They now lay out from the parsed equation tree: scripts, fractions, radicals, n-ary operators with limits, fences, accents, bars, boxes, matrices and equation arrays, in Cambria Math or STIX Two Math on macOS, placed by glyph ink. The three native equation fixtures render with the structure and placement OneNote 2010 gives them. The linear text remains the source the caret moves through. Assisted-by: claude-opus-5.5

7 files changed, 848 insertions(+), 68 deletions(-)

crates/canvas/src/gpu/page.rs+86-37
...@@ -546,43 +546,12 @@ impl PageScene {...@@ -546,43 +546,12 @@ impl PageScene {
546 outline.append_background_primitives(primitives, object_origin);546 outline.append_background_primitives(primitives, object_origin);
547 self.append_outline_objects(outline, object_origin, primitives);547 self.append_outline_objects(outline, object_origin, primitives);
548 for (index, paragraph) in outline.paragraphs.iter().enumerate() {548 for (index, paragraph) in outline.paragraphs.iter().enumerate() {
549 let origin = [549 outline.append_paragraph_primitives(
550 object_origin[0] + paragraph.origin[0],550 index,
551 object_origin[1] + paragraph.origin[1],551 paragraph,
552 ];552 object_origin,
553 let clip = outline.paragraph_cell(index).map(|cell| {553 primitives,
554 let [left, top, right, bottom] = cell.text_bounds();554 );
555 [
556 left + object_origin[0],
557 top + object_origin[1],
558 right + object_origin[0],
559 bottom + object_origin[1],
560 ]
561 });
562 primitives.push(Primitive::Text {
563 clip,
564 layout: &paragraph.text,
565 origin,
566 });
567 for (layout, origin) in &paragraph.markers {
568 primitives.push(Primitive::Text {
569 clip,
570 layout,
571 origin: [
572 object_origin[0] + origin[0],
573 object_origin[1] + (origin[1] + paragraph.origin[1]),
574 ],
575 });
576 }
577 for tag in &paragraph.tags {
578 primitives.push(Primitive::Tag {
579 tag,
580 origin: [
581 object_origin[0] + outline.tag_column_offset(),
582 object_origin[1] + paragraph.origin[1],
583 ],
584 });
585 }
586 }555 }
587 }556 }
588 Content::Editable(_) | Content::ReadOnly(_) | Content::Ink(_) => unreachable!(),557 Content::Editable(_) | Content::ReadOnly(_) | Content::Ink(_) => unreachable!(),
...@@ -623,6 +592,86 @@ impl crate::outline::OutlineLayout {...@@ -623,6 +592,86 @@ impl crate::outline::OutlineLayout {
623 }592 }
624 }593 }
625594
595 /// One paragraph of this outline, whose origin is `origin`: its text or equation, list
596 /// markers and tags.
597 pub fn append_paragraph_primitives<'a>(
598 &'a self,
599 index: usize,
600 paragraph: &'a crate::outline::ParagraphLayout,
601 origin: [f32; 2],
602 primitives: &mut Vec<Primitive<'a>>,
603 ) {
604 let [x, y] = [
605 origin[0] + paragraph.origin[0],
606 origin[1] + paragraph.origin[1],
607 ];
608 let clip = self.paragraph_cell(index).map(|cell| {
609 let [left, top, right, bottom] = cell.text_bounds();
610 [
611 left + origin[0],
612 top + origin[1],
613 right + origin[0],
614 bottom + origin[1],
615 ]
616 });
617 match &paragraph.math {
618 Some(math) => {
619 for item in &math.items {
620 primitives.push(match item {
621 crate::math::MathItem::Text { layout, origin } => Primitive::Text {
622 clip,
623 layout,
624 origin: [x + origin[0], y + origin[1]],
625 },
626 // As pen strokes, so hairline rules keep a device pixel.
627 crate::math::MathItem::Rule([x0, y0, x1, y1]) => {
628 let width = (x1 - x0).min(y1 - y0);
629 let [from, to] = if x1 - x0 >= y1 - y0 {
630 let middle = (y0 + y1) / 2.0;
631 [[x0 + width / 2.0, middle], [x1 - width / 2.0, middle]]
632 } else {
633 let middle = (x0 + x1) / 2.0;
634 [[middle, y0 + width / 2.0], [middle, y1 - width / 2.0]]
635 };
636 Primitive::Segment {
637 from: [x + from[0], y + from[1]],
638 to: [x + to[0], y + to[1]],
639 width,
640 round: false,
641 color: colorref(math.color),
642 }
643 }
644 crate::math::MathItem::Stroke { from, to, width } => Primitive::Segment {
645 from: [x + from[0], y + from[1]],
646 to: [x + to[0], y + to[1]],
647 width: *width,
648 round: true,
649 color: colorref(math.color),
650 },
651 });
652 }
653 }
654 None => primitives.push(Primitive::Text {
655 clip,
656 layout: &paragraph.text,
657 origin: [x, y],
658 }),
659 }
660 for (layout, marker) in &paragraph.markers {
661 primitives.push(Primitive::Text {
662 clip,
663 layout,
664 origin: [origin[0] + marker[0], y + marker[1]],
665 });
666 }
667 for tag in &paragraph.tags {
668 primitives.push(Primitive::Tag {
669 tag,
670 origin: [origin[0] + self.tag_column_offset(), y],
671 });
672 }
673 }
674
626 pub fn append_table_primitives(&self, primitives: &mut Vec<Primitive<'_>>, origin: [f32; 2]) {675 pub fn append_table_primitives(&self, primitives: &mut Vec<Primitive<'_>>, origin: [f32; 2]) {
627 for table in &self.tables {676 for table in &self.tables {
628 let (Some(first), Some(last)) = (table.cells.first(), table.cells.last()) else {677 let (Some(first), Some(last)) = (table.cells.first(), table.cells.last()) else {
crates/canvas/src/layout.rs+11-3
...@@ -142,11 +142,19 @@ impl TextEngine {...@@ -142,11 +142,19 @@ impl TextEngine {
142 }142 }
143 let properties = [143 let properties = [
144 StyleProperty::FontFamily(FontFamily::List(144 StyleProperty::FontFamily(FontFamily::List(
145 vec![145 [
146 FontFamilyName::named(format.font.as_deref().unwrap_or("Arial")),146 Some(FontFamilyName::named(
147 format.font.as_deref().unwrap_or("Arial"),
148 )),
149 // macOS ships STIX Two Math where Windows has Cambria Math.
150 (format.font.as_deref() == Some("Cambria Math"))
151 .then_some(FontFamilyName::named("STIX Two Math")),
147 // A missing family otherwise falls back per script and can put digits in an emoji font.152 // A missing family otherwise falls back per script and can put digits in an emoji font.
148 GenericFamily::SansSerif.into(),153 Some(GenericFamily::SansSerif.into()),
149 ]154 ]
155 .into_iter()
156 .flatten()
157 .collect::<Vec<_>>()
150 .into(),158 .into(),
151 )),159 )),
152 StyleProperty::FontSize(size),160 StyleProperty::FontSize(size),
crates/canvas/src/lib.rs+1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1pub mod document;1pub mod document;
2pub mod editor;2pub mod editor;
3pub mod layout;3pub mod layout;
4pub mod math;
4pub mod outline;5pub mod outline;
56
6pub mod date;7pub mod date;
crates/canvas/src/math.rs created+727
...@@ -0,0 +1,727 @@
1//! Two-dimensional layout for equations, from the tree `onestore::page::Math` parses out of
2//! the linear text OneNote stores.
3
4use crate::layout::{LayoutError, TextEngine, TextLayout};
5use onestore::document::Format;
6use onestore::page::Math;
7use onestore::page::text::Paragraph;
8
9/// An equation laid out for drawing; coordinates are relative to its top-left.
10#[derive(Clone)]
11pub struct MathLayout {
12 pub size: [f32; 2],
13 /// COLORREF for rules and strokes.
14 pub color: u32,
15 /// Distance from the top to the baseline of the equation's main row.
16 pub baseline: f32,
17 pub items: Vec<MathItem>,
18}
19
20#[derive(Clone)]
21pub enum MathItem {
22 Text {
23 layout: TextLayout,
24 origin: [f32; 2],
25 },
26 Rule([f32; 4]),
27 /// A pen stroke, for radical signs.
28 Stroke {
29 from: [f32; 2],
30 to: [f32; 2],
31 width: f32,
32 },
33}
34
35/// Items are relative to the box origin on its baseline; y grows downward.
36struct Bx {
37 width: f32,
38 ascent: f32,
39 descent: f32,
40 items: Vec<MathItem>,
41}
42
43impl Bx {
44 fn empty() -> Self {
45 Self {
46 width: 0.0,
47 ascent: 0.0,
48 descent: 0.0,
49 items: Vec::new(),
50 }
51 }
52
53 /// Places `other` with its baseline origin at `at` in this box's coordinates.
54 fn place(&mut self, other: Bx, at: [f32; 2]) {
55 self.ascent = self.ascent.max(other.ascent - at[1]);
56 self.descent = self.descent.max(other.descent + at[1]);
57 self.width = self.width.max(at[0] + other.width);
58 self.items
59 .extend(other.items.into_iter().map(|item| offset(item, at)));
60 }
61
62 fn append(&mut self, other: Bx) {
63 let x = self.width;
64 self.place(other, [x, 0.0]);
65 }
66
67 fn space(&mut self, width: f32) {
68 self.width += width;
69 }
70
71 fn height(&self) -> f32 {
72 self.ascent + self.descent
73 }
74}
75
76fn offset(item: MathItem, [dx, dy]: [f32; 2]) -> MathItem {
77 match item {
78 MathItem::Text { layout, origin } => MathItem::Text {
79 layout,
80 origin: [origin[0] + dx, origin[1] + dy],
81 },
82 MathItem::Rule([x0, y0, x1, y1]) => MathItem::Rule([x0 + dx, y0 + dy, x1 + dx, y1 + dy]),
83 MathItem::Stroke { from, to, width } => MathItem::Stroke {
84 from: [from[0] + dx, from[1] + dy],
85 to: [to[0] + dx, to[1] + dy],
86 width,
87 },
88 }
89}
90
91const SCRIPT: f32 = 0.7;
92const AXIS: f32 = 0.25;
93const RULE: f32 = 0.06;
94
95struct Context<'a> {
96 engine: &'a mut TextEngine,
97 color: Option<u32>,
98}
99
100impl Context<'_> {
101 fn text(&mut self, text: &str, size: f32) -> Result<Bx, LayoutError> {
102 let layout = self.engine.layout(
103 &Paragraph::new(
104 text.into(),
105 Format {
106 font: Some("Cambria Math".into()),
107 font_size: Some(size),
108 color: self.color,
109 ..Format::default()
110 },
111 ),
112 // Atoms never wrap.
113 f32::MAX / 4.0,
114 )?;
115 let (line, bounds) = layout
116 .lines()
117 .next()
118 .ok_or(LayoutError::InvalidFontMetrics)?;
119 let (width, baseline) = (line.metrics().advance, bounds.baseline);
120 // Math fonts' line boxes span their tallest glyphs; placement follows the ink instead.
121 let [ascent, descent] = ink(&layout);
122 Ok(Bx {
123 width,
124 ascent,
125 descent,
126 items: vec![MathItem::Text {
127 layout,
128 origin: [0.0, -baseline],
129 }],
130 })
131 }
132
133 fn row(&mut self, nodes: &[Math], size: f32) -> Result<Bx, LayoutError> {
134 let mut result = Bx::empty();
135 for (index, node) in nodes.iter().enumerate() {
136 let spaced = match node {
137 Math::Operator(c) if index > 0 => spacing(*c),
138 _ => 0.0,
139 } * size;
140 result.space(spaced);
141 result.append(self.node(node, size)?);
142 if index + 1 < nodes.len() {
143 result.space(spaced);
144 }
145 }
146 Ok(result)
147 }
148
149 fn node(&mut self, node: &Math, size: f32) -> Result<Bx, LayoutError> {
150 match node {
151 Math::Identifier(c) => self.text(&italic(*c).to_string(), size),
152 Math::Function(name) | Math::Number(name) => self.text(name, size),
153 Math::Operator(' ') => {
154 let mut space = Bx::empty();
155 space.space(0.2 * size);
156 Ok(space)
157 }
158 Math::Operator(c) => self.text(&c.to_string(), size),
159 Math::Object {
160 kind,
161 symbols,
162 columns,
163 arguments,
164 } => self.object(*kind, symbols, *columns, arguments, size),
165 }
166 }
167
168 fn argument(
169 &mut self,
170 arguments: &[Vec<Math>],
171 index: usize,
172 size: f32,
173 ) -> Result<Bx, LayoutError> {
174 self.row(arguments.get(index).map_or(&[][..], Vec::as_slice), size)
175 }
176
177 fn object(
178 &mut self,
179 kind: u32,
180 symbols: &[char],
181 columns: Option<u8>,
182 arguments: &[Vec<Math>],
183 size: f32,
184 ) -> Result<Bx, LayoutError> {
185 let script = size * SCRIPT;
186 Ok(match (kind, arguments.len()) {
187 (31, 2) => self.scripts(None, Some(1), arguments, size)?,
188 (29, 2) => self.scripts(Some(1), None, arguments, size)?,
189 (30, 3) => self.scripts(Some(1), Some(2), arguments, size)?,
190 (16, 2) | (26, 2) => {
191 let numerator = self.argument(arguments, 0, size)?;
192 let denominator = self.argument(arguments, 1, size)?;
193 let width = numerator.width.max(denominator.width) + 0.2 * size;
194 let (axis, rule, gap) = (AXIS * size, RULE * size, 0.12 * size);
195 let mut result = Bx::empty();
196 let top = -axis - rule / 2.0 - gap - numerator.descent;
197 let bottom = -axis + rule / 2.0 + gap + denominator.ascent;
198 result.place(numerator.centered(width), [0.0, top]);
199 result.place(denominator.centered(width), [0.0, bottom]);
200 result.items.push(MathItem::Rule([
201 0.0,
202 -axis - rule / 2.0,
203 width,
204 -axis + rule / 2.0,
205 ]));
206 result
207 }
208 (25, _) => {
209 let body = self.argument(arguments, arguments.len().saturating_sub(1), size)?;
210 let degree = (arguments.len() == 2 && !arguments[0].is_empty())
211 .then(|| self.row(&arguments[0], size * 0.5))
212 .transpose()?;
213 radical(body, degree, size)
214 }
215 (19, 2) | (33, 2) => {
216 let base = self.argument(arguments, 0, size)?;
217 let limit = self.argument(arguments, 1, script)?;
218 if kind == 19 {
219 stacked(base, Some(limit), None, size)
220 } else {
221 stacked(base, None, Some(limit), size)
222 }
223 }
224 (13, _) => {
225 let body = self.argument(arguments, 0, size)?;
226 let open = symbols
227 .first()
228 .map(|c| self.fence(*c, &body, size))
229 .transpose()?;
230 let close = symbols
231 .get(1)
232 .map(|c| self.fence(*c, &body, size))
233 .transpose()?;
234 let mut result = Bx::empty();
235 for part in [open, Some(body), close].into_iter().flatten() {
236 result.append(part);
237 }
238 result
239 }
240 (10, 1) => {
241 let base = self.argument(arguments, 0, size)?;
242 let accent = self.text(
243 &spacing_accent(symbols.first().copied().unwrap_or('\u{302}')).to_string(),
244 size,
245 )?;
246 let mut result = Bx::empty();
247 let width = base.width;
248 // The accent's ink bottom sits just above the base's ink top.
249 let lift = -base.ascent - 0.05 * size - accent.descent;
250 result.place(base, [0.0, 0.0]);
251 let x = (width - accent.width) / 2.0;
252 result.place(accent, [x.max(0.0), lift]);
253 result
254 }
255 (23, 1) => {
256 let base = self.argument(arguments, 0, size)?;
257 let (rule, gap) = (RULE * size, 0.1 * size);
258 let mut result = Bx::empty();
259 let top = -base.ascent - gap;
260 let width = base.width;
261 result.place(base, [0.0, 0.0]);
262 result
263 .items
264 .push(MathItem::Rule([0.0, top - rule, width, top]));
265 result.ascent = result.ascent.max(-(top - rule));
266 result
267 }
268 (12, 1) => {
269 let body = self.argument(arguments, 0, size)?;
270 let (rule, pad) = (RULE * size, 0.12 * size);
271 let mut result = Bx::empty();
272 let [w, a, d] = [
273 body.width + 2.0 * pad,
274 body.ascent + pad,
275 body.descent + pad,
276 ];
277 result.place(body, [pad, 0.0]);
278 for rect in [
279 [0.0, -a, w, -a + rule],
280 [0.0, d - rule, w, d],
281 [0.0, -a, rule, d],
282 [w - rule, -a, w, d],
283 ] {
284 result.items.push(MathItem::Rule(rect));
285 }
286 result.ascent = result.ascent.max(a);
287 result.descent = result.descent.max(d);
288 result.width = w;
289 result
290 }
291 (21, 3) => {
292 let operator = symbols.first().copied().unwrap_or('∑');
293 let integral = ('\u{222b}'..='\u{2233}').contains(&operator);
294 // Display-size operators, as OneNote draws them.
295 let scale = if integral { 1.9 } else { 1.75 };
296 let glyph = self
297 .text(&operator.to_string(), size * scale)?
298 .on_axis(size);
299 let lower = (!arguments[0].is_empty())
300 .then(|| self.row(&arguments[0], script))
301 .transpose()?;
302 let upper = (!arguments[1].is_empty())
303 .then(|| self.row(&arguments[1], script))
304 .transpose()?;
305 let mut result = if integral {
306 let mut result = Bx::empty();
307 let (ascent, descent, width) = (glyph.ascent, glyph.descent, glyph.width);
308 result.append(glyph);
309 let x = width + 0.05 * size;
310 if let Some(upper) = upper {
311 let y = -ascent + upper.ascent;
312 result.place(upper, [x, y]);
313 }
314 if let Some(lower) = lower {
315 let y = descent - lower.descent;
316 result.place(lower, [x - 0.2 * size, y]);
317 }
318 result
319 } else {
320 stacked(glyph, lower, upper, size)
321 };
322 result.space(0.15 * size);
323 result.append(self.argument(arguments, 2, size)?);
324 result
325 }
326 (20, _) | (15, _) => {
327 let width = if kind == 20 {
328 usize::from(columns.unwrap_or(1).max(1))
329 } else {
330 1
331 };
332 let cells = arguments
333 .iter()
334 .map(|cell| {
335 let cell: Vec<Math> = cell
336 .iter()
337 .filter(|n| **n != Math::Operator('&'))
338 .cloned()
339 .collect();
340 self.row(&cell, size)
341 })
342 .collect::<Result<Vec<_>, _>>()?;
343 grid(cells, width, kind == 20, size)
344 }
345 (11, 1) => self.argument(arguments, 0, size)?,
346 _ => {
347 let mut result = Bx::empty();
348 for symbol in symbols {
349 result.append(self.text(&symbol.to_string(), size)?);
350 }
351 for index in 0..arguments.len() {
352 result.append(self.argument(arguments, index, size)?);
353 }
354 result
355 }
356 })
357 }
358
359 /// The first argument with the arguments at `lower` and `upper` as its scripts.
360 fn scripts(
361 &mut self,
362 lower: Option<usize>,
363 upper: Option<usize>,
364 arguments: &[Vec<Math>],
365 size: f32,
366 ) -> Result<Bx, LayoutError> {
367 let script = size * SCRIPT;
368 let mut result = self.argument(arguments, 0, size)?;
369 let x = result.width + 0.03 * size;
370 let both = lower.is_some() && upper.is_some();
371 if let Some(index) = upper {
372 let upper = self.argument(arguments, index, script)?;
373 result.place(upper, [x, -0.4 * size]);
374 }
375 if let Some(index) = lower {
376 let lower = self.argument(arguments, index, script)?;
377 result.place(lower, [x, if both { 0.3 } else { 0.2 } * size]);
378 }
379 Ok(result)
380 }
381
382 /// A delimiter tall enough for `body`, centered on the math axis.
383 fn fence(&mut self, symbol: char, body: &Bx, size: f32) -> Result<Bx, LayoutError> {
384 let axis = AXIS * size;
385 let reach = (body.ascent - axis).max(body.descent + axis);
386 let grown = (reach * 2.0 / (size * 1.15)).max(1.0) * size;
387 Ok(self.text(&symbol.to_string(), grown)?.on_axis(size))
388 }
389}
390
391impl Bx {
392 /// This box moved vertically so its extent centers on the math axis.
393 fn on_axis(self, size: f32) -> Bx {
394 let shift = (self.ascent - self.descent) / 2.0 - AXIS * size;
395 Bx {
396 width: self.width,
397 ascent: self.ascent - shift,
398 descent: self.descent + shift,
399 items: self
400 .items
401 .into_iter()
402 .map(|i| offset(i, [0.0, shift]))
403 .collect(),
404 }
405 }
406
407 /// This box horizontally centered in `width`.
408 fn centered(self, width: f32) -> Bx {
409 let dx = (width - self.width) / 2.0;
410 Bx {
411 width,
412 ascent: self.ascent,
413 descent: self.descent,
414 items: self
415 .items
416 .into_iter()
417 .map(|i| offset(i, [dx, 0.0]))
418 .collect(),
419 }
420 }
421}
422
423/// `base` with `lower` below and `upper` above, all centered on the widest.
424fn stacked(base: Bx, lower: Option<Bx>, upper: Option<Bx>, size: f32) -> Bx {
425 let gap = 0.08 * size;
426 let width = [Some(&base), lower.as_ref(), upper.as_ref()]
427 .into_iter()
428 .flatten()
429 .map(|b| b.width)
430 .fold(0.0, f32::max);
431 let (ascent, descent) = (base.ascent, base.descent);
432 let mut result = Bx::empty();
433 result.place(base.centered(width), [0.0, 0.0]);
434 if let Some(lower) = lower {
435 let y = descent + gap + lower.ascent;
436 result.place(lower.centered(width), [0.0, y]);
437 }
438 if let Some(upper) = upper {
439 let y = -ascent - gap - upper.descent;
440 result.place(upper.centered(width), [0.0, y]);
441 }
442 result
443}
444
445/// A radical sign drawn with the pen over `body`, with an optional small `degree`.
446fn radical(body: Bx, degree: Option<Bx>, size: f32) -> Bx {
447 let (rule, gap) = (RULE * size, 0.12 * size);
448 let top = -body.ascent - gap;
449 let bottom = body.descent;
450 let sign = 0.55 * size;
451 let mut result = Bx::empty();
452 let lead = degree
453 .as_ref()
454 .map_or(0.0, |d| (d.width - sign * 0.4).max(0.0));
455 let [x0, x1, x2] = [lead, lead + sign * 0.35, lead + sign * 0.6];
456 let mid = bottom - (bottom - top) * 0.45;
457 for (from, to, width) in [
458 ([x0, mid + 0.05 * size], [x0 + sign * 0.12, mid], rule),
459 ([x0 + sign * 0.12, mid], [x1, bottom], rule * 1.6),
460 ([x1, bottom], [x2, top + rule / 2.0], rule),
461 ] {
462 result.items.push(MathItem::Stroke { from, to, width });
463 }
464 let body_width = body.width;
465 result.place(body, [x2 + 0.05 * size, 0.0]);
466 result.items.push(MathItem::Rule([
467 x2,
468 top,
469 x2 + 0.05 * size + body_width + 0.05 * size,
470 top + rule,
471 ]));
472 result.width = x2 + 0.1 * size + body_width;
473 result.ascent = result.ascent.max(-top);
474 result.descent = result.descent.max(bottom);
475 if let Some(degree) = degree {
476 let y = mid - 0.1 * size - degree.descent;
477 result.place(degree, [0.0, y]);
478 }
479 result
480}
481
482/// Cells in rows of `width` columns, centered in each column (left-aligned for equation
483/// arrays), the whole grid centered on the math axis.
484fn grid(cells: Vec<Bx>, width: usize, centered: bool, size: f32) -> Bx {
485 let (column_gap, row_gap) = (0.8 * size, 0.2 * size);
486 let mut columns = vec![0.0_f32; width];
487 for (index, cell) in cells.iter().enumerate() {
488 columns[index % width] = columns[index % width].max(cell.width);
489 }
490 let rows: Vec<(f32, f32)> = cells
491 .chunks(width)
492 .map(|row| {
493 row.iter().fold((0.0_f32, 0.0_f32), |(a, d), cell| {
494 (a.max(cell.ascent), d.max(cell.descent))
495 })
496 })
497 .collect();
498 let height: f32 = rows.iter().map(|(a, d)| a + d).sum::<f32>()
499 + row_gap * rows.len().saturating_sub(1) as f32;
500 let mut y = -AXIS * size - height / 2.0;
501 let mut result = Bx::empty();
502 let mut cells = cells.into_iter();
503 for (ascent, descent) in rows {
504 let mut x = 0.0;
505 for column in &columns {
506 let Some(cell) = cells.next() else { break };
507 let dx = if centered {
508 (column - cell.width) / 2.0
509 } else {
510 0.0
511 };
512 result.place(cell, [x + dx, y + ascent]);
513 x += column + column_gap;
514 }
515 y += ascent + descent + row_gap;
516 }
517 result.width = columns.iter().sum::<f32>() + column_gap * width.saturating_sub(1) as f32;
518 result
519}
520
521/// Extra space each side of an operator, in em: medium for binary operators, thick for
522/// relations and arrows.
523fn spacing(c: char) -> f32 {
524 match c {
525 '+' | '-' | '−' | '±' | '∓' | '×' | '÷' | '·' | '∙' | '*' => 4.0 / 18.0,
526 '=' | '<' | '>' | '≤' | '≥' | '≠' | '≈' | '≡' | '→' | '←' | '↔' | '⇒' | '∈' | '∉' => {
527 5.0 / 18.0
528 }
529 _ => 0.0,
530 }
531}
532
533/// The spacing form of a combining accent, drawn above its base.
534fn spacing_accent(c: char) -> char {
535 match c {
536 '\u{302}' => 'ˆ',
537 '\u{303}' => '˜',
538 '\u{307}' => '˙',
539 '\u{308}' => '¨',
540 '\u{301}' => '´',
541 '\u{300}' => '`',
542 '\u{306}' => '˘',
543 '\u{30c}' => 'ˇ',
544 '\u{20d7}' => '→',
545 c => c,
546 }
547}
548
549/// Latin letters draw in mathematical italic, as OneNote's equation editor stores them.
550fn italic(c: char) -> char {
551 match c {
552 'h' => '\u{210e}',
553 'a'..='z' => char::from_u32(0x1d44e + (c as u32 - 'a' as u32)).unwrap(),
554 'A'..='Z' => char::from_u32(0x1d434 + (c as u32 - 'A' as u32)).unwrap(),
555 c => c,
556 }
557}
558
559/// Height above and depth below the baseline of the glyphs' outlines; the depth is negative
560/// for ink that floats above the baseline, as an accent's does.
561fn ink(layout: &TextLayout) -> [f32; 2] {
562 use skrifa::{MetadataProvider, instance::Size};
563 let mut extent = [f32::NEG_INFINITY; 2];
564 for line in layout.shaped.lines() {
565 for item in line.items() {
566 let parley::PositionedLayoutItem::GlyphRun(glyphs) = item else {
567 continue;
568 };
569 let run = glyphs.run();
570 let font = &run.font().font;
571 let Ok(font) = skrifa::FontRef::from_index(font.data.as_ref(), font.index) else {
572 continue;
573 };
574 let metrics = font.glyph_metrics(
575 Size::new(run.font_size()),
576 skrifa::instance::LocationRef::default(),
577 );
578 for glyph in glyphs.glyphs() {
579 match metrics.bounds(skrifa::GlyphId::new(glyph.id)) {
580 // Blank glyphs, such as spaces, have no ink.
581 Some(bounds) if bounds.y_max > bounds.y_min => {
582 extent[0] = extent[0].max(bounds.y_max - glyph.y);
583 extent[1] = extent[1].max(glyph.y - bounds.y_min);
584 }
585 _ => {}
586 }
587 }
588 }
589 }
590 extent.map(|v| if v.is_finite() { v } else { 0.0 })
591}
592
593/// Lays out an equation paragraph at the font size and colour of its first run.
594pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result<MathLayout, LayoutError> {
595 let nodes = Math::parse(paragraph).map_err(|_| LayoutError::UnsupportedContent)?;
596 let format = &paragraph.spans()[0].format;
597 let size = format.font_size.unwrap_or(11.0);
598 let color = format.color.filter(|color| *color != 0xff00_0000);
599 // An equation line is at least as tall as a line of text at its size.
600 let strut = engine.layout(
601 &Paragraph::new(
602 " ".into(),
603 Format {
604 font_size: Some(size),
605 ..Format::default()
606 },
607 ),
608 100.0,
609 )?;
610 let (_, line) = strut
611 .lines()
612 .next()
613 .ok_or(LayoutError::InvalidFontMetrics)?;
614 let mut body = Context { engine, color }.row(&nodes, size)?;
615 body.ascent = body.ascent.max(line.baseline);
616 body.descent = body.descent.max(line.height - line.baseline);
617 Ok(MathLayout {
618 size: [body.width, body.height()],
619 color: color.unwrap_or(0),
620 baseline: body.ascent,
621 items: body
622 .items
623 .into_iter()
624 .map(|item| offset(item, [0.0, body.ascent]))
625 .collect(),
626 })
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 fn object(kind: u32, symbols: &[char], arguments: Vec<Vec<Math>>) -> Math {
634 Math::Object {
635 kind,
636 symbols: symbols.to_vec(),
637 columns: None,
638 arguments,
639 }
640 }
641
642 /// Each text's left edge and baseline.
643 fn texts(layout: &MathLayout) -> Vec<[f32; 2]> {
644 layout
645 .items
646 .iter()
647 .filter_map(|item| match item {
648 MathItem::Text { origin, layout } => Some([
649 origin[0],
650 origin[1] + layout.lines().next().unwrap().1.baseline,
651 ]),
652 _ => None,
653 })
654 .collect()
655 }
656
657 fn laid_out(nodes: &[Math]) -> MathLayout {
658 let mut engine = TextEngine::default();
659 layout(&mut engine, &Math::paragraph(nodes, &Format::default())).unwrap()
660 }
661
662 #[test]
663 fn scripts_fractions_and_accents_stack_as_equations_do() {
664 let x = || vec![Math::Identifier('x')];
665 let scripted = laid_out(&[object(
666 30,
667 &[],
668 vec![
669 x(),
670 vec![Math::Identifier('i')],
671 vec![Math::Number("2".into())],
672 ],
673 )]);
674 let [base, upper, lower] = texts(&scripted)[..] else {
675 panic!()
676 };
677 assert!(lower[0] > base[0] && upper[0] == lower[0]);
678 assert!(upper[1] < base[1] && lower[1] > base[1]);
679
680 let fraction = laid_out(&[object(16, &[], vec![x(), vec![Math::Identifier('y')]])]);
681 let [numerator, denominator] = texts(&fraction)[..] else {
682 panic!()
683 };
684 let bar = fraction.items.iter().find_map(|item| match item {
685 MathItem::Rule(rect) => Some(*rect),
686 _ => None,
687 });
688 let [_, top, _, bottom] = bar.unwrap();
689 assert!(numerator[1] < top && denominator[1] > numerator[1] && bottom > top);
690 assert!(fraction.size[1] > laid_out(&[Math::Identifier('x')]).size[1]);
691
692 let accented = laid_out(&[object(10, &['\u{302}'], vec![x()])]);
693 let [base, accent] = texts(&accented)[..] else {
694 panic!()
695 };
696 assert!(accent[1] < base[1]);
697
698 let radical = laid_out(&[object(25, &[], vec![vec![], x()])]);
699 assert!(
700 radical
701 .items
702 .iter()
703 .any(|item| matches!(item, MathItem::Stroke { .. }))
704 );
705 assert!(
706 radical
707 .items
708 .iter()
709 .any(|item| matches!(item, MathItem::Rule(_)))
710 );
711 }
712
713 #[test]
714 fn a_plain_equation_keeps_the_height_of_a_line_of_text() {
715 let mut engine = TextEngine::default();
716 let line = engine
717 .layout(&Paragraph::new("x".into(), Format::default()), 100.0)
718 .unwrap();
719 let equation = laid_out(&[
720 Math::Identifier('x'),
721 Math::Operator('+'),
722 Math::Number("1".into()),
723 ]);
724 assert!((equation.size[1] - line.height()).abs() < 0.01);
725 assert!(equation.size[0] > 0.0);
726 }
727}
crates/canvas/src/outline.rs+16-1
...@@ -86,6 +86,8 @@ pub struct ParagraphLayout {...@@ -86,6 +86,8 @@ pub struct ParagraphLayout {
86 /// Marker x is outline-local; y is paragraph-local so reflow cannot accumulate rounding drift.86 /// Marker x is outline-local; y is paragraph-local so reflow cannot accumulate rounding drift.
87 pub markers: Vec<(TextLayout, [f32; 2])>,87 pub markers: Vec<(TextLayout, [f32; 2])>,
88 pub tags: Vec<ParagraphTag>,88 pub tags: Vec<ParagraphTag>,
89 /// An equation draws in two dimensions in place of its linear text.
90 pub math: Option<crate::math::MathLayout>,
89}91}
9092
91#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]93#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
...@@ -201,6 +203,9 @@ impl ParagraphLayout {...@@ -201,6 +203,9 @@ impl ParagraphLayout {
201 }203 }
202204
203 fn size(&self) -> [f32; 2] {205 fn size(&self) -> [f32; 2] {
206 if let Some(math) = &self.math {
207 return [self.origin[0] + math.size[0], math.size[1]];
208 }
204 [209 [
205 self.origin[0] + self.text.shaped.width(),210 self.origin[0] + self.text.shaped.width(),
206 self.markers211 self.markers
...@@ -257,7 +262,8 @@ impl ParagraphLayout {...@@ -257,7 +262,8 @@ impl ParagraphLayout {
257 // The newest tag that sets a colour wins.262 // The newest tag that sets a colour wins.
258 let color = tag_definitions.iter().rev().find_map(|tag| tag.3);263 let color = tag_definitions.iter().rev().find_map(|tag| tag.3);
259 let highlight = tag_definitions.iter().rev().find_map(|tag| tag.4);264 let highlight = tag_definitions.iter().rev().find_map(|tag| tag.4);
260 let mut text = if color.is_none() && highlight.is_none() {265 let equation = onestore::page::Math::is_equation(&source.text);
266 let mut text = if color.is_none() && highlight.is_none() && !equation {
261 engine.layout(projection.text(), width - indent)?267 engine.layout(projection.text(), width - indent)?
262 } else {268 } else {
263 let visible = projection.text();269 let visible = projection.text();
...@@ -266,6 +272,11 @@ impl ParagraphLayout {...@@ -266,6 +272,11 @@ impl ParagraphLayout {
266 let mut format = span.format.clone();272 let mut format = span.format.clone();
267 format.color = color.or(format.color);273 format.color = color.or(format.color);
268 format.highlight = highlight.or(format.highlight);274 format.highlight = highlight.or(format.highlight);
275 // An equation draws in two dimensions; its linear text only holds the caret,
276 // which the body font sizes like a line of text.
277 if equation {
278 format.font = None;
279 }
269 let run = (visible.text()[start..span.end].to_owned(), format);280 let run = (visible.text()[start..span.end].to_owned(), format);
270 start = span.end;281 start = span.end;
271 run282 run
...@@ -337,6 +348,9 @@ impl ParagraphLayout {...@@ -337,6 +348,9 @@ impl ParagraphLayout {
337 disabled: tag.status & 2 != 0,348 disabled: tag.status & 2 != 0,
338 });349 });
339 }350 }
351 let math = equation
352 .then(|| crate::math::layout(engine, &source.text))
353 .transpose()?;
340 Ok(Self {354 Ok(Self {
341 id: paragraph.id,355 id: paragraph.id,
342 origin: [indent, 0.0],356 origin: [indent, 0.0],
...@@ -344,6 +358,7 @@ impl ParagraphLayout {...@@ -344,6 +358,7 @@ impl ParagraphLayout {
344 text,358 text,
345 markers,359 markers,
346 tags,360 tags,
361 math,
347 })362 })
348 }363 }
349}364}
crates/snowbound/src/main.rs+3-27
...@@ -2226,33 +2226,9 @@ fn append_outline<'a>(...@@ -2226,33 +2226,9 @@ fn append_outline<'a>(
2226 }2226 }
2227 }2227 }
2228 for (index, (_, paragraph)) in outline.layouts().enumerate() {2228 for (index, (_, paragraph)) in outline.layouts().enumerate() {
2229 let layout = &paragraph.text;2229 outline
2230 let paragraph_origin = paragraph.origin;2230 .shaped()
2231 let clip = outline.shaped().paragraph_cell(index).map(|cell| {2231 .append_paragraph_primitives(index, paragraph, origin, primitives);
2232 let [left, top, right, bottom] = cell.text_bounds();
2233 [left + x, top + y, right + x, bottom + y]
2234 });
2235 primitives.push(Primitive::Text {
2236 clip,
2237 layout,
2238 origin: [x + paragraph_origin[0], y + paragraph_origin[1]],
2239 });
2240 for (layout, origin) in &paragraph.markers {
2241 primitives.push(Primitive::Text {
2242 clip,
2243 layout,
2244 origin: [x + origin[0], y + (origin[1] + paragraph_origin[1])],
2245 });
2246 }
2247 for tag in &paragraph.tags {
2248 primitives.push(Primitive::Tag {
2249 tag,
2250 origin: [
2251 x + outline.shaped().tag_column_offset(),
2252 y + paragraph_origin[1],
2253 ],
2254 });
2255 }
2256 }2232 }
2257 if let Some(editor) = editor {2233 if let Some(editor) = editor {
2258 for rect in editor.marked_rects()? {2234 for rect in editor.marked_rects()? {
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## Equations
53
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.
55
52## Optional font substitution56## Optional font substitution
5357
54The host and `layout-probe` accept repeated `--substitute-font FONT_FILE` options. Supply Arimo regular/italic variable fonts or Carlito regular/bold/italic/bold-italic files. Each file is registered only in this process, as Arial or Calibri respectively; this explicitly replaces that family even when a system copy exists. Stored `Format.font` values remain unchanged. No font files are installed or bundled by this option.58The host and `layout-probe` accept repeated `--substitute-font FONT_FILE` options. Supply Arimo regular/italic variable fonts or Carlito regular/bold/italic/bold-italic files. Each file is registered only in this process, as Arial or Calibri respectively; this explicitly replaces that family even when a system copy exists. Stored `Format.font` values remain unchanged. No font files are installed or bundled by this option.