Annual Report"), "{h}");
- assert!(h.contains(""), "{h}");
- let r = rtf(&[p.clone()]);
- assert!(r.contains("{\\colortbl;\\red13\\green59\\blue46;\\red0\\green255\\blue0;}"), "{r}");
- assert!(r.contains("\\cf1 Annual Report"), "{r}");
- let ppt = pptx(&[p], "Colour");
- let pxml = part(&ppt, "ppt/slides/slide1.xml");
- assert!(pxml.contains(""), "{pxml}");
- assert!(pxml.contains(""), "out-of-range values clamp: {pxml}");
- // A table cell keeps its colour too.
- let mut t = table_page();
- t.blocks[0].color = [1.0, 0.0, 0.0];
- let xml = part(&docx(&[t.clone()], "Grid"), "word/document.xml");
- assert!(xml.contains(""), "{xml}");
- assert!(html(&[t], "Grid").contains("style=\"color:#FF0000\""));
- }
-
- #[test]
- fn rtf_escapes_and_sizes() {
- let mut p = page();
- p.blocks.push(Block {
- text: "Café {x}".into(),
- rect: [72.0, 100.0, 200.0, 110.0],
- size: 10.0,
- bold: false,
- italic: true,
- color: [0.0; 3],
- rotation: 0.0,
- space_around: [false; 2],
- });
- let r = rtf(&[p]);
- assert!(r.starts_with("{\\rtf1") && r.ends_with('}'));
- assert!(r.contains("\\fs48\\b Annual Report"));
- assert!(r.contains("\\fs20\\i Caf\\u233? \\{x\\}"), "{r}");
- }
-
- /// A 3-column table with a spanning header row and a hole in the last row.
- fn table_page() -> Page {
- let cell = |t: &str, x: f64, y: f64, w: f64| Block {
- text: t.into(),
- rect: [x, y, x + w, y + 12.0],
- size: 11.0,
- bold: false,
- italic: false,
- color: [0.0; 3],
- rotation: 0.0,
- space_around: [false; 2],
- };
- Page {
- width: 612.0,
- height: 792.0,
- blocks: vec![
- cell("Meter", 72.0, 620.0, 120.0),
- cell("Unit", 232.0, 620.0, 80.0),
- cell("Reading", 392.0, 620.0, 100.0),
- cell("A1", 72.0, 590.0, 120.0),
- cell("kWh", 232.0, 590.0, 80.0),
- cell("37414.00", 392.0, 590.0, 100.0),
- cell("A2", 72.0, 560.0, 120.0),
- cell("kW", 232.0, 560.0, 80.0),
- // A2's row is missing its third cell (an empty cell in the grid).
- cell("Totals", 72.0, 530.0, 240.0),
- Block {
- text: "Notes follow the table and are long enough to be the body text size of the page here.".into(),
- rect: [72.0, 460.0, 500.0, 471.0],
- size: 11.0,
- bold: false,
- italic: false,
- color: [0.0; 3],
- rotation: 0.0,
- space_around: [false; 2],
- },
- ],
- images: Vec::new(),
- background: None,
- }
- }
-
- #[test]
- fn grid_text_becomes_a_real_table_in_every_format() {
- let p = table_page();
- let h = html(std::slice::from_ref(&p), "Bill");
- assert!(h.contains(""), "{h}");
- assert!(h.contains("| Totals | "), "spanning cell: {h}");
- assert_eq!(h.matches("").count(), 4, "{h}");
- assert!(h.contains(" | "), "the hole is an empty cell: {h}");
- assert!(h.contains("Notes follow"), "the paragraph after the table stays: {h}");
-
- let d = docx(std::slice::from_ref(&p), "Bill");
- assert!(d.starts_with(b"PK"));
- assert!(d.windows(b"word/document.xml".len()).any(|w| w == b"word/document.xml"));
- let (tables, _) = tables(&p.blocks);
- assert_eq!(tables.len(), 1);
- let table_xml = docx_table(&tables[0]);
- assert!(table_xml.contains(""));
- assert!(table_xml.contains(""), "{table_xml}");
- assert_eq!(table_xml.matches("").count(), 4);
-
- let r = rtf(std::slice::from_ref(&p));
- assert!(r.contains("\\trowd"), "{r}");
- assert_eq!(r.matches("\\row").count(), 4);
- // Boundaries precede the cells; "Totals" spans two columns, so it ends at the third edge.
- assert!(r.contains("\\trowd\\trgaph108\\cellx4640\\cellx7840\\cellx9920{"), "{r}");
- assert!(r.contains("\\trowd\\trgaph108\\cellx7840{\\intbl\\fs22 Totals}\\cell\\row"), "{r}");
-
- let pt = pptx(std::slice::from_ref(&p), "Bill");
- assert!(pt.starts_with(b"PK"));
- assert!(pt.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml"));
- let slide_xml = part(&pt, "ppt/slides/slide1.xml");
- assert!(slide_xml.contains(""));
- assert!(slide_xml.contains("gridSpan=\"2\""));
- assert_eq!(slide_xml.matches(" = (0..n)
- .map(|i| {
- let (row, col) = (i / 2, i / 2 + i % 2);
- let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0);
- Block {
- text: "x".into(),
- rect: [x, y, x + 8.0, y + 12.0],
- size: 11.0,
- bold: false,
- italic: false,
- color: [0.0; 3],
- rotation: 0.0,
- space_around: [false; 2],
- }
- })
- .collect();
- let started = std::time::Instant::now();
- let (tables, _) = tables(&blocks);
- assert!(tables.iter().all(|t| t.cols.len() <= MAX_COLS), "{} columns", tables.iter().map(|t| t.cols.len()).max().unwrap_or(0));
- assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
- }
-
- #[test]
- fn word_opens_tables_up_to_its_column_limit() {
- // Rows of short numbers on a regular grid, n columns wide.
- let grid = |n: usize| Page {
- width: 1500.0,
- height: 792.0,
- blocks: (0..3)
- .flat_map(|row| {
- (0..n).map(move |col| {
- let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0);
- Block {
- text: (col + 1).to_string(),
- rect: [x, y, x + 8.0, y + 8.0],
- size: 8.0,
- bold: false,
- italic: false,
- color: [0.0; 3],
- rotation: 0.0,
- space_around: [false; 2],
- }
- })
- })
- .collect(),
- images: Vec::new(),
- background: None,
- };
- let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches(" Block {
- Block {
- text: t.into(),
- rect: [x0, y0, x1, y1],
- size: 12.0,
- bold: false,
- italic: false,
- color: [0.0; 3],
- rotation: 0.0,
- space_around: [false; 2],
- }
- }
-
- /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks
- /// on one line. They are not table columns, even when several such lines share left edges.
- #[test]
- fn a_link_inside_a_sentence_is_not_a_table_column() {
- let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) };
- let p = Page {
- width: 612.0,
- height: 792.0,
- blocks: vec![
- block("6.3. 2. All content shown at", 121.7, 710.3, 272.0, 724.9),
- blue("http", 273.5, 710.3, 294.1, 724.9),
- blue("s", 294.1, 709.8, 298.8, 725.8),
- blue("://women", 298.8, 710.3, 348.0, 724.9),
- blue("g", 348.1, 709.8, 353.7, 725.8),
- blue("randprix.fide.com", 353.7, 710.3, 443.2, 724.9),
- block("shall be reviewed and approved by the Press Officer.", 164.3, 622.5, 533.1, 724.9),
- block("6.3. 3. The Organiser shall warrant that", 121.7, 601.8, 345.1, 616.5),
- blue("https://womengrandprix.fide.com", 350.2, 601.3, 519.9, 617.4),
- block("is managed in a professional way and furthermore that it is capable of", 164.3, 557.9, 533.0, 616.5),
- block("6. 3. 4. The Press Officer shall report to the FIDE Head of PR.", 121.7, 516.6, 530.3, 551.9),
- block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6),
- ],
- images: Vec::new(),
- background: None,
- };
- let h = html(std::slice::from_ref(&p), "Regulations");
- assert!(!h.contains(""), "running text became a table: {h}");
- // The link is a run of its sentence, in its own colour (#4158).
- assert!(h.contains("6.3. 2. All content shown at http"), "{h}");
- for b in &p.blocks {
- assert!(h.contains(&b.text), "{} was lost: {h}", b.text);
- }
- }
-
- /// #2806: lines that each run on into a link starting at the same place are sentences, not a
- /// two-column table, even three in a row.
- #[test]
- fn sentences_that_run_on_into_links_are_not_a_table() {
- let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) };
- let mut blocks = Vec::new();
- for (n, lead) in ["Rules are published at", "Pairings are posted at", "Results are listed at"].iter().enumerate() {
- let y = 700.0 - 16.0 * n as f64;
- blocks.push(block(lead, 72.0, y, 211.0, y + 12.0));
- blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0));
- }
- let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
- let h = html(std::slice::from_ref(&p), "Event");
- assert!(!h.contains("
"), "sentences became a table: {h}");
- assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}");
- }
-
- /// A continuation can drift into a grid column covered by the preceding cell's span.
- /// It must remain available as paragraph text if that slot never becomes an output cell.
- #[test]
- fn table_spans_do_not_consume_text_they_do_not_emit() {
- let mut blocks = vec![
- Block { size: 12.0, ..block("ABCDEFGHIJK", 72.1, 697.0, 151.3, 710.8) },
- Block { size: 12.0, color: [0.0, 0.0, 1.0], ..block("LOST", 150.4, 697.0, 179.2, 710.8) },
- Block { size: 12.0, ..block("R0", 250.0, 697.0, 264.4, 710.8) },
- ];
- for row in 1..=4 {
- let y = 697.0 - f64::from(row) * 50.0;
- for (text, left, right) in [(format!("L{row}"), 72.1, 86.5), (format!("M{row}"), 146.4, 160.8), (format!("R{row}"), 250.0, 264.4)] {
- blocks.push(Block { size: 12.0, ..block(&text, left, y, right, y + 13.8) });
- }
- }
- let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() };
- let (tables, consumed) = tables(&p.blocks);
- assert_eq!(tables.len(), 1);
- assert_eq!(tables[0].rows[0][0].span, 2);
- assert!(!consumed[1], "a covered slot did not emit its text");
- assert!(consumed.iter().enumerate().all(|(index, used)| index == 1 || *used));
- let outputs = [
- html(std::slice::from_ref(&p), "Continuations"),
- part(&docx(std::slice::from_ref(&p), "Continuations"), "word/document.xml"),
- rtf(std::slice::from_ref(&p)),
- part(&pptx(std::slice::from_ref(&p), "Continuations"), "ppt/slides/slide1.xml"),
- ];
- for output in outputs {
- for b in &p.blocks {
- assert_eq!(output.matches(&b.text).count(), 1, "{} must survive exactly once", b.text);
- }
- }
- }
-
- /// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table
- /// around it stays a table.
- #[test]
- fn a_superscript_beside_a_number_keeps_its_table() {
- let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)];
- for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd"), ("4", "th")].iter().enumerate() {
- let y = 680.0 - 20.0 * n as f64;
- blocks.push(block(row, 100.0, y, 106.0, y + 12.0));
- blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) });
- blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0));
- }
- let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
- let h = html(std::slice::from_ref(&p), "Prizes");
- assert!(h.contains(""), "the prize table was lost: {h}");
- assert!(h.contains("30,000") && h.contains("15,000"), "{h}");
- let (tables, consumed) = tables(&p.blocks);
- assert!(consumed.iter().all(|used| *used), "ordinary table continuations stay in their cells");
- let cells: Vec<&str> = tables.iter().flat_map(|t| &t.rows).flatten().map(|c| c.text.as_str()).collect();
- assert!(["st", "nd", "rd", "th"].iter().all(|suffix| cells.contains(suffix)));
- }
-
- #[test]
- fn two_column_body_text_is_not_mistaken_for_a_table() {
- let para = |t: &str, x: f64, y: f64| Block {
- text: t.into(),
- rect: [x, y, x + 200.0, y + 90.0], // nine lines tall: body text, not a cell
- size: 11.0,
- bold: false,
- italic: false,
- color: [0.0; 3],
- rotation: 0.0,
- space_around: [false; 2],
- };
- let p = Page {
- width: 612.0,
- height: 792.0,
- blocks: vec![
- para("Left column text of the page, long enough to be a flowing paragraph.", 72.0, 500.0),
- para("Right column text of the page, long enough to be a flowing paragraph.", 320.0, 500.0),
- para("Second left block, still a tall flowing paragraph rather than a cell.", 72.0, 400.0),
- para("Second right block, still a tall flowing paragraph rather than a cell.", 320.0, 400.0),
- ],
- images: Vec::new(),
- background: None,
- };
- let h = html(&[p], "Paper");
- assert!(!h.contains(""), "tall two-column text must stay paragraphs: {h}");
- }
-
- /// #1936: every picture Word is told to draw (`r:embed`) names a relationship whose target is a
- /// file in the package, with a content type; JPEG pictures too, inline or placed.
- #[test]
- fn word_pictures_resolve_to_media_in_the_package() {
- let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() };
- for placed in [false, true] {
- let mut p = page();
- p.images = vec![jpeg.clone()];
- if placed {
- p.images[0].rect = [72.0, 640.0, 272.0, 790.0];
- p.blocks[0].rect = [72.0, 700.0, 300.0, 760.0];
- }
- let zip = docx(&[p], "T");
- let xml = part(&zip, "word/document.xml");
- assert_eq!(xml.contains(" Vec> {
- let body = xml.split("").map(|s| s.split_once("").map_or(s, |(_, after)| after)).collect::();
- body.split("")
- .skip(1)
- .map(|p| {
- let p = p.split_once("").map_or(p, |(inside, _)| inside);
- p.split("")
- .skip(1)
- .filter_map(|r| {
- let text = r.split_once("")?.1.split_once("")?.0;
- Some((text.to_string(), r.contains("")))
- })
- .collect::>()
- })
- .filter(|runs| !runs.is_empty())
- .collect()
- }
-
- fn runs(r: &[(&str, bool)]) -> Vec<(String, bool)> {
- r.iter().map(|(t, i)| (t.to_string(), *i)).collect()
- }
-
- /// "Before *Chevron* after." on one line: the roman text, the italic case name and the roman
- /// text after it, the way reading blocks split a sentence at its style changes (#4158). The
- /// italic face stands a little taller than the roman one around it.
- fn italic_sentence() -> Vec {
- vec![
- block("Before", 72.0, 700.0, 110.0, 712.0),
- Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.6) },
- block("after.", 161.0, 700.0, 190.0, 712.0),
- ]
- }
-
- /// #4158: a run in another style inside a sentence stays in that sentence's paragraph, as a
- /// run in its own style, in every Office format.
- #[test]
- fn an_italic_word_stays_in_its_sentence() {
- let mut blocks = italic_sentence();
- blocks.push(block("The next line is a paragraph of its own.", 72.0, 684.0, 300.0, 696.0));
- let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
- let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
- assert_eq!(
- word_paragraphs(&xml),
- vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("The next line is a paragraph of its own.", false)])],
- "{xml}"
- );
- let h = html(std::slice::from_ref(&p), "Opinion");
- assert!(h.contains("Before Chevron after.
"), "{h}");
- let r = rtf(std::slice::from_ref(&p));
- assert!(r.contains("{\\pard\\fs24 Before {\\fs24\\b0\\i\\cf0 Chevron }{\\fs24\\b0\\i0\\cf0 after.}\\par}"), "{r}");
- // The longest line is the slide's title; the sentence is one paragraph of the body.
- let pxml = part(&pptx(std::slice::from_ref(&p), "Opinion"), "ppt/slides/slide1.xml");
- assert!(
- pxml.contains("Before Chevron after."),
- "{pxml}"
- );
- }
-
- /// #4158: the runs keep their order along the line whatever order the blocks come in, and the
- /// paragraph keeps its place on the page, though the italic run stands taller than the first.
- #[test]
- fn a_sentence_keeps_its_reading_order() {
- let mut blocks = vec![block("Then the next line.", 72.0, 684.0, 200.0, 696.0)];
- blocks.extend(italic_sentence().into_iter().rev());
- let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
- let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
- assert_eq!(
- word_paragraphs(&xml),
- vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("Then the next line.", false)])],
- "{xml}"
- );
- }
-
- /// The text of a block leaves out the spaces at its ends, and the box of a run often takes in
- /// its trailing space, so runs can touch. The source's own space still separates them; runs
- /// that touch with no space between them (a superscript) stay joined.
- #[test]
- fn runs_keep_the_spaces_the_source_had_between_them() {
- let blocks = vec![
- Block { space_around: [false, true], ..block("Before", 72.0, 700.0, 113.0, 712.0) },
- Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.0) },
- Block { space_around: [true, false], ..block("after.", 158.0, 700.0, 190.0, 712.0) },
- block("E = mc", 72.0, 660.0, 110.0, 672.0),
- Block { size: 8.0, ..block("2", 110.0, 665.0, 115.0, 673.0) },
- ];
- let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
- let xml = part(&docx(std::slice::from_ref(&p), "Spaces"), "word/document.xml");
- assert_eq!(
- word_paragraphs(&xml),
- vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("E = mc", false), ("2", false)])],
- "{xml}"
- );
- }
- /// Text turned another way (a label up the margin) is not a run of the line it touches.
- #[test]
- fn turned_text_is_not_a_run_of_the_line_beside_it() {
- let blocks = vec![block("Before", 72.0, 700.0, 110.0, 712.0), Block { rotation: 90.0, ..block("SIDE", 111.0, 690.0, 123.0, 720.0) }];
- let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
- let h = html(std::slice::from_ref(&p), "Turned");
- assert!(h.contains("Before
") && h.contains("SIDE
"), "{h}");
- }
- /// A block that starts where the line above ends is the next line, not a run of it.
- #[test]
- fn a_block_on_the_next_line_starts_a_paragraph() {
- let blocks = vec![
- block("The first line ends here", 72.0, 700.0, 190.0, 712.0),
- Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) },
- ];
- let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
- let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml");
- assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}");
- }
-
- /// A superscript beside a table cell stays in the table: table detection consumes it before
- /// paragraphs are formed, and a sentence after the table still keeps its italic run.
- #[test]
- fn table_cells_are_not_runs_of_a_paragraph() {
- let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)];
- for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() {
- let y = 680.0 - 20.0 * n as f64;
- blocks.push(block(row, 100.0, y, 106.0, y + 12.0));
- blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) });
- blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0));
- }
- blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b }));
- let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
- let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml");
- assert_eq!(xml.matches("").count(), 1, "{xml}");
- for cell in ["st", "nd", "rd", "30,000"] {
- assert_eq!(xml.matches(&format!("preserve\">{cell}")).count(), 1, "{cell}: {xml}");
- }
- // The cells are all in the table: the only flowing paragraph is the sentence.
- assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}");
- }
-
- /// A bold lead-in followed by body text on its line is body text, not a heading; a heading
- /// with a smaller footnote marker after it stays a heading.
- #[test]
- fn a_paragraph_is_a_heading_only_when_its_text_is() {
- let blocks = vec![
- Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) },
- Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) },
- Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) },
- block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0),
- ];
- let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
- let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml");
- assert_eq!(xml.matches("").count(), 1, "{xml}");
- assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}");
- assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}");
- }
-}
+mod tests;
diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs
new file mode 100644
index 000000000..044ed236d
--- /dev/null
+++ b/crates/export/src/tests.rs
@@ -0,0 +1,780 @@
+use super::*;
+
+/// A page of `n` one-word blocks, each in its own size and colour.
+fn varied_page(n: usize) -> Page {
+ let blocks = (0..n)
+ .map(|i| Block {
+ text: "word".into(),
+ rect: [72.0, i as f64, 100.0, i as f64 + 1.0],
+ size: 4.0 + i as f64 * 0.5,
+ bold: false,
+ italic: false,
+ color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5],
+ rotation: 0.0,
+ space_around: [false; 2],
+ })
+ .collect();
+ Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }
+}
+
+fn lookup_steps(f: impl FnOnce()) -> usize {
+ LOOKUP_STEPS.with(|c| c.set(0));
+ f();
+ LOOKUP_STEPS.with(|c| c.get())
+}
+
+#[test]
+fn many_distinct_sizes_are_counted_in_constant_steps_each() {
+ let pages = [varied_page(3000)];
+ let steps = lookup_steps(|| {
+ body_size(&pages);
+ });
+ assert!(steps <= 2 * 3000, "{steps} steps");
+}
+
+#[test]
+fn many_distinct_colours_take_constant_steps_each() {
+ let pages = [varied_page(3000)];
+ let mut out = String::new();
+ let steps = lookup_steps(|| out = rtf(&pages));
+ assert!(steps <= 4 * 3000, "{steps} steps");
+ assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot");
+}
+
+fn page() -> Page {
+ let b = |t: &str, y: f64, size: f64, bold: bool| Block {
+ text: t.into(),
+ rect: [72.0, y, 500.0, y + size],
+ size,
+ bold,
+ italic: false,
+ color: [0.0; 3],
+ rotation: 0.0,
+ space_around: [false; 2],
+ };
+ Page {
+ width: 612.0,
+ height: 792.0,
+ blocks: vec![
+ b("Body text after the picture, which is long enough to be the main text size of the page.", 400.0, 11.0, false),
+ b("Annual Report", 700.0, 24.0, true),
+ b("Overview", 650.0, 14.0, true),
+ b("Some text & more of it so that eleven points is the body size here.", 620.0, 11.0, false),
+ ],
+ images: vec![Image {
+ ext: "png",
+ bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(),
+ rect: [72.0, 450.0, 272.0, 600.0],
+ size: [200.0, 150.0],
+ rotation: 0.0,
+ }],
+ background: None,
+ }
+}
+
+#[test]
+fn html_has_headings_paragraphs_and_images_in_order() {
+ let h = html(&[page(), page()], "Report");
+ let order: Vec = ["Annual Report
", "Overview
", "Some <body> text &", "
Body text after"]
+ .iter()
+ .map(|x| h.find(x).unwrap_or_else(|| panic!("{x} missing in {h}")))
+ .collect();
+ assert!(order.windows(2).all(|w| w[0] < w[1]), "{order:?}");
+ assert_eq!(h.matches("
").count(), 1);
+ assert_eq!(base64(b"Man"), "TWFu");
+ assert_eq!(base64(b"Ma"), "TWE=");
+}
+
+#[test]
+fn docx_is_a_word_package() {
+ let d = docx(&[page()], "Report");
+ assert!(d.starts_with(b"PK"));
+ let names: Vec<&str> =
+ ["[Content_Types].xml", "word/document.xml", "word/styles.xml", "word/_rels/document.xml.rels", "word/media/image1.png", "_rels/.rels"]
+ .to_vec();
+ for n in names {
+ assert!(d.windows(n.len()).any(|w| w == n.as_bytes()), "{n}");
+ }
+}
+
+#[test]
+fn word_does_not_invent_borders_for_aligned_invoice_fields() {
+ let xml = part(&docx(&[table_page()], "Invoice"), "word/document.xml");
+ assert!(xml.contains(""));
+ assert!(!xml.contains("w:val=\"single\""), "text alignment is not evidence of a border: {xml}");
+ assert!(xml.contains(""));
+}
+
+#[test]
+fn word_table_cell_widths_follow_the_columns_they_span() {
+ let cell = |text: &str, span| Cell { text: text.into(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span };
+ let table = Table {
+ rect: [0.0, 0.0, 300.0, 100.0],
+ cols: vec![0.0, 50.0, 150.0],
+ rows: vec![
+ vec![cell("First", 1), cell("Second", 1), cell("Third", 1)],
+ vec![cell("First", 1), cell("Last two", 2)],
+ vec![cell("First two", 2), cell("Third", 1)],
+ vec![cell("All", 3)],
+ vec![cell("First", 1)],
+ ],
+ };
+ let xml = docx_table(&table);
+ assert!(xml.contains(""), "{xml}");
+ let widths: Vec> =
+ xml.split("").skip(1).map(|row| row.split(""), "the picture stays inline: {xml}");
+ assert!(xml.contains(""), "the title stays a heading: {xml}");
+ let (title, body) = (xml.find(">Annual Report"), xml.find(">Body text after the picture"));
+ assert!(title.is_some() && body.is_some() && title < body, "reading order kept: {xml}");
+ // The same page with the picture moved over the body text floats.
+ let mut over = page();
+ over.images[0].rect = [72.0, 380.0, 272.0, 530.0];
+ let xml = part(&docx(&[over], "Report"), "word/document.xml");
+ assert!(xml.contains("position:absolute"), "an image over text floats: {xml}");
+}
+
+#[test]
+fn word_overlap_does_not_consume_invoice_body_space() {
+ let mut p = table_page();
+ p.blocks.push(Block {
+ text: "DRAFT".into(),
+ rect: [100.0, 200.0, 500.0, 700.0],
+ size: 100.0,
+ bold: false,
+ italic: false,
+ color: [0.8; 3],
+ rotation: 0.0,
+ space_around: [false; 2],
+ });
+ p.images = page().images;
+ let xml = part(&docx(&[p], "Invoice"), "word/document.xml");
+ assert!(xml.contains("position:absolute"), "overlapping text must float: {xml}");
+ assert!(!xml.contains(""), "images must not displace invoice text");
+ assert!(!xml.contains(""), "the source text boxes must keep their positions");
+ assert_eq!(xml.matches(">DRAFT").count(), 1);
+ assert_eq!(xml.matches("w:type=\"page\"").count(), 0);
+ assert!(xml.contains("margin-left:72.000pt;margin-top:160.000pt"), "Meter field keeps its source position: {xml}");
+ let watermark = xml.split(">DRAFT").next().unwrap().rsplit(""), "{xml}");
+}
+
+#[test]
+fn word_rotated_pages_keep_separate_page_sizes_and_unique_shapes() {
+ let mut p = table_page();
+ p.blocks[0].rotation = 45.0;
+ let mut second = p.clone();
+ second.width = 400.0;
+ second.height = 500.0;
+ let xml = part(&docx(&[p, second], "Two invoices"), "word/document.xml");
+ assert!(xml.contains("rotation:315.000"), "PDF y-up becomes Word y-down");
+ assert_eq!(xml.matches("").count(), 2);
+ assert!(xml.contains(""));
+ assert_eq!(xml.matches("id=\"Text1\"").count(), 1);
+ assert_eq!(xml.matches(">Meter").count(), 2);
+}
+
+/// #2992: pages that share a background colour get it as the Word document's page colour
+/// (with the setting that makes Word show it); differing or white backgrounds don't.
+#[test]
+fn docx_keeps_a_shared_page_background() {
+ let teal = Page { background: Some([0.0, 0.5, 0.5]), ..page() };
+ let zip = docx(&[teal.clone(), teal.clone()], "Teal");
+ let xml = part(&zip, "word/document.xml");
+ assert!(xml.contains(""), "{xml}");
+ assert!(part(&zip, "word/settings.xml").contains(""));
+ assert!(part(&zip, "[Content_Types].xml").contains("/word/settings.xml"));
+ assert!(part(&zip, "word/_rels/document.xml.rels").contains("Target=\"settings.xml\""));
+ let other = Page { background: Some([1.0, 0.0, 0.0]), ..page() };
+ for (pages, why) in [
+ (vec![teal.clone(), other], "different colours"),
+ (vec![teal.clone(), page()], "a page without one"),
+ (vec![Page { background: Some([1.0; 3]), ..page() }], "white"),
+ (vec![page()], "none"),
+ ] {
+ let zip = docx(&pages, "Plain");
+ assert!(!part(&zip, "word/document.xml").contains(" String {
+ let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize);
+ let mut i = 0;
+ while zip[i..].starts_with(b"PK\x03\x04") {
+ let (size, name_len) = (at(i + 18, 4), at(i + 26, 2));
+ let data = i + 30 + name_len;
+ if &zip[i + 30..data] == name.as_bytes() {
+ let mut s = String::new();
+ std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut s).unwrap();
+ return s;
+ }
+ i = data + size;
+ }
+ panic!("{name} missing")
+}
+
+#[test]
+fn word_opens_text_with_control_characters_and_long_pages() {
+ // #72: text extracted without a usable /ToUnicode holds C0 controls, which XML forbids;
+ // Word then refused the whole file. A till receipt is also taller than Word's 22 in.
+ let mut p = page();
+ p.height = 2400.0;
+ p.blocks.push(Block {
+ text: "Total\u{0}\u{3}\u{c} 4.50\u{FFFF}\tGBP".into(),
+ rect: [72.0, 100.0, 300.0, 110.0],
+ size: f64::NAN,
+ bold: false,
+ italic: false,
+ color: [0.0; 3],
+ rotation: 0.0,
+ space_around: [false; 2],
+ });
+ let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml");
+ assert!(xml.contains(">Total 4.50\tGBP"), "{xml}");
+ assert!(!xml.chars().any(|c| !allowed(c)));
+ assert!(xml.contains(""), "clamped to 22 in: {xml}");
+ assert!(xml.contains(""), "a non-finite size falls back to 12 pt");
+ assert!(rtf(&[p]).contains("Total 4.50\tGBP"));
+ // A narrow page keeps room for text: margins shrink.
+ let narrow = Page { width: 100.0, height: 200.0, ..page() };
+ let xml = part(&docx(&[narrow], "Narrow"), "word/document.xml");
+ assert!(xml.contains(""), "{xml}");
+ assert!(xml.contains(""), "out-of-range values clamp: {xml}");
+ // Black text keeps Word's default colour.
+ assert_eq!(xml.matches("Annual Report"), "{h}");
+ assert!(h.contains(""), "{h}");
+ let r = rtf(&[p.clone()]);
+ assert!(r.contains("{\\colortbl;\\red13\\green59\\blue46;\\red0\\green255\\blue0;}"), "{r}");
+ assert!(r.contains("\\cf1 Annual Report"), "{r}");
+ let ppt = pptx(&[p], "Colour");
+ let pxml = part(&ppt, "ppt/slides/slide1.xml");
+ assert!(pxml.contains(""), "{pxml}");
+ assert!(pxml.contains(""), "out-of-range values clamp: {pxml}");
+ // A table cell keeps its colour too.
+ let mut t = table_page();
+ t.blocks[0].color = [1.0, 0.0, 0.0];
+ let xml = part(&docx(&[t.clone()], "Grid"), "word/document.xml");
+ assert!(xml.contains(""), "{xml}");
+ assert!(html(&[t], "Grid").contains("style=\"color:#FF0000\""));
+}
+
+#[test]
+fn rtf_escapes_and_sizes() {
+ let mut p = page();
+ p.blocks.push(Block {
+ text: "Café {x}".into(),
+ rect: [72.0, 100.0, 200.0, 110.0],
+ size: 10.0,
+ bold: false,
+ italic: true,
+ color: [0.0; 3],
+ rotation: 0.0,
+ space_around: [false; 2],
+ });
+ let r = rtf(&[p]);
+ assert!(r.starts_with("{\\rtf1") && r.ends_with('}'));
+ assert!(r.contains("\\fs48\\b Annual Report"));
+ assert!(r.contains("\\fs20\\i Caf\\u233? \\{x\\}"), "{r}");
+}
+
+/// A 3-column table with a spanning header row and a hole in the last row.
+fn table_page() -> Page {
+ let cell = |t: &str, x: f64, y: f64, w: f64| Block {
+ text: t.into(),
+ rect: [x, y, x + w, y + 12.0],
+ size: 11.0,
+ bold: false,
+ italic: false,
+ color: [0.0; 3],
+ rotation: 0.0,
+ space_around: [false; 2],
+ };
+ Page {
+ width: 612.0,
+ height: 792.0,
+ blocks: vec![
+ cell("Meter", 72.0, 620.0, 120.0),
+ cell("Unit", 232.0, 620.0, 80.0),
+ cell("Reading", 392.0, 620.0, 100.0),
+ cell("A1", 72.0, 590.0, 120.0),
+ cell("kWh", 232.0, 590.0, 80.0),
+ cell("37414.00", 392.0, 590.0, 100.0),
+ cell("A2", 72.0, 560.0, 120.0),
+ cell("kW", 232.0, 560.0, 80.0),
+ // A2's row is missing its third cell (an empty cell in the grid).
+ cell("Totals", 72.0, 530.0, 240.0),
+ Block {
+ text: "Notes follow the table and are long enough to be the body text size of the page here.".into(),
+ rect: [72.0, 460.0, 500.0, 471.0],
+ size: 11.0,
+ bold: false,
+ italic: false,
+ color: [0.0; 3],
+ rotation: 0.0,
+ space_around: [false; 2],
+ },
+ ],
+ images: Vec::new(),
+ background: None,
+ }
+}
+
+#[test]
+fn grid_text_becomes_a_real_table_in_every_format() {
+ let p = table_page();
+ let h = html(std::slice::from_ref(&p), "Bill");
+ assert!(h.contains(""), "{h}");
+ assert!(h.contains("| Totals | "), "spanning cell: {h}");
+ assert_eq!(h.matches("").count(), 4, "{h}");
+ assert!(h.contains(" | "), "the hole is an empty cell: {h}");
+ assert!(h.contains("Notes follow"), "the paragraph after the table stays: {h}");
+
+ let d = docx(std::slice::from_ref(&p), "Bill");
+ assert!(d.starts_with(b"PK"));
+ assert!(d.windows(b"word/document.xml".len()).any(|w| w == b"word/document.xml"));
+ let (tables, _) = tables(&p.blocks);
+ assert_eq!(tables.len(), 1);
+ let table_xml = docx_table(&tables[0]);
+ assert!(table_xml.contains(""));
+ assert!(table_xml.contains(""), "{table_xml}");
+ assert_eq!(table_xml.matches("").count(), 4);
+
+ let r = rtf(std::slice::from_ref(&p));
+ assert!(r.contains("\\trowd"), "{r}");
+ assert_eq!(r.matches("\\row").count(), 4);
+ // Boundaries precede the cells; "Totals" spans two columns, so it ends at the third edge.
+ assert!(r.contains("\\trowd\\trgaph108\\cellx4640\\cellx7840\\cellx9920{"), "{r}");
+ assert!(r.contains("\\trowd\\trgaph108\\cellx7840{\\intbl\\fs22 Totals}\\cell\\row"), "{r}");
+
+ let pt = pptx(std::slice::from_ref(&p), "Bill");
+ assert!(pt.starts_with(b"PK"));
+ assert!(pt.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml"));
+ let slide_xml = part(&pt, "ppt/slides/slide1.xml");
+ assert!(slide_xml.contains(""));
+ assert!(slide_xml.contains("gridSpan=\"2\""));
+ assert_eq!(slide_xml.matches(" = (0..n)
+ .map(|i| {
+ let (row, col) = (i / 2, i / 2 + i % 2);
+ let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0);
+ Block {
+ text: "x".into(),
+ rect: [x, y, x + 8.0, y + 12.0],
+ size: 11.0,
+ bold: false,
+ italic: false,
+ color: [0.0; 3],
+ rotation: 0.0,
+ space_around: [false; 2],
+ }
+ })
+ .collect();
+ let started = std::time::Instant::now();
+ let (tables, _) = tables(&blocks);
+ assert!(tables.iter().all(|t| t.cols.len() <= MAX_COLS), "{} columns", tables.iter().map(|t| t.cols.len()).max().unwrap_or(0));
+ assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
+}
+
+#[test]
+fn word_opens_tables_up_to_its_column_limit() {
+ // Rows of short numbers on a regular grid, n columns wide.
+ let grid = |n: usize| Page {
+ width: 1500.0,
+ height: 792.0,
+ blocks: (0..3)
+ .flat_map(|row| {
+ (0..n).map(move |col| {
+ let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0);
+ Block {
+ text: (col + 1).to_string(),
+ rect: [x, y, x + 8.0, y + 8.0],
+ size: 8.0,
+ bold: false,
+ italic: false,
+ color: [0.0; 3],
+ rotation: 0.0,
+ space_around: [false; 2],
+ }
+ })
+ })
+ .collect(),
+ images: Vec::new(),
+ background: None,
+ };
+ let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches(" Block {
+ Block {
+ text: t.into(),
+ rect: [x0, y0, x1, y1],
+ size: 12.0,
+ bold: false,
+ italic: false,
+ color: [0.0; 3],
+ rotation: 0.0,
+ space_around: [false; 2],
+ }
+}
+
+/// #2806: a sentence whose link is set in another colour or font runs on as separate blocks
+/// on one line. They are not table columns, even when several such lines share left edges.
+#[test]
+fn a_link_inside_a_sentence_is_not_a_table_column() {
+ let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) };
+ let p = Page {
+ width: 612.0,
+ height: 792.0,
+ blocks: vec![
+ block("6.3. 2. All content shown at", 121.7, 710.3, 272.0, 724.9),
+ blue("http", 273.5, 710.3, 294.1, 724.9),
+ blue("s", 294.1, 709.8, 298.8, 725.8),
+ blue("://women", 298.8, 710.3, 348.0, 724.9),
+ blue("g", 348.1, 709.8, 353.7, 725.8),
+ blue("randprix.fide.com", 353.7, 710.3, 443.2, 724.9),
+ block("shall be reviewed and approved by the Press Officer.", 164.3, 622.5, 533.1, 724.9),
+ block("6.3. 3. The Organiser shall warrant that", 121.7, 601.8, 345.1, 616.5),
+ blue("https://womengrandprix.fide.com", 350.2, 601.3, 519.9, 617.4),
+ block("is managed in a professional way and furthermore that it is capable of", 164.3, 557.9, 533.0, 616.5),
+ block("6. 3. 4. The Press Officer shall report to the FIDE Head of PR.", 121.7, 516.6, 530.3, 551.9),
+ block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6),
+ ],
+ images: Vec::new(),
+ background: None,
+ };
+ let h = html(std::slice::from_ref(&p), "Regulations");
+ assert!(!h.contains(""), "running text became a table: {h}");
+ // The link is a run of its sentence, in its own colour (#4158).
+ assert!(h.contains("6.3. 2. All content shown at http"), "{h}");
+ for b in &p.blocks {
+ assert!(h.contains(&b.text), "{} was lost: {h}", b.text);
+ }
+}
+
+/// #2806: lines that each run on into a link starting at the same place are sentences, not a
+/// two-column table, even three in a row.
+#[test]
+fn sentences_that_run_on_into_links_are_not_a_table() {
+ let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) };
+ let mut blocks = Vec::new();
+ for (n, lead) in ["Rules are published at", "Pairings are posted at", "Results are listed at"].iter().enumerate() {
+ let y = 700.0 - 16.0 * n as f64;
+ blocks.push(block(lead, 72.0, y, 211.0, y + 12.0));
+ blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0));
+ }
+ let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
+ let h = html(std::slice::from_ref(&p), "Event");
+ assert!(!h.contains("
"), "sentences became a table: {h}");
+ assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}");
+}
+
+/// A continuation can drift into a grid column covered by the preceding cell's span.
+/// It must remain available as paragraph text if that slot never becomes an output cell.
+#[test]
+fn table_spans_do_not_consume_text_they_do_not_emit() {
+ let mut blocks = vec![
+ Block { size: 12.0, ..block("ABCDEFGHIJK", 72.1, 697.0, 151.3, 710.8) },
+ Block { size: 12.0, color: [0.0, 0.0, 1.0], ..block("LOST", 150.4, 697.0, 179.2, 710.8) },
+ Block { size: 12.0, ..block("R0", 250.0, 697.0, 264.4, 710.8) },
+ ];
+ for row in 1..=4 {
+ let y = 697.0 - f64::from(row) * 50.0;
+ for (text, left, right) in [(format!("L{row}"), 72.1, 86.5), (format!("M{row}"), 146.4, 160.8), (format!("R{row}"), 250.0, 264.4)] {
+ blocks.push(Block { size: 12.0, ..block(&text, left, y, right, y + 13.8) });
+ }
+ }
+ let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() };
+ let (tables, consumed) = tables(&p.blocks);
+ assert_eq!(tables.len(), 1);
+ assert_eq!(tables[0].rows[0][0].span, 2);
+ assert!(!consumed[1], "a covered slot did not emit its text");
+ assert!(consumed.iter().enumerate().all(|(index, used)| index == 1 || *used));
+ let outputs = [
+ html(std::slice::from_ref(&p), "Continuations"),
+ part(&docx(std::slice::from_ref(&p), "Continuations"), "word/document.xml"),
+ rtf(std::slice::from_ref(&p)),
+ part(&pptx(std::slice::from_ref(&p), "Continuations"), "ppt/slides/slide1.xml"),
+ ];
+ for output in outputs {
+ for b in &p.blocks {
+ assert_eq!(output.matches(&b.text).count(), 1, "{} must survive exactly once", b.text);
+ }
+ }
+}
+
+/// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table
+/// around it stays a table.
+#[test]
+fn a_superscript_beside_a_number_keeps_its_table() {
+ let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)];
+ for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd"), ("4", "th")].iter().enumerate() {
+ let y = 680.0 - 20.0 * n as f64;
+ blocks.push(block(row, 100.0, y, 106.0, y + 12.0));
+ blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) });
+ blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0));
+ }
+ let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
+ let h = html(std::slice::from_ref(&p), "Prizes");
+ assert!(h.contains(""), "the prize table was lost: {h}");
+ assert!(h.contains("30,000") && h.contains("15,000"), "{h}");
+ let (tables, consumed) = tables(&p.blocks);
+ assert!(consumed.iter().all(|used| *used), "ordinary table continuations stay in their cells");
+ let cells: Vec<&str> = tables.iter().flat_map(|t| &t.rows).flatten().map(|c| c.text.as_str()).collect();
+ assert!(["st", "nd", "rd", "th"].iter().all(|suffix| cells.contains(suffix)));
+}
+
+#[test]
+fn two_column_body_text_is_not_mistaken_for_a_table() {
+ let para = |t: &str, x: f64, y: f64| Block {
+ text: t.into(),
+ rect: [x, y, x + 200.0, y + 90.0], // nine lines tall: body text, not a cell
+ size: 11.0,
+ bold: false,
+ italic: false,
+ color: [0.0; 3],
+ rotation: 0.0,
+ space_around: [false; 2],
+ };
+ let p = Page {
+ width: 612.0,
+ height: 792.0,
+ blocks: vec![
+ para("Left column text of the page, long enough to be a flowing paragraph.", 72.0, 500.0),
+ para("Right column text of the page, long enough to be a flowing paragraph.", 320.0, 500.0),
+ para("Second left block, still a tall flowing paragraph rather than a cell.", 72.0, 400.0),
+ para("Second right block, still a tall flowing paragraph rather than a cell.", 320.0, 400.0),
+ ],
+ images: Vec::new(),
+ background: None,
+ };
+ let h = html(&[p], "Paper");
+ assert!(!h.contains(""), "tall two-column text must stay paragraphs: {h}");
+}
+
+/// #1936: every picture Word is told to draw (`r:embed`) names a relationship whose target is a
+/// file in the package, with a content type; JPEG pictures too, inline or placed.
+#[test]
+fn word_pictures_resolve_to_media_in_the_package() {
+ let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() };
+ for placed in [false, true] {
+ let mut p = page();
+ p.images = vec![jpeg.clone()];
+ if placed {
+ p.images[0].rect = [72.0, 640.0, 272.0, 790.0];
+ p.blocks[0].rect = [72.0, 700.0, 300.0, 760.0];
+ }
+ let zip = docx(&[p], "T");
+ let xml = part(&zip, "word/document.xml");
+ assert_eq!(xml.contains(" Vec> {
+ let body = xml.split("").map(|s| s.split_once("").map_or(s, |(_, after)| after)).collect::();
+ body.split("")
+ .skip(1)
+ .map(|p| {
+ let p = p.split_once("").map_or(p, |(inside, _)| inside);
+ p.split("")
+ .skip(1)
+ .filter_map(|r| {
+ let text = r.split_once("")?.1.split_once("")?.0;
+ Some((text.to_string(), r.contains("")))
+ })
+ .collect::>()
+ })
+ .filter(|runs| !runs.is_empty())
+ .collect()
+}
+
+fn runs(r: &[(&str, bool)]) -> Vec<(String, bool)> {
+ r.iter().map(|(t, i)| (t.to_string(), *i)).collect()
+}
+
+/// "Before *Chevron* after." on one line: the roman text, the italic case name and the roman
+/// text after it, the way reading blocks split a sentence at its style changes (#4158). The
+/// italic face stands a little taller than the roman one around it.
+fn italic_sentence() -> Vec {
+ vec![
+ block("Before", 72.0, 700.0, 110.0, 712.0),
+ Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.6) },
+ block("after.", 161.0, 700.0, 190.0, 712.0),
+ ]
+}
+
+/// #4158: a run in another style inside a sentence stays in that sentence's paragraph, as a
+/// run in its own style, in every Office format.
+#[test]
+fn an_italic_word_stays_in_its_sentence() {
+ let mut blocks = italic_sentence();
+ blocks.push(block("The next line is a paragraph of its own.", 72.0, 684.0, 300.0, 696.0));
+ let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
+ let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
+ assert_eq!(
+ word_paragraphs(&xml),
+ vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("The next line is a paragraph of its own.", false)])],
+ "{xml}"
+ );
+ let h = html(std::slice::from_ref(&p), "Opinion");
+ assert!(h.contains("Before Chevron after.
"), "{h}");
+ let r = rtf(std::slice::from_ref(&p));
+ assert!(r.contains("{\\pard\\fs24 Before {\\fs24\\b0\\i\\cf0 Chevron }{\\fs24\\b0\\i0\\cf0 after.}\\par}"), "{r}");
+ // The longest line is the slide's title; the sentence is one paragraph of the body.
+ let pxml = part(&pptx(std::slice::from_ref(&p), "Opinion"), "ppt/slides/slide1.xml");
+ assert!(
+ pxml.contains("Before Chevron after."),
+ "{pxml}"
+ );
+}
+
+/// #4158: the runs keep their order along the line whatever order the blocks come in, and the
+/// paragraph keeps its place on the page, though the italic run stands taller than the first.
+#[test]
+fn a_sentence_keeps_its_reading_order() {
+ let mut blocks = vec![block("Then the next line.", 72.0, 684.0, 200.0, 696.0)];
+ blocks.extend(italic_sentence().into_iter().rev());
+ let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
+ let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
+ assert_eq!(
+ word_paragraphs(&xml),
+ vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("Then the next line.", false)])],
+ "{xml}"
+ );
+}
+
+/// The text of a block leaves out the spaces at its ends, and the box of a run often takes in
+/// its trailing space, so runs can touch. The source's own space still separates them; runs
+/// that touch with no space between them (a superscript) stay joined.
+#[test]
+fn runs_keep_the_spaces_the_source_had_between_them() {
+ let blocks = vec![
+ Block { space_around: [false, true], ..block("Before", 72.0, 700.0, 113.0, 712.0) },
+ Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.0) },
+ Block { space_around: [true, false], ..block("after.", 158.0, 700.0, 190.0, 712.0) },
+ block("E = mc", 72.0, 660.0, 110.0, 672.0),
+ Block { size: 8.0, ..block("2", 110.0, 665.0, 115.0, 673.0) },
+ ];
+ let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
+ let xml = part(&docx(std::slice::from_ref(&p), "Spaces"), "word/document.xml");
+ assert_eq!(
+ word_paragraphs(&xml),
+ vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("E = mc", false), ("2", false)])],
+ "{xml}"
+ );
+}
+/// Text turned another way (a label up the margin) is not a run of the line it touches.
+#[test]
+fn turned_text_is_not_a_run_of_the_line_beside_it() {
+ let blocks = vec![block("Before", 72.0, 700.0, 110.0, 712.0), Block { rotation: 90.0, ..block("SIDE", 111.0, 690.0, 123.0, 720.0) }];
+ let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
+ let h = html(std::slice::from_ref(&p), "Turned");
+ assert!(h.contains("Before
") && h.contains("SIDE
"), "{h}");
+}
+/// A block that starts where the line above ends is the next line, not a run of it.
+#[test]
+fn a_block_on_the_next_line_starts_a_paragraph() {
+ let blocks = vec![
+ block("The first line ends here", 72.0, 700.0, 190.0, 712.0),
+ Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) },
+ ];
+ let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
+ let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml");
+ assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}");
+}
+
+/// A superscript beside a table cell stays in the table: table detection consumes it before
+/// paragraphs are formed, and a sentence after the table still keeps its italic run.
+#[test]
+fn table_cells_are_not_runs_of_a_paragraph() {
+ let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)];
+ for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() {
+ let y = 680.0 - 20.0 * n as f64;
+ blocks.push(block(row, 100.0, y, 106.0, y + 12.0));
+ blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) });
+ blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0));
+ }
+ blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b }));
+ let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
+ let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml");
+ assert_eq!(xml.matches("").count(), 1, "{xml}");
+ for cell in ["st", "nd", "rd", "30,000"] {
+ assert_eq!(xml.matches(&format!("preserve\">{cell}")).count(), 1, "{cell}: {xml}");
+ }
+ // The cells are all in the table: the only flowing paragraph is the sentence.
+ assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}");
+}
+
+/// A bold lead-in followed by body text on its line is body text, not a heading; a heading
+/// with a smaller footnote marker after it stays a heading.
+#[test]
+fn a_paragraph_is_a_heading_only_when_its_text_is() {
+ let blocks = vec![
+ Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) },
+ Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) },
+ Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) },
+ block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0),
+ ];
+ let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
+ let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml");
+ assert_eq!(xml.matches("").count(), 1, "{xml}");
+ assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}");
+ assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}");
+}