diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index a45bec496..f16af6a1f 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -40,9 +40,31 @@ pub struct Page { pub images: Vec, } +/// One cell of a detected table. `span` is how many grid columns the cell covers (a header +/// row that stretches over sub-columns). +#[derive(Clone, Debug, PartialEq)] +pub struct Cell { + pub text: String, + pub size: f64, + pub bold: bool, + pub italic: bool, + pub span: usize, +} + +/// A table detected from the page's text layout: rows of cells, and the left edge of each +/// grid column (user space, y up) so the writers can size the columns. +#[derive(Clone, Debug, PartialEq)] +pub struct Table { + pub rect: [f64; 4], + pub cols: Vec, + pub rows: Vec>, +} + /// What goes into the output, in order. enum Item<'a> { Para(&'a Block, u8), + /// Owned: tables are detected per page inside [`items`], so they cannot borrow. + Table(Table), Img(&'a Image), PageBreak, } @@ -72,6 +94,176 @@ fn level(b: &Block, body: f64) -> u8 { } } +/// How far two cell left edges may drift (points) and still be the same grid column. +const COL_TOL: f64 = 4.0; +/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. +fn is_cell_like(b: &Block) -> bool { + (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() +} + +/// 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, +/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). +fn tables(blocks: &[Block]) -> (Vec, Vec) { + 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(); + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) + for (x, i) in xs { + match cols.last_mut() { + Some((e, m)) if (x - *e).abs() <= COL_TOL => { + *e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64; + m.push(i); + } + _ => cols.push((x, vec![i])), + } + } + let cols: Vec<(f64, Vec)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect(); + if cols.len() < 2 { + return (Vec::new(), consumed); + } + // Assign every cell-like block to its nearest column (within tolerance). + let mut rows: Vec> = Vec::new(); // (column index, block index), top to bottom + let mut order: Vec = (0..blocks.len()).collect(); + order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); + for &bi in &order { + let b = &blocks[bi]; + if !is_cell_like(b) { + continue; + } + let Some(ci) = cols.iter().position(|(e, _)| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; + let joins = rows + .last() + .and_then(|row| row.first()) + .map(|(_, fi)| { + let rb = &blocks[*fi]; + let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]); + overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1]) + }) + .unwrap_or(false); + if joins { + if let Some(row) = rows.last_mut() { + row.push((ci, bi)); + } + } else { + rows.push(vec![(ci, bi)]); + } + } + // 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| { + if run.len() < 2 { + run.clear(); + return; + } + let mut used: Vec = run.iter().flatten().map(|(_, i)| *i).collect(); + used.sort_unstable(); + used.dedup(); + let mut edges: Vec = used.iter().filter_map(|&i| cols.iter().position(|(_, m)| m.contains(&i))).map(|ci| cols[ci].0).collect(); + edges.sort_by(|a, b| a.total_cmp(b)); + edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); + let ncols = edges.len(); + let ok = ncols >= 3 || (ncols == 2 && run.len() >= 3); + 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(..) { + for slot in slots.iter_mut() { + *slot = None; + } + for (_ci, bi) in row { + let b = &blocks[bi]; + let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; + let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; + let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, span }; + match &mut slots[slot] { + Some(c) => { + c.text.push(' '); + c.text.push_str(&cell.text); + } + None => slots[slot] = Some(cell), + } + consumed[bi] = true; + } + let mut materialized = Vec::with_capacity(ncols); + let mut column = 0; + while column < ncols { + match slots[column].take() { + Some(mut cell) => { + cell.span = cell.span.min(ncols - column).max(1); + column += cell.span; + materialized.push(cell); + } + None => { + column += 1; + materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, span: 1 }); + } + } + } + while materialized.last().is_some_and(|cell| cell.text.is_empty()) && materialized.iter().any(|cell| cell.span > 1) { + materialized.pop(); + } + rows_out.push(materialized); + } + let rect = [ + edges[0], + used.iter().map(|&i| blocks[i].rect[1]).fold(f64::MAX, f64::min), + right, + used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max), + ]; + out.push(Table { rect, cols: edges, rows: rows_out }); + }; + // 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 + // the table; other single-column rows (captions, following text) stay paragraphs. + let spans_run = |edges: &[f64], bi: usize| -> bool { + let b = &blocks[bi]; + edges.iter().any(|e| (*e - b.rect[0]).abs() <= COL_TOL) && edges.iter().any(|e| *e > b.rect[0] + COL_TOL && *e < b.rect[2] - COL_TOL) + }; + let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec { + let mut es: Vec = run.iter().flatten().filter_map(|(_, i)| cols.iter().position(|(_, m)| m.contains(i))).map(|ci| cols[ci].0).collect(); + es.sort_by(|a, b| a.total_cmp(b)); + es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); + es + }; + let qual: Vec = rows + .iter() + .map(|row| { + let mut cs: Vec = row.iter().map(|(c, _)| *c).collect(); + cs.sort_unstable(); + cs.dedup(); + cs.len() >= 2 + }) + .collect(); + let mut i = 0; + while i < rows.len() { + if !qual[i] { + i += 1; + continue; + } + let start = i; + while i < rows.len() && qual[i] { + i += 1; + } + let end = i; // run = rows[start..end] + let mut pieces: Vec> = Vec::new(); + if start > 0 && rows[start - 1].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[start - 1][0].1) { + pieces.push(rows[start - 1].clone()); + } + pieces.extend(rows[start..end].iter().cloned()); + 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); + } + (out, consumed) +} + fn items(pages: &[Page]) -> Vec> { let body = body_size(pages); let mut out = Vec::new(); @@ -79,8 +271,16 @@ fn items(pages: &[Page]) -> Vec> { if i > 0 { out.push(Item::PageBreak); } + let (tables, consumed) = tables(&p.blocks); // Blocks and images by their top edge, top to bottom. - let mut parts: Vec<(f64, f64, Item)> = p.blocks.iter().map(|b| (b.rect[3], b.rect[0], Item::Para(b, level(b, body)))).collect(); + let mut parts: Vec<(f64, f64, Item)> = p + .blocks + .iter() + .enumerate() + .filter(|(bi, _)| !consumed.get(*bi).copied().unwrap_or(false)) + .map(|(_, b)| (b.rect[3], b.rect[0], Item::Para(b, level(b, body)))) + .collect(); + parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); out.extend(parts.into_iter().map(|x| x.2)); @@ -105,10 +305,13 @@ fn base64(data: &[u8]) -> String { s } -/// One HTML file: headings and paragraphs, images inline, a rule between pages. +/// One HTML file: headings and paragraphs, tables as real `
` elements, images inline, a +/// rule between pages. pub fn html(pages: &[Page], title: &str) -> String { let mut s = format!( - "\n\n\n\n{}\n\n\n\n", + "\n\n\n\n{}\n\n\n\n", esc(title) ); for it in items(pages) { @@ -126,6 +329,25 @@ pub fn html(pages: &[Page], title: &str) -> String { l => s.push_str(&format!("{t}\n")), } } + Item::Table(t) => { + s.push_str("
\n"); + for row in &t.rows { + s.push_str(""); + for c in row { + let mut body = esc(&c.text); + if c.italic { + body = format!("{body}"); + } + if c.bold { + body = format!("{body}"); + } + let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; + s.push_str(&format!("{body}")); + } + s.push_str("\n"); + } + s.push_str("
\n"); + } Item::Img(im) => { let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); @@ -137,24 +359,73 @@ pub fn html(pages: &[Page], title: &str) -> String { s } +/// A formatted text run (shared by paragraphs and table cells). +fn run_xml(text: &str, size: f64, bold: bool, italic: bool) -> String { + let mut rpr = String::new(); + if bold { + rpr.push_str(""); + } + if italic { + rpr.push_str(""); + } + rpr.push_str(&format!("", (size * 2.0).round().clamp(2.0, 3276.0) as i64)); + format!("{rpr}{}", esc(text)) +} + +/// One Word table: explicit single borders (so it renders without a table style), a grid sized +/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes. +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 border = ""; + let mut s = String::from(""); + for b in [ + border, + "", + "", + "", + "", + "", + ] { + s.push_str(b); + } + s.push_str(""); + for w in &widths { + s.push_str(&format!("")); + } + s.push_str(""); + for row in &t.rows { + s.push_str(""); + for c in row { + let span = if c.span > 1 { format!("", c.span) } else { String::new() }; + let w: i64 = widths.iter().take(c.span.min(widths.len())).sum(); + s.push_str(&format!( + "{span}{}", + run_xml(&c.text, c.size, c.bold, c.italic) + )); + } + // Pad the grid so every row covers all columns (Word rejects short rows). + let used: usize = row.iter().map(|c| c.span).sum(); + for _ in used..t.cols.len() { + s.push_str(""); + } + s.push_str(""); + } + s.push_str(""); + s +} + /// A Word document (.docx, Office Open XML): Heading 1/2 and Normal paragraphs, images inline at /// their size on the page, page breaks between pages. pub fn docx(pages: &[Page], title: &str) -> Vec { let mut body = String::new(); let mut media: Vec<(String, &Image)> = Vec::new(); - let run = |b: &Block| { - let mut rpr = String::new(); - if b.bold { - rpr.push_str(""); - } - if b.italic { - rpr.push_str(""); - } - rpr.push_str(&format!("", (b.size * 2.0).round().clamp(2.0, 3276.0) as i64)); - format!("{rpr}{}", esc(&b.text)) - }; + // Word needs a paragraph between adjacent tables and after the last one in the body. + let mut after_table = false; + let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic); for it in items(pages) { - match it { + match &it { Item::Para(b, lvl) => { let style = match lvl { 1 => "", @@ -163,6 +434,12 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { }; body.push_str(&format!("{style}{}", run(b))); } + Item::Table(t) => { + if after_table { + body.push_str(""); + } + body.push_str(&docx_table(t)); + } Item::Img(im) => { let n = media.len() + 1; let name = format!("image{n}.{}", im.ext); @@ -182,6 +459,10 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { } Item::PageBreak => body.push_str(""), } + after_table = matches!(it, Item::Table(_)); + } + if after_table { + body.push_str(""); } // The first page's size and margins of 1 in. let (pw, ph) = pages.first().map_or((612.0, 792.0), |p| (p.width, p.height)); @@ -259,8 +540,8 @@ fn rtf_text(s: &str) -> String { o } -/// Rich Text Format: paragraphs with their sizes and bold/italic, page breaks between pages -/// (images are left out). +/// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks +/// between pages (images are left out). pub fn rtf(pages: &[Page]) -> String { let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); for it in items(pages) { @@ -275,6 +556,35 @@ pub fn rtf(pages: &[Page]) -> String { } s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text))); } + Item::Table(t) => { + let mut edges = t.cols.clone(); + edges.push(t.rect[2]); + let cellx: Vec = edges.iter().skip(1).map(|e| (e * 20.0).round() as i64).collect(); + for row in &t.rows { + s.push_str("\\trowd\\trgaph108"); + for x in cellx.iter().take(row.len().max(1)) { + s.push_str(&format!("\\cellx{x}")); + } + // Fill the row's cells; extend the last \cellx if the row spans wider. + for (i, c) in row.iter().enumerate() { + let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64); + if c.bold { + fmt.push_str("\\b"); + } + if c.italic { + fmt.push_str("\\i"); + } + s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); + if c.span > 1 + && let Some(extra) = cellx.get(i + c.span - 1) + { + s.push_str(&format!("\\cellx{extra}")); + } + } + let _ = cellx.len(); + s.push_str("\\row\n"); + } + } Item::Img(_) => {} Item::PageBreak => s.push_str("\\page\n"), } @@ -340,4 +650,82 @@ mod tests { 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 }; + 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, + }, + ], + images: Vec::new(), + } + } + + #[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(""), "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(&[p]); + assert!(r.contains("\\trowd"), "{r}"); + assert_eq!(r.matches("\\row").count(), 4); + } + + #[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, + }; + 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(), + }; + let h = html(&[p], "Paper"); + assert!(!h.contains("

Totals
"), "tall two-column text must stay paragraphs: {h}"); + } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3d887d104..6b5c936f2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6850,8 +6850,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::docx_is_a_word_package", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, images inline, page breaks; checked with macOS textutil. Not yet: tables, columns, fonts and colours, exact layout mode." +evidence = ["crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] +notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, images inline, page breaks, and detected tables with spans/empty cells; checked with macOS textutil. Not yet: columns, fonts and colours, exact layout mode." [[feature]] id = "create.export-rtf" @@ -6946,8 +6946,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Single HTML file with headings, paragraphs and inline images. Not yet: multi-page folder output, tables, CSS from fonts." +evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] +notes = "Single HTML file with headings, paragraphs, inline images, and detected tables with spans/empty cells. Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text"