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}");
}