Keep ruled tables when exporting a PDF to Word (#5154)

Fixes #5149
This commit is contained in:
NetterSilver authored and GitHub committed 2026-11-08 19:28:07 +08:00
1 parent adbf1dc01f
commit f750589812
9 files changed
+340 -28

No files matched your search

+46
View File
@@ -8326,6 +8326,52 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET";
assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. })));
}
/// #5149: a two-column, two-row table drawn with ruled lines reaches Word as a table with its
/// rules, not as four paragraphs one under another.
#[test]
fn exporting_a_ruled_two_by_two_table_to_word_keeps_the_table() {
let dir = workdir("ruled-table-word");
let content = "0.5 w 20 270 m 300 270 l S 20 245 m 300 245 l S 20 220 m 300 220 l S \
20 220 m 20 270 l S 160 220 m 160 270 l S 300 220 m 300 270 l S \
BT /F1 11 Tf 25 253 Td (Name) Tj 140 0 Td (Value) Tj -140 -25 Td (Alpha) Tj 140 0 Td (One) Tj ET \
BT /F1 11 Tf 20 150 Td (A paragraph after the table.) Tj ET";
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
stream.as_str(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
std::fs::write(dir.join("table.pdf"), pdf_of(&objs)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "table.docx" }));
let zip = std::fs::read(dir.join("table.docx")).unwrap();
let mut at = 0;
let mut xml = String::new();
while zip[at..].starts_with(b"PK\x03\x04") {
let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize;
let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize;
let data = at + 30 + len;
if &zip[at + 30..data] == b"word/document.xml" {
std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap();
break;
}
at = data + size;
}
assert_eq!(xml.matches("<w:tbl>").count(), 1, "the ruled grid is a table: {xml}");
let table = xml.split_once("<w:tbl>").and_then(|(_, t)| t.split_once("</w:tbl>")).map(|(t, _)| t).unwrap();
assert_eq!(table.matches("<w:tr>").count(), 2, "{table}");
for word in ["Name", "Value", "Alpha", "One"] {
assert_eq!(table.matches(&format!(">{word}</w:t>")).count(), 1, "{word} is a cell: {xml}");
assert_eq!(xml.matches(&format!(">{word}</w:t>")).count(), 1, "{word} is written once");
}
assert!(table.contains("w:val=\"single\""), "the drawn rules become borders: {table}");
assert!(!table.contains("A paragraph after the table."));
assert_eq!(xml.matches(">A paragraph after the table.</w:t>").count(), 1);
}
/// #4158: a word set in italic inside a sentence ("Before *Chevron* after.") reaches Word as an
/// italic run of the sentence's paragraph, not as a paragraph of its own between two halves.
#[test]
+2
View File
@@ -43,6 +43,8 @@ pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops};
pub use text::strip_subset_tag;
mod background;
pub use background::page_background;
mod rules;
pub use rules::page_rules;
pub mod objects;
pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects};
+142
View File
@@ -0,0 +1,142 @@
//! A page's ruled lines: the horizontal and vertical strokes (and thin filled bars) its content
//! draws, so exports can tell a ruled table from text that merely lines up (#5149).
use pdfcraft_content::{Matrix, Pieces};
use pdfcraft_cos::Document;
use crate::EditError;
/// How deep `q` may nest (deeper saves are not tracked).
const MAX_SAVES: usize = 64;
/// At most this many rules are returned, and a path keeps at most this many segments, so a
/// hostile page stays bounded.
pub const MAX_RULES: usize = 4096;
/// A segment whose ends differ by at most this much across its run (points) is horizontal or
/// vertical; a filled rectangle at most this thick is a bar drawn as a rule.
const THIN: f64 = 2.0;
/// The page's ruled lines, each as its box `[x0, y0, x1, y1]` in default user space (`x0 <= x1`,
/// `y0 <= y1`; a stroke has zero thickness): horizontal and vertical straight segments of stroked
/// paths (rectangle edges included) and filled rectangles at most two points thick. Curves,
/// slanted lines and paths that are only clipped are left out, and so are the contents of forms.
/// At most [`MAX_RULES`] rules, in content order.
pub fn page_rules(doc: &Document, page: usize) -> Result<Vec<[f64; 4]>, EditError> {
let pages = pdfcraft_model::pages(doc);
let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?;
let streams = crate::text::content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let mut out: Vec<[f64; 4]> = Vec::new();
let mut ctm = Matrix::IDENTITY;
let mut saved: Vec<Matrix> = Vec::new();
let mut lost_saves = 0usize;
// The current path in page space: its straight segments, its rectangles, the current point
// and where the current subpath started.
let mut segments: Vec<[(f64, f64); 2]> = Vec::new();
let mut rects: Vec<[(f64, f64); 4]> = Vec::new();
let mut at: Option<(f64, f64)> = None;
let mut start: Option<(f64, f64)> = None;
for op in &ops {
if out.len() >= MAX_RULES {
break;
}
match op.op.as_slice() {
b"q" => {
if saved.len() < MAX_SAVES {
saved.push(ctm);
} else {
lost_saves = lost_saves.saturating_add(1);
}
}
b"Q" => {
if lost_saves > 0 {
lost_saves -= 1;
} else if let Some(m) = saved.pop() {
ctm = m;
}
}
b"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands) {
ctm = m.then(&ctm);
}
}
b"m" => {
let p = op.nums::<2>().map(|[x, y]| ctm.apply(x, y));
(at, start) = (p, p);
}
b"l" => {
let p = op.nums::<2>().map(|[x, y]| ctm.apply(x, y));
if let (Some(a), Some(b)) = (at, p)
&& segments.len() < MAX_RULES
{
segments.push([a, b]);
}
at = p;
}
b"c" => at = op.nums::<6>().map(|[.., x, y]| ctm.apply(x, y)),
b"v" | b"y" => at = op.nums::<4>().map(|[.., x, y]| ctm.apply(x, y)),
b"h" => {
if let (Some(a), Some(b)) = (at, start)
&& segments.len() < MAX_RULES
{
segments.push([a, b]);
}
at = start;
}
b"re" => {
if let Some([x, y, w, h]) = op.nums::<4>() {
let q = [ctm.apply(x, y), ctm.apply(x + w, y), ctm.apply(x + w, y + h), ctm.apply(x, y + h)];
if rects.len() < MAX_RULES {
rects.push(q);
}
(at, start) = (Some(q[0]), Some(q[0]));
}
}
b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" => {
let closes = matches!(op.op.as_slice(), b"s" | b"b" | b"b*");
if closes && let (Some(a), Some(b)) = (at, start) {
segments.push([a, b]);
}
let edges = rects.iter().flat_map(|q| [[q[0], q[1]], [q[1], q[2]], [q[2], q[3]], [q[3], q[0]]]);
out.extend(segments.iter().copied().chain(edges).filter_map(axis_aligned));
(segments, rects, at, start) = (Vec::new(), Vec::new(), None, None);
}
b"f" | b"F" | b"f*" => {
out.extend(rects.iter().filter_map(thin_bar));
(segments, rects, at, start) = (Vec::new(), Vec::new(), None, None);
}
b"n" => (segments, rects, at, start) = (Vec::new(), Vec::new(), None, None),
_ => {}
}
}
out.truncate(MAX_RULES);
Ok(out)
}
/// The box of a horizontal or vertical segment, or `None` for a slanted, tiny or non-finite one.
fn axis_aligned([a, b]: [(f64, f64); 2]) -> Option<[f64; 4]> {
let r = [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)];
let (w, h) = (r[2] - r[0], r[3] - r[1]);
(r.iter().all(|v| v.is_finite()) && w.min(h) <= THIN && w.max(h) > THIN).then_some(r)
}
/// The box of a filled rectangle drawn as a rule: upright, at most [`THIN`] thick and longer than that.
fn thin_bar(q: &[(f64, f64); 4]) -> Option<[f64; 4]> {
let upright = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() <= 0.01 || (a.1 - b.1).abs() <= 0.01;
if !(upright(q[0], q[1]) && upright(q[1], q[2])) {
return None;
}
let xs = q.map(|p| p.0);
let ys = q.map(|p| p.1);
let r = [
xs.iter().copied().fold(f64::INFINITY, f64::min),
ys.iter().copied().fold(f64::INFINITY, f64::min),
xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
];
let (w, h) = (r[2] - r[0], r[3] - r[1]);
(r.iter().all(|v| v.is_finite()) && w.min(h) <= THIN && w.max(h) > THIN).then_some(r)
}
+3 -1
View File
@@ -313,7 +313,9 @@ impl crate::Document {
}
// The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one.
let background = pdfcraft_edit::page_background(cos, i).ok().flatten();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background }
// The ruled lines tell a drawn table from text that merely lines up (#5149).
let rules = pdfcraft_edit::page_rules(cos, i).unwrap_or_default().into_iter().map(visible_rect).collect();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background, rules }
})
.collect();
(pages, truncated)
+8 -3
View File
@@ -31,15 +31,20 @@ fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) ->
format!("<w:r><w:rPr>{rpr}</w:rPr><w:t xml:space=\"preserve\">{}</w:t></w:r>", esc(text))
}
/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized
/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes.
/// One Word table: single borders when the page's rules draw its grid, explicit absent borders
/// otherwise (alignment alone cannot prove PDF rules), a grid sized from the detected column
/// edges, `gridSpan` for spanning cells, empty cells for holes.
pub(crate) fn docx_table(t: &Table) -> String {
let mut edges = t.cols.clone();
edges.push(t.rect[2]);
let widths: Vec<i64> = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect();
let mut s = String::from("<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/><w:tblBorders>");
for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] {
s.push_str(&format!("<w:{edge} w:val=\"nil\"/>"));
if t.ruled {
s.push_str(&format!("<w:{edge} w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>"));
} else {
s.push_str(&format!("<w:{edge} w:val=\"nil\"/>"));
}
}
s.push_str("</w:tblBorders></w:tblPr><w:tblGrid>");
for w in &widths {
+80 -8
View File
@@ -43,6 +43,9 @@ pub struct Page {
/// The colour the whole page is painted before its content (a page background), as sRGB
/// components in 0..=1; `None` for a page with no background of its own.
pub background: Option<[f64; 3]>,
/// The ruled lines the page draws (horizontal and vertical strokes, thin filled bars), each
/// as its box [x0, y0, x1, y1] on the displayed page in points (y up).
pub rules: Vec<[f64; 4]>,
}
/// One cell of a detected table. `span` is how many grid columns the cell covers (a header
@@ -65,6 +68,9 @@ pub struct Table {
pub rect: [f64; 4],
pub cols: Vec<f64>,
pub rows: Vec<Vec<Cell>>,
/// Whether the page's ruled lines separate every pair of neighbouring cells, so the writers
/// can draw the table's borders.
pub ruled: bool,
}
/// What goes into the output, in order.
@@ -248,10 +254,72 @@ fn is_cell_like(b: &Block) -> bool {
(b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty()
}
/// A rule at most this thick (points) is a line, not a filled area.
const RULE_THICK: f64 = 3.0;
/// Rule comparisons one page may spend deciding whether its tables are ruled; past it a table
/// counts as unruled, so a hostile page of many rules and cells stays quick.
const RULE_BUDGET: usize = 4_000_000;
/// Whether `rules` separate every pair of neighbouring cells of a table whose rows are `rows`
/// (block indices, top to bottom) and whose columns start at `edges` and end at `right`: between
/// two columns a vertical rule beside each row (unless a cell of that row spans across), and
/// between two rows a horizontal rule under each column. Every check spends `budget`.
fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec<usize>], edges: &[f64], right: f64, budget: &mut usize) -> bool {
let vertical: Vec<&[f64; 4]> = rules.iter().filter(|r| r[2] - r[0] <= RULE_THICK && r[3] - r[1] > RULE_THICK).collect();
let horizontal: Vec<&[f64; 4]> = rules.iter().filter(|r| r[3] - r[1] <= RULE_THICK && r[2] - r[0] > RULE_THICK).collect();
let mut find = |set: &[&[f64; 4]], hit: &dyn Fn(&[f64; 4]) -> bool| -> bool {
set.iter().any(|r| {
*budget = budget.saturating_sub(1);
*budget > 0 && hit(r)
})
};
// Each row's vertical span.
let spans: Vec<[f64; 2]> = rows
.iter()
.map(|row| {
let bs = || row.iter().filter_map(|&i| blocks.get(i));
[bs().map(|b| b.rect[1]).fold(f64::INFINITY, f64::min), bs().map(|b| b.rect[3]).fold(f64::NEG_INFINITY, f64::max)]
})
.collect();
for (row, span) in rows.iter().zip(&spans) {
let mid = (span[0] + span[1]) / 2.0;
for pair in edges.windows(2) {
let (lo, hi) = (pair[0], pair[1]);
let cells = || row.iter().filter_map(|&i| blocks.get(i));
if cells().any(|b| b.rect[0] < hi - COL_TOL && b.rect[2] > hi + COL_TOL) {
continue;
}
let clear = |x: f64| !cells().any(|b| b.rect[0] + COL_TOL < x && x < b.rect[2] - COL_TOL);
if !find(&vertical, &|r| {
let x = (r[0] + r[2]) / 2.0;
lo + COL_TOL < x && x <= hi + COL_TOL && r[1] <= mid && mid <= r[3] && clear(x)
}) {
return false;
}
}
}
let mids: Vec<f64> = edges.iter().zip(edges.iter().skip(1).chain(std::iter::once(&right))).map(|(a, b)| (a + b) / 2.0).collect();
for pair in spans.windows(2) {
let (below_top, above_bottom) = (pair[1][1], pair[0][0]);
for &x in &mids {
if !find(&horizontal, &|r| {
let y = (r[1] + r[3]) / 2.0;
below_top - COL_TOL <= y && y <= above_bottom + COL_TOL && r[0] <= x && x <= r[2]
}) {
return false;
}
}
}
true
}
/// Detect tables in a page's blocks from their text layout: columns are left edges shared by
/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and,
/// several short blocks, rows are blocks whose vertical spans overlap. Two columns of two rows
/// are a table only when `rules` (the page's ruled lines) draw its grid. Returns the tables and,
/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow).
pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec<Table>, Vec<bool>) {
let mut budget = RULE_BUDGET;
let mut consumed = vec![false; blocks.len()];
// Candidate columns: cluster the left edges of cell-like blocks.
let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect();
@@ -313,7 +381,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
}
// Runs of consecutive rows that have two or more distinct columns are tables.
let mut out = Vec::new();
let flush = |run: &mut Vec<Vec<(usize, usize)>>, out: &mut Vec<Table>, consumed: &mut Vec<bool>| {
let flush = |run: &mut Vec<Vec<(usize, usize)>>, out: &mut Vec<Table>, consumed: &mut Vec<bool>, budget: &mut usize| {
if run.len() < 2 {
run.clear();
return;
@@ -325,12 +393,16 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
edges.sort_by(f64::total_cmp);
edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL);
let ncols = edges.len();
let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS;
let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL;
let is_ruled = (2..=MAX_COLS).contains(&ncols) && !rules.is_empty() && {
let row_blocks: Vec<Vec<usize>> = run.iter().map(|row| row.iter().map(|(_, i)| *i).collect()).collect();
ruled(blocks, rules, &row_blocks, &edges, right, budget)
};
let ok = (ncols >= 3 || (ncols == 2 && (run.len() >= 3 || is_ruled))) && ncols <= MAX_COLS;
if !ok {
run.clear();
return;
}
let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL;
let mut rows_out = Vec::new();
let mut slots: Vec<Option<(Cell, Vec<usize>)>> = vec![None; ncols];
for row in run.drain(..) {
@@ -385,7 +457,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
right,
used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max),
];
out.push(Table { rect, cols: edges, rows: rows_out });
out.push(Table { rect, cols: edges, rows: rows_out, ruled: is_ruled });
};
// Runs of consecutive multi-column rows are tables. Rows on either side of a run that
// hold a single cell wide enough to span two columns (a header band, a totals row) join
@@ -436,7 +508,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) {
pieces.push(rows[end].clone());
}
flush(&mut pieces, &mut out, &mut consumed);
flush(&mut pieces, &mut out, &mut consumed, &mut budget);
}
(out, consumed)
}
@@ -448,7 +520,7 @@ pub(crate) fn items(pages: &[Page]) -> Vec<Item<'_>> {
if i > 0 {
out.push(Item::PageBreak);
}
let (tables, consumed) = tables(&p.blocks);
let (tables, consumed) = tables(&p.blocks, &p.rules);
// Blocks and images by their top edge, top to bottom.
// A paragraph sits where its first run does: its other runs follow along the same line.
let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed)
+1 -1
View File
@@ -127,7 +127,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec<u8> {
let mut slides: Vec<(String, String)> = Vec::new(); // (slide_xml, slide_rels)
for p in pages {
let (tables, consumed) = crate::tables(&p.blocks);
let (tables, consumed) = crate::tables(&p.blocks, &p.rules);
let title_idx = p
.blocks
.iter()
+4 -1
View File
@@ -46,6 +46,7 @@ fn sample_page() -> Page {
rotation: 0.0,
}],
background: None,
rules: Vec::new(),
}
}
@@ -174,6 +175,7 @@ fn pptx_package_parts_are_complete_and_consistent() {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
};
let bytes = pptx(&[sample_page(), table_page], "Two");
let parts = entries(&bytes);
@@ -278,7 +280,7 @@ fn hostile_sizes_do_not_overflow_the_slide_position() {
rotation: 0.0,
})
.collect();
let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None };
let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None, rules: Vec::new() };
let bytes = pptx(&[p], "Hostile");
let slide = part(&bytes, "ppt/slides/slide1.xml");
assert!(slide.contains("Title") && slide.contains("<p:pic>"), "the slide is still written");
@@ -360,6 +362,7 @@ fn pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title() {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
};
let bytes = pptx(&[p], "Table Check");
let slide_xml = part(&bytes, "ppt/slides/slide1.xml");
+54 -14
View File
@@ -14,7 +14,7 @@ fn varied_page(n: usize) -> Page {
space_around: [false; 2],
})
.collect();
Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }
Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() }
}
fn lookup_steps(f: impl FnOnce()) -> usize {
@@ -69,6 +69,7 @@ fn page() -> Page {
rotation: 0.0,
}],
background: None,
rules: Vec::new(),
}
}
@@ -136,6 +137,7 @@ fn word_table_cell_widths_follow_the_columns_they_span() {
vec![cell("All", 3)],
vec![cell("First", 1)],
],
ruled: false,
};
let xml = docx_table(&table);
assert!(xml.contains("<w:gridCol w:w=\"1000\"/><w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"3000\"/>"), "{xml}");
@@ -360,6 +362,7 @@ fn table_page() -> Page {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
}
}
@@ -376,7 +379,7 @@ fn grid_text_becomes_a_real_table_in_every_format() {
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);
let (tables, _) = tables(&p.blocks, &p.rules);
assert_eq!(tables.len(), 1);
let table_xml = docx_table(&tables[0]);
assert!(table_xml.contains("<w:tblGrid>"));
@@ -421,7 +424,7 @@ fn a_staircase_of_blocks_is_not_a_huge_table() {
})
.collect();
let started = std::time::Instant::now();
let (tables, _) = tables(&blocks);
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());
}
@@ -451,6 +454,7 @@ fn word_opens_tables_up_to_its_column_limit() {
.collect(),
images: Vec::new(),
background: None,
rules: Vec::new(),
};
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");
@@ -486,6 +490,7 @@ fn a_link_inside_a_sentence_is_not_a_table_column() {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
};
let h = html(std::slice::from_ref(&p), "Regulations");
assert!(!h.contains("<table>"), "running text became a table: {h}");
@@ -507,7 +512,7 @@ fn sentences_that_run_on_into_links_are_not_a_table() {
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 p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
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}");
@@ -529,7 +534,7 @@ fn table_spans_do_not_consume_text_they_do_not_emit() {
}
}
let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() };
let (tables, consumed) = tables(&p.blocks);
let (tables, consumed) = tables(&p.blocks, &p.rules);
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");
@@ -558,11 +563,11 @@ fn a_superscript_beside_a_number_keeps_its_table() {
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 p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
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);
let (tables, consumed) = tables(&p.blocks, &p.rules);
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)));
@@ -591,6 +596,7 @@ fn two_column_body_text_is_not_mistaken_for_a_table() {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
};
let h = html(&[p], "Paper");
assert!(!h.contains("<table>"), "tall two-column text must stay paragraphs: {h}");
@@ -675,7 +681,7 @@ fn italic_sentence() -> Vec<Block> {
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 p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
assert_eq!(
word_paragraphs(&xml),
@@ -700,7 +706,7 @@ fn an_italic_word_stays_in_its_sentence() {
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 p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
assert_eq!(
word_paragraphs(&xml),
@@ -721,7 +727,7 @@ fn runs_keep_the_spaces_the_source_had_between_them() {
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 p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let xml = part(&docx(std::slice::from_ref(&p), "Spaces"), "word/document.xml");
assert_eq!(
word_paragraphs(&xml),
@@ -733,7 +739,7 @@ fn runs_keep_the_spaces_the_source_had_between_them() {
#[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 p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
let h = html(std::slice::from_ref(&p), "Turned");
assert!(h.contains("<p>Before</p>") && h.contains("<p>SIDE</p>"), "{h}");
}
@@ -744,7 +750,7 @@ fn a_block_on_the_next_line_starts_a_paragraph() {
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 p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
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}");
}
@@ -761,7 +767,7 @@ fn table_cells_are_not_runs_of_a_paragraph() {
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 p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
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"] {
@@ -781,7 +787,7 @@ fn a_paragraph_is_a_heading_only_when_its_text_is() {
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 p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None, rules: Vec::new() };
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}");
@@ -830,6 +836,7 @@ fn markdown_escapes_markup_and_keeps_emphasis_and_tables() {
],
images: Vec::new(),
background: None,
rules: Vec::new(),
};
let md = markdown(&[p]);
assert!(md.contains("\\- not a list item"), "{md}");
@@ -859,3 +866,36 @@ fn markdown_escapes_markup_and_keeps_emphasis_and_tables() {
assert!(rows.contains(&"| A1 | kWh | 37414.00 |"), "{md}");
assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}");
}
/// #5149: two columns of two rows are a table when ruled lines draw its grid, and the docx keeps
/// its borders; the same text without rules (or with rules that only underline it) stays paragraphs.
#[test]
fn a_ruled_two_by_two_grid_is_a_table() {
let blocks = vec![
block("Name", 25.0, 250.0, 55.0, 261.0),
block("Value", 165.0, 250.0, 195.0, 261.0),
block("Alpha", 25.0, 225.0, 55.0, 236.0),
block("One", 165.0, 225.0, 185.0, 236.0),
];
let grid = vec![
[20.0, 270.0, 300.0, 270.0],
[20.0, 245.0, 300.0, 245.0],
[20.0, 220.0, 300.0, 220.0],
[20.0, 220.0, 20.0, 270.0],
[160.0, 220.0, 160.0, 270.0],
[300.0, 220.0, 300.0, 270.0],
];
let (found, consumed) = tables(&blocks, &grid);
assert_eq!(found.len(), 1);
assert!(found[0].ruled);
assert_eq!(found[0].rows.len(), 2);
assert!(consumed.iter().all(|used| *used));
let p = Page { width: 320.0, height: 300.0, blocks: blocks.clone(), rules: grid, ..Page::default() };
let xml = part(&docx(std::slice::from_ref(&p), "Grid"), "word/document.xml");
assert_eq!(xml.matches("<w:tbl>").count(), 1, "{xml}");
assert!(xml.contains("w:val=\"single\"") && !xml.contains("w:val=\"nil\""), "{xml}");
assert!(tables(&blocks, &[]).0.is_empty(), "alignment alone does not make two by two a table");
let underlines = [[20.0, 245.0, 300.0, 245.0]];
assert!(tables(&blocks, &underlines).0.is_empty(), "a rule between the rows alone is no grid");
}