diff --git a/Cargo.lock b/Cargo.lock
index 6bf20d2837fa08a080aa0d3d68222c77f9fa7e13..34a6fe55afe48023c7ef60de5d8340d15e9c73d1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -792,6 +792,12 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+[[package]]
+name = "fax"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
+
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1229,6 +1235,7 @@ dependencies = [
"moxcms",
"num-traits",
"png",
+ "tiff",
"zune-core",
"zune-jpeg",
]
@@ -2282,6 +2289,12 @@ version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -2927,6 +2940,20 @@ dependencies = [
"syn 3.0.5",
]
+[[package]]
+name = "tiff"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
+dependencies = [
+ "fax",
+ "flate2",
+ "half",
+ "quick-error",
+ "weezl",
+ "zune-jpeg",
+]
+
[[package]]
name = "tiny-skia"
version = "0.11.4"
diff --git a/crates/canvas/assets/tags/address.svg b/crates/canvas/assets/tags/address.svg
new file mode 100644
index 0000000000000000000000000000000000000000..428250519b22d61e99daa3f5a71634f66d0043f1
--- /dev/null
+++ b/crates/canvas/assets/tags/address.svg
@@ -0,0 +1,34 @@
+
diff --git a/crates/canvas/assets/tags/contact.svg b/crates/canvas/assets/tags/contact.svg
new file mode 100644
index 0000000000000000000000000000000000000000..33d2f91d9b16edbf8434235264e8e4c1b158749c
--- /dev/null
+++ b/crates/canvas/assets/tags/contact.svg
@@ -0,0 +1,25 @@
+
diff --git a/crates/canvas/assets/tags/highlight.svg b/crates/canvas/assets/tags/highlight.svg
new file mode 100644
index 0000000000000000000000000000000000000000..9922e577f6a695a0c1b4597805fc654a293811c4
--- /dev/null
+++ b/crates/canvas/assets/tags/highlight.svg
@@ -0,0 +1,29 @@
+
diff --git a/crates/canvas/assets/tags/phone.svg b/crates/canvas/assets/tags/phone.svg
new file mode 100644
index 0000000000000000000000000000000000000000..07d6349b3f96797e1cba895eff3d0b7ca84b16d0
--- /dev/null
+++ b/crates/canvas/assets/tags/phone.svg
@@ -0,0 +1,14 @@
+
diff --git a/crates/canvas/assets/tags/star.svg b/crates/canvas/assets/tags/star.svg
new file mode 100644
index 0000000000000000000000000000000000000000..62f340191717c95b0a5fbc21a2522a3241ed68ad
--- /dev/null
+++ b/crates/canvas/assets/tags/star.svg
@@ -0,0 +1,14 @@
+
diff --git a/crates/canvas/src/date.rs b/crates/canvas/src/date.rs
index fc7cfab6c42caa1d69af24a9b70837e03db109ba..0ef98c2460670404ca1450e061750bd06efe0dd9 100644
--- a/crates/canvas/src/date.rs
+++ b/crates/canvas/src/date.rs
@@ -72,13 +72,17 @@ impl PageDate {
if source.title || source.paragraphs.is_empty() || source.paragraphs.len() > 2 {
return Err(EditError::UnsupportedContent.into());
}
- let document = TextDocument::from_nodes(source.paragraphs.clone())?;
- document.validate_flat()?;
- if source
- .paragraphs
- .iter()
- .any(|paragraph| paragraph.text().unwrap().text.spans().len() != 1)
- {
+ TextDocument::from_nodes(source.paragraphs.clone())?;
+ // A date field is one plain run in a plain paragraph.
+ if source.paragraphs.iter().any(|paragraph| {
+ !paragraph.lists.is_empty()
+ || !paragraph.tags.is_empty()
+ || paragraph.collapsed
+ || paragraph.parent.is_some()
+ || paragraph
+ .text()
+ .is_none_or(|text| !text.tags.is_empty() || text.text.spans().len() != 1)
+ }) {
return Err(EditError::UnsupportedContent.into());
}
let layout = outline_layout(&source, engine, definitions, TITLE_WIDTH)?;
diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs
index 08d18edf13876cd2278b99968f8c3bd5c9b86747..2cf577707378dae05471f0c125696899da26cd19 100644
--- a/crates/canvas/src/document.rs
+++ b/crates/canvas/src/document.rs
@@ -243,19 +243,158 @@ fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator-
})
}
-pub(crate) fn validate_flat<'a>(
- mut nodes: impl Iterator
- ,
-) -> Result<(), EditError> {
- if nodes.any(|node| {
- !node.lists.is_empty()
- || !node.tags.is_empty()
- || node.collapsed
- || node.parent.is_some()
- || node.text().is_none_or(|text| !text.tags.is_empty())
- }) {
- return Err(EditError::UnsupportedContent);
- }
- Ok(())
+/// The index after `nodes[index]`'s last descendant. Descendants follow their ancestor
+/// contiguously; a paragraph only an outline group indents has no parent to descend from.
+pub(crate) fn subtree_end(nodes: &[PageParagraph], index: usize) -> usize {
+ let mut members = BTreeSet::from([nodes[index].id]);
+ index
+ + 1
+ + nodes[index + 1..]
+ .iter()
+ .take_while(|node| {
+ node.parent.is_some_and(|parent| members.contains(&parent))
+ && members.insert(node.id)
+ })
+ .count()
+}
+
+/// The nodes from `from` on that descend from a paragraph in `moves` or `shifts`, rebuilt so
+/// the children of each paragraph in `moves` belong to its new parent, one level below it, and
+/// every subtree keeps its depth below its root; ends at the last node that changes.
+fn adopt(
+ nodes: &[PageParagraph],
+ from: usize,
+ moves: &BTreeMap,
+ mut shifts: BTreeMap,
+) -> Result, EditError> {
+ let mut adopted = Vec::new();
+ let mut changed = 0;
+ for node in &nodes[from..] {
+ let Some(parent) = node.parent else { break };
+ let mut node = node.clone();
+ let shift = match (moves.get(&parent), shifts.get(&parent)) {
+ (Some(holder), _) => {
+ node.parent = Some(holder.id);
+ i64::from(holder.level) + 1 - i64::from(node.level)
+ }
+ (None, Some(shift)) => *shift,
+ (None, None) => break,
+ };
+ node.level = u32::try_from(i64::from(node.level) + shift)
+ .map_err(|_| EditError::InvalidStructure)?;
+ shifts.insert(node.id, shift);
+ if shift != 0 || moves.contains_key(&parent) {
+ changed = adopted.len() + 1;
+ }
+ adopted.push(node);
+ }
+ adopted.truncate(changed);
+ Ok(adopted)
+}
+
+/// The sibling `nodes[index]` follows, passing over `skipped` siblings, when everything
+/// between them descends from it or from a skipped paragraph.
+pub(crate) fn previous_sibling(
+ nodes: &[PageParagraph],
+ index: usize,
+ skipped: &BTreeSet,
+) -> Option {
+ let node = &nodes[index];
+ let sibling = (0..index).rev().find(|&at| {
+ nodes[at].level <= node.level
+ && !(nodes[at].level == node.level && skipped.contains(&nodes[at].id))
+ })?;
+ if nodes[sibling].level != node.level || nodes[sibling].parent != node.parent {
+ return None;
+ }
+ let mut members = BTreeSet::from([nodes[sibling].id]);
+ nodes[sibling + 1..index]
+ .iter()
+ .all(|node| {
+ let inside = skipped.contains(&node.id)
+ || node.parent.is_some_and(|parent| members.contains(&parent));
+ members.insert(node.id);
+ inside
+ })
+ .then_some(sibling)
+}
+
+/// Tab or Shift+Tab on `range` of `nodes` as OneNote does, moving each paragraph with its
+/// subtree: indenting makes a paragraph the last child of its previous sibling, or without one
+/// indents it within its group; outdenting a child makes it its parent's sibling, adopting the
+/// siblings after it. None when nothing moves.
+pub(crate) fn indent(
+ nodes: &[PageParagraph],
+ container: Option,
+ range: Range,
+ outdent: bool,
+) -> Option {
+ let selected = nodes[range.clone()]
+ .iter()
+ .map(|node| node.id)
+ .collect::>();
+ let mut end = range.end;
+ let mut tops = BTreeMap::new();
+ let mut adopters = BTreeMap::new();
+ for index in range.clone() {
+ let node = &nodes[index];
+ if node.parent.is_some_and(|parent| selected.contains(&parent)) {
+ continue;
+ }
+ end = end.max(subtree_end(nodes, index));
+ if !outdent {
+ let parent = previous_sibling(nodes, index, &selected).map(|at| nodes[at].id);
+ tops.insert(node.id, (parent.or(node.parent), 1));
+ } else if node.level > 1 {
+ let parent = node
+ .parent
+ .and_then(|id| nodes[..index].iter().rposition(|node| node.id == id))
+ .filter(|&at| nodes[at].level + 1 == node.level);
+ let parent = match parent {
+ Some(at) => {
+ end = end.max(subtree_end(nodes, at));
+ adopters.insert(nodes[at].id, node.id);
+ nodes[at].parent
+ }
+ None => node.parent,
+ };
+ tops.insert(node.id, (parent, -1));
+ }
+ }
+ if tops.is_empty() {
+ return None;
+ }
+ let mut shifts = BTreeMap::new();
+ let replacement = nodes[range.start..end]
+ .iter()
+ .map(|node| {
+ let mut node = node.clone();
+ let shift = match (tops.get(&node.id), node.parent) {
+ (Some(&(parent, shift)), _) => {
+ node.parent = parent;
+ shift
+ }
+ (None, Some(parent))
+ if !selected.contains(&node.id) && adopters.contains_key(&parent) =>
+ {
+ node.parent = Some(adopters[&parent]);
+ 0
+ }
+ (None, parent) => parent
+ .and_then(|parent| shifts.get(&parent).copied())
+ .unwrap_or(0),
+ };
+ node.level = node.level.saturating_add_signed(shift);
+ shifts.insert(node.id, shift);
+ node
+ })
+ .collect();
+ Some(DocumentEdit {
+ columns: BTreeMap::new(),
+ container,
+ range: range.start..end,
+ replacement,
+ })
}
pub(crate) fn container_mut(
@@ -318,10 +457,6 @@ impl TextDocument {
&self.nodes
}
- pub(crate) fn validate_flat(&self) -> Result<(), EditError> {
- validate_flat(self.nodes.iter())
- }
-
pub fn text_nodes(&self) -> impl Iterator
- {
leaves(&self.nodes, None).map(|(_, _, node)| node)
}
@@ -452,6 +587,11 @@ impl TextDocument {
Ok(result)
}
+ /// Replaces `range` as OneNote's typing, Enter and deletion do: the first paragraph keeps
+ /// its identity and properties, and a paragraph the replacement adds takes its level,
+ /// parent, style and lists but no note tags, except that a range's last paragraph stays
+ /// itself when the replacement ends in one. The last paragraph holds the children of the
+ /// paragraphs the edit removes or splits.
pub(crate) fn replace(
&self,
range: Range,
@@ -469,13 +609,7 @@ impl TextDocument {
if container != end_container {
return Err(EditError::UnsupportedContent);
}
- if range.start.paragraph != range.end.paragraph || replacement.len() != 1 {
- validate_flat(self.container(container)?.iter().enumerate().filter_map(
- |(index, node)| {
- ((start..=end).contains(&index) || node.text().is_some()).then_some(node)
- },
- ))?;
- }
+ let nodes = self.container(container)?;
let mut prefix = first.text().unwrap().text.slice(0..range.start.offset)?;
let last_text = &last.text().unwrap().text;
let suffix =
@@ -484,32 +618,90 @@ impl TextDocument {
prefix.append(replacement.next().ok_or(EditError::InvalidRange)?)?;
let mut head = first.clone();
head.text_mut().unwrap().text = prefix;
- let mut nodes = vec![head];
let following = replacement.len();
+ let keeps_last = start != end && following > 0;
+ let mut added = Vec::new();
for (index, text) in replacement.enumerate() {
- if index + 1 == following && range.start.paragraph != range.end.paragraph {
+ let mut next = if keeps_last && index + 1 == following {
let mut end = last.clone();
end.text_mut().unwrap().text = text;
- nodes.push(end);
+ end
} else {
let mut next = node(text, first.format.clone())?;
- next.level = first.level;
next.style = first.style;
- nodes.push(next);
- }
+ next.lists.clone_from(&first.lists);
+ next
+ };
+ next.parent = first.parent;
+ next.level = first.level;
+ added.push(next);
}
+ if let Some(tail) = added.last_mut() {
+ tail.collapsed |= std::mem::take(&mut head.collapsed);
+ }
+ let tail = added.last_mut().unwrap_or(&mut head);
if !suffix.text().is_empty() {
- let end = nodes.last_mut().unwrap();
- end.text_mut().unwrap().text.append(suffix)?;
+ tail.text_mut().unwrap().text.append(suffix)?;
}
+ let tail = added.last().unwrap_or(&head);
+ let mut moves = nodes[start + 1..end + usize::from(!keeps_last)]
+ .iter()
+ .map(|node| (node.id, tail))
+ .collect::>();
+ if tail.id != head.id {
+ moves.insert(head.id, tail);
+ }
+ let shifts = keeps_last
+ .then(|| (last.id, i64::from(first.level) - i64::from(last.level)))
+ .into_iter()
+ .collect();
+ let adopted = adopt(nodes, end + 1, &moves, shifts)?;
Ok(DocumentEdit {
columns: BTreeMap::new(),
container,
- range: start..end + 1,
- replacement: nodes,
+ range: start..end + 1 + adopted.len(),
+ replacement: [head].into_iter().chain(added).chain(adopted).collect(),
})
}
+ /// Appends text leaf `lower`'s text to `upper`'s, keeping the upper paragraph's properties
+ /// and giving it the lower one's children; None unless nothing but `upper`'s hidden subtree
+ /// lies between them in one container.
+ pub(crate) fn join(
+ &self,
+ upper: usize,
+ lower: usize,
+ ) -> Result