1//! Clips as HTML, both ways. Copy writes what OneNote 2010 puts on the clipboard (lab,
2//! 2026-10-02): every run's formatting inline on a `span`, lists as `ul` and `ol` nested by
3//! level, tables bordered, pictures and note tags left out. Paste reads that and what other
4//! applications write into paragraphs, lists, tables and pictures.
5
6use super::Piece;
7use super::clip::Clip;
8use super::format::{AUTOMATIC, ListStyle, NUMBER_LIBRARY, list_definition};
9use super::link::{field_code, links};
10use super::table::COLUMN_WIDTH;
11use crate::document::node;
12use crate::layout::{DEFAULT_FONT, DEFAULT_FONT_SIZE};
13use onestore::ExGuid;
14use onestore::document::{Format, Kind};
15use onestore::page::text::{EditError, Paragraph, new_id};
16use onestore::page::{
17 Definition, PageParagraph, ParagraphContent, Table, TableCell, TableColumn, TableRow,
18};
19use std::collections::BTreeMap;
20use std::fmt::Write;
21
22impl Clip {
23 /// The clip as a web page whose fragment the comments OneNote's mark out hold.
24 pub fn html(&self) -> String {
25 let mut body = String::new();
26 blocks(&self.paragraphs, &self.definitions, &mut body);
27 format!(
28 "<html>\n<head>\n<meta http-equiv=Content-Type content=\"text/html; charset=utf-8\">\n\
29 <meta name=Generator content=\"Snowbound\">\n</head>\n<body>\n\
30 <!--StartFragment-->\n{body}<!--EndFragment-->\n</body>\n</html>\n"
31 )
32 }
33}
34
35/// `nodes` as blocks: a list opens at a paragraph whose list differs from the one open at
36/// its level, and a block's left margin steps it in from the list it sits in, which OneNote
37/// and Word take its level from.
38fn blocks(nodes: &[PageParagraph], definitions: &BTreeMap<ExGuid, Definition>, out: &mut String) {
39 let mut open: Vec<(u32, &str)> = Vec::new();
40 for node in nodes {
41 let list = node
42 .lists
43 .last()
44 .and_then(|id| definitions.get(id))
45 .filter(|definition| matches!(definition.kind, Kind::List { .. }))
46 .map(|definition| list_tag(&definition.kind));
47 while let Some(&(level, tag)) = open.last()
48 && (level > node.level || level == node.level && list.map(|(tag, _)| tag) != Some(tag))
49 {
50 let _ = writeln!(out, "</{tag}>");
51 open.pop();
52 }
53 // A level's step in, past the list it sits in, as OneNote indents its nested lists.
54 let indent = (node.level - open.last().map_or(1, |(level, _)| *level)) as f32 * 0.375;
55 match (list, &node.content) {
56 (Some((tag, kind)), ParagraphContent::Text(text)) => {
57 if open.last().map(|(level, _)| *level) != Some(node.level) {
58 let _ = writeln!(
59 out,
60 "<{tag} type={kind} style='margin-left:{indent}in;margin-top:0in;margin-bottom:0in'>"
61 );
62 open.push((node.level, tag));
63 }
64 out.push_str("<li style='margin-top:0;margin-bottom:0'>");
65 runs(&text.text, out);
66 out.push_str("</li>\n");
67 }
68 (_, ParagraphContent::Text(text)) => {
69 let _ = write!(out, "<p style='margin:0in;margin-left:{indent}in'>");
70 runs(&text.text, out);
71 out.push_str("</p>\n");
72 }
73 (_, ParagraphContent::Table(table)) => {
74 out.push_str(
75 "<table border=1 cellpadding=0 cellspacing=0 style='border-collapse:collapse;\
76 border-style:solid;border-color:#A3A3A3;border-width:1pt'>\n",
77 );
78 for row in &table.rows {
79 out.push_str("<tr>\n");
80 for (cell, column) in row.cells.iter().zip(&table.columns) {
81 let _ = writeln!(
82 out,
83 "<td style='border-style:solid;border-color:#A3A3A3;border-width:1pt;\
84 vertical-align:top;width:{}pt;padding:4pt 4pt 4pt 4pt'>",
85 column.width
86 );
87 blocks(&cell.paragraphs, definitions, out);
88 out.push_str("</td>\n");
89 }
90 out.push_str("</tr>\n");
91 }
92 out.push_str("</table>\n");
93 }
94 _ => {}
95 }
96 }
97 for (_, tag) in open.into_iter().rev() {
98 let _ = writeln!(out, "</{tag}>");
99 }
100}
101
102/// The element and `type` a stored list writes as.
103pub(super) fn list_tag(kind: &Kind) -> (&'static str, &'static str) {
104 let Kind::List { bullet, format, .. } = kind else {
105 return ("ul", "disc");
106 };
107 let sequence = format
108 .as_deref()
109 .and_then(|format| format.split('\u{fffd}').nth(1)?.chars().next());
110 if bullet.is_some() || sequence.is_none() {
111 let kind = match ListStyle::of(kind) {
112 Some(ListStyle::Bullet(3)) => "circle",
113 Some(ListStyle::Bullet(11 | 14)) => "square",
114 _ => "disc",
115 };
116 return ("ul", kind);
117 }
118 let kind = match sequence.map(u32::from) {
119 Some(1) => "I",
120 Some(2) => "i",
121 Some(3) => "A",
122 Some(4) => "a",
123 _ => "1",
124 };
125 ("ol", kind)
126}
127
128/// A paragraph's shown runs as spans, its links' around theirs.
129fn runs(text: &Paragraph, out: &mut String) {
130 let links = links(text, 0);
131 let (mut byte, mut unit) = (0, 0);
132 let mut shown = false;
133 let mut space = true;
134 for span in text.spans() {
135 let fragment = &text.text()[byte..span.end];
136 let start = unit;
137 (byte, unit) = (span.end, unit + fragment.encode_utf16().count() as u32);
138 if span.format.hidden == Some(true) || fragment.is_empty() {
139 continue;
140 }
141 shown = true;
142 let link = links.iter().find(|link| link.label.contains(&start));
143 if let Some(link) = link {
144 let _ = write!(out, "<a href=\"{}\">", escape(&link.target, &mut false));
145 }
146 let _ = write!(
147 out,
148 "<span style='{}'>{}</span>",
149 css(&span.format),
150 escape(fragment, &mut space)
151 );
152 if link.is_some() {
153 out.push_str("</a>");
154 }
155 }
156 if !shown {
157 out.push_str("&nbsp;");
158 }
159}
160
161fn css(format: &Format) -> String {
162 let font = format.font.as_deref().unwrap_or(DEFAULT_FONT);
163 let font = if font.contains(' ') {
164 format!("\"{font}\"")
165 } else {
166 font.to_owned()
167 };
168 let mut css = format!(
169 "font-family:{font};font-size:{}pt",
170 format.font_size.unwrap_or(DEFAULT_FONT_SIZE)
171 );
172 let set = |value: Option<bool>| value == Some(true);
173 match format.bold {
174 Some(true) => css.push_str(";font-weight:bold"),
175 Some(false) => css.push_str(";font-weight:normal"),
176 None => {}
177 }
178 match format.italic {
179 Some(true) => css.push_str(";font-style:italic"),
180 Some(false) => css.push_str(";font-style:normal"),
181 None => {}
182 }
183 match (set(format.underline), set(format.strike)) {
184 (true, true) => css.push_str(";text-decoration:underline line-through"),
185 (true, false) => css.push_str(";text-decoration:underline"),
186 (false, true) => css.push_str(";text-decoration:line-through"),
187 (false, false) if format.underline.is_some() || format.strike.is_some() => {
188 css.push_str(";text-decoration:none")
189 }
190 (false, false) => {}
191 }
192 if set(format.superscript) {
193 css.push_str(";vertical-align:super");
194 } else if set(format.subscript) {
195 css.push_str(";vertical-align:sub");
196 }
197 let hex = |color: u32| {
198 let [red, green, blue, _] = color.to_le_bytes();
199 format!("#{red:02X}{green:02X}{blue:02X}")
200 };
201 if let Some(color) = format.color.filter(|color| *color != AUTOMATIC) {
202 let _ = write!(css, ";color:{}", hex(color));
203 }
204 if let Some(color) = format.highlight.filter(|color| *color != AUTOMATIC) {
205 let _ = write!(css, ";background:{}", hex(color));
206 }
207 css.replace('\'', "&#39;")
208}
209
210/// Text as HTML keeps it: markup characters escaped, line breaks as `br`, and a space after
211/// `space`, whether the text follows a space or starts its paragraph, unbreakable so it
212/// doesn't collapse.
213fn escape(text: &str, space: &mut bool) -> String {
214 let mut escaped = String::with_capacity(text.len());
215 for c in text.chars() {
216 match c {
217 '&' => escaped.push_str("&amp;"),
218 '<' => escaped.push_str("&lt;"),
219 '>' => escaped.push_str("&gt;"),
220 '"' => escaped.push_str("&quot;"),
221 '\u{b}' | '\n' => escaped.push_str("<br>"),
222 '\t' => escaped.push_str("&nbsp;&nbsp;&nbsp; "),
223 ' ' if *space => escaped.push_str("&nbsp;"),
224 c => escaped.push(c),
225 }
226 *space = c == ' ';
227 }
228 escaped
229}
230
231/// A web page fragment's paragraphs, lists and tables as clips, split around its pictures
232/// in order, as OneNote 2010 pastes them (lab, 2026-09-30). Text without a font or size of
233/// its own is Calibri 11 in `language`, an LCID, as pasted text is. `load` makes a picture's
234/// piece from its source and its `width` and `height` in CSS pixels; one it can't, or one
235/// in a table, is left out.
236pub fn html_pieces(
237 html: &str,
238 language: u32,
239 load: impl FnMut(&str, [Option<f32>; 2]) -> Option<Piece>,
240) -> Vec<Piece> {
241 let html = html
242 .split_once("<!--StartFragment-->")
243 .map_or(html, |(_, fragment)| fragment);
244 let html = html
245 .split_once("<!--EndFragment-->")
246 .map_or(html, |(fragment, _)| fragment);
247 let mut reader = Reader {
248 base: Format {
249 font: Some("Calibri".into()),
250 font_size: Some(11.0),
251 language: Some(language),
252 ..Format::default()
253 },
254 load,
255 pieces: Vec::new(),
256 blocks: Vec::new(),
257 tables: Vec::new(),
258 lists: Vec::new(),
259 item: None,
260 word: None,
261 sequences: BTreeMap::new(),
262 open: Vec::new(),
263 runs: Vec::new(),
264 shown: false,
265 space: None,
266 };
267 reader.read(html);
268 reader.end_clip();
269 reader.pieces
270}
271
272/// A paragraph Word marks as a list item, its `mso-list` list and level, and the marker Word
273/// wrote before its text, in the marker's font.
274struct WordItem {
275 list: String,
276 level: usize,
277 marker: String,
278 font: Option<String>,
279}
280
281/// A paragraph or table read, before it becomes a clip's.
282enum Block {
283 Text {
284 runs: Vec<(String, Format)>,
285 /// How many lists it sits in, its own not counted.
286 depth: usize,
287 list: Option<Box<Definition>>,
288 },
289 Table {
290 rows: Vec<Vec<Vec<Block>>>,
291 widths: Vec<Option<f32>>,
292 depth: usize,
293 },
294}
295
296#[derive(Default)]
297struct TableRead {
298 rows: Vec<Vec<Vec<Block>>>,
299 widths: Vec<Option<f32>>,
300 /// The open cell's blocks.
301 cell: Option<Vec<Block>>,
302}
303
304/// An open element: its name, the formatting it gives what it holds, and where a link's
305/// field code went in the runs, if it is one.
306struct Open {
307 name: String,
308 format: Format,
309 link: Option<usize>,
310 /// Whether it holds a Word list item's marker (`mso-list:Ignore`).
311 marker: bool,
312}
313
314struct Reader<F> {
315 base: Format,
316 load: F,
317 pieces: Vec<Piece>,
318 blocks: Vec<Block>,
319 tables: Vec<TableRead>,
320 lists: Vec<ListStyle>,
321 /// The list of the item whose text has yet to come.
322 item: Option<ListStyle>,
323 /// The Word list item being read, as `mso-list` names it.
324 word: Option<WordItem>,
325 /// The number sequence each Word list level (`mso-list` list and level) began with.
326 sequences: BTreeMap<(String, usize), char>,
327 open: Vec<Open>,
328 runs: Vec<(String, Format)>,
329 /// Whether the paragraph read so far shows anything.
330 shown: bool,
331 /// The formatting of white space after what it shows, which shows as one space if
332 /// more follows.
333 space: Option<Format>,
334}
335
336/// Elements that hold no text and have no closing tag.
337const VOID: [&str; 9] = [
338 "br", "img", "meta", "link", "input", "hr", "col", "wbr", "area",
339];
340
341/// Elements that begin and end a paragraph.
342const BLOCKS: [&str; 20] = [
343 "p",
344 "div",
345 "li",
346 "h1",
347 "h2",
348 "h3",
349 "h4",
350 "h5",
351 "h6",
352 "blockquote",
353 "pre",
354 "dt",
355 "dd",
356 "section",
357 "article",
358 "header",
359 "footer",
360 "figure",
361 "figcaption",
362 "address",
363];
364
365impl<F: FnMut(&str, [Option<f32>; 2]) -> Option<Piece>> Reader<F> {
366 fn read(&mut self, html: &str) {
367 // Elements whose content is not text: script, style, head and title.
368 let mut hidden: Option<String> = None;
369 let mut rest = html;
370 while !rest.is_empty() {
371 if let Some(after) = rest.strip_prefix("<!--") {
372 rest = after.split_once("-->").map_or("", |(_, after)| after);
373 continue;
374 }
375 let Some(after) = rest.strip_prefix('<') else {
376 let end = rest.find('<').unwrap_or(rest.len());
377 if hidden.is_none() {
378 self.text(&decode_entities(&rest[..end]));
379 }
380 rest = &rest[end..];
381 continue;
382 };
383 let end = tag_end(after);
384 let tag = &after[..end];
385 rest = after.get(end + 1..).unwrap_or("");
386 // Declarations and Word's `<![if ...]>` conditions.
387 if tag.starts_with('!') {
388 continue;
389 }
390 let (closing, tag) = match tag.strip_prefix('/') {
391 Some(tag) => (true, tag),
392 None => (false, tag),
393 };
394 let name = tag
395 .split(|c: char| c.is_whitespace() || c == '/')
396 .next()
397 .unwrap_or_default()
398 .to_ascii_lowercase();
399 if let Some(until) = &hidden {
400 if closing && name == *until {
401 hidden = None;
402 }
403 continue;
404 }
405 match (name.as_str(), closing) {
406 ("script" | "style" | "head" | "title", false) => hidden = Some(name),
407 (_, false) => self.start(&name, tag),
408 (_, true) => self.end(&name),
409 }
410 }
411 }
412
413 fn start(&mut self, name: &str, tag: &str) {
414 let block = BLOCKS.contains(&name);
415 if block || matches!(name, "br" | "table" | "tr" | "td" | "th" | "ul" | "ol") {
416 self.paragraph();
417 }
418 let list = style(tag, "mso-list");
419 if block && let Some(list) = &list {
420 let mut words = list.split_whitespace();
421 if let (Some(id), Some(Ok(level))) = (
422 words.next(),
423 words
424 .next()
425 .and_then(|level| level.strip_prefix("level"))
426 .map(str::parse::<usize>),
427 ) && level > 0
428 {
429 self.word = Some(WordItem {
430 list: id.to_owned(),
431 level,
432 marker: String::new(),
433 font: None,
434 });
435 }
436 }
437 match name {
438 "br" => return,
439 "img" => {
440 let css = |name| {
441 attribute(tag, name)
442 .and_then(|value| value.trim_end_matches("px").parse::<f32>().ok())
443 .filter(|pixels| *pixels > 0.0)
444 };
445 if self.tables.is_empty()
446 && let Some(picture) = attribute(tag, "src").and_then(|source| {
447 (self.load)(&decode_entities(source), [css("width"), css("height")])
448 })
449 {
450 self.end_clip();
451 self.pieces.push(picture);
452 }
453 return;
454 }
455 "ul" | "ol" => {
456 let kind = attribute(tag, "type")
457 .map(str::to_owned)
458 .or_else(|| style(tag, "list-style-type"));
459 self.lists.push(list_style(name, kind.as_deref()));
460 }
461 "li" => {
462 self.implicit_end("li");
463 self.item = Some(self.lists.last().copied().unwrap_or(ListStyle::BULLET));
464 }
465 "p" => self.implicit_end("p"),
466 "table" => self.tables.push(TableRead::default()),
467 "tr" => {
468 if let Some(table) = self.tables.last_mut() {
469 table.end_cell();
470 table.rows.push(Vec::new());
471 }
472 }
473 "td" | "th" => {
474 if let Some(table) = self.tables.last_mut() {
475 table.end_cell();
476 if table.rows.is_empty() {
477 table.rows.push(Vec::new());
478 }
479 let column = table.rows.last().unwrap().len();
480 if table.widths.len() <= column {
481 table.widths.resize(column + 1, None);
482 }
483 let width = style(tag, "width")
484 .and_then(|width| length(&width, 12.0))
485 .or_else(|| {
486 attribute(tag, "width")?
487 .parse::<f32>()
488 .ok()
489 .map(|px| px * 0.75)
490 });
491 table.widths[column] = table.widths[column].or(width);
492 table.cell = Some(Vec::new());
493 }
494 }
495 _ => {}
496 }
497 if VOID.contains(&name) {
498 return;
499 }
500 let current = self.format();
501 let mut format = element_format(name, tag, &current);
502 let link = (name == "a")
503 .then(|| attribute(tag, "href"))
504 .flatten()
505 .map(|href| {
506 if let Some(space) = self.space.take() {
507 self.push(' ', space);
508 }
509 let mut code = format.inherit(&current);
510 code.hidden = Some(true);
511 code.hyperlink = Some(true);
512 code.hyperlink_label = Some(true);
513 self.runs.push((field_code(&decode_entities(href)), code));
514 format.hyperlink = Some(true);
515 format.hyperlink_label = Some(true);
516 self.runs.len() - 1
517 });
518 self.open.push(Open {
519 name: name.to_owned(),
520 format,
521 link,
522 marker: list.is_some_and(|list| list.eq_ignore_ascii_case("ignore")),
523 });
524 }
525
526 fn end(&mut self, name: &str) {
527 if BLOCKS.contains(&name) || matches!(name, "table" | "tr" | "td" | "th" | "ul" | "ol") {
528 self.paragraph();
529 }
530 if let Some(at) = self.open.iter().rposition(|open| open.name == name) {
531 for open in self.open.drain(at..) {
532 // A link that showed nothing keeps no field code.
533 if let Some(code) = open.link
534 && code + 1 == self.runs.len()
535 {
536 self.runs.pop();
537 }
538 }
539 }
540 match name {
541 "ul" | "ol" => {
542 self.lists.pop();
543 }
544 "li" => self.item = None,
545 "td" | "th" => {
546 if let Some(table) = self.tables.last_mut() {
547 table.end_cell();
548 }
549 }
550 "table" => {
551 if let Some(mut table) = self.tables.pop() {
552 table.end_cell();
553 table.rows.retain(|row| !row.is_empty());
554 if !table.rows.is_empty() {
555 let block = Block::Table {
556 rows: table.rows,
557 widths: table.widths,
558 depth: self.lists.len(),
559 };
560 self.sink().push(block);
561 }
562 }
563 }
564 _ => {}
565 }
566 }
567
568 /// Ends an unclosed `name`, as a `p` or `li` is by the next, within the innermost list
569 /// or table.
570 fn implicit_end(&mut self, name: &str) {
571 let boundary =
572 |open: &Open| matches!(open.name.as_str(), "ul" | "ol" | "table" | "td" | "th");
573 if let Some(at) = self
574 .open
575 .iter()
576 .rposition(|open| open.name == name || boundary(open))
577 && self.open[at].name == name
578 {
579 self.open.truncate(at);
580 }
581 }
582
583 fn format(&self) -> Format {
584 self.open.iter().fold(self.base.clone(), |format, open| {
585 open.format.inherit(&format)
586 })
587 }
588
589 fn text(&mut self, text: &str) {
590 let format = self.format();
591 if self.open.iter().any(|open| open.marker) {
592 if let Some(item) = &mut self.word {
593 let shown = text.trim_matches(|c: char| c.is_whitespace() || c == '\u{a0}');
594 if !shown.is_empty() && item.marker.is_empty() {
595 item.font = format.font.clone();
596 }
597 item.marker.push_str(shown);
598 }
599 return;
600 }
601 for c in text.chars() {
602 if c.is_whitespace() && c != '\u{a0}' {
603 if self.shown && self.space.is_none() {
604 self.space = Some(format.clone());
605 }
606 continue;
607 }
608 if let Some(space) = self.space.take() {
609 self.push(' ', space);
610 }
611 self.shown = true;
612 self.push(if c == '\u{a0}' { ' ' } else { c }, format.clone());
613 }
614 }
615
616 fn push(&mut self, c: char, format: Format) {
617 match self.runs.last_mut() {
618 Some((text, last)) if *last == format => text.push(c),
619 _ => self.runs.push((c.to_string(), format)),
620 }
621 }
622
623 /// Ends the paragraph being read, which goes in if it showed anything; a link's field
624 /// code begun in one that didn't carries on into the next.
625 fn paragraph(&mut self) {
626 self.space = None;
627 if !std::mem::take(&mut self.shown) {
628 return;
629 }
630 let mut runs = std::mem::take(&mut self.runs);
631 for open in &mut self.open {
632 open.link = None;
633 }
634 runs.retain(|(text, _)| !text.is_empty());
635 if runs.is_empty() {
636 runs.push((String::new(), self.format()));
637 }
638 let (list, depth) = match self.word.take() {
639 Some(item) => {
640 let depth = self.lists.len() + item.level - 1;
641 (Some(Box::new(self.word_list(item, &runs[0].1))), depth)
642 }
643 None => {
644 let list = self.item.take();
645 let depth =
646 self.lists.len() - usize::from(list.is_some() && !self.lists.is_empty());
647 (
648 list.map(|style| Box::new(list_definition(style, &runs[0].1))),
649 depth,
650 )
651 }
652 };
653 self.sink().push(Block::Text { runs, depth, list });
654 }
655
656 /// The list OneNote 2010 made of a Word list item whose text begins in `format` (lab,
657 /// 2026-10-02): a marker without a number is a bullet in Word's glyph and font; a number
658 /// keeps Word's punctuation, in the sequence its list level began with.
659 fn word_list(&mut self, item: WordItem, format: &Format) -> Definition {
660 let mut definition = list_definition(ListStyle::NUMBER, format);
661 let Kind::List {
662 font, format: list, ..
663 } = &mut definition.kind
664 else {
665 unreachable!("a number is a list")
666 };
667 let marker = item.marker;
668 let alphanumeric = |c: char| c.is_ascii_alphanumeric();
669 let token = marker
670 .find(alphanumeric)
671 .zip(marker.rfind(alphanumeric))
672 .map(|(start, end)| start..end + 1);
673 let digits = token
674 .as_ref()
675 .is_some_and(|token| marker[token.clone()].bytes().any(|b| b.is_ascii_digit()));
676 let Some(number) = token.filter(|token| digits || token.len() < marker.len()) else {
677 *font = item.font;
678 *list = Some(marker);
679 return definition;
680 };
681 let token = &marker[number.clone()];
682 let sequence = *self
683 .sequences
684 .entry((item.list, item.level))
685 .or_insert_with(|| {
686 let roman = token == "i"
687 || token == "I"
688 || token.len() > 1 && token.chars().all(|c| "ivxlcdmIVXLCDM".contains(c));
689 let upper = token.starts_with(|c: char| c.is_ascii_uppercase());
690 match (digits, roman, upper) {
691 (true, ..) => '\u{0}',
692 (_, true, true) => '\u{1}',
693 (_, true, false) => '\u{2}',
694 (_, false, true) => '\u{3}',
695 (_, false, false) => '\u{4}',
696 }
697 });
698 *list = Some(format!(
699 "{}\u{fffd}{sequence}{}",
700 &marker[..number.start],
701 &marker[number.end..]
702 ));
703 definition
704 }
705
706 /// Where a block read goes: the open cell, else the clip.
707 fn sink(&mut self) -> &mut Vec<Block> {
708 match self.tables.last_mut() {
709 Some(table) => table.cell.get_or_insert_with(Vec::new),
710 None => &mut self.blocks,
711 }
712 }
713
714 /// Makes what was read since the last picture a clip.
715 fn end_clip(&mut self) {
716 self.paragraph();
717 let blocks = std::mem::take(&mut self.blocks);
718 if blocks.is_empty() {
719 return;
720 }
721 let mut definitions = BTreeMap::new();
722 if let Ok(paragraphs) = nodes(blocks, &mut definitions)
723 && !paragraphs.is_empty()
724 {
725 self.pieces.push(Piece::Clip(Clip {
726 paragraphs,
727 definitions,
728 }));
729 }
730 }
731}
732
733impl TableRead {
734 fn end_cell(&mut self) {
735 if let Some(cell) = self.cell.take() {
736 match self.rows.last_mut() {
737 Some(row) => row.push(cell),
738 None => self.rows.push(vec![cell]),
739 }
740 }
741 }
742}
743
744/// `blocks` as paragraphs, each the child of the one before it at a lower level, with the
745/// list definitions they name added to `definitions`.
746fn nodes(
747 blocks: Vec<Block>,
748 definitions: &mut BTreeMap<ExGuid, Definition>,
749) -> Result<Vec<PageParagraph>, EditError> {
750 let depth = |block: &Block| match block {
751 Block::Text { depth, .. } | Block::Table { depth, .. } => *depth,
752 };
753 let least = blocks.iter().map(depth).min().unwrap_or(0);
754 let mut nodes = Vec::new();
755 let mut ancestors: Vec<(u32, ExGuid)> = Vec::new();
756 for block in blocks {
757 let level = (depth(&block) - least + 1) as u32;
758 let mut paragraph = match block {
759 Block::Text { runs, list, .. } => {
760 let mut paragraph = node(Paragraph::from_runs(runs), Format::default())?;
761 if let Some(list) = list {
762 let id = new_id()?;
763 definitions.insert(id, *list);
764 paragraph.lists = vec![id];
765 }
766 paragraph
767 }
768 Block::Table { rows, widths, .. } => table(rows, widths, definitions)?,
769 };
770 while ancestors.last().is_some_and(|(above, _)| *above >= level) {
771 ancestors.pop();
772 }
773 paragraph.parent = ancestors.last().map(|(_, id)| *id);
774 paragraph.level = level;
775 ancestors.push((level, paragraph.id));
776 nodes.push(paragraph);
777 }
778 Ok(nodes)
779}
780
781fn table(
782 rows: Vec<Vec<Vec<Block>>>,
783 widths: Vec<Option<f32>>,
784 definitions: &mut BTreeMap<ExGuid, Definition>,
785) -> Result<PageParagraph, EditError> {
786 let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
787 let rows = rows
788 .into_iter()
789 .map(|mut cells| {
790 cells.resize_with(columns, Vec::new);
791 Ok(TableRow {
792 id: new_id()?,
793 cells: cells
794 .into_iter()
795 .map(|blocks| {
796 let mut paragraphs = nodes(blocks, definitions)?;
797 if paragraphs.is_empty() {
798 let empty = Paragraph::new(String::new(), Format::default());
799 paragraphs.push(node(empty, Format::default())?);
800 }
801 Ok(TableCell {
802 id: new_id()?,
803 layout: Default::default(),
804 indents: Vec::new(),
805 shading: None,
806 paragraphs,
807 unsupported: Vec::new(),
808 })
809 })
810 .collect::<Result<_, EditError>>()?,
811 })
812 })
813 .collect::<Result<_, EditError>>()?;
814 let mut paragraph = node(
815 Paragraph::new(String::new(), Format::default()),
816 Format::default(),
817 )?;
818 paragraph.content = ParagraphContent::Table(Table {
819 id: new_id()?,
820 columns: (0..columns)
821 .map(|column| TableColumn {
822 width: widths
823 .get(column)
824 .copied()
825 .flatten()
826 .unwrap_or(COLUMN_WIDTH)
827 .max(COLUMN_WIDTH),
828 locked: false,
829 })
830 .collect(),
831 rows,
832 borders: Some(true),
833 layout: Default::default(),
834 tags: Vec::new(),
835 });
836 Ok(paragraph)
837}
838
839/// The list style an `ul` or `ol` of `kind`, its `type` or `list-style-type`, gives.
840fn list_style(name: &str, kind: Option<&str>) -> ListStyle {
841 let number = |format: &str| {
842 let index = NUMBER_LIBRARY.iter().position(|known| *known == format);
843 ListStyle::Number(index.unwrap_or(0))
844 };
845 match (name, kind.map(str::trim)) {
846 ("ul", Some("circle")) => ListStyle::Bullet(3),
847 ("ul", Some("square")) => ListStyle::Bullet(11),
848 ("ul", _) => ListStyle::BULLET,
849 (_, Some("a" | "lower-alpha" | "lower-latin")) => number("\u{fffd}\u{4}."),
850 (_, Some("A" | "upper-alpha" | "upper-latin")) => number("\u{fffd}\u{3}."),
851 (_, Some("i" | "lower-roman")) => number("\u{fffd}\u{2}."),
852 (_, Some("I" | "upper-roman")) => number("\u{fffd}\u{1}."),
853 _ => ListStyle::NUMBER,
854 }
855}
856
857/// The formatting an element gives its text: its own, as `b` and `font` give, then its
858/// `style`'s. `current` is the formatting around it.
859fn element_format(name: &str, tag: &str, current: &Format) -> Format {
860 let mut format = Format::default();
861 match name {
862 "b" | "strong" => format.bold = Some(true),
863 "i" | "em" | "cite" | "dfn" | "var" => format.italic = Some(true),
864 "u" | "ins" => format.underline = Some(true),
865 "s" | "strike" | "del" => format.strike = Some(true),
866 "sup" => format.superscript = Some(true),
867 "sub" => format.subscript = Some(true),
868 "code" | "tt" | "pre" | "kbd" | "samp" => format.font = Some("Courier New".into()),
869 "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
870 const SIZES: [f32; 6] = [24.0, 18.0, 14.0, 12.0, 10.0, 8.0];
871 format.bold = Some(true);
872 format.font_size = Some(SIZES[usize::from(name.as_bytes()[1] - b'1')]);
873 }
874 "font" => {
875 format.font = attribute(tag, "face").and_then(family);
876 format.color = attribute(tag, "color").and_then(color).flatten();
877 format.font_size = attribute(tag, "size")
878 .and_then(|size| size.trim().parse::<usize>().ok())
879 .map(|size| [8.0, 10.0, 12.0, 14.0, 18.0, 24.0, 36.0][size.clamp(1, 7) - 1]);
880 }
881 _ => {}
882 }
883 let Some(css) = attribute(tag, "style") else {
884 return format;
885 };
886 let size = format
887 .font_size
888 .or(current.font_size)
889 .unwrap_or(DEFAULT_FONT_SIZE);
890 for declaration in decode_entities(css).split(';') {
891 let Some((property, value)) = declaration.split_once(':') else {
892 continue;
893 };
894 let value = value.trim().trim_end_matches("!important").trim();
895 match property.trim().to_ascii_lowercase().as_str() {
896 "font-weight" => {
897 format.bold = match value {
898 "bold" | "bolder" => Some(true),
899 "normal" | "lighter" => Some(false),
900 weight => weight.parse::<u32>().ok().map(|weight| weight >= 600),
901 }
902 }
903 "font-style" => format.italic = Some(matches!(value, "italic" | "oblique")),
904 "text-decoration" | "text-decoration-line" => {
905 format.underline = Some(value.contains("underline"));
906 format.strike = Some(value.contains("line-through"));
907 }
908 "font-family" => format.font = family(value).or(format.font),
909 "font-size" => format.font_size = length(value, size).or(format.font_size),
910 "color" => {
911 if let Some(color) = color(value) {
912 format.color = color;
913 }
914 }
915 "background" | "background-color" | "mso-highlight" => {
916 if let Some(color) = value.split_whitespace().find_map(color) {
917 format.highlight = color;
918 }
919 }
920 "vertical-align" => match value {
921 "super" => format.superscript = Some(true),
922 "sub" => format.subscript = Some(true),
923 _ => {}
924 },
925 _ => {}
926 }
927 }
928 format
929}
930
931/// The first family a `font-family` names, unless it is a generic one or a browser's name
932/// for the system's.
933fn family(value: &str) -> Option<String> {
934 let first = value
935 .split(',')
936 .next()?
937 .trim()
938 .trim_matches(['"', '\''])
939 .trim();
940 let generic = [
941 "serif",
942 "sans-serif",
943 "monospace",
944 "cursive",
945 "fantasy",
946 "system-ui",
947 "inherit",
948 "blinkmacsystemfont",
949 ];
950 let lower = first.to_ascii_lowercase();
951 (!first.is_empty() && !first.starts_with('-') && !generic.contains(&lower.as_str()))
952 .then(|| first.to_owned())
953}
954
955/// A CSS length in points, `em` and `%` of `size`.
956fn length(value: &str, size: f32) -> Option<f32> {
957 let value = value.trim();
958 let keyword = match value {
959 "xx-small" => Some(7.0),
960 "x-small" => Some(7.5),
961 "small" => Some(10.0),
962 "medium" => Some(12.0),
963 "large" => Some(13.5),
964 "x-large" => Some(18.0),
965 "xx-large" => Some(24.0),
966 _ => None,
967 };
968 if keyword.is_some() {
969 return keyword;
970 }
971 let split = value
972 .find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
973 .unwrap_or(value.len());
974 let (number, unit) = value.split_at(split);
975 let number: f32 = number.parse().ok()?;
976 let points = match unit.trim().to_ascii_lowercase().as_str() {
977 "pt" => number,
978 "px" | "" => number * 0.75,
979 "in" => number * 72.0,
980 "cm" => number * 72.0 / 2.54,
981 "mm" => number * 72.0 / 25.4,
982 "pc" => number * 12.0,
983 "em" | "rem" => number * size,
984 "%" => number * size / 100.0,
985 _ => return None,
986 };
987 (points.is_finite() && points > 0.0).then_some(points)
988}
989
990/// A CSS colour as a COLORREF: `Some(None)` for black and the defaults, which pasted text
991/// takes as automatic so it follows the page; none where it isn't one.
992fn color(value: &str) -> Option<Option<u32>> {
993 let value = value.trim().to_ascii_lowercase();
994 let rgb = |red: u32, green: u32, blue: u32| Some(red | green << 8 | blue << 16);
995 let parsed = if let Some(hex) = value.strip_prefix('#') {
996 let digits: Vec<u32> = hex.chars().map(|c| c.to_digit(16)).collect::<Option<_>>()?;
997 match digits[..] {
998 [r, g, b] => rgb(r * 17, g * 17, b * 17),
999 [r1, r0, g1, g0, b1, b0] => rgb(r1 * 16 + r0, g1 * 16 + g0, b1 * 16 + b0),
1000 _ => return None,
1001 }
1002 } else if let Some(arguments) = value
1003 .strip_prefix("rgb(")
1004 .or_else(|| value.strip_prefix("rgba("))
1005 {
1006 let channels: Vec<u32> = arguments
1007 .trim_end_matches(')')
1008 .split(',')
1009 .take(3)
1010 .map(|channel| {
1011 channel
1012 .trim()
1013 .parse::<f32>()
1014 .ok()
1015 .map(|c| c.clamp(0.0, 255.0) as u32)
1016 })
1017 .collect::<Option<_>>()?;
1018 match channels[..] {
1019 [r, g, b] => rgb(r, g, b),
1020 _ => return None,
1021 }
1022 } else {
1023 match value.as_str() {
1024 "black" | "windowtext" | "auto" | "inherit" | "initial" | "currentcolor" | "none"
1025 | "transparent" => None,
1026 "white" => rgb(255, 255, 255),
1027 "red" => rgb(255, 0, 0),
1028 "green" => rgb(0, 128, 0),
1029 "lime" => rgb(0, 255, 0),
1030 "blue" => rgb(0, 0, 255),
1031 "yellow" => rgb(255, 255, 0),
1032 "orange" => rgb(255, 165, 0),
1033 "purple" => rgb(128, 0, 128),
1034 "gray" | "grey" => rgb(128, 128, 128),
1035 "silver" => rgb(192, 192, 192),
1036 "maroon" => rgb(128, 0, 0),
1037 "navy" => rgb(0, 0, 128),
1038 "teal" => rgb(0, 128, 128),
1039 "olive" => rgb(128, 128, 0),
1040 "aqua" | "cyan" => rgb(0, 255, 255),
1041 "fuchsia" | "magenta" => rgb(255, 0, 255),
1042 "darkred" => rgb(139, 0, 0),
1043 "darkblue" => rgb(0, 0, 139),
1044 "darkgreen" => rgb(0, 100, 0),
1045 _ => return None,
1046 }
1047 };
1048 Some(parsed.filter(|color| *color != 0))
1049}
1050
1051/// The value `name` takes in a tag's `style`.
1052fn style(tag: &str, name: &str) -> Option<String> {
1053 decode_entities(attribute(tag, "style")?)
1054 .split(';')
1055 .filter_map(|declaration| declaration.split_once(':'))
1056 .find(|(property, _)| property.trim().eq_ignore_ascii_case(name))
1057 .map(|(_, value)| value.trim().to_owned())
1058}
1059
1060/// Where a tag's text ends, before its `>`, outside quoted attribute values.
1061fn tag_end(tag: &str) -> usize {
1062 let mut quote = None;
1063 for (at, c) in tag.char_indices() {
1064 match (quote, c) {
1065 (None, '"' | '\'') => quote = Some(c),
1066 (Some(open), _) if c == open => quote = None,
1067 (None, '>') => return at,
1068 _ => {}
1069 }
1070 }
1071 tag.len()
1072}
1073
1074/// The value of attribute `name` in `tag`, entities still encoded.
1075fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
1076 let mut rest = tag;
1077 while let Some(at) = rest.find('=') {
1078 let key = rest[..at].trim_end().rsplit(char::is_whitespace).next()?;
1079 let value = rest[at + 1..].trim_start();
1080 let (value, after) = match value.chars().next()? {
1081 quote @ ('"' | '\'') => value[1..].split_once(quote)?,
1082 _ => value.split_at(value.find(char::is_whitespace).unwrap_or(value.len())),
1083 };
1084 if key.eq_ignore_ascii_case(name) {
1085 return Some(value);
1086 }
1087 rest = after;
1088 }
1089 None
1090}
1091
1092fn decode_entities(text: &str) -> String {
1093 let mut decoded = String::with_capacity(text.len());
1094 let mut rest = text;
1095 while let Some(at) = rest.find('&') {
1096 decoded.push_str(&rest[..at]);
1097 rest = &rest[at..];
1098 let entity = rest[1..]
1099 .find(';')
1100 .filter(|end| *end <= 10)
1101 .map(|end| &rest[1..end + 1]);
1102 let c = entity.and_then(|entity| match entity {
1103 "amp" => Some('&'),
1104 "lt" => Some('<'),
1105 "gt" => Some('>'),
1106 "quot" => Some('"'),
1107 "apos" => Some('\''),
1108 "nbsp" => Some('\u{a0}'),
1109 _ => {
1110 let number = entity.strip_prefix('#')?;
1111 let code = match number.strip_prefix(['x', 'X']) {
1112 Some(hex) => u32::from_str_radix(hex, 16).ok()?,
1113 None => number.parse().ok()?,
1114 };
1115 char::from_u32(code)
1116 }
1117 });
1118 match (c, entity) {
1119 (Some(c), Some(entity)) => {
1120 decoded.push(c);
1121 rest = &rest[entity.len() + 2..];
1122 }
1123 _ => {
1124 decoded.push('&');
1125 rest = &rest[1..];
1126 }
1127 }
1128 }
1129 decoded.push_str(rest);
1130 decoded
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135 use super::*;
1136
1137 fn clips(html: &str) -> Vec<Clip> {
1138 html_pieces(html, 0x409, |_, _| None)
1139 .into_iter()
1140 .map(|piece| match piece {
1141 Piece::Clip(clip) => clip,
1142 _ => panic!("a picture where none was loaded"),
1143 })
1144 .collect()
1145 }
1146
1147 const ONENOTE: &str = include_str!("../../../../corpus/clipboard/onenote-2010.html");
1148
1149 /// What OneNote 2010 copied (`corpus/clipboard`) pastes with its formatting, links,
1150 /// lists and table; its tag is not in its HTML.
1151 #[test]
1152 fn onenotes_html_pastes_with_its_formatting() {
1153 let [clip] = &clips(ONENOTE)[..] else {
1154 panic!("one clip")
1155 };
1156 assert_eq!(
1157 clip.outline(),
1158 [
1159 "<b>Bold line</>",
1160 "plain <i>italic</> <color=0000c0>red</> <size=16>big</> \
1161 <font=Times New Roman>times</> <u>under</> <highlight=00ffff>hi</> \
1162 <link>link</> -> https://example.com/",
1163 "• bullet one",
1164 " • nested bullet",
1165 "• bullet two",
1166 "1. number one",
1167 "1. number two",
1168 "tagged todo",
1169 "after",
1170 "table 2x2",
1171 " | 0,0: a1",
1172 " | 0,1: <b>b1</>",
1173 " | 1,0: a2",
1174 " | 1,1: b2",
1175 "last line",
1176 ]
1177 );
1178 }
1179
1180 /// Word 2010's list paragraphs (`corpus/clipboard/word-2010.html`) paste as the lists
1181 /// OneNote 2010 made of them (lab, 2026-10-02): each level nested under the item above,
1182 /// a bullet in Word's own glyph and font, a number in Word's sequence and punctuation.
1183 #[test]
1184 fn words_lists_paste_as_lists() {
1185 let html = include_str!("../../../../corpus/clipboard/word-2010.html");
1186 let [clip] = &clips(html)[..] else {
1187 panic!("one clip")
1188 };
1189 assert_eq!(
1190 clip.outline(),
1191 [
1192 "• Bullet one",
1193 " • Bullet nested",
1194 " • Bullet deeper",
1195 "• Bullet two",
1196 "Plain paragraph",
1197 "1. Number one",
1198 " a. Number nested",
1199 " i. Number deeper",
1200 "1. Number two",
1201 "Last plain",
1202 ]
1203 );
1204 let lists = crate::document::leaves(&clip.paragraphs, None)
1205 .filter_map(
1206 |(.., node)| match &clip.definitions[node.lists.first()?].kind {
1207 Kind::List {
1208 font,
1209 format,
1210 bullet,
1211 ..
1212 } => Some((font.clone(), format.clone().unwrap(), *bullet)),
1213 _ => None,
1214 },
1215 )
1216 .collect::<Vec<_>>();
1217 let bullet = |font: &str, glyph: &str| (Some(font.to_owned()), glyph.to_owned(), None);
1218 let number = |format: &str| (None, format.to_owned(), None);
1219 assert_eq!(
1220 lists,
1221 [
1222 bullet("Symbol", "\u{b7}"),
1223 bullet("Courier New", "o"),
1224 bullet("Wingdings", "\u{a7}"),
1225 bullet("Symbol", "\u{b7}"),
1226 number("\u{fffd}\u{0}."),
1227 number("\u{fffd}\u{4}."),
1228 number("\u{fffd}\u{2}."),
1229 number("\u{fffd}\u{0}."),
1230 ]
1231 );
1232 }
1233
1234 /// LibreOffice writes lists as nested `ul` and `ol` around paragraphs, leaving items
1235 /// unclosed.
1236 #[test]
1237 fn libreoffices_lists_paste_as_lists() {
1238 let clip = &clips(
1239 "<ul>\n\t<li><p style=\"margin-bottom: 0in\">Bullet one</p>\n\t<ul>\n\t\t\
1240 <li><p>Bullet nested</p>\n\t</ul>\n\t<li><p>Bullet two</p>\n</ul>\n<ol>\n\t\
1241 <li><p>Number one</p>\n\t<ol type=\"a\">\n\t\t<li><p>Number nested</p>\n\t\
1242 </ol>\n\t<li><p>Number two</p>\n</ol>",
1243 )[0];
1244 assert_eq!(
1245 clip.outline(),
1246 [
1247 "• Bullet one",
1248 " • Bullet nested",
1249 "• Bullet two",
1250 "1. Number one",
1251 " a. Number nested",
1252 "1. Number two",
1253 ]
1254 );
1255 }
1256
1257 /// Snowbound's HTML reads back as the clip it was written from.
1258 #[test]
1259 fn snowbound_html_reads_back_as_written() {
1260 let [clip] = &clips(ONENOTE)[..] else {
1261 panic!("one clip")
1262 };
1263 let written = clip.html();
1264 let [read] = &clips(&written)[..] else {
1265 panic!("one clip")
1266 };
1267 assert_eq!(read.outline(), clip.outline());
1268 let runs = |clip: &Clip| {
1269 crate::document::leaves(&clip.paragraphs, None)
1270 .map(|(.., node)| node.text().unwrap().text.clone())
1271 .collect::<Vec<_>>()
1272 };
1273 assert_eq!(runs(read), runs(clip));
1274 }
1275
1276 /// Markup in text is escaped, spaces kept, and a paragraph that shows nothing still
1277 /// takes a line.
1278 #[test]
1279 fn text_survives_html() {
1280 let clip = &clips("<p>a &lt;b&gt; &amp;&nbsp;&nbsp;c</p><p>&nbsp;</p><p>d</p>")[0];
1281 assert_eq!(clip.text(), "a <b> & c\n \nd");
1282 let read = &clips(&clip.html())[0];
1283 assert_eq!(read.text(), clip.text());
1284 }
1285
1286 /// Other applications' markup: tags and inline styles, unclosed items, sizes in pixels
1287 /// and colours by name.
1288 #[test]
1289 fn web_markup_gives_formatting() {
1290 let clip = &clips(
1291 "<div style=\"font-family: Arial, sans-serif; font-size: 16px\">\
1292 <strong>Bold</strong> <em>it</em> <font color=red size=5>big</font> \
1293 <span style=\"font-weight:700; text-decoration:line-through\">gone</span>\
1294 x<sup>2</sup></div><ol type=a><li>first<li>second</ol>",
1295 )[0];
1296 assert_eq!(
1297 clip.outline(),
1298 [
1299 "<b font=Arial size=12>Bold</><font=Arial size=12> </>\
1300 <i font=Arial size=12>it</><font=Arial size=12> </>\
1301 <color=0000ff font=Arial size=18>big</><font=Arial size=12> </>\
1302 <b s font=Arial size=12>gone</><font=Arial size=12>x</>\
1303 <sup font=Arial size=12>2</>",
1304 "a. first",
1305 "a. second",
1306 ]
1307 );
1308 }
1309
1310 /// As OneNote 2010 pasted `<p>before <img> after</p>` (lab, 2026-09-30): the text on
1311 /// either side of the picture, the picture at the size its attributes give; one in a
1312 /// table is left out.
1313 #[test]
1314 fn a_pasted_page_gives_its_text_and_pictures_in_order() {
1315 let html = "Version:0.9\r\n<html><head><style>p{}</style><title>T</title></head>\
1316 <body><!--StartFragment--><p>before <img alt=\"a > b\" \
1317 src=\"file:///C:/work/pic.png\" width=\"200\" height=\"100px\"> after</p>\
1318 <p>Fish &amp; chips&nbsp;&#x2014;&#8212;</p><ul><li>one<li>two<br>three</ul>\
1319 <table><tr><td><img src=\"cell.png\">cell</td></tr></table>\
1320 <img src=\"missing.png\"><IMG SRC='https://example.invalid/a.png' width=40>\
1321 <!--EndFragment--></body></html>";
1322 let mut asked = Vec::new();
1323 let pieces = html_pieces(html, 0x409, |source, css| {
1324 asked.push((source.to_owned(), css));
1325 match source {
1326 "missing.png" => None,
1327 "https://example.invalid/a.png" => Some(Piece::Awaited),
1328 _ => Some(Piece::Picture(vec![0; 3], [150.0, 75.0])),
1329 }
1330 });
1331 assert_eq!(
1332 asked,
1333 [
1334 (
1335 "file:///C:/work/pic.png".to_owned(),
1336 [Some(200.0), Some(100.0)]
1337 ),
1338 ("missing.png".to_owned(), [None, None]),
1339 (
1340 "https://example.invalid/a.png".to_owned(),
1341 [Some(40.0), None]
1342 ),
1343 ]
1344 );
1345 let shown: Vec<Vec<String>> = pieces
1346 .iter()
1347 .map(|piece| match piece {
1348 Piece::Clip(clip) => clip.outline(),
1349 Piece::Picture(bytes, size) => vec![format!("[{} {size:?}]", bytes.len())],
1350 Piece::Awaited => vec!["[awaited]".to_owned()],
1351 })
1352 .collect();
1353 assert_eq!(
1354 shown,
1355 [
1356 vec!["before"],
1357 vec!["[3 [150.0, 75.0]]"],
1358 vec![
1359 "after",
1360 "Fish & chips \u{2014}\u{2014}",
1361 "• one",
1362 "• two",
1363 " three",
1364 "table 1x1",
1365 " | 0,0: cell",
1366 ],
1367 vec!["[awaited]"],
1368 ]
1369 );
1370 }
1371}