| 1 | //! Table columns fit their content as it is typed and lock where a border is dragged, each |
| 2 | //! keystroke's width stored in the revision with its text, and OneNote 2010 reads them back |
| 3 | //! as it sizes them itself (`corpus/table-widths`). |
| 4 | |
| 5 | use canvas::{document::TextPosition, editor::CanvasEditor, layout::TextEngine}; |
| 6 | use onestore::{ |
| 7 | Arena, ExGuid, Section, Store, |
| 8 | op::{Edit, Op}, |
| 9 | page::{Page, PageObject, PageParagraph, ParagraphContent, TableColumn}, |
| 10 | }; |
| 11 | |
| 12 | const TABLES: &[u8] = include_bytes!("../../../corpus/table-tags/native/notebook/Tables.one"); |
| 13 | |
| 14 | /// Each table's first cell text and columns, in page order. |
| 15 | fn tables(page: &Page) -> Vec<(String, Vec<TableColumn>)> { |
| 16 | fn walk(nodes: &[PageParagraph], out: &mut Vec<(String, Vec<TableColumn>)>) { |
| 17 | for node in nodes { |
| 18 | if let ParagraphContent::Table(table) = &node.content { |
| 19 | let first = table.rows[0].cells[0].paragraphs[0] |
| 20 | .text() |
| 21 | .map_or(String::new(), |text| text.text.text().to_owned()); |
| 22 | out.push((first, table.columns.clone())); |
| 23 | for cell in table.rows.iter().flat_map(|row| &row.cells) { |
| 24 | walk(&cell.paragraphs, out); |
| 25 | } |
| 26 | } |
| 27 | } |
| 28 | } |
| 29 | let mut out = Vec::new(); |
| 30 | for object in &page.objects { |
| 31 | if let PageObject::Outline(outline) = object { |
| 32 | walk(&outline.paragraphs, &mut out); |
| 33 | } |
| 34 | } |
| 35 | out |
| 36 | } |
| 37 | |
| 38 | fn body(page: &Page) -> ExGuid { |
| 39 | page.objects |
| 40 | .iter() |
| 41 | .find_map(|object| match object { |
| 42 | PageObject::Outline(outline) if !outline.paragraphs.is_empty() => Some(outline.id), |
| 43 | _ => None, |
| 44 | }) |
| 45 | .unwrap() |
| 46 | } |
| 47 | |
| 48 | fn publish(section: &mut Section<'_>, space: ExGuid, editor: &mut CanvasEditor, at: u64) { |
| 49 | let ops = editor |
| 50 | .take_ops() |
| 51 | .unwrap() |
| 52 | .into_iter() |
| 53 | .map(|op| Op::Page { space, op }) |
| 54 | .collect(); |
| 55 | section.apply("Author", &Edit { at, ops }).unwrap(); |
| 56 | } |
| 57 | |
| 58 | /// Opens `space` with the caret at the end of its body's last paragraph. |
| 59 | fn at_end(section: &Section<'_>, space: ExGuid, engine: &mut TextEngine) -> CanvasEditor { |
| 60 | let page = section.page(space).unwrap(); |
| 61 | let mut editor = CanvasEditor::from_page(page.clone(), engine).unwrap(); |
| 62 | editor.focus_outline(body(&page)).unwrap(); |
| 63 | let document = editor.active_outline().document(); |
| 64 | let paragraph = document.paragraphs().count() - 1; |
| 65 | let offset = document |
| 66 | .paragraphs() |
| 67 | .last() |
| 68 | .unwrap() |
| 69 | .text() |
| 70 | .encode_utf16() |
| 71 | .count() as u32; |
| 72 | editor |
| 73 | .select([TextPosition { paragraph, offset }; 2].into()) |
| 74 | .unwrap(); |
| 75 | editor |
| 76 | } |
| 77 | |
| 78 | fn type_rows(editor: &mut CanvasEditor, engine: &mut TextEngine, rows: &[&[&str]]) { |
| 79 | for (index, row) in rows.iter().enumerate() { |
| 80 | if index > 0 { |
| 81 | editor.enter(engine, false).unwrap(); |
| 82 | } |
| 83 | for (column, text) in row.iter().enumerate() { |
| 84 | if column > 0 { |
| 85 | editor.tab(engine, false).unwrap(); |
| 86 | } |
| 87 | editor.insert(engine, text).unwrap(); |
| 88 | } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | fn close(columns: &[TableColumn], expected: &[(f32, bool)]) -> bool { |
| 93 | columns.len() == expected.len() |
| 94 | && columns |
| 95 | .iter() |
| 96 | .zip(expected) |
| 97 | .all(|(column, (width, locked))| { |
| 98 | (column.width - width).abs() < 0.01 && column.locked == *locked |
| 99 | }) |
| 100 | } |
| 101 | |
| 102 | /// `SNOWBOUND_TABLE_WIDTHS_EXPORT` names a new directory receiving the candidate for a cold |
| 103 | /// reopen. |
| 104 | #[test] |
| 105 | fn typed_and_dragged_columns_store_onenote_s_widths() { |
| 106 | let arena = Arena::default(); |
| 107 | let mut section = Section::open(&arena, TABLES.to_vec()).unwrap(); |
| 108 | let pages = section.pages().unwrap(); |
| 109 | let space = |title: &str| pages.iter().find(|page| page.1 == title).unwrap().0; |
| 110 | let (fruit, typed, dragged) = ( |
| 111 | space("Check a table"), |
| 112 | space("Edit a tagged table"), |
| 113 | space("Delete and undo"), |
| 114 | ); |
| 115 | let mut engine = TextEngine::default(); |
| 116 | let mut at = 134_000_000_000_000_000; |
| 117 | let mut step = |section: &mut Section<'_>, space, editor: &mut CanvasEditor| { |
| 118 | at += 10_000_000; |
| 119 | publish(section, space, editor, at); |
| 120 | }; |
| 121 | |
| 122 | // A table typed after the page's text, one revision per keystroke. |
| 123 | let mut editor = at_end(&section, fruit, &mut engine); |
| 124 | editor.enter(&mut engine, false).unwrap(); |
| 125 | editor.insert_table(&mut engine, 1, 1).unwrap(); |
| 126 | step(&mut section, fruit, &mut editor); |
| 127 | for (index, row) in [ |
| 128 | ["Fruit", "Colour", "Notes"], |
| 129 | ["Apple", "Red", "Crisp and sweet, good for pies"], |
| 130 | ["Watermelon", "Green", "Summer"], |
| 131 | ] |
| 132 | .iter() |
| 133 | .enumerate() |
| 134 | { |
| 135 | if index > 0 { |
| 136 | editor.enter(&mut engine, false).unwrap(); |
| 137 | step(&mut section, fruit, &mut editor); |
| 138 | } |
| 139 | for (column, text) in row.iter().enumerate() { |
| 140 | if column > 0 { |
| 141 | editor.tab(&mut engine, false).unwrap(); |
| 142 | step(&mut section, fruit, &mut editor); |
| 143 | } |
| 144 | for character in text.chars() { |
| 145 | editor.insert(&mut engine, &character.to_string()).unwrap(); |
| 146 | step(&mut section, fruit, &mut editor); |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | let stored = tables(&section.page(fruit).unwrap()); |
| 151 | // OneNote 2010 typing the same table (lab, 2026-09-30). |
| 152 | assert!( |
| 153 | close( |
| 154 | &stored[1].1, |
| 155 | &[(60.759, false), (37.11, false), (139.086, false)] |
| 156 | ), |
| 157 | "{stored:?}" |
| 158 | ); |
| 159 | |
| 160 | // Typing into a native table's cell widens its column; deleting narrows it again. |
| 161 | let mut editor = CanvasEditor::from_page(section.page(typed).unwrap(), &mut engine).unwrap(); |
| 162 | editor |
| 163 | .focus_outline(body(&section.page(typed).unwrap())) |
| 164 | .unwrap(); |
| 165 | editor |
| 166 | .select( |
| 167 | [TextPosition { |
| 168 | paragraph: 1, |
| 169 | offset: 10, |
| 170 | }; 2] |
| 171 | .into(), |
| 172 | ) |
| 173 | .unwrap(); |
| 174 | editor |
| 175 | .insert(&mut engine, " and typed on in Snowbound") |
| 176 | .unwrap(); |
| 177 | step(&mut section, typed, &mut editor); |
| 178 | let widened = tables(&section.page(typed).unwrap()); |
| 179 | assert!(widened[0].1[0].width > 100.0, "{widened:?}"); |
| 180 | |
| 181 | // A dragged column locks and keeps its width while text wraps in it, and the column |
| 182 | // after it moves; a long line in that column stops at the outline's width. |
| 183 | let mut editor = at_end(&section, dragged, &mut engine); |
| 184 | editor.enter(&mut engine, false).unwrap(); |
| 185 | editor.insert_table(&mut engine, 1, 1).unwrap(); |
| 186 | type_rows(&mut editor, &mut engine, &[&["Dragged", "Fitted"]]); |
| 187 | step(&mut section, dragged, &mut editor); |
| 188 | let table = editor |
| 189 | .active_outline() |
| 190 | .document() |
| 191 | .nodes() |
| 192 | .iter() |
| 193 | .find_map(|node| match &node.content { |
| 194 | ParagraphContent::Table(table) |
| 195 | if table.rows[0].cells[0].paragraphs[0] |
| 196 | .text() |
| 197 | .is_some_and(|text| text.text.text() == "Dragged") => |
| 198 | { |
| 199 | Some(table.id) |
| 200 | } |
| 201 | _ => None, |
| 202 | }) |
| 203 | .unwrap(); |
| 204 | editor.resize_column(&mut engine, table, 0, 96.0).unwrap(); |
| 205 | step(&mut section, dragged, &mut editor); |
| 206 | editor |
| 207 | .insert( |
| 208 | &mut engine, |
| 209 | " and then a line long enough to reach the outline's edge and wrap there", |
| 210 | ) |
| 211 | .unwrap(); |
| 212 | step(&mut section, dragged, &mut editor); |
| 213 | let resized = tables(&section.page(dragged).unwrap()); |
| 214 | assert!( |
| 215 | resized[2].1[0].locked && resized[2].1[0].width == 96.0, |
| 216 | "{resized:?}" |
| 217 | ); |
| 218 | assert!( |
| 219 | !resized[2].1[1].locked && resized[2].1[1].width > 200.0, |
| 220 | "{resized:?}" |
| 221 | ); |
| 222 | |
| 223 | section.seal().unwrap(); |
| 224 | let written = section.image(); |
| 225 | let reread = Section::open(&arena, written.clone()).unwrap(); |
| 226 | assert_eq!(tables(&reread.page(fruit).unwrap()), stored); |
| 227 | assert_eq!(tables(&reread.page(typed).unwrap()), widened); |
| 228 | assert_eq!(tables(&reread.page(dragged).unwrap()), resized); |
| 229 | if let Some(directory) = std::env::var_os("SNOWBOUND_TABLE_WIDTHS_EXPORT") { |
| 230 | let directory = std::path::PathBuf::from(directory); |
| 231 | std::fs::create_dir(&directory).unwrap(); |
| 232 | std::fs::write(directory.join("Tables.one"), &written).unwrap(); |
| 233 | let file_id = Store::parse(&written).unwrap().header.file_id; |
| 234 | std::fs::write( |
| 235 | directory.join("Open Notebook.onetoc2"), |
| 236 | onestore::create_table_of_contents("Open Notebook.onetoc2", &[("Tables.one", file_id)]) |
| 237 | .unwrap(), |
| 238 | ) |
| 239 | .unwrap(); |
| 240 | } |
| 241 | } |