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,
});
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,
};
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,
},
],
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 }
})
.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,
}
})
})
.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 }
}
/// #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}");
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}");
}
/// #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}");
}
#[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,
};
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: vec![0xFF, 0xD8, 0xFF, 0xD9], ..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("