1//! Files attached through the editor, as Snowbound's Attach File and drops store them: the
2//! ops the editor records seal into a section that reads back with the exact bytes.
3//! `CANVAS_ATTACHMENT_EXPORT` names a directory receiving that section as a notebook, for
4//! a cold reopen in OneNote 2010 (`corpus/attachment-insert`), and `CANVAS_FLOATING_EXPORT`
5//! the section of files attached on blank page (`corpus/attachment-floating`).
6
7use canvas::{editor::CanvasEditor, layout::TextEngine};
8use draw::edit::Movement;
9use onestore::{
10 Arena, PageCreation, Section, Store,
11 op::{Edit, Op, SectionOp},
12 page::{Attachment, PageObject, ParagraphContent},
13};
14use std::sync::Arc;
15
16/// The icon OneNote 2010 stored with a text file (`corpus/native/cold-05-06-attachment`).
17fn native_icon() -> Arc<[u8]> {
18 let native =
19 include_bytes!("../../../corpus/native/cold-05-06-attachment/notebook/synthetic.one");
20 let arena = Arena::default();
21 let mut section = Section::open(&arena, native.to_vec()).unwrap();
22 let (space, ..) = section.pages().unwrap()[0].clone();
23 let page = section.page(space).unwrap();
24 page.objects
25 .iter()
26 .filter_map(|object| match object {
27 PageObject::Outline(outline) => Some(&outline.paragraphs),
28 _ => None,
29 })
30 .flatten()
31 .find_map(|paragraph| match &paragraph.content {
32 ParagraphContent::Attachment(file) => file.preview.clone(),
33 _ => None,
34 })
35 .unwrap()
36}
37
38/// Two mebibytes no compressor shortens, larger than any buffer the writer fills at once.
39fn large() -> Vec<u8> {
40 let mut state = 0x2545_f491_4f6c_dd1d_u64;
41 (0..2 << 20)
42 .map(|_| {
43 state ^= state << 13;
44 state ^= state >> 7;
45 state ^= state << 17;
46 state as u8
47 })
48 .collect()
49}
50
51fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment {
52 Attachment {
53 id: onestore::page::text::new_id().unwrap(),
54 filename: name.into(),
55 source_path: Some(format!("/Users/snowbound/Documents/{name}")),
56 size: Some([24.0, 24.0]),
57 layout: Default::default(),
58 bytes: Some(bytes.into()),
59 preview,
60 recording: None,
61 tags: Vec::new(),
62 }
63}
64
65#[test]
66fn a_file_attached_from_the_title_stores_in_the_body() {
67 let mut image = onestore::create_section("files.one", "First page", "Author").unwrap();
68 let arena = Arena::default();
69 let mut section = Section::open(&arena, image.clone()).unwrap();
70 section
71 .apply(
72 "Author",
73 &Edit {
74 at: 133_000_000_000_000_000,
75 ops: vec![Op::Section(SectionOp::Create(
76 PageCreation::new(None, Some("Attachment from title"), "Author").unwrap(),
77 ))],
78 },
79 )
80 .unwrap();
81 let (space, ..) = section.pages().unwrap()[1].clone();
82 let mut engine = TextEngine::default();
83 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
84 let title = editor
85 .outlines()
86 .iter()
87 .find(|outline| outline.title)
88 .unwrap()
89 .id;
90 editor.focus_outline(title).unwrap();
91 let attached = file(
92 "attachment.txt",
93 b"Snowbound bounded import fixture\n".to_vec(),
94 Some(native_icon()),
95 );
96 editor
97 .insert_attachment(&mut engine, attached.clone())
98 .unwrap();
99 assert!(!editor.active_outline().title);
100 let ops = editor
101 .take_ops()
102 .unwrap()
103 .into_iter()
104 .map(|op| Op::Page { space, op })
105 .collect();
106 section
107 .apply(
108 "Author",
109 &Edit {
110 at: 133_000_000_010_000_000,
111 ops,
112 },
113 )
114 .unwrap();
115 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
116 let arena = Arena::default();
117 let mut reopened = Section::open(&arena, image.clone()).unwrap();
118 assert_eq!(reopened.pages().unwrap()[1].1, "Attachment from title");
119 let page = reopened.page(space).unwrap();
120 let files: Vec<_> = page
121 .objects
122 .iter()
123 .filter_map(|object| match object {
124 PageObject::Outline(outline) if !outline.title => Some(&outline.paragraphs),
125 _ => None,
126 })
127 .flatten()
128 .filter_map(|paragraph| match &paragraph.content {
129 ParagraphContent::Attachment(file) => Some(file),
130 _ => None,
131 })
132 .collect();
133 assert_eq!(files, [&attached]);
134 if let Some(path) = std::env::var_os("CANVAS_TITLE_ATTACHMENT_EXPORT") {
135 std::fs::write(path, image).unwrap();
136 }
137}
138
139#[test]
140fn attached_files_store_their_bytes_between_the_text_around_them() {
141 let source = onestore::create_section("files.one", "Before the file", "Author").unwrap();
142 let arena = Arena::default();
143 let mut section = Section::open(&arena, source.clone()).unwrap();
144 let (space, ..) = section.pages().unwrap()[0].clone();
145 let mut engine = TextEngine::default();
146 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
147 let body = editor
148 .outlines()
149 .iter()
150 .find(|outline| !outline.title)
151 .unwrap()
152 .id;
153 editor.focus_outline(body).unwrap();
154 editor
155 .move_selection(&mut engine, Movement::DocumentEnd, false)
156 .unwrap();
157 let small = file(
158 "notes 🦀.txt",
159 b"Snowbound attached this file.\n".to_vec(),
160 Some(native_icon()),
161 );
162 // The icon a host without the system's draws, which `PageView::insert_attachment` gives.
163 let large = file(
164 "large.bin",
165 large(),
166 Some(canvas::gpu::page::file_icon().into()),
167 );
168 let mut at = 133_000_000_000_000_000;
169 let mut store = |editor: &mut CanvasEditor| {
170 let ops = editor.take_ops().unwrap();
171 assert!(!ops.is_empty());
172 at += 10_000_000;
173 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
174 section.apply("Author", &Edit { at, ops }).unwrap();
175 };
176 editor
177 .insert_attachment(&mut engine, small.clone())
178 .unwrap();
179 store(&mut editor);
180 editor.insert(&mut engine, "After the file").unwrap();
181 store(&mut editor);
182 editor
183 .insert_attachment(&mut engine, large.clone())
184 .unwrap();
185 store(&mut editor);
186 let mut image = source;
187 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
188
189 let arena = Arena::default();
190 let reopened = Section::open(&arena, image.clone()).unwrap();
191 let page = reopened.page(space).unwrap();
192 let body = page
193 .objects
194 .iter()
195 .find_map(|object| match object {
196 PageObject::Outline(outline) if !outline.title => Some(outline),
197 _ => None,
198 })
199 .unwrap();
200 let shown: Vec<String> = body
201 .paragraphs
202 .iter()
203 .map(|paragraph| match &paragraph.content {
204 ParagraphContent::Text(text) => text.text.text().to_owned(),
205 ParagraphContent::Attachment(file) => {
206 let expected = [&small, &large]
207 .into_iter()
208 .find(|expected| expected.id == file.id)
209 .unwrap();
210 assert_eq!(file, expected);
211 assert_eq!(file.bytes, expected.bytes);
212 assert_eq!(file.preview, expected.preview);
213 format!("[{}]", file.filename)
214 }
215 _ => panic!(),
216 })
217 .collect();
218 assert_eq!(
219 shown,
220 [
221 "Before the file",
222 "[notes 🦀.txt]",
223 "After the file",
224 "[large.bin]",
225 ""
226 ]
227 );
228
229 if let Some(directory) = std::env::var_os("CANVAS_ATTACHMENT_EXPORT") {
230 let directory = std::path::PathBuf::from(directory);
231 std::fs::create_dir_all(&directory).unwrap();
232 std::fs::write(directory.join("files.one"), &image).unwrap();
233 let file_id = Store::parse(&image).unwrap().header.file_id;
234 std::fs::write(
235 directory.join("Open Notebook.onetoc2"),
236 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)])
237 .unwrap(),
238 )
239 .unwrap();
240 }
241}
242
243/// Files attached where a click on blank page left the caret lie on the page there, as OneNote
244/// 2010 places them, and move as one does.
245#[test]
246fn files_attached_on_blank_page_store_their_bytes_where_they_were_placed_and_moved() {
247 let source = onestore::create_section("files.one", "Floating files", "Author").unwrap();
248 let arena = Arena::default();
249 let mut section = Section::open(&arena, source.clone()).unwrap();
250 let (space, ..) = section.pages().unwrap()[0].clone();
251 let mut engine = TextEngine::default();
252 let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap();
253 let small = file(
254 "float 🦀.txt",
255 b"Snowbound put this file on the page.\n".to_vec(),
256 Some(native_icon()),
257 );
258 let large = file(
259 "large.bin",
260 large(),
261 Some(canvas::gpu::page::file_icon().into()),
262 );
263 let mut at = 133_000_000_000_000_000;
264 let mut store = |editor: &mut CanvasEditor| {
265 let ops = editor.take_ops().unwrap();
266 assert_eq!(ops.len(), 1, "{ops:?}");
267 at += 10_000_000;
268 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
269 section.apply("Author", &Edit { at, ops }).unwrap();
270 };
271 editor
272 .place_caret(&mut engine, [342.0, 284.4], 240.0)
273 .unwrap();
274 editor
275 .insert_attachment(&mut engine, small.clone())
276 .unwrap();
277 store(&mut editor);
278 editor
279 .place_caret(&mut engine, [126.0, 410.4], 240.0)
280 .unwrap();
281 editor
282 .insert_attachment(&mut engine, large.clone())
283 .unwrap();
284 store(&mut editor);
285 let (_, size) = editor.image_placement(large.id).unwrap();
286 editor
287 .place_image(&mut engine, large.id, [288.0, 140.4], size)
288 .unwrap();
289 store(&mut editor);
290 let mut image = source;
291 section.seal().unwrap().unwrap().apply(&mut image).unwrap();
292
293 let arena = Arena::default();
294 let reopened = Section::open(&arena, image.clone()).unwrap();
295 let page = reopened.page(space).unwrap();
296 let files: Vec<&Attachment> = page
297 .objects
298 .iter()
299 .filter_map(|object| match object {
300 PageObject::Attachment(file) => Some(file),
301 _ => None,
302 })
303 .collect();
304 let [first, second] = files.as_slice() else {
305 panic!("{page:?}")
306 };
307 for (stored, expected, at) in [
308 (first, &small, [342.0, 284.4]),
309 (second, &large, [288.0, 140.4]),
310 ] {
311 assert_eq!(stored.id, expected.id);
312 assert_eq!([stored.layout.x, stored.layout.y], at.map(Some));
313 assert_eq!(stored.layout.max_width, Some(54.0));
314 assert_eq!(stored.bytes, expected.bytes);
315 assert_eq!(stored.preview, expected.preview);
316 }
317 assert_eq!(editor.page().unwrap(), page);
318
319 if let Some(directory) = std::env::var_os("CANVAS_FLOATING_EXPORT") {
320 let directory = std::path::PathBuf::from(directory);
321 std::fs::create_dir_all(&directory).unwrap();
322 std::fs::write(directory.join("files.one"), &image).unwrap();
323 let file_id = Store::parse(&image).unwrap().header.file_id;
324 std::fs::write(
325 directory.join("Open Notebook.onetoc2"),
326 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("files.one", file_id)])
327 .unwrap(),
328 )
329 .unwrap();
330 }
331}