diff --git a/crates/canvas/assets/tags/blue-square.svg b/crates/canvas/assets/tags/blue-square.svg
new file mode 100644
index 0000000000000000000000000000000000000000..7354567b0e85cd07d543f82a97fb5cbb801b0876
--- /dev/null
+++ b/crates/canvas/assets/tags/blue-square.svg
@@ -0,0 +1,19 @@
+
diff --git a/crates/canvas/assets/tags/exclamation.svg b/crates/canvas/assets/tags/exclamation.svg
new file mode 100644
index 0000000000000000000000000000000000000000..34e54f41c46b94f6416264d276d29a397b1ce37e
--- /dev/null
+++ b/crates/canvas/assets/tags/exclamation.svg
@@ -0,0 +1,9 @@
+
diff --git a/crates/canvas/assets/tags/red-square.svg b/crates/canvas/assets/tags/red-square.svg
new file mode 100644
index 0000000000000000000000000000000000000000..f642e987415b3550bc122289352a2ee4379adf6c
--- /dev/null
+++ b/crates/canvas/assets/tags/red-square.svg
@@ -0,0 +1,19 @@
+
diff --git a/crates/canvas/assets/tags/yellow-square.svg b/crates/canvas/assets/tags/yellow-square.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6369f854bf919af45fc5460d4a456f61cf42d80b
--- /dev/null
+++ b/crates/canvas/assets/tags/yellow-square.svg
@@ -0,0 +1,19 @@
+
diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs
index b31dcb60fe45be827dcec0e7f6756c499cf13853..c17a4021b30e3606c8c030d4bebc6929f208d68a 100644
--- a/crates/canvas/src/gpu/page.rs
+++ b/crates/canvas/src/gpu/page.rs
@@ -497,7 +497,10 @@ impl PageScene {
for tag in ¶graph.tags {
primitives.push(Primitive::Tag {
tag,
- origin: [object_origin[0], object_origin[1] + paragraph.origin[1]],
+ origin: [
+ object_origin[0] + outline.tag_column_offset(),
+ object_origin[1] + paragraph.origin[1],
+ ],
});
}
}
diff --git a/crates/canvas/src/gpu/tags.rs b/crates/canvas/src/gpu/tags.rs
index c68f4b8554b09fd97df2463acca67e6fbcb0e381..37038f6904e233460ecb9bfb7e8ae0793f7b90b9 100644
--- a/crates/canvas/src/gpu/tags.rs
+++ b/crates/canvas/src/gpu/tags.rs
@@ -12,6 +12,10 @@ pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image {
),
TagIcon::Question => (include_str!("../../assets/tags/question.svg"), None),
TagIcon::Music => (include_str!("../../assets/tags/music.svg"), None),
+ TagIcon::Exclamation => (include_str!("../../assets/tags/exclamation.svg"), None),
+ TagIcon::RedSquare => (include_str!("../../assets/tags/red-square.svg"), None),
+ TagIcon::YellowSquare => (include_str!("../../assets/tags/yellow-square.svg"), None),
+ TagIcon::BlueSquare => (include_str!("../../assets/tags/blue-square.svg"), None),
};
let width = (size + 1.0).ceil() as u32;
let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize];
@@ -107,6 +111,10 @@ mod tests {
TagIcon::CheckBox { checked: true },
TagIcon::Question,
TagIcon::Music,
+ TagIcon::Exclamation,
+ TagIcon::RedSquare,
+ TagIcon::YellowSquare,
+ TagIcon::BlueSquare,
] {
let image = rasterize(icon, size, phase);
assert_eq!(
diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs
index ccb747c769d8f1c7edc6086369d3715aaf526764..8efbc1d901a4357790156f1914d0a7682114d224 100644
--- a/crates/canvas/src/outline.rs
+++ b/crates/canvas/src/outline.rs
@@ -74,6 +74,10 @@ pub enum TagIcon {
CheckBox { checked: bool },
Question,
Music,
+ Exclamation,
+ RedSquare,
+ YellowSquare,
+ BlueSquare,
}
#[derive(Clone, Debug, PartialEq)]
@@ -155,7 +159,50 @@ impl ParagraphLayout {
if !spacing.is_finite() || spacing < 0.0 {
return Err(LayoutError::InvalidSpacing);
}
- let mut text = engine.layout(projection.text(), width - indent)?;
+ // Stored newest first; OneNote lists and paints tags oldest first.
+ let mut tag_definitions = paragraph
+ .tags
+ .iter()
+ .chain(&source.tags)
+ .map(
+ |tag| match tag.definition.as_ref().and_then(|id| definitions.get(id)) {
+ Some(Definition {
+ kind:
+ Kind::TagDefinition {
+ shape,
+ label,
+ color,
+ highlight,
+ ..
+ },
+ ..
+ }) if tag.status & 4 == 0 => Ok((tag, *shape, label, *color, *highlight)),
+ _ => Err(LayoutError::UnsupportedContent),
+ },
+ )
+ .collect::, _>>()?;
+ tag_definitions.reverse();
+ // The newest tag that sets a colour wins.
+ let color = tag_definitions.iter().rev().find_map(|tag| tag.3);
+ let highlight = tag_definitions.iter().rev().find_map(|tag| tag.4);
+ let mut text = if color.is_none() && highlight.is_none() {
+ engine.layout(projection.text(), width - indent)?
+ } else {
+ let visible = projection.text();
+ let mut start = 0;
+ let runs = visible.spans().iter().map(|span| {
+ let mut format = span.format.clone();
+ format.color = color.or(format.color);
+ format.highlight = highlight.or(format.highlight);
+ let run = (visible.text()[start..span.end].to_owned(), format);
+ start = span.end;
+ run
+ });
+ engine.layout(
+ &Paragraph::from_runs(runs.collect::>()),
+ width - indent,
+ )?
+ };
let mut markers = Vec::new();
let mut marker_x = indent;
for id in paragraph.lists.iter().rev() {
@@ -194,37 +241,26 @@ impl ParagraphLayout {
}
text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?;
let mut tags = Vec::new();
- for tag in paragraph.tags.iter().chain(&source.tags) {
- let Some(Definition {
- kind:
- Kind::TagDefinition {
- shape,
- label,
- color,
- highlight,
- ..
- },
- ..
- }) = tag.definition.as_ref().and_then(|id| definitions.get(id))
- else {
- return Err(LayoutError::UnsupportedContent);
- };
- if color.is_some() || highlight.is_some() || tag.status & 4 != 0 {
- return Err(LayoutError::UnsupportedContent);
- }
+ for tag in &tag_definitions {
+ let (tag, shape, label) = (tag.0, tag.1, tag.2);
let icon = match shape {
Some(0) => continue,
Some(3) => TagIcon::CheckBox {
checked: tag.status & 1 != 0,
},
Some(15) => TagIcon::Question,
+ Some(17) => TagIcon::Exclamation,
+ Some(100) => TagIcon::RedSquare,
+ Some(101) => TagIcon::YellowSquare,
+ Some(102) => TagIcon::BlueSquare,
Some(121) => TagIcon::Music,
_ => return Err(LayoutError::UnsupportedContent),
};
- marker_x -= 20.25;
+ // Later tags follow the first to its right, toward the text.
+ let x = marker_x - 20.25 + 12.0 * tags.len() as f32;
tags.push(ParagraphTag {
icon,
- origin: [marker_x, 0.0],
+ origin: [x, 0.0],
label: label.clone().unwrap_or_default(),
disabled: tag.status & 2 != 0,
});
@@ -292,6 +328,18 @@ pub(crate) fn arrange<'a>(
}
impl OutlineLayout {
+ /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each
+ /// paragraph's tags start at the column's left edge. Tag origins assume a one-tag column.
+ pub fn tag_column_offset(&self) -> f32 {
+ let widest = self
+ .paragraphs
+ .iter()
+ .map(|p| p.tags.len())
+ .max()
+ .unwrap_or(0);
+ -12.0 * widest.saturating_sub(1) as f32
+ }
+
/// Innermost table cell containing a visible paragraph index.
pub fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> {
self.tables.iter().rev().find_map(|table| {
@@ -932,6 +980,10 @@ mod tests {
for (shape, expected) in [
(3, TagIcon::CheckBox { checked: true }),
(15, TagIcon::Question),
+ (17, TagIcon::Exclamation),
+ (100, TagIcon::RedSquare),
+ (101, TagIcon::YellowSquare),
+ (102, TagIcon::BlueSquare),
(121, TagIcon::Music),
] {
definitions.insert(
@@ -1000,6 +1052,75 @@ mod tests {
));
}
+ #[test]
+ fn tags_paint_oldest_first_and_the_newest_colour_wins() {
+ use onestore::document::Tag;
+ let mut definitions = BTreeMap::new();
+ let mut tag = |n, shape, color| {
+ let id = ExGuid {
+ n,
+ ..ExGuid::default()
+ };
+ definitions.insert(
+ id,
+ Definition {
+ kind: Kind::TagDefinition {
+ shape: Some(shape),
+ label: None,
+ action_type: None,
+ color,
+ highlight: None,
+ },
+ format: Format::default(),
+ },
+ );
+ Tag {
+ definition: Some(id),
+ status: 1,
+ action_type: None,
+ created: None,
+ completed: None,
+ start: None,
+ due: None,
+ task_id: None,
+ extra_set: 0,
+ }
+ };
+ let [action, mood, project, question] = [
+ tag(1, 0, Some(0x0080_0080)),
+ tag(2, 0, Some(0x0080_8000)),
+ tag(3, 100, None),
+ tag(4, 15, None),
+ ];
+ let mut node = paragraph(1, "Lyric", 1, None);
+ // As stored: newest first.
+ node.text_mut().unwrap().tags = vec![question, project, mood, action];
+ let mut engine = TextEngine::default();
+ let shaped =
+ ParagraphLayout::shape(&mut engine, &node, 200.0, &[0.0, 0.0], &definitions).unwrap();
+ assert_eq!(
+ shaped
+ .tags
+ .iter()
+ .map(|tag| (tag.icon, tag.origin[0]))
+ .collect::>(),
+ [(TagIcon::RedSquare, -20.25), (TagIcon::Question, -8.25)]
+ );
+ let colors: Vec<_> = shaped
+ .text
+ .lines()
+ .flat_map(|(line, _)| line.items().collect::>())
+ .filter_map(|item| match item {
+ parley::PositionedLayoutItem::GlyphRun(run) => Some(run.style().brush.color),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(colors, [0x0080_8000]);
+ assert_eq!(shaped.projection.text().spans()[0].format.color, None);
+ let outline = OutlineLayout::new(vec![shaped], 200.0, false).unwrap();
+ assert_eq!(outline.tag_column_offset(), -12.0);
+ }
+
#[test]
fn title_uses_a_default_wrap_limit_and_keeps_date_after_wrapped_title() {
let mut title = Title {
diff --git a/crates/snowbound/src/accessibility.rs b/crates/snowbound/src/accessibility.rs
index 15d769b41cadcd7399c6528c44a83addba9632e2..cb8c3bfc6440f460004a22550547de2653454e37 100644
--- a/crates/snowbound/src/accessibility.rs
+++ b/crates/snowbound/src/accessibility.rs
@@ -366,6 +366,10 @@ impl Accessibility {
TagIcon::CheckBox { .. } => "To do",
TagIcon::Question => "Question",
TagIcon::Music => "Music",
+ TagIcon::Exclamation => "Critical",
+ TagIcon::RedSquare => "Project A",
+ TagIcon::YellowSquare => "Project B",
+ TagIcon::BlueSquare => "Project C",
};
let label = if tag.label.is_empty() {
fallback
diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs
index fa100bb08d36da7aa9c51c6d7af672b2d994e130..2ada41bfb62d13332bbd9063db3c631c82178735 100644
--- a/crates/snowbound/src/main.rs
+++ b/crates/snowbound/src/main.rs
@@ -1753,7 +1753,7 @@ fn page_hit_test(
|| outline.layouts().any(|(_, paragraph)| {
paragraph.tags.iter().any(|tag| {
let origin = outline.origin();
- let x = origin[0] + tag.origin[0];
+ let x = origin[0] + outline.shaped().tag_column_offset() + tag.origin[0];
let y = origin[1] + paragraph.origin[1] + tag.origin[1];
let size = canvas::outline::ParagraphTag::SIZE;
(x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1])
@@ -2211,7 +2211,10 @@ fn append_outline<'a>(
for tag in ¶graph.tags {
primitives.push(Primitive::Tag {
tag,
- origin: [x, y + paragraph_origin[1]],
+ origin: [
+ x + outline.shaped().tag_column_offset(),
+ y + paragraph_origin[1],
+ ],
});
}
}
diff --git a/tools/canvas/README.md b/tools/canvas/README.md
index 7fb0d689143b42e54a2a506f74e82e6888754dfb..a31ce478b39da265b351be6844c73f532fb397a8 100644
--- a/tools/canvas/README.md
+++ b/tools/canvas/README.md
@@ -45,9 +45,9 @@ Image textures have a separate 64 MiB / 256-entry cache. Offscreen images do not
## Note tags
-Paragraph layout retains blue checkbox (shape 3), question mark (15) and musical note (121) tags from paragraph and rich-text nodes. Their 12 pt tiles precede the list marker or text origin with a 20.25 pt gutter; they do not change text advances, wrapping or line height. These dimensions cover the three reference pages' 17 tags. Hand-authored vector paths use Swash's existing rasterizer and the text atlas, including its subpixel phases, offscreen culling and eviction. No Office bitmap assets or tag fonts are bundled.
+Paragraph layout retains checkbox (shape 3), question mark (15), exclamation (17), red, yellow and blue square (100–102) and musical note (121) tags from paragraph and rich-text nodes. Tags are stored newest first and drawn oldest first: an outline has one tag column, as wide as its most-tagged paragraph needs (12 pt per tag), whose left edge sits 20.25 pt plus 12 pt per extra tag before the list marker or text origin, and each paragraph's tags start at that edge. Tags do not change text advances, wrapping or line height. A tag's font colour or highlight paints the whole paragraph, the newest such tag winning; shape 0 draws no icon. Positions, order and colours match OneNote 2010 on the notebook's lyric pages. Hand-authored vector paths use Swash's existing rasterizer and the text atlas, including its subpixel phases, offscreen culling and eviction. No Office bitmap assets or tag fonts are bundled.
-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, task tags and tag color/highlight overrides return `UnsupportedContent` during layout.
+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.
## Optional font substitution