| 1 | use crate::{ |
| 2 | document::{DocumentEdit, descendants, edited_nodes, leaves}, |
| 3 | layout::{InlineSpace, LayoutError, TextEngine, TextLayout}, |
| 4 | }; |
| 5 | use onestore::page::text::{Paragraph, TextProjection}; |
| 6 | use onestore::page::{Definition, Outline, PageParagraph, ParagraphContent, Table, Title}; |
| 7 | use onestore::{ |
| 8 | ExGuid, |
| 9 | document::{Format, Kind}, |
| 10 | }; |
| 11 | use std::{ |
| 12 | collections::{BTreeMap, BTreeSet}, |
| 13 | ops::Range, |
| 14 | sync::Arc, |
| 15 | }; |
| 16 | |
| 17 | pub(crate) const TITLE_WIDTH: f32 = 468.0; |
| 18 | |
| 19 | #[derive(Clone, Default)] |
| 20 | pub struct OutlineLayout { |
| 21 | pub paragraphs: Vec<ParagraphLayout>, |
| 22 | pub tables: Vec<TableLayout>, |
| 23 | /// Pictures, files and handwriting that occupy a paragraph of their own. |
| 24 | pub(crate) objects: Vec<ObjectLayout>, |
| 25 | pub size: [f32; 2], |
| 26 | /// One per root node, so an edit places anew only what it changes and moves what follows. |
| 27 | blocks: Vec<Block>, |
| 28 | /// The widest root node, at least 36 points, and the widest root text's line boxes; kept so |
| 29 | /// an edit sizes an automatic width without measuring every node. |
| 30 | pub(crate) widths: [f32; 2], |
| 31 | } |
| 32 | |
| 33 | /// A root node's place in its outline's flow. |
| 34 | #[derive(Clone)] |
| 35 | struct Block { |
| 36 | /// Where the node's paragraphs, tables and objects start. |
| 37 | first: [usize; 3], |
| 38 | /// Spacing before and after the node and its own height, or `None` while hidden. |
| 39 | metrics: Option<[f32; 3]>, |
| 40 | /// The node's right edge and, for text, its line boxes' right edge. |
| 41 | extent: [f32; 2], |
| 42 | /// The flow's bottom and pending spacing after the node. |
| 43 | state: (f64, Option<f32>), |
| 44 | /// A table's vertical coordinates before its offset. |
| 45 | rel: Vec<f32>, |
| 46 | /// The number the next numbered sibling at the node's level continues from. |
| 47 | count: Option<Count>, |
| 48 | } |
| 49 | |
| 50 | /// A layout of root nodes `range` once an edit applies, and how the nodes after them move. |
| 51 | pub(crate) struct Relayout { |
| 52 | pub(crate) range: Range<usize>, |
| 53 | pub(crate) segment: OutlineLayout, |
| 54 | /// Each following node's new top and flow state, up to the first that stays. |
| 55 | moved: Vec<(f32, (f64, Option<f32>))>, |
| 56 | pub(crate) size: [f32; 2], |
| 57 | widths: [f32; 2], |
| 58 | } |
| 59 | |
| 60 | #[derive(Clone)] |
| 61 | pub(crate) struct ObjectLayout { |
| 62 | /// The object's identity, which keys a picture's or file icon's decoded image. |
| 63 | pub(crate) id: ExGuid, |
| 64 | /// Where the picture, file icon, drawing or placeholder draws, outline-local. |
| 65 | pub(crate) rect: [f32; 4], |
| 66 | pub(crate) kind: ObjectKind, |
| 67 | /// Outline-local bottom of the whole object, label included. |
| 68 | pub(crate) bottom: f32, |
| 69 | pub(crate) tags: Option<BlockTags>, |
| 70 | } |
| 71 | |
| 72 | /// The note tags of a table, picture or file, which OneNote centres on it in the tag column. |
| 73 | #[derive(Clone)] |
| 74 | pub(crate) struct BlockTags { |
| 75 | /// The paragraph holding the block. |
| 76 | pub(crate) paragraph: ExGuid, |
| 77 | /// Its siblings' group, whose list markers the tags clear. |
| 78 | parent: Option<ExGuid>, |
| 79 | /// The block's left edge, outline-local. |
| 80 | x: f32, |
| 81 | /// Origins are outline-local. |
| 82 | pub(crate) tags: Vec<ParagraphTag>, |
| 83 | } |
| 84 | |
| 85 | impl BlockTags { |
| 86 | /// `node`'s tags and those of its content, `content`, shaped as a tagged paragraph of |
| 87 | /// `node`'s format would show them; `None` without any. |
| 88 | fn new( |
| 89 | node: &PageParagraph, |
| 90 | content: &[onestore::document::Tag], |
| 91 | x: f32, |
| 92 | shape: &mut impl FnMut( |
| 93 | &PageParagraph, |
| 94 | Option<&Count>, |
| 95 | f32, |
| 96 | &[f32], |
| 97 | ) -> Result<ParagraphLayout, LayoutError>, |
| 98 | ) -> Result<Option<Self>, LayoutError> { |
| 99 | if node.tags.is_empty() && content.is_empty() { |
| 100 | return Ok(None); |
| 101 | } |
| 102 | let mut tagged = caption(node.id, node.id, "", node.format.clone()); |
| 103 | tagged.tags.clone_from(&node.tags); |
| 104 | if let ParagraphContent::Text(text) = &mut tagged.content { |
| 105 | text.tags = content.to_vec(); |
| 106 | } |
| 107 | let tags = shape(&tagged, None, ATTACHMENT_WIDTH, &[0.0, 0.0])?.tags; |
| 108 | Ok(Some(Self { |
| 109 | paragraph: node.id, |
| 110 | parent: node.parent, |
| 111 | x, |
| 112 | tags, |
| 113 | })) |
| 114 | } |
| 115 | |
| 116 | /// Centres the tags on a block spanning `top..bottom`. |
| 117 | fn centre(&mut self, top: f32, bottom: f32) { |
| 118 | for tag in &mut self.tags { |
| 119 | tag.origin[1] = (top + bottom - tag.size) / 2.0; |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | fn offset(&mut self, [x, y]: [f32; 2]) { |
| 124 | self.x += x; |
| 125 | for tag in &mut self.tags { |
| 126 | tag.origin[0] += x; |
| 127 | tag.origin[1] += y; |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | #[derive(Clone)] |
| 133 | pub(crate) enum ObjectKind { |
| 134 | Picture, |
| 135 | /// A file's icon with its name centered below. |
| 136 | File(ParagraphLayout), |
| 137 | /// Handwriting, whose strokes are relative to the rect's top-left. |
| 138 | Ink(onestore::page::Ink), |
| 139 | /// Content the canvas cannot draw, marked by a labelled box. |
| 140 | Unsupported(ParagraphLayout), |
| 141 | } |
| 142 | |
| 143 | impl ObjectLayout { |
| 144 | pub(crate) fn label(&self) -> Option<&ParagraphLayout> { |
| 145 | match &self.kind { |
| 146 | ObjectKind::File(label) | ObjectKind::Unsupported(label) => Some(label), |
| 147 | ObjectKind::Picture | ObjectKind::Ink(_) => None, |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | /// The whole object, a file's column with its name included. |
| 152 | pub fn bounds(&self) -> [f32; 4] { |
| 153 | match &self.kind { |
| 154 | ObjectKind::File(label) => [ |
| 155 | label.origin[0], |
| 156 | self.rect[1] - 6.0, |
| 157 | label.origin[0] + ATTACHMENT_WIDTH, |
| 158 | self.bottom, |
| 159 | ], |
| 160 | _ => [self.rect[0], self.rect[1], self.rect[2], self.bottom], |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | /// The rect's top and bottom, the object's bottom and its label's top once the object |
| 165 | /// starts at `y`, given its own height, and the height it takes in the flow. |
| 166 | fn at(&self, y: f32, height: f32) -> ([f32; 4], f32) { |
| 167 | match &self.kind { |
| 168 | ObjectKind::File(label) => { |
| 169 | let top = y + 6.0; |
| 170 | let label_top = top + height + 10.5; |
| 171 | let bottom = label_top + label.text.height() + 9.0; |
| 172 | ([top, top + height, bottom, label_top], bottom - y) |
| 173 | } |
| 174 | ObjectKind::Unsupported(_) => ([y, y + height, y + height, y + 8.0], height), |
| 175 | ObjectKind::Picture | ObjectKind::Ink(_) => ([y, y + height, y + height, 0.0], height), |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | fn place(&mut self, y: f32, height: f32) -> f32 { |
| 180 | let ([top, end, bottom, label_top], flow) = self.at(y, height); |
| 181 | [self.rect[1], self.rect[3], self.bottom] = [top, end, bottom]; |
| 182 | if let ObjectKind::File(label) | ObjectKind::Unsupported(label) = &mut self.kind { |
| 183 | label.origin[1] = label_top; |
| 184 | } |
| 185 | let [_, top, _, bottom] = self.bounds(); |
| 186 | if let Some(tags) = &mut self.tags { |
| 187 | tags.centre(top, bottom); |
| 188 | } |
| 189 | flow |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /// The vertical coordinates of these pieces, in a fixed order. |
| 194 | fn verticals<'a>( |
| 195 | paragraphs: &'a mut [ParagraphLayout], |
| 196 | tables: &'a mut [TableLayout], |
| 197 | objects: &'a mut [ObjectLayout], |
| 198 | ) -> impl Iterator<Item = &'a mut f32> { |
| 199 | let tags = |tags: &'a mut Option<BlockTags>| { |
| 200 | tags.iter_mut() |
| 201 | .flat_map(|tags| &mut tags.tags) |
| 202 | .map(|tag| &mut tag.origin[1]) |
| 203 | }; |
| 204 | paragraphs |
| 205 | .iter_mut() |
| 206 | .map(|paragraph| &mut paragraph.origin[1]) |
| 207 | .chain(tables.iter_mut().flat_map(move |table| { |
| 208 | let cells = table.cells.iter_mut().flat_map(|cell| { |
| 209 | let [_, top, _, bottom] = &mut cell.rect; |
| 210 | [top, bottom] |
| 211 | }); |
| 212 | cells.chain(tags(&mut table.tags)) |
| 213 | })) |
| 214 | .chain(objects.iter_mut().flat_map(move |object| { |
| 215 | let ObjectLayout { |
| 216 | rect: [_, top, _, end], |
| 217 | bottom, |
| 218 | kind, |
| 219 | tags: block, |
| 220 | .. |
| 221 | } = object; |
| 222 | let label = match kind { |
| 223 | ObjectKind::File(label) | ObjectKind::Unsupported(label) => { |
| 224 | Some(&mut label.origin[1]) |
| 225 | } |
| 226 | ObjectKind::Picture | ObjectKind::Ink(_) => None, |
| 227 | }; |
| 228 | [top, end, bottom] |
| 229 | .into_iter() |
| 230 | .chain(label) |
| 231 | .chain(tags(block)) |
| 232 | })) |
| 233 | } |
| 234 | |
| 235 | /// OneNote centers a file's icon and name in a column this wide. |
| 236 | const ATTACHMENT_WIDTH: f32 = 54.0; |
| 237 | /// The grey OneNote gives secondary text such as the page date (its `PageDateTime` style). |
| 238 | const PLACEHOLDER: u32 = 0x0080_8080; |
| 239 | |
| 240 | #[derive(Clone)] |
| 241 | pub struct TableLayout { |
| 242 | pub id: ExGuid, |
| 243 | /// Cells are in paragraph order with disjoint visible ranges. |
| 244 | pub cells: Vec<CellLayout>, |
| 245 | pub borders: bool, |
| 246 | pub(crate) tags: Option<BlockTags>, |
| 247 | } |
| 248 | |
| 249 | #[derive(Clone)] |
| 250 | pub struct CellLayout { |
| 251 | pub id: ExGuid, |
| 252 | pub rect: [f32; 4], |
| 253 | /// Visible paragraph indices, including paragraphs in nested tables. |
| 254 | pub(crate) paragraphs: std::ops::Range<usize>, |
| 255 | } |
| 256 | |
| 257 | impl CellLayout { |
| 258 | /// Native ink gutters extend beyond the cell borders. |
| 259 | pub(crate) fn text_bounds(&self) -> [f32; 4] { |
| 260 | [ |
| 261 | self.rect[0] - 2.7, |
| 262 | self.rect[1], |
| 263 | self.rect[2] + 4.62, |
| 264 | self.rect[3], |
| 265 | ] |
| 266 | } |
| 267 | |
| 268 | pub(crate) fn clip(&self, rect: parley::BoundingBox) -> Option<parley::BoundingBox> { |
| 269 | let [left, top, right, bottom] = self.text_bounds().map(f64::from); |
| 270 | let rect = parley::BoundingBox::new( |
| 271 | rect.x0.max(left), |
| 272 | rect.y0.max(top), |
| 273 | rect.x1.min(right), |
| 274 | rect.y1.min(bottom), |
| 275 | ); |
| 276 | (rect.width() > 0.0 && rect.height() > 0.0).then_some(rect) |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | #[derive(Clone)] |
| 281 | pub struct ParagraphLayout { |
| 282 | pub id: ExGuid, |
| 283 | pub origin: [f32; 2], |
| 284 | pub projection: TextProjection, |
| 285 | pub text: TextLayout, |
| 286 | /// Marker x is outline-local; y is paragraph-local so reflow cannot accumulate rounding drift. |
| 287 | pub markers: Vec<(TextLayout, [f32; 2])>, |
| 288 | /// A numbered paragraph's number, and whether its list restarts the count instead of |
| 289 | /// continuing from the previous sibling's. |
| 290 | pub(crate) number: Option<(Count, bool)>, |
| 291 | pub tags: Vec<ParagraphTag>, |
| 292 | /// Its siblings' group, whose list markers its tags clear: the parent paragraph, or the |
| 293 | /// table cell holding a cell's top-level paragraph. |
| 294 | pub(crate) parent: Option<ExGuid>, |
| 295 | /// The equations drawn in two dimensions in spaces of the text, in order. |
| 296 | pub math: Vec<crate::math::MathLayout>, |
| 297 | /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its |
| 298 | /// outline behind its lines, as OneNote marks a conflict page's conflicting changes. |
| 299 | pub(crate) band: Option<u32>, |
| 300 | } |
| 301 | |
| 302 | /// How the page draws a note tag. |
| 303 | #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] |
| 304 | pub enum TagIcon { |
| 305 | /// Symbol `shape` of MS-ONE's NoteTagShape, `checked` only where it is a check box. |
| 306 | Symbol { shape: u16, checked: bool }, |
| 307 | /// An Outlook task, drawn with its stored follow-up flag and never checked from the page. |
| 308 | Task { shape: u16 }, |
| 309 | } |
| 310 | |
| 311 | impl TagIcon { |
| 312 | /// How the page draws tag symbol `shape`, `checked` where it is a check box; none for |
| 313 | /// shape 0, which marks the text instead. |
| 314 | pub fn of(shape: u16, checked: bool) -> Option<Self> { |
| 315 | (shape != 0).then_some(Self::Symbol { |
| 316 | shape, |
| 317 | checked: checked && checkable(shape), |
| 318 | }) |
| 319 | } |
| 320 | |
| 321 | pub(crate) fn checkable(self) -> bool { |
| 322 | matches!(self, Self::Symbol { shape, .. } if checkable(shape)) |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | /// Whether tag symbol `shape` is a check box, as MS-ONE's NoteTagShape marks them. |
| 327 | pub fn checkable(shape: u16) -> bool { |
| 328 | matches!( |
| 329 | shape, |
| 330 | 1..=12 | 28 | 30 | 32 | 48 | 50 | 52 | 69 | 71 | 73 | 89..=99 |
| 331 | ) |
| 332 | } |
| 333 | |
| 334 | /// OneNote 2010's name for tag symbol `shape`, as its symbol gallery's tooltips give it; the |
| 335 | /// follow-up flags it keeps for Outlook tasks take MS-ONE's descriptions. |
| 336 | pub fn symbol_name(shape: u16) -> Option<&'static str> { |
| 337 | SYMBOL_NAMES |
| 338 | .get(usize::from(shape).checked_sub(1)?) |
| 339 | .copied() |
| 340 | } |
| 341 | |
| 342 | const SYMBOL_NAMES: [&str; 143] = [ |
| 343 | "Green Check Box", |
| 344 | "Yellow Check Box", |
| 345 | "Blue Check Box", |
| 346 | "Green Star Check Box", |
| 347 | "Yellow Star Check Box", |
| 348 | "Blue Star Check Box", |
| 349 | "Green Exclamation Check Box", |
| 350 | "Yellow Exclamation Check Box", |
| 351 | "Blue Exclamation Check Box", |
| 352 | "Green Arrow Check Box", |
| 353 | "Yellow Arrow Check Box", |
| 354 | "Blue Arrow Check Box", |
| 355 | "Yellow Star", |
| 356 | "Follow-up Flag", |
| 357 | "Question", |
| 358 | "Blue Right Arrow", |
| 359 | "High Priority", |
| 360 | "Telephone", |
| 361 | "Calendar", |
| 362 | "Clock", |
| 363 | "Light Bulb", |
| 364 | "Pushpin", |
| 365 | "Home", |
| 366 | "Comment", |
| 367 | "Smiley", |
| 368 | "Award Ribbon", |
| 369 | "Key", |
| 370 | "Blue Check Box 1", |
| 371 | "Blue Circle 1", |
| 372 | "Blue Check Box 2", |
| 373 | "Blue Circle 2", |
| 374 | "Blue Check Box 3", |
| 375 | "Blue Circle 3", |
| 376 | "Blue 8-Point Star", |
| 377 | "Blue Check Mark", |
| 378 | "Blue Circle", |
| 379 | "Blue Down Arrow", |
| 380 | "Blue Left Arrow", |
| 381 | "Blue Solid Target", |
| 382 | "Blue Star", |
| 383 | "Blue Sun", |
| 384 | "Blue Target", |
| 385 | "Blue Triangle", |
| 386 | "Blue Umbrella", |
| 387 | "Blue Up Arrow", |
| 388 | "Blue X with Dots", |
| 389 | "Blue X", |
| 390 | "Green Check Box 1", |
| 391 | "Green Circle 1", |
| 392 | "Green Check Box 2", |
| 393 | "Green Circle 2", |
| 394 | "Green Check Box 3", |
| 395 | "Green Circle 3", |
| 396 | "Green 8-Point Star", |
| 397 | "Green Check Mark", |
| 398 | "Green Circle", |
| 399 | "Green Down Arrow", |
| 400 | "Green Left Arrow", |
| 401 | "Green Right Arrow", |
| 402 | "Green Solid Target", |
| 403 | "Green Star", |
| 404 | "Green Sun", |
| 405 | "Green Target", |
| 406 | "Green Triangle", |
| 407 | "Green Umbrella", |
| 408 | "Green Up Arrow", |
| 409 | "Green X with Dots", |
| 410 | "Green X", |
| 411 | "Yellow Check Box 1", |
| 412 | "Yellow Circle 1", |
| 413 | "Yellow Check Box 2", |
| 414 | "Yellow Circle 2", |
| 415 | "Yellow Check Box 3", |
| 416 | "Yellow Circle 3", |
| 417 | "Yellow 8-Point Star", |
| 418 | "Yellow Check Mark", |
| 419 | "Yellow Circle", |
| 420 | "Yellow Down Arrow", |
| 421 | "Yellow Left Arrow", |
| 422 | "Yellow Right Arrow", |
| 423 | "Yellow Solid Target", |
| 424 | "Yellow Sun", |
| 425 | "Yellow Target", |
| 426 | "Yellow Triangle", |
| 427 | "Yellow Umbrella", |
| 428 | "Yellow Up Arrow", |
| 429 | "Yellow X with Dots", |
| 430 | "Yellow X", |
| 431 | "Follow Up Today Flag", |
| 432 | "Follow Up Tomorrow Flag", |
| 433 | "Follow Up This Week Flag", |
| 434 | "Follow Up Next Week Flag", |
| 435 | "No Follow Up Date Flag", |
| 436 | "To Do Blue Person", |
| 437 | "To Do Yellow Person", |
| 438 | "To Do Green Person", |
| 439 | "To Do Blue Flag", |
| 440 | "To Do Yellow Flag", |
| 441 | "To Do Green Flag", |
| 442 | "Red Square (Project A)", |
| 443 | "Yellow Square (Project B)", |
| 444 | "Blue Square (Project C)", |
| 445 | "Green Square", |
| 446 | "Orange Square", |
| 447 | "Pink Square", |
| 448 | "E-mail", |
| 449 | "Envelope (Closed)", |
| 450 | "Envelope (Opened)", |
| 451 | "Mobile phone", |
| 452 | "Telephone with Clock", |
| 453 | "Question Balloon", |
| 454 | "Paperclip", |
| 455 | "Frowning", |
| 456 | "IM contact", |
| 457 | "Person", |
| 458 | "Two People", |
| 459 | "Reminder Bell", |
| 460 | "Contact (like Outlook's)", |
| 461 | "Flowers Bouquet", |
| 462 | "Date", |
| 463 | "Music Note", |
| 464 | "Movie Clip", |
| 465 | "Quote Mark", |
| 466 | "Globe", |
| 467 | "Link Globe", |
| 468 | "Laptop", |
| 469 | "Plane", |
| 470 | "Car", |
| 471 | "Binoculars", |
| 472 | "Presentation", |
| 473 | "Padlock", |
| 474 | "Book (Open)", |
| 475 | "Notebook Icon", |
| 476 | "Paper (blank with lines)", |
| 477 | "Research Icon", |
| 478 | "Marker", |
| 479 | "Dollar sign $", |
| 480 | "Coins with window behind it", |
| 481 | "Schedule Task", |
| 482 | "Lighting Bolt", |
| 483 | "Cloud", |
| 484 | "Heart", |
| 485 | "Flower", |
| 486 | ]; |
| 487 | |
| 488 | #[derive(Clone, Debug, PartialEq)] |
| 489 | pub struct ParagraphTag { |
| 490 | pub icon: TagIcon, |
| 491 | /// Coordinates are outline-local in x and paragraph-local in y. |
| 492 | pub origin: [f32; 2], |
| 493 | /// The icon's side, which follows its paragraph's first run. |
| 494 | pub size: f32, |
| 495 | pub label: String, |
| 496 | pub disabled: bool, |
| 497 | } |
| 498 | |
| 499 | impl ParagraphTag { |
| 500 | /// The icon's side beside 10 to 17.5 pt text. |
| 501 | pub(crate) const SIZE: f32 = 12.0; |
| 502 | /// How far a 12 pt icon starts left of its text when no sibling has a list marker. |
| 503 | pub(crate) const INSET: f32 = 20.25; |
| 504 | /// Space between a tag and the leftmost list marker among its paragraph's siblings. |
| 505 | const MARKER_GAP: f32 = 0.9; |
| 506 | |
| 507 | /// Where an icon of `side` starts below its paragraph's top, for a first line whose |
| 508 | /// baseline is `baseline` below it and a first run of `size` points: OneNote 2010 centres |
| 509 | /// it 0.357 of the run's size, less 0.2 pt, above the baseline (within 0.75 pt, 8 to 60 pt). |
| 510 | fn top(baseline: f32, size: f32, side: f32) -> f32 { |
| 511 | baseline - 0.357 * size + 0.2 - side / 2.0 |
| 512 | } |
| 513 | |
| 514 | /// OneNote 2010's icon side for text of `size` points. |
| 515 | fn side(size: f32) -> f32 { |
| 516 | match size { |
| 517 | 24.0.. => 24.0, |
| 518 | 18.0.. => 18.0, |
| 519 | 10.0.. => Self::SIZE, |
| 520 | _ => 9.0, |
| 521 | } |
| 522 | } |
| 523 | |
| 524 | /// Where a one-tag column of `side` starts for text at `x` among siblings whose leftmost |
| 525 | /// list marker starts at `marker`; its right edge stays put as the icon grows. |
| 526 | fn column(x: f32, marker: Option<f32>, side: f32) -> f32 { |
| 527 | let right = x - Self::INSET + Self::SIZE; |
| 528 | marker.map_or(right, |marker| right.min(marker - Self::MARKER_GAP)) - side |
| 529 | } |
| 530 | } |
| 531 | |
| 532 | /// How far a 12 pt icon starts left of a picture or file on the page, as OneNote 2010 draws |
| 533 | /// one (`corpus/object-tags`). |
| 534 | const PAGE_INSET: f32 = 24.75; |
| 535 | |
| 536 | /// The note tags of a picture or file on the page, whose bounds are `bounds`: oldest first in |
| 537 | /// a column ending left of it, centred on it; a tag whose definition is missing draws nothing. |
| 538 | pub(crate) fn object_tags( |
| 539 | tags: &[onestore::document::Tag], |
| 540 | definitions: &BTreeMap<ExGuid, Definition>, |
| 541 | [x, top, _, bottom]: [f32; 4], |
| 542 | ) -> Vec<ParagraphTag> { |
| 543 | let side = ParagraphTag::side(crate::layout::DEFAULT_FONT_SIZE); |
| 544 | let mut drawn: Vec<ParagraphTag> = tags |
| 545 | .iter() |
| 546 | .rev() |
| 547 | .filter_map(|tag| { |
| 548 | let (icon, label) = if tag.status & 4 != 0 { |
| 549 | let shape = tag.shape.unwrap_or(0); |
| 550 | (TagIcon::Task { shape }, None) |
| 551 | } else { |
| 552 | let Kind::TagDefinition { shape, label, .. } = |
| 553 | &definitions.get(tag.definition.as_ref()?)?.kind |
| 554 | else { |
| 555 | return None; |
| 556 | }; |
| 557 | let checked = tag.status & 1 != 0; |
| 558 | (TagIcon::of(shape.unwrap_or(0), checked)?, label.as_deref()) |
| 559 | }; |
| 560 | Some(ParagraphTag { |
| 561 | icon, |
| 562 | origin: [0.0, (top + bottom - side) / 2.0], |
| 563 | size: side, |
| 564 | label: label.unwrap_or_default().to_owned(), |
| 565 | disabled: tag.status & 2 != 0, |
| 566 | }) |
| 567 | }) |
| 568 | .collect(); |
| 569 | let right = x - PAGE_INSET + ParagraphTag::SIZE; |
| 570 | let count = drawn.len() as f32; |
| 571 | for (index, tag) in drawn.iter_mut().enumerate() { |
| 572 | tag.origin[0] = right - side * (count - index as f32); |
| 573 | } |
| 574 | drawn |
| 575 | } |
| 576 | |
| 577 | /// How much further than its list spacing OneNote 2010 sets a marker's advance from its text. |
| 578 | const MARKER_OFFSET: f32 = 3.9; |
| 579 | /// Points an equation's linear text lays out at, holding the caret without showing. |
| 580 | const HELD: f32 = 0.01; |
| 581 | |
| 582 | /// A one-run paragraph standing in for an object's caption, so it lays out through the same |
| 583 | /// shaping (and caching) as the outline's text. |
| 584 | fn caption(id: ExGuid, text_id: ExGuid, text: &str, format: Format) -> PageParagraph { |
| 585 | PageParagraph { |
| 586 | id, |
| 587 | parent: None, |
| 588 | level: 1, |
| 589 | style: None, |
| 590 | format: Format::default(), |
| 591 | content: ParagraphContent::Text(onestore::page::TextObject { |
| 592 | id: text_id, |
| 593 | date_field: None, |
| 594 | text: Paragraph::new(text.into(), format), |
| 595 | tags: Vec::new(), |
| 596 | }), |
| 597 | lists: Vec::new(), |
| 598 | tags: Vec::new(), |
| 599 | media: Default::default(), |
| 600 | collapsed: false, |
| 601 | } |
| 602 | } |
| 603 | |
| 604 | /// A file's column at `x`, before it is placed: its icon centred over its name, which OneNote |
| 605 | /// shows without the extension; and the icon's height. |
| 606 | fn file_column( |
| 607 | file: &onestore::page::Attachment, |
| 608 | paragraph: ExGuid, |
| 609 | format: &Format, |
| 610 | x: f32, |
| 611 | shape: &mut impl FnMut( |
| 612 | &PageParagraph, |
| 613 | Option<&Count>, |
| 614 | f32, |
| 615 | &[f32], |
| 616 | ) -> Result<ParagraphLayout, LayoutError>, |
| 617 | ) -> Result<(ObjectLayout, f32), LayoutError> { |
| 618 | let [w, h] = file.size.unwrap_or([24.0, 24.0]); |
| 619 | let left = x + (ATTACHMENT_WIDTH - w) / 2.0; |
| 620 | let name = std::path::Path::new(&file.filename) |
| 621 | .file_stem() |
| 622 | .and_then(|stem| stem.to_str()) |
| 623 | .unwrap_or(&file.filename); |
| 624 | let format = Format { |
| 625 | alignment: Some(1), |
| 626 | ..format.clone() |
| 627 | }; |
| 628 | let mut label = shape( |
| 629 | &caption(paragraph, file.id, name, format), |
| 630 | None, |
| 631 | ATTACHMENT_WIDTH, |
| 632 | &[0.0, 0.0], |
| 633 | )?; |
| 634 | label.reset_origin(x); |
| 635 | let object = ObjectLayout { |
| 636 | id: file.id, |
| 637 | rect: [left, 0.0, left + w, 0.0], |
| 638 | kind: ObjectKind::File(label), |
| 639 | bottom: 0.0, |
| 640 | tags: None, |
| 641 | }; |
| 642 | Ok((object, h)) |
| 643 | } |
| 644 | |
| 645 | /// A file on the page as OneNote draws one, relative to its position: the column a |
| 646 | /// paragraph's file takes, with its text in the page's default format. |
| 647 | pub(crate) fn page_file( |
| 648 | engine: &mut TextEngine, |
| 649 | file: &onestore::page::Attachment, |
| 650 | ) -> Result<ObjectLayout, LayoutError> { |
| 651 | let (mut object, height) = file_column( |
| 652 | file, |
| 653 | file.id, |
| 654 | &Format::default(), |
| 655 | 0.0, |
| 656 | &mut |paragraph, previous, width, indents| { |
| 657 | ParagraphLayout::shape( |
| 658 | engine, |
| 659 | paragraph, |
| 660 | previous, |
| 661 | width, |
| 662 | indents, |
| 663 | &BTreeMap::new(), |
| 664 | ) |
| 665 | }, |
| 666 | )?; |
| 667 | object.place(0.0, height); |
| 668 | Ok(object) |
| 669 | } |
| 670 | |
| 671 | /// A picture's displayed size: the user-set layout size, else its intrinsic size. |
| 672 | pub(crate) fn image_size(image: &onestore::page::Image) -> Option<[f32; 2]> { |
| 673 | let size = [ |
| 674 | image.layout.max_width.or(image.size.map(|s| s[0]))?, |
| 675 | image.layout.max_height.or(image.size.map(|s| s[1]))?, |
| 676 | ]; |
| 677 | size.iter() |
| 678 | .all(|v| v.is_finite() && *v > 0.0) |
| 679 | .then_some(size) |
| 680 | } |
| 681 | |
| 682 | /// The step OneNote gives each level past the end of an indentation table. |
| 683 | const MISSING_INDENT: f64 = 27.0; |
| 684 | |
| 685 | /// Text offset of `level` from its outline: entry `n` of `indents` steps level `n` in from |
| 686 | /// level `n - 1`; entry 0 moves nothing OneNote 2010 draws, text nor markers. |
| 687 | pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result<f32, LayoutError> { |
| 688 | if indents.iter().any(|v| !v.is_finite() || *v < 0.0) || level == 0 { |
| 689 | return Err(LayoutError::InvalidIndentation); |
| 690 | } |
| 691 | let known = (level as usize).min(indents.len().saturating_sub(1)); |
| 692 | let indent = (indents |
| 693 | .get(1..=known) |
| 694 | .unwrap_or_default() |
| 695 | .iter() |
| 696 | .map(|v| f64::from(*v)) |
| 697 | .sum::<f64>() |
| 698 | + f64::from(level - known as u32) * MISSING_INDENT) as f32; |
| 699 | if !indent.is_finite() || indent >= width { |
| 700 | return Err(LayoutError::InvalidIndentation); |
| 701 | } |
| 702 | Ok(indent) |
| 703 | } |
| 704 | |
| 705 | /// A numbered paragraph's number and its NumberListFormat. |
| 706 | #[derive(Clone, Debug, PartialEq)] |
| 707 | pub(crate) struct Count { |
| 708 | pub(crate) number: u32, |
| 709 | format: Arc<str>, |
| 710 | } |
| 711 | |
| 712 | /// The number after a previous sibling's in the same `format`, or the first. A number in |
| 713 | /// another format starts again at 1 even where one in this format came before it; siblings |
| 714 | /// without a number leave the count alone (`evidence/toolbar-17/restart.txt`). |
| 715 | fn next(previous: Option<&Count>, format: &str) -> u32 { |
| 716 | previous |
| 717 | .filter(|count| &*count.format == format) |
| 718 | .map_or(1, |count| count.number.saturating_add(1)) |
| 719 | } |
| 720 | |
| 721 | /// The number a paragraph laid out as `number` takes once it follows `previous`. |
| 722 | fn follows(number: Option<&(Count, bool)>, previous: Option<&Count>) -> Option<Count> { |
| 723 | number.map(|(count, restart)| Count { |
| 724 | number: if *restart { |
| 725 | count.number |
| 726 | } else { |
| 727 | next(previous, &count.format) |
| 728 | }, |
| 729 | format: count.format.clone(), |
| 730 | }) |
| 731 | } |
| 732 | |
| 733 | /// What the numbered sibling after `node`, numbered `number`, continues from: siblings without |
| 734 | /// a number leave the count alone, as do empty numbered ones after another number |
| 735 | /// (`evidence/structural-edits/xml/c8-bs-1.xml`, `c8-enter-empty-1.xml`, `c2s-num-1.xml`). |
| 736 | fn tally(node: &PageParagraph, number: Option<Count>, previous: Option<Count>) -> Option<Count> { |
| 737 | number |
| 738 | .filter(|_| { |
| 739 | previous.is_none() || node.text().is_some_and(|text| !text.text.text().is_empty()) |
| 740 | }) |
| 741 | .or(previous) |
| 742 | } |
| 743 | |
| 744 | /// Removes deeper paragraphs' entries from a flow's `(level, count)` stack of the latest |
| 745 | /// paragraph at each level, and the previous sibling's at `level`, returning that one's count. |
| 746 | fn sibling(siblings: &mut Vec<(u32, Option<Count>)>, level: u32) -> Option<Count> { |
| 747 | while siblings.last().is_some_and(|(deeper, _)| *deeper > level) { |
| 748 | siblings.pop(); |
| 749 | } |
| 750 | match siblings.last() { |
| 751 | Some((same, _)) if *same == level => siblings.pop().unwrap().1, |
| 752 | _ => None, |
| 753 | } |
| 754 | } |
| 755 | |
| 756 | /// `text` in `font` as a Unicode font shows it. Windows' Symbol font is symbol-encoded, so |
| 757 | /// its bytes map through the Adobe Symbol encoding (a bullet is `U+00B7` there); Wingdings' |
| 758 | /// map into the private use area their symbol cmaps cover, which also keeps a shaper from |
| 759 | /// dropping `U+00AD` as a soft hyphen. |
| 760 | pub fn symbol_text(font: &str, text: &str) -> String { |
| 761 | /// The Adobe Symbol encoding from 0x20 and from 0xA0, after Unicode's `SYMBOL.TXT`. |
| 762 | const SYMBOL: [&str; 2] = [ |
| 763 | " !∀#∃%&∋()∗+,−./0123456789:;<=>?≅ΑΒΧΔΕΦΓΗΙϑΚΛΜΝΟΠΘΡΣΤΥςΩΞΨΖ[∴]⊥_‾αβχδεφγηιϕκλμνοπθρστυϖωξψζ{|}∼", |
| 764 | "€ϒ′≤⁄∞ƒ♣♦♥♠↔←↑→↓°±″≥×∝∂•÷≠≡≈…⏐⎯↵ℵℑℜ℘⊗⊕∅∩∪⊃⊇⊄⊂⊆∈∉∠∇®©™∏√⋅¬∧∨⇔⇐⇑⇒⇓◊〈®©™∑⎛⎜⎝⎡⎢⎣⎧⎨⎩⎪\u{f8ff}〉∫⌠⎮⌡⎞⎟⎠⎤⎥⎦⎫⎬⎭", |
| 765 | ]; |
| 766 | if font.eq_ignore_ascii_case("Symbol") { |
| 767 | text.chars() |
| 768 | .map(|c| { |
| 769 | let (range, from) = match u32::from(c) { |
| 770 | code @ 0x20..0x7f => (SYMBOL[0], code - 0x20), |
| 771 | code @ 0xa0..0xff => (SYMBOL[1], code - 0xa0), |
| 772 | _ => return c, |
| 773 | }; |
| 774 | range.chars().nth(from as usize).unwrap_or(c) |
| 775 | }) |
| 776 | .collect() |
| 777 | } else if font.to_ascii_lowercase().starts_with("wingdings") { |
| 778 | text.chars() |
| 779 | .map(|c| match u32::from(c) { |
| 780 | code @ 0x20..0x100 => char::from_u32(0xf000 + code).unwrap_or(c), |
| 781 | _ => c, |
| 782 | }) |
| 783 | .collect() |
| 784 | } else { |
| 785 | text.to_owned() |
| 786 | } |
| 787 | } |
| 788 | |
| 789 | /// The marker NumberListFormat `format` shows for `number`. |
| 790 | pub fn numbered(format: &str, number: u32) -> Result<String, LayoutError> { |
| 791 | let (prefix, rest) = format |
| 792 | .split_once('\u{fffd}') |
| 793 | .ok_or(LayoutError::InvalidList)?; |
| 794 | let mut rest = rest.chars(); |
| 795 | Ok(format!( |
| 796 | "{prefix}{}{}", |
| 797 | numeral(rest.next(), number)?, |
| 798 | rest.as_str() |
| 799 | )) |
| 800 | } |
| 801 | |
| 802 | /// `number` in a list numbering sequence: 0 arabic, 1 and 2 upper and lower roman, 3 and 4 |
| 803 | /// upper and lower letters, 5 ordinal, 6 and 7 cardinal and ordinal words, 22 arabic of two |
| 804 | /// digits at least, as OneNote 2010's `numberSequence` spells them in English |
| 805 | /// (`evidence/toolbar-17/sequences.txt`). |
| 806 | pub(crate) fn numeral(sequence: Option<char>, number: u32) -> Result<String, LayoutError> { |
| 807 | let roman = |number: u32| { |
| 808 | const DIGITS: [(u32, &str); 13] = [ |
| 809 | (1000, "M"), |
| 810 | (900, "CM"), |
| 811 | (500, "D"), |
| 812 | (400, "CD"), |
| 813 | (100, "C"), |
| 814 | (90, "XC"), |
| 815 | (50, "L"), |
| 816 | (40, "XL"), |
| 817 | (10, "X"), |
| 818 | (9, "IX"), |
| 819 | (5, "V"), |
| 820 | (4, "IV"), |
| 821 | (1, "I"), |
| 822 | ]; |
| 823 | // Past 3999 the thousands run on as Ms; a million, far past what was observed, |
| 824 | // would lay out a thousand of them. |
| 825 | if !(1..1_000_000).contains(&number) { |
| 826 | return Err(LayoutError::UnsupportedContent); |
| 827 | } |
| 828 | let mut rest = number; |
| 829 | let mut text = String::new(); |
| 830 | for (value, digits) in DIGITS { |
| 831 | while rest >= value { |
| 832 | text.push_str(digits); |
| 833 | rest -= value; |
| 834 | } |
| 835 | } |
| 836 | Ok(text) |
| 837 | }; |
| 838 | // Past Z a letter repeats, AA to ZZ, up to thirty times, then starts over at A. |
| 839 | let letter = |number: u32| { |
| 840 | let index = number |
| 841 | .checked_sub(1) |
| 842 | .ok_or(LayoutError::UnsupportedContent)?; |
| 843 | let letter = char::from(b'A' + (index % 26) as u8); |
| 844 | Ok(std::iter::repeat_n(letter, (index / 26 % 30) as usize + 1).collect::<String>()) |
| 845 | }; |
| 846 | match sequence.map(u32::from) { |
| 847 | Some(0) => Ok(number.to_string()), |
| 848 | Some(22) => Ok(format!("{number:02}")), |
| 849 | Some(1) => roman(number), |
| 850 | Some(2) => roman(number).map(|text| text.to_lowercase()), |
| 851 | Some(3) => letter(number), |
| 852 | Some(4) => letter(number).map(|text| text.to_lowercase()), |
| 853 | Some(5) => { |
| 854 | let suffix = match (number % 10, number % 100) { |
| 855 | (_, 11..=13) => "th", |
| 856 | (1, _) => "st", |
| 857 | (2, _) => "nd", |
| 858 | (3, _) => "rd", |
| 859 | _ => "th", |
| 860 | }; |
| 861 | Ok(format!("{number}{suffix}")) |
| 862 | } |
| 863 | Some(sequence @ (6 | 7)) => { |
| 864 | let mut words = cardinal(number)?; |
| 865 | if sequence == 7 { |
| 866 | // The last word turns ordinal: one to first, twenty to twentieth. |
| 867 | let at = words.rfind([' ', '-']).map_or(0, |at| at + 1); |
| 868 | let last = &words[at..]; |
| 869 | let ordinal = match last { |
| 870 | "one" => "first".to_owned(), |
| 871 | "two" => "second".to_owned(), |
| 872 | "three" => "third".to_owned(), |
| 873 | "five" => "fifth".to_owned(), |
| 874 | "eight" => "eighth".to_owned(), |
| 875 | "nine" => "ninth".to_owned(), |
| 876 | "twelve" => "twelfth".to_owned(), |
| 877 | _ if last.ends_with('y') => format!("{}ieth", &last[..last.len() - 1]), |
| 878 | _ => format!("{last}th"), |
| 879 | }; |
| 880 | words.replace_range(at.., &ordinal); |
| 881 | } |
| 882 | let mut chars = words.chars(); |
| 883 | let first = chars.next().unwrap_or_default(); |
| 884 | Ok(first.to_uppercase().chain(chars).collect()) |
| 885 | } |
| 886 | _ => Err(LayoutError::UnsupportedContent), |
| 887 | } |
| 888 | } |
| 889 | |
| 890 | /// `number` in lowercase English words, "one hundred twenty-one", below a million. |
| 891 | fn cardinal(number: u32) -> Result<String, LayoutError> { |
| 892 | const SMALL: [&str; 20] = [ |
| 893 | "zero", |
| 894 | "one", |
| 895 | "two", |
| 896 | "three", |
| 897 | "four", |
| 898 | "five", |
| 899 | "six", |
| 900 | "seven", |
| 901 | "eight", |
| 902 | "nine", |
| 903 | "ten", |
| 904 | "eleven", |
| 905 | "twelve", |
| 906 | "thirteen", |
| 907 | "fourteen", |
| 908 | "fifteen", |
| 909 | "sixteen", |
| 910 | "seventeen", |
| 911 | "eighteen", |
| 912 | "nineteen", |
| 913 | ]; |
| 914 | const TENS: [&str; 10] = [ |
| 915 | "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety", |
| 916 | ]; |
| 917 | fn below_thousand(number: u32) -> String { |
| 918 | let (hundreds, rest) = (number / 100, number % 100); |
| 919 | let rest = match rest { |
| 920 | 0 => None, |
| 921 | 1..20 => Some(SMALL[rest as usize].to_owned()), |
| 922 | _ if rest % 10 == 0 => Some(TENS[(rest / 10) as usize].to_owned()), |
| 923 | _ => Some(format!( |
| 924 | "{}-{}", |
| 925 | TENS[(rest / 10) as usize], |
| 926 | SMALL[(rest % 10) as usize] |
| 927 | )), |
| 928 | }; |
| 929 | match (hundreds, rest) { |
| 930 | (0, rest) => rest.unwrap_or_default(), |
| 931 | (hundreds, None) => format!("{} hundred", SMALL[hundreds as usize]), |
| 932 | (hundreds, Some(rest)) => format!("{} hundred {rest}", SMALL[hundreds as usize]), |
| 933 | } |
| 934 | } |
| 935 | match number { |
| 936 | 1..1000 => Ok(below_thousand(number)), |
| 937 | 1000..1_000_000 => { |
| 938 | let (thousands, rest) = (number / 1000, number % 1000); |
| 939 | let head = format!("{} thousand", below_thousand(thousands)); |
| 940 | Ok(if rest == 0 { |
| 941 | head |
| 942 | } else { |
| 943 | format!("{head} {}", below_thousand(rest)) |
| 944 | }) |
| 945 | } |
| 946 | _ => Err(LayoutError::UnsupportedContent), |
| 947 | } |
| 948 | } |
| 949 | |
| 950 | fn spacing(format: &Format) -> Result<[f32; 2], LayoutError> { |
| 951 | let spacing = [format.space_before, format.space_after].map(|space| space.unwrap_or(0.0)); |
| 952 | if spacing.iter().any(|v| !v.is_finite() || *v < 0.0) { |
| 953 | return Err(LayoutError::InvalidSpacing); |
| 954 | } |
| 955 | Ok(spacing) |
| 956 | } |
| 957 | |
| 958 | /// Where a node with `spacing` starts after the flow `state` of a bottom and pending spacing. |
| 959 | fn top(state: &mut (f64, Option<f32>), [before, after]: [f32; 2]) -> f32 { |
| 960 | if let Some(previous) = state.1 { |
| 961 | state.0 += f64::from(previous.max(before)); |
| 962 | } |
| 963 | state.1 = Some(after); |
| 964 | state.0 as f32 |
| 965 | } |
| 966 | |
| 967 | impl ParagraphLayout { |
| 968 | pub(crate) fn reset_origin(&mut self, x: f32) { |
| 969 | let offset = x - self.origin[0]; |
| 970 | self.origin = [x, 0.0]; |
| 971 | for (_, origin) in &mut self.markers { |
| 972 | origin[0] += offset; |
| 973 | } |
| 974 | for tag in &mut self.tags { |
| 975 | tag.origin[0] += offset; |
| 976 | } |
| 977 | } |
| 978 | |
| 979 | fn size(&self) -> [f32; 2] { |
| 980 | [ |
| 981 | self.origin[0] + self.text.shaped.width(), |
| 982 | self.markers |
| 983 | .iter() |
| 984 | .map(|(layout, _)| layout.height()) |
| 985 | .fold(self.text.height(), f32::max), |
| 986 | ] |
| 987 | } |
| 988 | |
| 989 | /// Lays out a text paragraph after a sibling numbered `previous`. |
| 990 | pub(crate) fn shape( |
| 991 | engine: &mut TextEngine, |
| 992 | paragraph: &PageParagraph, |
| 993 | previous: Option<&Count>, |
| 994 | width: f32, |
| 995 | indents: &[f32], |
| 996 | definitions: &BTreeMap<ExGuid, Definition>, |
| 997 | ) -> Result<Self, LayoutError> { |
| 998 | if !width.is_finite() || width <= 0.0 { |
| 999 | return Err(LayoutError::InvalidWidth); |
| 1000 | } |
| 1001 | let indent = indentation(paragraph.level, indents, width)?; |
| 1002 | let source = paragraph.text().ok_or(LayoutError::UnsupportedContent)?; |
| 1003 | let projection = source |
| 1004 | .text |
| 1005 | .project() |
| 1006 | .map_err(|_| LayoutError::InvalidSourceRange)?; |
| 1007 | let format = &projection.text().spans()[0].format; |
| 1008 | let spacing = format.list_spacing.unwrap_or(7.2); |
| 1009 | if !spacing.is_finite() || spacing < 0.0 { |
| 1010 | return Err(LayoutError::InvalidSpacing); |
| 1011 | } |
| 1012 | // Stored newest first; OneNote lists and paints tags oldest first. |
| 1013 | // An Outlook task tag carries no definition but its own icon; a tag whose definition |
| 1014 | // is missing shows nothing, as OneNote draws no icon for it. |
| 1015 | let mut tag_definitions: Vec<_> = paragraph |
| 1016 | .tags |
| 1017 | .iter() |
| 1018 | .chain(&source.tags) |
| 1019 | .filter_map(|tag| { |
| 1020 | if tag.status & 4 != 0 { |
| 1021 | return Some((tag, None, None, None, None)); |
| 1022 | } |
| 1023 | match &definitions.get(tag.definition.as_ref()?)?.kind { |
| 1024 | Kind::TagDefinition { |
| 1025 | shape, |
| 1026 | label, |
| 1027 | color, |
| 1028 | highlight, |
| 1029 | .. |
| 1030 | } => Some((tag, Some(*shape), label.as_deref(), *color, *highlight)), |
| 1031 | _ => None, |
| 1032 | } |
| 1033 | }) |
| 1034 | .collect(); |
| 1035 | tag_definitions.reverse(); |
| 1036 | // The newest tag that sets a colour wins. |
| 1037 | let color = tag_definitions.iter().rev().find_map(|tag| tag.3); |
| 1038 | let highlight = tag_definitions.iter().rev().find_map(|tag| tag.4); |
| 1039 | // Equations with objects draw in two dimensions in a space kept in their line; their |
| 1040 | // linear text stays in the text, too small to see, to hold the caret. |
| 1041 | let visible = projection.text(); |
| 1042 | let mut equations = Vec::new(); |
| 1043 | for zone in crate::math::built(visible) { |
| 1044 | let units = |byte| { |
| 1045 | visible |
| 1046 | .utf16_offset(byte) |
| 1047 | .map_err(|_| LayoutError::InvalidSourceRange) |
| 1048 | }; |
| 1049 | let math = visible |
| 1050 | .slice(units(zone.start)?..units(zone.end)?) |
| 1051 | .map_err(|_| LayoutError::InvalidSourceRange)?; |
| 1052 | // An equation that does not parse shows its linear text. |
| 1053 | if let Ok(math) = crate::math::layout(engine, &math) { |
| 1054 | equations.push((zone, math)); |
| 1055 | } |
| 1056 | } |
| 1057 | let spaces: Vec<_> = equations |
| 1058 | .iter() |
| 1059 | .map(|(zone, math)| InlineSpace { |
| 1060 | index: zone.start, |
| 1061 | width: math.size[0], |
| 1062 | ascent: math.baseline, |
| 1063 | descent: math.size[1] - math.baseline, |
| 1064 | }) |
| 1065 | .collect(); |
| 1066 | // URL text shows as a link, as OneNote links it on opening a page. |
| 1067 | let mut marks: Vec<(Range<usize>, bool)> = crate::editor::shown_urls(visible) |
| 1068 | .into_iter() |
| 1069 | .map(|url| (url, false)) |
| 1070 | .chain(equations.iter().map(|(zone, _)| (zone.clone(), true))) |
| 1071 | .collect(); |
| 1072 | marks.sort_by_key(|(range, _)| range.start); |
| 1073 | let mut text = if color.is_none() && highlight.is_none() && marks.is_empty() { |
| 1074 | engine.layout(visible, width - indent)? |
| 1075 | } else { |
| 1076 | let mut start = 0; |
| 1077 | let runs = visible.spans().iter().flat_map(|span| { |
| 1078 | let mut format = span.format.clone(); |
| 1079 | format.color = color.or(format.color); |
| 1080 | format.highlight = highlight.or(format.highlight); |
| 1081 | let mut runs = Vec::new(); |
| 1082 | let mut from = start; |
| 1083 | for (range, equation) in &marks { |
| 1084 | let (a, b) = ( |
| 1085 | range.start.clamp(from, span.end), |
| 1086 | range.end.clamp(from, span.end), |
| 1087 | ); |
| 1088 | if a < b { |
| 1089 | runs.push((visible.text()[from..a].to_owned(), format.clone())); |
| 1090 | let marked = if *equation { |
| 1091 | Format { |
| 1092 | font_size: Some(HELD), |
| 1093 | superscript: None, |
| 1094 | subscript: None, |
| 1095 | ..format.clone() |
| 1096 | } |
| 1097 | } else { |
| 1098 | Format { |
| 1099 | hyperlink: Some(true), |
| 1100 | ..format.clone() |
| 1101 | } |
| 1102 | }; |
| 1103 | runs.push((visible.text()[a..b].to_owned(), marked)); |
| 1104 | from = b; |
| 1105 | } |
| 1106 | } |
| 1107 | runs.push((visible.text()[from..span.end].to_owned(), format)); |
| 1108 | start = span.end; |
| 1109 | runs |
| 1110 | }); |
| 1111 | engine.layout_with( |
| 1112 | &Paragraph::from_runs( |
| 1113 | runs.filter(|(text, _)| !text.is_empty()) |
| 1114 | .collect::<Vec<_>>(), |
| 1115 | ), |
| 1116 | width - indent, |
| 1117 | &spaces, |
| 1118 | )? |
| 1119 | }; |
| 1120 | let mut markers = Vec::new(); |
| 1121 | let mut marker_x = indent - MARKER_OFFSET; |
| 1122 | let mut number = None; |
| 1123 | for id in paragraph.lists.iter().rev() { |
| 1124 | let definition = definitions.get(id).ok_or(LayoutError::InvalidList)?; |
| 1125 | let Kind::List { |
| 1126 | font, |
| 1127 | format: Some(value), |
| 1128 | restart, |
| 1129 | .. |
| 1130 | } = &definition.kind |
| 1131 | else { |
| 1132 | return Err(LayoutError::InvalidList); |
| 1133 | }; |
| 1134 | let mut color = definition.format.color; |
| 1135 | let value = if value.contains('\u{fffd}') { |
| 1136 | // An empty numbered paragraph shows its number as a gray placeholder. |
| 1137 | if source.text.text().is_empty() { |
| 1138 | color = Some(PLACEHOLDER); |
| 1139 | } |
| 1140 | let current = restart.unwrap_or(next(previous, value)); |
| 1141 | let format = value.as_str().into(); |
| 1142 | number = Some(( |
| 1143 | Count { |
| 1144 | number: current, |
| 1145 | format, |
| 1146 | }, |
| 1147 | restart.is_some(), |
| 1148 | )); |
| 1149 | numbered(value, current)? |
| 1150 | } else { |
| 1151 | value.clone() |
| 1152 | }; |
| 1153 | if value.contains('\u{fffd}') { |
| 1154 | return Err(LayoutError::UnsupportedContent); |
| 1155 | } |
| 1156 | // Without a font of its own a marker takes its text's, as a number does. |
| 1157 | let font = font |
| 1158 | .clone() |
| 1159 | .or_else(|| definition.format.font.clone()) |
| 1160 | .or_else(|| format.font.clone()); |
| 1161 | let value = font |
| 1162 | .as_deref() |
| 1163 | .map_or(value.clone(), |font| symbol_text(font, &value)); |
| 1164 | let marker = Paragraph::new( |
| 1165 | value, |
| 1166 | Format { |
| 1167 | font, |
| 1168 | font_size: definition.format.font_size.or(format.font_size), |
| 1169 | color, |
| 1170 | ..Format::default() |
| 1171 | }, |
| 1172 | ); |
| 1173 | let layout = engine.layout(&marker, width)?; |
| 1174 | if layout.lines().count() != 1 { |
| 1175 | return Err(LayoutError::InvalidList); |
| 1176 | } |
| 1177 | let (line, metrics) = layout.lines().next().unwrap(); |
| 1178 | marker_x -= spacing + line.metrics().advance; |
| 1179 | if !marker_x.is_finite() { |
| 1180 | return Err(LayoutError::InvalidSpacing); |
| 1181 | } |
| 1182 | let y = text.lines().next().unwrap().1.baseline - metrics.baseline; |
| 1183 | markers.push((layout, [marker_x, y])); |
| 1184 | } |
| 1185 | text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?; |
| 1186 | let baseline = text.lines().next().unwrap().1.baseline; |
| 1187 | let mut tags = Vec::new(); |
| 1188 | for tag in &tag_definitions { |
| 1189 | let (tag, shape, label) = (tag.0, tag.1, tag.2); |
| 1190 | let icon = match shape { |
| 1191 | None => TagIcon::Task { |
| 1192 | shape: tag.shape.unwrap_or(0), |
| 1193 | }, |
| 1194 | Some(shape) => match TagIcon::of(shape.unwrap_or(0), tag.status & 1 != 0) { |
| 1195 | Some(icon) => icon, |
| 1196 | None => continue, |
| 1197 | }, |
| 1198 | }; |
| 1199 | let size = format.font_size.unwrap_or(crate::layout::DEFAULT_FONT_SIZE); |
| 1200 | let side = ParagraphTag::side(size); |
| 1201 | tags.push(ParagraphTag { |
| 1202 | icon, |
| 1203 | origin: [0.0, ParagraphTag::top(baseline, size, side)], |
| 1204 | size: side, |
| 1205 | label: label.unwrap_or_default().to_owned(), |
| 1206 | disabled: tag.status & 2 != 0, |
| 1207 | }); |
| 1208 | } |
| 1209 | let math = equations.into_iter().map(|(_, math)| math).collect(); |
| 1210 | let mut result = Self { |
| 1211 | id: paragraph.id, |
| 1212 | origin: [indent, 0.0], |
| 1213 | projection, |
| 1214 | text, |
| 1215 | markers, |
| 1216 | number, |
| 1217 | tags, |
| 1218 | math, |
| 1219 | parent: paragraph.parent, |
| 1220 | band: paragraph.format.highlight, |
| 1221 | }; |
| 1222 | result.place_tags(result.marker_left()); |
| 1223 | Ok(result) |
| 1224 | } |
| 1225 | |
| 1226 | fn marker_left(&self) -> Option<f32> { |
| 1227 | self.markers.iter().map(|(_, [x, _])| *x).reduce(f32::min) |
| 1228 | } |
| 1229 | |
| 1230 | fn place_tags(&mut self, marker: Option<f32>) { |
| 1231 | place_tags(&mut self.tags, self.origin[0], marker); |
| 1232 | } |
| 1233 | } |
| 1234 | |
| 1235 | /// Places the tags of content at `x` among siblings whose leftmost marker starts at |
| 1236 | /// `marker`; later tags follow the first to its right, toward the content. |
| 1237 | fn place_tags(tags: &mut [ParagraphTag], x: f32, marker: Option<f32>) { |
| 1238 | for (index, tag) in tags.iter_mut().enumerate() { |
| 1239 | tag.origin[0] = ParagraphTag::column(x, marker, tag.size) + tag.size * index as f32; |
| 1240 | } |
| 1241 | } |
| 1242 | |
| 1243 | pub(crate) fn visible_paragraphs<'a>( |
| 1244 | nodes: impl Iterator<Item = &'a PageParagraph>, |
| 1245 | ) -> impl Iterator<Item = &'a PageParagraph> { |
| 1246 | let mut hidden = BTreeSet::new(); |
| 1247 | nodes.filter(move |paragraph| { |
| 1248 | let invisible = paragraph |
| 1249 | .parent |
| 1250 | .is_some_and(|parent| hidden.contains(&parent)); |
| 1251 | if invisible || paragraph.collapsed { |
| 1252 | hidden.insert(paragraph.id); |
| 1253 | } |
| 1254 | !invisible |
| 1255 | }) |
| 1256 | } |
| 1257 | |
| 1258 | impl OutlineLayout { |
| 1259 | /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each |
| 1260 | /// paragraph's tags start at the column's left edge. Tag origins assume a one-tag column. |
| 1261 | pub(crate) fn tag_column_offset(&self) -> f32 { |
| 1262 | -self |
| 1263 | .paragraphs |
| 1264 | .iter() |
| 1265 | .map(|p| p.tags.as_slice()) |
| 1266 | .chain(self.block_tags().map(|block| block.tags.as_slice())) |
| 1267 | .map(|tags| tags.iter().skip(1).map(|tag| tag.size).sum::<f32>()) |
| 1268 | .fold(0.0, f32::max) |
| 1269 | } |
| 1270 | |
| 1271 | fn block_tags(&self) -> impl Iterator<Item = &BlockTags> { |
| 1272 | let tables = self.tables.iter().filter_map(|table| table.tags.as_ref()); |
| 1273 | tables.chain( |
| 1274 | self.objects |
| 1275 | .iter() |
| 1276 | .filter_map(|object| object.tags.as_ref()), |
| 1277 | ) |
| 1278 | } |
| 1279 | |
| 1280 | /// Every note tag the outline draws, with the paragraph it marks and its outline-local |
| 1281 | /// origin before `tag_column_offset`. |
| 1282 | pub fn tags(&self) -> impl Iterator<Item = (ExGuid, [f32; 2], &ParagraphTag)> { |
| 1283 | let text = self.paragraphs.iter().flat_map(|paragraph| { |
| 1284 | paragraph.tags.iter().map(move |tag| { |
| 1285 | let y = paragraph.origin[1] + tag.origin[1]; |
| 1286 | (paragraph.id, [tag.origin[0], y], tag) |
| 1287 | }) |
| 1288 | }); |
| 1289 | let blocks = self.block_tags().flat_map(|block| { |
| 1290 | block |
| 1291 | .tags |
| 1292 | .iter() |
| 1293 | .map(|tag| (block.paragraph, tag.origin, tag)) |
| 1294 | }); |
| 1295 | text.chain(blocks) |
| 1296 | } |
| 1297 | |
| 1298 | /// Innermost table cell containing a visible paragraph index. |
| 1299 | pub(crate) fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> { |
| 1300 | self.tables.iter().rev().find_map(|table| { |
| 1301 | let cell = table |
| 1302 | .cells |
| 1303 | .partition_point(|cell| cell.paragraphs.end <= index); |
| 1304 | table |
| 1305 | .cells |
| 1306 | .get(cell) |
| 1307 | .filter(|cell| cell.paragraphs.contains(&index)) |
| 1308 | }) |
| 1309 | } |
| 1310 | |
| 1311 | fn append(&mut self, mut child: Self, origin: [f32; 2]) { |
| 1312 | for paragraph in &mut child.paragraphs { |
| 1313 | paragraph.origin[0] += origin[0]; |
| 1314 | paragraph.origin[1] += origin[1]; |
| 1315 | for (_, marker) in &mut paragraph.markers { |
| 1316 | marker[0] += origin[0]; |
| 1317 | } |
| 1318 | for tag in &mut paragraph.tags { |
| 1319 | tag.origin[0] += origin[0]; |
| 1320 | } |
| 1321 | } |
| 1322 | for table in &mut child.tables { |
| 1323 | for cell in &mut table.cells { |
| 1324 | cell.paragraphs.start += self.paragraphs.len(); |
| 1325 | cell.paragraphs.end += self.paragraphs.len(); |
| 1326 | for (value, offset) in cell.rect.iter_mut().zip(origin.into_iter().cycle()) { |
| 1327 | *value += offset; |
| 1328 | } |
| 1329 | } |
| 1330 | if let Some(tags) = &mut table.tags { |
| 1331 | tags.offset(origin); |
| 1332 | } |
| 1333 | } |
| 1334 | for object in &mut child.objects { |
| 1335 | for (value, offset) in object.rect.iter_mut().zip(origin.into_iter().cycle()) { |
| 1336 | *value += offset; |
| 1337 | } |
| 1338 | object.bottom += origin[1]; |
| 1339 | if let Some(tags) = &mut object.tags { |
| 1340 | tags.offset(origin); |
| 1341 | } |
| 1342 | if let ObjectKind::File(label) | ObjectKind::Unsupported(label) = &mut object.kind { |
| 1343 | let y = label.origin[1]; |
| 1344 | label.reset_origin(label.origin[0] + origin[0]); |
| 1345 | label.origin[1] = y + origin[1]; |
| 1346 | } |
| 1347 | } |
| 1348 | self.paragraphs.extend(child.paragraphs); |
| 1349 | self.tables.extend(child.tables); |
| 1350 | self.objects.extend(child.objects); |
| 1351 | } |
| 1352 | |
| 1353 | pub(crate) fn flow<'a>( |
| 1354 | nodes: impl Iterator<Item = &'a PageParagraph>, |
| 1355 | indents: &[f32], |
| 1356 | width: f32, |
| 1357 | fixed_width: bool, |
| 1358 | depth: usize, |
| 1359 | edit: Option<&DocumentEdit>, |
| 1360 | shape: &mut impl FnMut( |
| 1361 | &PageParagraph, |
| 1362 | Option<&Count>, |
| 1363 | f32, |
| 1364 | &[f32], |
| 1365 | ) -> Result<ParagraphLayout, LayoutError>, |
| 1366 | ) -> Result<Self, LayoutError> { |
| 1367 | let mut result = Self::stack( |
| 1368 | nodes, |
| 1369 | indents, |
| 1370 | width, |
| 1371 | depth, |
| 1372 | edit, |
| 1373 | BTreeSet::new(), |
| 1374 | (0.0, None), |
| 1375 | &mut Vec::new(), |
| 1376 | shape, |
| 1377 | )?; |
| 1378 | result.size[0] = if fixed_width { width } else { result.size[0] }.max(result.table_width()); |
| 1379 | result.place_tags(); |
| 1380 | Ok(result) |
| 1381 | } |
| 1382 | |
| 1383 | /// Moves each paragraph's tags clear of the widest list marker among its siblings. |
| 1384 | fn place_tags(&mut self) { |
| 1385 | let mut markers = BTreeMap::<Option<ExGuid>, f32>::new(); |
| 1386 | for paragraph in &self.paragraphs { |
| 1387 | if let Some(x) = paragraph.marker_left() { |
| 1388 | let left = markers.entry(paragraph.parent).or_insert(x); |
| 1389 | *left = left.min(x); |
| 1390 | } |
| 1391 | } |
| 1392 | for paragraph in &mut self.paragraphs { |
| 1393 | let marker = markers.get(&paragraph.parent).copied(); |
| 1394 | paragraph.place_tags(marker); |
| 1395 | } |
| 1396 | let tables = self |
| 1397 | .tables |
| 1398 | .iter_mut() |
| 1399 | .filter_map(|table| table.tags.as_mut()); |
| 1400 | let objects = self |
| 1401 | .objects |
| 1402 | .iter_mut() |
| 1403 | .filter_map(|object| object.tags.as_mut()); |
| 1404 | for block in tables.chain(objects) { |
| 1405 | place_tags( |
| 1406 | &mut block.tags, |
| 1407 | block.x, |
| 1408 | markers.get(&block.parent).copied(), |
| 1409 | ); |
| 1410 | } |
| 1411 | } |
| 1412 | |
| 1413 | /// Lays out `nodes` after the flow `state` and `siblings` stack, hiding the children of |
| 1414 | /// `hiding`; at the root, keeps a block for each node. |
| 1415 | #[allow(clippy::too_many_arguments)] |
| 1416 | fn stack<'a>( |
| 1417 | nodes: impl Iterator<Item = &'a PageParagraph>, |
| 1418 | indents: &[f32], |
| 1419 | width: f32, |
| 1420 | depth: usize, |
| 1421 | edit: Option<&DocumentEdit>, |
| 1422 | mut hiding: BTreeSet<ExGuid>, |
| 1423 | mut state: (f64, Option<f32>), |
| 1424 | siblings: &mut Vec<(u32, Option<Count>)>, |
| 1425 | shape: &mut impl FnMut( |
| 1426 | &PageParagraph, |
| 1427 | Option<&Count>, |
| 1428 | f32, |
| 1429 | &[f32], |
| 1430 | ) -> Result<ParagraphLayout, LayoutError>, |
| 1431 | ) -> Result<Self, LayoutError> { |
| 1432 | if !width.is_finite() || width <= 0.0 { |
| 1433 | return Err(LayoutError::InvalidWidth); |
| 1434 | } |
| 1435 | if depth > 64 { |
| 1436 | return Err(LayoutError::UnsupportedContent); |
| 1437 | } |
| 1438 | let mut result = Self { |
| 1439 | size: [36.0, 0.0], |
| 1440 | widths: [36.0, f32::NEG_INFINITY], |
| 1441 | ..Self::default() |
| 1442 | }; |
| 1443 | for node in nodes { |
| 1444 | let hidden = node.parent.is_some_and(|parent| hiding.contains(&parent)); |
| 1445 | if hidden || node.collapsed { |
| 1446 | hiding.insert(node.id); |
| 1447 | } |
| 1448 | let previous = sibling(siblings, node.level); |
| 1449 | let mut number = None; |
| 1450 | let mut block = Block { |
| 1451 | first: [ |
| 1452 | result.paragraphs.len(), |
| 1453 | result.tables.len(), |
| 1454 | result.objects.len(), |
| 1455 | ], |
| 1456 | metrics: None, |
| 1457 | extent: [f32::NEG_INFINITY; 2], |
| 1458 | state, |
| 1459 | rel: Vec::new(), |
| 1460 | count: None, |
| 1461 | }; |
| 1462 | if !hidden { |
| 1463 | let (space, height, flow, extent) = match &node.content { |
| 1464 | ParagraphContent::Text(_) => { |
| 1465 | let mut paragraph = shape(node, previous.as_ref(), width, indents)?; |
| 1466 | number = paragraph.number.clone().map(|(count, _)| count); |
| 1467 | let space = spacing(&paragraph.projection.text().spans()[0].format)?; |
| 1468 | paragraph.origin[1] = top(&mut state, space); |
| 1469 | let size = paragraph.size(); |
| 1470 | block.extent[1] = paragraph.origin[0] + paragraph.text.shaped.width(); |
| 1471 | result.paragraphs.push(paragraph); |
| 1472 | (space, size[1], size[1], size[0]) |
| 1473 | } |
| 1474 | ParagraphContent::Table(table) => { |
| 1475 | let x = indentation(node.level, indents, width)?; |
| 1476 | let space = spacing(&node.format)?; |
| 1477 | let y = top(&mut state, space); |
| 1478 | let mut child = Self::table(table, depth + 1, edit, shape)?; |
| 1479 | child.tables[0].tags = |
| 1480 | BlockTags::new(node, &table.tags, 0.0, shape)?.map(|mut tags| { |
| 1481 | tags.centre(0.0, child.size[1]); |
| 1482 | tags |
| 1483 | }); |
| 1484 | if depth == 0 { |
| 1485 | block.rel = verticals( |
| 1486 | &mut child.paragraphs, |
| 1487 | &mut child.tables, |
| 1488 | &mut child.objects, |
| 1489 | ) |
| 1490 | .map(|value| *value) |
| 1491 | .collect(); |
| 1492 | } |
| 1493 | let size = child.size; |
| 1494 | result.append(child, [x, y]); |
| 1495 | (space, size[1], size[1], x + size[0]) |
| 1496 | } |
| 1497 | content => { |
| 1498 | let x = indentation(node.level, indents, width)?; |
| 1499 | let space = spacing(&node.format)?; |
| 1500 | let y = top(&mut state, space); |
| 1501 | let (mut object, height, extent) = match content { |
| 1502 | ParagraphContent::Image(image) => { |
| 1503 | let [w, h] = |
| 1504 | image_size(image).ok_or(LayoutError::UnsupportedContent)?; |
| 1505 | let object = ObjectLayout { |
| 1506 | id: image.id, |
| 1507 | rect: [x, 0.0, x + w, 0.0], |
| 1508 | kind: ObjectKind::Picture, |
| 1509 | bottom: 0.0, |
| 1510 | tags: None, |
| 1511 | }; |
| 1512 | (object, h, x + w) |
| 1513 | } |
| 1514 | ParagraphContent::Attachment(file) => { |
| 1515 | let (object, h) = |
| 1516 | file_column(file, node.id, &node.format, x, shape)?; |
| 1517 | (object, h, x + ATTACHMENT_WIDTH) |
| 1518 | } |
| 1519 | ParagraphContent::Ink(ink) => { |
| 1520 | // The paragraph reaches from its origin to the farthest stroke point. |
| 1521 | let [w, h] = ink |
| 1522 | .bounds() |
| 1523 | .map(|[x, y, w, h]| [x + w, y + h]) |
| 1524 | .filter(|size| size.iter().all(|v| v.is_finite() && *v >= 0.0)) |
| 1525 | .ok_or(LayoutError::UnsupportedContent)?; |
| 1526 | let object = ObjectLayout { |
| 1527 | id: ink.id, |
| 1528 | rect: [x, 0.0, x + w, 0.0], |
| 1529 | kind: ObjectKind::Ink(ink.clone()), |
| 1530 | bottom: 0.0, |
| 1531 | tags: None, |
| 1532 | }; |
| 1533 | (object, h, x + w) |
| 1534 | } |
| 1535 | ParagraphContent::Unsupported(unsupported) => { |
| 1536 | let w = unsupported.layout.max_width.unwrap_or(160.0).max(160.0); |
| 1537 | let mut label = shape( |
| 1538 | &caption( |
| 1539 | node.id, |
| 1540 | unsupported.id, |
| 1541 | "Unsupported content", |
| 1542 | Format::default(), |
| 1543 | ), |
| 1544 | None, |
| 1545 | w - 16.0, |
| 1546 | &[0.0, 0.0], |
| 1547 | )?; |
| 1548 | label.reset_origin(x + 8.0); |
| 1549 | let h = unsupported |
| 1550 | .layout |
| 1551 | .max_height |
| 1552 | .unwrap_or(0.0) |
| 1553 | .max(label.text.height() + 16.0); |
| 1554 | let object = ObjectLayout { |
| 1555 | id: unsupported.id, |
| 1556 | rect: [x, 0.0, x + w, 0.0], |
| 1557 | kind: ObjectKind::Unsupported(label), |
| 1558 | bottom: 0.0, |
| 1559 | tags: None, |
| 1560 | }; |
| 1561 | (object, h, x + w) |
| 1562 | } |
| 1563 | ParagraphContent::Text(_) | ParagraphContent::Table(_) => { |
| 1564 | unreachable!("text and tables are not objects") |
| 1565 | } |
| 1566 | }; |
| 1567 | let tags = match content { |
| 1568 | ParagraphContent::Image(image) => image.tags.as_slice(), |
| 1569 | ParagraphContent::Attachment(file) => file.tags.as_slice(), |
| 1570 | _ => &[], |
| 1571 | }; |
| 1572 | object.tags = BlockTags::new(node, tags, x, shape)?; |
| 1573 | let flow = object.place(y, height); |
| 1574 | result.objects.push(object); |
| 1575 | (space, height, flow, extent) |
| 1576 | } |
| 1577 | }; |
| 1578 | state.0 += f64::from(flow); |
| 1579 | result.size[0] = result.size[0].max(extent); |
| 1580 | result.widths[1] = result.widths[1].max(block.extent[1]); |
| 1581 | block.extent[0] = extent; |
| 1582 | block.metrics = Some([space[0], space[1], height]); |
| 1583 | if !(state.0 as f32).is_finite() || !result.size[0].is_finite() { |
| 1584 | return Err(LayoutError::InvalidSpacing); |
| 1585 | } |
| 1586 | } |
| 1587 | block.count = tally(node, number, previous); |
| 1588 | siblings.push((node.level, block.count.clone())); |
| 1589 | block.state = state; |
| 1590 | if depth == 0 { |
| 1591 | result.blocks.push(block); |
| 1592 | } |
| 1593 | } |
| 1594 | result.size[1] = state.0 as f32; |
| 1595 | result.widths[0] = result.size[0]; |
| 1596 | Ok(result) |
| 1597 | } |
| 1598 | |
| 1599 | /// Root node `root`'s number, when it is numbered text on show. |
| 1600 | fn number(&self, nodes: &[PageParagraph], root: usize) -> Option<&(Count, bool)> { |
| 1601 | let block = &self.blocks[root]; |
| 1602 | (block.metrics.is_some() && nodes[root].text().is_some()) |
| 1603 | .then(|| self.paragraphs[block.first[0]].number.as_ref()) |
| 1604 | .flatten() |
| 1605 | } |
| 1606 | |
| 1607 | /// Where each root node's flow starts and ends in the outline, or `None` while a collapsed |
| 1608 | /// parent hides it. |
| 1609 | pub(crate) fn spans(&self) -> impl Iterator<Item = Option<[f32; 2]>> + '_ { |
| 1610 | let mut state = (0.0, None); |
| 1611 | self.blocks.iter().map(move |block| { |
| 1612 | let span = block |
| 1613 | .metrics |
| 1614 | .map(|[before, after, _]| [top(&mut state, [before, after]), block.state.0 as f32]); |
| 1615 | state = block.state; |
| 1616 | span |
| 1617 | }) |
| 1618 | } |
| 1619 | |
| 1620 | /// The paragraphs, tables and objects of root nodes `range`. |
| 1621 | pub(crate) fn pieces(&self, range: Range<usize>) -> [Range<usize>; 3] { |
| 1622 | let first = |root: usize| { |
| 1623 | self.blocks.get(root).map_or( |
| 1624 | [self.paragraphs.len(), self.tables.len(), self.objects.len()], |
| 1625 | |block| block.first, |
| 1626 | ) |
| 1627 | }; |
| 1628 | let [start, end] = [first(range.start), first(range.end)]; |
| 1629 | [0, 1, 2].map(|kind| start[kind]..end[kind]) |
| 1630 | } |
| 1631 | |
| 1632 | /// Lays out root nodes `range` of `nodes` once `edit` applies, the edit's own or the table |
| 1633 | /// holding its cell, and works out where the nodes after them move. Resizing columns of |
| 1634 | /// other nodes or showing or hiding them lays out every node; renumbering lays out the |
| 1635 | /// renumbered. |
| 1636 | #[allow(clippy::too_many_arguments)] |
| 1637 | pub(crate) fn relayout( |
| 1638 | &self, |
| 1639 | nodes: &[PageParagraph], |
| 1640 | edit: &DocumentEdit, |
| 1641 | range: Range<usize>, |
| 1642 | indents: &[f32], |
| 1643 | width: f32, |
| 1644 | fixed_width: bool, |
| 1645 | shape: &mut impl FnMut( |
| 1646 | &PageParagraph, |
| 1647 | Option<&Count>, |
| 1648 | f32, |
| 1649 | &[f32], |
| 1650 | ) -> Result<ParagraphLayout, LayoutError>, |
| 1651 | ) -> Result<Relayout, LayoutError> { |
| 1652 | let resized = |node: &PageParagraph| match &node.content { |
| 1653 | ParagraphContent::Table(table) => edit.columns.contains_key(&table.id), |
| 1654 | _ => false, |
| 1655 | }; |
| 1656 | let mut range = if edit.columns.len() |
| 1657 | == descendants(&nodes[range.clone()], None) |
| 1658 | .filter(|(_, _, node)| resized(node)) |
| 1659 | .count() |
| 1660 | { |
| 1661 | range |
| 1662 | } else { |
| 1663 | 0..nodes.len() |
| 1664 | }; |
| 1665 | loop { |
| 1666 | let old = self.pieces(range.clone()); |
| 1667 | let sources = leaves(&nodes[range.clone()], None) |
| 1668 | .map(|(_, _, node)| (node.id, node)) |
| 1669 | .collect::<BTreeMap<_, _>>(); |
| 1670 | let cached = self.paragraphs[old[0].clone()] |
| 1671 | .iter() |
| 1672 | .filter_map(|layout| Some((layout.id, (*sources.get(&layout.id)?, layout)))) |
| 1673 | .collect::<BTreeMap<_, _>>(); |
| 1674 | let count = match edit.container { |
| 1675 | None => range.len() - edit.range.len() + edit.replacement.len(), |
| 1676 | Some(_) => range.len(), |
| 1677 | }; |
| 1678 | let replacement = edited_nodes(nodes, None, Some(edit)) |
| 1679 | .skip(range.start) |
| 1680 | .take(count); |
| 1681 | let supplied = replacement |
| 1682 | .clone() |
| 1683 | .map(|node| node.id) |
| 1684 | .collect::<BTreeSet<_>>(); |
| 1685 | // Children of a hidden or collapsed earlier node stay hidden. |
| 1686 | let hiding = replacement |
| 1687 | .clone() |
| 1688 | .filter_map(|node| { |
| 1689 | let parent = node.parent.filter(|parent| !supplied.contains(parent))?; |
| 1690 | let root = nodes[..range.start] |
| 1691 | .iter() |
| 1692 | .rposition(|node| node.id == parent)?; |
| 1693 | (self.blocks[root].metrics.is_none() || nodes[root].collapsed).then_some(parent) |
| 1694 | }) |
| 1695 | .collect(); |
| 1696 | let start = range |
| 1697 | .start |
| 1698 | .checked_sub(1) |
| 1699 | .map_or((0.0, None), |root| self.blocks[root].state); |
| 1700 | // Each earlier level's latest paragraph, back to the nearest at the first level. |
| 1701 | let mut siblings = Vec::new(); |
| 1702 | let mut level = u32::MAX; |
| 1703 | for root in (0..range.start).rev() { |
| 1704 | if nodes[root].level < level { |
| 1705 | level = nodes[root].level; |
| 1706 | siblings.push((level, self.blocks[root].count.clone())); |
| 1707 | if level <= 1 { |
| 1708 | break; |
| 1709 | } |
| 1710 | } |
| 1711 | } |
| 1712 | siblings.reverse(); |
| 1713 | let segment = Self::stack( |
| 1714 | replacement.clone(), |
| 1715 | indents, |
| 1716 | width, |
| 1717 | 0, |
| 1718 | Some(edit), |
| 1719 | hiding, |
| 1720 | start, |
| 1721 | &mut siblings, |
| 1722 | &mut |node, previous, width, indents| { |
| 1723 | let indent = indentation(node.level, indents, width)?; |
| 1724 | if let Some((source, cached)) = cached.get(&node.id) |
| 1725 | && *source == node |
| 1726 | && follows(cached.number.as_ref(), previous) |
| 1727 | == cached.number.clone().map(|(count, _)| count) |
| 1728 | && cached.text.shaped.layout_max_advance() == width - indent |
| 1729 | && cached |
| 1730 | .markers |
| 1731 | .iter() |
| 1732 | .all(|(marker, _)| marker.shaped.layout_max_advance() == width) |
| 1733 | { |
| 1734 | let mut result = (*cached).clone(); |
| 1735 | result.reset_origin(indent); |
| 1736 | return Ok(result); |
| 1737 | } |
| 1738 | shape(node, previous, width, indents) |
| 1739 | }, |
| 1740 | )?; |
| 1741 | let hides = |
| 1742 | |block: &Block, node: &PageParagraph| block.metrics.is_none() || node.collapsed; |
| 1743 | if range.end < nodes.len() { |
| 1744 | let now = replacement |
| 1745 | .zip(&segment.blocks) |
| 1746 | .map(|(node, block)| (node.id, hides(block, node))) |
| 1747 | .collect::<BTreeMap<_, _>>(); |
| 1748 | if nodes[range.clone()] |
| 1749 | .iter() |
| 1750 | .zip(&self.blocks[range.clone()]) |
| 1751 | .any(|(node, block)| { |
| 1752 | now.get(&node.id) |
| 1753 | .is_some_and(|now| *now != hides(block, node)) |
| 1754 | }) |
| 1755 | { |
| 1756 | range = 0..nodes.len(); |
| 1757 | continue; |
| 1758 | } |
| 1759 | } |
| 1760 | let mut renumbered = range.end; |
| 1761 | for root in range.end..nodes.len() { |
| 1762 | let previous = sibling(&mut siblings, nodes[root].level); |
| 1763 | let number = self.number(nodes, root); |
| 1764 | let now = follows(number, previous.as_ref()); |
| 1765 | let counted = tally(&nodes[root], now.clone(), previous); |
| 1766 | if now.as_ref() != number.map(|(count, _)| count) |
| 1767 | || counted != self.blocks[root].count |
| 1768 | { |
| 1769 | renumbered = root + 1; |
| 1770 | } |
| 1771 | siblings.push((nodes[root].level, counted)); |
| 1772 | if nodes[root].level <= 1 && renumbered <= root { |
| 1773 | break; |
| 1774 | } |
| 1775 | } |
| 1776 | if renumbered > range.end { |
| 1777 | range.end = renumbered; |
| 1778 | continue; |
| 1779 | } |
| 1780 | let mut state = segment.blocks.last().map_or(start, |block| block.state); |
| 1781 | let mut incoming = range |
| 1782 | .end |
| 1783 | .checked_sub(1) |
| 1784 | .map_or((0.0, None), |root| self.blocks[root].state); |
| 1785 | let mut moved = Vec::new(); |
| 1786 | for root in range.end..self.blocks.len() { |
| 1787 | if state == incoming { |
| 1788 | break; |
| 1789 | } |
| 1790 | let block = &self.blocks[root]; |
| 1791 | incoming = block.state; |
| 1792 | let mut y = 0.0; |
| 1793 | if let Some([before, after, height]) = block.metrics { |
| 1794 | y = top(&mut state, [before, after]); |
| 1795 | let [_, tables, objects] = self.pieces(root..root + 1); |
| 1796 | let flow = match self.objects[objects].first() { |
| 1797 | Some(object) if tables.is_empty() => object.at(y, height).1, |
| 1798 | _ => height, |
| 1799 | }; |
| 1800 | state.0 += f64::from(flow); |
| 1801 | if !(state.0 as f32).is_finite() { |
| 1802 | return Err(LayoutError::InvalidSpacing); |
| 1803 | } |
| 1804 | } |
| 1805 | moved.push((y, state)); |
| 1806 | } |
| 1807 | let bottom = if moved.len() == self.blocks.len() - range.end { |
| 1808 | state.0 as f32 |
| 1809 | } else { |
| 1810 | self.size[1] |
| 1811 | }; |
| 1812 | let widest = |blocks: &[Block], side: usize| { |
| 1813 | blocks |
| 1814 | .iter() |
| 1815 | .map(|block| block.extent[side]) |
| 1816 | .fold(f32::NEG_INFINITY, f32::max) |
| 1817 | }; |
| 1818 | let widths = [0, 1].map(|side| { |
| 1819 | let [before, after] = [ |
| 1820 | widest(&self.blocks[range.clone()], side), |
| 1821 | widest(&segment.blocks, side), |
| 1822 | ]; |
| 1823 | if after >= before || before < self.widths[side] { |
| 1824 | self.widths[side].max(after) |
| 1825 | } else { |
| 1826 | [ |
| 1827 | &self.blocks[..range.start], |
| 1828 | &segment.blocks, |
| 1829 | &self.blocks[range.end..], |
| 1830 | ] |
| 1831 | .into_iter() |
| 1832 | .map(|blocks| widest(blocks, side)) |
| 1833 | .fold([36.0, f32::NEG_INFINITY][side], f32::max) |
| 1834 | } |
| 1835 | }); |
| 1836 | let tables = &old[1]; |
| 1837 | let tables = self.tables[..tables.start] |
| 1838 | .iter() |
| 1839 | .chain(&segment.tables) |
| 1840 | .chain(&self.tables[tables.end..]); |
| 1841 | let size = [ |
| 1842 | if fixed_width { width } else { widths[0] }.max(table_width(tables)), |
| 1843 | bottom, |
| 1844 | ]; |
| 1845 | return Ok(Relayout { |
| 1846 | range, |
| 1847 | segment, |
| 1848 | moved, |
| 1849 | size, |
| 1850 | widths, |
| 1851 | }); |
| 1852 | } |
| 1853 | } |
| 1854 | |
| 1855 | /// Puts a [`Relayout`] of this layout in place. |
| 1856 | pub(crate) fn commit(&mut self, relayout: Relayout) { |
| 1857 | let Relayout { |
| 1858 | range, |
| 1859 | mut segment, |
| 1860 | moved, |
| 1861 | size, |
| 1862 | widths, |
| 1863 | } = relayout; |
| 1864 | let [paragraphs, tables, objects] = self.pieces(range.clone()); |
| 1865 | let first = [paragraphs.start, tables.start, objects.start]; |
| 1866 | let delta = [ |
| 1867 | segment.paragraphs.len() as isize - paragraphs.len() as isize, |
| 1868 | segment.tables.len() as isize - tables.len() as isize, |
| 1869 | segment.objects.len() as isize - objects.len() as isize, |
| 1870 | ]; |
| 1871 | for cell in segment.tables.iter_mut().flat_map(|table| &mut table.cells) { |
| 1872 | cell.paragraphs = cell.paragraphs.start + first[0]..cell.paragraphs.end + first[0]; |
| 1873 | } |
| 1874 | for block in &mut segment.blocks { |
| 1875 | for (value, first) in block.first.iter_mut().zip(first) { |
| 1876 | *value += first; |
| 1877 | } |
| 1878 | } |
| 1879 | let after = range.start + segment.blocks.len(); |
| 1880 | let later_tables = tables.start + segment.tables.len(); |
| 1881 | self.paragraphs.splice(paragraphs, segment.paragraphs); |
| 1882 | self.tables.splice(tables, segment.tables); |
| 1883 | self.objects.splice(objects, segment.objects); |
| 1884 | self.blocks.splice(range, segment.blocks); |
| 1885 | if delta != [0; 3] { |
| 1886 | for block in &mut self.blocks[after..] { |
| 1887 | for (value, delta) in block.first.iter_mut().zip(delta) { |
| 1888 | *value = value.wrapping_add_signed(delta); |
| 1889 | } |
| 1890 | } |
| 1891 | for cell in self.tables[later_tables..] |
| 1892 | .iter_mut() |
| 1893 | .flat_map(|table| &mut table.cells) |
| 1894 | { |
| 1895 | cell.paragraphs = cell.paragraphs.start.wrapping_add_signed(delta[0]) |
| 1896 | ..cell.paragraphs.end.wrapping_add_signed(delta[0]); |
| 1897 | } |
| 1898 | } |
| 1899 | for (root, (y, state)) in (after..).zip(moved) { |
| 1900 | let [paragraphs, tables, objects] = self.pieces(root..root + 1); |
| 1901 | let block = &mut self.blocks[root]; |
| 1902 | block.state = state; |
| 1903 | let Some([.., height]) = block.metrics else { |
| 1904 | continue; |
| 1905 | }; |
| 1906 | if !tables.is_empty() { |
| 1907 | for (value, rel) in verticals( |
| 1908 | &mut self.paragraphs[paragraphs], |
| 1909 | &mut self.tables[tables], |
| 1910 | &mut self.objects[objects], |
| 1911 | ) |
| 1912 | .zip(&block.rel) |
| 1913 | { |
| 1914 | *value = rel + y; |
| 1915 | } |
| 1916 | } else if let Some(object) = self.objects[objects].first_mut() { |
| 1917 | object.place(y, height); |
| 1918 | } else { |
| 1919 | self.paragraphs[paragraphs.start].origin[1] = y; |
| 1920 | } |
| 1921 | } |
| 1922 | self.size = size; |
| 1923 | self.widths = widths; |
| 1924 | self.place_tags(); |
| 1925 | } |
| 1926 | |
| 1927 | pub(crate) fn table_width(&self) -> f32 { |
| 1928 | table_width(&self.tables) |
| 1929 | } |
| 1930 | |
| 1931 | /// How far the content reaches, without the 36-point floor of `size`. |
| 1932 | pub(crate) fn content_width(&self) -> f32 { |
| 1933 | let text = self.paragraphs.iter().map(|paragraph| paragraph.size()[0]); |
| 1934 | let objects = self.objects.iter().map(|object| object.rect[2]); |
| 1935 | text.chain(objects).fold(self.table_width(), f32::max) |
| 1936 | } |
| 1937 | |
| 1938 | fn table( |
| 1939 | table: &Table, |
| 1940 | depth: usize, |
| 1941 | edit: Option<&DocumentEdit>, |
| 1942 | shape: &mut impl FnMut( |
| 1943 | &PageParagraph, |
| 1944 | Option<&Count>, |
| 1945 | f32, |
| 1946 | &[f32], |
| 1947 | ) -> Result<ParagraphLayout, LayoutError>, |
| 1948 | ) -> Result<Self, LayoutError> { |
| 1949 | let widths = edit.and_then(|edit| edit.columns.get(&table.id)); |
| 1950 | if widths.is_some_and(|widths| widths.len() != table.columns.len()) { |
| 1951 | return Err(LayoutError::InvalidWidth); |
| 1952 | } |
| 1953 | let width = |
| 1954 | |index: usize| widths.map_or(table.columns[index].width, |widths| widths[index]); |
| 1955 | if table.columns.is_empty() |
| 1956 | || table.rows.is_empty() |
| 1957 | || table |
| 1958 | .columns |
| 1959 | .iter() |
| 1960 | .enumerate() |
| 1961 | .any(|(index, _)| !width(index).is_finite() || width(index) < 36.0) |
| 1962 | { |
| 1963 | return Err(LayoutError::InvalidWidth); |
| 1964 | } |
| 1965 | let mut result = Self { |
| 1966 | tables: vec![TableLayout { |
| 1967 | id: table.id, |
| 1968 | cells: Vec::new(), |
| 1969 | borders: table.borders.unwrap_or(true), |
| 1970 | tags: None, |
| 1971 | }], |
| 1972 | size: [ |
| 1973 | (0..table.columns.len()) |
| 1974 | .map(|index| width(index) + 4.98) |
| 1975 | .sum::<f32>() |
| 1976 | - 1.83, |
| 1977 | 3.54, |
| 1978 | ], |
| 1979 | ..Self::default() |
| 1980 | }; |
| 1981 | let mut y = 0.0; |
| 1982 | for row in &table.rows { |
| 1983 | if row.cells.len() != table.columns.len() { |
| 1984 | return Err(LayoutError::InvalidWidth); |
| 1985 | } |
| 1986 | let start = result.tables[0].cells.len(); |
| 1987 | let mut x = 0.0; |
| 1988 | let mut height = 0.0_f32; |
| 1989 | for (index, cell) in row.cells.iter().enumerate() { |
| 1990 | let width = width(index); |
| 1991 | if cell.paragraphs.is_empty() || !cell.unsupported.is_empty() { |
| 1992 | return Err(LayoutError::UnsupportedContent); |
| 1993 | } |
| 1994 | let mut child = Self::flow( |
| 1995 | edited_nodes(&cell.paragraphs, Some(cell.id), edit), |
| 1996 | &cell.indents, |
| 1997 | width, |
| 1998 | false, |
| 1999 | depth, |
| 2000 | edit, |
| 2001 | shape, |
| 2002 | )?; |
| 2003 | height = height.max(child.size[1]); |
| 2004 | for paragraph in &mut child.paragraphs { |
| 2005 | paragraph.parent.get_or_insert(cell.id); |
| 2006 | } |
| 2007 | let tables = child |
| 2008 | .tables |
| 2009 | .iter_mut() |
| 2010 | .filter_map(|table| table.tags.as_mut()); |
| 2011 | let objects = child |
| 2012 | .objects |
| 2013 | .iter_mut() |
| 2014 | .filter_map(|object| object.tags.as_mut()); |
| 2015 | for block in tables.chain(objects) { |
| 2016 | block.parent.get_or_insert(cell.id); |
| 2017 | } |
| 2018 | let paragraph_start = result.paragraphs.len(); |
| 2019 | result.append(child, [x, y + 3.54]); |
| 2020 | result.tables[0].cells.push(CellLayout { |
| 2021 | id: cell.id, |
| 2022 | rect: [x - 3.6, y + 1.86, x + width + 1.38, 0.0], |
| 2023 | paragraphs: paragraph_start..result.paragraphs.len(), |
| 2024 | }); |
| 2025 | x += width + 4.98; |
| 2026 | } |
| 2027 | y += height + 4.98; |
| 2028 | for cell in &mut result.tables[0].cells[start..] { |
| 2029 | cell.rect[3] = y + 1.86; |
| 2030 | } |
| 2031 | } |
| 2032 | result.size[1] += y; |
| 2033 | if result.size.iter().any(|v| !v.is_finite()) { |
| 2034 | return Err(LayoutError::InvalidSpacing); |
| 2035 | } |
| 2036 | Ok(result) |
| 2037 | } |
| 2038 | } |
| 2039 | |
| 2040 | /// Stored column widths extend 1.77pt past the final cell border. |
| 2041 | fn table_width<'a>(tables: impl IntoIterator<Item = &'a TableLayout>) -> f32 { |
| 2042 | tables |
| 2043 | .into_iter() |
| 2044 | .filter_map(|table| table.cells.last()) |
| 2045 | .map(|cell| cell.rect[2] + 1.77) |
| 2046 | .fold(0.0, f32::max) |
| 2047 | } |
| 2048 | |
| 2049 | /// Shapes a page object into positioned paragraph layouts. |
| 2050 | pub trait Arrange { |
| 2051 | type Output; |
| 2052 | fn layout( |
| 2053 | &self, |
| 2054 | engine: &mut TextEngine, |
| 2055 | definitions: &BTreeMap<ExGuid, Definition>, |
| 2056 | ) -> Result<Self::Output, LayoutError>; |
| 2057 | } |
| 2058 | |
| 2059 | impl Arrange for Outline { |
| 2060 | type Output = OutlineLayout; |
| 2061 | |
| 2062 | fn layout( |
| 2063 | &self, |
| 2064 | engine: &mut TextEngine, |
| 2065 | definitions: &BTreeMap<ExGuid, Definition>, |
| 2066 | ) -> Result<OutlineLayout, LayoutError> { |
| 2067 | let width = self |
| 2068 | .layout |
| 2069 | .reserved_width |
| 2070 | .or(self.layout.max_width) |
| 2071 | .ok_or(LayoutError::InvalidWidth)?; |
| 2072 | outline_layout(self, engine, definitions, width) |
| 2073 | } |
| 2074 | } |
| 2075 | |
| 2076 | pub(crate) fn outline_layout( |
| 2077 | outline: &Outline, |
| 2078 | engine: &mut TextEngine, |
| 2079 | definitions: &BTreeMap<ExGuid, Definition>, |
| 2080 | width: f32, |
| 2081 | ) -> Result<OutlineLayout, LayoutError> { |
| 2082 | if !outline.unsupported.is_empty() { |
| 2083 | return Err(LayoutError::UnsupportedContent); |
| 2084 | } |
| 2085 | OutlineLayout::flow( |
| 2086 | outline.paragraphs.iter(), |
| 2087 | &outline.indents, |
| 2088 | width, |
| 2089 | outline.layout.width_set_by_user == Some(true), |
| 2090 | 0, |
| 2091 | None, |
| 2092 | &mut |node, previous, width, indents| { |
| 2093 | ParagraphLayout::shape(engine, node, previous, width, indents, definitions) |
| 2094 | }, |
| 2095 | ) |
| 2096 | } |
| 2097 | |
| 2098 | impl Arrange for Title { |
| 2099 | type Output = Vec<([f32; 2], OutlineLayout)>; |
| 2100 | |
| 2101 | fn layout( |
| 2102 | &self, |
| 2103 | engine: &mut TextEngine, |
| 2104 | definitions: &BTreeMap<ExGuid, Definition>, |
| 2105 | ) -> Result<Vec<([f32; 2], OutlineLayout)>, LayoutError> { |
| 2106 | if [self.layout.x, self.layout.y] |
| 2107 | .into_iter() |
| 2108 | .flatten() |
| 2109 | .any(|v| !v.is_finite()) |
| 2110 | { |
| 2111 | return Err(LayoutError::InvalidSpacing); |
| 2112 | } |
| 2113 | let mut layouts = Vec::new(); |
| 2114 | let mut bottom = 0.0_f32; |
| 2115 | for outline in &self.outlines { |
| 2116 | let width = outline |
| 2117 | .layout |
| 2118 | .reserved_width |
| 2119 | .or(outline.layout.max_width) |
| 2120 | .unwrap_or(TITLE_WIDTH); |
| 2121 | let layout = outline_layout(outline, engine, definitions, width)?; |
| 2122 | let origin = [ |
| 2123 | outline.layout.x.unwrap_or(0.0), |
| 2124 | bottom + outline.layout.y.unwrap_or(0.0), |
| 2125 | ]; |
| 2126 | let height = outline.layout.max_height.unwrap_or(0.0); |
| 2127 | if origin.iter().any(|v| !v.is_finite()) || !height.is_finite() || height < 0.0 { |
| 2128 | return Err(LayoutError::InvalidSpacing); |
| 2129 | } |
| 2130 | bottom = origin[1] + layout.size[1].max(height) + if outline.title { 3.6 } else { 0.0 }; |
| 2131 | if !bottom.is_finite() { |
| 2132 | return Err(LayoutError::InvalidSpacing); |
| 2133 | } |
| 2134 | layouts.push((origin, layout)); |
| 2135 | } |
| 2136 | Ok(layouts) |
| 2137 | } |
| 2138 | } |
| 2139 | |
| 2140 | #[cfg(test)] |
| 2141 | mod tests { |
| 2142 | use super::*; |
| 2143 | use onestore::document::Layout; |
| 2144 | use onestore::page::{PageParagraph, TextObject}; |
| 2145 | |
| 2146 | fn paragraph(n: u32, text: &str, level: u32, parent: Option<u32>) -> PageParagraph { |
| 2147 | PageParagraph { |
| 2148 | id: ExGuid { |
| 2149 | n, |
| 2150 | ..ExGuid::default() |
| 2151 | }, |
| 2152 | parent: parent.map(|n| ExGuid { |
| 2153 | n, |
| 2154 | ..ExGuid::default() |
| 2155 | }), |
| 2156 | level, |
| 2157 | format: Format::default(), |
| 2158 | content: onestore::page::ParagraphContent::Text(TextObject { |
| 2159 | date_field: None, |
| 2160 | id: ExGuid { |
| 2161 | n: n + 100, |
| 2162 | ..ExGuid::default() |
| 2163 | }, |
| 2164 | text: Paragraph::new(text.into(), Format::default()), |
| 2165 | tags: Vec::new(), |
| 2166 | }), |
| 2167 | lists: Vec::new(), |
| 2168 | tags: Vec::new(), |
| 2169 | media: Default::default(), |
| 2170 | collapsed: false, |
| 2171 | style: None, |
| 2172 | } |
| 2173 | } |
| 2174 | |
| 2175 | fn table(rows: &[&[&str]], widths: &[f32], mut n: u32) -> PageParagraph { |
| 2176 | use onestore::page::{TableCell, TableColumn, TableRow}; |
| 2177 | let mut id = || { |
| 2178 | n += 1; |
| 2179 | ExGuid { |
| 2180 | n, |
| 2181 | ..ExGuid::default() |
| 2182 | } |
| 2183 | }; |
| 2184 | let mut node = paragraph(id().n, "", 1, None); |
| 2185 | node.content = ParagraphContent::Table(Table { |
| 2186 | id: id(), |
| 2187 | columns: widths |
| 2188 | .iter() |
| 2189 | .map(|width| TableColumn { |
| 2190 | width: *width, |
| 2191 | locked: false, |
| 2192 | }) |
| 2193 | .collect(), |
| 2194 | rows: rows |
| 2195 | .iter() |
| 2196 | .map(|row| TableRow { |
| 2197 | id: id(), |
| 2198 | cells: row |
| 2199 | .iter() |
| 2200 | .map(|text| TableCell { |
| 2201 | id: id(), |
| 2202 | layout: Layout::default(), |
| 2203 | indents: vec![18.0, 0.0, 27.0], |
| 2204 | shading: None, |
| 2205 | paragraphs: vec![paragraph(id().n, text, 1, None)], |
| 2206 | unsupported: Vec::new(), |
| 2207 | }) |
| 2208 | .collect(), |
| 2209 | }) |
| 2210 | .collect(), |
| 2211 | borders: Some(true), |
| 2212 | layout: Layout::default(), |
| 2213 | tags: Vec::new(), |
| 2214 | }); |
| 2215 | node |
| 2216 | } |
| 2217 | |
| 2218 | #[test] |
| 2219 | fn table_rows_align_cells_and_expand_for_wrapped_text() { |
| 2220 | let mut engine = TextEngine::default(); |
| 2221 | let mut outline = Outline { |
| 2222 | id: ExGuid::default(), |
| 2223 | title: false, |
| 2224 | min_width: None, |
| 2225 | layout: Layout { |
| 2226 | max_width: Some(300.0), |
| 2227 | ..Layout::default() |
| 2228 | }, |
| 2229 | indents: vec![18.0, 0.0, 27.0], |
| 2230 | paragraphs: vec![ |
| 2231 | paragraph(1, "Before", 1, None), |
| 2232 | table( |
| 2233 | &[ |
| 2234 | &[ |
| 2235 | "A long paragraph that wraps inside a single table cell", |
| 2236 | "B", |
| 2237 | ], |
| 2238 | &["C", "D"], |
| 2239 | ], |
| 2240 | &[72.0, 48.0], |
| 2241 | 1000, |
| 2242 | ), |
| 2243 | paragraph(2, "After", 1, None), |
| 2244 | ], |
| 2245 | unsupported: Vec::new(), |
| 2246 | }; |
| 2247 | let definitions = BTreeMap::new(); |
| 2248 | let layout = outline.layout(&mut engine, &definitions).unwrap(); |
| 2249 | assert_eq!(layout.paragraphs.len(), 6); |
| 2250 | assert_eq!(layout.tables.len(), 1); |
| 2251 | let cells = &layout.tables[0].cells; |
| 2252 | assert_eq!(cells.len(), 4); |
| 2253 | assert_eq!( |
| 2254 | cells |
| 2255 | .iter() |
| 2256 | .map(|cell| cell.paragraphs.clone()) |
| 2257 | .collect::<Vec<_>>(), |
| 2258 | [1..2, 2..3, 3..4, 4..5] |
| 2259 | ); |
| 2260 | assert!(layout.paragraph_cell(0).is_none()); |
| 2261 | assert_eq!(layout.paragraph_cell(4).unwrap().id, cells[3].id); |
| 2262 | assert!(layout.paragraph_cell(5).is_none()); |
| 2263 | let paragraphs = &layout.paragraphs; |
| 2264 | assert!(paragraphs[1].text.lines().count() > 1); |
| 2265 | assert_eq!(paragraphs[2].text.lines().count(), 1); |
| 2266 | assert_eq!(paragraphs[1].origin[1], paragraphs[2].origin[1]); |
| 2267 | assert_eq!(paragraphs[3].origin[1], paragraphs[4].origin[1]); |
| 2268 | assert_eq!(cells[0].rect[3], cells[1].rect[3]); |
| 2269 | assert_eq!(cells[0].rect[3], cells[2].rect[1]); |
| 2270 | assert!((cells[0].rect[2] - cells[1].rect[0]).abs() < 0.00001); |
| 2271 | assert_eq!(paragraphs[1].origin[0], 0.0); |
| 2272 | assert_eq!(paragraphs[2].origin[0], 76.98); |
| 2273 | assert!(paragraphs[3].origin[1] > paragraphs[1].origin[1] + paragraphs[1].text.height()); |
| 2274 | assert!(paragraphs[5].origin[1] > cells[3].rect[3]); |
| 2275 | assert_eq!( |
| 2276 | layout.size[1], |
| 2277 | paragraphs[5].origin[1] + paragraphs[5].text.height() |
| 2278 | ); |
| 2279 | let ParagraphContent::Table(source) = &outline.paragraphs[1].content else { |
| 2280 | panic!() |
| 2281 | }; |
| 2282 | assert_eq!(layout.tables[0].id, source.id); |
| 2283 | assert_eq!( |
| 2284 | cells.iter().map(|c| c.id).collect::<Vec<_>>(), |
| 2285 | source |
| 2286 | .rows |
| 2287 | .iter() |
| 2288 | .flat_map(|row| row.cells.iter().map(|c| c.id)) |
| 2289 | .collect::<Vec<_>>() |
| 2290 | ); |
| 2291 | |
| 2292 | outline.layout.width_set_by_user = Some(true); |
| 2293 | let fixed = outline.layout(&mut engine, &definitions).unwrap(); |
| 2294 | assert_eq!(fixed.size, [300.0, layout.size[1]]); |
| 2295 | assert_eq!(fixed.paragraphs[2].origin, paragraphs[2].origin); |
| 2296 | let ParagraphContent::Table(source) = &mut outline.paragraphs[1].content else { |
| 2297 | panic!() |
| 2298 | }; |
| 2299 | source.columns[0].width = 160.0; |
| 2300 | let widened = outline.layout(&mut engine, &definitions).unwrap(); |
| 2301 | assert!(widened.size[1] < fixed.size[1]); |
| 2302 | assert_eq!(widened.paragraphs[2].origin[0], 164.98); |
| 2303 | } |
| 2304 | |
| 2305 | #[test] |
| 2306 | fn nested_table_layout_translates_cells_and_respects_collapsed_children() { |
| 2307 | let mut outer = table(&[&["Left", "Right"]], &[160.0, 72.0], 1000); |
| 2308 | let mut inner = table(&[&["Nested", "Cell"]], &[48.0, 48.0], 2000); |
| 2309 | inner.level = 2; |
| 2310 | let ParagraphContent::Table(source) = &mut outer.content else { |
| 2311 | panic!() |
| 2312 | }; |
| 2313 | let hidden = paragraph( |
| 2314 | 42, |
| 2315 | "Hidden descendant", |
| 2316 | 2, |
| 2317 | Some(source.rows[0].cells[0].paragraphs[0].id.n), |
| 2318 | ); |
| 2319 | source.rows[0].cells[0].paragraphs[0].collapsed = true; |
| 2320 | source.rows[0].cells[0].paragraphs.push(hidden); |
| 2321 | source.rows[0].cells[0].paragraphs.push(inner); |
| 2322 | let mut engine = TextEngine::default(); |
| 2323 | let layout = OutlineLayout::flow( |
| 2324 | [&outer].into_iter(), |
| 2325 | &[18.0, 0.0, 27.0], |
| 2326 | 300.0, |
| 2327 | false, |
| 2328 | 0, |
| 2329 | None, |
| 2330 | &mut |node, previous, width, indents| { |
| 2331 | ParagraphLayout::shape( |
| 2332 | &mut engine, |
| 2333 | node, |
| 2334 | previous, |
| 2335 | width, |
| 2336 | indents, |
| 2337 | &BTreeMap::new(), |
| 2338 | ) |
| 2339 | }, |
| 2340 | ) |
| 2341 | .unwrap(); |
| 2342 | assert_eq!(layout.tables.len(), 2); |
| 2343 | assert_eq!(layout.tables[0].cells[0].paragraphs, 0..3); |
| 2344 | assert_eq!(layout.tables[0].cells[1].paragraphs, 3..4); |
| 2345 | assert_eq!(layout.tables[1].cells[0].paragraphs, 1..2); |
| 2346 | assert_eq!(layout.tables[1].cells[1].paragraphs, 2..3); |
| 2347 | assert_eq!( |
| 2348 | layout.paragraph_cell(1).unwrap().id, |
| 2349 | layout.tables[1].cells[0].id |
| 2350 | ); |
| 2351 | assert_eq!( |
| 2352 | layout |
| 2353 | .paragraphs |
| 2354 | .iter() |
| 2355 | .map(|p| p.projection.text().text()) |
| 2356 | .collect::<Vec<_>>(), |
| 2357 | ["Left", "Nested", "Cell", "Right"] |
| 2358 | ); |
| 2359 | assert_eq!(layout.paragraphs[1].origin[0], 27.0); |
| 2360 | assert_eq!(layout.tables[1].cells[0].rect[0], 27.0 - 3.6); |
| 2361 | assert!(layout.tables[1].cells[0].rect[1] > layout.tables[0].cells[0].rect[1]); |
| 2362 | assert!(layout.tables[1].cells[0].rect[3] < layout.tables[0].cells[0].rect[3]); |
| 2363 | assert_eq!( |
| 2364 | layout.paragraphs[0].origin[1], |
| 2365 | layout.paragraphs[3].origin[1] |
| 2366 | ); |
| 2367 | } |
| 2368 | |
| 2369 | #[test] |
| 2370 | #[ignore = "requires CANVAS_TEST_SECTION native Tab capture"] |
| 2371 | fn native_table_layout() { |
| 2372 | use onestore::page::{Page, PageObject}; |
| 2373 | use onestore::{RevisionIndex, Store, document::Document}; |
| 2374 | let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); |
| 2375 | let store = Store::parse(&bytes).unwrap(); |
| 2376 | let index = RevisionIndex::parse(&store).unwrap(); |
| 2377 | let document = Document::parse(&index).unwrap(); |
| 2378 | let mut engine = TextEngine::default(); |
| 2379 | let page = Page::from_document(&document, "rows").unwrap(); |
| 2380 | let outline = page |
| 2381 | .objects |
| 2382 | .iter() |
| 2383 | .find_map(|object| match object { |
| 2384 | PageObject::Outline(outline) => Some(outline), |
| 2385 | _ => None, |
| 2386 | }) |
| 2387 | .unwrap(); |
| 2388 | let layout = outline.layout(&mut engine, &page.definitions).unwrap(); |
| 2389 | assert!((layout.size[0] - 84.6).abs() < 0.001); |
| 2390 | assert!((layout.size[1] - 58.76315).abs() < 0.001); |
| 2391 | for (paragraph, expected) in layout.paragraphs.iter().zip([ |
| 2392 | [0.0, 3.54], |
| 2393 | [44.34, 3.54], |
| 2394 | [0.0, 21.947714], |
| 2395 | [44.34, 21.947714], |
| 2396 | [0.0, 40.355427], |
| 2397 | [44.34, 40.355427], |
| 2398 | ]) { |
| 2399 | assert_eq!(paragraph.text.lines().count(), 1); |
| 2400 | for (actual, expected) in paragraph.origin.into_iter().zip(expected) { |
| 2401 | assert!((actual - expected).abs() < 0.001); |
| 2402 | } |
| 2403 | } |
| 2404 | assert_eq!(layout.tables.len(), 1); |
| 2405 | assert_eq!(layout.tables[0].cells.len(), 6); |
| 2406 | for (title, size) in [ |
| 2407 | ("soft-break", [82.350006, 35.375435]), |
| 2408 | ("fixed", [180.0, 21.947721]), |
| 2409 | ] { |
| 2410 | let page = Page::from_document(&document, title).unwrap(); |
| 2411 | let outline = page |
| 2412 | .objects |
| 2413 | .iter() |
| 2414 | .find_map(|object| match object { |
| 2415 | PageObject::Outline(outline) => Some(outline), |
| 2416 | _ => None, |
| 2417 | }) |
| 2418 | .unwrap(); |
| 2419 | let layout = outline.layout(&mut engine, &page.definitions).unwrap(); |
| 2420 | for (actual, expected) in layout.size.into_iter().zip(size) { |
| 2421 | assert!( |
| 2422 | (actual - expected).abs() < 0.001, |
| 2423 | "{title}: {actual} != {expected}" |
| 2424 | ); |
| 2425 | } |
| 2426 | assert_eq!(layout.tables.len(), 1); |
| 2427 | assert_eq!(layout.tables[0].cells.len(), 2); |
| 2428 | assert_eq!( |
| 2429 | layout |
| 2430 | .paragraphs |
| 2431 | .iter() |
| 2432 | .map(|p| p.text.lines().count()) |
| 2433 | .max(), |
| 2434 | Some(if title == "soft-break" { 2 } else { 1 }) |
| 2435 | ); |
| 2436 | } |
| 2437 | } |
| 2438 | |
| 2439 | #[test] |
| 2440 | fn tags_preserve_line_geometry_and_follow_list_indentation() { |
| 2441 | use onestore::document::Tag; |
| 2442 | let id = ExGuid { |
| 2443 | n: 500, |
| 2444 | ..ExGuid::default() |
| 2445 | }; |
| 2446 | let mut node = paragraph(1, "Text that wraps across several lines", 2, None); |
| 2447 | let mut engine = TextEngine::default(); |
| 2448 | let mut definitions = BTreeMap::new(); |
| 2449 | let plain = ParagraphLayout::shape( |
| 2450 | &mut engine, |
| 2451 | &node, |
| 2452 | None, |
| 2453 | 120.0, |
| 2454 | &[18.0, 0.0, 27.0], |
| 2455 | &definitions, |
| 2456 | ) |
| 2457 | .unwrap(); |
| 2458 | node.text_mut().unwrap().tags.push(Tag { |
| 2459 | definition: Some(id), |
| 2460 | status: 3, |
| 2461 | action_type: None, |
| 2462 | shape: None, |
| 2463 | property_status: None, |
| 2464 | created: None, |
| 2465 | completed: None, |
| 2466 | start: None, |
| 2467 | due: None, |
| 2468 | task_id: None, |
| 2469 | extra_set: 0, |
| 2470 | }); |
| 2471 | for shape in [3, 13, 15, 17, 18, 23, 118, 136, 100, 101, 102, 121] { |
| 2472 | definitions.insert( |
| 2473 | id, |
| 2474 | Definition { |
| 2475 | kind: Kind::TagDefinition { |
| 2476 | shape: Some(shape), |
| 2477 | label: Some("Label".into()), |
| 2478 | action_type: None, |
| 2479 | color: None, |
| 2480 | highlight: None, |
| 2481 | }, |
| 2482 | format: Format::default(), |
| 2483 | }, |
| 2484 | ); |
| 2485 | let tagged = ParagraphLayout::shape( |
| 2486 | &mut engine, |
| 2487 | &node, |
| 2488 | None, |
| 2489 | 120.0, |
| 2490 | &[18.0, 0.0, 27.0], |
| 2491 | &definitions, |
| 2492 | ) |
| 2493 | .unwrap(); |
| 2494 | assert_eq!( |
| 2495 | tagged.tags[0].icon, |
| 2496 | TagIcon::Symbol { |
| 2497 | shape, |
| 2498 | checked: shape == 3 |
| 2499 | } |
| 2500 | ); |
| 2501 | let baseline = tagged.text.lines().next().unwrap().1.baseline; |
| 2502 | assert_eq!( |
| 2503 | tagged.tags[0].origin, |
| 2504 | [6.75, baseline - 0.357 * 11.0 + 0.2 - 6.0] |
| 2505 | ); |
| 2506 | assert_eq!(tagged.tags[0].label, "Label"); |
| 2507 | assert!(tagged.tags[0].disabled); |
| 2508 | assert_eq!(tagged.text.height(), plain.text.height()); |
| 2509 | assert_eq!( |
| 2510 | tagged |
| 2511 | .text |
| 2512 | .lines() |
| 2513 | .map(|(_, b)| (&b.source, b.baseline)) |
| 2514 | .collect::<Vec<_>>(), |
| 2515 | plain |
| 2516 | .text |
| 2517 | .lines() |
| 2518 | .map(|(_, b)| (&b.source, b.baseline)) |
| 2519 | .collect::<Vec<_>>() |
| 2520 | ); |
| 2521 | } |
| 2522 | let list = ExGuid { |
| 2523 | n: 501, |
| 2524 | ..ExGuid::default() |
| 2525 | }; |
| 2526 | definitions.insert( |
| 2527 | list, |
| 2528 | Definition { |
| 2529 | kind: Kind::List { |
| 2530 | font: Some("Arial".into()), |
| 2531 | format: Some("•".into()), |
| 2532 | bullet: None, |
| 2533 | restart: None, |
| 2534 | }, |
| 2535 | format: Format::default(), |
| 2536 | }, |
| 2537 | ); |
| 2538 | node.lists.push(list); |
| 2539 | let tagged = ParagraphLayout::shape( |
| 2540 | &mut engine, |
| 2541 | &node, |
| 2542 | None, |
| 2543 | 120.0, |
| 2544 | &[18.0, 0.0, 27.0], |
| 2545 | &definitions, |
| 2546 | ) |
| 2547 | .unwrap(); |
| 2548 | assert_eq!(tagged.tags[0].origin[0], tagged.markers[0].1[0] - 12.9); |
| 2549 | let baseline = tagged.text.lines().next().unwrap().1.baseline; |
| 2550 | assert_eq!( |
| 2551 | tagged.tags[0].origin[1], |
| 2552 | baseline - 0.357 * 11.0 + 0.2 - 6.0 |
| 2553 | ); |
| 2554 | let Kind::TagDefinition { shape, .. } = &mut definitions.get_mut(&id).unwrap().kind else { |
| 2555 | unreachable!() |
| 2556 | }; |
| 2557 | *shape = Some(999); |
| 2558 | let mut shaped = |node: &PageParagraph, definitions| { |
| 2559 | ParagraphLayout::shape( |
| 2560 | &mut engine, |
| 2561 | node, |
| 2562 | None, |
| 2563 | 120.0, |
| 2564 | &[18.0, 0.0, 27.0], |
| 2565 | definitions, |
| 2566 | ) |
| 2567 | .unwrap() |
| 2568 | }; |
| 2569 | // A symbol MS-ONE does not list keeps its number, an Outlook task shows its flag, and |
| 2570 | // a tag whose definition is missing shows nothing. |
| 2571 | assert_eq!( |
| 2572 | shaped(&node, &definitions).tags[0].icon, |
| 2573 | TagIcon::Symbol { |
| 2574 | shape: 999, |
| 2575 | checked: false |
| 2576 | } |
| 2577 | ); |
| 2578 | let mut task = node.clone(); |
| 2579 | let stored = &mut task.text_mut().unwrap().tags[0]; |
| 2580 | stored.status |= 4; |
| 2581 | stored.shape = Some(89); |
| 2582 | assert_eq!( |
| 2583 | shaped(&task, &definitions).tags[0].icon, |
| 2584 | TagIcon::Task { shape: 89 } |
| 2585 | ); |
| 2586 | let mut untagged = definitions.clone(); |
| 2587 | untagged.remove(&id); |
| 2588 | assert!(shaped(&node, &untagged).tags.is_empty()); |
| 2589 | } |
| 2590 | |
| 2591 | /// OneNote 2010 at 400% (Calibri bullets and numbers from 8 to 24 pt, default list |
| 2592 | /// spacing): a marker's advance ends 11.1 pt before its text, and a tag's 12 pt slot ends |
| 2593 | /// 0.9 pt before the leftmost marker among its paragraph's siblings, or 8.25 pt before its |
| 2594 | /// text when none has one. |
| 2595 | #[test] |
| 2596 | fn markers_and_tags_sit_where_onenote_draws_them() { |
| 2597 | use onestore::document::Tag; |
| 2598 | let id = |n| ExGuid { |
| 2599 | n, |
| 2600 | ..ExGuid::default() |
| 2601 | }; |
| 2602 | let list = |format: &str| Definition { |
| 2603 | kind: Kind::List { |
| 2604 | font: None, |
| 2605 | format: Some(format.into()), |
| 2606 | bullet: None, |
| 2607 | restart: None, |
| 2608 | }, |
| 2609 | format: Format::default(), |
| 2610 | }; |
| 2611 | let definitions = BTreeMap::from([ |
| 2612 | (id(900), list("\u{2022}")), |
| 2613 | (id(901), list("\u{fffd}\u{0}.")), |
| 2614 | ( |
| 2615 | id(902), |
| 2616 | Definition { |
| 2617 | kind: Kind::TagDefinition { |
| 2618 | shape: Some(3), |
| 2619 | label: None, |
| 2620 | action_type: None, |
| 2621 | color: None, |
| 2622 | highlight: None, |
| 2623 | }, |
| 2624 | format: Format::default(), |
| 2625 | }, |
| 2626 | ), |
| 2627 | ]); |
| 2628 | let tag = Tag { |
| 2629 | definition: Some(id(902)), |
| 2630 | action_type: None, |
| 2631 | shape: None, |
| 2632 | property_status: None, |
| 2633 | status: 0, |
| 2634 | created: None, |
| 2635 | completed: None, |
| 2636 | start: None, |
| 2637 | due: None, |
| 2638 | task_id: None, |
| 2639 | extra_set: 0, |
| 2640 | }; |
| 2641 | let tagged = |n, level, parent| { |
| 2642 | let mut node = paragraph(n, "Tag", level, parent); |
| 2643 | node.text_mut().unwrap().tags.push(tag.clone()); |
| 2644 | node |
| 2645 | }; |
| 2646 | let mut number = paragraph(1, "Number", 1, None); |
| 2647 | number.lists.push(id(901)); |
| 2648 | let mut bullet = paragraph(5, "Bullet", 2, Some(3)); |
| 2649 | bullet.lists.push(id(900)); |
| 2650 | let nodes = [ |
| 2651 | number, |
| 2652 | tagged(2, 1, None), |
| 2653 | paragraph(3, "Plain", 1, None), |
| 2654 | tagged(4, 2, Some(3)), |
| 2655 | bullet, |
| 2656 | paragraph(6, "Plain", 2, Some(3)), |
| 2657 | ]; |
| 2658 | let mut engine = TextEngine::default(); |
| 2659 | let layout = OutlineLayout::flow( |
| 2660 | nodes.iter(), |
| 2661 | &[18.0, 0.0, 27.0, 27.0], |
| 2662 | 400.0, |
| 2663 | false, |
| 2664 | 0, |
| 2665 | None, |
| 2666 | &mut |node, previous, width, indents| { |
| 2667 | ParagraphLayout::shape(&mut engine, node, previous, width, indents, &definitions) |
| 2668 | }, |
| 2669 | ) |
| 2670 | .unwrap(); |
| 2671 | let [number, first, _, second, bullet, _] = &layout.paragraphs[..] else { |
| 2672 | panic!() |
| 2673 | }; |
| 2674 | for paragraph in [number, bullet] { |
| 2675 | let (marker, [x, _]) = &paragraph.markers[0]; |
| 2676 | let advance = marker.lines().next().unwrap().0.metrics().advance; |
| 2677 | assert!((x + advance - (paragraph.origin[0] - 11.1)).abs() < 1e-4); |
| 2678 | } |
| 2679 | // A numbered sibling pushes the level-1 tag left; the level-2 tag clears the bullet |
| 2680 | // that follows it rather than its own text. |
| 2681 | assert_eq!(first.tags[0].origin[0], number.markers[0].1[0] - 0.9 - 12.0); |
| 2682 | assert_eq!( |
| 2683 | second.tags[0].origin[0], |
| 2684 | bullet.markers[0].1[0] - 0.9 - 12.0 |
| 2685 | ); |
| 2686 | assert!(first.tags[0].origin[0] < first.origin[0] - 20.25); |
| 2687 | let alone = OutlineLayout::flow( |
| 2688 | [tagged(7, 1, None)].iter(), |
| 2689 | &[18.0, 0.0, 27.0, 27.0], |
| 2690 | 400.0, |
| 2691 | false, |
| 2692 | 0, |
| 2693 | None, |
| 2694 | &mut |node, previous, width, indents| { |
| 2695 | ParagraphLayout::shape(&mut engine, node, previous, width, indents, &definitions) |
| 2696 | }, |
| 2697 | ) |
| 2698 | .unwrap(); |
| 2699 | assert_eq!(alone.paragraphs[0].tags[0].origin[0], -20.25); |
| 2700 | } |
| 2701 | |
| 2702 | /// OneNote 2010 at 400%: the first run's size picks the tag icon (9 pt below 10 pt text, |
| 2703 | /// 12 pt to 17.5, 18 pt to 23.5, then 24 pt up to at least 60), whose right edge stays |
| 2704 | /// 8.25 pt before the text and whose centre sits 0.357 of the run's size less 0.2 pt above |
| 2705 | /// the first baseline. |
| 2706 | #[test] |
| 2707 | fn tag_icons_follow_the_first_run() { |
| 2708 | use onestore::document::Tag; |
| 2709 | let id = |n| ExGuid { |
| 2710 | n, |
| 2711 | ..ExGuid::default() |
| 2712 | }; |
| 2713 | let definitions = BTreeMap::from([( |
| 2714 | id(902), |
| 2715 | Definition { |
| 2716 | kind: Kind::TagDefinition { |
| 2717 | shape: Some(3), |
| 2718 | label: None, |
| 2719 | action_type: None, |
| 2720 | color: None, |
| 2721 | highlight: None, |
| 2722 | }, |
| 2723 | format: Format::default(), |
| 2724 | }, |
| 2725 | )]); |
| 2726 | let tag = Tag { |
| 2727 | definition: Some(id(902)), |
| 2728 | action_type: None, |
| 2729 | shape: None, |
| 2730 | property_status: None, |
| 2731 | status: 0, |
| 2732 | created: None, |
| 2733 | completed: None, |
| 2734 | start: None, |
| 2735 | due: None, |
| 2736 | task_id: None, |
| 2737 | extra_set: 0, |
| 2738 | }; |
| 2739 | let tagged = |sizes: &[f32], tags: usize| { |
| 2740 | let mut node = paragraph(1, "", 1, None); |
| 2741 | let text = node.text_mut().unwrap(); |
| 2742 | text.text = Paragraph::from_runs(sizes.iter().map(|size| { |
| 2743 | ( |
| 2744 | "Tag ".to_string(), |
| 2745 | Format { |
| 2746 | font_size: Some(*size), |
| 2747 | ..Format::default() |
| 2748 | }, |
| 2749 | ) |
| 2750 | })); |
| 2751 | text.tags = vec![tag.clone(); tags]; |
| 2752 | node |
| 2753 | }; |
| 2754 | let mut engine = TextEngine::default(); |
| 2755 | let mut lay = |nodes: &[PageParagraph]| { |
| 2756 | OutlineLayout::flow( |
| 2757 | nodes.iter(), |
| 2758 | &[18.0, 0.0, 27.0, 27.0], |
| 2759 | 400.0, |
| 2760 | false, |
| 2761 | 0, |
| 2762 | None, |
| 2763 | &mut |node, previous, width, indents| { |
| 2764 | ParagraphLayout::shape( |
| 2765 | &mut engine, |
| 2766 | node, |
| 2767 | previous, |
| 2768 | width, |
| 2769 | indents, |
| 2770 | &definitions, |
| 2771 | ) |
| 2772 | }, |
| 2773 | ) |
| 2774 | .unwrap() |
| 2775 | }; |
| 2776 | for (sizes, side) in [ |
| 2777 | (&[9.5][..], 9.0), |
| 2778 | (&[10.0], 12.0), |
| 2779 | (&[17.5], 12.0), |
| 2780 | (&[18.0], 18.0), |
| 2781 | (&[23.5], 18.0), |
| 2782 | (&[24.0], 24.0), |
| 2783 | (&[60.0], 24.0), |
| 2784 | (&[11.0, 24.0], 12.0), |
| 2785 | (&[24.0, 11.0], 24.0), |
| 2786 | ] { |
| 2787 | let layout = lay(&[tagged(sizes, 1)]); |
| 2788 | let icon = &layout.paragraphs[0].tags[0]; |
| 2789 | assert_eq!((icon.size, icon.origin[0] + icon.size), (side, -8.25)); |
| 2790 | let baseline = layout.paragraphs[0].text.lines().next().unwrap().1.baseline; |
| 2791 | let centre = icon.origin[1] + side / 2.0; |
| 2792 | assert!((centre - (baseline - 0.357 * sizes[0] + 0.2)).abs() < 1e-4); |
| 2793 | } |
| 2794 | // A second icon extends the column left by its own side. |
| 2795 | let layout = lay(&[tagged(&[20.0], 2)]); |
| 2796 | assert_eq!(layout.tag_column_offset(), -18.0); |
| 2797 | } |
| 2798 | |
| 2799 | #[test] |
| 2800 | fn files_and_ink_take_their_own_paragraph_in_the_flow() { |
| 2801 | use onestore::page::{Attachment, Ink, InkStroke}; |
| 2802 | let before = paragraph(1, "Before", 1, None); |
| 2803 | let mut file = paragraph(2, "", 1, None); |
| 2804 | file.content = ParagraphContent::Attachment(Attachment { |
| 2805 | id: ExGuid { |
| 2806 | n: 20, |
| 2807 | ..ExGuid::default() |
| 2808 | }, |
| 2809 | filename: "notes 🦀.txt".into(), |
| 2810 | source_path: None, |
| 2811 | size: Some([24.0, 24.0]), |
| 2812 | layout: Default::default(), |
| 2813 | bytes: None, |
| 2814 | preview: None, |
| 2815 | recording: None, |
| 2816 | tags: Vec::new(), |
| 2817 | }); |
| 2818 | let mut ink = paragraph(3, "", 1, None); |
| 2819 | ink.content = ParagraphContent::Ink(Ink { |
| 2820 | id: ExGuid { |
| 2821 | n: 30, |
| 2822 | ..ExGuid::default() |
| 2823 | }, |
| 2824 | layout: Default::default(), |
| 2825 | strokes: vec![InkStroke { |
| 2826 | id: ExGuid::default(), |
| 2827 | points: vec![[300.0, 120.0], [360.0, 180.0]], |
| 2828 | width: 1.0, |
| 2829 | height: 1.0, |
| 2830 | color: None, |
| 2831 | transparency: None, |
| 2832 | pen_tip: None, |
| 2833 | raster_operation: None, |
| 2834 | pressure: Vec::new(), |
| 2835 | }], |
| 2836 | groups: Vec::new(), |
| 2837 | shape: None, |
| 2838 | }); |
| 2839 | let after = paragraph(4, "After", 1, None); |
| 2840 | let mut engine = TextEngine::default(); |
| 2841 | let layout = OutlineLayout::flow( |
| 2842 | [&before, &file, &ink, &after].into_iter(), |
| 2843 | &[0.0, 0.0], |
| 2844 | 468.0, |
| 2845 | false, |
| 2846 | 0, |
| 2847 | None, |
| 2848 | &mut |node, previous, width, indents| { |
| 2849 | ParagraphLayout::shape( |
| 2850 | &mut engine, |
| 2851 | node, |
| 2852 | previous, |
| 2853 | width, |
| 2854 | indents, |
| 2855 | &BTreeMap::new(), |
| 2856 | ) |
| 2857 | }, |
| 2858 | ) |
| 2859 | .unwrap(); |
| 2860 | let top = layout.paragraphs[0].text.height(); |
| 2861 | let [icon, handwriting] = [&layout.objects[0], &layout.objects[1]]; |
| 2862 | assert_eq!(icon.rect, [15.0, top + 6.0, 39.0, top + 30.0]); |
| 2863 | let label = icon.label().unwrap(); |
| 2864 | assert_eq!(label.projection.text().text(), "notes 🦀"); |
| 2865 | assert_eq!(label.origin, [0.0, top + 40.5]); |
| 2866 | assert_eq!(icon.bottom, label.origin[1] + label.text.height() + 9.0); |
| 2867 | assert_eq!( |
| 2868 | handwriting.rect, |
| 2869 | [0.0, icon.bottom, 360.0, icon.bottom + 180.0] |
| 2870 | ); |
| 2871 | assert_eq!(layout.paragraphs[1].origin[1], handwriting.bottom); |
| 2872 | assert_eq!(layout.size[0], 360.0); |
| 2873 | } |
| 2874 | |
| 2875 | #[test] |
| 2876 | fn tags_paint_oldest_first_and_the_newest_colour_wins() { |
| 2877 | use onestore::document::Tag; |
| 2878 | let mut definitions = BTreeMap::new(); |
| 2879 | let mut tag = |n, shape, color| { |
| 2880 | let id = ExGuid { |
| 2881 | n, |
| 2882 | ..ExGuid::default() |
| 2883 | }; |
| 2884 | definitions.insert( |
| 2885 | id, |
| 2886 | Definition { |
| 2887 | kind: Kind::TagDefinition { |
| 2888 | shape: Some(shape), |
| 2889 | label: None, |
| 2890 | action_type: None, |
| 2891 | color, |
| 2892 | highlight: None, |
| 2893 | }, |
| 2894 | format: Format::default(), |
| 2895 | }, |
| 2896 | ); |
| 2897 | Tag { |
| 2898 | definition: Some(id), |
| 2899 | status: 1, |
| 2900 | action_type: None, |
| 2901 | shape: None, |
| 2902 | property_status: None, |
| 2903 | created: None, |
| 2904 | completed: None, |
| 2905 | start: None, |
| 2906 | due: None, |
| 2907 | task_id: None, |
| 2908 | extra_set: 0, |
| 2909 | } |
| 2910 | }; |
| 2911 | let [action, mood, project, question] = [ |
| 2912 | tag(1, 0, Some(0x0080_0080)), |
| 2913 | tag(2, 0, Some(0x0080_8000)), |
| 2914 | tag(3, 100, None), |
| 2915 | tag(4, 15, None), |
| 2916 | ]; |
| 2917 | let mut node = paragraph(1, "Lyric", 1, None); |
| 2918 | // As stored: newest first. |
| 2919 | node.text_mut().unwrap().tags = vec![question, project, mood, action]; |
| 2920 | let mut engine = TextEngine::default(); |
| 2921 | let shaped = |
| 2922 | ParagraphLayout::shape(&mut engine, &node, None, 200.0, &[0.0, 0.0], &definitions) |
| 2923 | .unwrap(); |
| 2924 | assert_eq!( |
| 2925 | shaped |
| 2926 | .tags |
| 2927 | .iter() |
| 2928 | .map(|tag| (tag.icon, tag.origin[0])) |
| 2929 | .collect::<Vec<_>>(), |
| 2930 | [ |
| 2931 | (TagIcon::of(100, false).unwrap(), -20.25), |
| 2932 | (TagIcon::of(15, false).unwrap(), -8.25) |
| 2933 | ] |
| 2934 | ); |
| 2935 | let colors: Vec<_> = shaped |
| 2936 | .text |
| 2937 | .lines() |
| 2938 | .flat_map(|(line, _)| line.items().collect::<Vec<_>>()) |
| 2939 | .filter_map(|item| match item { |
| 2940 | parley::PositionedLayoutItem::GlyphRun(run) => Some(run.style().brush.color), |
| 2941 | _ => None, |
| 2942 | }) |
| 2943 | .collect(); |
| 2944 | assert_eq!(colors, [Some(0x0080_8000)]); |
| 2945 | assert_eq!(shaped.projection.text().spans()[0].format.color, None); |
| 2946 | let outline = OutlineLayout { |
| 2947 | paragraphs: vec![shaped], |
| 2948 | ..OutlineLayout::default() |
| 2949 | }; |
| 2950 | assert_eq!(outline.tag_column_offset(), -12.0); |
| 2951 | } |
| 2952 | |
| 2953 | #[test] |
| 2954 | fn title_uses_a_default_wrap_limit_and_keeps_date_after_wrapped_title() { |
| 2955 | let mut title = Title { |
| 2956 | date: None, |
| 2957 | id: ExGuid::default(), |
| 2958 | layout: Layout::default(), |
| 2959 | outlines: ["A title with enough words to wrap", "A date"] |
| 2960 | .into_iter() |
| 2961 | .enumerate() |
| 2962 | .map(|(index, text)| Outline { |
| 2963 | title: index == 0, |
| 2964 | min_width: None, |
| 2965 | id: ExGuid { |
| 2966 | n: index as u32, |
| 2967 | ..ExGuid::default() |
| 2968 | }, |
| 2969 | layout: Layout { |
| 2970 | max_height: Some(21.6), |
| 2971 | ..Layout::default() |
| 2972 | }, |
| 2973 | indents: vec![18.0, 0.0], |
| 2974 | paragraphs: vec![paragraph(index as u32, text, 1, None)], |
| 2975 | unsupported: Vec::new(), |
| 2976 | }) |
| 2977 | .collect(), |
| 2978 | }; |
| 2979 | let mut engine = TextEngine::default(); |
| 2980 | let definitions = BTreeMap::new(); |
| 2981 | assert!(matches!( |
| 2982 | title.outlines[0].layout(&mut engine, &definitions), |
| 2983 | Err(LayoutError::InvalidWidth) |
| 2984 | )); |
| 2985 | let layouts = title.layout(&mut engine, &definitions).unwrap(); |
| 2986 | assert_eq!(layouts[0].1.paragraphs[0].text.lines().count(), 1); |
| 2987 | assert_eq!(layouts[1].0, [0.0, 21.6 + 3.6]); |
| 2988 | title.outlines[0].paragraphs[0].text_mut().unwrap().text = Paragraph::new( |
| 2989 | "A title with enough words to wrap ".repeat(12), |
| 2990 | Format::default(), |
| 2991 | ); |
| 2992 | let layouts = title.layout(&mut engine, &definitions).unwrap(); |
| 2993 | assert!(layouts[0].1.paragraphs[0].text.lines().count() > 1); |
| 2994 | assert!(layouts[0].1.size[0] <= 468.0); |
| 2995 | assert_eq!(layouts[1].0[1], layouts[0].1.size[1] + 3.6); |
| 2996 | title.outlines[0].layout.max_width = Some(70.0); |
| 2997 | let layouts = title.layout(&mut engine, &definitions).unwrap(); |
| 2998 | assert!(layouts[0].1.paragraphs[0].text.lines().count() > 1); |
| 2999 | assert_eq!(layouts[1].0[1], layouts[0].1.size[1] + 3.6); |
| 3000 | for value in [f32::NAN, f32::INFINITY, -1.0] { |
| 3001 | title.outlines[0].layout.max_height = Some(value); |
| 3002 | assert!(matches!( |
| 3003 | title.layout(&mut engine, &definitions), |
| 3004 | Err(LayoutError::InvalidSpacing) |
| 3005 | )); |
| 3006 | } |
| 3007 | title.outlines[0].layout.max_height = Some(21.6); |
| 3008 | title.layout.x = Some(f32::NAN); |
| 3009 | assert!(matches!( |
| 3010 | title.layout(&mut engine, &definitions), |
| 3011 | Err(LayoutError::InvalidSpacing) |
| 3012 | )); |
| 3013 | } |
| 3014 | |
| 3015 | #[test] |
| 3016 | fn collapses_descendants_and_uses_the_larger_adjacent_spacing() { |
| 3017 | let mut first = paragraph(1, "First", 1, None); |
| 3018 | first.text_mut().unwrap().text = Paragraph::new( |
| 3019 | "First".into(), |
| 3020 | Format { |
| 3021 | space_after: Some(4.0), |
| 3022 | ..Format::default() |
| 3023 | }, |
| 3024 | ); |
| 3025 | let mut second = paragraph(2, "Second", 2, Some(1)); |
| 3026 | second.text_mut().unwrap().text = Paragraph::new( |
| 3027 | "Second".into(), |
| 3028 | Format { |
| 3029 | space_before: Some(7.0), |
| 3030 | space_after: Some(3.0), |
| 3031 | ..Format::default() |
| 3032 | }, |
| 3033 | ); |
| 3034 | second.collapsed = true; |
| 3035 | let mut outline = Outline { |
| 3036 | title: false, |
| 3037 | min_width: None, |
| 3038 | id: ExGuid::default(), |
| 3039 | layout: Layout { |
| 3040 | max_width: Some(300.0), |
| 3041 | ..Layout::default() |
| 3042 | }, |
| 3043 | indents: vec![18.0, 0.0, 27.0], |
| 3044 | paragraphs: vec![ |
| 3045 | first, |
| 3046 | second, |
| 3047 | paragraph(3, "Hidden", 6, Some(2)), |
| 3048 | paragraph(4, "Last", 1, None), |
| 3049 | ], |
| 3050 | unsupported: Vec::new(), |
| 3051 | }; |
| 3052 | let mut engine = TextEngine::default(); |
| 3053 | let layout = outline.layout(&mut engine, &BTreeMap::new()).unwrap(); |
| 3054 | assert_eq!( |
| 3055 | layout.paragraphs.iter().map(|p| p.id.n).collect::<Vec<_>>(), |
| 3056 | [1, 2, 4] |
| 3057 | ); |
| 3058 | assert_eq!( |
| 3059 | layout.paragraphs[1].origin, |
| 3060 | [27.0, layout.paragraphs[0].text.height() + 7.0] |
| 3061 | ); |
| 3062 | assert_eq!( |
| 3063 | layout.paragraphs[2].origin[1], |
| 3064 | layout.paragraphs[1].origin[1] + layout.paragraphs[1].text.height() + 3.0 |
| 3065 | ); |
| 3066 | outline.paragraphs[1].collapsed = false; |
| 3067 | let expanded = outline.layout(&mut engine, &BTreeMap::new()).unwrap(); |
| 3068 | assert_eq!(expanded.paragraphs[2].origin[0], 135.0); |
| 3069 | assert!(expanded.size[1] > layout.size[1]); |
| 3070 | outline.indents[1] = f32::NAN; |
| 3071 | assert!(matches!( |
| 3072 | outline.layout(&mut engine, &BTreeMap::new()), |
| 3073 | Err(LayoutError::InvalidIndentation) |
| 3074 | )); |
| 3075 | outline.paragraphs.truncate(1); |
| 3076 | for (indents, x) in [(vec![18.0, 0.0, 27.0, 27.0], 0.0), (vec![0.0], 27.0)] { |
| 3077 | outline.indents = indents; |
| 3078 | let single = outline.layout(&mut engine, &BTreeMap::new()).unwrap(); |
| 3079 | assert_eq!(single.paragraphs[0].origin[0], x); |
| 3080 | } |
| 3081 | } |
| 3082 | |
| 3083 | /// Text offsets of levels 1 through 5 as OneNote 2010 draws each table (cold reads of |
| 3084 | /// Rust-written outlines, measured to the pixel at 96 dpi). |
| 3085 | #[test] |
| 3086 | fn indentation_follows_onenote_for_short_and_unusual_tables() { |
| 3087 | for (indents, expected) in [ |
| 3088 | (&[18.0, 0.0, 27.0, 27.0][..], [0.0, 27.0, 54.0, 81.0, 108.0]), |
| 3089 | (&[18.0, 0.0], [0.0, 27.0, 54.0, 81.0, 108.0]), |
| 3090 | (&[0.0, 0.0], [0.0, 27.0, 54.0, 81.0, 108.0]), |
| 3091 | (&[0.0], [27.0, 54.0, 81.0, 108.0, 135.0]), |
| 3092 | (&[18.0], [27.0, 54.0, 81.0, 108.0, 135.0]), |
| 3093 | (&[], [27.0, 54.0, 81.0, 108.0, 135.0]), |
| 3094 | (&[0.0, 5.0], [5.0, 32.0, 59.0, 86.0, 113.0]), |
| 3095 | (&[5.0, 10.0, 20.0, 40.0], [10.0, 30.0, 70.0, 97.0, 124.0]), |
| 3096 | ( |
| 3097 | &[40.0, 0.0, 10.0, 20.0, 30.0, 50.0], |
| 3098 | [0.0, 10.0, 30.0, 60.0, 110.0], |
| 3099 | ), |
| 3100 | ] { |
| 3101 | for (level, x) in (1..).zip(expected) { |
| 3102 | assert_eq!( |
| 3103 | indentation(level, indents, 500.0).unwrap(), |
| 3104 | x, |
| 3105 | "{indents:?}" |
| 3106 | ); |
| 3107 | } |
| 3108 | } |
| 3109 | } |
| 3110 | |
| 3111 | #[test] |
| 3112 | fn marker_height_expands_the_paragraph_box_without_changing_text_line_advances() { |
| 3113 | let marker_id = ExGuid { |
| 3114 | n: 99, |
| 3115 | ..ExGuid::default() |
| 3116 | }; |
| 3117 | let mut short = paragraph(1, "Short", 2, None); |
| 3118 | short.lists.push(marker_id); |
| 3119 | let mut long = paragraph( |
| 3120 | 2, |
| 3121 | "A long paragraph wraps onto several separate lines of text.", |
| 3122 | 2, |
| 3123 | None, |
| 3124 | ); |
| 3125 | long.lists.push(marker_id); |
| 3126 | let outline = Outline { |
| 3127 | title: false, |
| 3128 | min_width: None, |
| 3129 | id: ExGuid::default(), |
| 3130 | layout: Layout { |
| 3131 | max_width: Some(110.0), |
| 3132 | width_set_by_user: Some(true), |
| 3133 | ..Layout::default() |
| 3134 | }, |
| 3135 | indents: vec![18.0, 0.0, 27.0], |
| 3136 | paragraphs: vec![short, long, paragraph(3, "Last", 1, None)], |
| 3137 | unsupported: Vec::new(), |
| 3138 | }; |
| 3139 | let definitions = BTreeMap::from([( |
| 3140 | marker_id, |
| 3141 | Definition { |
| 3142 | kind: Kind::List { |
| 3143 | font: Some("Courier New".into()), |
| 3144 | format: Some("○".into()), |
| 3145 | restart: None, |
| 3146 | bullet: Some(4), |
| 3147 | }, |
| 3148 | format: Format { |
| 3149 | font_size: Some(22.0), |
| 3150 | ..Format::default() |
| 3151 | }, |
| 3152 | }, |
| 3153 | )]); |
| 3154 | let mut engine = TextEngine::default(); |
| 3155 | let layout = outline.layout(&mut engine, &definitions).unwrap(); |
| 3156 | let short = &layout.paragraphs[0]; |
| 3157 | let long = &layout.paragraphs[1]; |
| 3158 | assert!(short.markers[0].0.height() > short.text.height()); |
| 3159 | assert!(long.text.height() > long.markers[0].0.height()); |
| 3160 | assert_eq!(long.origin[1], short.markers[0].0.height()); |
| 3161 | assert_eq!( |
| 3162 | layout.paragraphs[2].origin[1], |
| 3163 | long.origin[1] + long.text.height() |
| 3164 | ); |
| 3165 | let plain = engine.layout(long.projection.text(), 83.0).unwrap(); |
| 3166 | assert_eq!( |
| 3167 | long.text |
| 3168 | .lines() |
| 3169 | .map(|(_, l)| l.baseline) |
| 3170 | .collect::<Vec<_>>(), |
| 3171 | plain.lines().map(|(_, l)| l.baseline).collect::<Vec<_>>() |
| 3172 | ); |
| 3173 | assert_eq!( |
| 3174 | short.origin[1] |
| 3175 | + short.markers[0].1[1] |
| 3176 | + short.markers[0].0.lines().next().unwrap().1.baseline, |
| 3177 | short.origin[1] + short.text.lines().next().unwrap().1.baseline |
| 3178 | ); |
| 3179 | assert_eq!(layout.size[0], 110.0); |
| 3180 | } |
| 3181 | |
| 3182 | #[test] |
| 3183 | fn symbol_fonts_show_what_windows_draws() { |
| 3184 | // OneNote's bullet library stores these (`BULLET_LIBRARY`). |
| 3185 | assert_eq!(symbol_text("Symbol", "\u{b7}"), "\u{2022}"); |
| 3186 | assert_eq!(symbol_text("Symbol", "\u{de}"), "\u{21d2}"); |
| 3187 | assert_eq!(symbol_text("Symbol", "ap\u{f0}"), "\u{3b1}\u{3c0}\u{f8ff}"); |
| 3188 | assert_eq!(symbol_text("Wingdings", "l\u{ad}"), "\u{f06c}\u{f0ad}"); |
| 3189 | assert_eq!(symbol_text("Wingdings 2", "\u{9d}"), "\u{f09d}"); |
| 3190 | assert_eq!(symbol_text("Calibri", "\u{b7}"), "\u{b7}"); |
| 3191 | } |
| 3192 | |
| 3193 | #[test] |
| 3194 | fn numerals_follow_onenote_number_sequences() { |
| 3195 | assert_eq!(numeral(Some('\u{0}'), 12).unwrap(), "12"); |
| 3196 | assert_eq!(numeral(Some('\u{1}'), 1994).unwrap(), "MCMXCIV"); |
| 3197 | assert_eq!(numeral(Some('\u{2}'), 4).unwrap(), "iv"); |
| 3198 | assert_eq!(numeral(Some('\u{3}'), 3).unwrap(), "C"); |
| 3199 | assert_eq!(numeral(Some('\u{4}'), 26).unwrap(), "z"); |
| 3200 | assert!(numeral(Some('\u{1}'), 0).is_err()); |
| 3201 | // As OneNote 2010 renders them through COM: letters to 800, Roman numerals to 4010. |
| 3202 | assert_eq!(numeral(Some('\u{4}'), 27).unwrap(), "aa"); |
| 3203 | assert_eq!(numeral(Some('\u{3}'), 105).unwrap(), "AAAAA"); |
| 3204 | assert_eq!(numeral(Some('\u{3}'), 780).unwrap(), "Z".repeat(30)); |
| 3205 | assert_eq!(numeral(Some('\u{3}'), 781).unwrap(), "A"); |
| 3206 | assert_eq!(numeral(Some('\u{3}'), 800).unwrap(), "T"); |
| 3207 | assert_eq!(numeral(Some('\u{1}'), 3999).unwrap(), "MMMCMXCIX"); |
| 3208 | assert_eq!(numeral(Some('\u{1}'), 4009).unwrap(), "MMMMIX"); |
| 3209 | assert_eq!(numeral(Some('\u{2}'), 4000).unwrap(), "mmmm"); |
| 3210 | assert_eq!(numeral(Some('\u{16}'), 7).unwrap(), "07"); |
| 3211 | assert_eq!(numeral(Some('\u{16}'), 106).unwrap(), "106"); |
| 3212 | assert!(numeral(Some('\u{9}'), 1).is_err()); |
| 3213 | // As OneNote 2010 renders them through COM, 1 to 130. |
| 3214 | let words = |sequence, numbers: &[u32]| { |
| 3215 | numbers |
| 3216 | .iter() |
| 3217 | .map(|number| numeral(Some(sequence), *number).unwrap()) |
| 3218 | .collect::<Vec<_>>() |
| 3219 | }; |
| 3220 | assert_eq!( |
| 3221 | words( |
| 3222 | '\u{5}', |
| 3223 | &[1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 101, 111, 112, 121] |
| 3224 | ), |
| 3225 | [ |
| 3226 | "1st", "2nd", "3rd", "4th", "11th", "12th", "13th", "21st", "22nd", "23rd", |
| 3227 | "101st", "111th", "112th", "121st" |
| 3228 | ] |
| 3229 | ); |
| 3230 | assert_eq!( |
| 3231 | words('\u{6}', &[1, 12, 20, 21, 99, 100, 101, 130]), |
| 3232 | [ |
| 3233 | "One", |
| 3234 | "Twelve", |
| 3235 | "Twenty", |
| 3236 | "Twenty-one", |
| 3237 | "Ninety-nine", |
| 3238 | "One hundred", |
| 3239 | "One hundred one", |
| 3240 | "One hundred thirty" |
| 3241 | ] |
| 3242 | ); |
| 3243 | assert_eq!( |
| 3244 | words( |
| 3245 | '\u{7}', |
| 3246 | &[1, 2, 3, 5, 8, 9, 12, 20, 21, 40, 100, 101, 112, 130] |
| 3247 | ), |
| 3248 | [ |
| 3249 | "First", |
| 3250 | "Second", |
| 3251 | "Third", |
| 3252 | "Fifth", |
| 3253 | "Eighth", |
| 3254 | "Ninth", |
| 3255 | "Twelfth", |
| 3256 | "Twentieth", |
| 3257 | "Twenty-first", |
| 3258 | "Fortieth", |
| 3259 | "One hundredth", |
| 3260 | "One hundred first", |
| 3261 | "One hundred twelfth", |
| 3262 | "One hundred thirtieth" |
| 3263 | ] |
| 3264 | ); |
| 3265 | } |
| 3266 | } |