mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
1 parent
adbf1dc01f
commit
f750589812
9 files changed
+340
-28
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user