diff --git a/arc/canvas.md b/arc/canvas.md
index 0e9e5bd858acc26be02b28077c72c1cca61571e8..7143de8e0c4f4d1beffd9a683f567f3c6a63a5d9 100644
--- a/arc/canvas.md
+++ b/arc/canvas.md
@@ -147,10 +147,17 @@ lies on the page at the caret, and the caret moves to the grid row below it.
From the title it joins the outline where the body starts, or lies two grid rows
below the page's content when none starts there. A picture takes the size its
resolution gives it, or 96 dpi without one. Paste takes files first (a picture
-file as its picture, as a dropped one goes in), then a web page holding
-pictures, its text and pictures in order as one undo step, then text, then a
-picture: Finder offers a copied file's name and icon beside it, and other apps a
-picture of copied text.
+file as its picture, as a dropped one goes in), then Snowbound's own copy, then
+a web page, its formatted text, lists, tables and pictures in order as one undo
+step, then text, then a picture: Finder offers a copied file's name and icon
+beside it, and other apps a picture of copied text.
+
+Copy offers what OneNote 2010 does beside text, HTML with each run's formatting
+inline, lists as `ul` and `ol` and tables bordered, which OneNote and Word paste
+as copied (`corpus/clipboard`). Snowbound's own format, a `Clip` of paragraphs
+and the definitions they name as JSON, carries styles, tags and pictures too.
+Several paragraphs go between the halves of the caret's paragraph, an empty
+half dropped; a title takes the text alone.
## Recordings
diff --git a/corpus/clipboard/README.md b/corpus/clipboard/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..8ff98c85062e95364b31be341c4cabe37605e033
--- /dev/null
+++ b/corpus/clipboard/README.md
@@ -0,0 +1,11 @@
+# Clipboard
+
+`onenote-2010.html` is the `HTML Format` OneNote 2010 put on the clipboard for a
+whole outline holding bold, italic, coloured, resized, re-fonted, underlined and
+highlighted runs, a link, nested bullets, a numbered list, a To Do tag, a 2 by 2
+table with a bold cell, and a last line (lab, 2026-10-02; `tools/w7`, a page
+built through COM and copied with Ctrl+A, Ctrl+C). Beside it OneNote offered
+`OneNote 2010 Internal` (56 bytes naming the copy, not its content), Unicode and
+ANSI text, an enhanced metafile and a device-independent bitmap, but no RTF. The
+tag is absent from the HTML. `crates/canvas/src/editor/html.rs` reads it in its
+tests.
diff --git a/corpus/clipboard/onenote-2010.html b/corpus/clipboard/onenote-2010.html
new file mode 100644
index 0000000000000000000000000000000000000000..829c1c86df4d411557c57293a6035906be5b26ed
--- /dev/null
+++ b/corpus/clipboard/onenote-2010.html
@@ -0,0 +1,96 @@
+Version:1.0
+StartHTML:0000000105
+EndHTML:0000004577
+StartFragment:0000000482
+EndFragment:0000004537
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs
index e29df4feb16ae6879a145678e4f199942d074d2e..0c28a42612c03a77eb8f2bac1a2f8801f96842ba 100644
--- a/crates/canvas/src/document.rs
+++ b/crates/canvas/src/document.rs
@@ -757,35 +757,45 @@ impl TextDocument {
Err(EditError::InvalidRange)
}
- pub(crate) fn slice(&self, range: Range) -> Result, EditError> {
- if range.start > range.end {
- return Err(EditError::InvalidRange);
- }
- let count = range
- .end
- .paragraph
- .checked_sub(range.start.paragraph)
- .and_then(|n| n.checked_add(1))
+ /// The nodes `range` covers, its end paragraphs cut to it: those of its container from
+ /// one end to the other, or where it crosses a table's edge, the root nodes holding them.
+ pub(crate) fn selected(
+ &self,
+ range: Range,
+ ) -> Result, EditError> {
+ let (start_container, start, first) = self
+ .leaf(range.start.paragraph)
+ .ok_or(EditError::InvalidRange)?;
+ let (end_container, end, last) = self
+ .leaf(range.end.paragraph)
.ok_or(EditError::InvalidRange)?;
- let result = self
- .paragraphs()
- .skip(range.start.paragraph)
- .take(count)
- .enumerate()
- .map(|(index, paragraph)| {
- let start = if index == 0 { range.start.offset } else { 0 };
- let end = if index == count - 1 {
- range.end.offset
- } else {
- paragraph.utf16_offset(paragraph.text().len())?
- };
- paragraph.slice(start..end)
- })
- .collect::, EditError>>()?;
- if result.len() != count {
- return Err(EditError::InvalidRange);
+ let (first, last) = (first.id, last.id);
+ let (nodes, start, end) = if self.crosses_table(range.clone())? {
+ let root = |container: Option, index| match container {
+ Some(cell) => self.root(cell),
+ None => Ok(index),
+ };
+ (
+ &self.nodes[..],
+ root(start_container, start)?,
+ root(end_container, end)?,
+ )
+ } else {
+ (self.container(start_container)?, start, end)
+ };
+ let mut nodes = nodes[start..=end].to_vec();
+ let tail = nodes.len() - 1;
+ if nodes[tail].id == last {
+ let from = if first == last { range.start.offset } else { 0 };
+ let text = &mut nodes[tail].text_mut().unwrap().text;
+ *text = text.slice(from..range.end.offset)?;
+ }
+ if nodes[0].id == first && first != last {
+ let text = &mut nodes[0].text_mut().unwrap().text;
+ let length = text.utf16_offset(text.text().len())?;
+ *text = text.slice(range.start.offset..length)?;
}
- Ok(result)
+ Ok(nodes)
}
/// Replaces `range` as OneNote's typing, Enter and deletion do: the first paragraph keeps
@@ -1332,14 +1342,20 @@ mod tests {
.collect::>(),
["before", "a🌲b", "second", "right", "after"]
);
+ let shown = |nodes: Vec| {
+ leaves(&nodes, None)
+ .map(|(.., node)| node.text().unwrap().text.text().to_owned())
+ .collect::>()
+ };
+ let selected = |range| shown(document.selected(range).unwrap());
+ assert_eq!(selected(position(1, 1)..position(2, 3)), ["🌲b", "sec"]);
assert_eq!(
- document
- .slice(position(1, 1)..position(3, 2))
- .unwrap()
- .iter()
- .map(Paragraph::text)
- .collect::>(),
- ["🌲b", "second", "ri"]
+ selected(position(1, 1)..position(3, 2)),
+ ["a🌲b", "second", "right"]
+ );
+ assert_eq!(
+ selected(position(0, 2)..position(4, 1)),
+ ["fore", "a🌲b", "second", "right", "a"]
);
let edit = document
.replace(
diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs
index c8df4c35b27d9ded6f1d54e6341fb8b9aedc7c44..064daf6ac2c276a344e1eb5d8eb77994fd7ce261 100644
--- a/crates/canvas/src/editor.rs
+++ b/crates/canvas/src/editor.rs
@@ -24,10 +24,12 @@ use std::{
pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0;
+mod clip;
mod equation;
#[cfg(test)]
mod evidence;
mod format;
+mod html;
mod ink;
mod link;
mod ops;
@@ -35,10 +37,12 @@ pub(crate) mod page;
mod recording;
mod space;
mod table;
+pub use clip::Clip;
pub use format::{
Alignment, BULLET_LIBRARY, DefaultFont, FormatState, Formatting, ListStyle, NUMBER_LIBRARY,
NoteTag, Toggle,
};
+pub use html::html_pieces;
pub use ink::{FAVORITES, Pen};
pub use link::{Link, shown_urls};
pub use page::ReadOnlyObject;
@@ -84,7 +88,7 @@ pub struct Selection {
/// A piece of pasted content, in the order it comes.
pub enum Piece {
- Text(String),
+ Clip(Clip),
/// An encoded picture and its size in points.
Picture(Vec, [f32; 2]),
/// A picture still on its way, which ends the paragraph and goes in where
@@ -2333,20 +2337,19 @@ impl CanvasEditor {
self.place_caret(engine, [x, margin[1] + row * 18.0], DEFAULT_OUTLINE_WIDTH)
}
- /// Pastes text in `language` and pictures in order, as one undo step, as OneNote 2010
- /// pastes a web page's text with its pictures; the places of the awaited ones, in order.
+ /// Pastes clips and pictures in order, as one undo step, as OneNote 2010 pastes a web
+ /// page's text with its pictures; the places of the awaited ones, in order.
pub fn paste_pieces(
&mut self,
engine: &mut TextEngine,
pieces: Vec,
- language: u32,
) -> Result, EditorError> {
let depth = self.undo.len();
let mut result = Ok(());
let mut awaited = Vec::new();
for piece in pieces {
result = match piece {
- Piece::Text(text) => self.paste(engine, &text, language),
+ Piece::Clip(clip) => self.paste_clip(engine, clip),
Piece::Picture(bytes, size) => picture(bytes, size)
.map_err(EditorError::from)
.and_then(|image| self.insert_picture(engine, image)),
@@ -10659,10 +10662,9 @@ mod tests {
.paste_pieces(
&mut engine,
vec![
- Piece::Text("Pasted".into()),
+ html_pieces("Pasted", 0x409, |_, _| None).pop().unwrap(),
Piece::Picture(vec![0; 8], [10.0; 2]),
],
- 0x409,
)
.unwrap();
assert_eq!(editor.history_depth(), [1, 0]);
diff --git a/crates/canvas/src/editor/clip.rs b/crates/canvas/src/editor/clip.rs
new file mode 100644
index 0000000000000000000000000000000000000000..c37c524f6969d6b3d77dbf39af8737a712beb0ea
--- /dev/null
+++ b/crates/canvas/src/editor/clip.rs
@@ -0,0 +1,548 @@
+//! Copy and Paste of formatted content: a selection's paragraphs with the definitions they
+//! name, which hosts put on the clipboard in Snowbound's own format beside HTML and text.
+
+use super::*;
+use onestore::page::text::new_id;
+
+/// Paragraphs copied from a page, in document order with each parent before its children;
+/// levels count from 1 and a paragraph whose parent was not copied has none.
+#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
+pub struct Clip {
+ pub paragraphs: Vec,
+ /// The styles, lists and tags the paragraphs name.
+ pub definitions: BTreeMap,
+}
+
+impl Clip {
+ pub(crate) fn new(
+ mut paragraphs: Vec,
+ definitions: &BTreeMap,
+ ) -> Self {
+ let held: BTreeSet = paragraphs.iter().map(|node| node.id).collect();
+ let shift = paragraphs.iter().map(|node| node.level).min().unwrap_or(1) - 1;
+ for node in &mut paragraphs {
+ node.parent = node.parent.filter(|parent| held.contains(parent));
+ node.level -= shift;
+ }
+ Self {
+ definitions: ops::named(definitions, &[¶graphs]),
+ paragraphs,
+ }
+ }
+
+ /// The text as shown, a line to each paragraph, table cells included.
+ pub fn text(&self) -> String {
+ leaves(&self.paragraphs, None)
+ .map(|(.., node)| {
+ let text = &node.text().unwrap().text;
+ text.project().map_or_else(
+ |_| text.text().to_owned(),
+ |shown| shown.text().text().to_owned(),
+ )
+ })
+ .collect::>()
+ .join("\n")
+ }
+
+ /// Snowbound's own clipboard format.
+ pub fn encode(&self) -> String {
+ serde_json::to_string(self).expect("a clip serializes")
+ }
+
+ /// None for a clip another version of Snowbound wrote in a shape this one can't read.
+ pub fn decode(json: &str) -> Option {
+ serde_json::from_str(json).ok()
+ }
+}
+
+impl CanvasEditor {
+ /// What Copy takes: the selection's paragraphs with the first and last cut to it, or the
+ /// page selection's outlines top to bottom with an empty paragraph between, as OneNote
+ /// 2010 copies them. None where nothing shows.
+ pub fn clip(&self) -> Result, EditError> {
+ let paragraphs = if self.page_selected() {
+ let mut outlines = self
+ .outlines
+ .iter()
+ .filter(|outline| !outline.title)
+ .collect::>();
+ outlines.sort_by(|a, b| {
+ let ([ax, ay], [bx, by]) = (a.origin(), b.origin());
+ ay.total_cmp(&by).then(ax.total_cmp(&bx))
+ });
+ let mut paragraphs = Vec::new();
+ for outline in outlines {
+ if !paragraphs.is_empty() {
+ let empty = Paragraph::new(String::new(), Format::default());
+ paragraphs.push(crate::document::node(empty, Format::default())?);
+ }
+ paragraphs.extend_from_slice(outline.document.nodes());
+ }
+ paragraphs
+ } else {
+ let [anchor, focus] = self.selection().positions;
+ self.active_outline()
+ .document
+ .selected(anchor.min(focus)..anchor.max(focus))?
+ };
+ let clip = Clip::new(paragraphs, &self.definitions);
+ Ok((!clip.text().is_empty()).then_some(clip))
+ }
+
+ /// Pastes `clip` as one undo step: one paragraph's runs go in at the selection; more
+ /// paragraphs go between the halves of the caret's paragraph, each keeping its
+ /// formatting, style, list, tags and indentation below that paragraph's. A title takes
+ /// the text alone.
+ pub fn paste_clip(&mut self, engine: &mut TextEngine, clip: Clip) -> Result<(), EditorError> {
+ if self.page_selected() {
+ return self.grouped(|editor| {
+ editor.remove_page(engine, true)?;
+ editor.paste_clip(engine, clip)
+ });
+ }
+ if self.active_outline().title {
+ // A title takes text alone, as OneNote 2010's does (lab, 2026-10-02).
+ let language = leaves(&clip.paragraphs, None)
+ .find_map(|(.., node)| node.text()?.text.spans()[0].format.language)
+ .unwrap_or(0x409);
+ return self.paste(engine, &clip.text(), language);
+ }
+ self.take_objects()?;
+ if let Some(pasted) = self.across(engine, |editor, engine| {
+ editor.paste_clip(engine, clip.clone())
+ })? {
+ return Ok(pasted);
+ }
+ let mut nodes = self.admit(clip)?;
+ if nodes.is_empty() {
+ return Ok(());
+ }
+ if let [node] = &nodes[..]
+ && let Some(text) = node.text()
+ {
+ return self.replace(engine, vec![text.text.clone()]);
+ }
+ let [anchor, focus] = self.active_outline().selection.positions;
+ let (start, end) = (anchor.min(focus), anchor.max(focus));
+ let edge = Paragraph::new(String::new(), self.typing_format(start)?);
+ let texts = nodes.iter().map(|node| {
+ node.text()
+ .map_or_else(|| edge.clone(), |text| text.text.clone())
+ });
+ let replacement = std::iter::once(edge.clone())
+ .chain(texts)
+ .chain([edge.clone()])
+ .collect();
+ let mut edit = self
+ .active_outline()
+ .document
+ .replace(start..end, replacement)?;
+ let head = &edit.replacement[0];
+ let (parent, level) = (head.parent, head.level);
+ for node in &mut nodes {
+ if node.parent.is_none() {
+ node.parent = parent;
+ }
+ node.level += level - 1;
+ }
+ let ends_in_text = nodes.last().is_some_and(|node| node.text().is_some());
+ let last = nodes
+ .last()
+ .and_then(|node| node.text())
+ .map_or(Ok(0), |text| text.text.utf16_offset(text.text.text().len()))?;
+ let count = nodes.len();
+ let added = leaves(&nodes, None).count();
+ edit.replacement.splice(1..=count, nodes);
+ // A half of the caret's paragraph goes where empty, as OneNote 2010 pastes (lab,
+ // 2026-10-02), unless it holds children or the caret.
+ let gone = |edit: &DocumentEdit, at: usize| {
+ let node = &edit.replacement[at];
+ node.text().is_some_and(|text| text.text.text().is_empty())
+ && !edit
+ .replacement
+ .iter()
+ .any(|child| child.parent == Some(node.id))
+ };
+ if ends_in_text && gone(&edit, count + 1) {
+ edit.replacement.remove(count + 1);
+ }
+ let head = gone(&edit, 0);
+ if head {
+ edit.replacement.remove(0);
+ }
+ let paragraph = start.paragraph + added - usize::from(head);
+ let caret = if ends_in_text {
+ TextPosition {
+ paragraph,
+ offset: last,
+ }
+ } else {
+ TextPosition {
+ paragraph: paragraph + 1,
+ offset: 0,
+ }
+ };
+ self.commit(engine, edit, [caret; 2].into())
+ }
+
+ /// `clip`'s paragraphs under identities of their own, naming this page's definitions:
+ /// a style or tag the page already has, else one added beside its own.
+ fn admit(&mut self, clip: Clip) -> Result, EditError> {
+ let mut renamed = BTreeMap::new();
+ for (id, definition) in clip.definitions {
+ let own = match definition.kind {
+ Kind::Style { .. } => self.define_style(&definition)?,
+ Kind::TagDefinition { .. } => self.define_tag(&definition)?,
+ _ => {
+ let own = new_id()?;
+ self.definitions.insert(own, definition);
+ own
+ }
+ };
+ renamed.insert(id, own);
+ }
+ let mut nodes = clip.paragraphs;
+ renew(&mut nodes, &renamed, &self.active_outline().indents)?;
+ Ok(nodes)
+ }
+}
+
+/// Gives `nodes` and everything they hold new identities, keeping their tree, and points
+/// their definitions at `renamed`'s, dropping what names none; a cell without indents takes
+/// `indents`.
+fn renew(
+ nodes: &mut Vec,
+ renamed: &BTreeMap,
+ indents: &[f32],
+) -> Result<(), EditError> {
+ let tags = |tags: &mut Vec| {
+ tags.retain_mut(|tag| match tag.definition {
+ Some(id) => renamed
+ .get(&id)
+ .map(|own| tag.definition = Some(*own))
+ .is_some(),
+ None => true,
+ });
+ };
+ // What a clip from elsewhere holds without its bytes can't be pasted; their
+ // children lose their parent.
+ nodes.retain(|node| match &node.content {
+ ParagraphContent::Image(image) => image.bytes.is_some(),
+ ParagraphContent::Attachment(file) => file.bytes.is_some(),
+ ParagraphContent::Unsupported(_) => false,
+ _ => true,
+ });
+ let mut parents = BTreeMap::new();
+ for node in nodes.iter_mut() {
+ let id = new_id()?;
+ parents.insert(node.id, id);
+ node.id = id;
+ node.parent = node.parent.and_then(|parent| parents.get(&parent).copied());
+ node.style = node.style.and_then(|style| renamed.get(&style).copied());
+ node.lists = node
+ .lists
+ .iter()
+ .filter_map(|list| renamed.get(list).copied())
+ .collect();
+ tags(&mut node.tags);
+ node.media = Default::default();
+ match &mut node.content {
+ ParagraphContent::Text(text) => {
+ text.id = new_id()?;
+ text.date_field = None;
+ tags(&mut text.tags);
+ }
+ ParagraphContent::Image(image) => {
+ image.id = new_id()?;
+ tags(&mut image.tags);
+ }
+ ParagraphContent::Attachment(file) => file.id = new_id()?,
+ ParagraphContent::Ink(ink) => ink.id = new_id()?,
+ ParagraphContent::Unsupported(unsupported) => unsupported.id = new_id()?,
+ ParagraphContent::Table(table) => {
+ table.id = new_id()?;
+ tags(&mut table.tags);
+ for row in &mut table.rows {
+ row.id = new_id()?;
+ for cell in &mut row.cells {
+ cell.id = new_id()?;
+ if cell.indents.is_empty() {
+ cell.indents = indents.to_vec();
+ }
+ renew(&mut cell.paragraphs, renamed, indents)?;
+ }
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+impl Clip {
+ /// A line to each paragraph: its indentation, list, runs with their formatting, links,
+ /// tags and style, then each table's cells below it.
+ pub(super) fn outline(&self) -> Vec {
+ let mut lines = Vec::new();
+ outline(&self.paragraphs, &self.definitions, "", &mut lines);
+ lines
+ }
+}
+
+#[cfg(test)]
+fn outline(
+ nodes: &[PageParagraph],
+ definitions: &BTreeMap,
+ prefix: &str,
+ lines: &mut Vec,
+) {
+ use super::format::AUTOMATIC;
+ for node in nodes {
+ let mut line = format!("{prefix}{}", " ".repeat(node.level as usize - 1));
+ match node.lists.last().map(|list| &definitions[list].kind) {
+ Some(Kind::List {
+ bullet: Some(_), ..
+ }) => line.push_str("• "),
+ Some(kind @ Kind::List { .. }) => {
+ let (_, kind) = super::html::list_tag(kind);
+ line.push_str(&format!("{kind}. "));
+ }
+ _ => {}
+ }
+ let mut tags = node.tags.clone();
+ match &node.content {
+ ParagraphContent::Text(text) => {
+ tags.extend(text.tags.iter().cloned());
+ let shown = text.text.project().unwrap();
+ let shown = shown.text();
+ let mut byte = 0;
+ for span in shown.spans() {
+ let fragment = &shown.text()[byte..span.end];
+ byte = span.end;
+ let format = &span.format;
+ let mut marks = Vec::new();
+ for (set, mark) in [
+ (format.bold, "b"),
+ (format.italic, "i"),
+ (format.underline, "u"),
+ (format.strike, "s"),
+ (format.superscript, "sup"),
+ (format.subscript, "sub"),
+ (format.hyperlink, "link"),
+ ] {
+ if set == Some(true) {
+ marks.push(mark.to_owned());
+ }
+ }
+ if let Some(color) = format.color.filter(|color| *color != AUTOMATIC) {
+ marks.push(format!("color={color:06x}"));
+ }
+ if let Some(color) = format.highlight.filter(|color| *color != AUTOMATIC) {
+ marks.push(format!("highlight={color:06x}"));
+ }
+ if let Some(font) = format.font.as_deref().filter(|font| *font != "Calibri") {
+ marks.push(format!("font={font}"));
+ }
+ if let Some(size) = format.font_size.filter(|size| *size != 11.0) {
+ marks.push(format!("size={size}"));
+ }
+ if marks.is_empty() {
+ line.push_str(fragment);
+ } else {
+ line.push_str(&format!("<{}>{fragment}>", marks.join(" ")));
+ }
+ }
+ for link in super::link::links(&text.text, 0) {
+ line.push_str(&format!(" -> {}", link.target));
+ }
+ }
+ ParagraphContent::Table(table) => {
+ line.push_str(&format!(
+ "table {}x{}",
+ table.rows.len(),
+ table.columns.len()
+ ));
+ }
+ ParagraphContent::Image(image) => line.push_str(&format!("picture {:?}", image.size)),
+ _ => line.push_str("other"),
+ }
+ for tag in &tags {
+ if let Some(Kind::TagDefinition { label, .. }) =
+ tag.definition.map(|id| &definitions[&id].kind)
+ {
+ line.push_str(&format!(" [{}]", label.as_deref().unwrap_or("")));
+ }
+ }
+ if let Some(Kind::Style {
+ name: Some(name), ..
+ }) = node.style.map(|id| &definitions[&id].kind)
+ {
+ line.push_str(&format!(" ({name})"));
+ }
+ lines.push(line);
+ if let ParagraphContent::Table(table) = &node.content {
+ for (row, cells) in table.rows.iter().enumerate() {
+ for (column, cell) in cells.cells.iter().enumerate() {
+ let prefix = format!("{prefix} | {row},{column}: ");
+ outline(&cell.paragraphs, definitions, &prefix, lines);
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::editor::format::{NoteTag, Toggle};
+ use crate::editor::{Formatting, html_pieces};
+
+ fn at(paragraph: usize, offset: u32) -> TextPosition {
+ TextPosition { paragraph, offset }
+ }
+
+ fn editor(engine: &mut TextEngine, lines: &[&str]) -> CanvasEditor {
+ let paragraphs = lines
+ .iter()
+ .map(|line| {
+ Paragraph::new(
+ (*line).to_owned(),
+ Format {
+ font: Some("Calibri".into()),
+ font_size: Some(11.0),
+ ..Format::default()
+ },
+ )
+ })
+ .collect();
+ CanvasEditor::new(engine, TextDocument::new(paragraphs).unwrap(), 400.0).unwrap()
+ }
+
+ fn page(editor: &mut CanvasEditor) -> Clip {
+ editor.select_page().unwrap();
+ let clip = editor.clip().unwrap().unwrap();
+ editor.select([at(0, 0); 2].into()).unwrap();
+ clip
+ }
+
+ /// The report's case: a bold line copied, through Snowbound's own format, into a new
+ /// paragraph stays bold.
+ #[test]
+ fn a_copied_bold_line_pastes_bold() {
+ let mut engine = TextEngine::default();
+ let mut editor = editor(&mut engine, &["Bold line", ""]);
+ editor.select([at(0, 0), at(0, 9)].into()).unwrap();
+ editor
+ .format(&mut engine, Formatting::Toggle(Toggle::Bold))
+ .unwrap();
+ let clip = editor.clip().unwrap().unwrap();
+ assert_eq!(clip.text(), "Bold line");
+ let clip = Clip::decode(&clip.encode()).unwrap();
+ editor.select([at(1, 0); 2].into()).unwrap();
+ editor.paste_clip(&mut engine, clip).unwrap();
+ assert_eq!(editor.selection().positions, [at(1, 9); 2]);
+ assert_eq!(
+ page(&mut editor).outline(),
+ ["Bold line>", "Bold line>"]
+ );
+ }
+
+ /// Part of a paragraph copies only what is selected, and pastes into the middle of
+ /// another's text.
+ #[test]
+ fn a_partial_selection_pastes_inline() {
+ let mut engine = TextEngine::default();
+ let mut editor = editor(&mut engine, &["one two three", "ab"]);
+ editor.select([at(0, 4), at(0, 7)].into()).unwrap();
+ editor
+ .format(&mut engine, Formatting::Toggle(Toggle::Italic))
+ .unwrap();
+ let clip = editor.clip().unwrap().unwrap();
+ editor.select([at(1, 1); 2].into()).unwrap();
+ editor.paste_clip(&mut engine, clip).unwrap();
+ assert_eq!(
+ page(&mut editor).outline(),
+ ["one two> three", "atwo>b"]
+ );
+ }
+
+ /// Styles, lists, indentation, tags and tables go through Snowbound's own format to
+ /// another page between the halves of the caret's paragraph, as one undo step.
+ #[test]
+ fn a_clip_carries_styles_lists_tags_and_tables_to_another_page() {
+ let mut engine = TextEngine::default();
+ let mut source = editor(&mut engine, &["Heading", "item one", "child", "tagged"]);
+ let heading = Definition {
+ kind: Kind::Style {
+ name: Some("h1".into()),
+ next: None,
+ },
+ format: Format {
+ bold: Some(true),
+ font: Some("Calibri".into()),
+ font_size: Some(16.0),
+ color: Some(0x0060_3a1f),
+ ..Format::default()
+ },
+ };
+ let mut apply = |editor: &mut CanvasEditor, selection: [TextPosition; 2], command| {
+ editor.select(selection.into()).unwrap();
+ editor.format(&mut engine, command).unwrap();
+ };
+ apply(&mut source, [at(0, 0); 2], Formatting::Style(heading));
+ apply(&mut source, [at(1, 0), at(2, 5)], Formatting::Bullets);
+ apply(&mut source, [at(2, 2); 2], Formatting::Indent);
+ apply(
+ &mut source,
+ [at(3, 0); 2],
+ Formatting::Tag(NoteTag::defaults()[0].clone(), 0),
+ );
+ source.select([at(3, 6); 2].into()).unwrap();
+ source.insert_table(&mut engine, 2, 2).unwrap();
+ source.insert(&mut engine, "a1").unwrap();
+ let copied = page(&mut source);
+ let expected = [
+ "Heading> (h1)",
+ " • item one",
+ " • child",
+ "tagged [To Do]",
+ "table 2x2",
+ " | 0,0: a1",
+ " | 0,1: ",
+ " | 1,0: ",
+ " | 1,1: ",
+ ];
+ assert_eq!(copied.outline(), expected);
+
+ let mut target = editor(&mut engine, &["before after"]);
+ target.select([at(0, 7); 2].into()).unwrap();
+ let clip = Clip::decode(&copied.encode()).unwrap();
+ target.paste_clip(&mut engine, clip).unwrap();
+ let pasted = page(&mut target).outline();
+ assert_eq!(pasted[0], "before ");
+ assert_eq!(pasted[1..pasted.len() - 1], expected);
+ assert_eq!(pasted.last().unwrap(), "after");
+ target.page().unwrap();
+ assert!(target.undo(&mut engine).unwrap());
+ assert_eq!(page(&mut target).outline(), ["before after"]);
+ }
+
+ /// A page's HTML pasted into an empty paragraph takes its place, keeping its formatting
+ /// and lists, as OneNote 2010 pastes (lab, 2026-10-02).
+ #[test]
+ fn pasted_html_keeps_formatting_and_lists() {
+ let mut engine = TextEngine::default();
+ let mut editor = editor(&mut engine, &[""]);
+ let pieces = html_pieces(
+ " Bold and red
\
+ ",
+ 0x409,
+ |_, _| None,
+ );
+ editor.paste_pieces(&mut engine, pieces).unwrap();
+ assert_eq!(
+ page(&mut editor).outline(),
+ ["Bold> and red>", "• one", " • two"]
+ );
+ }
+}
diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs
index ba1f7ae4e23ee86ca1d8a9fd7aac275dcd0c9a9a..17ec0895649772d4d23fb70f0feb6bf9bcd55e4c 100644
--- a/crates/canvas/src/editor/format.rs
+++ b/crates/canvas/src/editor/format.rs
@@ -8,7 +8,7 @@ use onestore::page::text::new_id;
use serde::{Deserialize, Serialize};
/// The COLORREF OneNote stores for automatic colour, which overrides a style's colour.
-const AUTOMATIC: u32 = 0xff00_0000;
+pub(super) const AUTOMATIC: u32 = 0xff00_0000;
/// To Do's check box.
const CHECKBOX: u16 = 3;
@@ -143,7 +143,7 @@ impl ListStyle {
pub(super) const NUMBER: Self = Self::Number(0);
/// The library style a stored list has, if it is one.
- fn of(kind: &Kind) -> Option {
+ pub(super) fn of(kind: &Kind) -> Option {
let Kind::List {
font,
format: Some(format),
@@ -1010,7 +1010,7 @@ impl CanvasEditor {
}
/// The page's definition of the tag `definition` describes, made where it has none.
- fn define_tag(&mut self, definition: &Definition) -> Result {
+ pub(super) fn define_tag(&mut self, definition: &Definition) -> Result {
if let Some((id, _)) = self
.definitions
.iter()
diff --git a/crates/canvas/src/editor/html.rs b/crates/canvas/src/editor/html.rs
new file mode 100644
index 0000000000000000000000000000000000000000..3e0a4f45fe9f00023bc73b47434ff32bf6350181
--- /dev/null
+++ b/crates/canvas/src/editor/html.rs
@@ -0,0 +1,1181 @@
+//! Clips as HTML, both ways. Copy writes what OneNote 2010 puts on the clipboard (lab,
+//! 2026-10-02): every run's formatting inline on a `span`, lists as `ul` and `ol` nested by
+//! level, tables bordered, pictures and note tags left out. Paste reads that and what other
+//! applications write into paragraphs, lists, tables and pictures.
+
+use super::Piece;
+use super::clip::Clip;
+use super::format::{AUTOMATIC, ListStyle, NUMBER_LIBRARY, list_definition};
+use super::link::{field_code, links};
+use super::table::COLUMN_WIDTH;
+use crate::document::node;
+use crate::layout::{DEFAULT_FONT, DEFAULT_FONT_SIZE};
+use onestore::ExGuid;
+use onestore::document::{Format, Kind};
+use onestore::page::text::{EditError, Paragraph, new_id};
+use onestore::page::{
+ Definition, PageParagraph, ParagraphContent, Table, TableCell, TableColumn, TableRow,
+};
+use std::collections::BTreeMap;
+use std::fmt::Write;
+
+impl Clip {
+ /// The clip as a web page whose fragment the comments OneNote's mark out hold.
+ pub fn html(&self) -> String {
+ let mut body = String::new();
+ blocks(&self.paragraphs, &self.definitions, &mut body);
+ format!(
+ "\n\n \n\
+ \n\n\n\
+ \n{body}\n\n\n"
+ )
+ }
+}
+
+/// `nodes` as blocks: a list opens at a paragraph whose list differs from the one open at
+/// its level, and a block's left margin steps it in from the list it sits in, which OneNote
+/// and Word take its level from.
+fn blocks(nodes: &[PageParagraph], definitions: &BTreeMap, out: &mut String) {
+ let mut open: Vec<(u32, &str)> = Vec::new();
+ for node in nodes {
+ let list = node
+ .lists
+ .last()
+ .and_then(|id| definitions.get(id))
+ .filter(|definition| matches!(definition.kind, Kind::List { .. }))
+ .map(|definition| list_tag(&definition.kind));
+ while let Some(&(level, tag)) = open.last()
+ && (level > node.level || level == node.level && list.map(|(tag, _)| tag) != Some(tag))
+ {
+ let _ = writeln!(out, "{tag}>");
+ open.pop();
+ }
+ // A level's step in, past the list it sits in, as OneNote indents its nested lists.
+ let indent = (node.level - open.last().map_or(1, |(level, _)| *level)) as f32 * 0.375;
+ match (list, &node.content) {
+ (Some((tag, kind)), ParagraphContent::Text(text)) => {
+ if open.last().map(|(level, _)| *level) != Some(node.level) {
+ let _ = writeln!(
+ out,
+ "<{tag} type={kind} style='margin-left:{indent}in;margin-top:0in;margin-bottom:0in'>"
+ );
+ open.push((node.level, tag));
+ }
+ out.push_str("");
+ runs(&text.text, out);
+ out.push_str(" \n");
+ }
+ (_, ParagraphContent::Text(text)) => {
+ let _ = write!(out, "");
+ runs(&text.text, out);
+ out.push_str("
\n");
+ }
+ (_, ParagraphContent::Table(table)) => {
+ out.push_str(
+ "\n",
+ );
+ for row in &table.rows {
+ out.push_str("\n");
+ for (cell, column) in row.cells.iter().zip(&table.columns) {
+ let _ = writeln!(
+ out,
+ "",
+ column.width
+ );
+ blocks(&cell.paragraphs, definitions, out);
+ out.push_str(" \n");
+ }
+ out.push_str(" \n");
+ }
+ out.push_str("
\n");
+ }
+ _ => {}
+ }
+ }
+ for (_, tag) in open.into_iter().rev() {
+ let _ = writeln!(out, "{tag}>");
+ }
+}
+
+/// The element and `type` a stored list writes as.
+pub(super) fn list_tag(kind: &Kind) -> (&'static str, &'static str) {
+ let Kind::List { bullet, format, .. } = kind else {
+ return ("ul", "disc");
+ };
+ if bullet.is_some() {
+ let kind = match ListStyle::of(kind) {
+ Some(ListStyle::Bullet(3)) => "circle",
+ Some(ListStyle::Bullet(11 | 14)) => "square",
+ _ => "disc",
+ };
+ return ("ul", kind);
+ }
+ let sequence = format
+ .as_deref()
+ .and_then(|format| format.split('\u{fffd}').nth(1)?.chars().next());
+ let kind = match sequence.map(u32::from) {
+ Some(1) => "I",
+ Some(2) => "i",
+ Some(3) => "A",
+ Some(4) => "a",
+ _ => "1",
+ };
+ ("ol", kind)
+}
+
+/// A paragraph's shown runs as spans, its links' around theirs.
+fn runs(text: &Paragraph, out: &mut String) {
+ let links = links(text, 0);
+ let (mut byte, mut unit) = (0, 0);
+ let mut shown = false;
+ let mut space = true;
+ for span in text.spans() {
+ let fragment = &text.text()[byte..span.end];
+ let start = unit;
+ (byte, unit) = (span.end, unit + fragment.encode_utf16().count() as u32);
+ if span.format.hidden == Some(true) || fragment.is_empty() {
+ continue;
+ }
+ shown = true;
+ let link = links.iter().find(|link| link.label.contains(&start));
+ if let Some(link) = link {
+ let _ = write!(out, "", escape(&link.target, &mut false));
+ }
+ let _ = write!(
+ out,
+ "{} ",
+ css(&span.format),
+ escape(fragment, &mut space)
+ );
+ if link.is_some() {
+ out.push_str(" ");
+ }
+ }
+ if !shown {
+ out.push_str(" ");
+ }
+}
+
+fn css(format: &Format) -> String {
+ let font = format.font.as_deref().unwrap_or(DEFAULT_FONT);
+ let font = if font.contains(' ') {
+ format!("\"{font}\"")
+ } else {
+ font.to_owned()
+ };
+ let mut css = format!(
+ "font-family:{font};font-size:{}pt",
+ format.font_size.unwrap_or(DEFAULT_FONT_SIZE)
+ );
+ let set = |value: Option| value == Some(true);
+ match format.bold {
+ Some(true) => css.push_str(";font-weight:bold"),
+ Some(false) => css.push_str(";font-weight:normal"),
+ None => {}
+ }
+ match format.italic {
+ Some(true) => css.push_str(";font-style:italic"),
+ Some(false) => css.push_str(";font-style:normal"),
+ None => {}
+ }
+ match (set(format.underline), set(format.strike)) {
+ (true, true) => css.push_str(";text-decoration:underline line-through"),
+ (true, false) => css.push_str(";text-decoration:underline"),
+ (false, true) => css.push_str(";text-decoration:line-through"),
+ (false, false) if format.underline.is_some() || format.strike.is_some() => {
+ css.push_str(";text-decoration:none")
+ }
+ (false, false) => {}
+ }
+ if set(format.superscript) {
+ css.push_str(";vertical-align:super");
+ } else if set(format.subscript) {
+ css.push_str(";vertical-align:sub");
+ }
+ let hex = |color: u32| {
+ let [red, green, blue, _] = color.to_le_bytes();
+ format!("#{red:02X}{green:02X}{blue:02X}")
+ };
+ if let Some(color) = format.color.filter(|color| *color != AUTOMATIC) {
+ let _ = write!(css, ";color:{}", hex(color));
+ }
+ if let Some(color) = format.highlight.filter(|color| *color != AUTOMATIC) {
+ let _ = write!(css, ";background:{}", hex(color));
+ }
+ css.replace('\'', "'")
+}
+
+/// Text as HTML keeps it: markup characters escaped, line breaks as `br`, and a space after
+/// `space`, whether the text follows a space or starts its paragraph, unbreakable so it
+/// doesn't collapse.
+fn escape(text: &str, space: &mut bool) -> String {
+ let mut escaped = String::with_capacity(text.len());
+ for c in text.chars() {
+ match c {
+ '&' => escaped.push_str("&"),
+ '<' => escaped.push_str("<"),
+ '>' => escaped.push_str(">"),
+ '"' => escaped.push_str("""),
+ '\u{b}' | '\n' => escaped.push_str(" "),
+ '\t' => escaped.push_str(" "),
+ ' ' if *space => escaped.push_str(" "),
+ c => escaped.push(c),
+ }
+ *space = c == ' ';
+ }
+ escaped
+}
+
+/// A web page fragment's paragraphs, lists and tables as clips, split around its pictures
+/// in order, as OneNote 2010 pastes them (lab, 2026-09-30). Text without a font or size of
+/// its own is Calibri 11 in `language`, an LCID, as pasted text is. `load` makes a picture's
+/// piece from its source and its `width` and `height` in CSS pixels; one it can't, or one
+/// in a table, is left out.
+pub fn html_pieces(
+ html: &str,
+ language: u32,
+ load: impl FnMut(&str, [Option; 2]) -> Option,
+) -> Vec {
+ let html = html
+ .split_once("")
+ .map_or(html, |(_, fragment)| fragment);
+ let html = html
+ .split_once("")
+ .map_or(html, |(fragment, _)| fragment);
+ let mut reader = Reader {
+ base: Format {
+ font: Some("Calibri".into()),
+ font_size: Some(11.0),
+ language: Some(language),
+ ..Format::default()
+ },
+ load,
+ pieces: Vec::new(),
+ blocks: Vec::new(),
+ tables: Vec::new(),
+ lists: Vec::new(),
+ item: None,
+ open: Vec::new(),
+ runs: Vec::new(),
+ shown: false,
+ space: None,
+ };
+ reader.read(html);
+ reader.end_clip();
+ reader.pieces
+}
+
+/// A paragraph or table read, before it becomes a clip's.
+enum Block {
+ Text {
+ runs: Vec<(String, Format)>,
+ /// How many lists it sits in, its own not counted.
+ depth: usize,
+ list: Option,
+ },
+ Table {
+ rows: Vec>>,
+ widths: Vec>,
+ depth: usize,
+ },
+}
+
+#[derive(Default)]
+struct TableRead {
+ rows: Vec>>,
+ widths: Vec>,
+ /// The open cell's blocks.
+ cell: Option>,
+}
+
+/// An open element: its name, the formatting it gives what it holds, and where a link's
+/// field code went in the runs, if it is one.
+struct Open {
+ name: String,
+ format: Format,
+ link: Option,
+}
+
+struct Reader {
+ base: Format,
+ load: F,
+ pieces: Vec,
+ blocks: Vec,
+ tables: Vec,
+ lists: Vec,
+ /// The list of the item whose text has yet to come.
+ item: Option,
+ open: Vec,
+ runs: Vec<(String, Format)>,
+ /// Whether the paragraph read so far shows anything.
+ shown: bool,
+ /// The formatting of white space after what it shows, which shows as one space if
+ /// more follows.
+ space: Option,
+}
+
+/// Elements that hold no text and have no closing tag.
+const VOID: [&str; 9] = [
+ "br", "img", "meta", "link", "input", "hr", "col", "wbr", "area",
+];
+
+/// Elements that begin and end a paragraph.
+const BLOCKS: [&str; 20] = [
+ "p",
+ "div",
+ "li",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "blockquote",
+ "pre",
+ "dt",
+ "dd",
+ "section",
+ "article",
+ "header",
+ "footer",
+ "figure",
+ "figcaption",
+ "address",
+];
+
+impl; 2]) -> Option> Reader {
+ fn read(&mut self, html: &str) {
+ // Elements whose content is not text: script, style, head and title.
+ let mut hidden: Option = None;
+ let mut rest = html;
+ while !rest.is_empty() {
+ if let Some(after) = rest.strip_prefix("").map_or("", |(_, after)| after);
+ continue;
+ }
+ let Some(after) = rest.strip_prefix('<') else {
+ let end = rest.find('<').unwrap_or(rest.len());
+ if hidden.is_none() {
+ self.text(&decode_entities(&rest[..end]));
+ }
+ rest = &rest[end..];
+ continue;
+ };
+ let end = tag_end(after);
+ let tag = &after[..end];
+ rest = after.get(end + 1..).unwrap_or("");
+ let (closing, tag) = match tag.strip_prefix('/') {
+ Some(tag) => (true, tag),
+ None => (false, tag),
+ };
+ let name = tag
+ .split(|c: char| c.is_whitespace() || c == '/')
+ .next()
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ if let Some(until) = &hidden {
+ if closing && name == *until {
+ hidden = None;
+ }
+ continue;
+ }
+ match (name.as_str(), closing) {
+ ("script" | "style" | "head" | "title", false) => hidden = Some(name),
+ (_, false) => self.start(&name, tag),
+ (_, true) => self.end(&name),
+ }
+ }
+ }
+
+ fn start(&mut self, name: &str, tag: &str) {
+ let block = BLOCKS.contains(&name);
+ if block || matches!(name, "br" | "table" | "tr" | "td" | "th" | "ul" | "ol") {
+ self.paragraph();
+ }
+ match name {
+ "br" => return,
+ "img" => {
+ let css = |name| {
+ attribute(tag, name)
+ .and_then(|value| value.trim_end_matches("px").parse::().ok())
+ .filter(|pixels| *pixels > 0.0)
+ };
+ if self.tables.is_empty()
+ && let Some(picture) = attribute(tag, "src").and_then(|source| {
+ (self.load)(&decode_entities(source), [css("width"), css("height")])
+ })
+ {
+ self.end_clip();
+ self.pieces.push(picture);
+ }
+ return;
+ }
+ "ul" | "ol" => {
+ let kind = attribute(tag, "type")
+ .map(str::to_owned)
+ .or_else(|| style(tag, "list-style-type"));
+ self.lists.push(list_style(name, kind.as_deref()));
+ }
+ "li" => {
+ self.implicit_end("li");
+ self.item = Some(self.lists.last().copied().unwrap_or(ListStyle::BULLET));
+ }
+ "p" => self.implicit_end("p"),
+ "table" => self.tables.push(TableRead::default()),
+ "tr" => {
+ if let Some(table) = self.tables.last_mut() {
+ table.end_cell();
+ table.rows.push(Vec::new());
+ }
+ }
+ "td" | "th" => {
+ if let Some(table) = self.tables.last_mut() {
+ table.end_cell();
+ if table.rows.is_empty() {
+ table.rows.push(Vec::new());
+ }
+ let column = table.rows.last().unwrap().len();
+ if table.widths.len() <= column {
+ table.widths.resize(column + 1, None);
+ }
+ let width = style(tag, "width")
+ .and_then(|width| length(&width, 12.0))
+ .or_else(|| {
+ attribute(tag, "width")?
+ .parse::()
+ .ok()
+ .map(|px| px * 0.75)
+ });
+ table.widths[column] = table.widths[column].or(width);
+ table.cell = Some(Vec::new());
+ }
+ }
+ _ => {}
+ }
+ if VOID.contains(&name) {
+ return;
+ }
+ let current = self.format();
+ let mut format = element_format(name, tag, ¤t);
+ let link = (name == "a")
+ .then(|| attribute(tag, "href"))
+ .flatten()
+ .map(|href| {
+ if let Some(space) = self.space.take() {
+ self.push(' ', space);
+ }
+ let mut code = format.inherit(¤t);
+ code.hidden = Some(true);
+ code.hyperlink = Some(true);
+ code.hyperlink_label = Some(true);
+ self.runs.push((field_code(&decode_entities(href)), code));
+ format.hyperlink = Some(true);
+ format.hyperlink_label = Some(true);
+ self.runs.len() - 1
+ });
+ self.open.push(Open {
+ name: name.to_owned(),
+ format,
+ link,
+ });
+ }
+
+ fn end(&mut self, name: &str) {
+ if BLOCKS.contains(&name) || matches!(name, "table" | "tr" | "td" | "th" | "ul" | "ol") {
+ self.paragraph();
+ }
+ if let Some(at) = self.open.iter().rposition(|open| open.name == name) {
+ for open in self.open.drain(at..) {
+ // A link that showed nothing keeps no field code.
+ if let Some(code) = open.link
+ && code + 1 == self.runs.len()
+ {
+ self.runs.pop();
+ }
+ }
+ }
+ match name {
+ "ul" | "ol" => {
+ self.lists.pop();
+ }
+ "li" => self.item = None,
+ "td" | "th" => {
+ if let Some(table) = self.tables.last_mut() {
+ table.end_cell();
+ }
+ }
+ "table" => {
+ if let Some(mut table) = self.tables.pop() {
+ table.end_cell();
+ table.rows.retain(|row| !row.is_empty());
+ if !table.rows.is_empty() {
+ let block = Block::Table {
+ rows: table.rows,
+ widths: table.widths,
+ depth: self.lists.len(),
+ };
+ self.sink().push(block);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ /// Ends an unclosed `name`, as a `p` or `li` is by the next, within the innermost list
+ /// or table.
+ fn implicit_end(&mut self, name: &str) {
+ let boundary =
+ |open: &Open| matches!(open.name.as_str(), "ul" | "ol" | "table" | "td" | "th");
+ if let Some(at) = self
+ .open
+ .iter()
+ .rposition(|open| open.name == name || boundary(open))
+ && self.open[at].name == name
+ {
+ self.open.truncate(at);
+ }
+ }
+
+ fn format(&self) -> Format {
+ self.open.iter().fold(self.base.clone(), |format, open| {
+ open.format.inherit(&format)
+ })
+ }
+
+ fn text(&mut self, text: &str) {
+ let format = self.format();
+ for c in text.chars() {
+ if c.is_whitespace() && c != '\u{a0}' {
+ if self.shown && self.space.is_none() {
+ self.space = Some(format.clone());
+ }
+ continue;
+ }
+ if let Some(space) = self.space.take() {
+ self.push(' ', space);
+ }
+ self.shown = true;
+ self.push(if c == '\u{a0}' { ' ' } else { c }, format.clone());
+ }
+ }
+
+ fn push(&mut self, c: char, format: Format) {
+ match self.runs.last_mut() {
+ Some((text, last)) if *last == format => text.push(c),
+ _ => self.runs.push((c.to_string(), format)),
+ }
+ }
+
+ /// Ends the paragraph being read, which goes in if it showed anything; a link's field
+ /// code begun in one that didn't carries on into the next.
+ fn paragraph(&mut self) {
+ self.space = None;
+ if !std::mem::take(&mut self.shown) {
+ return;
+ }
+ let mut runs = std::mem::take(&mut self.runs);
+ for open in &mut self.open {
+ open.link = None;
+ }
+ let list = self.item.take();
+ let depth = self.lists.len() - usize::from(list.is_some() && !self.lists.is_empty());
+ runs.retain(|(text, _)| !text.is_empty());
+ if runs.is_empty() {
+ runs.push((String::new(), self.format()));
+ }
+ self.sink().push(Block::Text { runs, depth, list });
+ }
+
+ /// Where a block read goes: the open cell, else the clip.
+ fn sink(&mut self) -> &mut Vec {
+ match self.tables.last_mut() {
+ Some(table) => table.cell.get_or_insert_with(Vec::new),
+ None => &mut self.blocks,
+ }
+ }
+
+ /// Makes what was read since the last picture a clip.
+ fn end_clip(&mut self) {
+ self.paragraph();
+ let blocks = std::mem::take(&mut self.blocks);
+ if blocks.is_empty() {
+ return;
+ }
+ let mut definitions = BTreeMap::new();
+ if let Ok(paragraphs) = nodes(blocks, &mut definitions)
+ && !paragraphs.is_empty()
+ {
+ self.pieces.push(Piece::Clip(Clip {
+ paragraphs,
+ definitions,
+ }));
+ }
+ }
+}
+
+impl TableRead {
+ fn end_cell(&mut self) {
+ if let Some(cell) = self.cell.take() {
+ match self.rows.last_mut() {
+ Some(row) => row.push(cell),
+ None => self.rows.push(vec![cell]),
+ }
+ }
+ }
+}
+
+/// `blocks` as paragraphs, each the child of the one before it at a lower level, with the
+/// list definitions they name added to `definitions`.
+fn nodes(
+ blocks: Vec,
+ definitions: &mut BTreeMap,
+) -> Result, EditError> {
+ let depth = |block: &Block| match block {
+ Block::Text { depth, .. } | Block::Table { depth, .. } => *depth,
+ };
+ let least = blocks.iter().map(depth).min().unwrap_or(0);
+ let mut nodes = Vec::new();
+ let mut ancestors: Vec<(u32, ExGuid)> = Vec::new();
+ for block in blocks {
+ let level = (depth(&block) - least + 1) as u32;
+ let mut paragraph = match block {
+ Block::Text { runs, list, .. } => {
+ let first = runs[0].1.clone();
+ let mut paragraph = node(Paragraph::from_runs(runs), Format::default())?;
+ if let Some(style) = list {
+ let id = new_id()?;
+ definitions.insert(id, list_definition(style, &first));
+ paragraph.lists = vec![id];
+ }
+ paragraph
+ }
+ Block::Table { rows, widths, .. } => table(rows, widths, definitions)?,
+ };
+ while ancestors.last().is_some_and(|(above, _)| *above >= level) {
+ ancestors.pop();
+ }
+ paragraph.parent = ancestors.last().map(|(_, id)| *id);
+ paragraph.level = level;
+ ancestors.push((level, paragraph.id));
+ nodes.push(paragraph);
+ }
+ Ok(nodes)
+}
+
+fn table(
+ rows: Vec>>,
+ widths: Vec>,
+ definitions: &mut BTreeMap,
+) -> Result {
+ let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
+ let rows = rows
+ .into_iter()
+ .map(|mut cells| {
+ cells.resize_with(columns, Vec::new);
+ Ok(TableRow {
+ id: new_id()?,
+ cells: cells
+ .into_iter()
+ .map(|blocks| {
+ let mut paragraphs = nodes(blocks, definitions)?;
+ if paragraphs.is_empty() {
+ let empty = Paragraph::new(String::new(), Format::default());
+ paragraphs.push(node(empty, Format::default())?);
+ }
+ Ok(TableCell {
+ id: new_id()?,
+ layout: Default::default(),
+ indents: Vec::new(),
+ shading: None,
+ paragraphs,
+ unsupported: Vec::new(),
+ })
+ })
+ .collect::>()?,
+ })
+ })
+ .collect::>()?;
+ let mut paragraph = node(
+ Paragraph::new(String::new(), Format::default()),
+ Format::default(),
+ )?;
+ paragraph.content = ParagraphContent::Table(Table {
+ id: new_id()?,
+ columns: (0..columns)
+ .map(|column| TableColumn {
+ width: widths
+ .get(column)
+ .copied()
+ .flatten()
+ .unwrap_or(COLUMN_WIDTH)
+ .max(COLUMN_WIDTH),
+ locked: false,
+ })
+ .collect(),
+ rows,
+ borders: Some(true),
+ layout: Default::default(),
+ tags: Vec::new(),
+ });
+ Ok(paragraph)
+}
+
+/// The list style an `ul` or `ol` of `kind`, its `type` or `list-style-type`, gives.
+fn list_style(name: &str, kind: Option<&str>) -> ListStyle {
+ let number = |format: &str| {
+ let index = NUMBER_LIBRARY.iter().position(|known| *known == format);
+ ListStyle::Number(index.unwrap_or(0))
+ };
+ match (name, kind.map(str::trim)) {
+ ("ul", Some("circle")) => ListStyle::Bullet(3),
+ ("ul", Some("square")) => ListStyle::Bullet(11),
+ ("ul", _) => ListStyle::BULLET,
+ (_, Some("a" | "lower-alpha" | "lower-latin")) => number("\u{fffd}\u{4}."),
+ (_, Some("A" | "upper-alpha" | "upper-latin")) => number("\u{fffd}\u{3}."),
+ (_, Some("i" | "lower-roman")) => number("\u{fffd}\u{2}."),
+ (_, Some("I" | "upper-roman")) => number("\u{fffd}\u{1}."),
+ _ => ListStyle::NUMBER,
+ }
+}
+
+/// The formatting an element gives its text: its own, as `b` and `font` give, then its
+/// `style`'s. `current` is the formatting around it.
+fn element_format(name: &str, tag: &str, current: &Format) -> Format {
+ let mut format = Format::default();
+ match name {
+ "b" | "strong" => format.bold = Some(true),
+ "i" | "em" | "cite" | "dfn" | "var" => format.italic = Some(true),
+ "u" | "ins" => format.underline = Some(true),
+ "s" | "strike" | "del" => format.strike = Some(true),
+ "sup" => format.superscript = Some(true),
+ "sub" => format.subscript = Some(true),
+ "code" | "tt" | "pre" | "kbd" | "samp" => format.font = Some("Courier New".into()),
+ "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
+ const SIZES: [f32; 6] = [24.0, 18.0, 14.0, 12.0, 10.0, 8.0];
+ format.bold = Some(true);
+ format.font_size = Some(SIZES[usize::from(name.as_bytes()[1] - b'1')]);
+ }
+ "font" => {
+ format.font = attribute(tag, "face").and_then(family);
+ format.color = attribute(tag, "color").and_then(color).flatten();
+ format.font_size = attribute(tag, "size")
+ .and_then(|size| size.trim().parse::().ok())
+ .map(|size| [8.0, 10.0, 12.0, 14.0, 18.0, 24.0, 36.0][size.clamp(1, 7) - 1]);
+ }
+ _ => {}
+ }
+ let Some(css) = attribute(tag, "style") else {
+ return format;
+ };
+ let size = format
+ .font_size
+ .or(current.font_size)
+ .unwrap_or(DEFAULT_FONT_SIZE);
+ for declaration in decode_entities(css).split(';') {
+ let Some((property, value)) = declaration.split_once(':') else {
+ continue;
+ };
+ let value = value.trim().trim_end_matches("!important").trim();
+ match property.trim().to_ascii_lowercase().as_str() {
+ "font-weight" => {
+ format.bold = match value {
+ "bold" | "bolder" => Some(true),
+ "normal" | "lighter" => Some(false),
+ weight => weight.parse::().ok().map(|weight| weight >= 600),
+ }
+ }
+ "font-style" => format.italic = Some(matches!(value, "italic" | "oblique")),
+ "text-decoration" | "text-decoration-line" => {
+ format.underline = Some(value.contains("underline"));
+ format.strike = Some(value.contains("line-through"));
+ }
+ "font-family" => format.font = family(value).or(format.font),
+ "font-size" => format.font_size = length(value, size).or(format.font_size),
+ "color" => {
+ if let Some(color) = color(value) {
+ format.color = color;
+ }
+ }
+ "background" | "background-color" | "mso-highlight" => {
+ if let Some(color) = value.split_whitespace().find_map(color) {
+ format.highlight = color;
+ }
+ }
+ "vertical-align" => match value {
+ "super" => format.superscript = Some(true),
+ "sub" => format.subscript = Some(true),
+ _ => {}
+ },
+ _ => {}
+ }
+ }
+ format
+}
+
+/// The first family a `font-family` names, unless it is a generic one or a browser's name
+/// for the system's.
+fn family(value: &str) -> Option {
+ let first = value
+ .split(',')
+ .next()?
+ .trim()
+ .trim_matches(['"', '\''])
+ .trim();
+ let generic = [
+ "serif",
+ "sans-serif",
+ "monospace",
+ "cursive",
+ "fantasy",
+ "system-ui",
+ "inherit",
+ "blinkmacsystemfont",
+ ];
+ let lower = first.to_ascii_lowercase();
+ (!first.is_empty() && !first.starts_with('-') && !generic.contains(&lower.as_str()))
+ .then(|| first.to_owned())
+}
+
+/// A CSS length in points, `em` and `%` of `size`.
+fn length(value: &str, size: f32) -> Option {
+ let value = value.trim();
+ let keyword = match value {
+ "xx-small" => Some(7.0),
+ "x-small" => Some(7.5),
+ "small" => Some(10.0),
+ "medium" => Some(12.0),
+ "large" => Some(13.5),
+ "x-large" => Some(18.0),
+ "xx-large" => Some(24.0),
+ _ => None,
+ };
+ if keyword.is_some() {
+ return keyword;
+ }
+ let split = value
+ .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
+ .unwrap_or(value.len());
+ let (number, unit) = value.split_at(split);
+ let number: f32 = number.parse().ok()?;
+ let points = match unit.trim().to_ascii_lowercase().as_str() {
+ "pt" => number,
+ "px" | "" => number * 0.75,
+ "in" => number * 72.0,
+ "cm" => number * 72.0 / 2.54,
+ "mm" => number * 72.0 / 25.4,
+ "pc" => number * 12.0,
+ "em" | "rem" => number * size,
+ "%" => number * size / 100.0,
+ _ => return None,
+ };
+ (points.is_finite() && points > 0.0).then_some(points)
+}
+
+/// A CSS colour as a COLORREF: `Some(None)` for black and the defaults, which pasted text
+/// takes as automatic so it follows the page; none where it isn't one.
+fn color(value: &str) -> Option> {
+ let value = value.trim().to_ascii_lowercase();
+ let rgb = |red: u32, green: u32, blue: u32| Some(red | green << 8 | blue << 16);
+ let parsed = if let Some(hex) = value.strip_prefix('#') {
+ let digits: Vec = hex.chars().map(|c| c.to_digit(16)).collect::>()?;
+ match digits[..] {
+ [r, g, b] => rgb(r * 17, g * 17, b * 17),
+ [r1, r0, g1, g0, b1, b0] => rgb(r1 * 16 + r0, g1 * 16 + g0, b1 * 16 + b0),
+ _ => return None,
+ }
+ } else if let Some(arguments) = value
+ .strip_prefix("rgb(")
+ .or_else(|| value.strip_prefix("rgba("))
+ {
+ let channels: Vec = arguments
+ .trim_end_matches(')')
+ .split(',')
+ .take(3)
+ .map(|channel| {
+ channel
+ .trim()
+ .parse::()
+ .ok()
+ .map(|c| c.clamp(0.0, 255.0) as u32)
+ })
+ .collect::>()?;
+ match channels[..] {
+ [r, g, b] => rgb(r, g, b),
+ _ => return None,
+ }
+ } else {
+ match value.as_str() {
+ "black" | "windowtext" | "auto" | "inherit" | "initial" | "currentcolor" | "none"
+ | "transparent" => None,
+ "white" => rgb(255, 255, 255),
+ "red" => rgb(255, 0, 0),
+ "green" => rgb(0, 128, 0),
+ "lime" => rgb(0, 255, 0),
+ "blue" => rgb(0, 0, 255),
+ "yellow" => rgb(255, 255, 0),
+ "orange" => rgb(255, 165, 0),
+ "purple" => rgb(128, 0, 128),
+ "gray" | "grey" => rgb(128, 128, 128),
+ "silver" => rgb(192, 192, 192),
+ "maroon" => rgb(128, 0, 0),
+ "navy" => rgb(0, 0, 128),
+ "teal" => rgb(0, 128, 128),
+ "olive" => rgb(128, 128, 0),
+ "aqua" | "cyan" => rgb(0, 255, 255),
+ "fuchsia" | "magenta" => rgb(255, 0, 255),
+ "darkred" => rgb(139, 0, 0),
+ "darkblue" => rgb(0, 0, 139),
+ "darkgreen" => rgb(0, 100, 0),
+ _ => return None,
+ }
+ };
+ Some(parsed.filter(|color| *color != 0))
+}
+
+/// The value `name` takes in a tag's `style`.
+fn style(tag: &str, name: &str) -> Option {
+ decode_entities(attribute(tag, "style")?)
+ .split(';')
+ .filter_map(|declaration| declaration.split_once(':'))
+ .find(|(property, _)| property.trim().eq_ignore_ascii_case(name))
+ .map(|(_, value)| value.trim().to_owned())
+}
+
+/// Where a tag's text ends, before its `>`, outside quoted attribute values.
+fn tag_end(tag: &str) -> usize {
+ let mut quote = None;
+ for (at, c) in tag.char_indices() {
+ match (quote, c) {
+ (None, '"' | '\'') => quote = Some(c),
+ (Some(open), _) if c == open => quote = None,
+ (None, '>') => return at,
+ _ => {}
+ }
+ }
+ tag.len()
+}
+
+/// The value of attribute `name` in `tag`, entities still encoded.
+fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
+ let mut rest = tag;
+ while let Some(at) = rest.find('=') {
+ let key = rest[..at].trim_end().rsplit(char::is_whitespace).next()?;
+ let value = rest[at + 1..].trim_start();
+ let (value, after) = match value.chars().next()? {
+ quote @ ('"' | '\'') => value[1..].split_once(quote)?,
+ _ => value.split_at(value.find(char::is_whitespace).unwrap_or(value.len())),
+ };
+ if key.eq_ignore_ascii_case(name) {
+ return Some(value);
+ }
+ rest = after;
+ }
+ None
+}
+
+fn decode_entities(text: &str) -> String {
+ let mut decoded = String::with_capacity(text.len());
+ let mut rest = text;
+ while let Some(at) = rest.find('&') {
+ decoded.push_str(&rest[..at]);
+ rest = &rest[at..];
+ let entity = rest[1..]
+ .find(';')
+ .filter(|end| *end <= 10)
+ .map(|end| &rest[1..end + 1]);
+ let c = entity.and_then(|entity| match entity {
+ "amp" => Some('&'),
+ "lt" => Some('<'),
+ "gt" => Some('>'),
+ "quot" => Some('"'),
+ "apos" => Some('\''),
+ "nbsp" => Some('\u{a0}'),
+ _ => {
+ let number = entity.strip_prefix('#')?;
+ let code = match number.strip_prefix(['x', 'X']) {
+ Some(hex) => u32::from_str_radix(hex, 16).ok()?,
+ None => number.parse().ok()?,
+ };
+ char::from_u32(code)
+ }
+ });
+ match (c, entity) {
+ (Some(c), Some(entity)) => {
+ decoded.push(c);
+ rest = &rest[entity.len() + 2..];
+ }
+ _ => {
+ decoded.push('&');
+ rest = &rest[1..];
+ }
+ }
+ }
+ decoded.push_str(rest);
+ decoded
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn clips(html: &str) -> Vec {
+ html_pieces(html, 0x409, |_, _| None)
+ .into_iter()
+ .map(|piece| match piece {
+ Piece::Clip(clip) => clip,
+ _ => panic!("a picture where none was loaded"),
+ })
+ .collect()
+ }
+
+ const ONENOTE: &str = include_str!("../../../../corpus/clipboard/onenote-2010.html");
+
+ /// What OneNote 2010 copied (`corpus/clipboard`) pastes with its formatting, links,
+ /// lists and table; its tag is not in its HTML.
+ #[test]
+ fn onenotes_html_pastes_with_its_formatting() {
+ let [clip] = &clips(ONENOTE)[..] else {
+ panic!("one clip")
+ };
+ assert_eq!(
+ clip.outline(),
+ [
+ "Bold line>",
+ "plain italic> red> big> \
+ times> under> hi> \
+ link> -> https://example.com/",
+ "• bullet one",
+ " • nested bullet",
+ "• bullet two",
+ "1. number one",
+ "1. number two",
+ "tagged todo",
+ "after",
+ "table 2x2",
+ " | 0,0: a1",
+ " | 0,1: b1>",
+ " | 1,0: a2",
+ " | 1,1: b2",
+ "last line",
+ ]
+ );
+ }
+
+ /// Snowbound's HTML reads back as the clip it was written from.
+ #[test]
+ fn snowbound_html_reads_back_as_written() {
+ let [clip] = &clips(ONENOTE)[..] else {
+ panic!("one clip")
+ };
+ let written = clip.html();
+ let [read] = &clips(&written)[..] else {
+ panic!("one clip")
+ };
+ assert_eq!(read.outline(), clip.outline());
+ let runs = |clip: &Clip| {
+ crate::document::leaves(&clip.paragraphs, None)
+ .map(|(.., node)| node.text().unwrap().text.clone())
+ .collect::>()
+ };
+ assert_eq!(runs(read), runs(clip));
+ }
+
+ /// Markup in text is escaped, spaces kept, and a paragraph that shows nothing still
+ /// takes a line.
+ #[test]
+ fn text_survives_html() {
+ let clip = &clips("a <b> & c
d
")[0];
+ assert_eq!(clip.text(), "a & c\n \nd");
+ let read = &clips(&clip.html())[0];
+ assert_eq!(read.text(), clip.text());
+ }
+
+ /// Other applications' markup: tags and inline styles, unclosed items, sizes in pixels
+ /// and colours by name.
+ #[test]
+ fn web_markup_gives_formatting() {
+ let clip = &clips(
+ " \
+ Bold it big \
+ gone \
+ x2
first second ",
+ )[0];
+ assert_eq!(
+ clip.outline(),
+ [
+ "Bold> >\
+ it> >\
+ big> >\
+ gone>x>\
+ 2>",
+ "a. first",
+ "a. second",
+ ]
+ );
+ }
+
+ /// As OneNote 2010 pasted `before after
` (lab, 2026-09-30): the text on
+ /// either side of the picture, the picture at the size its attributes give; one in a
+ /// table is left out.
+ #[test]
+ fn a_pasted_page_gives_its_text_and_pictures_in_order() {
+ let html = "Version:0.9\r\nT \
+ before b\" \
+ src=\"file:///C:/work/pic.png\" width=\"200\" height=\"100px\"> after
\
+ Fish & chips ——
\
+ cell
\
+ \
+ ";
+ let mut asked = Vec::new();
+ let pieces = html_pieces(html, 0x409, |source, css| {
+ asked.push((source.to_owned(), css));
+ match source {
+ "missing.png" => None,
+ "https://example.invalid/a.png" => Some(Piece::Awaited),
+ _ => Some(Piece::Picture(vec![0; 3], [150.0, 75.0])),
+ }
+ });
+ assert_eq!(
+ asked,
+ [
+ (
+ "file:///C:/work/pic.png".to_owned(),
+ [Some(200.0), Some(100.0)]
+ ),
+ ("missing.png".to_owned(), [None, None]),
+ (
+ "https://example.invalid/a.png".to_owned(),
+ [Some(40.0), None]
+ ),
+ ]
+ );
+ let shown: Vec> = pieces
+ .iter()
+ .map(|piece| match piece {
+ Piece::Clip(clip) => clip.outline(),
+ Piece::Picture(bytes, size) => vec![format!("[{} {size:?}]", bytes.len())],
+ Piece::Awaited => vec!["[awaited]".to_owned()],
+ })
+ .collect();
+ assert_eq!(
+ shown,
+ [
+ vec!["before"],
+ vec!["[3 [150.0, 75.0]]"],
+ vec![
+ "after",
+ "Fish & chips \u{2014}\u{2014}",
+ "• one",
+ "• two",
+ " three",
+ "table 1x1",
+ " | 0,0: cell",
+ ],
+ vec!["[awaited]"],
+ ]
+ );
+ }
+}
diff --git a/crates/canvas/src/editor/link.rs b/crates/canvas/src/editor/link.rs
index d765e7328306bf5ba6b6542ea2c0da2bf375d349..a1bcfb1353f89b5ad0cde79b93590cfb21cd0e26 100644
--- a/crates/canvas/src/editor/link.rs
+++ b/crates/canvas/src/editor/link.rs
@@ -159,7 +159,7 @@ pub(crate) fn unlinked(format: &mut Format) {
}
/// A field code naming `address`, which cannot hold its quotes or line breaks.
-fn field_code(address: &str) -> String {
+pub(super) fn field_code(address: &str) -> String {
let address: String = address
.trim()
.chars()
diff --git a/crates/canvas/src/editor/ops.rs b/crates/canvas/src/editor/ops.rs
index 803123ce38e89b0508838b86492241e45de88ee0..44ebc51f72b118b2d8b88da55b3ebf558109f994 100644
--- a/crates/canvas/src/editor/ops.rs
+++ b/crates/canvas/src/editor/ops.rs
@@ -72,7 +72,7 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator- +
}
/// The definitions `paragraphs` and their cells name.
-fn named(
+pub(super) fn named(
definitions: &BTreeMap
,
paragraphs: &[&[PageParagraph]],
) -> BTreeMap {
diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs
index ec41efe676b9f9de3945bc54a46c0317827c05c1..b9c35f562c066ecd78c9efc8e6fb238d7054b6a1 100644
--- a/crates/canvas/src/editor/table.rs
+++ b/crates/canvas/src/editor/table.rs
@@ -141,7 +141,7 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result Result {
+ if !self.accepts_text() {
+ return Ok(Response::default());
+ }
+ self.editor.paste_clip(&mut self.engine, clip)?;
+ self.follow_pictures()?;
+ self.edited()
+ }
+
/// Clipboard text in `language`, an LCID; see [`CanvasEditor::paste`].
pub fn paste(&mut self, text: &str, language: u32) -> Result {
if !self.accepts_text() {
@@ -1551,53 +1561,19 @@ impl PageView {
self.edited()
}
- /// Copy, or Cut with `cut`: the selected text goes to the clipboard, the page selection's
- /// outlines top to bottom with a blank line between, as OneNote 2010 copies them.
+ /// Copy, or Cut with `cut`: [`CanvasEditor::clip`] goes to the clipboard.
pub fn copy(&mut self, cut: bool) -> Result {
- let text = |outline: &TextOutline, selection: Selection| -> Result {
- let [anchor, focus] = selection.positions;
- Ok(outline
- .document()
- .slice(anchor.min(focus)..anchor.max(focus))?
- .iter()
- .map(|paragraph| {
- paragraph
- .project()
- .map(|projection| projection.text().text().to_owned())
- })
- .collect::, _>>()?
- .join("\n"))
- };
let page = self.editor.whole() == Some(Whole::Page);
- let text = if page {
- let mut outlines = self
- .editor
- .outlines()
- .iter()
- .filter(|outline| !outline.title)
- .collect::>();
- outlines.sort_by(|a, b| {
- let ([ax, ay], [bx, by]) = (a.origin(), b.origin());
- ay.total_cmp(&by).then(ax.total_cmp(&bx))
- });
- outlines
- .into_iter()
- .map(|outline| text(outline, outline.whole()))
- .collect::>>()?
- .join("\n\n")
- } else {
- text(self.editor.active_outline(), self.editor.selection())?
- };
- if text.is_empty() {
+ let Some(clip) = self.editor.clip()? else {
return Ok(Response::default());
- }
+ };
if cut && page {
self.editor.delete(&mut self.engine, false)?;
} else if cut {
self.editor.insert(&mut self.engine, "")?;
}
Ok(Response {
- request: Some(Request::Copy(text)),
+ request: Some(Request::Copy(clip)),
..self.edited()?
})
}
@@ -1821,19 +1797,13 @@ impl PageView {
self.edited()
}
- /// Pasted text in `language`, an LCID, and pictures, in order and as one undo step; see
+ /// Pasted clips and pictures, in order and as one undo step; see
/// [`CanvasEditor::paste_pieces`].
- pub fn paste_pieces(
- &mut self,
- pieces: Vec,
- language: u32,
- ) -> Result<(Vec, Response)> {
+ pub fn paste_pieces(&mut self, pieces: Vec) -> Result<(Vec, Response)> {
if self.edits_wait() {
return Ok((Vec::new(), Response::default()));
}
- let awaited = self
- .editor
- .paste_pieces(&mut self.engine, pieces, language)?;
+ let awaited = self.editor.paste_pieces(&mut self.engine, pieces)?;
self.follow_pictures()?;
Ok((awaited, self.edited()?))
}
diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs
index 7e34db0aa5c32674d84144838800898b3bc41ac9..d6e8ec6d53d36a0c17e26058f25f0d283f43829a 100644
--- a/crates/canvas/src/interaction/tests.rs
+++ b/crates/canvas/src/interaction/tests.rs
@@ -1681,10 +1681,7 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
})
.unwrap();
let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap();
- assert_eq!(
- view.copy(false).unwrap().request,
- Some(Request::Copy("B".into()))
- );
+ assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some("B"));
let _ = view
.modifiers_changed(Modifiers {
command: true,
@@ -2363,12 +2360,10 @@ fn keys_act_on_the_page_selection_as_onenote_does() {
fn copy_and_cut_take_the_page_selection() {
let (mut view, [alpha, ..]) = page_selection_view();
select_page(&mut view, alpha);
- let copied = Some(Request::Copy(
- "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text".into(),
- ));
- assert_eq!(view.copy(false).unwrap().request, copied);
+ let text = "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text";
+ assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some(text));
assert_eq!(view.editor.whole(), Some(Whole::Page));
- assert_eq!(view.copy(true).unwrap().request, copied);
+ assert_eq!(copied(view.copy(true).unwrap()).as_deref(), Some(text));
assert!(view.editor.outlines().iter().all(|outline| outline.title));
assert!(view.editor.caret_outline().is_some());
}
@@ -3357,3 +3352,11 @@ fn a_column_border_drags_with_a_resize_pointer_and_stores_on_release() {
assert!((table.columns[0].width - (37.11 + 50.0)).abs() < 0.01);
assert_eq!(view.editor.take_ops().unwrap().len(), 1);
}
+
+/// The text of what a response asks the host to copy.
+fn copied(response: Response) -> Option {
+ match response.request {
+ Some(Request::Copy(clip)) => Some(clip.text()),
+ _ => None,
+ }
+}
diff --git a/crates/canvas/tests/clip_paste.rs b/crates/canvas/tests/clip_paste.rs
new file mode 100644
index 0000000000000000000000000000000000000000..9193fdb60045a3d0bec882c6427458167f555a39
--- /dev/null
+++ b/crates/canvas/tests/clip_paste.rs
@@ -0,0 +1,193 @@
+//! Formatted content pasted through the editor and stored: what OneNote 2010 copied as HTML
+//! (`corpus/clipboard`), then that outline copied in Snowbound's own format and pasted again
+//! elsewhere on the page, each one edit that the section stores and reads back.
+//! `CANVAS_CLIP_EXPORT` names a directory receiving the section as a notebook, for a cold
+//! reopen in OneNote 2010.
+
+use canvas::{
+ editor::{CanvasEditor, Clip, Piece, html_pieces},
+ layout::TextEngine,
+};
+use onestore::{
+ Arena, Section, Store,
+ document::Kind,
+ op::{Edit, Op},
+ page::{Outline, Page, PageObject, PageParagraph, ParagraphContent},
+};
+
+const ONENOTE: &str = include_str!("../../../corpus/clipboard/onenote-2010.html");
+
+/// Each paragraph of a body outline: its level, list kind, text and bold runs.
+fn outline(page: &Page, outline: &Outline) -> Vec {
+ fn lines(page: &Page, nodes: &[PageParagraph], out: &mut Vec) {
+ for node in nodes {
+ let list = match node.lists.last().map(|id| &page.definitions[id].kind) {
+ Some(Kind::List {
+ bullet: Some(_), ..
+ }) => "• ",
+ Some(Kind::List { .. }) => "# ",
+ _ => "",
+ };
+ let indent = " ".repeat(node.level as usize - 1);
+ match &node.content {
+ ParagraphContent::Text(text) => {
+ let shown = text.text.project().unwrap();
+ let bold = shown
+ .text()
+ .spans()
+ .iter()
+ .any(|span| span.format.bold == Some(true));
+ let bold = if bold { " (bold)" } else { "" };
+ out.push(format!("{indent}{list}{}{bold}", shown.text().text()));
+ }
+ ParagraphContent::Table(table) => {
+ out.push(format!("{indent}table"));
+ for cell in table.rows.iter().flat_map(|row| &row.cells) {
+ lines(page, &cell.paragraphs, out);
+ }
+ }
+ _ => out.push(format!("{indent}other")),
+ }
+ }
+ }
+ let mut out = Vec::new();
+ lines(page, &outline.paragraphs, &mut out);
+ out
+}
+
+fn bodies(page: &Page) -> Vec> {
+ page.objects
+ .iter()
+ .filter_map(|object| match object {
+ PageObject::Outline(body) if !body.title => Some(outline(page, body)),
+ _ => None,
+ })
+ .collect()
+}
+
+#[test]
+fn onenotes_html_and_snowbounds_own_copy_paste_and_store() {
+ let source = onestore::create_section("clips.one", "Pasted clips", "Author").unwrap();
+ let arena = Arena::default();
+ let mut section = Section::open(&arena, source.clone()).unwrap();
+ let (space, ..) = section.pages().unwrap()[0].clone();
+ let mut engine = TextEngine::default();
+ let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
+ let mut at = 133_000_000_000_000_000;
+ let mut store = |editor: &mut CanvasEditor| {
+ let ops = editor.take_ops().unwrap();
+ assert!(!ops.is_empty());
+ at += 10_000_000;
+ let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
+ section.apply("Author", &Edit { at, ops }).unwrap();
+ };
+
+ editor
+ .place_caret(&mut engine, [72.0, 120.0], 400.0)
+ .unwrap();
+ let pieces = html_pieces(ONENOTE, 0x409, |_, _| None);
+ assert!(matches!(pieces[..], [Piece::Clip(_)]));
+ editor.paste_pieces(&mut engine, pieces).unwrap();
+ store(&mut editor);
+
+ editor.select_all().unwrap();
+ let copied = editor.clip().unwrap().unwrap();
+ let copied = Clip::decode(&copied.encode()).unwrap();
+ editor
+ .place_caret(&mut engine, [72.0, 500.0], 400.0)
+ .unwrap();
+ editor.paste_clip(&mut engine, copied).unwrap();
+ store(&mut editor);
+
+ let mut image = source;
+ section.seal().unwrap().unwrap().apply(&mut image).unwrap();
+ let arena = Arena::default();
+ let reopened = Section::open(&arena, image.clone()).unwrap();
+ let page = reopened.page(space).unwrap();
+ let pasted = [
+ "Bold line (bold)",
+ "plain italic red big times under hi link",
+ "• bullet one",
+ " • nested bullet",
+ "• bullet two",
+ "# number one",
+ "# number two",
+ "tagged todo",
+ "after",
+ "table",
+ "a1",
+ "b1 (bold)",
+ "a2",
+ "b2",
+ "last line",
+ ];
+ assert_eq!(
+ bodies(&page),
+ [vec!["Pasted clips"], pasted.to_vec(), pasted.to_vec()]
+ );
+ assert_eq!(bodies(&page), bodies(&editor.page().unwrap()));
+
+ if let Some(directory) = std::env::var_os("CANVAS_CLIP_EXPORT") {
+ let directory = std::path::PathBuf::from(directory);
+ std::fs::create_dir_all(&directory).unwrap();
+ std::fs::write(directory.join("clips.one"), &image).unwrap();
+ let file_id = Store::parse(&image).unwrap().header.file_id;
+ std::fs::write(
+ directory.join("Open Notebook.onetoc2"),
+ onestore::create_table_of_contents("Open Notebook.onetoc2", &[("clips.one", file_id)])
+ .unwrap(),
+ )
+ .unwrap();
+ }
+}
+
+/// A title takes a clip's text alone, as OneNote 2010's does (lab, 2026-10-02).
+#[test]
+fn a_title_takes_the_text_alone() {
+ let source = onestore::create_section("clips.one", "First page", "Author").unwrap();
+ let arena = Arena::default();
+ let mut section = Section::open(&arena, source).unwrap();
+ let creation = onestore::PageCreation::new(None, Some(""), "Author").unwrap();
+ let create = Op::Section(onestore::op::SectionOp::Create(creation));
+ let at = 133_000_000_000_000_000;
+ section
+ .apply(
+ "Author",
+ &Edit {
+ at,
+ ops: vec![create],
+ },
+ )
+ .unwrap();
+ let (space, ..) = section.pages().unwrap()[1].clone();
+ let mut engine = TextEngine::default();
+ let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
+ let title = editor
+ .outlines()
+ .iter()
+ .find(|outline| outline.title)
+ .unwrap()
+ .id;
+ editor.focus_outline(title).unwrap();
+ let pieces = html_pieces("Bold title ", 0x409, |_, _| None);
+ editor.paste_pieces(&mut engine, pieces).unwrap();
+ let ops = editor.take_ops().unwrap();
+ let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
+ section
+ .apply(
+ "Author",
+ &Edit {
+ at: at + 10_000_000,
+ ops,
+ },
+ )
+ .unwrap();
+ let page = section.page(space).unwrap();
+ assert_eq!(page.title, "Bold title");
+ let title = editor
+ .outlines()
+ .iter()
+ .find(|outline| outline.title)
+ .unwrap();
+ assert_eq!(title.shown_text(), "Bold title");
+}
diff --git a/crates/canvas/tests/picture_paste.rs b/crates/canvas/tests/picture_paste.rs
index 9ad6f1fb9398ccc166d951a8b5ae1c9ec7dd6c25..8c22da377d26aab7003173f8db8912c582f002b3 100644
--- a/crates/canvas/tests/picture_paste.rs
+++ b/crates/canvas/tests/picture_paste.rs
@@ -133,24 +133,16 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {
.paste_pieces(
&mut engine,
vec![
- Piece::Text("web ".into()),
+ pasted("web "),
Piece::Picture(png, [150.0, 75.0]),
- Piece::Text("page\ntext ".into()),
+ pasted("page text "),
],
- 0x0409,
)
.unwrap();
store(&mut editor);
assert_eq!(
body(&editor),
- [
- "Pasted web ",
- "[150.0, 75.0]",
- "",
- "page",
- "text ",
- "pictures"
- ]
+ ["Pasted web ", "[150.0, 75.0]", "page", "text ", "pictures"]
);
editor.undo(&mut engine).unwrap();
store(&mut editor);
@@ -162,12 +154,7 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() {
let awaited = editor
.paste_pieces(
&mut engine,
- vec![
- Piece::Text("web".into()),
- Piece::Awaited,
- Piece::Text("page ".into()),
- ],
- 0x0409,
+ vec![pasted("web"), Piece::Awaited, pasted("page ")],
)
.unwrap();
store(&mut editor);
@@ -333,3 +320,10 @@ fn a_picture_pasted_from_the_title_goes_into_the_body() {
[Some(start[0]), Some(margin[1] + row * 18.0)]
);
}
+
+/// Pasted text, as a web page gives it.
+fn pasted(html: &str) -> Piece {
+ canvas::editor::html_pieces(html, 0x0409, |_, _| None)
+ .pop()
+ .unwrap()
+}
diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs
index 79e34f05565ebfd60e89dcd9205f4de403875b0f..f33585dde9b3421be681cc4d05b1394c91dbd3f5 100644
--- a/crates/mobile/src/lib.rs
+++ b/crates/mobile/src/lib.rs
@@ -1225,8 +1225,8 @@ pub unsafe extern "C" fn sb_view_select_paragraph(view: &mut View, id: *const c_
pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char {
let mut copied = None;
let result = view.canvas.page.copy(cut).map(|response| {
- if let Some(Request::Copy(text)) = &response.request {
- copied = Some(text.clone());
+ if let Some(Request::Copy(clip)) = &response.request {
+ copied = Some(clip.text());
}
moved(response)
});
diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml
index 0906724eb78675c299a1747e70d79ab242654c1b..162acefe89b17c21ca48a7251877daffeb557ff9 100644
--- a/crates/snowbound/Cargo.toml
+++ b/crates/snowbound/Cargo.toml
@@ -77,10 +77,13 @@ windows-sys = { version = "0.61", features = [
"Win32_Storage_FileSystem",
"Win32_System_Com",
"Win32_System_Console",
+ "Win32_System_DataExchange",
"Win32_System_Diagnostics_Debug",
"Win32_System_IO",
"Win32_System_Kernel",
"Win32_System_LibraryLoader",
+ "Win32_System_Memory",
+ "Win32_System_Ole",
"Win32_System_Pipes",
"Win32_System_Registry",
"Win32_System_Threading",
diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs
index c6b9fb6aa7422788103f441c6dfcfd90b9aec196..cc9d510e5844fac9a7182f6bafd289e9afaba2e1 100644
--- a/crates/snowbound/src/linux.rs
+++ b/crates/snowbound/src/linux.rs
@@ -197,6 +197,9 @@ pub fn install_title_bar(window: &Window) {
crate::desktop::set_toplevel_icon(window);
}
+/// The HTML and Snowbound's own format of this process's last copy through X11.
+static LAST_COPY: std::sync::Mutex> = std::sync::Mutex::new(None);
+
/// Wayland's clipboard through the window's own connection, as not every compositor offers
/// a clipboard to clients without a window; X11's otherwise.
pub enum Clipboard {
@@ -226,6 +229,35 @@ impl Clipboard {
}
}
+ /// Text, HTML and Snowbound's own format, as one copy: see [`crate::paste::Copied`].
+ /// smithay-clipboard offers text alone.
+ pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), arboard::Error> {
+ match self {
+ Self::Wayland(clipboard) => {
+ clipboard.store(copied.text.clone());
+ Ok(())
+ }
+ Self::X11(clipboard) => {
+ clipboard.set().html(&copied.html, Some(&copied.text))?;
+ *LAST_COPY.lock().unwrap() = Some((copied.html.clone(), copied.clip.clone()));
+ Ok(())
+ }
+ }
+ }
+
+ /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of
+ /// Snowbound's own through, the last copy's, while the clipboard holds its HTML.
+ pub fn get_clip(&mut self) -> Option {
+ if !matches!(self, Self::X11(_)) {
+ return None;
+ }
+ let html = self.get_html()?;
+ match &*LAST_COPY.lock().unwrap() {
+ Some((copied, clip)) if *copied == html => Some(clip.clone()),
+ _ => None,
+ }
+ }
+
pub fn get_text(&mut self) -> Result> {
Ok(match self {
Self::Wayland(clipboard) => clipboard.load()?,
diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs
index ba221d1edf36466fda588dd4e6ae4d1eb47738b5..c9d4724ee67fb2628709c9aa54a3dd53b8b63ccc 100644
--- a/crates/snowbound/src/macos.rs
+++ b/crates/snowbound/src/macos.rs
@@ -195,10 +195,33 @@ impl Clipboard {
set.then_some(()).ok_or("The pasteboard refused the text")
}
+ /// Text, HTML and Snowbound's own format, as one copy.
+ pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> {
+ let set = unsafe {
+ let _: isize = msg_send![&self.0, clearContents];
+ [
+ (TEXT, &copied.text),
+ ("public.html", &copied.html),
+ (CLIP, &copied.clip),
+ ]
+ .into_iter()
+ .all(|(kind, value)| {
+ let (value, kind) = (NSString::from_str(value), NSString::from_str(kind));
+ msg_send![&self.0, setString: &*value, forType: &*kind]
+ })
+ };
+ set.then_some(()).ok_or("The pasteboard refused the copy")
+ }
+
pub fn get_text(&mut self) -> Result {
self.string(TEXT).ok_or("The pasteboard holds no text")
}
+ /// What Snowbound itself copied, if it did.
+ pub fn get_clip(&mut self) -> Option {
+ self.string(CLIP)
+ }
+
/// The files copied, as Finder puts them on the pasteboard.
pub fn get_files(&mut self) -> Vec {
objc2::rc::autoreleasepool(|_| unsafe {
@@ -262,6 +285,9 @@ impl Clipboard {
/// NSPasteboardTypeString.
const TEXT: &str = "public.utf8-plain-text";
+/// Snowbound's own format's pasteboard type.
+const CLIP: &str = "net.paperclover.snowbound.clip";
+
/// TIFF `data` as PNG data, keeping its resolution.
unsafe fn tiff_png(data: &AnyObject) -> Option> {
const PNG: usize = 4; // NSBitmapImageFileTypePNG
@@ -1541,10 +1567,10 @@ mod tests {
use super::*;
use crate::library::{Login, Mount};
- /// Text, a page and files read back as written, from a pasteboard of the test's own,
- /// never the user's.
+ /// Text, a page, files and a copy's every format read back as written, from a
+ /// pasteboard of the test's own, never the user's.
#[test]
- fn the_pasteboard_reads_back_text_pages_and_files() {
+ fn the_pasteboard_reads_back_text_pages_files_and_copies() {
let board: Retained =
unsafe { msg_send_id![class!(NSPasteboard), pasteboardWithUniqueName] };
let mut clipboard = Clipboard(board.clone());
@@ -1567,6 +1593,17 @@ mod tests {
let _: bool = msg_send![&board, writeObjects: &*urls];
}
assert_eq!(clipboard.get_files(), [std::path::PathBuf::from(path)]);
+ let copied = crate::paste::Copied {
+ text: "Copied".into(),
+ html: "Copied
".into(),
+ clip: "{}".into(),
+ };
+ clipboard.set(&copied).unwrap();
+ assert_eq!(clipboard.get_text().unwrap(), "Copied");
+ assert_eq!(clipboard.get_html().as_deref(), Some("Copied
"));
+ assert_eq!(clipboard.get_clip().as_deref(), Some("{}"));
+ clipboard.set_text("Plain".into()).unwrap();
+ assert_eq!(clipboard.get_clip(), None);
unsafe { msg_send![&board, releaseGlobally] }
}
diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs
index ddbd1cd7cead8ab8e398e74dbfe68e626ca9cb04..8c1ddd796fe72b06e340b42d646eea6dae9fc8a5 100644
--- a/crates/snowbound/src/main.rs
+++ b/crates/snowbound/src/main.rs
@@ -89,6 +89,7 @@ mod watch;
#[cfg(not(target_arch = "wasm32"))]
use accesskit_winit::Adapter as AccessAdapter;
+use canvas::editor::Clip;
#[cfg(not(target_arch = "wasm32"))]
use canvas::editor::{DEFAULT_OUTLINE_WIDTH, TextOutline};
use canvas::gpu::colorref;
@@ -255,14 +256,23 @@ struct Redraw(EventLoopProxy);
enum Clipboard {
System(platform::Clipboard),
- Memory(String),
+ /// What was last copied, text alone or a clip.
+ Memory(String, Option),
}
impl Clipboard {
fn set_text(&mut self, text: String) -> Result<(), Box> {
match self {
Self::System(clipboard) => clipboard.set_text(text)?,
- Self::Memory(held) => *held = text,
+ Self::Memory(held, clip) => (*held, *clip) = (text, None),
+ }
+ Ok(())
+ }
+
+ fn set(&mut self, clip: Clip) -> Result<(), Box> {
+ match self {
+ Self::System(clipboard) => clipboard.set(&paste::Copied::new(&clip))?,
+ Self::Memory(held, kept) => (*held, *kept) = (clip.text(), Some(clip)),
}
Ok(())
}
@@ -271,21 +281,25 @@ impl Clipboard {
match self {
Self::System(clipboard) => {
let files = clipboard.get_files();
+ let text = |clipboard: &mut platform::Clipboard| {
+ clipboard.get_text().ok().filter(|text| !text.is_empty())
+ };
if !files.is_empty() {
Some(paste::Pasted::Files(files))
- } else if let Some(html) = clipboard
- .get_html()
- .filter(|html| html.to_ascii_lowercase().contains(" Some(paste::Pasted::Text(held.clone())),
+ Self::Memory(held, clip) => Some(match clip {
+ Some(clip) => paste::Pasted::Clip(clip.clone()),
+ None => paste::Pasted::Text(held.clone()),
+ }),
}
}
}
@@ -1068,7 +1082,7 @@ impl State {
let clipboard = if visible && std::env::var_os("SNOWBOUND_REPLAY").is_none() {
Clipboard::System(platform::Clipboard::new(&window)?)
} else {
- Clipboard::Memory(String::new())
+ Clipboard::Memory(String::new(), None)
};
let redraw: std::task::Waker = Arc::new(Redraw(proxy.clone())).into();
let search = search::Search::new(stored.search_scope, redraw.clone());
@@ -3159,7 +3173,7 @@ impl State {
}
Command::Template(choice) => self.apply_template(choice)?,
Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
- Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
+ Command::Page(Request::Copy(clip)) => self.clipboard.set(clip)?,
Command::Page(Request::Paste) => self.paste()?,
Command::Page(Request::OpenLink(address)) => self.open_link(&address)?,
Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?,
diff --git a/crates/snowbound/src/paste.rs b/crates/snowbound/src/paste.rs
index db042b40d44493dab41b38abc4c9d5c8c7c2df69..03e30071811dcc6bb52a2a3bf0ff931dbb6f2eed 100644
--- a/crates/snowbound/src/paste.rs
+++ b/crates/snowbound/src/paste.rs
@@ -1,20 +1,62 @@
-//! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them.
+//! The clipboard's formats: what Copy offers, and pictures, files and pages Paste takes from
+//! it, with pictures inserted, as OneNote 2010 places them.
use crate::{State, platform};
-use canvas::editor::Piece;
+use canvas::editor::{Clip, Piece};
use std::{
error::Error,
path::{Path, PathBuf},
};
+/// Every format Copy puts on the clipboard for one selection, which each platform's clipboard
+/// offers together: text; HTML, as OneNote 2010 offers it beside text (lab, 2026-10-02); and
+/// Snowbound's own format, [`Clip::encode`]'s JSON, which pastes losslessly into Snowbound.
+/// Its name is `net.paperclover.snowbound.clip` on macOS, `Snowbound Clip` on Windows and
+/// `application/x-snowbound-clip` where formats are MIME types.
+pub(crate) struct Copied {
+ pub text: String,
+ /// A whole page, the copy between `` and ``.
+ pub html: String,
+ pub clip: String,
+}
+
+impl Copied {
+ pub(crate) fn new(clip: &Clip) -> Self {
+ Self {
+ text: clip.text(),
+ html: clip.html(),
+ clip: clip.encode(),
+ }
+ }
+}
+
+/// `html` behind the header Windows' `HTML Format` begins with, which gives the byte offsets
+/// of the page and of the fragment its comments mark out.
+#[cfg(any(windows, test))]
+pub(crate) fn cf_html(html: &str) -> String {
+ let header = |offsets: [usize; 4]| {
+ format!(
+ "Version:0.9\r\nStartHTML:{:010}\r\nEndHTML:{:010}\r\nStartFragment:{:010}\r\nEndFragment:{:010}\r\n",
+ offsets[0], offsets[1], offsets[2], offsets[3]
+ )
+ };
+ let start = header([0; 4]).len();
+ let fragment = html
+ .find("")
+ .map_or(0, |at| at + "".len());
+ let end = html.find("").unwrap_or(html.len());
+ header([start, start + html.len(), start + fragment, start + end]) + html
+}
+
/// What Paste takes from the clipboard. Files come first, as Finder also offers their names
-/// as text and their icons as pictures; then a page holding pictures, as its text alone
-/// would lose them; text before a picture, as Office and browsers offer a picture of copied
-/// text beside it.
+/// as text and their icons as pictures; then Snowbound's own copy, then a page, as its text
+/// alone would lose its formatting and pictures; text before a picture, as Office and
+/// browsers offer a picture of copied text beside it.
pub(crate) enum Pasted {
Files(Vec),
- /// HTML holding a picture.
- Page(String),
+ Clip(Clip),
+ /// A web page, and the clipboard's text for one that holds nothing to paste.
+ Page(String, Option),
Text(String),
Picture(Vec),
}
@@ -27,10 +69,15 @@ impl State {
self.place_file(&path, None)?;
}
}
- Some(Pasted::Page(html)) => {
+ Some(Pasted::Clip(clip)) => {
+ let response = self.view.paste_clip(clip)?;
+ self.respond(response);
+ }
+ Some(Pasted::Page(html, text)) => {
// Web pictures come later, so the window never waits on the network.
let mut fetched = Vec::new();
- let pieces = html_pieces(&html, |source, css| {
+ let language = canvas::language::lcid(&platform::input_language());
+ let pieces = canvas::editor::html_pieces(&html, language, |source, css| {
if source.starts_with("https://") || source.starts_with("http://") {
fetched.push((source.to_owned(), css));
return Some(Piece::Awaited);
@@ -39,8 +86,14 @@ impl State {
let size = sized(&bytes, css)?;
Some(Piece::Picture(bytes, size))
});
- let language = canvas::language::lcid(&platform::input_language());
- let (places, response) = self.view.paste_pieces(pieces, language)?;
+ if pieces.is_empty() {
+ if let Some(text) = text {
+ let response = self.view.paste(&text, language)?;
+ self.respond(response);
+ }
+ return Ok(());
+ }
+ let (places, response) = self.view.paste_pieces(pieces)?;
self.respond(response);
for (place, (source, css)) in places.into_iter().zip(fetched) {
let proxy = self.proxy.clone();
@@ -111,179 +164,6 @@ impl State {
}
}
-/// A web page fragment's text, a line to each block, and its pictures in order, as OneNote
-/// 2010 pastes them (lab, 2026-09-30). `load` makes a picture's piece from its source and
-/// its `width` and `height` in CSS pixels; one it can't is left out.
-fn html_pieces(
- html: &str,
- mut load: impl FnMut(&str, [Option; 2]) -> Option,
-) -> Vec {
- let html = html
- .split_once("")
- .map_or(html, |(_, fragment)| fragment);
- let html = html
- .split_once("")
- .map_or(html, |(fragment, _)| fragment);
- let mut pieces = Vec::new();
- let mut text = String::new();
- let flush = |text: &mut String, pieces: &mut Vec| {
- let trimmed = text.trim_matches(|c: char| c.is_whitespace());
- if !trimmed.is_empty() {
- pieces.push(Piece::Text(trimmed.to_owned()));
- }
- text.clear();
- };
- let line = |text: &mut String| {
- text.truncate(text.trim_end_matches(' ').len());
- if !text.is_empty() && !text.ends_with('\n') {
- text.push('\n');
- }
- };
- // Elements whose content is not text: script, style, head and title.
- let mut hidden: Option = None;
- let mut rest = html;
- while !rest.is_empty() {
- if let Some(after) = rest.strip_prefix("").map_or("", |(_, after)| after);
- continue;
- }
- let Some(after) = rest.strip_prefix('<') else {
- let end = rest.find('<').unwrap_or(rest.len());
- if hidden.is_none() {
- for c in decode_entities(&rest[..end]).chars() {
- if c.is_whitespace() && c != '\u{a0}' {
- if !text.is_empty() && !text.ends_with([' ', '\n']) {
- text.push(' ');
- }
- } else {
- text.push(if c == '\u{a0}' { ' ' } else { c });
- }
- }
- }
- rest = &rest[end..];
- continue;
- };
- let end = tag_end(after);
- let tag = &after[..end];
- rest = after.get(end + 1..).unwrap_or("");
- let (closing, tag) = match tag.strip_prefix('/') {
- Some(tag) => (true, tag),
- None => (false, tag),
- };
- let name = tag
- .split(|c: char| c.is_whitespace() || c == '/')
- .next()
- .unwrap_or_default()
- .to_ascii_lowercase();
- if let Some(until) = &hidden {
- if closing && name == *until {
- hidden = None;
- }
- continue;
- }
- match name.as_str() {
- "script" | "style" | "head" | "title" if !closing => hidden = Some(name),
- "br" => {
- text.truncate(text.trim_end_matches(' ').len());
- text.push('\n');
- }
- "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote"
- | "pre" | "table" | "ul" | "ol" | "dt" | "dd" | "section" | "article" | "header"
- | "footer" | "figure" | "figcaption" => line(&mut text),
- "img" if !closing => {
- let css = |name| {
- attribute(tag, name)
- .and_then(|value| value.trim_end_matches("px").parse::().ok())
- .filter(|pixels| *pixels > 0.0)
- };
- let Some(picture) = attribute(tag, "src").and_then(|source| {
- load(&decode_entities(source), [css("width"), css("height")])
- }) else {
- continue;
- };
- flush(&mut text, &mut pieces);
- pieces.push(picture);
- }
- _ => {}
- }
- }
- flush(&mut text, &mut pieces);
- pieces
-}
-
-/// Where a tag's text ends, before its `>`, outside quoted attribute values.
-fn tag_end(tag: &str) -> usize {
- let mut quote = None;
- for (at, c) in tag.char_indices() {
- match (quote, c) {
- (None, '"' | '\'') => quote = Some(c),
- (Some(open), _) if c == open => quote = None,
- (None, '>') => return at,
- _ => {}
- }
- }
- tag.len()
-}
-
-/// The value of attribute `name` in `tag`, entities still encoded.
-fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
- let mut rest = tag;
- while let Some(at) = rest.find('=') {
- let key = rest[..at].trim_end().rsplit(char::is_whitespace).next()?;
- let value = rest[at + 1..].trim_start();
- let (value, after) = match value.chars().next()? {
- quote @ ('"' | '\'') => value[1..].split_once(quote)?,
- _ => value.split_at(value.find(char::is_whitespace).unwrap_or(value.len())),
- };
- if key.eq_ignore_ascii_case(name) {
- return Some(value);
- }
- rest = after;
- }
- None
-}
-
-fn decode_entities(text: &str) -> String {
- let mut decoded = String::with_capacity(text.len());
- let mut rest = text;
- while let Some(at) = rest.find('&') {
- decoded.push_str(&rest[..at]);
- rest = &rest[at..];
- let entity = rest[1..]
- .find(';')
- .filter(|end| *end <= 10)
- .map(|end| &rest[1..end + 1]);
- let c = entity.and_then(|entity| match entity {
- "amp" => Some('&'),
- "lt" => Some('<'),
- "gt" => Some('>'),
- "quot" => Some('"'),
- "apos" => Some('\''),
- "nbsp" => Some('\u{a0}'),
- _ => {
- let number = entity.strip_prefix('#')?;
- let code = match number.strip_prefix(['x', 'X']) {
- Some(hex) => u32::from_str_radix(hex, 16).ok()?,
- None => number.parse().ok()?,
- };
- char::from_u32(code)
- }
- });
- match (c, entity) {
- (Some(c), Some(entity)) => {
- decoded.push(c);
- rest = &rest[entity.len() + 2..];
- }
- _ => {
- decoded.push('&');
- rest = &rest[1..];
- }
- }
- }
- decoded.push_str(rest);
- decoded
-}
-
/// A picture's size in points from its `width` and `height` in CSS pixels, either giving
/// the other by its proportions, or else from its resolution; none where it doesn't decode.
fn sized(bytes: &[u8], css: [Option; 2]) -> Option<[f32; 2]> {
@@ -597,58 +477,20 @@ mod tests {
bytes
}
- fn shown(pieces: &[Piece]) -> Vec {
- pieces
- .iter()
- .map(|piece| match piece {
- Piece::Text(text) => text.clone(),
- Piece::Picture(bytes, size) => format!("[{} {size:?}]", bytes.len()),
- Piece::Awaited => "[awaited]".to_owned(),
- })
- .collect()
- }
-
- /// As OneNote 2010 pasted `before after
` (lab, 2026-09-30): the text on
- /// either side of the picture, the picture at the size its attributes give.
+ /// The header's offsets find the page and the fragment, counted in bytes.
#[test]
- fn a_pasted_page_gives_its_text_and_pictures_in_order() {
- let html = "Version:0.9\r\nT \
- before b\" \
- src=\"file:///C:/work/pic.png\" width=\"200\" height=\"100px\"> after
\
- Fish & chips ——
\
- \
- ";
- let mut asked = Vec::new();
- let pieces = html_pieces(html, |source, css| {
- asked.push((source.to_owned(), css));
- match source {
- "missing.png" => None,
- "https://example.invalid/a.png" => Some(Piece::Awaited),
- _ => Some(Piece::Picture(vec![0; 3], [150.0, 75.0])),
- }
- });
+ fn the_html_format_header_gives_its_offsets() {
+ let html =
+ "\u{e9}t\u{e9}
";
+ let wrapped = cf_html(html);
+ let offset = |name: &str| {
+ let at = wrapped.find(name).unwrap() + name.len() + 1;
+ wrapped[at..at + 10].parse::().unwrap()
+ };
+ assert_eq!(&wrapped[offset("StartHTML")..offset("EndHTML")], html);
assert_eq!(
- asked,
- [
- (
- "file:///C:/work/pic.png".to_owned(),
- [Some(200.0), Some(100.0)]
- ),
- ("missing.png".to_owned(), [None, None]),
- (
- "https://example.invalid/a.png".to_owned(),
- [Some(40.0), None]
- ),
- ]
- );
- assert_eq!(
- shown(&pieces),
- [
- "before",
- "[3 [150.0, 75.0]]",
- "after\nFish & chips \u{2014}\u{2014}\none\ntwo\nthree",
- "[awaited]",
- ]
+ &wrapped[offset("StartFragment")..offset("EndFragment")],
+ "\u{e9}t\u{e9}
"
);
}
diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs
index 5a7bf65dcd3c4ac2d01351bbb3ac6470db7b1d14..28c113df9a1b242d545343856353780db3e71abb 100644
--- a/crates/snowbound/src/web.rs
+++ b/crates/snowbound/src/web.rs
@@ -447,6 +447,15 @@ impl Clipboard {
Ok(())
}
+ /// A copy's text, the format every browser's clipboard takes.
+ pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box> {
+ self.set_text(copied.text.clone())
+ }
+
+ pub fn get_clip(&mut self) -> Option {
+ None
+ }
+
pub fn get_text(&mut self) -> Result> {
PASTED
.with_borrow(|pasted| pasted.text.clone())
diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs
index be8b64d254e8ff49d76aadf877c8b6c94492ba77..727aa2c1817f8a34c27a8b5f6b07be4eb103ce85 100644
--- a/crates/snowbound/src/windows.rs
+++ b/crates/snowbound/src/windows.rs
@@ -1082,17 +1082,89 @@ pub fn appearance(window: &Window) -> Theme {
window.theme().unwrap_or(Theme::Light)
}
-pub struct Clipboard(arboard::Clipboard);
+/// The clipboard: arboard reads it; Copy writes its formats itself, as arboard can't offer
+/// Snowbound's own.
+pub struct Clipboard(arboard::Clipboard, HWND);
+
+/// Snowbound's own format's registered name.
+const CLIP: &str = "Snowbound Clip";
impl Clipboard {
- pub fn new(_: &Window) -> Result {
- arboard::Clipboard::new().map(Self)
+ pub fn new(window: &Window) -> Result {
+ Ok(Self(arboard::Clipboard::new()?, hwnd(window)))
}
pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {
self.0.set_text(text)
}
+ /// Text, `HTML Format` and Snowbound's own, as one copy.
+ pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> {
+ use windows_sys::Win32::System::{DataExchange as clip, Memory, Ole::CF_UNICODETEXT};
+ let text: Vec = copied
+ .text
+ .replace('\n', "\r\n")
+ .encode_utf16()
+ .chain([0])
+ .flat_map(u16::to_le_bytes)
+ .collect();
+ let html = crate::paste::cf_html(&copied.html);
+ let formats = [
+ (u32::from(CF_UNICODETEXT), text),
+ (registered("HTML Format"), [html.as_bytes(), &[0]].concat()),
+ (registered(CLIP), [copied.clip.as_bytes(), &[0]].concat()),
+ ];
+ let _open = Open::new(self.1)?;
+ if unsafe { clip::EmptyClipboard() } == 0 {
+ return Err("The clipboard couldn't be emptied");
+ }
+ for (format, bytes) in formats {
+ unsafe {
+ let memory = Memory::GlobalAlloc(Memory::GMEM_MOVEABLE, bytes.len());
+ if memory.is_null() {
+ return Err("No memory for the clipboard");
+ }
+ let target = Memory::GlobalLock(memory);
+ std::ptr::copy_nonoverlapping(bytes.as_ptr(), target.cast(), bytes.len());
+ Memory::GlobalUnlock(memory);
+ // The clipboard owns the memory once it takes it.
+ if clip::SetClipboardData(format, memory).is_null() {
+ windows_sys::Win32::Foundation::GlobalFree(memory);
+ return Err("The clipboard refused a format");
+ }
+ }
+ }
+ Ok(())
+ }
+
+ /// What Snowbound itself copied, if it did.
+ pub fn get_clip(&mut self) -> Option {
+ use windows_sys::Win32::System::{DataExchange as clip, Memory};
+ let format = registered(CLIP);
+ if unsafe { clip::IsClipboardFormatAvailable(format) } == 0 {
+ return None;
+ }
+ let _open = Open::new(self.1).ok()?;
+ unsafe {
+ let memory = clip::GetClipboardData(format);
+ if memory.is_null() {
+ return None;
+ }
+ let data = Memory::GlobalLock(memory);
+ if data.is_null() {
+ return None;
+ }
+ let bytes = std::slice::from_raw_parts(data.cast::(), Memory::GlobalSize(memory));
+ let end = bytes
+ .iter()
+ .position(|&byte| byte == 0)
+ .unwrap_or(bytes.len());
+ let text = String::from_utf8(bytes[..end].to_vec()).ok();
+ Memory::GlobalUnlock(memory);
+ text
+ }
+ }
+
pub fn get_text(&mut self) -> Result {
self.0.get_text()
}
@@ -1111,6 +1183,36 @@ impl Clipboard {
}
}
+/// The clipboard, open until dropped; another application may hold it a moment, so this
+/// retries for a while as arboard does.
+struct Open;
+
+impl Open {
+ fn new(owner: HWND) -> Result {
+ use windows_sys::Win32::System::DataExchange::OpenClipboard;
+ for _ in 0..10 {
+ if unsafe { OpenClipboard(owner) } != 0 {
+ return Ok(Self);
+ }
+ std::thread::sleep(Duration::from_millis(10));
+ }
+ Err("Another application is holding the clipboard")
+ }
+}
+
+impl Drop for Open {
+ fn drop(&mut self) {
+ unsafe { windows_sys::Win32::System::DataExchange::CloseClipboard() };
+ }
+}
+
+/// The number Windows gives clipboard format `name`.
+fn registered(name: &str) -> u32 {
+ unsafe {
+ windows_sys::Win32::System::DataExchange::RegisterClipboardFormatW(wide(name).as_ptr())
+ }
+}
+
/// The DWORD `value` under `key` in the user's registry hive.
fn registry_dword(key: &str, value: &str) -> Option {
use windows_sys::Win32::System::Registry;