refactor(export): move lib.rs's tests to tests.rs

This commit is contained in:
aiongg
2026-10-26 20:51:19 +00:00
parent 79d72038f1
commit e0033d3989
2 changed files with 781 additions and 782 deletions
+1 -782
View File
@@ -1066,785 +1066,4 @@ pub fn rtf(pages: &[Page]) -> String {
}
#[cfg(test)]
mod tests {
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 <body> 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<usize> = ["<h1>Annual Report</h1>", "<h2>Overview</h2>", "<p>Some &lt;body&gt; text &amp;", "<img", "<p>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("<hr>").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("<w:tbl>"));
assert!(!xml.contains("w:val=\"single\""), "text alignment is not evidence of a border: {xml}");
assert!(xml.contains("<w:insideH w:val=\"nil\"/>"));
}
#[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("<w:gridCol w:w=\"1000\"/><w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"3000\"/>"), "{xml}");
let widths: Vec<Vec<&str>> =
xml.split("<w:tr>").skip(1).map(|row| row.split("<w:tcW w:w=\"").skip(1).map(|cell| cell.split('"').next().unwrap()).collect()).collect();
assert_eq!(widths, vec![vec!["1000", "2000", "3000"], vec!["1000", "5000"], vec!["3000", "3000"], vec!["6000"], vec!["1000", "0", "0"]]);
}
#[test]
fn word_keeps_a_page_with_a_clear_image_and_big_title_in_flow() {
// page() has a picture between paragraphs and a 24 pt title over 11 pt body text, but
// nothing overlaps: it must stay flowing paragraphs with the image inline.
let xml = part(&docx(&[page()], "Report"), "word/document.xml");
assert!(!xml.contains("position:absolute"), "nothing overlaps, so nothing floats: {xml}");
assert!(xml.contains("<wp:inline>"), "the picture stays inline: {xml}");
assert!(xml.contains("<w:pStyle w:val=\"Heading1\"/>"), "the title stays a heading: {xml}");
let (title, body) = (xml.find(">Annual Report</w:t>"), 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("<wp:inline>"), "images must not displace invoice text");
assert!(!xml.contains("<w:tbl>"), "the source text boxes must keep their positions");
assert_eq!(xml.matches(">DRAFT</w:t>").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</w:t>").next().unwrap().rsplit("<v:rect ").next().unwrap();
assert!(watermark.contains("z-index:-2"), "watermark is behind foreground graphics");
// The picture is a DrawingML anchor behind the text (a VML rectangle shows no picture in Word).
assert!(!xml.contains("<v:imagedata"), "{xml}");
assert!(xml.contains("<wp:anchor ") && xml.contains("behindDoc=\"1\"") && xml.contains("<a:blip r:embed=\"rIdImg1\"/>"), "{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("<w:sectPr>").count(), 2);
assert!(xml.contains("<w:pgSz w:w=\"8000\" w:h=\"10000\"/>"));
assert_eq!(xml.matches("id=\"Text1\"").count(), 1);
assert_eq!(xml.matches(">Meter</w:t>").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("<w:background w:color=\"008080\"/><w:body>"), "{xml}");
assert!(part(&zip, "word/settings.xml").contains("<w:displayBackgroundShape/>"));
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("<w:background"), "{why}");
assert!(!zip.windows(b"word/settings.xml".len()).any(|w| w == b"word/settings.xml"), "{why}");
}
}
/// One part of a package written by [`Zip`], inflated.
pub(crate) fn part(zip: &[u8], name: &str) -> 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</w:t>"), "{xml}");
assert!(!xml.chars().any(|c| !allowed(c)));
assert!(xml.contains("<w:pgSz w:w=\"12240\" w:h=\"31680\"/>"), "clamped to 22 in: {xml}");
assert!(xml.contains("<w:sz w:val=\"24\"/>"), "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("<w:pgMar w:top=\"500\" w:right=\"250\" w:bottom=\"500\" w:left=\"250\""), "{xml}");
}
#[test]
fn text_colour_survives_every_format() {
// #526: a dark green heading (0.05 0.23 0.18 rg) came out black in Word.
let mut p = page();
p.blocks[1].color = [0.05, 0.23, 0.18];
p.blocks[3].color = [f64::NAN, 2.0, -1.0];
let xml = part(&docx(&[p.clone()], "Colour"), "word/document.xml");
assert!(xml.contains("<w:color w:val=\"0D3B2E\"/>"), "{xml}");
assert!(xml.contains("<w:color w:val=\"00FF00\"/>"), "out-of-range values clamp: {xml}");
// Black text keeps Word's default colour.
assert_eq!(xml.matches("<w:color ").count(), 2, "{xml}");
let h = html(&[p.clone()], "Colour");
assert!(h.contains("<h1 style=\"color:#0D3B2E\">Annual Report</h1>"), "{h}");
assert!(h.contains("<p style=\"color:#00FF00\">"), "{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("<a:srgbClr val=\"0D3B2E\"/>"), "{pxml}");
assert!(pxml.contains("<a:srgbClr val=\"00FF00\"/>"), "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("<w:color w:val=\"FF0000\"/>"), "{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("<table>"), "{h}");
assert!(h.contains("<td colspan=\"2\">Totals</td>"), "spanning cell: {h}");
assert_eq!(h.matches("<tr>").count(), 4, "{h}");
assert!(h.contains("<td></td>"), "the hole is an empty cell: {h}");
assert!(h.contains("<p>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("<w:tblGrid>"));
assert!(table_xml.contains("<w:gridSpan w:val=\"2\"/>"), "{table_xml}");
assert_eq!(table_xml.matches("<w:tr>").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("<a:tbl>"));
assert!(slide_xml.contains("gridSpan=\"2\""));
assert_eq!(slide_xml.matches("<a:tr ").count(), 4);
}
#[test]
fn a_staircase_of_blocks_is_not_a_huge_table() {
// Row k holds blocks in columns k and k + 1: every column has two members and every row
// two columns, so without a cap this would be one table of (n / 2)² cells.
let n = 4000;
let blocks: Vec<Block> = (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("<w:gridCol ").count();
assert_eq!(grid_cols(&grid(63)), 63, "63 columns is still a table");
// Word refuses to open a document with a 64-column table, so that grid stays text.
assert_eq!(grid_cols(&grid(64)), 0);
}
fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> 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("<table>"), "running text became a table: {h}");
// The link is a run of its sentence, in its own colour (#4158).
assert!(h.contains("<p>6.3. 2. All content shown at <span style=\"color:#0563C1\">http</span>"), "{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("<table>"), "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("<table>"), "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("<table>"), "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("<wp:anchor "), placed, "{xml}");
let rels = part(&zip, "word/_rels/document.xml.rels");
let embed = xml.split("r:embed=\"").nth(1).and_then(|r| r.split('"').next()).unwrap();
let rel = rels.split(&format!("Id=\"{embed}\"")).nth(1).unwrap();
assert!(rel.contains("relationships/image\""), "{rels}");
let target = rel.split("Target=\"").nth(1).and_then(|t| t.split('"').next()).unwrap();
assert_eq!(target, "media/image1.jpg");
assert!(zip.windows(21).any(|w| w == b"word/media/image1.jpg"));
assert!(part(&zip, "[Content_Types].xml").contains("Extension=\"jpg\" ContentType=\"image/jpeg\""));
assert!(xml.matches("<wp:docPr ").count() == 1 && !xml.contains("<v:imagedata"));
}
}
#[test]
fn word_export_bounds_the_number_of_pictures_and_skips_empty_ones() {
let mut p = page();
let one = p.images[0].clone();
p.images = vec![Image { bytes: [].as_slice().into(), ..one.clone() }];
p.images.extend(std::iter::repeat_n(one, MAX_MEDIA + 10));
let zip = docx(&[p], "T");
let rels = part(&zip, "word/_rels/document.xml.rels");
assert_eq!(rels.matches("relationships/image\"").count(), MAX_MEDIA);
}
/// The flowing paragraphs of a Word document (table cells left out), each as its runs' text
/// and whether the run is italic.
fn word_paragraphs(xml: &str) -> Vec<Vec<(String, bool)>> {
let body = xml.split("<w:tbl>").map(|s| s.split_once("</w:tbl>").map_or(s, |(_, after)| after)).collect::<String>();
body.split("<w:p>")
.skip(1)
.map(|p| {
let p = p.split_once("</w:p>").map_or(p, |(inside, _)| inside);
p.split("<w:r>")
.skip(1)
.filter_map(|r| {
let text = r.split_once("<w:t xml:space=\"preserve\">")?.1.split_once("</w:t>")?.0;
Some((text.to_string(), r.contains("<w:i/>")))
})
.collect::<Vec<_>>()
})
.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<Block> {
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("<p>Before <em>Chevron </em>after.</p>"), "{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("<a:p><a:r><a:rPr sz=\"1200\"></a:rPr><a:t>Before </a:t></a:r><a:r><a:rPr sz=\"1200\" i=\"1\"></a:rPr><a:t>Chevron </a:t></a:r><a:r><a:rPr sz=\"1200\"></a:rPr><a:t>after.</a:t></a:r></a:p>"),
"{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("<p>Before</p>") && h.contains("<p>SIDE</p>"), "{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("<w:tbl>").count(), 1, "{xml}");
for cell in ["st", "nd", "rd", "30,000"] {
assert_eq!(xml.matches(&format!("preserve\">{cell}</w:t>")).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("<w:pStyle w:val=\"Heading1\"/>").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;
+780
View File
@@ -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 <body> 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<usize> = ["<h1>Annual Report</h1>", "<h2>Overview</h2>", "<p>Some &lt;body&gt; text &amp;", "<img", "<p>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("<hr>").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("<w:tbl>"));
assert!(!xml.contains("w:val=\"single\""), "text alignment is not evidence of a border: {xml}");
assert!(xml.contains("<w:insideH w:val=\"nil\"/>"));
}
#[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("<w:gridCol w:w=\"1000\"/><w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"3000\"/>"), "{xml}");
let widths: Vec<Vec<&str>> =
xml.split("<w:tr>").skip(1).map(|row| row.split("<w:tcW w:w=\"").skip(1).map(|cell| cell.split('"').next().unwrap()).collect()).collect();
assert_eq!(widths, vec![vec!["1000", "2000", "3000"], vec!["1000", "5000"], vec!["3000", "3000"], vec!["6000"], vec!["1000", "0", "0"]]);
}
#[test]
fn word_keeps_a_page_with_a_clear_image_and_big_title_in_flow() {
// page() has a picture between paragraphs and a 24 pt title over 11 pt body text, but
// nothing overlaps: it must stay flowing paragraphs with the image inline.
let xml = part(&docx(&[page()], "Report"), "word/document.xml");
assert!(!xml.contains("position:absolute"), "nothing overlaps, so nothing floats: {xml}");
assert!(xml.contains("<wp:inline>"), "the picture stays inline: {xml}");
assert!(xml.contains("<w:pStyle w:val=\"Heading1\"/>"), "the title stays a heading: {xml}");
let (title, body) = (xml.find(">Annual Report</w:t>"), 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("<wp:inline>"), "images must not displace invoice text");
assert!(!xml.contains("<w:tbl>"), "the source text boxes must keep their positions");
assert_eq!(xml.matches(">DRAFT</w:t>").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</w:t>").next().unwrap().rsplit("<v:rect ").next().unwrap();
assert!(watermark.contains("z-index:-2"), "watermark is behind foreground graphics");
// The picture is a DrawingML anchor behind the text (a VML rectangle shows no picture in Word).
assert!(!xml.contains("<v:imagedata"), "{xml}");
assert!(xml.contains("<wp:anchor ") && xml.contains("behindDoc=\"1\"") && xml.contains("<a:blip r:embed=\"rIdImg1\"/>"), "{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("<w:sectPr>").count(), 2);
assert!(xml.contains("<w:pgSz w:w=\"8000\" w:h=\"10000\"/>"));
assert_eq!(xml.matches("id=\"Text1\"").count(), 1);
assert_eq!(xml.matches(">Meter</w:t>").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("<w:background w:color=\"008080\"/><w:body>"), "{xml}");
assert!(part(&zip, "word/settings.xml").contains("<w:displayBackgroundShape/>"));
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("<w:background"), "{why}");
assert!(!zip.windows(b"word/settings.xml".len()).any(|w| w == b"word/settings.xml"), "{why}");
}
}
/// One part of a package written by [`Zip`], inflated.
pub(crate) fn part(zip: &[u8], name: &str) -> 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</w:t>"), "{xml}");
assert!(!xml.chars().any(|c| !allowed(c)));
assert!(xml.contains("<w:pgSz w:w=\"12240\" w:h=\"31680\"/>"), "clamped to 22 in: {xml}");
assert!(xml.contains("<w:sz w:val=\"24\"/>"), "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("<w:pgMar w:top=\"500\" w:right=\"250\" w:bottom=\"500\" w:left=\"250\""), "{xml}");
}
#[test]
fn text_colour_survives_every_format() {
// #526: a dark green heading (0.05 0.23 0.18 rg) came out black in Word.
let mut p = page();
p.blocks[1].color = [0.05, 0.23, 0.18];
p.blocks[3].color = [f64::NAN, 2.0, -1.0];
let xml = part(&docx(&[p.clone()], "Colour"), "word/document.xml");
assert!(xml.contains("<w:color w:val=\"0D3B2E\"/>"), "{xml}");
assert!(xml.contains("<w:color w:val=\"00FF00\"/>"), "out-of-range values clamp: {xml}");
// Black text keeps Word's default colour.
assert_eq!(xml.matches("<w:color ").count(), 2, "{xml}");
let h = html(&[p.clone()], "Colour");
assert!(h.contains("<h1 style=\"color:#0D3B2E\">Annual Report</h1>"), "{h}");
assert!(h.contains("<p style=\"color:#00FF00\">"), "{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("<a:srgbClr val=\"0D3B2E\"/>"), "{pxml}");
assert!(pxml.contains("<a:srgbClr val=\"00FF00\"/>"), "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("<w:color w:val=\"FF0000\"/>"), "{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("<table>"), "{h}");
assert!(h.contains("<td colspan=\"2\">Totals</td>"), "spanning cell: {h}");
assert_eq!(h.matches("<tr>").count(), 4, "{h}");
assert!(h.contains("<td></td>"), "the hole is an empty cell: {h}");
assert!(h.contains("<p>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("<w:tblGrid>"));
assert!(table_xml.contains("<w:gridSpan w:val=\"2\"/>"), "{table_xml}");
assert_eq!(table_xml.matches("<w:tr>").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("<a:tbl>"));
assert!(slide_xml.contains("gridSpan=\"2\""));
assert_eq!(slide_xml.matches("<a:tr ").count(), 4);
}
#[test]
fn a_staircase_of_blocks_is_not_a_huge_table() {
// Row k holds blocks in columns k and k + 1: every column has two members and every row
// two columns, so without a cap this would be one table of (n / 2)² cells.
let n = 4000;
let blocks: Vec<Block> = (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("<w:gridCol ").count();
assert_eq!(grid_cols(&grid(63)), 63, "63 columns is still a table");
// Word refuses to open a document with a 64-column table, so that grid stays text.
assert_eq!(grid_cols(&grid(64)), 0);
}
fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> 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("<table>"), "running text became a table: {h}");
// The link is a run of its sentence, in its own colour (#4158).
assert!(h.contains("<p>6.3. 2. All content shown at <span style=\"color:#0563C1\">http</span>"), "{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("<table>"), "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("<table>"), "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("<table>"), "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("<wp:anchor "), placed, "{xml}");
let rels = part(&zip, "word/_rels/document.xml.rels");
let embed = xml.split("r:embed=\"").nth(1).and_then(|r| r.split('"').next()).unwrap();
let rel = rels.split(&format!("Id=\"{embed}\"")).nth(1).unwrap();
assert!(rel.contains("relationships/image\""), "{rels}");
let target = rel.split("Target=\"").nth(1).and_then(|t| t.split('"').next()).unwrap();
assert_eq!(target, "media/image1.jpg");
assert!(zip.windows(21).any(|w| w == b"word/media/image1.jpg"));
assert!(part(&zip, "[Content_Types].xml").contains("Extension=\"jpg\" ContentType=\"image/jpeg\""));
assert!(xml.matches("<wp:docPr ").count() == 1 && !xml.contains("<v:imagedata"));
}
}
#[test]
fn word_export_bounds_the_number_of_pictures_and_skips_empty_ones() {
let mut p = page();
let one = p.images[0].clone();
p.images = vec![Image { bytes: [].as_slice().into(), ..one.clone() }];
p.images.extend(std::iter::repeat_n(one, MAX_MEDIA + 10));
let zip = docx(&[p], "T");
let rels = part(&zip, "word/_rels/document.xml.rels");
assert_eq!(rels.matches("relationships/image\"").count(), MAX_MEDIA);
}
/// The flowing paragraphs of a Word document (table cells left out), each as its runs' text
/// and whether the run is italic.
fn word_paragraphs(xml: &str) -> Vec<Vec<(String, bool)>> {
let body = xml.split("<w:tbl>").map(|s| s.split_once("</w:tbl>").map_or(s, |(_, after)| after)).collect::<String>();
body.split("<w:p>")
.skip(1)
.map(|p| {
let p = p.split_once("</w:p>").map_or(p, |(inside, _)| inside);
p.split("<w:r>")
.skip(1)
.filter_map(|r| {
let text = r.split_once("<w:t xml:space=\"preserve\">")?.1.split_once("</w:t>")?.0;
Some((text.to_string(), r.contains("<w:i/>")))
})
.collect::<Vec<_>>()
})
.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<Block> {
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("<p>Before <em>Chevron </em>after.</p>"), "{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("<a:p><a:r><a:rPr sz=\"1200\"></a:rPr><a:t>Before </a:t></a:r><a:r><a:rPr sz=\"1200\" i=\"1\"></a:rPr><a:t>Chevron </a:t></a:r><a:r><a:rPr sz=\"1200\"></a:rPr><a:t>after.</a:t></a:r></a:p>"),
"{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("<p>Before</p>") && h.contains("<p>SIDE</p>"), "{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("<w:tbl>").count(), 1, "{xml}");
for cell in ["st", "nd", "rd", "30,000"] {
assert_eq!(xml.matches(&format!("preserve\">{cell}</w:t>")).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("<w:pStyle w:val=\"Heading1\"/>").count(), 1, "{xml}");
assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}");
assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}");
}