diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 1b5ad777a1..22e076bd57 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -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("").count(), 1, "the ruled grid is a table: {xml}"); + let table = xml.split_once("").and_then(|(_, t)| t.split_once("")).map(|(t, _)| t).unwrap(); + assert_eq!(table.matches("").count(), 2, "{table}"); + for word in ["Name", "Value", "Alpha", "One"] { + assert_eq!(table.matches(&format!(">{word}")).count(), 1, "{word} is a cell: {xml}"); + assert_eq!(xml.matches(&format!(">{word}")).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.").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] diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 0cf1886afd..cb703ea809 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -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}; diff --git a/crates/edit/src/rules.rs b/crates/edit/src/rules.rs new file mode 100644 index 0000000000..a5a77903a0 --- /dev/null +++ b/crates/edit/src/rules.rs @@ -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, 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::>()); + let ops = pieces.parse().ops; + + let mut out: Vec<[f64; 4]> = Vec::new(); + let mut ctm = Matrix::IDENTITY; + let mut saved: Vec = 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) +} diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index de739101d5..200779baa9 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -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) diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index ab7451dc0e..7bd7023541 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -31,15 +31,20 @@ fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> format!("{rpr}{}", 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 = 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(""); for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] { - s.push_str(&format!("")); + if t.ruled { + s.push_str(&format!("")); + } else { + s.push_str(&format!("")); + } } s.push_str(""); for w in &widths { diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index d1d074ac61..851cbda753 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -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, pub rows: Vec>, + /// 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], 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 = 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, Vec) { +pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec
, Vec) { + 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
, Vec) { } // Runs of consecutive rows that have two or more distinct columns are tables. let mut out = Vec::new(); - let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec| { + let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec, budget: &mut usize| { if run.len() < 2 { run.clear(); return; @@ -325,12 +393,16 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { 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> = 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)>> = vec![None; ncols]; for row in run.drain(..) { @@ -385,7 +457,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { 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
, Vec) { 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> { 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) diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 49765000d3..04cc027ab1 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -127,7 +127,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { 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() diff --git a/crates/export/src/pptx/tests.rs b/crates/export/src/pptx/tests.rs index 2e1c1bd046..5e619b32f0 100644 --- a/crates/export/src/pptx/tests.rs +++ b/crates/export/src/pptx/tests.rs @@ -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(""), "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"); diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index f630d98d5d..0f10b0af27 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -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(""), "{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("")); @@ -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(""), "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("
"), "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("
"), "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("
"), "tall two-column text must stay paragraphs: {h}"); @@ -675,7 +681,7 @@ fn italic_sentence() -> Vec { 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("

Before

") && h.contains("

SIDE

"), "{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("").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("").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("").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"); +}