mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
* M10: keep invoice overlays out of Word body flow (#773) Export inferred tables without visible borders. Keep text and images positioned on pages with artwork, rotated or large text, and retain each source page's size, crop rotation and units. Cover invoice conversion through the automation tools, and handle unavailable Windows symlink setup in the confinement test. Co-authored-by: Codex <[email protected]> * M10: add @lollinoo's chosen contributor credit (#773) The contributor requested their public GitHub profile name while preparing the #773 PR. Co-authored-by: Codex <[email protected]> * Word export: float a page only when text overlaps an image or watermark positioned() turned every page with any image, or with text twice the body size, into absolutely positioned VML text boxes, so a page with a logo or a big title lost its flowing paragraphs, headings and tables. Now a page floats only when it has rotated text, or when an image or a watermark-sized text block actually overlaps other text (bounded pairwise test). Other pages flow with images inline. Adds a regression test for a page whose image and title sit clear of the text, and updates the docs and parity notes. --------- Co-authored-by: Codex <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
15 files changed
+377
-70
No files matched your search
Generated
+1
@@ -3616,6 +3616,7 @@ name = "pdfcraft-automation"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
"image",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
|
||||
@@ -172,6 +172,8 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
|
||||
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
|
||||
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
|
||||
- **2026-10-11 (M10, #773 integration):** Merged current upstream updates into the Word export fix and adopted the shared Windows symlink test setup. Word layout scope and estimates unchanged.
|
||||
- **2026-10-11 (M10, #773):** Word export no longer invents visible borders for tables inferred from text alignment. Rotated text, and pages where text overlaps an image or a watermark-sized text block, use positioned, editable shapes so invoice watermarks do not reserve body space (pages whose logo or title sits clear of the text keep flowing paragraphs, tables and inline images); source pages have separate sizes and section breaks. Synthetic export and automation regressions cover the invoice failure. Exact font matching, vector rules and general layout fidelity remain partial; estimates unchanged.
|
||||
- **2026-10-11 (docs, closed issues):** The progress docs caught up with issues closed on 2026-10-10: encrypted output opens in Acrobat (#774, #836; gap 1 closed, rank 0 is 32–55 h), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), and the #799–#821 batch of wrong-result fixes including the #816 panic. Scores and readiness not re-judged; overall estimate unchanged.
|
||||
- **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged.
|
||||
- **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged.
|
||||
@@ -374,6 +376,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. |
|
||||
| 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. |
|
||||
| 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged |
|
||||
| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged |
|
||||
|
||||
@@ -115,3 +115,6 @@ sync_github_name = false
|
||||
real_name = "Ufuk Efe"
|
||||
display_name = "Ufuk Efe"
|
||||
sync_github_name = true
|
||||
|
||||
[people.lollinoo]
|
||||
sync_github_name = true
|
||||
@@ -27,6 +27,7 @@ mcp = ["dep:base64"]
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
flate2.workspace = true
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
@@ -920,7 +920,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, HTML or RTF", "Export a PDF ▸ Word (.docx), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: paragraphs in reading order, headings from larger text, bold and italic, images where they fall, a page break between pages.")
|
||||
t("doc_export_office", "Export to Word, HTML or RTF", "Export a PDF ▸ Word (.docx), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. 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.")
|
||||
.cmd("export.docx")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["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.")
|
||||
|
||||
@@ -3713,6 +3713,49 @@ fn exporting_to_word_html_and_rtf() {
|
||||
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err());
|
||||
}
|
||||
|
||||
/// #773: a diagonal watermark must not enter the body flow or move invoice fields.
|
||||
#[test]
|
||||
fn exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow() {
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, SaveOptions, Stream, write_full};
|
||||
let dir = workdir("invoice-word");
|
||||
let mut pdf = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
|
||||
let content = b"q BT /F1 60 Tf 0.8 g 0.70710678 0.70710678 -0.70710678 0.70710678 30 80 Tm (DRAFT) Tj ET Q \
|
||||
BT /F1 11 Tf 20 250 Td (Description) Tj 110 0 Td (Amount) Tj \
|
||||
-110 -20 Td (Design) Tj 110 0 Td (100.00) Tj \
|
||||
-110 -20 Td (Review) Tj 110 0 Td (50.00) Tj ET \
|
||||
BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET";
|
||||
pdf.set(ObjRef::new(5, 0), Stream { dict: Dict::new(), raw: content.to_vec().into() });
|
||||
std::fs::write(dir.join("invoice.pdf"), write_full(&pdf, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "invoice.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "invoice.docx" }));
|
||||
// Inspect the actual package emitted by the tool, not an intermediate page model.
|
||||
let zip = std::fs::read(dir.join("invoice.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!(xml.contains("rotation:315.000"), "diagonal text survives extraction: {xml}");
|
||||
assert!(xml.contains("z-index:-2"), "the large watermark stays behind the invoice");
|
||||
assert!(!xml.contains("<wp:inline>") && !xml.contains("<w:tbl>"));
|
||||
assert!(!xml.contains("w:val=\"single\""));
|
||||
assert_eq!(xml.matches(">DRAFT</w:t>").count(), 1);
|
||||
assert_eq!(xml.matches(">Total due: 150.00</w:t>").count(), 1);
|
||||
assert_eq!(xml.matches("<w:sectPr>").count(), 1);
|
||||
assert!(!xml.contains("w:type=\"page\""));
|
||||
assert!(xml.contains("margin-left:20.000pt"), "invoice fields retain their left edge");
|
||||
let rendered = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_data_files_export_and_import_through_tools() {
|
||||
let dir = workdir("xfa-data");
|
||||
|
||||
@@ -236,6 +236,8 @@ pub struct TextBlock {
|
||||
/// The text fill colour as RGB, used by the visual editor and preserved when no colour
|
||||
/// override is requested.
|
||||
pub color: [f64; 3],
|
||||
/// Counterclockwise baseline angle in page user space, in degrees.
|
||||
pub rotation: f64,
|
||||
/// Indexes into [`text_lines`].
|
||||
pub lines: Vec<usize>,
|
||||
/// Visual rows; each contains indexes into [`text_lines`], including mixed-style runs.
|
||||
@@ -301,6 +303,11 @@ impl TextLine {
|
||||
pub fn paint_pos(&self) -> (usize, usize) {
|
||||
(self.stream, self.ops.iter().max().copied().unwrap_or(0))
|
||||
}
|
||||
|
||||
fn rotation(&self) -> f64 {
|
||||
let m = Matrix(self.origin.tm).then(&Matrix(self.origin.ctm)).0;
|
||||
m[1].atan2(m[0]).to_degrees()
|
||||
}
|
||||
}
|
||||
|
||||
/// What replacing a line did.
|
||||
|
||||
@@ -41,6 +41,7 @@ fn same_style(a: &TextLine, b: &TextLine) -> bool {
|
||||
&& a.bold == b.bold
|
||||
&& a.italic == b.italic
|
||||
&& a.color == b.color
|
||||
&& (a.rotation() - b.rotation()).abs() < 0.01
|
||||
&& (a.size - b.size).abs() < 0.01
|
||||
}
|
||||
|
||||
@@ -98,6 +99,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
bold: first.bold,
|
||||
italic: first.italic,
|
||||
color: first.color,
|
||||
rotation: first.rotation(),
|
||||
lines: Vec::new(),
|
||||
rows: Vec::new(),
|
||||
});
|
||||
|
||||
@@ -158,6 +158,16 @@ impl crate::Document {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, info)| {
|
||||
// Export uses the visible page in points, y up. Raw PDF coordinates include
|
||||
// CropBox offsets and precede /Rotate and /UserUnit.
|
||||
let visible_rect = |r: [f64; 4]| {
|
||||
let a = info.user_to_view(r[0] as f32, r[1] as f32);
|
||||
let b = info.user_to_view(r[2] as f32, r[3] as f32);
|
||||
[a[0].min(b[0]) as f64, (info.height - a[1].max(b[1])) as f64, a[0].max(b[0]) as f64, (info.height - a[1].min(b[1])) as f64]
|
||||
};
|
||||
let crop_width = f64::from(info.crop[2] - info.crop[0]);
|
||||
let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height });
|
||||
let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 };
|
||||
let blocks = pdfcraft_edit::reading_blocks(cos, i)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -166,11 +176,12 @@ impl crate::Document {
|
||||
let f = b.base_font.to_ascii_lowercase();
|
||||
pdfcraft_export::Block {
|
||||
text: b.text,
|
||||
rect: b.rect,
|
||||
size: b.size,
|
||||
rect: visible_rect(b.rect),
|
||||
size: b.size * unit,
|
||||
bold: f.contains("bold") || f.contains("black") || f.contains("heavy"),
|
||||
italic: f.contains("italic") || f.contains("oblique"),
|
||||
color: b.color,
|
||||
rotation: b.rotation - f64::from(info.rotation),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -179,7 +190,13 @@ impl crate::Document {
|
||||
.iter()
|
||||
.filter_map(|im| {
|
||||
let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?;
|
||||
Some(pdfcraft_export::Image { ext: if ext == "jpg" { "jpg" } else { "png" }, bytes, rect: im.rect })
|
||||
Some(pdfcraft_export::Image {
|
||||
ext: if ext == "jpg" { "jpg" } else { "png" },
|
||||
bytes,
|
||||
rect: visible_rect(im.rect),
|
||||
size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit],
|
||||
rotation: im.matrix[1].atan2(im.matrix[0]).to_degrees() - f64::from(info.rotation),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images }
|
||||
|
||||
@@ -2487,6 +2487,26 @@ BT /F1 11 Tf 0 g 72 650 Td (Black body text that is long enough to be the body s
|
||||
assert!(blocks.iter().any(|b| b.text == "Annual Report" && b.color == [0.05, 0.23, 0.18]), "{blocks:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn office_export_positions_follow_the_displayed_crop_rotation_and_units() {
|
||||
use pdfcraft_cos::{Document as CosDocument, ObjRef, Object, SaveOptions, write_full};
|
||||
let mut pdf = CosDocument::open(Arc::new(fixture(1))).unwrap();
|
||||
let mut page = pdf.get(ObjRef::new(4, 0)).as_dict().unwrap().clone();
|
||||
page.set("CropBox", Object::Array([10, 20, 190, 280].map(Object::Int).to_vec()));
|
||||
page.set("Rotate", Object::Int(90));
|
||||
page.set("UserUnit", Object::Int(2));
|
||||
pdf.set(ObjRef::new(4, 0), page);
|
||||
let mut session = Session::new();
|
||||
let id = session.open("turned.pdf", None, Arc::new(write_full(&pdf, &SaveOptions::default()).unwrap()), None).unwrap();
|
||||
let doc = session.get(id).unwrap();
|
||||
let pages = doc.export_pages();
|
||||
let b = &pages[0].blocks[0];
|
||||
assert!((b.rotation + 90.0).abs() < 0.01, "clockwise page rotation: {b:?}");
|
||||
assert!((b.rect[0] - 248.0).abs() < 0.01, "crop and UserUnit applied: {b:?}");
|
||||
assert_eq!(b.size, 48.0);
|
||||
assert_eq!((pages[0].width, pages[0].height), (520.0, 360.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_turns_a_panic_into_an_error() {
|
||||
assert_eq!(guard(|| 7), Ok(7));
|
||||
|
||||
+262
-59
@@ -2,8 +2,9 @@
|
||||
//!
|
||||
//! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and
|
||||
//! images (encoded bytes and box), in reading order. The writers turn that into a flowing
|
||||
//! document: headings are the paragraphs set larger than the body text, and images sit where
|
||||
//! they fall between paragraphs.
|
||||
//! document: headings are the paragraphs set larger than the body text. Word keeps pages with
|
||||
//! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable
|
||||
//! shapes to avoid reflowing overlays; other pages flow with images inline.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -15,7 +16,7 @@ pub use zip::Zip;
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Block {
|
||||
pub text: String,
|
||||
/// [x0, y0, x1, y1] in user space (y up).
|
||||
/// [x0, y0, x1, y1] on the displayed page in points (y up).
|
||||
pub rect: [f64; 4],
|
||||
/// Font size in points.
|
||||
pub size: f64,
|
||||
@@ -23,6 +24,8 @@ pub struct Block {
|
||||
pub italic: bool,
|
||||
/// Text fill colour as sRGB components in 0..=1 (black when unknown).
|
||||
pub color: [f64; 3],
|
||||
/// Counterclockwise baseline angle in user space (degrees).
|
||||
pub rotation: f64,
|
||||
}
|
||||
|
||||
/// An image of a page.
|
||||
@@ -32,6 +35,10 @@ pub struct Image {
|
||||
pub ext: &'static str,
|
||||
pub bytes: Vec<u8>,
|
||||
pub rect: [f64; 4],
|
||||
/// Width and height before rotation, in points.
|
||||
pub size: [f64; 2],
|
||||
/// Counterclockwise rotation in user space (degrees).
|
||||
pub rotation: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -431,23 +438,15 @@ 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 single borders (so it renders without a table style), a grid sized
|
||||
/// 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.
|
||||
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 border = "<w:top w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>";
|
||||
let mut s = String::from("<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/><w:tblBorders>");
|
||||
for b in [
|
||||
border,
|
||||
"<w:left w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>",
|
||||
"<w:bottom w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>",
|
||||
"<w:right w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>",
|
||||
"<w:insideH w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>",
|
||||
"<w:insideV w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"999999\"/>",
|
||||
] {
|
||||
s.push_str(b);
|
||||
for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] {
|
||||
s.push_str(&format!("<w:{edge} w:val=\"nil\"/>"));
|
||||
}
|
||||
s.push_str("</w:tblBorders></w:tblPr><w:tblGrid>");
|
||||
for w in &widths {
|
||||
@@ -475,45 +474,149 @@ fn docx_table(t: &Table) -> String {
|
||||
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<u8> {
|
||||
// The first page's size, within what Word accepts (0.1 to 22 in; a long receipt is taller),
|
||||
// in twips. Margins are 1 in, less on small pages so text keeps room.
|
||||
/// Pages with rotated text, or text that actually overlaps an image or an oversized
|
||||
/// (watermark-like) text block, cannot be put in paragraph flow without changing their layout.
|
||||
/// Keep those pages as editable, absolutely positioned text boxes. Every other page, including
|
||||
/// one with a logo or a big title that sits clear of the text, flows as paragraphs and tables.
|
||||
fn positioned(page: &Page) -> bool {
|
||||
if page.blocks.iter().any(|b| b.rotation.abs() > 0.1) {
|
||||
return true;
|
||||
}
|
||||
let body = body_size(std::slice::from_ref(page)).max(1.0);
|
||||
let overlays = page
|
||||
.images
|
||||
.iter()
|
||||
.map(|im| (im.rect, None))
|
||||
.chain(page.blocks.iter().enumerate().filter(|(_, b)| b.size >= 2.0 * body).map(|(i, b)| (b.rect, Some(i))));
|
||||
// Bound the pairwise test on hostile pages (many overlays times many blocks): past the
|
||||
// budget, keep the default flowing layout.
|
||||
let mut budget: usize = 1_000_000;
|
||||
for (r, own) in overlays {
|
||||
for (i, b) in page.blocks.iter().enumerate() {
|
||||
if budget == 0 {
|
||||
return false;
|
||||
}
|
||||
budget -= 1;
|
||||
if Some(i) != own && overlaps(r, b.rect) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether two [x0, y0, x1, y1] boxes share more than a hairline (0.5 pt each way); boxes that
|
||||
/// only touch, or hostile non-finite coordinates, do not count.
|
||||
fn overlaps(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
let w = a[2].min(b[2]) - a[0].max(b[0]);
|
||||
let h = a[3].min(b[3]) - a[1].max(b[1]);
|
||||
w.is_finite() && h.is_finite() && w > 0.5 && h > 0.5
|
||||
}
|
||||
|
||||
/// Bound every value written into a shape's style, including hostile PDF coordinates.
|
||||
fn layout_number(n: f64) -> f64 {
|
||||
if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String {
|
||||
// One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a
|
||||
// page-sized watermark cannot push the invoice footer onto another page.
|
||||
let mut s = String::from("<w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\" w:line=\"20\" w:lineRule=\"exact\"/></w:pPr>");
|
||||
let body = body_size(std::slice::from_ref(page)).max(1.0);
|
||||
for b in &page.blocks {
|
||||
*shape_id = shape_id.saturating_add(1);
|
||||
let rotation = layout_number(-b.rotation).rem_euclid(360.0);
|
||||
let (sin, cos) = rotation.to_radians().sin_cos();
|
||||
let (bw, bh) = (layout_number(b.rect[2] - b.rect[0]).max(1.0), layout_number(b.rect[3] - b.rect[1]).max(1.0));
|
||||
let h = if b.rotation.abs() > 0.1 { layout_number(b.size).max(1.0) } else { bh };
|
||||
// Recover the unrotated width from the axis-aligned box. Use the larger axis so
|
||||
// quarter turns and 45-degree watermarks do not divide by (nearly) zero.
|
||||
let w = if b.rotation.abs() <= 0.1 {
|
||||
bw
|
||||
} else if cos.abs() >= sin.abs() {
|
||||
(bw - h * sin.abs()) / cos.abs()
|
||||
} else {
|
||||
(bh - h * cos.abs()) / sin.abs()
|
||||
};
|
||||
let w = layout_number(w).max(1.0);
|
||||
let (x, y) = (layout_number(b.rect[0] + (bw - w) / 2.0), layout_number(page.height - b.rect[3] + (bh - h) / 2.0));
|
||||
let z = if b.size >= 2.0 * body { -2 } else { 1 };
|
||||
let style = format!(
|
||||
"position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:{z};mso-position-horizontal-relative:page;mso-position-vertical-relative:page"
|
||||
);
|
||||
s.push_str(&format!(
|
||||
"<w:r><w:pict><v:rect id=\"Text{}\" style=\"{style}\" filled=\"f\" stroked=\"f\"><v:textbox inset=\"0,0,0,0\" style=\"mso-fit-shape-to-text:t\"><w:txbxContent><w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\"/></w:pPr>{}</w:p></w:txbxContent></v:textbox><w10:wrap type=\"none\"/></v:rect></w:pict></w:r>",
|
||||
shape_id, run_xml(&b.text, b.size, b.bold, b.italic, b.color)
|
||||
));
|
||||
}
|
||||
for im in &page.images {
|
||||
*shape_id = shape_id.saturating_add(1);
|
||||
let n = media.len().saturating_add(1);
|
||||
let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0));
|
||||
let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0));
|
||||
let rotation = layout_number(-im.rotation).rem_euclid(360.0);
|
||||
s.push_str(&format!("<w:r><w:pict><v:rect id=\"Image{shape_id}\" style=\"position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:-1;mso-position-horizontal-relative:page;mso-position-vertical-relative:page\" stroked=\"f\"><v:imagedata r:id=\"rIdImg{n}\"/><w10:wrap type=\"none\"/></v:rect></w:pict></w:r>"));
|
||||
media.push((format!("image{n}.{}", im.ext), im));
|
||||
}
|
||||
s.push_str("</w:p>");
|
||||
s
|
||||
}
|
||||
|
||||
fn word_page_size(page: &Page) -> (i64, i64) {
|
||||
let twips = |pt: f64| if pt.is_finite() { (pt * 20.0).round().clamp(144.0, 31680.0) as i64 } else { 12240 };
|
||||
let (pw, ph) = pages.first().map_or((12240, 15840), |p| (twips(p.width), twips(p.height)));
|
||||
(twips(page.width), twips(page.height))
|
||||
}
|
||||
|
||||
fn section(page: &Page) -> String {
|
||||
let (pw, ph) = word_page_size(page);
|
||||
let (mx, my) = ((pw / 8).min(1440), (ph / 8).min(1440));
|
||||
// Images are at most the text width, in points.
|
||||
let text_w = (pw - 2 * mx) as f64 / 20.0;
|
||||
format!(
|
||||
"<w:sectPr><w:type w:val=\"nextPage\"/><w:pgSz w:w=\"{pw}\" w:h=\"{ph}\"/><w:pgMar w:top=\"{my}\" w:right=\"{mx}\" w:bottom=\"{my}\" w:left=\"{mx}\" w:header=\"{}\" w:footer=\"{}\" w:gutter=\"0\"/></w:sectPr>",
|
||||
my / 2,
|
||||
my / 2
|
||||
)
|
||||
}
|
||||
|
||||
/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables;
|
||||
/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout.
|
||||
pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
let mut body = String::new();
|
||||
let mut media: Vec<(String, &Image)> = Vec::new();
|
||||
// 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, b.color);
|
||||
for it in items(pages) {
|
||||
match &it {
|
||||
Item::Para(b, lvl) => {
|
||||
let style = match lvl {
|
||||
1 => "<w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr>",
|
||||
2 => "<w:pPr><w:pStyle w:val=\"Heading2\"/></w:pPr>",
|
||||
_ => "",
|
||||
};
|
||||
body.push_str(&format!("<w:p>{style}{}</w:p>", run(b)));
|
||||
}
|
||||
Item::Table(t) => {
|
||||
if after_table {
|
||||
body.push_str("<w:p/>");
|
||||
}
|
||||
body.push_str(&docx_table(t));
|
||||
}
|
||||
Item::Img(im) => {
|
||||
let n = media.len() + 1;
|
||||
let name = format!("image{n}.{}", im.ext);
|
||||
// Size on the page, in EMU (12700 per point), at most the text width.
|
||||
let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0));
|
||||
let k = (text_w / w).min(1.0);
|
||||
let (cx, cy) = ((w * k * 12700.0) as i64, (h * k * 12700.0) as i64);
|
||||
body.push_str(&format!(
|
||||
let mut shape_id = 0usize;
|
||||
for (page_index, page) in pages.iter().enumerate() {
|
||||
let (pw, _) = word_page_size(page);
|
||||
let text_w = (pw - 2 * (pw / 8).min(1440)) as f64 / 20.0;
|
||||
if positioned(page) {
|
||||
body.push_str(&positioned_page(page, &mut media, &mut shape_id));
|
||||
after_table = false;
|
||||
} else {
|
||||
for it in items(std::slice::from_ref(page)) {
|
||||
match &it {
|
||||
Item::Para(b, lvl) => {
|
||||
let style = match lvl {
|
||||
1 => "<w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr>",
|
||||
2 => "<w:pPr><w:pStyle w:val=\"Heading2\"/></w:pPr>",
|
||||
_ => "",
|
||||
};
|
||||
body.push_str(&format!("<w:p>{style}{}</w:p>", run(b)));
|
||||
}
|
||||
Item::Table(t) => {
|
||||
if after_table {
|
||||
body.push_str("<w:p/>");
|
||||
}
|
||||
body.push_str(&docx_table(t));
|
||||
}
|
||||
Item::Img(im) => {
|
||||
let n = media.len() + 1;
|
||||
let name = format!("image{n}.{}", im.ext);
|
||||
// Size on the page, in EMU (12700 per point), at most the text width.
|
||||
let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0));
|
||||
let k = (text_w / w).min(1.0);
|
||||
let (cx, cy) = ((w * k * 12700.0) as i64, (h * k * 12700.0) as i64);
|
||||
body.push_str(&format!(
|
||||
"<w:p><w:r><w:drawing><wp:inline><wp:extent cx=\"{cx}\" cy=\"{cy}\"/><wp:docPr id=\"{n}\" name=\"Picture {n}\"/>\
|
||||
<a:graphic xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/picture\">\
|
||||
<pic:pic xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\"><pic:nvPicPr><pic:cNvPr id=\"{n}\" name=\"{name}\"/><pic:cNvPicPr/></pic:nvPicPr>\
|
||||
@@ -521,22 +624,33 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
<pic:spPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"{cx}\" cy=\"{cy}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>\
|
||||
</a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"
|
||||
));
|
||||
media.push((name, im));
|
||||
media.push((name, im));
|
||||
}
|
||||
Item::PageBreak => body.push_str("<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>"),
|
||||
}
|
||||
after_table = matches!(it, Item::Table(_));
|
||||
}
|
||||
Item::PageBreak => body.push_str("<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>"),
|
||||
}
|
||||
after_table = matches!(it, Item::Table(_));
|
||||
}
|
||||
if after_table {
|
||||
body.push_str("<w:p/>");
|
||||
// Section breaks carry this source page's size and create exactly one next page.
|
||||
// The last section must be a direct child of the body (OOXML 17.6.17).
|
||||
if page_index + 1 < pages.len() {
|
||||
body.push_str(&format!(
|
||||
"<w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\" w:line=\"20\" w:lineRule=\"exact\"/>{}</w:pPr></w:p>",
|
||||
section(page)
|
||||
));
|
||||
} else {
|
||||
if after_table || page.blocks.is_empty() && page.images.is_empty() {
|
||||
body.push_str("<w:p><w:pPr><w:spacing w:after=\"0\" w:line=\"20\" w:lineRule=\"exact\"/></w:pPr></w:p>");
|
||||
}
|
||||
body.push_str(§ion(page));
|
||||
}
|
||||
after_table = false;
|
||||
}
|
||||
let doc = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
|
||||
<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \
|
||||
xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\"><w:body>{body}\
|
||||
<w:sectPr><w:pgSz w:w=\"{pw}\" w:h=\"{ph}\"/><w:pgMar w:top=\"{my}\" w:right=\"{mx}\" w:bottom=\"{my}\" w:left=\"{mx}\" w:header=\"{}\" w:footer=\"{}\" w:gutter=\"0\"/></w:sectPr></w:body></w:document>",
|
||||
my / 2,
|
||||
my / 2
|
||||
xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:w10=\"urn:schemas-microsoft-com:office:word\"><w:body>{body}\
|
||||
</w:body></w:document>"
|
||||
);
|
||||
let mut types = String::from(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\
|
||||
@@ -698,6 +812,7 @@ mod tests {
|
||||
bold,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
};
|
||||
Page {
|
||||
width: 612.0,
|
||||
@@ -712,6 +827,8 @@ mod tests {
|
||||
ext: "png",
|
||||
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(),
|
||||
rect: [72.0, 450.0, 272.0, 600.0],
|
||||
size: [200.0, 150.0],
|
||||
rotation: 0.0,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -741,6 +858,72 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_does_not_invent_borders_for_aligned_invoice_fields() {
|
||||
let xml = part(&docx(&[table_page()], "Invoice"), "word/document.xml");
|
||||
assert!(xml.contains("<w:tbl>"));
|
||||
assert!(!xml.contains("w:val=\"single\""), "text alignment is not evidence of a border: {xml}");
|
||||
assert!(xml.contains("<w:insideH w:val=\"nil\"/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_keeps_a_page_with_a_clear_image_and_big_title_in_flow() {
|
||||
// page() has a picture between paragraphs and a 24 pt title over 11 pt body text, but
|
||||
// nothing overlaps: it must stay flowing paragraphs with the image inline.
|
||||
let xml = part(&docx(&[page()], "Report"), "word/document.xml");
|
||||
assert!(!xml.contains("position:absolute"), "nothing overlaps, so nothing floats: {xml}");
|
||||
assert!(xml.contains("<wp:inline>"), "the picture stays inline: {xml}");
|
||||
assert!(xml.contains("<w:pStyle w:val=\"Heading1\"/>"), "the title stays a heading: {xml}");
|
||||
let (title, body) = (xml.find(">Annual Report</w:t>"), xml.find(">Body text after the picture"));
|
||||
assert!(title.is_some() && body.is_some() && title < body, "reading order kept: {xml}");
|
||||
// The same page with the picture moved over the body text floats.
|
||||
let mut over = page();
|
||||
over.images[0].rect = [72.0, 380.0, 272.0, 530.0];
|
||||
let xml = part(&docx(&[over], "Report"), "word/document.xml");
|
||||
assert!(xml.contains("position:absolute"), "an image over text floats: {xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_overlap_does_not_consume_invoice_body_space() {
|
||||
let mut p = table_page();
|
||||
p.blocks.push(Block {
|
||||
text: "DRAFT".into(),
|
||||
rect: [100.0, 200.0, 500.0, 700.0],
|
||||
size: 100.0,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.8; 3],
|
||||
rotation: 0.0,
|
||||
});
|
||||
p.images = page().images;
|
||||
let xml = part(&docx(&[p], "Invoice"), "word/document.xml");
|
||||
assert!(xml.contains("position:absolute"), "overlapping text must float: {xml}");
|
||||
assert!(!xml.contains("<wp:inline>"), "images must not displace invoice text");
|
||||
assert!(!xml.contains("<w:tbl>"), "the source text boxes must keep their positions");
|
||||
assert_eq!(xml.matches(">DRAFT</w:t>").count(), 1);
|
||||
assert_eq!(xml.matches("w:type=\"page\"").count(), 0);
|
||||
assert!(xml.contains("margin-left:72.000pt;margin-top:160.000pt"), "Meter field keeps its source position: {xml}");
|
||||
let watermark = xml.split(">DRAFT</w:t>").next().unwrap().rsplit("<v:rect ").next().unwrap();
|
||||
assert!(watermark.contains("z-index:-2"), "watermark is behind foreground graphics");
|
||||
let image = xml.split("<v:imagedata ").next().unwrap().rsplit("<v:rect ").next().unwrap();
|
||||
assert!(image.contains("z-index:-1"), "foreground graphics cover the watermark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_rotated_pages_keep_separate_page_sizes_and_unique_shapes() {
|
||||
let mut p = table_page();
|
||||
p.blocks[0].rotation = 45.0;
|
||||
let mut second = p.clone();
|
||||
second.width = 400.0;
|
||||
second.height = 500.0;
|
||||
let xml = part(&docx(&[p, second], "Two invoices"), "word/document.xml");
|
||||
assert!(xml.contains("rotation:315.000"), "PDF y-up becomes Word y-down");
|
||||
assert_eq!(xml.matches("<w:sectPr>").count(), 2);
|
||||
assert!(xml.contains("<w:pgSz w:w=\"8000\" w:h=\"10000\"/>"));
|
||||
assert_eq!(xml.matches("id=\"Text1\"").count(), 1);
|
||||
assert_eq!(xml.matches(">Meter</w:t>").count(), 2);
|
||||
}
|
||||
|
||||
/// One part of a package written by [`Zip`], inflated.
|
||||
fn part(zip: &[u8], name: &str) -> String {
|
||||
let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize);
|
||||
@@ -771,6 +954,7 @@ mod tests {
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
});
|
||||
let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml");
|
||||
assert!(xml.contains(">Total 4.50\tGBP</w:t>"), "{xml}");
|
||||
@@ -812,7 +996,15 @@ mod tests {
|
||||
#[test]
|
||||
fn rtf_escapes_and_sizes() {
|
||||
let mut p = page();
|
||||
p.blocks.push(Block { text: "Café {x}".into(), rect: [72.0, 100.0, 200.0, 110.0], size: 10.0, bold: false, italic: true, color: [0.0; 3] });
|
||||
p.blocks.push(Block {
|
||||
text: "Café {x}".into(),
|
||||
rect: [72.0, 100.0, 200.0, 110.0],
|
||||
size: 10.0,
|
||||
bold: false,
|
||||
italic: true,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
});
|
||||
let r = rtf(&[p]);
|
||||
assert!(r.starts_with("{\\rtf1") && r.ends_with('}'));
|
||||
assert!(r.contains("\\fs48\\b Annual Report"));
|
||||
@@ -828,6 +1020,7 @@ mod tests {
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
};
|
||||
Page {
|
||||
width: 612.0,
|
||||
@@ -850,6 +1043,7 @@ mod tests {
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
},
|
||||
],
|
||||
images: Vec::new(),
|
||||
@@ -893,7 +1087,7 @@ mod tests {
|
||||
.map(|i| {
|
||||
let (row, col) = (i / 2, i / 2 + i % 2);
|
||||
let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0);
|
||||
Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3] }
|
||||
Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 }
|
||||
})
|
||||
.collect();
|
||||
let started = std::time::Instant::now();
|
||||
@@ -912,7 +1106,15 @@ mod tests {
|
||||
.flat_map(|row| {
|
||||
(0..n).map(move |col| {
|
||||
let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0);
|
||||
Block { text: (col + 1).to_string(), rect: [x, y, x + 8.0, y + 8.0], size: 8.0, bold: false, italic: false, color: [0.0; 3] }
|
||||
Block {
|
||||
text: (col + 1).to_string(),
|
||||
rect: [x, y, x + 8.0, y + 8.0],
|
||||
size: 8.0,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
@@ -933,6 +1135,7 @@ mod tests {
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
};
|
||||
let p = Page {
|
||||
width: 612.0,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (encrypted output opens in Acrobat, #774; XFDF fixes #807, #809, #819) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Word invoice export, #773; encrypted output opens in Acrobat, #774; XFDF fixes #807, #809, #819) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
Every format Acrobat Pro reads or writes, and PdfCraft's support for it. Acrobat's list comes from
|
||||
its bundle's `Info.plist` `CFBundleDocumentTypes` (what it opens) and its public documentation
|
||||
@@ -79,7 +79,7 @@ features total).
|
||||
|
||||
| Target | Acrobat | PdfCraft | Fidelity | Tests and evidence |
|
||||
|---|---|---|---|---|
|
||||
| Word (`.docx`) | yes, layout-preserving | flowing document: paragraphs, headings, bold/italic, colour, images, tables | **basic** (#773: layout lost, fake borders, duplicated pages) | `create.export-docx` partial |
|
||||
| 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 97–2003 (`.doc`) | yes | no | none | |
|
||||
| Excel (`.xlsx`), XML Spreadsheet 2003 | yes | no | none | `create.export-xlsx` planned |
|
||||
| PowerPoint (`.pptx`) | yes | no | none | `create.export-pptx` planned |
|
||||
@@ -109,5 +109,6 @@ features total).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | #773: inferred tables have no invented borders; rotated or overlapping invoice pages use floating shapes and page sections. Full Office layout fidelity remains partial. |
|
||||
| 2026-10-11 | minor | Encrypted PDF row: written files open in Acrobat DC (#774, fixed by #836). FDF row: #807, #809 and #819 fixed |
|
||||
| 2026-10-10 | major | Created from Acrobat 26.002.21931's `CFBundleDocumentTypes`, Acrobat's public Create/Export documentation, and PdfCraft's code and checklist |
|
||||
+7
-2
@@ -1,5 +1,7 @@
|
||||
# Where PdfCraft falls short of Acrobat Pro
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Word invoice regression coverage, #773; Office fidelity still partial; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (gap 1 closed by #836; fixed issues removed from gaps 3, 6 and 16) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (document-font subset completion and curved/shared image clips in the contributor fork; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
Every known shortfall, one entry each, ranked by what it costs users. This is the work list: agents
|
||||
@@ -152,8 +154,10 @@ update the numbers.
|
||||
|
||||
- **Missing:** Word export that keeps layout; Excel and PowerPoint export; creating PDFs from Office,
|
||||
HTML, web pages and PostScript; SVG/XML/EPS/PS/JPEG 2000 export.
|
||||
- **Evidence:** `create.export-docx` partial; [#773](https://github.com/storytold/pdfcraft/issues/773)
|
||||
(an invoice converted to Word loses its layout, gains table borders and duplicates pages);
|
||||
- **Evidence:** `create.export-docx` partial; the synthetic invoice regression for
|
||||
[#773](https://github.com/storytold/pdfcraft/issues/773) now covers absent inferred borders,
|
||||
floating watermark text and per-page section sizes. Exact font matching, vector rules and
|
||||
broader layout fidelity remain incomplete;
|
||||
`create.export-xlsx`, `create.export-pptx`, `create.from-office`, `create.from-html` planned.
|
||||
- **Impact:** "Export PDF" and "Create PDF" are two of Acrobat's most used tools.
|
||||
- **Gate:** lossy or missing sub-cases of the convert-and-share workflow (partial, not blocking).
|
||||
@@ -305,6 +309,7 @@ update the numbers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Word invoice failure #773 has synthetic regression coverage; broader Office conversion remains gap 8. |
|
||||
| 2026-10-11 | minor | Contributor fork: mixed-style visual regions, source-font space reuse and a read-only textless background. Embedded sfnt source faces and Mac subset Unicode mappings keep edit baselines stable; live frames follow Apply/Save geometry as text grows or shrinks. Engine/tool/UI regressions and private all-page checks cover the changes; full matching font import, CFF, range styles, shaping and private Chinese typing checks added; legacy programs and general fidelity remain open; estimates unchanged |
|
||||
| 2026-10-11 | patch | Gap 2: 23 hayro-interpret and 10 hayro patches after axial and radial shadings became native gradients (backport of hayro 0.8) |
|
||||
| 2026-10-11 | minor | Closed gap 1 (#774, fixed by #836; numbers kept, its interop suite moved to gap 12); gap 6 lists the fixed #799–#821 batch; gap 3 and 16 updated for #844 (same-page move), #740 (column select) and #759 (1:1 touchpad scrolling) |
|
||||
|
||||
+2
-1
@@ -90,7 +90,7 @@ total matches the parity estimate.
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, web printing |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import, web printing |
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
|
||||
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools).<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
|
||||
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. |
|
||||
@@ -140,6 +140,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | M10: Word invoice export regressions (#773); estimates unchanged. |
|
||||
| 2026-10-12 | minor | M2: short same-line redraws split before placement; estimates unchanged. |
|
||||
| 2026-10-11 | minor | M13/#971: cap materialized text-search matches to the requested limit plus one continuation match. |
|
||||
| 2026-10-11 | minor | M7: #594 ordinary paragraph drags validated in the Linux test environment with source-loss baseline failures, UI/engine regressions, native save/reopen and all eight CI gates. Estimates unchanged. |
|
||||
|
||||
@@ -6934,8 +6934,8 @@ milestone = "M10"
|
||||
status = "partial"
|
||||
commands = ["export.docx"]
|
||||
tools = ["doc_export_office"]
|
||||
evidence = ["crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "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, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), images inline, page breaks, and detected tables with spans/empty cells; checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: columns, fonts, colour per run within a paragraph, exact layout mode."
|
||||
evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.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/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "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, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, 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; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. 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. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity."
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-rtf"
|
||||
|
||||
Reference in new issue
Block a user