mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
Word tables get outer borders only where the page rules them (#6626)
A grid drawn with inside lines only no longer gets a box in Word.
This commit is contained in:
1 parent
922f18cd8e
commit
edc5c757d3
6 files changed
+88
-20
No files matched your search
@@ -1327,7 +1327,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses inferred tables, bordered only along the grid lines the page draws, and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
|
||||
.cmd("export.docx")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "html_mode": { "type": "string", "enum": ["rich", "simple"], "default": "rich" } }), &["doc", "path"])),
|
||||
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
|
||||
|
||||
@@ -31,16 +31,19 @@ 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: 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
|
||||
/// One Word table: when the page's rules draw its grid, single inside borders and single outer
|
||||
/// borders on the edges they rule; 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"] {
|
||||
if t.ruled {
|
||||
let [top, left, bottom, right] = t.ruled.unwrap_or_default();
|
||||
let inside = t.ruled.is_some();
|
||||
for (edge, drawn) in [("top", top), ("left", left), ("bottom", bottom), ("right", right), ("insideH", inside), ("insideV", inside)] {
|
||||
if drawn {
|
||||
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\"/>"));
|
||||
|
||||
+47
-11
@@ -68,9 +68,10 @@ 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,
|
||||
/// `Some` when the page's ruled lines separate every pair of neighbouring cells, so the
|
||||
/// writers draw the inside borders; it holds which outer edges (top, left, bottom, right) the
|
||||
/// lines also draw, as only those get a border.
|
||||
pub ruled: Option<[bool; 4]>,
|
||||
}
|
||||
|
||||
/// What goes into the output, in order.
|
||||
@@ -264,8 +265,11 @@ 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 {
|
||||
/// between two rows a horizontal rule under each column. Every check spends `budget`. When they
|
||||
/// do, says which outer edges (top, left, bottom, right) are ruled too: a rule over every column
|
||||
/// above the first row or below the last, or beside every row left of the first column or right
|
||||
/// of the last, no farther from the cells than the next row or column is.
|
||||
fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec<usize>], edges: &[f64], right: f64, budget: &mut usize) -> Option<[bool; 4]> {
|
||||
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 {
|
||||
@@ -295,7 +299,7 @@ fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec<usize>], edges: &[f64
|
||||
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;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,11 +311,41 @@ fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec<usize>], edges: &[f64
|
||||
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;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
let (Some(first), Some(last), Some(&left), Some(&last_col)) = (spans.first(), spans.last(), edges.first(), edges.last()) else {
|
||||
return Some([false; 4]);
|
||||
};
|
||||
// How far the next row or column is: an outer rule farther out than that frames something else.
|
||||
let row_gap = |a: Option<&[f64; 2]>, b: Option<&[f64; 2]>, side: usize| a.zip(b).map_or(0.0, |(a, b)| (a[side] - b[side]).abs());
|
||||
let top_gap = row_gap(Some(first), spans.get(1), 1);
|
||||
let bottom_gap = row_gap(spans.len().checked_sub(2).and_then(|i| spans.get(i)), Some(last), 0);
|
||||
let left_gap = edges.get(1).map_or(0.0, |e| e - left);
|
||||
let right_gap = edges.len().checked_sub(2).and_then(|i| edges.get(i)).map_or(0.0, |e| last_col - e);
|
||||
let mut over_columns = |lo: f64, hi: f64| {
|
||||
mids.iter().all(|&x| {
|
||||
find(&horizontal, &|r| {
|
||||
let y = (r[1] + r[3]) / 2.0;
|
||||
lo <= y && y <= hi && r[0] <= x && x <= r[2]
|
||||
})
|
||||
})
|
||||
};
|
||||
let top = over_columns(first[1] - COL_TOL, first[1] + top_gap + COL_TOL);
|
||||
let bottom = over_columns(last[0] - bottom_gap - COL_TOL, last[0] + COL_TOL);
|
||||
let mut beside_rows = |lo: f64, hi: f64| {
|
||||
spans.iter().all(|span| {
|
||||
let mid = (span[0] + span[1]) / 2.0;
|
||||
find(&vertical, &|r| {
|
||||
let x = (r[0] + r[2]) / 2.0;
|
||||
lo <= x && x <= hi && r[1] <= mid && mid <= r[3]
|
||||
})
|
||||
})
|
||||
};
|
||||
let left_ruled = beside_rows(left - left_gap - COL_TOL, left + COL_TOL);
|
||||
let right_ruled = beside_rows(right - COL_TOL, right.max(last_col + right_gap) + COL_TOL);
|
||||
Some([top, left_ruled, bottom, right_ruled])
|
||||
}
|
||||
|
||||
/// Detect tables in a page's blocks from their text layout: columns are left edges shared by
|
||||
@@ -394,11 +428,13 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec<Table>, Vec<b
|
||||
edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL);
|
||||
let ncols = edges.len();
|
||||
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 frame = if (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)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ok = (ncols >= 3 || (ncols == 2 && (run.len() >= 3 || is_ruled))) && ncols <= MAX_COLS;
|
||||
let ok = (ncols >= 3 || (ncols == 2 && (run.len() >= 3 || frame.is_some()))) && ncols <= MAX_COLS;
|
||||
if !ok {
|
||||
run.clear();
|
||||
return;
|
||||
@@ -457,7 +493,7 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec<Table>, Vec<b
|
||||
right,
|
||||
used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max),
|
||||
];
|
||||
out.push(Table { rect, cols: edges, rows: rows_out, ruled: is_ruled });
|
||||
out.push(Table { rect, cols: edges, rows: rows_out, ruled: frame });
|
||||
};
|
||||
// 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
|
||||
|
||||
@@ -137,7 +137,7 @@ fn word_table_cell_widths_follow_the_columns_they_span() {
|
||||
vec![cell("All", 3)],
|
||||
vec![cell("First", 1)],
|
||||
],
|
||||
ruled: false,
|
||||
ruled: None,
|
||||
};
|
||||
let xml = docx_table(&table);
|
||||
assert!(xml.contains("<w:gridCol w:w=\"1000\"/><w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"3000\"/>"), "{xml}");
|
||||
@@ -887,7 +887,7 @@ fn a_ruled_two_by_two_grid_is_a_table() {
|
||||
];
|
||||
let (found, consumed) = tables(&blocks, &grid);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(found[0].ruled);
|
||||
assert_eq!(found[0].ruled, Some([true; 4]), "the grid's frame is ruled on every side");
|
||||
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() };
|
||||
@@ -899,3 +899,32 @@ fn a_ruled_two_by_two_grid_is_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");
|
||||
}
|
||||
|
||||
/// A grid drawn with inside lines only gets inside borders in Word, not a box the page never
|
||||
/// draws; an outer line far from the table (a page rule) is not its frame.
|
||||
#[test]
|
||||
fn a_ruled_table_gets_outer_borders_only_where_the_page_draws_them() {
|
||||
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 inside = vec![[20.0, 245.0, 300.0, 245.0], [160.0, 220.0, 160.0, 270.0]];
|
||||
let (found, _) = tables(&blocks, &inside);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].ruled, Some([false; 4]));
|
||||
let xml = docx_table(&found[0]);
|
||||
for edge in ["insideH", "insideV"] {
|
||||
assert!(xml.contains(&format!("<w:{edge} w:val=\"single\"")), "{xml}");
|
||||
}
|
||||
for edge in ["top", "left", "bottom", "right"] {
|
||||
assert!(xml.contains(&format!("<w:{edge} w:val=\"nil\"/>")), "{xml}");
|
||||
}
|
||||
|
||||
let mut ruled_top = inside.clone();
|
||||
ruled_top.push([20.0, 270.0, 300.0, 270.0]);
|
||||
ruled_top.push([0.0, 500.0, 320.0, 500.0]);
|
||||
ruled_top.push([0.0, 0.0, 320.0, 0.0]);
|
||||
assert_eq!(tables(&blocks, &ruled_top).0[0].ruled, Some([true, false, false, false]), "only the top line near the table frames it");
|
||||
}
|
||||
@@ -80,7 +80,7 @@ features total).
|
||||
|
||||
| Target | Acrobat | PdfCraft | Fidelity | Tests and evidence |
|
||||
|---|---|---|---|---|
|
||||
| Word (`.docx`) | yes, layout-preserving | flowing paragraphs and borderless inferred tables; positioned text/artwork only for pages with rotated text or text overlapping an image/watermark, with per-page sizes | **basic** (#773 synthetic invoice regression covered; exact fonts, vector rules and general layout fidelity remain incomplete) | `create.export-docx` partial |
|
||||
| Word (`.docx`) | yes, layout-preserving | flowing paragraphs and inferred tables (bordered only along the grid lines the page draws); positioned text/artwork only for pages with rotated text or text overlapping an image/watermark, with per-page sizes | **basic** (#773 synthetic invoice regression covered; exact fonts, vector rules and general layout fidelity remain incomplete) | `create.export-docx` partial |
|
||||
| Word 97–2003 (`.doc`) | yes | no | none | |
|
||||
| Excel (`.xlsx`), XML Spreadsheet 2003 | yes | no | none | `create.export-xlsx` planned |
|
||||
| PowerPoint (`.pptx`) | yes | slides per page: title from largest text, body text, images, tables (no slide overflow reflow / normAutofit; first page sets presentation dimensions) | basic | `create.export-pptx` partial |
|
||||
|
||||
@@ -7204,7 +7204,7 @@ status = "partial"
|
||||
commands = ["export.docx"]
|
||||
tools = ["doc_export_office"]
|
||||
evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"]
|
||||
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity."
|
||||
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), detected tables with spans/empty cells (borderless, except single borders along the grid lines the page draws, #5149) (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules other than a table grid, arbitrary paint-order/transparency, general exact layout fidelity."
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-rtf"
|
||||
|
||||
Reference in new issue
Block a user