| author | |
| committer | |
| log | a7388496fddd44f7037f7224f6300a31b9a784ea |
| tree | 6b9bc64cf103ed4d186a42d18d3d21225e9c3e58 |
| parent | bf28ad621ca5962e64fe4ab4c73a90d92204d181 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Copy put plain text alone on the clipboard, so a bold line pasted back came
out regular. It now offers what OneNote 2010 does beside text (lab): HTML
with each run's font, size, weight, slant, decoration and colours inline,
links, lists as nested ul and ol, and bordered tables, which OneNote 2010
and Word 2010 paste as copied. Beside them goes Snowbound's own format, the
copied paragraphs and the styles, lists and tags they name, which pastes
back losslessly, pictures included: an NSPasteboard type on macOS, a
registered clipboard format on Windows, and on X11 this process's last copy
while the clipboard still holds its HTML.
Paste prefers that format, then HTML, then text. HTML now pastes as
formatted paragraphs, lists and tables rather than text, so what OneNote
copies (corpus/clipboard) pastes as it shows. Several paragraphs go between
the halves of the caret's paragraph, an empty half dropped, as OneNote does;
a title takes the text alone. A pasted clip stores and cold-opens in
OneNote 2010.
Assisted-by: claude-opus-5.523 files changed, 2442 insertions(+), 382 deletions(-)
arc/canvas.md+11-4| ... | @@ -147,10 +147,17 @@ lies on the page at the caret, and the caret moves to the grid row below it. | ... | @@ -147,10 +147,17 @@ lies on the page at the caret, and the caret moves to the grid row below it. |
| 147 | From the title it joins the outline where the body starts, or lies two grid rows | 147 | From the title it joins the outline where the body starts, or lies two grid rows |
| 148 | below the page's content when none starts there. A picture takes the size its | 148 | below the page's content when none starts there. A picture takes the size its |
| 149 | resolution gives it, or 96 dpi without one. Paste takes files first (a picture | 149 | resolution gives it, or 96 dpi without one. Paste takes files first (a picture |
| 150 | file as its picture, as a dropped one goes in), then a web page holding | 150 | file as its picture, as a dropped one goes in), then Snowbound's own copy, then |
| 151 | pictures, its text and pictures in order as one undo step, then text, then a | 151 | a web page, its formatted text, lists, tables and pictures in order as one undo |
| 152 | picture: Finder offers a copied file's name and icon beside it, and other apps a | 152 | step, then text, then a picture: Finder offers a copied file's name and icon |
| 153 | picture of copied text. | 153 | beside it, and other apps a picture of copied text. |
| 154 | |||
| 155 | Copy offers what OneNote 2010 does beside text, HTML with each run's formatting | ||
| 156 | inline, lists as `ul` and `ol` and tables bordered, which OneNote and Word paste | ||
| 157 | as copied (`corpus/clipboard`). Snowbound's own format, a `Clip` of paragraphs | ||
| 158 | and the definitions they name as JSON, carries styles, tags and pictures too. | ||
| 159 | Several paragraphs go between the halves of the caret's paragraph, an empty | ||
| 160 | half dropped; a title takes the text alone. | ||
| 154 | 161 | ||
| 155 | ## Recordings | 162 | ## Recordings |
| 156 | 163 |
corpus/clipboard/README.md created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | # Clipboard | ||
| 2 | |||
| 3 | `onenote-2010.html` is the `HTML Format` OneNote 2010 put on the clipboard for a | ||
| 4 | whole outline holding bold, italic, coloured, resized, re-fonted, underlined and | ||
| 5 | highlighted runs, a link, nested bullets, a numbered list, a To Do tag, a 2 by 2 | ||
| 6 | table with a bold cell, and a last line (lab, 2026-10-02; `tools/w7`, a page | ||
| 7 | built through COM and copied with Ctrl+A, Ctrl+C). Beside it OneNote offered | ||
| 8 | `OneNote 2010 Internal` (56 bytes naming the copy, not its content), Unicode and | ||
| 9 | ANSI text, an enhanced metafile and a device-independent bitmap, but no RTF. The | ||
| 10 | tag is absent from the HTML. `crates/canvas/src/editor/html.rs` reads it in its | ||
| 11 | tests. | ||
corpus/clipboard/onenote-2010.html created+96| ... | @@ -0,0 +1,96 @@ | ||
| 1 | Version:1.0 | ||
| 2 | StartHTML:0000000105 | ||
| 3 | EndHTML:0000004577 | ||
| 4 | StartFragment:0000000482 | ||
| 5 | EndFragment:0000004537 | ||
| 6 | |||
| 7 | <html xmlns:o="urn:schemas-microsoft-com:office:office" | ||
| 8 | xmlns:dt="uuid:C2F41010-65B3-11d1-A29F-00AA00C14882" | ||
| 9 | xmlns="http://www.w3.org/TR/REC-html40"> | ||
| 10 | |||
| 11 | <head> | ||
| 12 | <meta http-equiv=Content-Type content="text/html; charset=utf-8"> | ||
| 13 | <meta name=ProgId content=OneNote.File> | ||
| 14 | <meta name=Generator content="Microsoft OneNote 14"> | ||
| 15 | </head> | ||
| 16 | |||
| 17 | <body lang=en-US> | ||
| 18 | <!--StartFragment--> | ||
| 19 | |||
| 20 | <ul style='margin-left:.4069in;direction:ltr;unicode-bidi:embed;margin-top: | ||
| 21 | 0in;margin-bottom:0in'> | ||
| 22 | <p style='margin:0in;font-family:Calibri;font-size:11.0pt'><span | ||
| 23 | style='font-weight:bold'>Bold line</span></p> | ||
| 24 | <p style='margin:0in'><span style='font-family:Calibri;font-size:11.0pt'>plain | ||
| 25 | </span><span style='font-style:italic;font-family:Calibri;font-size:11.0pt'>italic</span><span | ||
| 26 | style='font-family:Calibri;font-size:11.0pt'> </span><span style='font-family: | ||
| 27 | Calibri;font-size:11.0pt;color:#C00000'>red</span><span style='font-family: | ||
| 28 | Calibri;font-size:11.0pt'> </span><span style='font-family:Calibri;font-size: | ||
| 29 | 16.0pt'>big</span><span style='font-family:Calibri;font-size:11.0pt'> </span><span | ||
| 30 | style='font-family:"Times New Roman";font-size:11.0pt'>times</span><span | ||
| 31 | style='font-family:Calibri;font-size:11.0pt'> </span><span style='text-decoration: | ||
| 32 | underline;font-family:Calibri;font-size:11.0pt'>under</span><span | ||
| 33 | style='font-family:Calibri;font-size:11.0pt'> </span><span style='font-family: | ||
| 34 | Calibri;font-size:11.0pt;background:yellow;mso-highlight:yellow'>hi</span><span | ||
| 35 | style='font-family:Calibri;font-size:11.0pt'> </span><a | ||
| 36 | href="https://example.com/"><span style='font-family:Calibri;font-size:11.0pt'>link</span></a></p> | ||
| 37 | <ul type=disc style='margin-left:0in;direction:ltr;unicode-bidi:embed; | ||
| 38 | margin-top:0in;margin-bottom:0in'> | ||
| 39 | <li style='margin-top:0;margin-bottom:0;vertical-align:middle'><span | ||
| 40 | style='font-family:Calibri;font-size:11.0pt'>bullet one</span></li> | ||
| 41 | <ul type=circle style='margin-left:.375in;direction:ltr;unicode-bidi:embed; | ||
| 42 | margin-top:0in;margin-bottom:0in'> | ||
| 43 | <li style='margin-top:0;margin-bottom:0;vertical-align:middle'><span | ||
| 44 | style='font-family:Calibri;font-size:11.0pt'>nested bullet</span></li> | ||
| 45 | </ul> | ||
| 46 | </ul> | ||
| 47 | <ul type=disc style='margin-left:0in;direction:ltr;unicode-bidi:embed; | ||
| 48 | margin-top:0in;margin-bottom:0in'> | ||
| 49 | <li style='margin-top:0;margin-bottom:0;vertical-align:middle'><span | ||
| 50 | style='font-family:Calibri;font-size:11.0pt'>bullet two</span></li> | ||
| 51 | </ul> | ||
| 52 | <ol type=1 style='margin-left:0in;direction:ltr;unicode-bidi:embed;margin-top: | ||
| 53 | 0in;margin-bottom:0in;font-family:Verdana;font-size:11.0pt;font-weight:normal; | ||
| 54 | font-style:normal'> | ||
| 55 | <li value=1 style='margin-top:0;margin-bottom:0;vertical-align:middle'><span | ||
| 56 | style='font-family:Calibri;font-size:11.0pt;font-weight:normal; | ||
| 57 | font-style:normal;font-family:Calibri;font-size:11.0pt'>number one</span></li> | ||
| 58 | <li style='margin-top:0;margin-bottom:0;vertical-align:middle'><span | ||
| 59 | style='font-family:Calibri;font-size:11.0pt'>number two</span></li> | ||
| 60 | </ol> | ||
| 61 | <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>tagged todo</p> | ||
| 62 | <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>after</p> | ||
| 63 | <div style='direction:ltr'> | ||
| 64 | <table border=1 cellpadding=0 cellspacing=0 valign=top style='direction:ltr; | ||
| 65 | border-collapse:collapse;border-style:solid;border-color:#A3A3A3;border-width: | ||
| 66 | 1pt'> | ||
| 67 | <tr> | ||
| 68 | <td style='border-style:solid;border-color:#A3A3A3;border-width:1pt; | ||
| 69 | vertical-align:top;width:.6673in;padding:4pt 4pt 4pt 4pt'> | ||
| 70 | <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>a1</p> | ||
| 71 | </td> | ||
| 72 | <td style='border-style:solid;border-color:#A3A3A3;border-width:1pt; | ||
| 73 | vertical-align:top;width:.7166in;padding:4pt 4pt 4pt 4pt'> | ||
| 74 | <p style='margin:0in;font-family:Calibri;font-size:11.0pt'><span | ||
| 75 | style='font-weight:bold'>b1</span></p> | ||
| 76 | </td> | ||
| 77 | </tr> | ||
| 78 | <tr> | ||
| 79 | <td style='border-style:solid;border-color:#A3A3A3;border-width:1pt; | ||
| 80 | vertical-align:top;width:.6673in;padding:4pt 4pt 4pt 4pt'> | ||
| 81 | <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>a2</p> | ||
| 82 | </td> | ||
| 83 | <td style='border-style:solid;border-color:#A3A3A3;border-width:1pt; | ||
| 84 | vertical-align:top;width:.7166in;padding:4pt 4pt 4pt 4pt'> | ||
| 85 | <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>b2</p> | ||
| 86 | </td> | ||
| 87 | </tr> | ||
| 88 | </table> | ||
| 89 | </div> | ||
| 90 | <p style='margin:0in;font-family:Calibri;font-size:11.0pt'>last line</p> | ||
| 91 | </ul> | ||
| 92 | |||
| 93 | <!--EndFragment--> | ||
| 94 | </body> | ||
| 95 | |||
| 96 | </html> | ||
crates/canvas/src/document.rs+50-34| ... | @@ -757,35 +757,45 @@ impl TextDocument { | ... | @@ -757,35 +757,45 @@ impl TextDocument { |
| 757 | Err(EditError::InvalidRange) | 757 | Err(EditError::InvalidRange) |
| 758 | } | 758 | } |
| 759 | 759 | ||
| 760 | pub(crate) fn slice(&self, range: Range<TextPosition>) -> Result<Vec<Paragraph>, EditError> { | 760 | /// The nodes `range` covers, its end paragraphs cut to it: those of its container from |
| 761 | if range.start > range.end { | 761 | /// one end to the other, or where it crosses a table's edge, the root nodes holding them. |
| 762 | return Err(EditError::InvalidRange); | 762 | pub(crate) fn selected( |
| 763 | } | 763 | &self, |
| 764 | let count = range | 764 | range: Range<TextPosition>, |
| 765 | .end | 765 | ) -> Result<Vec<PageParagraph>, EditError> { |
| 766 | .paragraph | 766 | let (start_container, start, first) = self |
| 767 | .checked_sub(range.start.paragraph) | 767 | .leaf(range.start.paragraph) |
| 768 | .and_then(|n| n.checked_add(1)) | ||
| 769 | .ok_or(EditError::InvalidRange)?; | 768 | .ok_or(EditError::InvalidRange)?; |
| 770 | let result = self | 769 | let (end_container, end, last) = self |
| 771 | .paragraphs() | 770 | .leaf(range.end.paragraph) |
| 772 | .skip(range.start.paragraph) | 771 | .ok_or(EditError::InvalidRange)?; |
| 773 | .take(count) | 772 | let (first, last) = (first.id, last.id); |
| 774 | .enumerate() | 773 | let (nodes, start, end) = if self.crosses_table(range.clone())? { |
| 775 | .map(|(index, paragraph)| { | 774 | let root = |container: Option<ExGuid>, index| match container { |
| 776 | let start = if index == 0 { range.start.offset } else { 0 }; | 775 | Some(cell) => self.root(cell), |
| 777 | let end = if index == count - 1 { | 776 | None => Ok(index), |
| 778 | range.end.offset | 777 | }; |
| 779 | } else { | 778 | ( |
| 780 | paragraph.utf16_offset(paragraph.text().len())? | 779 | &self.nodes[..], |
| 781 | }; | 780 | root(start_container, start)?, |
| 782 | paragraph.slice(start..end) | 781 | root(end_container, end)?, |
| 783 | }) | 782 | ) |
| 784 | .collect::<Result<Vec<_>, EditError>>()?; | 783 | } else { |
| 785 | if result.len() != count { | 784 | (self.container(start_container)?, start, end) |
| 786 | return Err(EditError::InvalidRange); | 785 | }; |
| 786 | let mut nodes = nodes[start..=end].to_vec(); | ||
| 787 | let tail = nodes.len() - 1; | ||
| 788 | if nodes[tail].id == last { | ||
| 789 | let from = if first == last { range.start.offset } else { 0 }; | ||
| 790 | let text = &mut nodes[tail].text_mut().unwrap().text; | ||
| 791 | *text = text.slice(from..range.end.offset)?; | ||
| 787 | } | 792 | } |
| 788 | Ok(result) | 793 | if nodes[0].id == first && first != last { |
| 794 | let text = &mut nodes[0].text_mut().unwrap().text; | ||
| 795 | let length = text.utf16_offset(text.text().len())?; | ||
| 796 | *text = text.slice(range.start.offset..length)?; | ||
| 797 | } | ||
| 798 | Ok(nodes) | ||
| 789 | } | 799 | } |
| 790 | 800 | ||
| 791 | /// Replaces `range` as OneNote's typing, Enter and deletion do: the first paragraph keeps | 801 | /// Replaces `range` as OneNote's typing, Enter and deletion do: the first paragraph keeps |
| ... | @@ -1332,14 +1342,20 @@ mod tests { | ... | @@ -1332,14 +1342,20 @@ mod tests { |
| 1332 | .collect::<Vec<_>>(), | 1342 | .collect::<Vec<_>>(), |
| 1333 | ["before", "a🌲b", "second", "right", "after"] | 1343 | ["before", "a🌲b", "second", "right", "after"] |
| 1334 | ); | 1344 | ); |
| 1345 | let shown = |nodes: Vec<PageParagraph>| { | ||
| 1346 | leaves(&nodes, None) | ||
| 1347 | .map(|(.., node)| node.text().unwrap().text.text().to_owned()) | ||
| 1348 | .collect::<Vec<_>>() | ||
| 1349 | }; | ||
| 1350 | let selected = |range| shown(document.selected(range).unwrap()); | ||
| 1351 | assert_eq!(selected(position(1, 1)..position(2, 3)), ["🌲b", "sec"]); | ||
| 1352 | assert_eq!( | ||
| 1353 | selected(position(1, 1)..position(3, 2)), | ||
| 1354 | ["a🌲b", "second", "right"] | ||
| 1355 | ); | ||
| 1335 | assert_eq!( | 1356 | assert_eq!( |
| 1336 | document | 1357 | selected(position(0, 2)..position(4, 1)), |
| 1337 | .slice(position(1, 1)..position(3, 2)) | 1358 | ["fore", "a🌲b", "second", "right", "a"] |
| 1338 | .unwrap() | ||
| 1339 | .iter() | ||
| 1340 | .map(Paragraph::text) | ||
| 1341 | .collect::<Vec<_>>(), | ||
| 1342 | ["🌲b", "second", "ri"] | ||
| 1343 | ); | 1359 | ); |
| 1344 | let edit = document | 1360 | let edit = document |
| 1345 | .replace( | 1361 | .replace( |
crates/canvas/src/editor.rs+9-7| ... | @@ -24,10 +24,12 @@ use std::{ | ... | @@ -24,10 +24,12 @@ use std::{ |
| 24 | 24 | ||
| 25 | pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0; | 25 | pub const DEFAULT_OUTLINE_WIDTH: f32 = 468.0; |
| 26 | 26 | ||
| 27 | mod clip; | ||
| 27 | mod equation; | 28 | mod equation; |
| 28 | #[cfg(test)] | 29 | #[cfg(test)] |
| 29 | mod evidence; | 30 | mod evidence; |
| 30 | mod format; | 31 | mod format; |
| 32 | mod html; | ||
| 31 | mod ink; | 33 | mod ink; |
| 32 | mod link; | 34 | mod link; |
| 33 | mod ops; | 35 | mod ops; |
| ... | @@ -35,10 +37,12 @@ pub(crate) mod page; | ... | @@ -35,10 +37,12 @@ pub(crate) mod page; |
| 35 | mod recording; | 37 | mod recording; |
| 36 | mod space; | 38 | mod space; |
| 37 | mod table; | 39 | mod table; |
| 40 | pub use clip::Clip; | ||
| 38 | pub use format::{ | 41 | pub use format::{ |
| 39 | Alignment, BULLET_LIBRARY, DefaultFont, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, | 42 | Alignment, BULLET_LIBRARY, DefaultFont, FormatState, Formatting, ListStyle, NUMBER_LIBRARY, |
| 40 | NoteTag, Toggle, | 43 | NoteTag, Toggle, |
| 41 | }; | 44 | }; |
| 45 | pub use html::html_pieces; | ||
| 42 | pub use ink::{FAVORITES, Pen}; | 46 | pub use ink::{FAVORITES, Pen}; |
| 43 | pub use link::{Link, shown_urls}; | 47 | pub use link::{Link, shown_urls}; |
| 44 | pub use page::ReadOnlyObject; | 48 | pub use page::ReadOnlyObject; |
| ... | @@ -84,7 +88,7 @@ pub struct Selection { | ... | @@ -84,7 +88,7 @@ pub struct Selection { |
| 84 | 88 | ||
| 85 | /// A piece of pasted content, in the order it comes. | 89 | /// A piece of pasted content, in the order it comes. |
| 86 | pub enum Piece { | 90 | pub enum Piece { |
| 87 | Text(String), | 91 | Clip(Clip), |
| 88 | /// An encoded picture and its size in points. | 92 | /// An encoded picture and its size in points. |
| 89 | Picture(Vec<u8>, [f32; 2]), | 93 | Picture(Vec<u8>, [f32; 2]), |
| 90 | /// A picture still on its way, which ends the paragraph and goes in where | 94 | /// A picture still on its way, which ends the paragraph and goes in where |
| ... | @@ -2333,20 +2337,19 @@ impl CanvasEditor { | ... | @@ -2333,20 +2337,19 @@ impl CanvasEditor { |
| 2333 | self.place_caret(engine, [x, margin[1] + row * 18.0], DEFAULT_OUTLINE_WIDTH) | 2337 | self.place_caret(engine, [x, margin[1] + row * 18.0], DEFAULT_OUTLINE_WIDTH) |
| 2334 | } | 2338 | } |
| 2335 | 2339 | ||
| 2336 | /// Pastes text in `language` and pictures in order, as one undo step, as OneNote 2010 | 2340 | /// Pastes clips and pictures in order, as one undo step, as OneNote 2010 pastes a web |
| 2337 | /// pastes a web page's text with its pictures; the places of the awaited ones, in order. | 2341 | /// page's text with its pictures; the places of the awaited ones, in order. |
| 2338 | pub fn paste_pieces( | 2342 | pub fn paste_pieces( |
| 2339 | &mut self, | 2343 | &mut self, |
| 2340 | engine: &mut TextEngine, | 2344 | engine: &mut TextEngine, |
| 2341 | pieces: Vec<Piece>, | 2345 | pieces: Vec<Piece>, |
| 2342 | language: u32, | ||
| 2343 | ) -> Result<Vec<Awaited>, EditorError> { | 2346 | ) -> Result<Vec<Awaited>, EditorError> { |
| 2344 | let depth = self.undo.len(); | 2347 | let depth = self.undo.len(); |
| 2345 | let mut result = Ok(()); | 2348 | let mut result = Ok(()); |
| 2346 | let mut awaited = Vec::new(); | 2349 | let mut awaited = Vec::new(); |
| 2347 | for piece in pieces { | 2350 | for piece in pieces { |
| 2348 | result = match piece { | 2351 | result = match piece { |
| 2349 | Piece::Text(text) => self.paste(engine, &text, language), | 2352 | Piece::Clip(clip) => self.paste_clip(engine, clip), |
| 2350 | Piece::Picture(bytes, size) => picture(bytes, size) | 2353 | Piece::Picture(bytes, size) => picture(bytes, size) |
| 2351 | .map_err(EditorError::from) | 2354 | .map_err(EditorError::from) |
| 2352 | .and_then(|image| self.insert_picture(engine, image)), | 2355 | .and_then(|image| self.insert_picture(engine, image)), |
| ... | @@ -10659,10 +10662,9 @@ mod tests { | ... | @@ -10659,10 +10662,9 @@ mod tests { |
| 10659 | .paste_pieces( | 10662 | .paste_pieces( |
| 10660 | &mut engine, | 10663 | &mut engine, |
| 10661 | vec![ | 10664 | vec![ |
| 10662 | Piece::Text("Pasted".into()), | 10665 | html_pieces("Pasted", 0x409, |_, _| None).pop().unwrap(), |
| 10663 | Piece::Picture(vec![0; 8], [10.0; 2]), | 10666 | Piece::Picture(vec![0; 8], [10.0; 2]), |
| 10664 | ], | 10667 | ], |
| 10665 | 0x409, | ||
| 10666 | ) | 10668 | ) |
| 10667 | .unwrap(); | 10669 | .unwrap(); |
| 10668 | assert_eq!(editor.history_depth(), [1, 0]); | 10670 | assert_eq!(editor.history_depth(), [1, 0]); |
crates/canvas/src/editor/clip.rs created+548| ... | @@ -0,0 +1,548 @@ | ||
| 1 | //! Copy and Paste of formatted content: a selection's paragraphs with the definitions they | ||
| 2 | //! name, which hosts put on the clipboard in Snowbound's own format beside HTML and text. | ||
| 3 | |||
| 4 | use super::*; | ||
| 5 | use onestore::page::text::new_id; | ||
| 6 | |||
| 7 | /// Paragraphs copied from a page, in document order with each parent before its children; | ||
| 8 | /// levels count from 1 and a paragraph whose parent was not copied has none. | ||
| 9 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | ||
| 10 | pub struct Clip { | ||
| 11 | pub paragraphs: Vec<PageParagraph>, | ||
| 12 | /// The styles, lists and tags the paragraphs name. | ||
| 13 | pub definitions: BTreeMap<ExGuid, Definition>, | ||
| 14 | } | ||
| 15 | |||
| 16 | impl Clip { | ||
| 17 | pub(crate) fn new( | ||
| 18 | mut paragraphs: Vec<PageParagraph>, | ||
| 19 | definitions: &BTreeMap<ExGuid, Definition>, | ||
| 20 | ) -> Self { | ||
| 21 | let held: BTreeSet<ExGuid> = paragraphs.iter().map(|node| node.id).collect(); | ||
| 22 | let shift = paragraphs.iter().map(|node| node.level).min().unwrap_or(1) - 1; | ||
| 23 | for node in &mut paragraphs { | ||
| 24 | node.parent = node.parent.filter(|parent| held.contains(parent)); | ||
| 25 | node.level -= shift; | ||
| 26 | } | ||
| 27 | Self { | ||
| 28 | definitions: ops::named(definitions, &[&paragraphs]), | ||
| 29 | paragraphs, | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | /// The text as shown, a line to each paragraph, table cells included. | ||
| 34 | pub fn text(&self) -> String { | ||
| 35 | leaves(&self.paragraphs, None) | ||
| 36 | .map(|(.., node)| { | ||
| 37 | let text = &node.text().unwrap().text; | ||
| 38 | text.project().map_or_else( | ||
| 39 | |_| text.text().to_owned(), | ||
| 40 | |shown| shown.text().text().to_owned(), | ||
| 41 | ) | ||
| 42 | }) | ||
| 43 | .collect::<Vec<_>>() | ||
| 44 | .join("\n") | ||
| 45 | } | ||
| 46 | |||
| 47 | /// Snowbound's own clipboard format. | ||
| 48 | pub fn encode(&self) -> String { | ||
| 49 | serde_json::to_string(self).expect("a clip serializes") | ||
| 50 | } | ||
| 51 | |||
| 52 | /// None for a clip another version of Snowbound wrote in a shape this one can't read. | ||
| 53 | pub fn decode(json: &str) -> Option<Self> { | ||
| 54 | serde_json::from_str(json).ok() | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | impl CanvasEditor { | ||
| 59 | /// What Copy takes: the selection's paragraphs with the first and last cut to it, or the | ||
| 60 | /// page selection's outlines top to bottom with an empty paragraph between, as OneNote | ||
| 61 | /// 2010 copies them. None where nothing shows. | ||
| 62 | pub fn clip(&self) -> Result<Option<Clip>, EditError> { | ||
| 63 | let paragraphs = if self.page_selected() { | ||
| 64 | let mut outlines = self | ||
| 65 | .outlines | ||
| 66 | .iter() | ||
| 67 | .filter(|outline| !outline.title) | ||
| 68 | .collect::<Vec<_>>(); | ||
| 69 | outlines.sort_by(|a, b| { | ||
| 70 | let ([ax, ay], [bx, by]) = (a.origin(), b.origin()); | ||
| 71 | ay.total_cmp(&by).then(ax.total_cmp(&bx)) | ||
| 72 | }); | ||
| 73 | let mut paragraphs = Vec::new(); | ||
| 74 | for outline in outlines { | ||
| 75 | if !paragraphs.is_empty() { | ||
| 76 | let empty = Paragraph::new(String::new(), Format::default()); | ||
| 77 | paragraphs.push(crate::document::node(empty, Format::default())?); | ||
| 78 | } | ||
| 79 | paragraphs.extend_from_slice(outline.document.nodes()); | ||
| 80 | } | ||
| 81 | paragraphs | ||
| 82 | } else { | ||
| 83 | let [anchor, focus] = self.selection().positions; | ||
| 84 | self.active_outline() | ||
| 85 | .document | ||
| 86 | .selected(anchor.min(focus)..anchor.max(focus))? | ||
| 87 | }; | ||
| 88 | let clip = Clip::new(paragraphs, &self.definitions); | ||
| 89 | Ok((!clip.text().is_empty()).then_some(clip)) | ||
| 90 | } | ||
| 91 | |||
| 92 | /// Pastes `clip` as one undo step: one paragraph's runs go in at the selection; more | ||
| 93 | /// paragraphs go between the halves of the caret's paragraph, each keeping its | ||
| 94 | /// formatting, style, list, tags and indentation below that paragraph's. A title takes | ||
| 95 | /// the text alone. | ||
| 96 | pub fn paste_clip(&mut self, engine: &mut TextEngine, clip: Clip) -> Result<(), EditorError> { | ||
| 97 | if self.page_selected() { | ||
| 98 | return self.grouped(|editor| { | ||
| 99 | editor.remove_page(engine, true)?; | ||
| 100 | editor.paste_clip(engine, clip) | ||
| 101 | }); | ||
| 102 | } | ||
| 103 | if self.active_outline().title { | ||
| 104 | // A title takes text alone, as OneNote 2010's does (lab, 2026-10-02). | ||
| 105 | let language = leaves(&clip.paragraphs, None) | ||
| 106 | .find_map(|(.., node)| node.text()?.text.spans()[0].format.language) | ||
| 107 | .unwrap_or(0x409); | ||
| 108 | return self.paste(engine, &clip.text(), language); | ||
| 109 | } | ||
| 110 | self.take_objects()?; | ||
| 111 | if let Some(pasted) = self.across(engine, |editor, engine| { | ||
| 112 | editor.paste_clip(engine, clip.clone()) | ||
| 113 | })? { | ||
| 114 | return Ok(pasted); | ||
| 115 | } | ||
| 116 | let mut nodes = self.admit(clip)?; | ||
| 117 | if nodes.is_empty() { | ||
| 118 | return Ok(()); | ||
| 119 | } | ||
| 120 | if let [node] = &nodes[..] | ||
| 121 | && let Some(text) = node.text() | ||
| 122 | { | ||
| 123 | return self.replace(engine, vec![text.text.clone()]); | ||
| 124 | } | ||
| 125 | let [anchor, focus] = self.active_outline().selection.positions; | ||
| 126 | let (start, end) = (anchor.min(focus), anchor.max(focus)); | ||
| 127 | let edge = Paragraph::new(String::new(), self.typing_format(start)?); | ||
| 128 | let texts = nodes.iter().map(|node| { | ||
| 129 | node.text() | ||
| 130 | .map_or_else(|| edge.clone(), |text| text.text.clone()) | ||
| 131 | }); | ||
| 132 | let replacement = std::iter::once(edge.clone()) | ||
| 133 | .chain(texts) | ||
| 134 | .chain([edge.clone()]) | ||
| 135 | .collect(); | ||
| 136 | let mut edit = self | ||
| 137 | .active_outline() | ||
| 138 | .document | ||
| 139 | .replace(start..end, replacement)?; | ||
| 140 | let head = &edit.replacement[0]; | ||
| 141 | let (parent, level) = (head.parent, head.level); | ||
| 142 | for node in &mut nodes { | ||
| 143 | if node.parent.is_none() { | ||
| 144 | node.parent = parent; | ||
| 145 | } | ||
| 146 | node.level += level - 1; | ||
| 147 | } | ||
| 148 | let ends_in_text = nodes.last().is_some_and(|node| node.text().is_some()); | ||
| 149 | let last = nodes | ||
| 150 | .last() | ||
| 151 | .and_then(|node| node.text()) | ||
| 152 | .map_or(Ok(0), |text| text.text.utf16_offset(text.text.text().len()))?; | ||
| 153 | let count = nodes.len(); | ||
| 154 | let added = leaves(&nodes, None).count(); | ||
| 155 | edit.replacement.splice(1..=count, nodes); | ||
| 156 | // A half of the caret's paragraph goes where empty, as OneNote 2010 pastes (lab, | ||
| 157 | // 2026-10-02), unless it holds children or the caret. | ||
| 158 | let gone = |edit: &DocumentEdit, at: usize| { | ||
| 159 | let node = &edit.replacement[at]; | ||
| 160 | node.text().is_some_and(|text| text.text.text().is_empty()) | ||
| 161 | && !edit | ||
| 162 | .replacement | ||
| 163 | .iter() | ||
| 164 | .any(|child| child.parent == Some(node.id)) | ||
| 165 | }; | ||
| 166 | if ends_in_text && gone(&edit, count + 1) { | ||
| 167 | edit.replacement.remove(count + 1); | ||
| 168 | } | ||
| 169 | let head = gone(&edit, 0); | ||
| 170 | if head { | ||
| 171 | edit.replacement.remove(0); | ||
| 172 | } | ||
| 173 | let paragraph = start.paragraph + added - usize::from(head); | ||
| 174 | let caret = if ends_in_text { | ||
| 175 | TextPosition { | ||
| 176 | paragraph, | ||
| 177 | offset: last, | ||
| 178 | } | ||
| 179 | } else { | ||
| 180 | TextPosition { | ||
| 181 | paragraph: paragraph + 1, | ||
| 182 | offset: 0, | ||
| 183 | } | ||
| 184 | }; | ||
| 185 | self.commit(engine, edit, [caret; 2].into()) | ||
| 186 | } | ||
| 187 | |||
| 188 | /// `clip`'s paragraphs under identities of their own, naming this page's definitions: | ||
| 189 | /// a style or tag the page already has, else one added beside its own. | ||
| 190 | fn admit(&mut self, clip: Clip) -> Result<Vec<PageParagraph>, EditError> { | ||
| 191 | let mut renamed = BTreeMap::new(); | ||
| 192 | for (id, definition) in clip.definitions { | ||
| 193 | let own = match definition.kind { | ||
| 194 | Kind::Style { .. } => self.define_style(&definition)?, | ||
| 195 | Kind::TagDefinition { .. } => self.define_tag(&definition)?, | ||
| 196 | _ => { | ||
| 197 | let own = new_id()?; | ||
| 198 | self.definitions.insert(own, definition); | ||
| 199 | own | ||
| 200 | } | ||
| 201 | }; | ||
| 202 | renamed.insert(id, own); | ||
| 203 | } | ||
| 204 | let mut nodes = clip.paragraphs; | ||
| 205 | renew(&mut nodes, &renamed, &self.active_outline().indents)?; | ||
| 206 | Ok(nodes) | ||
| 207 | } | ||
| 208 | } | ||
| 209 | |||
| 210 | /// Gives `nodes` and everything they hold new identities, keeping their tree, and points | ||
| 211 | /// their definitions at `renamed`'s, dropping what names none; a cell without indents takes | ||
| 212 | /// `indents`. | ||
| 213 | fn renew( | ||
| 214 | nodes: &mut Vec<PageParagraph>, | ||
| 215 | renamed: &BTreeMap<ExGuid, ExGuid>, | ||
| 216 | indents: &[f32], | ||
| 217 | ) -> Result<(), EditError> { | ||
| 218 | let tags = |tags: &mut Vec<onestore::document::Tag>| { | ||
| 219 | tags.retain_mut(|tag| match tag.definition { | ||
| 220 | Some(id) => renamed | ||
| 221 | .get(&id) | ||
| 222 | .map(|own| tag.definition = Some(*own)) | ||
| 223 | .is_some(), | ||
| 224 | None => true, | ||
| 225 | }); | ||
| 226 | }; | ||
| 227 | // What a clip from elsewhere holds without its bytes can't be pasted; their | ||
| 228 | // children lose their parent. | ||
| 229 | nodes.retain(|node| match &node.content { | ||
| 230 | ParagraphContent::Image(image) => image.bytes.is_some(), | ||
| 231 | ParagraphContent::Attachment(file) => file.bytes.is_some(), | ||
| 232 | ParagraphContent::Unsupported(_) => false, | ||
| 233 | _ => true, | ||
| 234 | }); | ||
| 235 | let mut parents = BTreeMap::new(); | ||
| 236 | for node in nodes.iter_mut() { | ||
| 237 | let id = new_id()?; | ||
| 238 | parents.insert(node.id, id); | ||
| 239 | node.id = id; | ||
| 240 | node.parent = node.parent.and_then(|parent| parents.get(&parent).copied()); | ||
| 241 | node.style = node.style.and_then(|style| renamed.get(&style).copied()); | ||
| 242 | node.lists = node | ||
| 243 | .lists | ||
| 244 | .iter() | ||
| 245 | .filter_map(|list| renamed.get(list).copied()) | ||
| 246 | .collect(); | ||
| 247 | tags(&mut node.tags); | ||
| 248 | node.media = Default::default(); | ||
| 249 | match &mut node.content { | ||
| 250 | ParagraphContent::Text(text) => { | ||
| 251 | text.id = new_id()?; | ||
| 252 | text.date_field = None; | ||
| 253 | tags(&mut text.tags); | ||
| 254 | } | ||
| 255 | ParagraphContent::Image(image) => { | ||
| 256 | image.id = new_id()?; | ||
| 257 | tags(&mut image.tags); | ||
| 258 | } | ||
| 259 | ParagraphContent::Attachment(file) => file.id = new_id()?, | ||
| 260 | ParagraphContent::Ink(ink) => ink.id = new_id()?, | ||
| 261 | ParagraphContent::Unsupported(unsupported) => unsupported.id = new_id()?, | ||
| 262 | ParagraphContent::Table(table) => { | ||
| 263 | table.id = new_id()?; | ||
| 264 | tags(&mut table.tags); | ||
| 265 | for row in &mut table.rows { | ||
| 266 | row.id = new_id()?; | ||
| 267 | for cell in &mut row.cells { | ||
| 268 | cell.id = new_id()?; | ||
| 269 | if cell.indents.is_empty() { | ||
| 270 | cell.indents = indents.to_vec(); | ||
| 271 | } | ||
| 272 | renew(&mut cell.paragraphs, renamed, indents)?; | ||
| 273 | } | ||
| 274 | } | ||
| 275 | } | ||
| 276 | } | ||
| 277 | } | ||
| 278 | Ok(()) | ||
| 279 | } | ||
| 280 | |||
| 281 | #[cfg(test)] | ||
| 282 | impl Clip { | ||
| 283 | /// A line to each paragraph: its indentation, list, runs with their formatting, links, | ||
| 284 | /// tags and style, then each table's cells below it. | ||
| 285 | pub(super) fn outline(&self) -> Vec<String> { | ||
| 286 | let mut lines = Vec::new(); | ||
| 287 | outline(&self.paragraphs, &self.definitions, "", &mut lines); | ||
| 288 | lines | ||
| 289 | } | ||
| 290 | } | ||
| 291 | |||
| 292 | #[cfg(test)] | ||
| 293 | fn outline( | ||
| 294 | nodes: &[PageParagraph], | ||
| 295 | definitions: &BTreeMap<ExGuid, Definition>, | ||
| 296 | prefix: &str, | ||
| 297 | lines: &mut Vec<String>, | ||
| 298 | ) { | ||
| 299 | use super::format::AUTOMATIC; | ||
| 300 | for node in nodes { | ||
| 301 | let mut line = format!("{prefix}{}", " ".repeat(node.level as usize - 1)); | ||
| 302 | match node.lists.last().map(|list| &definitions[list].kind) { | ||
| 303 | Some(Kind::List { | ||
| 304 | bullet: Some(_), .. | ||
| 305 | }) => line.push_str("• "), | ||
| 306 | Some(kind @ Kind::List { .. }) => { | ||
| 307 | let (_, kind) = super::html::list_tag(kind); | ||
| 308 | line.push_str(&format!("{kind}. ")); | ||
| 309 | } | ||
| 310 | _ => {} | ||
| 311 | } | ||
| 312 | let mut tags = node.tags.clone(); | ||
| 313 | match &node.content { | ||
| 314 | ParagraphContent::Text(text) => { | ||
| 315 | tags.extend(text.tags.iter().cloned()); | ||
| 316 | let shown = text.text.project().unwrap(); | ||
| 317 | let shown = shown.text(); | ||
| 318 | let mut byte = 0; | ||
| 319 | for span in shown.spans() { | ||
| 320 | let fragment = &shown.text()[byte..span.end]; | ||
| 321 | byte = span.end; | ||
| 322 | let format = &span.format; | ||
| 323 | let mut marks = Vec::new(); | ||
| 324 | for (set, mark) in [ | ||
| 325 | (format.bold, "b"), | ||
| 326 | (format.italic, "i"), | ||
| 327 | (format.underline, "u"), | ||
| 328 | (format.strike, "s"), | ||
| 329 | (format.superscript, "sup"), | ||
| 330 | (format.subscript, "sub"), | ||
| 331 | (format.hyperlink, "link"), | ||
| 332 | ] { | ||
| 333 | if set == Some(true) { | ||
| 334 | marks.push(mark.to_owned()); | ||
| 335 | } | ||
| 336 | } | ||
| 337 | if let Some(color) = format.color.filter(|color| *color != AUTOMATIC) { | ||
| 338 | marks.push(format!("color={color:06x}")); | ||
| 339 | } | ||
| 340 | if let Some(color) = format.highlight.filter(|color| *color != AUTOMATIC) { | ||
| 341 | marks.push(format!("highlight={color:06x}")); | ||
| 342 | } | ||
| 343 | if let Some(font) = format.font.as_deref().filter(|font| *font != "Calibri") { | ||
| 344 | marks.push(format!("font={font}")); | ||
| 345 | } | ||
| 346 | if let Some(size) = format.font_size.filter(|size| *size != 11.0) { | ||
| 347 | marks.push(format!("size={size}")); | ||
| 348 | } | ||
| 349 | if marks.is_empty() { | ||
| 350 | line.push_str(fragment); | ||
| 351 | } else { | ||
| 352 | line.push_str(&format!("<{}>{fragment}</>", marks.join(" "))); | ||
| 353 | } | ||
| 354 | } | ||
| 355 | for link in super::link::links(&text.text, 0) { | ||
| 356 | line.push_str(&format!(" -> {}", link.target)); | ||
| 357 | } | ||
| 358 | } | ||
| 359 | ParagraphContent::Table(table) => { | ||
| 360 | line.push_str(&format!( | ||
| 361 | "table {}x{}", | ||
| 362 | table.rows.len(), | ||
| 363 | table.columns.len() | ||
| 364 | )); | ||
| 365 | } | ||
| 366 | ParagraphContent::Image(image) => line.push_str(&format!("picture {:?}", image.size)), | ||
| 367 | _ => line.push_str("other"), | ||
| 368 | } | ||
| 369 | for tag in &tags { | ||
| 370 | if let Some(Kind::TagDefinition { label, .. }) = | ||
| 371 | tag.definition.map(|id| &definitions[&id].kind) | ||
| 372 | { | ||
| 373 | line.push_str(&format!(" [{}]", label.as_deref().unwrap_or(""))); | ||
| 374 | } | ||
| 375 | } | ||
| 376 | if let Some(Kind::Style { | ||
| 377 | name: Some(name), .. | ||
| 378 | }) = node.style.map(|id| &definitions[&id].kind) | ||
| 379 | { | ||
| 380 | line.push_str(&format!(" ({name})")); | ||
| 381 | } | ||
| 382 | lines.push(line); | ||
| 383 | if let ParagraphContent::Table(table) = &node.content { | ||
| 384 | for (row, cells) in table.rows.iter().enumerate() { | ||
| 385 | for (column, cell) in cells.cells.iter().enumerate() { | ||
| 386 | let prefix = format!("{prefix} | {row},{column}: "); | ||
| 387 | outline(&cell.paragraphs, definitions, &prefix, lines); | ||
| 388 | } | ||
| 389 | } | ||
| 390 | } | ||
| 391 | } | ||
| 392 | } | ||
| 393 | |||
| 394 | #[cfg(test)] | ||
| 395 | mod tests { | ||
| 396 | use super::*; | ||
| 397 | use crate::editor::format::{NoteTag, Toggle}; | ||
| 398 | use crate::editor::{Formatting, html_pieces}; | ||
| 399 | |||
| 400 | fn at(paragraph: usize, offset: u32) -> TextPosition { | ||
| 401 | TextPosition { paragraph, offset } | ||
| 402 | } | ||
| 403 | |||
| 404 | fn editor(engine: &mut TextEngine, lines: &[&str]) -> CanvasEditor { | ||
| 405 | let paragraphs = lines | ||
| 406 | .iter() | ||
| 407 | .map(|line| { | ||
| 408 | Paragraph::new( | ||
| 409 | (*line).to_owned(), | ||
| 410 | Format { | ||
| 411 | font: Some("Calibri".into()), | ||
| 412 | font_size: Some(11.0), | ||
| 413 | ..Format::default() | ||
| 414 | }, | ||
| 415 | ) | ||
| 416 | }) | ||
| 417 | .collect(); | ||
| 418 | CanvasEditor::new(engine, TextDocument::new(paragraphs).unwrap(), 400.0).unwrap() | ||
| 419 | } | ||
| 420 | |||
| 421 | fn page(editor: &mut CanvasEditor) -> Clip { | ||
| 422 | editor.select_page().unwrap(); | ||
| 423 | let clip = editor.clip().unwrap().unwrap(); | ||
| 424 | editor.select([at(0, 0); 2].into()).unwrap(); | ||
| 425 | clip | ||
| 426 | } | ||
| 427 | |||
| 428 | /// The report's case: a bold line copied, through Snowbound's own format, into a new | ||
| 429 | /// paragraph stays bold. | ||
| 430 | #[test] | ||
| 431 | fn a_copied_bold_line_pastes_bold() { | ||
| 432 | let mut engine = TextEngine::default(); | ||
| 433 | let mut editor = editor(&mut engine, &["Bold line", ""]); | ||
| 434 | editor.select([at(0, 0), at(0, 9)].into()).unwrap(); | ||
| 435 | editor | ||
| 436 | .format(&mut engine, Formatting::Toggle(Toggle::Bold)) | ||
| 437 | .unwrap(); | ||
| 438 | let clip = editor.clip().unwrap().unwrap(); | ||
| 439 | assert_eq!(clip.text(), "Bold line"); | ||
| 440 | let clip = Clip::decode(&clip.encode()).unwrap(); | ||
| 441 | editor.select([at(1, 0); 2].into()).unwrap(); | ||
| 442 | editor.paste_clip(&mut engine, clip).unwrap(); | ||
| 443 | assert_eq!(editor.selection().positions, [at(1, 9); 2]); | ||
| 444 | assert_eq!( | ||
| 445 | page(&mut editor).outline(), | ||
| 446 | ["<b>Bold line</>", "<b>Bold line</>"] | ||
| 447 | ); | ||
| 448 | } | ||
| 449 | |||
| 450 | /// Part of a paragraph copies only what is selected, and pastes into the middle of | ||
| 451 | /// another's text. | ||
| 452 | #[test] | ||
| 453 | fn a_partial_selection_pastes_inline() { | ||
| 454 | let mut engine = TextEngine::default(); | ||
| 455 | let mut editor = editor(&mut engine, &["one two three", "ab"]); | ||
| 456 | editor.select([at(0, 4), at(0, 7)].into()).unwrap(); | ||
| 457 | editor | ||
| 458 | .format(&mut engine, Formatting::Toggle(Toggle::Italic)) | ||
| 459 | .unwrap(); | ||
| 460 | let clip = editor.clip().unwrap().unwrap(); | ||
| 461 | editor.select([at(1, 1); 2].into()).unwrap(); | ||
| 462 | editor.paste_clip(&mut engine, clip).unwrap(); | ||
| 463 | assert_eq!( | ||
| 464 | page(&mut editor).outline(), | ||
| 465 | ["one <i>two</> three", "a<i>two</>b"] | ||
| 466 | ); | ||
| 467 | } | ||
| 468 | |||
| 469 | /// Styles, lists, indentation, tags and tables go through Snowbound's own format to | ||
| 470 | /// another page between the halves of the caret's paragraph, as one undo step. | ||
| 471 | #[test] | ||
| 472 | fn a_clip_carries_styles_lists_tags_and_tables_to_another_page() { | ||
| 473 | let mut engine = TextEngine::default(); | ||
| 474 | let mut source = editor(&mut engine, &["Heading", "item one", "child", "tagged"]); | ||
| 475 | let heading = Definition { | ||
| 476 | kind: Kind::Style { | ||
| 477 | name: Some("h1".into()), | ||
| 478 | next: None, | ||
| 479 | }, | ||
| 480 | format: Format { | ||
| 481 | bold: Some(true), | ||
| 482 | font: Some("Calibri".into()), | ||
| 483 | font_size: Some(16.0), | ||
| 484 | color: Some(0x0060_3a1f), | ||
| 485 | ..Format::default() | ||
| 486 | }, | ||
| 487 | }; | ||
| 488 | let mut apply = |editor: &mut CanvasEditor, selection: [TextPosition; 2], command| { | ||
| 489 | editor.select(selection.into()).unwrap(); | ||
| 490 | editor.format(&mut engine, command).unwrap(); | ||
| 491 | }; | ||
| 492 | apply(&mut source, [at(0, 0); 2], Formatting::Style(heading)); | ||
| 493 | apply(&mut source, [at(1, 0), at(2, 5)], Formatting::Bullets); | ||
| 494 | apply(&mut source, [at(2, 2); 2], Formatting::Indent); | ||
| 495 | apply( | ||
| 496 | &mut source, | ||
| 497 | [at(3, 0); 2], | ||
| 498 | Formatting::Tag(NoteTag::defaults()[0].clone(), 0), | ||
| 499 | ); | ||
| 500 | source.select([at(3, 6); 2].into()).unwrap(); | ||
| 501 | source.insert_table(&mut engine, 2, 2).unwrap(); | ||
| 502 | source.insert(&mut engine, "a1").unwrap(); | ||
| 503 | let copied = page(&mut source); | ||
| 504 | let expected = [ | ||
| 505 | "<b color=603a1f size=16>Heading</> (h1)", | ||
| 506 | " • item one", | ||
| 507 | " • child", | ||
| 508 | "tagged [To Do]", | ||
| 509 | "table 2x2", | ||
| 510 | " | 0,0: a1", | ||
| 511 | " | 0,1: ", | ||
| 512 | " | 1,0: ", | ||
| 513 | " | 1,1: ", | ||
| 514 | ]; | ||
| 515 | assert_eq!(copied.outline(), expected); | ||
| 516 | |||
| 517 | let mut target = editor(&mut engine, &["before after"]); | ||
| 518 | target.select([at(0, 7); 2].into()).unwrap(); | ||
| 519 | let clip = Clip::decode(&copied.encode()).unwrap(); | ||
| 520 | target.paste_clip(&mut engine, clip).unwrap(); | ||
| 521 | let pasted = page(&mut target).outline(); | ||
| 522 | assert_eq!(pasted[0], "before "); | ||
| 523 | assert_eq!(pasted[1..pasted.len() - 1], expected); | ||
| 524 | assert_eq!(pasted.last().unwrap(), "after"); | ||
| 525 | target.page().unwrap(); | ||
| 526 | assert!(target.undo(&mut engine).unwrap()); | ||
| 527 | assert_eq!(page(&mut target).outline(), ["before after"]); | ||
| 528 | } | ||
| 529 | |||
| 530 | /// A page's HTML pasted into an empty paragraph takes its place, keeping its formatting | ||
| 531 | /// and lists, as OneNote 2010 pastes (lab, 2026-10-02). | ||
| 532 | #[test] | ||
| 533 | fn pasted_html_keeps_formatting_and_lists() { | ||
| 534 | let mut engine = TextEngine::default(); | ||
| 535 | let mut editor = editor(&mut engine, &[""]); | ||
| 536 | let pieces = html_pieces( | ||
| 537 | "<p><b>Bold</b> and <span style='color:#C00000'>red</span></p>\ | ||
| 538 | <ul><li>one<ul><li>two</li></ul></li></ul>", | ||
| 539 | 0x409, | ||
| 540 | |_, _| None, | ||
| 541 | ); | ||
| 542 | editor.paste_pieces(&mut engine, pieces).unwrap(); | ||
| 543 | assert_eq!( | ||
| 544 | page(&mut editor).outline(), | ||
| 545 | ["<b>Bold</> and <color=0000c0>red</>", "• one", " • two"] | ||
| 546 | ); | ||
| 547 | } | ||
| 548 | } | ||
crates/canvas/src/editor/format.rs+3-3| ... | @@ -8,7 +8,7 @@ use onestore::page::text::new_id; | ... | @@ -8,7 +8,7 @@ use onestore::page::text::new_id; |
| 8 | use serde::{Deserialize, Serialize}; | 8 | use serde::{Deserialize, Serialize}; |
| 9 | 9 | ||
| 10 | /// The COLORREF OneNote stores for automatic colour, which overrides a style's colour. | 10 | /// The COLORREF OneNote stores for automatic colour, which overrides a style's colour. |
| 11 | const AUTOMATIC: u32 = 0xff00_0000; | 11 | pub(super) const AUTOMATIC: u32 = 0xff00_0000; |
| 12 | /// To Do's check box. | 12 | /// To Do's check box. |
| 13 | const CHECKBOX: u16 = 3; | 13 | const CHECKBOX: u16 = 3; |
| 14 | 14 | ||
| ... | @@ -143,7 +143,7 @@ impl ListStyle { | ... | @@ -143,7 +143,7 @@ impl ListStyle { |
| 143 | pub(super) const NUMBER: Self = Self::Number(0); | 143 | pub(super) const NUMBER: Self = Self::Number(0); |
| 144 | 144 | ||
| 145 | /// The library style a stored list has, if it is one. | 145 | /// The library style a stored list has, if it is one. |
| 146 | fn of(kind: &Kind) -> Option<Self> { | 146 | pub(super) fn of(kind: &Kind) -> Option<Self> { |
| 147 | let Kind::List { | 147 | let Kind::List { |
| 148 | font, | 148 | font, |
| 149 | format: Some(format), | 149 | format: Some(format), |
| ... | @@ -1010,7 +1010,7 @@ impl CanvasEditor { | ... | @@ -1010,7 +1010,7 @@ impl CanvasEditor { |
| 1010 | } | 1010 | } |
| 1011 | 1011 | ||
| 1012 | /// The page's definition of the tag `definition` describes, made where it has none. | 1012 | /// The page's definition of the tag `definition` describes, made where it has none. |
| 1013 | fn define_tag(&mut self, definition: &Definition) -> Result<ExGuid, EditError> { | 1013 | pub(super) fn define_tag(&mut self, definition: &Definition) -> Result<ExGuid, EditError> { |
| 1014 | if let Some((id, _)) = self | 1014 | if let Some((id, _)) = self |
| 1015 | .definitions | 1015 | .definitions |
| 1016 | .iter() | 1016 | .iter() |
crates/canvas/src/editor/html.rs created+1181| ... | @@ -0,0 +1,1181 @@ | ||
| 1 | //! Clips as HTML, both ways. Copy writes what OneNote 2010 puts on the clipboard (lab, | ||
| 2 | //! 2026-10-02): every run's formatting inline on a `span`, lists as `ul` and `ol` nested by | ||
| 3 | //! level, tables bordered, pictures and note tags left out. Paste reads that and what other | ||
| 4 | //! applications write into paragraphs, lists, tables and pictures. | ||
| 5 | |||
| 6 | use super::Piece; | ||
| 7 | use super::clip::Clip; | ||
| 8 | use super::format::{AUTOMATIC, ListStyle, NUMBER_LIBRARY, list_definition}; | ||
| 9 | use super::link::{field_code, links}; | ||
| 10 | use super::table::COLUMN_WIDTH; | ||
| 11 | use crate::document::node; | ||
| 12 | use crate::layout::{DEFAULT_FONT, DEFAULT_FONT_SIZE}; | ||
| 13 | use onestore::ExGuid; | ||
| 14 | use onestore::document::{Format, Kind}; | ||
| 15 | use onestore::page::text::{EditError, Paragraph, new_id}; | ||
| 16 | use onestore::page::{ | ||
| 17 | Definition, PageParagraph, ParagraphContent, Table, TableCell, TableColumn, TableRow, | ||
| 18 | }; | ||
| 19 | use std::collections::BTreeMap; | ||
| 20 | use std::fmt::Write; | ||
| 21 | |||
| 22 | impl Clip { | ||
| 23 | /// The clip as a web page whose fragment the comments OneNote's mark out hold. | ||
| 24 | pub fn html(&self) -> String { | ||
| 25 | let mut body = String::new(); | ||
| 26 | blocks(&self.paragraphs, &self.definitions, &mut body); | ||
| 27 | format!( | ||
| 28 | "<html>\n<head>\n<meta http-equiv=Content-Type content=\"text/html; charset=utf-8\">\n\ | ||
| 29 | <meta name=Generator content=\"Snowbound\">\n</head>\n<body>\n\ | ||
| 30 | <!--StartFragment-->\n{body}<!--EndFragment-->\n</body>\n</html>\n" | ||
| 31 | ) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | /// `nodes` as blocks: a list opens at a paragraph whose list differs from the one open at | ||
| 36 | /// its level, and a block's left margin steps it in from the list it sits in, which OneNote | ||
| 37 | /// and Word take its level from. | ||
| 38 | fn blocks(nodes: &[PageParagraph], definitions: &BTreeMap<ExGuid, Definition>, out: &mut String) { | ||
| 39 | let mut open: Vec<(u32, &str)> = Vec::new(); | ||
| 40 | for node in nodes { | ||
| 41 | let list = node | ||
| 42 | .lists | ||
| 43 | .last() | ||
| 44 | .and_then(|id| definitions.get(id)) | ||
| 45 | .filter(|definition| matches!(definition.kind, Kind::List { .. })) | ||
| 46 | .map(|definition| list_tag(&definition.kind)); | ||
| 47 | while let Some(&(level, tag)) = open.last() | ||
| 48 | && (level > node.level || level == node.level && list.map(|(tag, _)| tag) != Some(tag)) | ||
| 49 | { | ||
| 50 | let _ = writeln!(out, "</{tag}>"); | ||
| 51 | open.pop(); | ||
| 52 | } | ||
| 53 | // A level's step in, past the list it sits in, as OneNote indents its nested lists. | ||
| 54 | let indent = (node.level - open.last().map_or(1, |(level, _)| *level)) as f32 * 0.375; | ||
| 55 | match (list, &node.content) { | ||
| 56 | (Some((tag, kind)), ParagraphContent::Text(text)) => { | ||
| 57 | if open.last().map(|(level, _)| *level) != Some(node.level) { | ||
| 58 | let _ = writeln!( | ||
| 59 | out, | ||
| 60 | "<{tag} type={kind} style='margin-left:{indent}in;margin-top:0in;margin-bottom:0in'>" | ||
| 61 | ); | ||
| 62 | open.push((node.level, tag)); | ||
| 63 | } | ||
| 64 | out.push_str("<li style='margin-top:0;margin-bottom:0'>"); | ||
| 65 | runs(&text.text, out); | ||
| 66 | out.push_str("</li>\n"); | ||
| 67 | } | ||
| 68 | (_, ParagraphContent::Text(text)) => { | ||
| 69 | let _ = write!(out, "<p style='margin:0in;margin-left:{indent}in'>"); | ||
| 70 | runs(&text.text, out); | ||
| 71 | out.push_str("</p>\n"); | ||
| 72 | } | ||
| 73 | (_, ParagraphContent::Table(table)) => { | ||
| 74 | out.push_str( | ||
| 75 | "<table border=1 cellpadding=0 cellspacing=0 style='border-collapse:collapse;\ | ||
| 76 | border-style:solid;border-color:#A3A3A3;border-width:1pt'>\n", | ||
| 77 | ); | ||
| 78 | for row in &table.rows { | ||
| 79 | out.push_str("<tr>\n"); | ||
| 80 | for (cell, column) in row.cells.iter().zip(&table.columns) { | ||
| 81 | let _ = writeln!( | ||
| 82 | out, | ||
| 83 | "<td style='border-style:solid;border-color:#A3A3A3;border-width:1pt;\ | ||
| 84 | vertical-align:top;width:{}pt;padding:4pt 4pt 4pt 4pt'>", | ||
| 85 | column.width | ||
| 86 | ); | ||
| 87 | blocks(&cell.paragraphs, definitions, out); | ||
| 88 | out.push_str("</td>\n"); | ||
| 89 | } | ||
| 90 | out.push_str("</tr>\n"); | ||
| 91 | } | ||
| 92 | out.push_str("</table>\n"); | ||
| 93 | } | ||
| 94 | _ => {} | ||
| 95 | } | ||
| 96 | } | ||
| 97 | for (_, tag) in open.into_iter().rev() { | ||
| 98 | let _ = writeln!(out, "</{tag}>"); | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | /// The element and `type` a stored list writes as. | ||
| 103 | pub(super) fn list_tag(kind: &Kind) -> (&'static str, &'static str) { | ||
| 104 | let Kind::List { bullet, format, .. } = kind else { | ||
| 105 | return ("ul", "disc"); | ||
| 106 | }; | ||
| 107 | if bullet.is_some() { | ||
| 108 | let kind = match ListStyle::of(kind) { | ||
| 109 | Some(ListStyle::Bullet(3)) => "circle", | ||
| 110 | Some(ListStyle::Bullet(11 | 14)) => "square", | ||
| 111 | _ => "disc", | ||
| 112 | }; | ||
| 113 | return ("ul", kind); | ||
| 114 | } | ||
| 115 | let sequence = format | ||
| 116 | .as_deref() | ||
| 117 | .and_then(|format| format.split('\u{fffd}').nth(1)?.chars().next()); | ||
| 118 | let kind = match sequence.map(u32::from) { | ||
| 119 | Some(1) => "I", | ||
| 120 | Some(2) => "i", | ||
| 121 | Some(3) => "A", | ||
| 122 | Some(4) => "a", | ||
| 123 | _ => "1", | ||
| 124 | }; | ||
| 125 | ("ol", kind) | ||
| 126 | } | ||
| 127 | |||
| 128 | /// A paragraph's shown runs as spans, its links' around theirs. | ||
| 129 | fn runs(text: &Paragraph, out: &mut String) { | ||
| 130 | let links = links(text, 0); | ||
| 131 | let (mut byte, mut unit) = (0, 0); | ||
| 132 | let mut shown = false; | ||
| 133 | let mut space = true; | ||
| 134 | for span in text.spans() { | ||
| 135 | let fragment = &text.text()[byte..span.end]; | ||
| 136 | let start = unit; | ||
| 137 | (byte, unit) = (span.end, unit + fragment.encode_utf16().count() as u32); | ||
| 138 | if span.format.hidden == Some(true) || fragment.is_empty() { | ||
| 139 | continue; | ||
| 140 | } | ||
| 141 | shown = true; | ||
| 142 | let link = links.iter().find(|link| link.label.contains(&start)); | ||
| 143 | if let Some(link) = link { | ||
| 144 | let _ = write!(out, "<a href=\"{}\">", escape(&link.target, &mut false)); | ||
| 145 | } | ||
| 146 | let _ = write!( | ||
| 147 | out, | ||
| 148 | "<span style='{}'>{}</span>", | ||
| 149 | css(&span.format), | ||
| 150 | escape(fragment, &mut space) | ||
| 151 | ); | ||
| 152 | if link.is_some() { | ||
| 153 | out.push_str("</a>"); | ||
| 154 | } | ||
| 155 | } | ||
| 156 | if !shown { | ||
| 157 | out.push_str("&nbsp;"); | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | fn css(format: &Format) -> String { | ||
| 162 | let font = format.font.as_deref().unwrap_or(DEFAULT_FONT); | ||
| 163 | let font = if font.contains(' ') { | ||
| 164 | format!("\"{font}\"") | ||
| 165 | } else { | ||
| 166 | font.to_owned() | ||
| 167 | }; | ||
| 168 | let mut css = format!( | ||
| 169 | "font-family:{font};font-size:{}pt", | ||
| 170 | format.font_size.unwrap_or(DEFAULT_FONT_SIZE) | ||
| 171 | ); | ||
| 172 | let set = |value: Option<bool>| value == Some(true); | ||
| 173 | match format.bold { | ||
| 174 | Some(true) => css.push_str(";font-weight:bold"), | ||
| 175 | Some(false) => css.push_str(";font-weight:normal"), | ||
| 176 | None => {} | ||
| 177 | } | ||
| 178 | match format.italic { | ||
| 179 | Some(true) => css.push_str(";font-style:italic"), | ||
| 180 | Some(false) => css.push_str(";font-style:normal"), | ||
| 181 | None => {} | ||
| 182 | } | ||
| 183 | match (set(format.underline), set(format.strike)) { | ||
| 184 | (true, true) => css.push_str(";text-decoration:underline line-through"), | ||
| 185 | (true, false) => css.push_str(";text-decoration:underline"), | ||
| 186 | (false, true) => css.push_str(";text-decoration:line-through"), | ||
| 187 | (false, false) if format.underline.is_some() || format.strike.is_some() => { | ||
| 188 | css.push_str(";text-decoration:none") | ||
| 189 | } | ||
| 190 | (false, false) => {} | ||
| 191 | } | ||
| 192 | if set(format.superscript) { | ||
| 193 | css.push_str(";vertical-align:super"); | ||
| 194 | } else if set(format.subscript) { | ||
| 195 | css.push_str(";vertical-align:sub"); | ||
| 196 | } | ||
| 197 | let hex = |color: u32| { | ||
| 198 | let [red, green, blue, _] = color.to_le_bytes(); | ||
| 199 | format!("#{red:02X}{green:02X}{blue:02X}") | ||
| 200 | }; | ||
| 201 | if let Some(color) = format.color.filter(|color| *color != AUTOMATIC) { | ||
| 202 | let _ = write!(css, ";color:{}", hex(color)); | ||
| 203 | } | ||
| 204 | if let Some(color) = format.highlight.filter(|color| *color != AUTOMATIC) { | ||
| 205 | let _ = write!(css, ";background:{}", hex(color)); | ||
| 206 | } | ||
| 207 | css.replace('\'', "&#39;") | ||
| 208 | } | ||
| 209 | |||
| 210 | /// Text as HTML keeps it: markup characters escaped, line breaks as `br`, and a space after | ||
| 211 | /// `space`, whether the text follows a space or starts its paragraph, unbreakable so it | ||
| 212 | /// doesn't collapse. | ||
| 213 | fn escape(text: &str, space: &mut bool) -> String { | ||
| 214 | let mut escaped = String::with_capacity(text.len()); | ||
| 215 | for c in text.chars() { | ||
| 216 | match c { | ||
| 217 | '&' => escaped.push_str("&amp;"), | ||
| 218 | '<' => escaped.push_str("&lt;"), | ||
| 219 | '>' => escaped.push_str("&gt;"), | ||
| 220 | '"' => escaped.push_str("&quot;"), | ||
| 221 | '\u{b}' | '\n' => escaped.push_str("<br>"), | ||
| 222 | '\t' => escaped.push_str("&nbsp;&nbsp;&nbsp; "), | ||
| 223 | ' ' if *space => escaped.push_str("&nbsp;"), | ||
| 224 | c => escaped.push(c), | ||
| 225 | } | ||
| 226 | *space = c == ' '; | ||
| 227 | } | ||
| 228 | escaped | ||
| 229 | } | ||
| 230 | |||
| 231 | /// A web page fragment's paragraphs, lists and tables as clips, split around its pictures | ||
| 232 | /// in order, as OneNote 2010 pastes them (lab, 2026-09-30). Text without a font or size of | ||
| 233 | /// its own is Calibri 11 in `language`, an LCID, as pasted text is. `load` makes a picture's | ||
| 234 | /// piece from its source and its `width` and `height` in CSS pixels; one it can't, or one | ||
| 235 | /// in a table, is left out. | ||
| 236 | pub fn html_pieces( | ||
| 237 | html: &str, | ||
| 238 | language: u32, | ||
| 239 | load: impl FnMut(&str, [Option<f32>; 2]) -> Option<Piece>, | ||
| 240 | ) -> Vec<Piece> { | ||
| 241 | let html = html | ||
| 242 | .split_once("<!--StartFragment-->") | ||
| 243 | .map_or(html, |(_, fragment)| fragment); | ||
| 244 | let html = html | ||
| 245 | .split_once("<!--EndFragment-->") | ||
| 246 | .map_or(html, |(fragment, _)| fragment); | ||
| 247 | let mut reader = Reader { | ||
| 248 | base: Format { | ||
| 249 | font: Some("Calibri".into()), | ||
| 250 | font_size: Some(11.0), | ||
| 251 | language: Some(language), | ||
| 252 | ..Format::default() | ||
| 253 | }, | ||
| 254 | load, | ||
| 255 | pieces: Vec::new(), | ||
| 256 | blocks: Vec::new(), | ||
| 257 | tables: Vec::new(), | ||
| 258 | lists: Vec::new(), | ||
| 259 | item: None, | ||
| 260 | open: Vec::new(), | ||
| 261 | runs: Vec::new(), | ||
| 262 | shown: false, | ||
| 263 | space: None, | ||
| 264 | }; | ||
| 265 | reader.read(html); | ||
| 266 | reader.end_clip(); | ||
| 267 | reader.pieces | ||
| 268 | } | ||
| 269 | |||
| 270 | /// A paragraph or table read, before it becomes a clip's. | ||
| 271 | enum Block { | ||
| 272 | Text { | ||
| 273 | runs: Vec<(String, Format)>, | ||
| 274 | /// How many lists it sits in, its own not counted. | ||
| 275 | depth: usize, | ||
| 276 | list: Option<ListStyle>, | ||
| 277 | }, | ||
| 278 | Table { | ||
| 279 | rows: Vec<Vec<Vec<Block>>>, | ||
| 280 | widths: Vec<Option<f32>>, | ||
| 281 | depth: usize, | ||
| 282 | }, | ||
| 283 | } | ||
| 284 | |||
| 285 | #[derive(Default)] | ||
| 286 | struct TableRead { | ||
| 287 | rows: Vec<Vec<Vec<Block>>>, | ||
| 288 | widths: Vec<Option<f32>>, | ||
| 289 | /// The open cell's blocks. | ||
| 290 | cell: Option<Vec<Block>>, | ||
| 291 | } | ||
| 292 | |||
| 293 | /// An open element: its name, the formatting it gives what it holds, and where a link's | ||
| 294 | /// field code went in the runs, if it is one. | ||
| 295 | struct Open { | ||
| 296 | name: String, | ||
| 297 | format: Format, | ||
| 298 | link: Option<usize>, | ||
| 299 | } | ||
| 300 | |||
| 301 | struct Reader<F> { | ||
| 302 | base: Format, | ||
| 303 | load: F, | ||
| 304 | pieces: Vec<Piece>, | ||
| 305 | blocks: Vec<Block>, | ||
| 306 | tables: Vec<TableRead>, | ||
| 307 | lists: Vec<ListStyle>, | ||
| 308 | /// The list of the item whose text has yet to come. | ||
| 309 | item: Option<ListStyle>, | ||
| 310 | open: Vec<Open>, | ||
| 311 | runs: Vec<(String, Format)>, | ||
| 312 | /// Whether the paragraph read so far shows anything. | ||
| 313 | shown: bool, | ||
| 314 | /// The formatting of white space after what it shows, which shows as one space if | ||
| 315 | /// more follows. | ||
| 316 | space: Option<Format>, | ||
| 317 | } | ||
| 318 | |||
| 319 | /// Elements that hold no text and have no closing tag. | ||
| 320 | const VOID: [&str; 9] = [ | ||
| 321 | "br", "img", "meta", "link", "input", "hr", "col", "wbr", "area", | ||
| 322 | ]; | ||
| 323 | |||
| 324 | /// Elements that begin and end a paragraph. | ||
| 325 | const BLOCKS: [&str; 20] = [ | ||
| 326 | "p", | ||
| 327 | "div", | ||
| 328 | "li", | ||
| 329 | "h1", | ||
| 330 | "h2", | ||
| 331 | "h3", | ||
| 332 | "h4", | ||
| 333 | "h5", | ||
| 334 | "h6", | ||
| 335 | "blockquote", | ||
| 336 | "pre", | ||
| 337 | "dt", | ||
| 338 | "dd", | ||
| 339 | "section", | ||
| 340 | "article", | ||
| 341 | "header", | ||
| 342 | "footer", | ||
| 343 | "figure", | ||
| 344 | "figcaption", | ||
| 345 | "address", | ||
| 346 | ]; | ||
| 347 | |||
| 348 | impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> { | ||
| 349 | fn read(&mut self, html: &str) { | ||
| 350 | // Elements whose content is not text: script, style, head and title. | ||
| 351 | let mut hidden: Option<String> = None; | ||
| 352 | let mut rest = html; | ||
| 353 | while !rest.is_empty() { | ||
| 354 | if let Some(after) = rest.strip_prefix("<!--") { | ||
| 355 | rest = after.split_once("-->").map_or("", |(_, after)| after); | ||
| 356 | continue; | ||
| 357 | } | ||
| 358 | let Some(after) = rest.strip_prefix('<') else { | ||
| 359 | let end = rest.find('<').unwrap_or(rest.len()); | ||
| 360 | if hidden.is_none() { | ||
| 361 | self.text(&decode_entities(&rest[..end])); | ||
| 362 | } | ||
| 363 | rest = &rest[end..]; | ||
| 364 | continue; | ||
| 365 | }; | ||
| 366 | let end = tag_end(after); | ||
| 367 | let tag = &after[..end]; | ||
| 368 | rest = after.get(end + 1..).unwrap_or(""); | ||
| 369 | let (closing, tag) = match tag.strip_prefix('/') { | ||
| 370 | Some(tag) => (true, tag), | ||
| 371 | None => (false, tag), | ||
| 372 | }; | ||
| 373 | let name = tag | ||
| 374 | .split(|c: char| c.is_whitespace() || c == '/') | ||
| 375 | .next() | ||
| 376 | .unwrap_or_default() | ||
| 377 | .to_ascii_lowercase(); | ||
| 378 | if let Some(until) = &hidden { | ||
| 379 | if closing && name == *until { | ||
| 380 | hidden = None; | ||
| 381 | } | ||
| 382 | continue; | ||
| 383 | } | ||
| 384 | match (name.as_str(), closing) { | ||
| 385 | ("script" | "style" | "head" | "title", false) => hidden = Some(name), | ||
| 386 | (_, false) => self.start(&name, tag), | ||
| 387 | (_, true) => self.end(&name), | ||
| 388 | } | ||
| 389 | } | ||
| 390 | } | ||
| 391 | |||
| 392 | fn start(&mut self, name: &str, tag: &str) { | ||
| 393 | let block = BLOCKS.contains(&name); | ||
| 394 | if block || matches!(name, "br" | "table" | "tr" | "td" | "th" | "ul" | "ol") { | ||
| 395 | self.paragraph(); | ||
| 396 | } | ||
| 397 | match name { | ||
| 398 | "br" => return, | ||
| 399 | "img" => { | ||
| 400 | let css = |name| { | ||
| 401 | attribute(tag, name) | ||
| 402 | .and_then(|value| value.trim_end_matches("px").parse::<f32>().ok()) | ||
| 403 | .filter(|pixels| *pixels > 0.0) | ||
| 404 | }; | ||
| 405 | if self.tables.is_empty() | ||
| 406 | && let Some(picture) = attribute(tag, "src").and_then(|source| { | ||
| 407 | (self.load)(&decode_entities(source), [css("width"), css("height")]) | ||
| 408 | }) | ||
| 409 | { | ||
| 410 | self.end_clip(); | ||
| 411 | self.pieces.push(picture); | ||
| 412 | } | ||
| 413 | return; | ||
| 414 | } | ||
| 415 | "ul" | "ol" => { | ||
| 416 | let kind = attribute(tag, "type") | ||
| 417 | .map(str::to_owned) | ||
| 418 | .or_else(|| style(tag, "list-style-type")); | ||
| 419 | self.lists.push(list_style(name, kind.as_deref())); | ||
| 420 | } | ||
| 421 | "li" => { | ||
| 422 | self.implicit_end("li"); | ||
| 423 | self.item = Some(self.lists.last().copied().unwrap_or(ListStyle::BULLET)); | ||
| 424 | } | ||
| 425 | "p" => self.implicit_end("p"), | ||
| 426 | "table" => self.tables.push(TableRead::default()), | ||
| 427 | "tr" => { | ||
| 428 | if let Some(table) = self.tables.last_mut() { | ||
| 429 | table.end_cell(); | ||
| 430 | table.rows.push(Vec::new()); | ||
| 431 | } | ||
| 432 | } | ||
| 433 | "td" | "th" => { | ||
| 434 | if let Some(table) = self.tables.last_mut() { | ||
| 435 | table.end_cell(); | ||
| 436 | if table.rows.is_empty() { | ||
| 437 | table.rows.push(Vec::new()); | ||
| 438 | } | ||
| 439 | let column = table.rows.last().unwrap().len(); | ||
| 440 | if table.widths.len() <= column { | ||
| 441 | table.widths.resize(column + 1, None); | ||
| 442 | } | ||
| 443 | let width = style(tag, "width") | ||
| 444 | .and_then(|width| length(&width, 12.0)) | ||
| 445 | .or_else(|| { | ||
| 446 | attribute(tag, "width")? | ||
| 447 | .parse::<f32>() | ||
| 448 | .ok() | ||
| 449 | .map(|px| px * 0.75) | ||
| 450 | }); | ||
| 451 | table.widths[column] = table.widths[column].or(width); | ||
| 452 | table.cell = Some(Vec::new()); | ||
| 453 | } | ||
| 454 | } | ||
| 455 | _ => {} | ||
| 456 | } | ||
| 457 | if VOID.contains(&name) { | ||
| 458 | return; | ||
| 459 | } | ||
| 460 | let current = self.format(); | ||
| 461 | let mut format = element_format(name, tag, &current); | ||
| 462 | let link = (name == "a") | ||
| 463 | .then(|| attribute(tag, "href")) | ||
| 464 | .flatten() | ||
| 465 | .map(|href| { | ||
| 466 | if let Some(space) = self.space.take() { | ||
| 467 | self.push(' ', space); | ||
| 468 | } | ||
| 469 | let mut code = format.inherit(&current); | ||
| 470 | code.hidden = Some(true); | ||
| 471 | code.hyperlink = Some(true); | ||
| 472 | code.hyperlink_label = Some(true); | ||
| 473 | self.runs.push((field_code(&decode_entities(href)), code)); | ||
| 474 | format.hyperlink = Some(true); | ||
| 475 | format.hyperlink_label = Some(true); | ||
| 476 | self.runs.len() - 1 | ||
| 477 | }); | ||
| 478 | self.open.push(Open { | ||
| 479 | name: name.to_owned(), | ||
| 480 | format, | ||
| 481 | link, | ||
| 482 | }); | ||
| 483 | } | ||
| 484 | |||
| 485 | fn end(&mut self, name: &str) { | ||
| 486 | if BLOCKS.contains(&name) || matches!(name, "table" | "tr" | "td" | "th" | "ul" | "ol") { | ||
| 487 | self.paragraph(); | ||
| 488 | } | ||
| 489 | if let Some(at) = self.open.iter().rposition(|open| open.name == name) { | ||
| 490 | for open in self.open.drain(at..) { | ||
| 491 | // A link that showed nothing keeps no field code. | ||
| 492 | if let Some(code) = open.link | ||
| 493 | && code + 1 == self.runs.len() | ||
| 494 | { | ||
| 495 | self.runs.pop(); | ||
| 496 | } | ||
| 497 | } | ||
| 498 | } | ||
| 499 | match name { | ||
| 500 | "ul" | "ol" => { | ||
| 501 | self.lists.pop(); | ||
| 502 | } | ||
| 503 | "li" => self.item = None, | ||
| 504 | "td" | "th" => { | ||
| 505 | if let Some(table) = self.tables.last_mut() { | ||
| 506 | table.end_cell(); | ||
| 507 | } | ||
| 508 | } | ||
| 509 | "table" => { | ||
| 510 | if let Some(mut table) = self.tables.pop() { | ||
| 511 | table.end_cell(); | ||
| 512 | table.rows.retain(|row| !row.is_empty()); | ||
| 513 | if !table.rows.is_empty() { | ||
| 514 | let block = Block::Table { | ||
| 515 | rows: table.rows, | ||
| 516 | widths: table.widths, | ||
| 517 | depth: self.lists.len(), | ||
| 518 | }; | ||
| 519 | self.sink().push(block); | ||
| 520 | } | ||
| 521 | } | ||
| 522 | } | ||
| 523 | _ => {} | ||
| 524 | } | ||
| 525 | } | ||
| 526 | |||
| 527 | /// Ends an unclosed `name`, as a `p` or `li` is by the next, within the innermost list | ||
| 528 | /// or table. | ||
| 529 | fn implicit_end(&mut self, name: &str) { | ||
| 530 | let boundary = | ||
| 531 | |open: &Open| matches!(open.name.as_str(), "ul" | "ol" | "table" | "td" | "th"); | ||
| 532 | if let Some(at) = self | ||
| 533 | .open | ||
| 534 | .iter() | ||
| 535 | .rposition(|open| open.name == name || boundary(open)) | ||
| 536 | && self.open[at].name == name | ||
| 537 | { | ||
| 538 | self.open.truncate(at); | ||
| 539 | } | ||
| 540 | } | ||
| 541 | |||
| 542 | fn format(&self) -> Format { | ||
| 543 | self.open.iter().fold(self.base.clone(), |format, open| { | ||
| 544 | open.format.inherit(&format) | ||
| 545 | }) | ||
| 546 | } | ||
| 547 | |||
| 548 | fn text(&mut self, text: &str) { | ||
| 549 | let format = self.format(); | ||
| 550 | for c in text.chars() { | ||
| 551 | if c.is_whitespace() && c != '\u{a0}' { | ||
| 552 | if self.shown && self.space.is_none() { | ||
| 553 | self.space = Some(format.clone()); | ||
| 554 | } | ||
| 555 | continue; | ||
| 556 | } | ||
| 557 | if let Some(space) = self.space.take() { | ||
| 558 | self.push(' ', space); | ||
| 559 | } | ||
| 560 | self.shown = true; | ||
| 561 | self.push(if c == '\u{a0}' { ' ' } else { c }, format.clone()); | ||
| 562 | } | ||
| 563 | } | ||
| 564 | |||
| 565 | fn push(&mut self, c: char, format: Format) { | ||
| 566 | match self.runs.last_mut() { | ||
| 567 | Some((text, last)) if *last == format => text.push(c), | ||
| 568 | _ => self.runs.push((c.to_string(), format)), | ||
| 569 | } | ||
| 570 | } | ||
| 571 | |||
| 572 | /// Ends the paragraph being read, which goes in if it showed anything; a link's field | ||
| 573 | /// code begun in one that didn't carries on into the next. | ||
| 574 | fn paragraph(&mut self) { | ||
| 575 | self.space = None; | ||
| 576 | if !std::mem::take(&mut self.shown) { | ||
| 577 | return; | ||
| 578 | } | ||
| 579 | let mut runs = std::mem::take(&mut self.runs); | ||
| 580 | for open in &mut self.open { | ||
| 581 | open.link = None; | ||
| 582 | } | ||
| 583 | let list = self.item.take(); | ||
| 584 | let depth = self.lists.len() - usize::from(list.is_some() && !self.lists.is_empty()); | ||
| 585 | runs.retain(|(text, _)| !text.is_empty()); | ||
| 586 | if runs.is_empty() { | ||
| 587 | runs.push((String::new(), self.format())); | ||
| 588 | } | ||
| 589 | self.sink().push(Block::Text { runs, depth, list }); | ||
| 590 | } | ||
| 591 | |||
| 592 | /// Where a block read goes: the open cell, else the clip. | ||
| 593 | fn sink(&mut self) -> &mut Vec<Block> { | ||
| 594 | match self.tables.last_mut() { | ||
| 595 | Some(table) => table.cell.get_or_insert_with(Vec::new), | ||
| 596 | None => &mut self.blocks, | ||
| 597 | } | ||
| 598 | } | ||
| 599 | |||
| 600 | /// Makes what was read since the last picture a clip. | ||
| 601 | fn end_clip(&mut self) { | ||
| 602 | self.paragraph(); | ||
| 603 | let blocks = std::mem::take(&mut self.blocks); | ||
| 604 | if blocks.is_empty() { | ||
| 605 | return; | ||
| 606 | } | ||
| 607 | let mut definitions = BTreeMap::new(); | ||
| 608 | if let Ok(paragraphs) = nodes(blocks, &mut definitions) | ||
| 609 | && !paragraphs.is_empty() | ||
| 610 | { | ||
| 611 | self.pieces.push(Piece::Clip(Clip { | ||
| 612 | paragraphs, | ||
| 613 | definitions, | ||
| 614 | })); | ||
| 615 | } | ||
| 616 | } | ||
| 617 | } | ||
| 618 | |||
| 619 | impl TableRead { | ||
| 620 | fn end_cell(&mut self) { | ||
| 621 | if let Some(cell) = self.cell.take() { | ||
| 622 | match self.rows.last_mut() { | ||
| 623 | Some(row) => row.push(cell), | ||
| 624 | None => self.rows.push(vec![cell]), | ||
| 625 | } | ||
| 626 | } | ||
| 627 | } | ||
| 628 | } | ||
| 629 | |||
| 630 | /// `blocks` as paragraphs, each the child of the one before it at a lower level, with the | ||
| 631 | /// list definitions they name added to `definitions`. | ||
| 632 | fn nodes( | ||
| 633 | blocks: Vec<Block>, | ||
| 634 | definitions: &mut BTreeMap<ExGuid, Definition>, | ||
| 635 | ) -> Result<Vec<PageParagraph>, EditError> { | ||
| 636 | let depth = |block: &Block| match block { | ||
| 637 | Block::Text { depth, .. } | Block::Table { depth, .. } => *depth, | ||
| 638 | }; | ||
| 639 | let least = blocks.iter().map(depth).min().unwrap_or(0); | ||
| 640 | let mut nodes = Vec::new(); | ||
| 641 | let mut ancestors: Vec<(u32, ExGuid)> = Vec::new(); | ||
| 642 | for block in blocks { | ||
| 643 | let level = (depth(&block) - least + 1) as u32; | ||
| 644 | let mut paragraph = match block { | ||
| 645 | Block::Text { runs, list, .. } => { | ||
| 646 | let first = runs[0].1.clone(); | ||
| 647 | let mut paragraph = node(Paragraph::from_runs(runs), Format::default())?; | ||
| 648 | if let Some(style) = list { | ||
| 649 | let id = new_id()?; | ||
| 650 | definitions.insert(id, list_definition(style, &first)); | ||
| 651 | paragraph.lists = vec![id]; | ||
| 652 | } | ||
| 653 | paragraph | ||
| 654 | } | ||
| 655 | Block::Table { rows, widths, .. } => table(rows, widths, definitions)?, | ||
| 656 | }; | ||
| 657 | while ancestors.last().is_some_and(|(above, _)| *above >= level) { | ||
| 658 | ancestors.pop(); | ||
| 659 | } | ||
| 660 | paragraph.parent = ancestors.last().map(|(_, id)| *id); | ||
| 661 | paragraph.level = level; | ||
| 662 | ancestors.push((level, paragraph.id)); | ||
| 663 | nodes.push(paragraph); | ||
| 664 | } | ||
| 665 | Ok(nodes) | ||
| 666 | } | ||
| 667 | |||
| 668 | fn table( | ||
| 669 | rows: Vec<Vec<Vec<Block>>>, | ||
| 670 | widths: Vec<Option<f32>>, | ||
| 671 | definitions: &mut BTreeMap<ExGuid, Definition>, | ||
| 672 | ) -> Result<PageParagraph, EditError> { | ||
| 673 | let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); | ||
| 674 | let rows = rows | ||
| 675 | .into_iter() | ||
| 676 | .map(|mut cells| { | ||
| 677 | cells.resize_with(columns, Vec::new); | ||
| 678 | Ok(TableRow { | ||
| 679 | id: new_id()?, | ||
| 680 | cells: cells | ||
| 681 | .into_iter() | ||
| 682 | .map(|blocks| { | ||
| 683 | let mut paragraphs = nodes(blocks, definitions)?; | ||
| 684 | if paragraphs.is_empty() { | ||
| 685 | let empty = Paragraph::new(String::new(), Format::default()); | ||
| 686 | paragraphs.push(node(empty, Format::default())?); | ||
| 687 | } | ||
| 688 | Ok(TableCell { | ||
| 689 | id: new_id()?, | ||
| 690 | layout: Default::default(), | ||
| 691 | indents: Vec::new(), | ||
| 692 | shading: None, | ||
| 693 | paragraphs, | ||
| 694 | unsupported: Vec::new(), | ||
| 695 | }) | ||
| 696 | }) | ||
| 697 | .collect::<Result<_, EditError>>()?, | ||
| 698 | }) | ||
| 699 | }) | ||
| 700 | .collect::<Result<_, EditError>>()?; | ||
| 701 | let mut paragraph = node( | ||
| 702 | Paragraph::new(String::new(), Format::default()), | ||
| 703 | Format::default(), | ||
| 704 | )?; | ||
| 705 | paragraph.content = ParagraphContent::Table(Table { | ||
| 706 | id: new_id()?, | ||
| 707 | columns: (0..columns) | ||
| 708 | .map(|column| TableColumn { | ||
| 709 | width: widths | ||
| 710 | .get(column) | ||
| 711 | .copied() | ||
| 712 | .flatten() | ||
| 713 | .unwrap_or(COLUMN_WIDTH) | ||
| 714 | .max(COLUMN_WIDTH), | ||
| 715 | locked: false, | ||
| 716 | }) | ||
| 717 | .collect(), | ||
| 718 | rows, | ||
| 719 | borders: Some(true), | ||
| 720 | layout: Default::default(), | ||
| 721 | tags: Vec::new(), | ||
| 722 | }); | ||
| 723 | Ok(paragraph) | ||
| 724 | } | ||
| 725 | |||
| 726 | /// The list style an `ul` or `ol` of `kind`, its `type` or `list-style-type`, gives. | ||
| 727 | fn list_style(name: &str, kind: Option<&str>) -> ListStyle { | ||
| 728 | let number = |format: &str| { | ||
| 729 | let index = NUMBER_LIBRARY.iter().position(|known| *known == format); | ||
| 730 | ListStyle::Number(index.unwrap_or(0)) | ||
| 731 | }; | ||
| 732 | match (name, kind.map(str::trim)) { | ||
| 733 | ("ul", Some("circle")) => ListStyle::Bullet(3), | ||
| 734 | ("ul", Some("square")) => ListStyle::Bullet(11), | ||
| 735 | ("ul", _) => ListStyle::BULLET, | ||
| 736 | (_, Some("a" | "lower-alpha" | "lower-latin")) => number("\u{fffd}\u{4}."), | ||
| 737 | (_, Some("A" | "upper-alpha" | "upper-latin")) => number("\u{fffd}\u{3}."), | ||
| 738 | (_, Some("i" | "lower-roman")) => number("\u{fffd}\u{2}."), | ||
| 739 | (_, Some("I" | "upper-roman")) => number("\u{fffd}\u{1}."), | ||
| 740 | _ => ListStyle::NUMBER, | ||
| 741 | } | ||
| 742 | } | ||
| 743 | |||
| 744 | /// The formatting an element gives its text: its own, as `b` and `font` give, then its | ||
| 745 | /// `style`'s. `current` is the formatting around it. | ||
| 746 | fn element_format(name: &str, tag: &str, current: &Format) -> Format { | ||
| 747 | let mut format = Format::default(); | ||
| 748 | match name { | ||
| 749 | "b" | "strong" => format.bold = Some(true), | ||
| 750 | "i" | "em" | "cite" | "dfn" | "var" => format.italic = Some(true), | ||
| 751 | "u" | "ins" => format.underline = Some(true), | ||
| 752 | "s" | "strike" | "del" => format.strike = Some(true), | ||
| 753 | "sup" => format.superscript = Some(true), | ||
| 754 | "sub" => format.subscript = Some(true), | ||
| 755 | "code" | "tt" | "pre" | "kbd" | "samp" => format.font = Some("Courier New".into()), | ||
| 756 | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => { | ||
| 757 | const SIZES: [f32; 6] = [24.0, 18.0, 14.0, 12.0, 10.0, 8.0]; | ||
| 758 | format.bold = Some(true); | ||
| 759 | format.font_size = Some(SIZES[usize::from(name.as_bytes()[1] - b'1')]); | ||
| 760 | } | ||
| 761 | "font" => { | ||
| 762 | format.font = attribute(tag, "face").and_then(family); | ||
| 763 | format.color = attribute(tag, "color").and_then(color).flatten(); | ||
| 764 | format.font_size = attribute(tag, "size") | ||
| 765 | .and_then(|size| size.trim().parse::<usize>().ok()) | ||
| 766 | .map(|size| [8.0, 10.0, 12.0, 14.0, 18.0, 24.0, 36.0][size.clamp(1, 7) - 1]); | ||
| 767 | } | ||
| 768 | _ => {} | ||
| 769 | } | ||
| 770 | let Some(css) = attribute(tag, "style") else { | ||
| 771 | return format; | ||
| 772 | }; | ||
| 773 | let size = format | ||
| 774 | .font_size | ||
| 775 | .or(current.font_size) | ||
| 776 | .unwrap_or(DEFAULT_FONT_SIZE); | ||
| 777 | for declaration in decode_entities(css).split(';') { | ||
| 778 | let Some((property, value)) = declaration.split_once(':') else { | ||
| 779 | continue; | ||
| 780 | }; | ||
| 781 | let value = value.trim().trim_end_matches("!important").trim(); | ||
| 782 | match property.trim().to_ascii_lowercase().as_str() { | ||
| 783 | "font-weight" => { | ||
| 784 | format.bold = match value { | ||
| 785 | "bold" | "bolder" => Some(true), | ||
| 786 | "normal" | "lighter" => Some(false), | ||
| 787 | weight => weight.parse::<u32>().ok().map(|weight| weight >= 600), | ||
| 788 | } | ||
| 789 | } | ||
| 790 | "font-style" => format.italic = Some(matches!(value, "italic" | "oblique")), | ||
| 791 | "text-decoration" | "text-decoration-line" => { | ||
| 792 | format.underline = Some(value.contains("underline")); | ||
| 793 | format.strike = Some(value.contains("line-through")); | ||
| 794 | } | ||
| 795 | "font-family" => format.font = family(value).or(format.font), | ||
| 796 | "font-size" => format.font_size = length(value, size).or(format.font_size), | ||
| 797 | "color" => { | ||
| 798 | if let Some(color) = color(value) { | ||
| 799 | format.color = color; | ||
| 800 | } | ||
| 801 | } | ||
| 802 | "background" | "background-color" | "mso-highlight" => { | ||
| 803 | if let Some(color) = value.split_whitespace().find_map(color) { | ||
| 804 | format.highlight = color; | ||
| 805 | } | ||
| 806 | } | ||
| 807 | "vertical-align" => match value { | ||
| 808 | "super" => format.superscript = Some(true), | ||
| 809 | "sub" => format.subscript = Some(true), | ||
| 810 | _ => {} | ||
| 811 | }, | ||
| 812 | _ => {} | ||
| 813 | } | ||
| 814 | } | ||
| 815 | format | ||
| 816 | } | ||
| 817 | |||
| 818 | /// The first family a `font-family` names, unless it is a generic one or a browser's name | ||
| 819 | /// for the system's. | ||
| 820 | fn family(value: &str) -> Option<String> { | ||
| 821 | let first = value | ||
| 822 | .split(',') | ||
| 823 | .next()? | ||
| 824 | .trim() | ||
| 825 | .trim_matches(['"', '\'']) | ||
| 826 | .trim(); | ||
| 827 | let generic = [ | ||
| 828 | "serif", | ||
| 829 | "sans-serif", | ||
| 830 | "monospace", | ||
| 831 | "cursive", | ||
| 832 | "fantasy", | ||
| 833 | "system-ui", | ||
| 834 | "inherit", | ||
| 835 | "blinkmacsystemfont", | ||
| 836 | ]; | ||
| 837 | let lower = first.to_ascii_lowercase(); | ||
| 838 | (!first.is_empty() && !first.starts_with('-') && !generic.contains(&lower.as_str())) | ||
| 839 | .then(|| first.to_owned()) | ||
| 840 | } | ||
| 841 | |||
| 842 | /// A CSS length in points, `em` and `%` of `size`. | ||
| 843 | fn length(value: &str, size: f32) -> Option<f32> { | ||
| 844 | let value = value.trim(); | ||
| 845 | let keyword = match value { | ||
| 846 | "xx-small" => Some(7.0), | ||
| 847 | "x-small" => Some(7.5), | ||
| 848 | "small" => Some(10.0), | ||
| 849 | "medium" => Some(12.0), | ||
| 850 | "large" => Some(13.5), | ||
| 851 | "x-large" => Some(18.0), | ||
| 852 | "xx-large" => Some(24.0), | ||
| 853 | _ => None, | ||
| 854 | }; | ||
| 855 | if keyword.is_some() { | ||
| 856 | return keyword; | ||
| 857 | } | ||
| 858 | let split = value | ||
| 859 | .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')) | ||
| 860 | .unwrap_or(value.len()); | ||
| 861 | let (number, unit) = value.split_at(split); | ||
| 862 | let number: f32 = number.parse().ok()?; | ||
| 863 | let points = match unit.trim().to_ascii_lowercase().as_str() { | ||
| 864 | "pt" => number, | ||
| 865 | "px" | "" => number * 0.75, | ||
| 866 | "in" => number * 72.0, | ||
| 867 | "cm" => number * 72.0 / 2.54, | ||
| 868 | "mm" => number * 72.0 / 25.4, | ||
| 869 | "pc" => number * 12.0, | ||
| 870 | "em" | "rem" => number * size, | ||
| 871 | "%" => number * size / 100.0, | ||
| 872 | _ => return None, | ||
| 873 | }; | ||
| 874 | (points.is_finite() && points > 0.0).then_some(points) | ||
| 875 | } | ||
| 876 | |||
| 877 | /// A CSS colour as a COLORREF: `Some(None)` for black and the defaults, which pasted text | ||
| 878 | /// takes as automatic so it follows the page; none where it isn't one. | ||
| 879 | fn color(value: &str) -> Option<Option<u32>> { | ||
| 880 | let value = value.trim().to_ascii_lowercase(); | ||
| 881 | let rgb = |red: u32, green: u32, blue: u32| Some(red | green << 8 | blue << 16); | ||
| 882 | let parsed = if let Some(hex) = value.strip_prefix('#') { | ||
| 883 | let digits: Vec<u32> = hex.chars().map(|c| c.to_digit(16)).collect::<Option<_>>()?; | ||
| 884 | match digits[..] { | ||
| 885 | [r, g, b] => rgb(r * 17, g * 17, b * 17), | ||
| 886 | [r1, r0, g1, g0, b1, b0] => rgb(r1 * 16 + r0, g1 * 16 + g0, b1 * 16 + b0), | ||
| 887 | _ => return None, | ||
| 888 | } | ||
| 889 | } else if let Some(arguments) = value | ||
| 890 | .strip_prefix("rgb(") | ||
| 891 | .or_else(|| value.strip_prefix("rgba(")) | ||
| 892 | { | ||
| 893 | let channels: Vec<u32> = arguments | ||
| 894 | .trim_end_matches(')') | ||
| 895 | .split(',') | ||
| 896 | .take(3) | ||
| 897 | .map(|channel| { | ||
| 898 | channel | ||
| 899 | .trim() | ||
| 900 | .parse::<f32>() | ||
| 901 | .ok() | ||
| 902 | .map(|c| c.clamp(0.0, 255.0) as u32) | ||
| 903 | }) | ||
| 904 | .collect::<Option<_>>()?; | ||
| 905 | match channels[..] { | ||
| 906 | [r, g, b] => rgb(r, g, b), | ||
| 907 | _ => return None, | ||
| 908 | } | ||
| 909 | } else { | ||
| 910 | match value.as_str() { | ||
| 911 | "black" | "windowtext" | "auto" | "inherit" | "initial" | "currentcolor" | "none" | ||
| 912 | | "transparent" => None, | ||
| 913 | "white" => rgb(255, 255, 255), | ||
| 914 | "red" => rgb(255, 0, 0), | ||
| 915 | "green" => rgb(0, 128, 0), | ||
| 916 | "lime" => rgb(0, 255, 0), | ||
| 917 | "blue" => rgb(0, 0, 255), | ||
| 918 | "yellow" => rgb(255, 255, 0), | ||
| 919 | "orange" => rgb(255, 165, 0), | ||
| 920 | "purple" => rgb(128, 0, 128), | ||
| 921 | "gray" | "grey" => rgb(128, 128, 128), | ||
| 922 | "silver" => rgb(192, 192, 192), | ||
| 923 | "maroon" => rgb(128, 0, 0), | ||
| 924 | "navy" => rgb(0, 0, 128), | ||
| 925 | "teal" => rgb(0, 128, 128), | ||
| 926 | "olive" => rgb(128, 128, 0), | ||
| 927 | "aqua" | "cyan" => rgb(0, 255, 255), | ||
| 928 | "fuchsia" | "magenta" => rgb(255, 0, 255), | ||
| 929 | "darkred" => rgb(139, 0, 0), | ||
| 930 | "darkblue" => rgb(0, 0, 139), | ||
| 931 | "darkgreen" => rgb(0, 100, 0), | ||
| 932 | _ => return None, | ||
| 933 | } | ||
| 934 | }; | ||
| 935 | Some(parsed.filter(|color| *color != 0)) | ||
| 936 | } | ||
| 937 | |||
| 938 | /// The value `name` takes in a tag's `style`. | ||
| 939 | fn style(tag: &str, name: &str) -> Option<String> { | ||
| 940 | decode_entities(attribute(tag, "style")?) | ||
| 941 | .split(';') | ||
| 942 | .filter_map(|declaration| declaration.split_once(':')) | ||
| 943 | .find(|(property, _)| property.trim().eq_ignore_ascii_case(name)) | ||
| 944 | .map(|(_, value)| value.trim().to_owned()) | ||
| 945 | } | ||
| 946 | |||
| 947 | /// Where a tag's text ends, before its `>`, outside quoted attribute values. | ||
| 948 | fn tag_end(tag: &str) -> usize { | ||
| 949 | let mut quote = None; | ||
| 950 | for (at, c) in tag.char_indices() { | ||
| 951 | match (quote, c) { | ||
| 952 | (None, '"' | '\'') => quote = Some(c), | ||
| 953 | (Some(open), _) if c == open => quote = None, | ||
| 954 | (None, '>') => return at, | ||
| 955 | _ => {} | ||
| 956 | } | ||
| 957 | } | ||
| 958 | tag.len() | ||
| 959 | } | ||
| 960 | |||
| 961 | /// The value of attribute `name` in `tag`, entities still encoded. | ||
| 962 | fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> { | ||
| 963 | let mut rest = tag; | ||
| 964 | while let Some(at) = rest.find('=') { | ||
| 965 | let key = rest[..at].trim_end().rsplit(char::is_whitespace).next()?; | ||
| 966 | let value = rest[at + 1..].trim_start(); | ||
| 967 | let (value, after) = match value.chars().next()? { | ||
| 968 | quote @ ('"' | '\'') => value[1..].split_once(quote)?, | ||
| 969 | _ => value.split_at(value.find(char::is_whitespace).unwrap_or(value.len())), | ||
| 970 | }; | ||
| 971 | if key.eq_ignore_ascii_case(name) { | ||
| 972 | return Some(value); | ||
| 973 | } | ||
| 974 | rest = after; | ||
| 975 | } | ||
| 976 | None | ||
| 977 | } | ||
| 978 | |||
| 979 | fn decode_entities(text: &str) -> String { | ||
| 980 | let mut decoded = String::with_capacity(text.len()); | ||
| 981 | let mut rest = text; | ||
| 982 | while let Some(at) = rest.find('&') { | ||
| 983 | decoded.push_str(&rest[..at]); | ||
| 984 | rest = &rest[at..]; | ||
| 985 | let entity = rest[1..] | ||
| 986 | .find(';') | ||
| 987 | .filter(|end| *end <= 10) | ||
| 988 | .map(|end| &rest[1..end + 1]); | ||
| 989 | let c = entity.and_then(|entity| match entity { | ||
| 990 | "amp" => Some('&'), | ||
| 991 | "lt" => Some('<'), | ||
| 992 | "gt" => Some('>'), | ||
| 993 | "quot" => Some('"'), | ||
| 994 | "apos" => Some('\''), | ||
| 995 | "nbsp" => Some('\u{a0}'), | ||
| 996 | _ => { | ||
| 997 | let number = entity.strip_prefix('#')?; | ||
| 998 | let code = match number.strip_prefix(['x', 'X']) { | ||
| 999 | Some(hex) => u32::from_str_radix(hex, 16).ok()?, | ||
| 1000 | None => number.parse().ok()?, | ||
| 1001 | }; | ||
| 1002 | char::from_u32(code) | ||
| 1003 | } | ||
| 1004 | }); | ||
| 1005 | match (c, entity) { | ||
| 1006 | (Some(c), Some(entity)) => { | ||
| 1007 | decoded.push(c); | ||
| 1008 | rest = &rest[entity.len() + 2..]; | ||
| 1009 | } | ||
| 1010 | _ => { | ||
| 1011 | decoded.push('&'); | ||
| 1012 | rest = &rest[1..]; | ||
| 1013 | } | ||
| 1014 | } | ||
| 1015 | } | ||
| 1016 | decoded.push_str(rest); | ||
| 1017 | decoded | ||
| 1018 | } | ||
| 1019 | |||
| 1020 | #[cfg(test)] | ||
| 1021 | mod tests { | ||
| 1022 | use super::*; | ||
| 1023 | |||
| 1024 | fn clips(html: &str) -> Vec<Clip> { | ||
| 1025 | html_pieces(html, 0x409, |_, _| None) | ||
| 1026 | .into_iter() | ||
| 1027 | .map(|piece| match piece { | ||
| 1028 | Piece::Clip(clip) => clip, | ||
| 1029 | _ => panic!("a picture where none was loaded"), | ||
| 1030 | }) | ||
| 1031 | .collect() | ||
| 1032 | } | ||
| 1033 | |||
| 1034 | const ONENOTE: &str = include_str!("../../../../corpus/clipboard/onenote-2010.html"); | ||
| 1035 | |||
| 1036 | /// What OneNote 2010 copied (`corpus/clipboard`) pastes with its formatting, links, | ||
| 1037 | /// lists and table; its tag is not in its HTML. | ||
| 1038 | #[test] | ||
| 1039 | fn onenotes_html_pastes_with_its_formatting() { | ||
| 1040 | let [clip] = &clips(ONENOTE)[..] else { | ||
| 1041 | panic!("one clip") | ||
| 1042 | }; | ||
| 1043 | assert_eq!( | ||
| 1044 | clip.outline(), | ||
| 1045 | [ | ||
| 1046 | "<b>Bold line</>", | ||
| 1047 | "plain <i>italic</> <color=0000c0>red</> <size=16>big</> \ | ||
| 1048 | <font=Times New Roman>times</> <u>under</> <highlight=00ffff>hi</> \ | ||
| 1049 | <link>link</> -> https://example.com/", | ||
| 1050 | "• bullet one", | ||
| 1051 | " • nested bullet", | ||
| 1052 | "• bullet two", | ||
| 1053 | "1. number one", | ||
| 1054 | "1. number two", | ||
| 1055 | "tagged todo", | ||
| 1056 | "after", | ||
| 1057 | "table 2x2", | ||
| 1058 | " | 0,0: a1", | ||
| 1059 | " | 0,1: <b>b1</>", | ||
| 1060 | " | 1,0: a2", | ||
| 1061 | " | 1,1: b2", | ||
| 1062 | "last line", | ||
| 1063 | ] | ||
| 1064 | ); | ||
| 1065 | } | ||
| 1066 | |||
| 1067 | /// Snowbound's HTML reads back as the clip it was written from. | ||
| 1068 | #[test] | ||
| 1069 | fn snowbound_html_reads_back_as_written() { | ||
| 1070 | let [clip] = &clips(ONENOTE)[..] else { | ||
| 1071 | panic!("one clip") | ||
| 1072 | }; | ||
| 1073 | let written = clip.html(); | ||
| 1074 | let [read] = &clips(&written)[..] else { | ||
| 1075 | panic!("one clip") | ||
| 1076 | }; | ||
| 1077 | assert_eq!(read.outline(), clip.outline()); | ||
| 1078 | let runs = |clip: &Clip| { | ||
| 1079 | crate::document::leaves(&clip.paragraphs, None) | ||
| 1080 | .map(|(.., node)| node.text().unwrap().text.clone()) | ||
| 1081 | .collect::<Vec<_>>() | ||
| 1082 | }; | ||
| 1083 | assert_eq!(runs(read), runs(clip)); | ||
| 1084 | } | ||
| 1085 | |||
| 1086 | /// Markup in text is escaped, spaces kept, and a paragraph that shows nothing still | ||
| 1087 | /// takes a line. | ||
| 1088 | #[test] | ||
| 1089 | fn text_survives_html() { | ||
| 1090 | let clip = &clips("<p>a &lt;b&gt; &amp;&nbsp;&nbsp;c</p><p>&nbsp;</p><p>d</p>")[0]; | ||
| 1091 | assert_eq!(clip.text(), "a <b> & c\n \nd"); | ||
| 1092 | let read = &clips(&clip.html())[0]; | ||
| 1093 | assert_eq!(read.text(), clip.text()); | ||
| 1094 | } | ||
| 1095 | |||
| 1096 | /// Other applications' markup: tags and inline styles, unclosed items, sizes in pixels | ||
| 1097 | /// and colours by name. | ||
| 1098 | #[test] | ||
| 1099 | fn web_markup_gives_formatting() { | ||
| 1100 | let clip = &clips( | ||
| 1101 | "<div style=\"font-family: Arial, sans-serif; font-size: 16px\">\ | ||
| 1102 | <strong>Bold</strong> <em>it</em> <font color=red size=5>big</font> \ | ||
| 1103 | <span style=\"font-weight:700; text-decoration:line-through\">gone</span>\ | ||
| 1104 | x<sup>2</sup></div><ol type=a><li>first<li>second</ol>", | ||
| 1105 | )[0]; | ||
| 1106 | assert_eq!( | ||
| 1107 | clip.outline(), | ||
| 1108 | [ | ||
| 1109 | "<b font=Arial size=12>Bold</><font=Arial size=12> </>\ | ||
| 1110 | <i font=Arial size=12>it</><font=Arial size=12> </>\ | ||
| 1111 | <color=0000ff font=Arial size=18>big</><font=Arial size=12> </>\ | ||
| 1112 | <b s font=Arial size=12>gone</><font=Arial size=12>x</>\ | ||
| 1113 | <sup font=Arial size=12>2</>", | ||
| 1114 | "a. first", | ||
| 1115 | "a. second", | ||
| 1116 | ] | ||
| 1117 | ); | ||
| 1118 | } | ||
| 1119 | |||
| 1120 | /// As OneNote 2010 pasted `<p>before <img> after</p>` (lab, 2026-09-30): the text on | ||
| 1121 | /// either side of the picture, the picture at the size its attributes give; one in a | ||
| 1122 | /// table is left out. | ||
| 1123 | #[test] | ||
| 1124 | fn a_pasted_page_gives_its_text_and_pictures_in_order() { | ||
| 1125 | let html = "Version:0.9\r\n<html><head><style>p{}</style><title>T</title></head>\ | ||
| 1126 | <body><!--StartFragment--><p>before <img alt=\"a > b\" \ | ||
| 1127 | src=\"file:///C:/work/pic.png\" width=\"200\" height=\"100px\"> after</p>\ | ||
| 1128 | <p>Fish &amp; chips&nbsp;&#x2014;&#8212;</p><ul><li>one<li>two<br>three</ul>\ | ||
| 1129 | <table><tr><td><img src=\"cell.png\">cell</td></tr></table>\ | ||
| 1130 | <img src=\"missing.png\"><IMG SRC='https://example.invalid/a.png' width=40>\ | ||
| 1131 | <!--EndFragment--></body></html>"; | ||
| 1132 | let mut asked = Vec::new(); | ||
| 1133 | let pieces = html_pieces(html, 0x409, |source, css| { | ||
| 1134 | asked.push((source.to_owned(), css)); | ||
| 1135 | match source { | ||
| 1136 | "missing.png" => None, | ||
| 1137 | "https://example.invalid/a.png" => Some(Piece::Awaited), | ||
| 1138 | _ => Some(Piece::Picture(vec![0; 3], [150.0, 75.0])), | ||
| 1139 | } | ||
| 1140 | }); | ||
| 1141 | assert_eq!( | ||
| 1142 | asked, | ||
| 1143 | [ | ||
| 1144 | ( | ||
| 1145 | "file:///C:/work/pic.png".to_owned(), | ||
| 1146 | [Some(200.0), Some(100.0)] | ||
| 1147 | ), | ||
| 1148 | ("missing.png".to_owned(), [None, None]), | ||
| 1149 | ( | ||
| 1150 | "https://example.invalid/a.png".to_owned(), | ||
| 1151 | [Some(40.0), None] | ||
| 1152 | ), | ||
| 1153 | ] | ||
| 1154 | ); | ||
| 1155 | let shown: Vec<Vec<String>> = pieces | ||
| 1156 | .iter() | ||
| 1157 | .map(|piece| match piece { | ||
| 1158 | Piece::Clip(clip) => clip.outline(), | ||
| 1159 | Piece::Picture(bytes, size) => vec![format!("[{} {size:?}]", bytes.len())], | ||
| 1160 | Piece::Awaited => vec!["[awaited]".to_owned()], | ||
| 1161 | }) | ||
| 1162 | .collect(); | ||
| 1163 | assert_eq!( | ||
| 1164 | shown, | ||
| 1165 | [ | ||
| 1166 | vec!["before"], | ||
| 1167 | vec!["[3 [150.0, 75.0]]"], | ||
| 1168 | vec![ | ||
| 1169 | "after", | ||
| 1170 | "Fish & chips \u{2014}\u{2014}", | ||
| 1171 | "• one", | ||
| 1172 | "• two", | ||
| 1173 | " three", | ||
| 1174 | "table 1x1", | ||
| 1175 | " | 0,0: cell", | ||
| 1176 | ], | ||
| 1177 | vec!["[awaited]"], | ||
| 1178 | ] | ||
| 1179 | ); | ||
| 1180 | } | ||
| 1181 | } | ||
crates/canvas/src/editor/link.rs+1-1| ... | @@ -159,7 +159,7 @@ pub(crate) fn unlinked(format: &mut Format) { | ... | @@ -159,7 +159,7 @@ pub(crate) fn unlinked(format: &mut Format) { |
| 159 | } | 159 | } |
| 160 | 160 | ||
| 161 | /// A field code naming `address`, which cannot hold its quotes or line breaks. | 161 | /// A field code naming `address`, which cannot hold its quotes or line breaks. |
| 162 | fn field_code(address: &str) -> String { | 162 | pub(super) fn field_code(address: &str) -> String { |
| 163 | let address: String = address | 163 | let address: String = address |
| 164 | .trim() | 164 | .trim() |
| 165 | .chars() | 165 | .chars() |
crates/canvas/src/editor/ops.rs+1-1| ... | @@ -72,7 +72,7 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> + | ... | @@ -72,7 +72,7 @@ pub(super) fn references(node: &PageParagraph) -> impl Iterator<Item = ExGuid> + |
| 72 | } | 72 | } |
| 73 | 73 | ||
| 74 | /// The definitions `paragraphs` and their cells name. | 74 | /// The definitions `paragraphs` and their cells name. |
| 75 | fn named( | 75 | pub(super) fn named( |
| 76 | definitions: &BTreeMap<ExGuid, Definition>, | 76 | definitions: &BTreeMap<ExGuid, Definition>, |
| 77 | paragraphs: &[&[PageParagraph]], | 77 | paragraphs: &[&[PageParagraph]], |
| 78 | ) -> BTreeMap<ExGuid, Definition> { | 78 | ) -> BTreeMap<ExGuid, Definition> { |
crates/canvas/src/editor/table.rs+1-1| ... | @@ -141,7 +141,7 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos | ... | @@ -141,7 +141,7 @@ fn cell_range(document: &TextDocument, cell: &TableCell) -> Result<Range<TextPos |
| 141 | } | 141 | } |
| 142 | 142 | ||
| 143 | /// OneNote 2010's width for a new column, and the narrowest it fits or drags one to. | 143 | /// OneNote 2010's width for a new column, and the narrowest it fits or drags one to. |
| 144 | const COLUMN_WIDTH: f32 = 37.11; | 144 | pub(super) const COLUMN_WIDTH: f32 = 37.11; |
| 145 | /// What OneNote 2010 fits an unlocked column to beyond its widest line (lab, 2026-09-30). | 145 | /// What OneNote 2010 fits an unlocked column to beyond its widest line (lab, 2026-09-30). |
| 146 | const COLUMN_ROOM: f32 = 4.347; | 146 | const COLUMN_ROOM: f32 = 4.347; |
| 147 | 147 |
crates/canvas/src/interaction/mod.rs+21-51| ... | @@ -17,8 +17,8 @@ use crate::gpu::{Paper, Viewport, page::PageScene}; | ... | @@ -17,8 +17,8 @@ use crate::gpu::{Paper, Viewport, page::PageScene}; |
| 17 | use crate::{ | 17 | use crate::{ |
| 18 | date::DateField, | 18 | date::DateField, |
| 19 | editor::{ | 19 | editor::{ |
| 20 | Awaited, CanvasEditor, DEFAULT_OUTLINE_WIDTH, Formatting, Piece, Selection, TextOutline, | 20 | Awaited, CanvasEditor, Clip, DEFAULT_OUTLINE_WIDTH, Formatting, Piece, Selection, |
| 21 | Whole, | 21 | TextOutline, Whole, |
| 22 | }, | 22 | }, |
| 23 | layout::TextEngine, | 23 | layout::TextEngine, |
| 24 | }; | 24 | }; |
| ... | @@ -58,8 +58,8 @@ pub enum Cursor { | ... | @@ -58,8 +58,8 @@ pub enum Cursor { |
| 58 | pub enum Request { | 58 | pub enum Request { |
| 59 | /// Show the date or time picker, then report the choice through `PageView::change_date`. | 59 | /// Show the date or time picker, then report the choice through `PageView::change_date`. |
| 60 | EditDate(DateField), | 60 | EditDate(DateField), |
| 61 | /// Put this text on the clipboard. | 61 | /// Put this on the clipboard. |
| 62 | Copy(String), | 62 | Copy(Clip), |
| 63 | /// Read the clipboard and hand its text to `PageView::commit_text`. | 63 | /// Read the clipboard and hand its text to `PageView::commit_text`. |
| 64 | Paste, | 64 | Paste, |
| 65 | /// Open a link's address, as a click on a link does. | 65 | /// Open a link's address, as a click on a link does. |
| ... | @@ -1494,6 +1494,16 @@ impl PageView { | ... | @@ -1494,6 +1494,16 @@ impl PageView { |
| 1494 | self.edited() | 1494 | self.edited() |
| 1495 | } | 1495 | } |
| 1496 | 1496 | ||
| 1497 | /// Content Snowbound copied; see [`CanvasEditor::paste_clip`]. | ||
| 1498 | pub fn paste_clip(&mut self, clip: Clip) -> Result<Response> { | ||
| 1499 | if !self.accepts_text() { | ||
| 1500 | return Ok(Response::default()); | ||
| 1501 | } | ||
| 1502 | self.editor.paste_clip(&mut self.engine, clip)?; | ||
| 1503 | self.follow_pictures()?; | ||
| 1504 | self.edited() | ||
| 1505 | } | ||
| 1506 | |||
| 1497 | /// Clipboard text in `language`, an LCID; see [`CanvasEditor::paste`]. | 1507 | /// Clipboard text in `language`, an LCID; see [`CanvasEditor::paste`]. |
| 1498 | pub fn paste(&mut self, text: &str, language: u32) -> Result<Response> { | 1508 | pub fn paste(&mut self, text: &str, language: u32) -> Result<Response> { |
| 1499 | if !self.accepts_text() { | 1509 | if !self.accepts_text() { |
| ... | @@ -1551,53 +1561,19 @@ impl PageView { | ... | @@ -1551,53 +1561,19 @@ impl PageView { |
| 1551 | self.edited() | 1561 | self.edited() |
| 1552 | } | 1562 | } |
| 1553 | 1563 | ||
| 1554 | /// Copy, or Cut with `cut`: the selected text goes to the clipboard, the page selection's | 1564 | /// Copy, or Cut with `cut`: [`CanvasEditor::clip`] goes to the clipboard. |
| 1555 | /// outlines top to bottom with a blank line between, as OneNote 2010 copies them. | ||
| 1556 | pub fn copy(&mut self, cut: bool) -> Result<Response> { | 1565 | pub fn copy(&mut self, cut: bool) -> Result<Response> { |
| 1557 | let text = |outline: &TextOutline, selection: Selection| -> Result<String> { | ||
| 1558 | let [anchor, focus] = selection.positions; | ||
| 1559 | Ok(outline | ||
| 1560 | .document() | ||
| 1561 | .slice(anchor.min(focus)..anchor.max(focus))? | ||
| 1562 | .iter() | ||
| 1563 | .map(|paragraph| { | ||
| 1564 | paragraph | ||
| 1565 | .project() | ||
| 1566 | .map(|projection| projection.text().text().to_owned()) | ||
| 1567 | }) | ||
| 1568 | .collect::<std::result::Result<Vec<_>, _>>()? | ||
| 1569 | .join("\n")) | ||
| 1570 | }; | ||
| 1571 | let page = self.editor.whole() == Some(Whole::Page); | 1566 | let page = self.editor.whole() == Some(Whole::Page); |
| 1572 | let text = if page { | 1567 | let Some(clip) = self.editor.clip()? else { |
| 1573 | let mut outlines = self | ||
| 1574 | .editor | ||
| 1575 | .outlines() | ||
| 1576 | .iter() | ||
| 1577 | .filter(|outline| !outline.title) | ||
| 1578 | .collect::<Vec<_>>(); | ||
| 1579 | outlines.sort_by(|a, b| { | ||
| 1580 | let ([ax, ay], [bx, by]) = (a.origin(), b.origin()); | ||
| 1581 | ay.total_cmp(&by).then(ax.total_cmp(&bx)) | ||
| 1582 | }); | ||
| 1583 | outlines | ||
| 1584 | .into_iter() | ||
| 1585 | .map(|outline| text(outline, outline.whole())) | ||
| 1586 | .collect::<Result<Vec<_>>>()? | ||
| 1587 | .join("\n\n") | ||
| 1588 | } else { | ||
| 1589 | text(self.editor.active_outline(), self.editor.selection())? | ||
| 1590 | }; | ||
| 1591 | if text.is_empty() { | ||
| 1592 | return Ok(Response::default()); | 1568 | return Ok(Response::default()); |
| 1593 | } | 1569 | }; |
| 1594 | if cut && page { | 1570 | if cut && page { |
| 1595 | self.editor.delete(&mut self.engine, false)?; | 1571 | self.editor.delete(&mut self.engine, false)?; |
| 1596 | } else if cut { | 1572 | } else if cut { |
| 1597 | self.editor.insert(&mut self.engine, "")?; | 1573 | self.editor.insert(&mut self.engine, "")?; |
| 1598 | } | 1574 | } |
| 1599 | Ok(Response { | 1575 | Ok(Response { |
| 1600 | request: Some(Request::Copy(text)), | 1576 | request: Some(Request::Copy(clip)), |
| 1601 | ..self.edited()? | 1577 | ..self.edited()? |
| 1602 | }) | 1578 | }) |
| 1603 | } | 1579 | } |
| ... | @@ -1821,19 +1797,13 @@ impl PageView { | ... | @@ -1821,19 +1797,13 @@ impl PageView { |
| 1821 | self.edited() | 1797 | self.edited() |
| 1822 | } | 1798 | } |
| 1823 | 1799 | ||
| 1824 | /// Pasted text in `language`, an LCID, and pictures, in order and as one undo step; see | 1800 | /// Pasted clips and pictures, in order and as one undo step; see |
| 1825 | /// [`CanvasEditor::paste_pieces`]. | 1801 | /// [`CanvasEditor::paste_pieces`]. |
| 1826 | pub fn paste_pieces( | 1802 | pub fn paste_pieces(&mut self, pieces: Vec<Piece>) -> Result<(Vec<Awaited>, Response)> { |
| 1827 | &mut self, | ||
| 1828 | pieces: Vec<Piece>, | ||
| 1829 | language: u32, | ||
| 1830 | ) -> Result<(Vec<Awaited>, Response)> { | ||
| 1831 | if self.edits_wait() { | 1803 | if self.edits_wait() { |
| 1832 | return Ok((Vec::new(), Response::default())); | 1804 | return Ok((Vec::new(), Response::default())); |
| 1833 | } | 1805 | } |
| 1834 | let awaited = self | 1806 | let awaited = self.editor.paste_pieces(&mut self.engine, pieces)?; |
| 1835 | .editor | ||
| 1836 | .paste_pieces(&mut self.engine, pieces, language)?; | ||
| 1837 | self.follow_pictures()?; | 1807 | self.follow_pictures()?; |
| 1838 | Ok((awaited, self.edited()?)) | 1808 | Ok((awaited, self.edited()?)) |
| 1839 | } | 1809 | } |
crates/canvas/src/interaction/tests.rs+12-9| ... | @@ -1681,10 +1681,7 @@ fn events_route_through_the_view_as_the_host_delivers_them() { | ... | @@ -1681,10 +1681,7 @@ fn events_route_through_the_view_as_the_host_delivers_them() { |
| 1681 | }) | 1681 | }) |
| 1682 | .unwrap(); | 1682 | .unwrap(); |
| 1683 | let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap(); | 1683 | let _ = view.key(&Key::Named(NamedKey::ArrowRight), None).unwrap(); |
| 1684 | assert_eq!( | 1684 | assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some("B")); |
| 1685 | view.copy(false).unwrap().request, | ||
| 1686 | Some(Request::Copy("B".into())) | ||
| 1687 | ); | ||
| 1688 | let _ = view | 1685 | let _ = view |
| 1689 | .modifiers_changed(Modifiers { | 1686 | .modifiers_changed(Modifiers { |
| 1690 | command: true, | 1687 | command: true, |
| ... | @@ -2363,12 +2360,10 @@ fn keys_act_on_the_page_selection_as_onenote_does() { | ... | @@ -2363,12 +2360,10 @@ fn keys_act_on_the_page_selection_as_onenote_does() { |
| 2363 | fn copy_and_cut_take_the_page_selection() { | 2360 | fn copy_and_cut_take_the_page_selection() { |
| 2364 | let (mut view, [alpha, ..]) = page_selection_view(); | 2361 | let (mut view, [alpha, ..]) = page_selection_view(); |
| 2365 | select_page(&mut view, alpha); | 2362 | select_page(&mut view, alpha); |
| 2366 | let copied = Some(Request::Copy( | 2363 | let text = "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text"; |
| 2367 | "Delta\n\nAlpha one\nAlpha two\n\nBeta\n\nGamma long line of text".into(), | 2364 | assert_eq!(copied(view.copy(false).unwrap()).as_deref(), Some(text)); |
| 2368 | )); | ||
| 2369 | assert_eq!(view.copy(false).unwrap().request, copied); | ||
| 2370 | assert_eq!(view.editor.whole(), Some(Whole::Page)); | 2365 | assert_eq!(view.editor.whole(), Some(Whole::Page)); |
| 2371 | assert_eq!(view.copy(true).unwrap().request, copied); | 2366 | assert_eq!(copied(view.copy(true).unwrap()).as_deref(), Some(text)); |
| 2372 | assert!(view.editor.outlines().iter().all(|outline| outline.title)); | 2367 | assert!(view.editor.outlines().iter().all(|outline| outline.title)); |
| 2373 | assert!(view.editor.caret_outline().is_some()); | 2368 | assert!(view.editor.caret_outline().is_some()); |
| 2374 | } | 2369 | } |
| ... | @@ -3357,3 +3352,11 @@ fn a_column_border_drags_with_a_resize_pointer_and_stores_on_release() { | ... | @@ -3357,3 +3352,11 @@ fn a_column_border_drags_with_a_resize_pointer_and_stores_on_release() { |
| 3357 | assert!((table.columns[0].width - (37.11 + 50.0)).abs() < 0.01); | 3352 | assert!((table.columns[0].width - (37.11 + 50.0)).abs() < 0.01); |
| 3358 | assert_eq!(view.editor.take_ops().unwrap().len(), 1); | 3353 | assert_eq!(view.editor.take_ops().unwrap().len(), 1); |
| 3359 | } | 3354 | } |
| 3355 | |||
| 3356 | /// The text of what a response asks the host to copy. | ||
| 3357 | fn copied(response: Response) -> Option<String> { | ||
| 3358 | match response.request { | ||
| 3359 | Some(Request::Copy(clip)) => Some(clip.text()), | ||
| 3360 | _ => None, | ||
| 3361 | } | ||
| 3362 | } |
crates/canvas/tests/clip_paste.rs created+193| ... | @@ -0,0 +1,193 @@ | ||
| 1 | //! Formatted content pasted through the editor and stored: what OneNote 2010 copied as HTML | ||
| 2 | //! (`corpus/clipboard`), then that outline copied in Snowbound's own format and pasted again | ||
| 3 | //! elsewhere on the page, each one edit that the section stores and reads back. | ||
| 4 | //! `CANVAS_CLIP_EXPORT` names a directory receiving the section as a notebook, for a cold | ||
| 5 | //! reopen in OneNote 2010. | ||
| 6 | |||
| 7 | use canvas::{ | ||
| 8 | editor::{CanvasEditor, Clip, Piece, html_pieces}, | ||
| 9 | layout::TextEngine, | ||
| 10 | }; | ||
| 11 | use onestore::{ | ||
| 12 | Arena, Section, Store, | ||
| 13 | document::Kind, | ||
| 14 | op::{Edit, Op}, | ||
| 15 | page::{Outline, Page, PageObject, PageParagraph, ParagraphContent}, | ||
| 16 | }; | ||
| 17 | |||
| 18 | const ONENOTE: &str = include_str!("../../../corpus/clipboard/onenote-2010.html"); | ||
| 19 | |||
| 20 | /// Each paragraph of a body outline: its level, list kind, text and bold runs. | ||
| 21 | fn outline(page: &Page, outline: &Outline) -> Vec<String> { | ||
| 22 | fn lines(page: &Page, nodes: &[PageParagraph], out: &mut Vec<String>) { | ||
| 23 | for node in nodes { | ||
| 24 | let list = match node.lists.last().map(|id| &page.definitions[id].kind) { | ||
| 25 | Some(Kind::List { | ||
| 26 | bullet: Some(_), .. | ||
| 27 | }) => "• ", | ||
| 28 | Some(Kind::List { .. }) => "# ", | ||
| 29 | _ => "", | ||
| 30 | }; | ||
| 31 | let indent = " ".repeat(node.level as usize - 1); | ||
| 32 | match &node.content { | ||
| 33 | ParagraphContent::Text(text) => { | ||
| 34 | let shown = text.text.project().unwrap(); | ||
| 35 | let bold = shown | ||
| 36 | .text() | ||
| 37 | .spans() | ||
| 38 | .iter() | ||
| 39 | .any(|span| span.format.bold == Some(true)); | ||
| 40 | let bold = if bold { " (bold)" } else { "" }; | ||
| 41 | out.push(format!("{indent}{list}{}{bold}", shown.text().text())); | ||
| 42 | } | ||
| 43 | ParagraphContent::Table(table) => { | ||
| 44 | out.push(format!("{indent}table")); | ||
| 45 | for cell in table.rows.iter().flat_map(|row| &row.cells) { | ||
| 46 | lines(page, &cell.paragraphs, out); | ||
| 47 | } | ||
| 48 | } | ||
| 49 | _ => out.push(format!("{indent}other")), | ||
| 50 | } | ||
| 51 | } | ||
| 52 | } | ||
| 53 | let mut out = Vec::new(); | ||
| 54 | lines(page, &outline.paragraphs, &mut out); | ||
| 55 | out | ||
| 56 | } | ||
| 57 | |||
| 58 | fn bodies(page: &Page) -> Vec<Vec<String>> { | ||
| 59 | page.objects | ||
| 60 | .iter() | ||
| 61 | .filter_map(|object| match object { | ||
| 62 | PageObject::Outline(body) if !body.title => Some(outline(page, body)), | ||
| 63 | _ => None, | ||
| 64 | }) | ||
| 65 | .collect() | ||
| 66 | } | ||
| 67 | |||
| 68 | #[test] | ||
| 69 | fn onenotes_html_and_snowbounds_own_copy_paste_and_store() { | ||
| 70 | let source = onestore::create_section("clips.one", "Pasted clips", "Author").unwrap(); | ||
| 71 | let arena = Arena::default(); | ||
| 72 | let mut section = Section::open(&arena, source.clone()).unwrap(); | ||
| 73 | let (space, ..) = section.pages().unwrap()[0].clone(); | ||
| 74 | let mut engine = TextEngine::default(); | ||
| 75 | let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); | ||
| 76 | let mut at = 133_000_000_000_000_000; | ||
| 77 | let mut store = |editor: &mut CanvasEditor| { | ||
| 78 | let ops = editor.take_ops().unwrap(); | ||
| 79 | assert!(!ops.is_empty()); | ||
| 80 | at += 10_000_000; | ||
| 81 | let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); | ||
| 82 | section.apply("Author", &Edit { at, ops }).unwrap(); | ||
| 83 | }; | ||
| 84 | |||
| 85 | editor | ||
| 86 | .place_caret(&mut engine, [72.0, 120.0], 400.0) | ||
| 87 | .unwrap(); | ||
| 88 | let pieces = html_pieces(ONENOTE, 0x409, |_, _| None); | ||
| 89 | assert!(matches!(pieces[..], [Piece::Clip(_)])); | ||
| 90 | editor.paste_pieces(&mut engine, pieces).unwrap(); | ||
| 91 | store(&mut editor); | ||
| 92 | |||
| 93 | editor.select_all().unwrap(); | ||
| 94 | let copied = editor.clip().unwrap().unwrap(); | ||
| 95 | let copied = Clip::decode(&copied.encode()).unwrap(); | ||
| 96 | editor | ||
| 97 | .place_caret(&mut engine, [72.0, 500.0], 400.0) | ||
| 98 | .unwrap(); | ||
| 99 | editor.paste_clip(&mut engine, copied).unwrap(); | ||
| 100 | store(&mut editor); | ||
| 101 | |||
| 102 | let mut image = source; | ||
| 103 | section.seal().unwrap().unwrap().apply(&mut image).unwrap(); | ||
| 104 | let arena = Arena::default(); | ||
| 105 | let reopened = Section::open(&arena, image.clone()).unwrap(); | ||
| 106 | let page = reopened.page(space).unwrap(); | ||
| 107 | let pasted = [ | ||
| 108 | "Bold line (bold)", | ||
| 109 | "plain italic red big times under hi link", | ||
| 110 | "• bullet one", | ||
| 111 | " • nested bullet", | ||
| 112 | "• bullet two", | ||
| 113 | "# number one", | ||
| 114 | "# number two", | ||
| 115 | "tagged todo", | ||
| 116 | "after", | ||
| 117 | "table", | ||
| 118 | "a1", | ||
| 119 | "b1 (bold)", | ||
| 120 | "a2", | ||
| 121 | "b2", | ||
| 122 | "last line", | ||
| 123 | ]; | ||
| 124 | assert_eq!( | ||
| 125 | bodies(&page), | ||
| 126 | [vec!["Pasted clips"], pasted.to_vec(), pasted.to_vec()] | ||
| 127 | ); | ||
| 128 | assert_eq!(bodies(&page), bodies(&editor.page().unwrap())); | ||
| 129 | |||
| 130 | if let Some(directory) = std::env::var_os("CANVAS_CLIP_EXPORT") { | ||
| 131 | let directory = std::path::PathBuf::from(directory); | ||
| 132 | std::fs::create_dir_all(&directory).unwrap(); | ||
| 133 | std::fs::write(directory.join("clips.one"), &image).unwrap(); | ||
| 134 | let file_id = Store::parse(&image).unwrap().header.file_id; | ||
| 135 | std::fs::write( | ||
| 136 | directory.join("Open Notebook.onetoc2"), | ||
| 137 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("clips.one", file_id)]) | ||
| 138 | .unwrap(), | ||
| 139 | ) | ||
| 140 | .unwrap(); | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | /// A title takes a clip's text alone, as OneNote 2010's does (lab, 2026-10-02). | ||
| 145 | #[test] | ||
| 146 | fn a_title_takes_the_text_alone() { | ||
| 147 | let source = onestore::create_section("clips.one", "First page", "Author").unwrap(); | ||
| 148 | let arena = Arena::default(); | ||
| 149 | let mut section = Section::open(&arena, source).unwrap(); | ||
| 150 | let creation = onestore::PageCreation::new(None, Some(""), "Author").unwrap(); | ||
| 151 | let create = Op::Section(onestore::op::SectionOp::Create(creation)); | ||
| 152 | let at = 133_000_000_000_000_000; | ||
| 153 | section | ||
| 154 | .apply( | ||
| 155 | "Author", | ||
| 156 | &Edit { | ||
| 157 | at, | ||
| 158 | ops: vec![create], | ||
| 159 | }, | ||
| 160 | ) | ||
| 161 | .unwrap(); | ||
| 162 | let (space, ..) = section.pages().unwrap()[1].clone(); | ||
| 163 | let mut engine = TextEngine::default(); | ||
| 164 | let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); | ||
| 165 | let title = editor | ||
| 166 | .outlines() | ||
| 167 | .iter() | ||
| 168 | .find(|outline| outline.title) | ||
| 169 | .unwrap() | ||
| 170 | .id; | ||
| 171 | editor.focus_outline(title).unwrap(); | ||
| 172 | let pieces = html_pieces("<b>Bold</b> <i>title</i>", 0x409, |_, _| None); | ||
| 173 | editor.paste_pieces(&mut engine, pieces).unwrap(); | ||
| 174 | let ops = editor.take_ops().unwrap(); | ||
| 175 | let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); | ||
| 176 | section | ||
| 177 | .apply( | ||
| 178 | "Author", | ||
| 179 | &Edit { | ||
| 180 | at: at + 10_000_000, | ||
| 181 | ops, | ||
| 182 | }, | ||
| 183 | ) | ||
| 184 | .unwrap(); | ||
| 185 | let page = section.page(space).unwrap(); | ||
| 186 | assert_eq!(page.title, "Bold title"); | ||
| 187 | let title = editor | ||
| 188 | .outlines() | ||
| 189 | .iter() | ||
| 190 | .find(|outline| outline.title) | ||
| 191 | .unwrap(); | ||
| 192 | assert_eq!(title.shown_text(), "Bold title"); | ||
| 193 | } | ||
crates/canvas/tests/picture_paste.rs+11-17| ... | @@ -133,24 +133,16 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { | ... | @@ -133,24 +133,16 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { |
| 133 | .paste_pieces( | 133 | .paste_pieces( |
| 134 | &mut engine, | 134 | &mut engine, |
| 135 | vec![ | 135 | vec![ |
| 136 | Piece::Text("web ".into()), | 136 | pasted("web&nbsp;"), |
| 137 | Piece::Picture(png, [150.0, 75.0]), | 137 | Piece::Picture(png, [150.0, 75.0]), |
| 138 | Piece::Text("page\ntext ".into()), | 138 | pasted("page<br>text&nbsp;"), |
| 139 | ], | 139 | ], |
| 140 | 0x0409, | ||
| 141 | ) | 140 | ) |
| 142 | .unwrap(); | 141 | .unwrap(); |
| 143 | store(&mut editor); | 142 | store(&mut editor); |
| 144 | assert_eq!( | 143 | assert_eq!( |
| 145 | body(&editor), | 144 | body(&editor), |
| 146 | [ | 145 | ["Pasted web ", "[150.0, 75.0]", "page", "text ", "pictures"] |
| 147 | "Pasted web ", | ||
| 148 | "[150.0, 75.0]", | ||
| 149 | "", | ||
| 150 | "page", | ||
| 151 | "text ", | ||
| 152 | "pictures" | ||
| 153 | ] | ||
| 154 | ); | 146 | ); |
| 155 | editor.undo(&mut engine).unwrap(); | 147 | editor.undo(&mut engine).unwrap(); |
| 156 | store(&mut editor); | 148 | store(&mut editor); |
| ... | @@ -162,12 +154,7 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { | ... | @@ -162,12 +154,7 @@ fn pasted_pictures_split_the_text_or_lie_on_the_page_as_onenote_places_them() { |
| 162 | let awaited = editor | 154 | let awaited = editor |
| 163 | .paste_pieces( | 155 | .paste_pieces( |
| 164 | &mut engine, | 156 | &mut engine, |
| 165 | vec![ | 157 | vec![pasted("web"), Piece::Awaited, pasted("page&nbsp;")], |
| 166 | Piece::Text("web".into()), | ||
| 167 | Piece::Awaited, | ||
| 168 | Piece::Text("page ".into()), | ||
| 169 | ], | ||
| 170 | 0x0409, | ||
| 171 | ) | 158 | ) |
| 172 | .unwrap(); | 159 | .unwrap(); |
| 173 | store(&mut editor); | 160 | store(&mut editor); |
| ... | @@ -333,3 +320,10 @@ fn a_picture_pasted_from_the_title_goes_into_the_body() { | ... | @@ -333,3 +320,10 @@ fn a_picture_pasted_from_the_title_goes_into_the_body() { |
| 333 | [Some(start[0]), Some(margin[1] + row * 18.0)] | 320 | [Some(start[0]), Some(margin[1] + row * 18.0)] |
| 334 | ); | 321 | ); |
| 335 | } | 322 | } |
| 323 | |||
| 324 | /// Pasted text, as a web page gives it. | ||
| 325 | fn pasted(html: &str) -> Piece { | ||
| 326 | canvas::editor::html_pieces(html, 0x0409, |_, _| None) | ||
| 327 | .pop() | ||
| 328 | .unwrap() | ||
| 329 | } |
crates/mobile/src/lib.rs+2-2| ... | @@ -1225,8 +1225,8 @@ pub unsafe extern "C" fn sb_view_select_paragraph(view: &mut View, id: *const c_ | ... | @@ -1225,8 +1225,8 @@ pub unsafe extern "C" fn sb_view_select_paragraph(view: &mut View, id: *const c_ |
| 1225 | pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char { | 1225 | pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char { |
| 1226 | let mut copied = None; | 1226 | let mut copied = None; |
| 1227 | let result = view.canvas.page.copy(cut).map(|response| { | 1227 | let result = view.canvas.page.copy(cut).map(|response| { |
| 1228 | if let Some(Request::Copy(text)) = &response.request { | 1228 | if let Some(Request::Copy(clip)) = &response.request { |
| 1229 | copied = Some(text.clone()); | 1229 | copied = Some(clip.text()); |
| 1230 | } | 1230 | } |
| 1231 | moved(response) | 1231 | moved(response) |
| 1232 | }); | 1232 | }); |
crates/snowbound/Cargo.toml+3| ... | @@ -77,10 +77,13 @@ windows-sys = { version = "0.61", features = [ | ... | @@ -77,10 +77,13 @@ windows-sys = { version = "0.61", features = [ |
| 77 | "Win32_Storage_FileSystem", | 77 | "Win32_Storage_FileSystem", |
| 78 | "Win32_System_Com", | 78 | "Win32_System_Com", |
| 79 | "Win32_System_Console", | 79 | "Win32_System_Console", |
| 80 | "Win32_System_DataExchange", | ||
| 80 | "Win32_System_Diagnostics_Debug", | 81 | "Win32_System_Diagnostics_Debug", |
| 81 | "Win32_System_IO", | 82 | "Win32_System_IO", |
| 82 | "Win32_System_Kernel", | 83 | "Win32_System_Kernel", |
| 83 | "Win32_System_LibraryLoader", | 84 | "Win32_System_LibraryLoader", |
| 85 | "Win32_System_Memory", | ||
| 86 | "Win32_System_Ole", | ||
| 84 | "Win32_System_Pipes", | 87 | "Win32_System_Pipes", |
| 85 | "Win32_System_Registry", | 88 | "Win32_System_Registry", |
| 86 | "Win32_System_Threading", | 89 | "Win32_System_Threading", |
crates/snowbound/src/linux.rs+32| ... | @@ -197,6 +197,9 @@ pub fn install_title_bar(window: &Window) { | ... | @@ -197,6 +197,9 @@ pub fn install_title_bar(window: &Window) { |
| 197 | crate::desktop::set_toplevel_icon(window); | 197 | crate::desktop::set_toplevel_icon(window); |
| 198 | } | 198 | } |
| 199 | 199 | ||
| 200 | /// The HTML and Snowbound's own format of this process's last copy through X11. | ||
| 201 | static LAST_COPY: std::sync::Mutex<Option<(String, String)>> = std::sync::Mutex::new(None); | ||
| 202 | |||
| 200 | /// Wayland's clipboard through the window's own connection, as not every compositor offers | 203 | /// Wayland's clipboard through the window's own connection, as not every compositor offers |
| 201 | /// a clipboard to clients without a window; X11's otherwise. | 204 | /// a clipboard to clients without a window; X11's otherwise. |
| 202 | pub enum Clipboard { | 205 | pub enum Clipboard { |
| ... | @@ -226,6 +229,35 @@ impl Clipboard { | ... | @@ -226,6 +229,35 @@ impl Clipboard { |
| 226 | } | 229 | } |
| 227 | } | 230 | } |
| 228 | 231 | ||
| 232 | /// Text, HTML and Snowbound's own format, as one copy: see [`crate::paste::Copied`]. | ||
| 233 | /// smithay-clipboard offers text alone. | ||
| 234 | pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), arboard::Error> { | ||
| 235 | match self { | ||
| 236 | Self::Wayland(clipboard) => { | ||
| 237 | clipboard.store(copied.text.clone()); | ||
| 238 | Ok(()) | ||
| 239 | } | ||
| 240 | Self::X11(clipboard) => { | ||
| 241 | clipboard.set().html(&copied.html, Some(&copied.text))?; | ||
| 242 | *LAST_COPY.lock().unwrap() = Some((copied.html.clone(), copied.clip.clone())); | ||
| 243 | Ok(()) | ||
| 244 | } | ||
| 245 | } | ||
| 246 | } | ||
| 247 | |||
| 248 | /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of | ||
| 249 | /// Snowbound's own through, the last copy's, while the clipboard holds its HTML. | ||
| 250 | pub fn get_clip(&mut self) -> Option<String> { | ||
| 251 | if !matches!(self, Self::X11(_)) { | ||
| 252 | return None; | ||
| 253 | } | ||
| 254 | let html = self.get_html()?; | ||
| 255 | match &*LAST_COPY.lock().unwrap() { | ||
| 256 | Some((copied, clip)) if *copied == html => Some(clip.clone()), | ||
| 257 | _ => None, | ||
| 258 | } | ||
| 259 | } | ||
| 260 | |||
| 229 | pub fn get_text(&mut self) -> Result<String, Box<dyn std::error::Error>> { | 261 | pub fn get_text(&mut self) -> Result<String, Box<dyn std::error::Error>> { |
| 230 | Ok(match self { | 262 | Ok(match self { |
| 231 | Self::Wayland(clipboard) => clipboard.load()?, | 263 | Self::Wayland(clipboard) => clipboard.load()?, |
crates/snowbound/src/macos.rs+40-3| ... | @@ -195,10 +195,33 @@ impl Clipboard { | ... | @@ -195,10 +195,33 @@ impl Clipboard { |
| 195 | set.then_some(()).ok_or("The pasteboard refused the text") | 195 | set.then_some(()).ok_or("The pasteboard refused the text") |
| 196 | } | 196 | } |
| 197 | 197 | ||
| 198 | /// Text, HTML and Snowbound's own format, as one copy. | ||
| 199 | pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> { | ||
| 200 | let set = unsafe { | ||
| 201 | let _: isize = msg_send![&self.0, clearContents]; | ||
| 202 | [ | ||
| 203 | (TEXT, &copied.text), | ||
| 204 | ("public.html", &copied.html), | ||
| 205 | (CLIP, &copied.clip), | ||
| 206 | ] | ||
| 207 | .into_iter() | ||
| 208 | .all(|(kind, value)| { | ||
| 209 | let (value, kind) = (NSString::from_str(value), NSString::from_str(kind)); | ||
| 210 | msg_send![&self.0, setString: &*value, forType: &*kind] | ||
| 211 | }) | ||
| 212 | }; | ||
| 213 | set.then_some(()).ok_or("The pasteboard refused the copy") | ||
| 214 | } | ||
| 215 | |||
| 198 | pub fn get_text(&mut self) -> Result<String, &'static str> { | 216 | pub fn get_text(&mut self) -> Result<String, &'static str> { |
| 199 | self.string(TEXT).ok_or("The pasteboard holds no text") | 217 | self.string(TEXT).ok_or("The pasteboard holds no text") |
| 200 | } | 218 | } |
| 201 | 219 | ||
| 220 | /// What Snowbound itself copied, if it did. | ||
| 221 | pub fn get_clip(&mut self) -> Option<String> { | ||
| 222 | self.string(CLIP) | ||
| 223 | } | ||
| 224 | |||
| 202 | /// The files copied, as Finder puts them on the pasteboard. | 225 | /// The files copied, as Finder puts them on the pasteboard. |
| 203 | pub fn get_files(&mut self) -> Vec<std::path::PathBuf> { | 226 | pub fn get_files(&mut self) -> Vec<std::path::PathBuf> { |
| 204 | objc2::rc::autoreleasepool(|_| unsafe { | 227 | objc2::rc::autoreleasepool(|_| unsafe { |
| ... | @@ -262,6 +285,9 @@ impl Clipboard { | ... | @@ -262,6 +285,9 @@ impl Clipboard { |
| 262 | /// NSPasteboardTypeString. | 285 | /// NSPasteboardTypeString. |
| 263 | const TEXT: &str = "public.utf8-plain-text"; | 286 | const TEXT: &str = "public.utf8-plain-text"; |
| 264 | 287 | ||
| 288 | /// Snowbound's own format's pasteboard type. | ||
| 289 | const CLIP: &str = "net.paperclover.snowbound.clip"; | ||
| 290 | |||
| 265 | /// TIFF `data` as PNG data, keeping its resolution. | 291 | /// TIFF `data` as PNG data, keeping its resolution. |
| 266 | unsafe fn tiff_png(data: &AnyObject) -> Option<Retained<AnyObject>> { | 292 | unsafe fn tiff_png(data: &AnyObject) -> Option<Retained<AnyObject>> { |
| 267 | const PNG: usize = 4; // NSBitmapImageFileTypePNG | 293 | const PNG: usize = 4; // NSBitmapImageFileTypePNG |
| ... | @@ -1541,10 +1567,10 @@ mod tests { | ... | @@ -1541,10 +1567,10 @@ mod tests { |
| 1541 | use super::*; | 1567 | use super::*; |
| 1542 | use crate::library::{Login, Mount}; | 1568 | use crate::library::{Login, Mount}; |
| 1543 | 1569 | ||
| 1544 | /// Text, a page and files read back as written, from a pasteboard of the test's own, | 1570 | /// Text, a page, files and a copy's every format read back as written, from a |
| 1545 | /// never the user's. | 1571 | /// pasteboard of the test's own, never the user's. |
| 1546 | #[test] | 1572 | #[test] |
| 1547 | fn the_pasteboard_reads_back_text_pages_and_files() { | 1573 | fn the_pasteboard_reads_back_text_pages_files_and_copies() { |
| 1548 | let board: Retained<AnyObject> = | 1574 | let board: Retained<AnyObject> = |
| 1549 | unsafe { msg_send_id![class!(NSPasteboard), pasteboardWithUniqueName] }; | 1575 | unsafe { msg_send_id![class!(NSPasteboard), pasteboardWithUniqueName] }; |
| 1550 | let mut clipboard = Clipboard(board.clone()); | 1576 | let mut clipboard = Clipboard(board.clone()); |
| ... | @@ -1567,6 +1593,17 @@ mod tests { | ... | @@ -1567,6 +1593,17 @@ mod tests { |
| 1567 | let _: bool = msg_send![&board, writeObjects: &*urls]; | 1593 | let _: bool = msg_send![&board, writeObjects: &*urls]; |
| 1568 | } | 1594 | } |
| 1569 | assert_eq!(clipboard.get_files(), [std::path::PathBuf::from(path)]); | 1595 | assert_eq!(clipboard.get_files(), [std::path::PathBuf::from(path)]); |
| 1596 | let copied = crate::paste::Copied { | ||
| 1597 | text: "Copied".into(), | ||
| 1598 | html: "<p>Copied</p>".into(), | ||
| 1599 | clip: "{}".into(), | ||
| 1600 | }; | ||
| 1601 | clipboard.set(&copied).unwrap(); | ||
| 1602 | assert_eq!(clipboard.get_text().unwrap(), "Copied"); | ||
| 1603 | assert_eq!(clipboard.get_html().as_deref(), Some("<p>Copied</p>")); | ||
| 1604 | assert_eq!(clipboard.get_clip().as_deref(), Some("{}")); | ||
| 1605 | clipboard.set_text("Plain".into()).unwrap(); | ||
| 1606 | assert_eq!(clipboard.get_clip(), None); | ||
| 1570 | unsafe { msg_send![&board, releaseGlobally] } | 1607 | unsafe { msg_send![&board, releaseGlobally] } |
| 1571 | } | 1608 | } |
| 1572 | 1609 |
crates/snowbound/src/main.rs+26-12| ... | @@ -89,6 +89,7 @@ mod watch; | ... | @@ -89,6 +89,7 @@ mod watch; |
| 89 | 89 | ||
| 90 | #[cfg(not(target_arch = "wasm32"))] | 90 | #[cfg(not(target_arch = "wasm32"))] |
| 91 | use accesskit_winit::Adapter as AccessAdapter; | 91 | use accesskit_winit::Adapter as AccessAdapter; |
| 92 | use canvas::editor::Clip; | ||
| 92 | #[cfg(not(target_arch = "wasm32"))] | 93 | #[cfg(not(target_arch = "wasm32"))] |
| 93 | use canvas::editor::{DEFAULT_OUTLINE_WIDTH, TextOutline}; | 94 | use canvas::editor::{DEFAULT_OUTLINE_WIDTH, TextOutline}; |
| 94 | use canvas::gpu::colorref; | 95 | use canvas::gpu::colorref; |
| ... | @@ -255,14 +256,23 @@ struct Redraw(EventLoopProxy<UserEvent>); | ... | @@ -255,14 +256,23 @@ struct Redraw(EventLoopProxy<UserEvent>); |
| 255 | 256 | ||
| 256 | enum Clipboard { | 257 | enum Clipboard { |
| 257 | System(platform::Clipboard), | 258 | System(platform::Clipboard), |
| 258 | Memory(String), | 259 | /// What was last copied, text alone or a clip. |
| 260 | Memory(String, Option<Clip>), | ||
| 259 | } | 261 | } |
| 260 | 262 | ||
| 261 | impl Clipboard { | 263 | impl Clipboard { |
| 262 | fn set_text(&mut self, text: String) -> Result<(), Box<dyn Error>> { | 264 | fn set_text(&mut self, text: String) -> Result<(), Box<dyn Error>> { |
| 263 | match self { | 265 | match self { |
| 264 | Self::System(clipboard) => clipboard.set_text(text)?, | 266 | Self::System(clipboard) => clipboard.set_text(text)?, |
| 265 | Self::Memory(held) => *held = text, | 267 | Self::Memory(held, clip) => (*held, *clip) = (text, None), |
| 268 | } | ||
| 269 | Ok(()) | ||
| 270 | } | ||
| 271 | |||
| 272 | fn set(&mut self, clip: Clip) -> Result<(), Box<dyn Error>> { | ||
| 273 | match self { | ||
| 274 | Self::System(clipboard) => clipboard.set(&paste::Copied::new(&clip))?, | ||
| 275 | Self::Memory(held, kept) => (*held, *kept) = (clip.text(), Some(clip)), | ||
| 266 | } | 276 | } |
| 267 | Ok(()) | 277 | Ok(()) |
| 268 | } | 278 | } |
| ... | @@ -271,21 +281,25 @@ impl Clipboard { | ... | @@ -271,21 +281,25 @@ impl Clipboard { |
| 271 | match self { | 281 | match self { |
| 272 | Self::System(clipboard) => { | 282 | Self::System(clipboard) => { |
| 273 | let files = clipboard.get_files(); | 283 | let files = clipboard.get_files(); |
| 284 | let text = |clipboard: &mut platform::Clipboard| { | ||
| 285 | clipboard.get_text().ok().filter(|text| !text.is_empty()) | ||
| 286 | }; | ||
| 274 | if !files.is_empty() { | 287 | if !files.is_empty() { |
| 275 | Some(paste::Pasted::Files(files)) | 288 | Some(paste::Pasted::Files(files)) |
| 276 | } else if let Some(html) = clipboard | 289 | } else if let Some(clip) = clipboard.get_clip().as_deref().and_then(Clip::decode) { |
| 277 | .get_html() | 290 | Some(paste::Pasted::Clip(clip)) |
| 278 | .filter(|html| html.to_ascii_lowercase().contains("<img")) | 291 | } else if let Some(html) = clipboard.get_html() { |
| 279 | { | 292 | Some(paste::Pasted::Page(html, text(clipboard))) |
| 280 | Some(paste::Pasted::Page(html)) | 293 | } else if let Some(text) = text(clipboard) { |
| 281 | } else if let Some(text) = clipboard.get_text().ok().filter(|text| !text.is_empty()) | ||
| 282 | { | ||
| 283 | Some(paste::Pasted::Text(text)) | 294 | Some(paste::Pasted::Text(text)) |
| 284 | } else { | 295 | } else { |
| 285 | clipboard.get_picture().map(paste::Pasted::Picture) | 296 | clipboard.get_picture().map(paste::Pasted::Picture) |
| 286 | } | 297 | } |
| 287 | } | 298 | } |
| 288 | Self::Memory(held) => Some(paste::Pasted::Text(held.clone())), | 299 | Self::Memory(held, clip) => Some(match clip { |
| 300 | Some(clip) => paste::Pasted::Clip(clip.clone()), | ||
| 301 | None => paste::Pasted::Text(held.clone()), | ||
| 302 | }), | ||
| 289 | } | 303 | } |
| 290 | } | 304 | } |
| 291 | } | 305 | } |
| ... | @@ -1068,7 +1082,7 @@ impl State { | ... | @@ -1068,7 +1082,7 @@ impl State { |
| 1068 | let clipboard = if visible && std::env::var_os("SNOWBOUND_REPLAY").is_none() { | 1082 | let clipboard = if visible && std::env::var_os("SNOWBOUND_REPLAY").is_none() { |
| 1069 | Clipboard::System(platform::Clipboard::new(&window)?) | 1083 | Clipboard::System(platform::Clipboard::new(&window)?) |
| 1070 | } else { | 1084 | } else { |
| 1071 | Clipboard::Memory(String::new()) | 1085 | Clipboard::Memory(String::new(), None) |
| 1072 | }; | 1086 | }; |
| 1073 | let redraw: std::task::Waker = Arc::new(Redraw(proxy.clone())).into(); | 1087 | let redraw: std::task::Waker = Arc::new(Redraw(proxy.clone())).into(); |
| 1074 | let search = search::Search::new(stored.search_scope, redraw.clone()); | 1088 | let search = search::Search::new(stored.search_scope, redraw.clone()); |
| ... | @@ -3159,7 +3173,7 @@ impl State { | ... | @@ -3159,7 +3173,7 @@ impl State { |
| 3159 | } | 3173 | } |
| 3160 | Command::Template(choice) => self.apply_template(choice)?, | 3174 | Command::Template(choice) => self.apply_template(choice)?, |
| 3161 | Command::Page(Request::EditDate(field)) => self.edit_date(field)?, | 3175 | Command::Page(Request::EditDate(field)) => self.edit_date(field)?, |
| 3162 | Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, | 3176 | Command::Page(Request::Copy(clip)) => self.clipboard.set(clip)?, |
| 3163 | Command::Page(Request::Paste) => self.paste()?, | 3177 | Command::Page(Request::Paste) => self.paste()?, |
| 3164 | Command::Page(Request::OpenLink(address)) => self.open_link(&address)?, | 3178 | Command::Page(Request::OpenLink(address)) => self.open_link(&address)?, |
| 3165 | Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?, | 3179 | Command::Page(Request::OpenAttachment(file)) => self.open_attachment(&file)?, |
crates/snowbound/src/paste.rs+76-234| ... | @@ -1,20 +1,62 @@ | ... | @@ -1,20 +1,62 @@ |
| 1 | //! Pictures and files from the clipboard, and pictures inserted, as OneNote 2010 places them. | 1 | //! The clipboard's formats: what Copy offers, and pictures, files and pages Paste takes from |
| 2 | //! it, with pictures inserted, as OneNote 2010 places them. | ||
| 2 | 3 | ||
| 3 | use crate::{State, platform}; | 4 | use crate::{State, platform}; |
| 4 | use canvas::editor::Piece; | 5 | use canvas::editor::{Clip, Piece}; |
| 5 | use std::{ | 6 | use std::{ |
| 6 | error::Error, | 7 | error::Error, |
| 7 | path::{Path, PathBuf}, | 8 | path::{Path, PathBuf}, |
| 8 | }; | 9 | }; |
| 9 | 10 | ||
| 11 | /// Every format Copy puts on the clipboard for one selection, which each platform's clipboard | ||
| 12 | /// offers together: text; HTML, as OneNote 2010 offers it beside text (lab, 2026-10-02); and | ||
| 13 | /// Snowbound's own format, [`Clip::encode`]'s JSON, which pastes losslessly into Snowbound. | ||
| 14 | /// Its name is `net.paperclover.snowbound.clip` on macOS, `Snowbound Clip` on Windows and | ||
| 15 | /// `application/x-snowbound-clip` where formats are MIME types. | ||
| 16 | pub(crate) struct Copied { | ||
| 17 | pub text: String, | ||
| 18 | /// A whole page, the copy between `<!--StartFragment-->` and `<!--EndFragment-->`. | ||
| 19 | pub html: String, | ||
| 20 | pub clip: String, | ||
| 21 | } | ||
| 22 | |||
| 23 | impl Copied { | ||
| 24 | pub(crate) fn new(clip: &Clip) -> Self { | ||
| 25 | Self { | ||
| 26 | text: clip.text(), | ||
| 27 | html: clip.html(), | ||
| 28 | clip: clip.encode(), | ||
| 29 | } | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | /// `html` behind the header Windows' `HTML Format` begins with, which gives the byte offsets | ||
| 34 | /// of the page and of the fragment its comments mark out. | ||
| 35 | #[cfg(any(windows, test))] | ||
| 36 | pub(crate) fn cf_html(html: &str) -> String { | ||
| 37 | let header = |offsets: [usize; 4]| { | ||
| 38 | format!( | ||
| 39 | "Version:0.9\r\nStartHTML:{:010}\r\nEndHTML:{:010}\r\nStartFragment:{:010}\r\nEndFragment:{:010}\r\n", | ||
| 40 | offsets[0], offsets[1], offsets[2], offsets[3] | ||
| 41 | ) | ||
| 42 | }; | ||
| 43 | let start = header([0; 4]).len(); | ||
| 44 | let fragment = html | ||
| 45 | .find("<!--StartFragment-->") | ||
| 46 | .map_or(0, |at| at + "<!--StartFragment-->".len()); | ||
| 47 | let end = html.find("<!--EndFragment-->").unwrap_or(html.len()); | ||
| 48 | header([start, start + html.len(), start + fragment, start + end]) + html | ||
| 49 | } | ||
| 50 | |||
| 10 | /// What Paste takes from the clipboard. Files come first, as Finder also offers their names | 51 | /// What Paste takes from the clipboard. Files come first, as Finder also offers their names |
| 11 | /// as text and their icons as pictures; then a page holding pictures, as its text alone | 52 | /// as text and their icons as pictures; then Snowbound's own copy, then a page, as its text |
| 12 | /// would lose them; text before a picture, as Office and browsers offer a picture of copied | 53 | /// alone would lose its formatting and pictures; text before a picture, as Office and |
| 13 | /// text beside it. | 54 | /// browsers offer a picture of copied text beside it. |
| 14 | pub(crate) enum Pasted { | 55 | pub(crate) enum Pasted { |
| 15 | Files(Vec<PathBuf>), | 56 | Files(Vec<PathBuf>), |
| 16 | /// HTML holding a picture. | 57 | Clip(Clip), |
| 17 | Page(String), | 58 | /// A web page, and the clipboard's text for one that holds nothing to paste. |
| 59 | Page(String, Option<String>), | ||
| 18 | Text(String), | 60 | Text(String), |
| 19 | Picture(Vec<u8>), | 61 | Picture(Vec<u8>), |
| 20 | } | 62 | } |
| ... | @@ -27,10 +69,15 @@ impl State { | ... | @@ -27,10 +69,15 @@ impl State { |
| 27 | self.place_file(&path, None)?; | 69 | self.place_file(&path, None)?; |
| 28 | } | 70 | } |
| 29 | } | 71 | } |
| 30 | Some(Pasted::Page(html)) => { | 72 | Some(Pasted::Clip(clip)) => { |
| 73 | let response = self.view.paste_clip(clip)?; | ||
| 74 | self.respond(response); | ||
| 75 | } | ||
| 76 | Some(Pasted::Page(html, text)) => { | ||
| 31 | // Web pictures come later, so the window never waits on the network. | 77 | // Web pictures come later, so the window never waits on the network. |
| 32 | let mut fetched = Vec::new(); | 78 | let mut fetched = Vec::new(); |
| 33 | let pieces = html_pieces(&html, |source, css| { | 79 | let language = canvas::language::lcid(&platform::input_language()); |
| 80 | let pieces = canvas::editor::html_pieces(&html, language, |source, css| { | ||
| 34 | if source.starts_with("https://") || source.starts_with("http://") { | 81 | if source.starts_with("https://") || source.starts_with("http://") { |
| 35 | fetched.push((source.to_owned(), css)); | 82 | fetched.push((source.to_owned(), css)); |
| 36 | return Some(Piece::Awaited); | 83 | return Some(Piece::Awaited); |
| ... | @@ -39,8 +86,14 @@ impl State { | ... | @@ -39,8 +86,14 @@ impl State { |
| 39 | let size = sized(&bytes, css)?; | 86 | let size = sized(&bytes, css)?; |
| 40 | Some(Piece::Picture(bytes, size)) | 87 | Some(Piece::Picture(bytes, size)) |
| 41 | }); | 88 | }); |
| 42 | let language = canvas::language::lcid(&platform::input_language()); | 89 | if pieces.is_empty() { |
| 43 | let (places, response) = self.view.paste_pieces(pieces, language)?; | 90 | if let Some(text) = text { |
| 91 | let response = self.view.paste(&text, language)?; | ||
| 92 | self.respond(response); | ||
| 93 | } | ||
| 94 | return Ok(()); | ||
| 95 | } | ||
| 96 | let (places, response) = self.view.paste_pieces(pieces)?; | ||
| 44 | self.respond(response); | 97 | self.respond(response); |
| 45 | for (place, (source, css)) in places.into_iter().zip(fetched) { | 98 | for (place, (source, css)) in places.into_iter().zip(fetched) { |
| 46 | let proxy = self.proxy.clone(); | 99 | let proxy = self.proxy.clone(); |
| ... | @@ -111,179 +164,6 @@ impl State { | ... | @@ -111,179 +164,6 @@ impl State { |
| 111 | } | 164 | } |
| 112 | } | 165 | } |
| 113 | 166 | ||
| 114 | /// A web page fragment's text, a line to each block, and its pictures in order, as OneNote | ||
| 115 | /// 2010 pastes them (lab, 2026-09-30). `load` makes a picture's piece from its source and | ||
| 116 | /// its `width` and `height` in CSS pixels; one it can't is left out. | ||
| 117 | fn html_pieces( | ||
| 118 | html: &str, | ||
| 119 | mut load: impl FnMut(&str, [Option<f32>; 2]) -> Option<Piece>, | ||
| 120 | ) -> Vec<Piece> { | ||
| 121 | let html = html | ||
| 122 | .split_once("<!--StartFragment-->") | ||
| 123 | .map_or(html, |(_, fragment)| fragment); | ||
| 124 | let html = html | ||
| 125 | .split_once("<!--EndFragment-->") | ||
| 126 | .map_or(html, |(fragment, _)| fragment); | ||
| 127 | let mut pieces = Vec::new(); | ||
| 128 | let mut text = String::new(); | ||
| 129 | let flush = |text: &mut String, pieces: &mut Vec<Piece>| { | ||
| 130 | let trimmed = text.trim_matches(|c: char| c.is_whitespace()); | ||
| 131 | if !trimmed.is_empty() { | ||
| 132 | pieces.push(Piece::Text(trimmed.to_owned())); | ||
| 133 | } | ||
| 134 | text.clear(); | ||
| 135 | }; | ||
| 136 | let line = |text: &mut String| { | ||
| 137 | text.truncate(text.trim_end_matches(' ').len()); | ||
| 138 | if !text.is_empty() && !text.ends_with('\n') { | ||
| 139 | text.push('\n'); | ||
| 140 | } | ||
| 141 | }; | ||
| 142 | // Elements whose content is not text: script, style, head and title. | ||
| 143 | let mut hidden: Option<String> = None; | ||
| 144 | let mut rest = html; | ||
| 145 | while !rest.is_empty() { | ||
| 146 | if let Some(after) = rest.strip_prefix("<!--") { | ||
| 147 | rest = after.split_once("-->").map_or("", |(_, after)| after); | ||
| 148 | continue; | ||
| 149 | } | ||
| 150 | let Some(after) = rest.strip_prefix('<') else { | ||
| 151 | let end = rest.find('<').unwrap_or(rest.len()); | ||
| 152 | if hidden.is_none() { | ||
| 153 | for c in decode_entities(&rest[..end]).chars() { | ||
| 154 | if c.is_whitespace() && c != '\u{a0}' { | ||
| 155 | if !text.is_empty() && !text.ends_with([' ', '\n']) { | ||
| 156 | text.push(' '); | ||
| 157 | } | ||
| 158 | } else { | ||
| 159 | text.push(if c == '\u{a0}' { ' ' } else { c }); | ||
| 160 | } | ||
| 161 | } | ||
| 162 | } | ||
| 163 | rest = &rest[end..]; | ||
| 164 | continue; | ||
| 165 | }; | ||
| 166 | let end = tag_end(after); | ||
| 167 | let tag = &after[..end]; | ||
| 168 | rest = after.get(end + 1..).unwrap_or(""); | ||
| 169 | let (closing, tag) = match tag.strip_prefix('/') { | ||
| 170 | Some(tag) => (true, tag), | ||
| 171 | None => (false, tag), | ||
| 172 | }; | ||
| 173 | let name = tag | ||
| 174 | .split(|c: char| c.is_whitespace() || c == '/') | ||
| 175 | .next() | ||
| 176 | .unwrap_or_default() | ||
| 177 | .to_ascii_lowercase(); | ||
| 178 | if let Some(until) = &hidden { | ||
| 179 | if closing && name == *until { | ||
| 180 | hidden = None; | ||
| 181 | } | ||
| 182 | continue; | ||
| 183 | } | ||
| 184 | match name.as_str() { | ||
| 185 | "script" | "style" | "head" | "title" if !closing => hidden = Some(name), | ||
| 186 | "br" => { | ||
| 187 | text.truncate(text.trim_end_matches(' ').len()); | ||
| 188 | text.push('\n'); | ||
| 189 | } | ||
| 190 | "p" | "div" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | ||
| 191 | | "pre" | "table" | "ul" | "ol" | "dt" | "dd" | "section" | "article" | "header" | ||
| 192 | | "footer" | "figure" | "figcaption" => line(&mut text), | ||
| 193 | "img" if !closing => { | ||
| 194 | let css = |name| { | ||
| 195 | attribute(tag, name) | ||
| 196 | .and_then(|value| value.trim_end_matches("px").parse::<f32>().ok()) | ||
| 197 | .filter(|pixels| *pixels > 0.0) | ||
| 198 | }; | ||
| 199 | let Some(picture) = attribute(tag, "src").and_then(|source| { | ||
| 200 | load(&decode_entities(source), [css("width"), css("height")]) | ||
| 201 | }) else { | ||
| 202 | continue; | ||
| 203 | }; | ||
| 204 | flush(&mut text, &mut pieces); | ||
| 205 | pieces.push(picture); | ||
| 206 | } | ||
| 207 | _ => {} | ||
| 208 | } | ||
| 209 | } | ||
| 210 | flush(&mut text, &mut pieces); | ||
| 211 | pieces | ||
| 212 | } | ||
| 213 | |||
| 214 | /// Where a tag's text ends, before its `>`, outside quoted attribute values. | ||
| 215 | fn tag_end(tag: &str) -> usize { | ||
| 216 | let mut quote = None; | ||
| 217 | for (at, c) in tag.char_indices() { | ||
| 218 | match (quote, c) { | ||
| 219 | (None, '"' | '\'') => quote = Some(c), | ||
| 220 | (Some(open), _) if c == open => quote = None, | ||
| 221 | (None, '>') => return at, | ||
| 222 | _ => {} | ||
| 223 | } | ||
| 224 | } | ||
| 225 | tag.len() | ||
| 226 | } | ||
| 227 | |||
| 228 | /// The value of attribute `name` in `tag`, entities still encoded. | ||
| 229 | fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> { | ||
| 230 | let mut rest = tag; | ||
| 231 | while let Some(at) = rest.find('=') { | ||
| 232 | let key = rest[..at].trim_end().rsplit(char::is_whitespace).next()?; | ||
| 233 | let value = rest[at + 1..].trim_start(); | ||
| 234 | let (value, after) = match value.chars().next()? { | ||
| 235 | quote @ ('"' | '\'') => value[1..].split_once(quote)?, | ||
| 236 | _ => value.split_at(value.find(char::is_whitespace).unwrap_or(value.len())), | ||
| 237 | }; | ||
| 238 | if key.eq_ignore_ascii_case(name) { | ||
| 239 | return Some(value); | ||
| 240 | } | ||
| 241 | rest = after; | ||
| 242 | } | ||
| 243 | None | ||
| 244 | } | ||
| 245 | |||
| 246 | fn decode_entities(text: &str) -> String { | ||
| 247 | let mut decoded = String::with_capacity(text.len()); | ||
| 248 | let mut rest = text; | ||
| 249 | while let Some(at) = rest.find('&') { | ||
| 250 | decoded.push_str(&rest[..at]); | ||
| 251 | rest = &rest[at..]; | ||
| 252 | let entity = rest[1..] | ||
| 253 | .find(';') | ||
| 254 | .filter(|end| *end <= 10) | ||
| 255 | .map(|end| &rest[1..end + 1]); | ||
| 256 | let c = entity.and_then(|entity| match entity { | ||
| 257 | "amp" => Some('&'), | ||
| 258 | "lt" => Some('<'), | ||
| 259 | "gt" => Some('>'), | ||
| 260 | "quot" => Some('"'), | ||
| 261 | "apos" => Some('\''), | ||
| 262 | "nbsp" => Some('\u{a0}'), | ||
| 263 | _ => { | ||
| 264 | let number = entity.strip_prefix('#')?; | ||
| 265 | let code = match number.strip_prefix(['x', 'X']) { | ||
| 266 | Some(hex) => u32::from_str_radix(hex, 16).ok()?, | ||
| 267 | None => number.parse().ok()?, | ||
| 268 | }; | ||
| 269 | char::from_u32(code) | ||
| 270 | } | ||
| 271 | }); | ||
| 272 | match (c, entity) { | ||
| 273 | (Some(c), Some(entity)) => { | ||
| 274 | decoded.push(c); | ||
| 275 | rest = &rest[entity.len() + 2..]; | ||
| 276 | } | ||
| 277 | _ => { | ||
| 278 | decoded.push('&'); | ||
| 279 | rest = &rest[1..]; | ||
| 280 | } | ||
| 281 | } | ||
| 282 | } | ||
| 283 | decoded.push_str(rest); | ||
| 284 | decoded | ||
| 285 | } | ||
| 286 | |||
| 287 | /// A picture's size in points from its `width` and `height` in CSS pixels, either giving | 167 | /// A picture's size in points from its `width` and `height` in CSS pixels, either giving |
| 288 | /// the other by its proportions, or else from its resolution; none where it doesn't decode. | 168 | /// the other by its proportions, or else from its resolution; none where it doesn't decode. |
| 289 | fn sized(bytes: &[u8], css: [Option<f32>; 2]) -> Option<[f32; 2]> { | 169 | fn sized(bytes: &[u8], css: [Option<f32>; 2]) -> Option<[f32; 2]> { |
| ... | @@ -597,58 +477,20 @@ mod tests { | ... | @@ -597,58 +477,20 @@ mod tests { |
| 597 | bytes | 477 | bytes |
| 598 | } | 478 | } |
| 599 | 479 | ||
| 600 | fn shown(pieces: &[Piece]) -> Vec<String> { | 480 | /// The header's offsets find the page and the fragment, counted in bytes. |
| 601 | pieces | ||
| 602 | .iter() | ||
| 603 | .map(|piece| match piece { | ||
| 604 | Piece::Text(text) => text.clone(), | ||
| 605 | Piece::Picture(bytes, size) => format!("[{} {size:?}]", bytes.len()), | ||
| 606 | Piece::Awaited => "[awaited]".to_owned(), | ||
| 607 | }) | ||
| 608 | .collect() | ||
| 609 | } | ||
| 610 | |||
| 611 | /// As OneNote 2010 pasted `<p>before <img> after</p>` (lab, 2026-09-30): the text on | ||
| 612 | /// either side of the picture, the picture at the size its attributes give. | ||
| 613 | #[test] | 481 | #[test] |
| 614 | fn a_pasted_page_gives_its_text_and_pictures_in_order() { | 482 | fn the_html_format_header_gives_its_offsets() { |
| 615 | let html = "Version:0.9\r\n<html><head><style>p{}</style><title>T</title></head>\ | 483 | let html = |
| 616 | <body><!--StartFragment--><p>before <img alt=\"a > b\" \ | 484 | "<html><body><!--StartFragment--><p>\u{e9}t\u{e9}</p><!--EndFragment--></body></html>"; |
| 617 | src=\"file:///C:/work/pic.png\" width=\"200\" height=\"100px\"> after</p>\ | 485 | let wrapped = cf_html(html); |
| 618 | <p>Fish &amp; chips&nbsp;&#x2014;&#8212;</p><ul><li>one<li>two<br>three</ul>\ | 486 | let offset = |name: &str| { |
| 619 | <img src=\"missing.png\"><IMG SRC='https://example.invalid/a.png' width=40>\ | 487 | let at = wrapped.find(name).unwrap() + name.len() + 1; |
| 620 | <!--EndFragment--></body></html>"; | 488 | wrapped[at..at + 10].parse::<usize>().unwrap() |
| 621 | let mut asked = Vec::new(); | 489 | }; |
| 622 | let pieces = html_pieces(html, |source, css| { | 490 | assert_eq!(&wrapped[offset("StartHTML")..offset("EndHTML")], html); |
| 623 | asked.push((source.to_owned(), css)); | ||
| 624 | match source { | ||
| 625 | "missing.png" => None, | ||
| 626 | "https://example.invalid/a.png" => Some(Piece::Awaited), | ||
| 627 | _ => Some(Piece::Picture(vec![0; 3], [150.0, 75.0])), | ||
| 628 | } | ||
| 629 | }); | ||
| 630 | assert_eq!( | ||
| 631 | asked, | ||
| 632 | [ | ||
| 633 | ( | ||
| 634 | "file:///C:/work/pic.png".to_owned(), | ||
| 635 | [Some(200.0), Some(100.0)] | ||
| 636 | ), | ||
| 637 | ("missing.png".to_owned(), [None, None]), | ||
| 638 | ( | ||
| 639 | "https://example.invalid/a.png".to_owned(), | ||
| 640 | [Some(40.0), None] | ||
| 641 | ), | ||
| 642 | ] | ||
| 643 | ); | ||
| 644 | assert_eq!( | 491 | assert_eq!( |
| 645 | shown(&pieces), | 492 | &wrapped[offset("StartFragment")..offset("EndFragment")], |
| 646 | [ | 493 | "<p>\u{e9}t\u{e9}</p>" |
| 647 | "before", | ||
| 648 | "[3 [150.0, 75.0]]", | ||
| 649 | "after\nFish & chips \u{2014}\u{2014}\none\ntwo\nthree", | ||
| 650 | "[awaited]", | ||
| 651 | ] | ||
| 652 | ); | 494 | ); |
| 653 | } | 495 | } |
| 654 | 496 |
crates/snowbound/src/web.rs+9| ... | @@ -447,6 +447,15 @@ impl Clipboard { | ... | @@ -447,6 +447,15 @@ impl Clipboard { |
| 447 | Ok(()) | 447 | Ok(()) |
| 448 | } | 448 | } |
| 449 | 449 | ||
| 450 | /// A copy's text, the format every browser's clipboard takes. | ||
| 451 | pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box<dyn Error>> { | ||
| 452 | self.set_text(copied.text.clone()) | ||
| 453 | } | ||
| 454 | |||
| 455 | pub fn get_clip(&mut self) -> Option<String> { | ||
| 456 | None | ||
| 457 | } | ||
| 458 | |||
| 450 | pub fn get_text(&mut self) -> Result<String, Box<dyn Error>> { | 459 | pub fn get_text(&mut self) -> Result<String, Box<dyn Error>> { |
| 451 | PASTED | 460 | PASTED |
| 452 | .with_borrow(|pasted| pasted.text.clone()) | 461 | .with_borrow(|pasted| pasted.text.clone()) |
crates/snowbound/src/windows.rs+105-3| ... | @@ -1082,17 +1082,89 @@ pub fn appearance(window: &Window) -> Theme { | ... | @@ -1082,17 +1082,89 @@ pub fn appearance(window: &Window) -> Theme { |
| 1082 | window.theme().unwrap_or(Theme::Light) | 1082 | window.theme().unwrap_or(Theme::Light) |
| 1083 | } | 1083 | } |
| 1084 | 1084 | ||
| 1085 | pub struct Clipboard(arboard::Clipboard); | 1085 | /// The clipboard: arboard reads it; Copy writes its formats itself, as arboard can't offer |
| 1086 | /// Snowbound's own. | ||
| 1087 | pub struct Clipboard(arboard::Clipboard, HWND); | ||
| 1088 | |||
| 1089 | /// Snowbound's own format's registered name. | ||
| 1090 | const CLIP: &str = "Snowbound Clip"; | ||
| 1086 | 1091 | ||
| 1087 | impl Clipboard { | 1092 | impl Clipboard { |
| 1088 | pub fn new(_: &Window) -> Result<Self, arboard::Error> { | 1093 | pub fn new(window: &Window) -> Result<Self, arboard::Error> { |
| 1089 | arboard::Clipboard::new().map(Self) | 1094 | Ok(Self(arboard::Clipboard::new()?, hwnd(window))) |
| 1090 | } | 1095 | } |
| 1091 | 1096 | ||
| 1092 | pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> { | 1097 | pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> { |
| 1093 | self.0.set_text(text) | 1098 | self.0.set_text(text) |
| 1094 | } | 1099 | } |
| 1095 | 1100 | ||
| 1101 | /// Text, `HTML Format` and Snowbound's own, as one copy. | ||
| 1102 | pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), &'static str> { | ||
| 1103 | use windows_sys::Win32::System::{DataExchange as clip, Memory, Ole::CF_UNICODETEXT}; | ||
| 1104 | let text: Vec<u8> = copied | ||
| 1105 | .text | ||
| 1106 | .replace('\n', "\r\n") | ||
| 1107 | .encode_utf16() | ||
| 1108 | .chain([0]) | ||
| 1109 | .flat_map(u16::to_le_bytes) | ||
| 1110 | .collect(); | ||
| 1111 | let html = crate::paste::cf_html(&copied.html); | ||
| 1112 | let formats = [ | ||
| 1113 | (u32::from(CF_UNICODETEXT), text), | ||
| 1114 | (registered("HTML Format"), [html.as_bytes(), &[0]].concat()), | ||
| 1115 | (registered(CLIP), [copied.clip.as_bytes(), &[0]].concat()), | ||
| 1116 | ]; | ||
| 1117 | let _open = Open::new(self.1)?; | ||
| 1118 | if unsafe { clip::EmptyClipboard() } == 0 { | ||
| 1119 | return Err("The clipboard couldn't be emptied"); | ||
| 1120 | } | ||
| 1121 | for (format, bytes) in formats { | ||
| 1122 | unsafe { | ||
| 1123 | let memory = Memory::GlobalAlloc(Memory::GMEM_MOVEABLE, bytes.len()); | ||
| 1124 | if memory.is_null() { | ||
| 1125 | return Err("No memory for the clipboard"); | ||
| 1126 | } | ||
| 1127 | let target = Memory::GlobalLock(memory); | ||
| 1128 | std::ptr::copy_nonoverlapping(bytes.as_ptr(), target.cast(), bytes.len()); | ||
| 1129 | Memory::GlobalUnlock(memory); | ||
| 1130 | // The clipboard owns the memory once it takes it. | ||
| 1131 | if clip::SetClipboardData(format, memory).is_null() { | ||
| 1132 | windows_sys::Win32::Foundation::GlobalFree(memory); | ||
| 1133 | return Err("The clipboard refused a format"); | ||
| 1134 | } | ||
| 1135 | } | ||
| 1136 | } | ||
| 1137 | Ok(()) | ||
| 1138 | } | ||
| 1139 | |||
| 1140 | /// What Snowbound itself copied, if it did. | ||
| 1141 | pub fn get_clip(&mut self) -> Option<String> { | ||
| 1142 | use windows_sys::Win32::System::{DataExchange as clip, Memory}; | ||
| 1143 | let format = registered(CLIP); | ||
| 1144 | if unsafe { clip::IsClipboardFormatAvailable(format) } == 0 { | ||
| 1145 | return None; | ||
| 1146 | } | ||
| 1147 | let _open = Open::new(self.1).ok()?; | ||
| 1148 | unsafe { | ||
| 1149 | let memory = clip::GetClipboardData(format); | ||
| 1150 | if memory.is_null() { | ||
| 1151 | return None; | ||
| 1152 | } | ||
| 1153 | let data = Memory::GlobalLock(memory); | ||
| 1154 | if data.is_null() { | ||
| 1155 | return None; | ||
| 1156 | } | ||
| 1157 | let bytes = std::slice::from_raw_parts(data.cast::<u8>(), Memory::GlobalSize(memory)); | ||
| 1158 | let end = bytes | ||
| 1159 | .iter() | ||
| 1160 | .position(|&byte| byte == 0) | ||
| 1161 | .unwrap_or(bytes.len()); | ||
| 1162 | let text = String::from_utf8(bytes[..end].to_vec()).ok(); | ||
| 1163 | Memory::GlobalUnlock(memory); | ||
| 1164 | text | ||
| 1165 | } | ||
| 1166 | } | ||
| 1167 | |||
| 1096 | pub fn get_text(&mut self) -> Result<String, arboard::Error> { | 1168 | pub fn get_text(&mut self) -> Result<String, arboard::Error> { |
| 1097 | self.0.get_text() | 1169 | self.0.get_text() |
| 1098 | } | 1170 | } |
| ... | @@ -1111,6 +1183,36 @@ impl Clipboard { | ... | @@ -1111,6 +1183,36 @@ impl Clipboard { |
| 1111 | } | 1183 | } |
| 1112 | } | 1184 | } |
| 1113 | 1185 | ||
| 1186 | /// The clipboard, open until dropped; another application may hold it a moment, so this | ||
| 1187 | /// retries for a while as arboard does. | ||
| 1188 | struct Open; | ||
| 1189 | |||
| 1190 | impl Open { | ||
| 1191 | fn new(owner: HWND) -> Result<Self, &'static str> { | ||
| 1192 | use windows_sys::Win32::System::DataExchange::OpenClipboard; | ||
| 1193 | for _ in 0..10 { | ||
| 1194 | if unsafe { OpenClipboard(owner) } != 0 { | ||
| 1195 | return Ok(Self); | ||
| 1196 | } | ||
| 1197 | std::thread::sleep(Duration::from_millis(10)); | ||
| 1198 | } | ||
| 1199 | Err("Another application is holding the clipboard") | ||
| 1200 | } | ||
| 1201 | } | ||
| 1202 | |||
| 1203 | impl Drop for Open { | ||
| 1204 | fn drop(&mut self) { | ||
| 1205 | unsafe { windows_sys::Win32::System::DataExchange::CloseClipboard() }; | ||
| 1206 | } | ||
| 1207 | } | ||
| 1208 | |||
| 1209 | /// The number Windows gives clipboard format `name`. | ||
| 1210 | fn registered(name: &str) -> u32 { | ||
| 1211 | unsafe { | ||
| 1212 | windows_sys::Win32::System::DataExchange::RegisterClipboardFormatW(wide(name).as_ptr()) | ||
| 1213 | } | ||
| 1214 | } | ||
| 1215 | |||
| 1114 | /// The DWORD `value` under `key` in the user's registry hive. | 1216 | /// The DWORD `value` under `key` in the user's registry hive. |
| 1115 | fn registry_dword(key: &str, value: &str) -> Option<u32> { | 1217 | fn registry_dword(key: &str, value: &str) -> Option<u32> { |
| 1116 | use windows_sys::Win32::System::Registry; | 1218 | use windows_sys::Win32::System::Registry; |