Merge remote-tracking branch 'origin/main' into feat/edit-scanned-page-offers-ocr

This commit is contained in:
Philip
2026-10-19 18:26:08 +01:00
34 changed files with 1258 additions and 134 deletions
+6 -1
View File
@@ -173,6 +173,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged.
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
@@ -191,9 +194,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
+5
View File
@@ -148,3 +148,8 @@ sync_github_name = false
[people.MortenHusted]
sync_github_name = true
[people.EricKaden]
real_name = "Eric Kaden"
display_name = "Eric Kaden"
sync_github_name = false
+1 -1
View File
@@ -174,7 +174,7 @@ fn interior_color(d: &Dict, fallback: Option<Rgb>) -> Option<Rgb> {
color(d, b"IC").flatten().or(fallback)
}
fn border_width(d: &Dict) -> f64 {
pub(crate) fn border_width(d: &Dict) -> f64 {
if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) {
return w.max(0.0);
}
+20 -14
View File
@@ -1987,13 +1987,12 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m
result
}
/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles,
/// ovals, polygons and clouds, §12.5.6.8–9).
/// Whether a comment has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6).
pub fn fillable(subtype: &str) -> bool {
matches!(subtype, "Square" | "Circle" | "Polygon")
matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText")
}
/// Fill a rectangle, oval or polygon comment with `fill`, or remove its fill (`None`), and
/// Fill a shape or FreeText comment with `fill`, or remove its fill (`None`), and
/// redraw its appearance (Acrobat: Properties ▸ Fill Color).
pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
@@ -2001,7 +2000,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
if !fillable(&subtype) {
return Err(AnnotError::Invalid("only rectangles, ovals and polygons can be filled".into()));
return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into()));
}
// Check before changing anything: a stale appearance would contradict the new fill.
if appearance::build(&d).is_none() {
@@ -2013,10 +2012,11 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
refuse_foreign_text(doc, &d)?;
let before = d;
doc.update_dict(r, |d| {
let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" };
match fill {
Some(c) => d.set(b"IC".to_vec(), rgb(c)),
Some(c) => d.set(key.to_vec(), rgb(c)),
None => {
d.remove(b"IC");
d.remove(key);
}
}
touch(d, meta);
@@ -2205,7 +2205,7 @@ pub struct Props {
pub subject: String,
pub color: Option<Rgb>,
pub opacity: f64,
/// Border / line width, for shapes, lines and drawings.
/// Border / line width, for shapes, lines, drawings and text boxes.
pub width: Option<f64>,
/// Notes only.
pub icon: Option<NoteIcon>,
@@ -2217,9 +2217,9 @@ pub struct Props {
pub locked: bool,
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
pub endings: Option<Vec<LineEnding>>,
/// Rectangles, ovals and polygons: their interior can be filled (see [`fillable`]).
/// Whether the comment has a fill control (see [`fillable`]).
pub fillable: bool,
/// `/IC`, the interior colour (`None`: no fill).
/// The interior colour (`None`: no fill).
pub fill: Option<Rgb>,
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
pub font_size: Option<f64>,
@@ -2234,8 +2234,13 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
let c: Vec<f64> = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default();
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) };
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine")
.then(|| d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0));
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
if subtype == "FreeText" {
if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
} else {
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)
}
});
Some(Props {
author: text_value(doc, d, b"T").unwrap_or_default(),
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
@@ -2254,9 +2259,10 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
})
}
/// `/IC` as an RGB colour. An empty array means no fill; grey and CMYK fills are converted.
/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted.
fn fill_of(d: &Dict) -> Option<Rgb> {
let c: Vec<f64> = d.get(b"IC")?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" };
let c: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
let rgb = match c.as_slice() {
[g] => [*g; 3],
[r, g, b] => [*r, *g, *b],
+47
View File
@@ -1335,3 +1335,50 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() {
assert_eq!(props(&doc, 0, i).unwrap().font_size, None);
}
}
#[test]
fn text_boxes_and_callouts_can_change_fill_and_border() {
let mut doc = fixture();
let shapes = [
Shape::TextBox { rect: [300.0, 600.0, 500.0, 660.0], font_size: 12.0 },
Shape::Callout {
rect: [300.0, 600.0, 500.0, 660.0],
knee: [260.0, 560.0],
point: [200.0, 520.0],
font_size: 12.0,
ending: LineEnding::OpenArrow,
},
];
for shape in shapes {
let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap();
assert!(props(&doc, 0, index).unwrap().fillable);
set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap();
set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap();
let saved = reopen(&doc);
let properties = props(&saved, 0, index).unwrap();
assert_eq!(properties.fill, Some([1.0; 3]));
assert_eq!(properties.width, Some(3.0));
let ap = ap_content(&saved, &list(&saved, 0)[index]);
assert!(ap.contains("1 1 1 rg") && ap.contains(" re f") && ap.contains("3 w"), "{ap}");
set_fill(&mut doc, 0, index, None, &meta("test")).unwrap();
assert_eq!(props(&doc, 0, index).unwrap().fill, None);
}
}
#[test]
fn free_text_properties_show_legacy_or_absent_border_width() {
let mut doc = fixture();
let index = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 100.0, 200.0, 140.0], font_size: 12.0 }), &meta("test")).unwrap();
let (_, reference) = annot_ref(&mut doc, 0, index).unwrap();
doc.update_dict(reference, |d| {
d.remove(b"BS");
d.remove(b"Border");
})
.unwrap();
assert_eq!(props(&doc, 0, index).unwrap().width, Some(0.0));
doc.update_dict(reference, |d| {
d.set(b"Border".to_vec(), Object::Array([0.0, 0.0, 4.0].into_iter().map(Object::Real).collect()));
})
.unwrap();
assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0));
}
+13 -3
View File
@@ -710,9 +710,19 @@ impl Automation {
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?;
let bytes = self.doc_to_copy(a)?.export_office(format);
write_atomic(&path, &bytes)?;
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() }))
let export = self.doc_to_copy(a)?.export_office_checked(format);
write_atomic(&path, &export.bytes)?;
// Text left out to keep a hostile page bounded is said, not silent (#3120).
let truncated: Vec<usize> = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect();
let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(),
"truncated": !truncated.is_empty(), "truncated_pages": truncated });
if !export.truncated_pages.is_empty() {
use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
out["warnings"] = json!([format!(
"forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
)]);
}
Ok(out)
}
fn pdfa_level(&self, a: &Args) -> Result<pdfcraft_engine::pdfa::Level> {
+3 -3
View File
@@ -784,13 +784,13 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
&["doc"],
)),
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
comment_ref(json!({
"contents": { "type": "string" },
"color": color(),
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
"width": { "type": "number", "minimum": 0 },
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." },
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." },
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"move": point(),
@@ -1065,7 +1065,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 or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), 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.")
t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), 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. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.")
.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.")
+99
View File
@@ -5282,6 +5282,74 @@ fn exporting_to_word_html_and_rtf() {
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err());
}
/// #3120: a PDF whose pages draw "Head", then a chain of `depths[page]` nested forms whose
/// innermost writes "TAIL".
fn nested_forms_pdf(depths: &[usize]) -> Vec<u8> {
let mut objs: Vec<String> =
vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), String::new(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()];
let mut kids = Vec::new();
for &depth in depths {
// The object number of D1, D2 and so on.
let first = objs.len() + 1;
let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", first + i - 1)).collect();
let resources = format!("<< /Font << /F1 3 0 R >> /XObject << {xobjects}>> >>");
for i in 1..=depth {
let body = if i == depth { "BT /F1 24 Tf 20 150 Td (TAIL) Tj ET".to_string() } else { format!("/D{} Do", i + 1) };
objs.push(format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 200 300] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream",
body.len()
));
}
let body = "BT /F1 24 Tf 20 250 Td (Head) Tj ET /D1 Do";
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", objs.len()));
kids.push(format!("{} 0 R", objs.len()));
}
objs[1] = format!("<< /Type /Pages /Kids [{}] /Count {} /MediaBox [0 0 200 300] >>", kids.join(" "), kids.len());
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// #3120: export reads forms as deeply nested as the renderer draws them (13 used to lose their
/// text), and its result says which pages draw forms past that, whose text is missing.
#[test]
fn exporting_office_files_says_which_pages_left_form_text_out() {
use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
let dir = workdir("office-forms");
std::fs::write(dir.join("deep.pdf"), nested_forms_pdf(&[13, MAX_FORM_DEPTH + 1, MAX_FORM_DEPTH])).unwrap();
std::fs::write(dir.join("fine.pdf"), nested_forms_pdf(&[13])).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "deep.pdf" }))["doc"].as_u64().unwrap();
for ext in ["html", "docx"] {
let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("deep.{ext}") }));
assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(true), &json!([2])), "{r}");
let warning = r["warnings"][0].as_str().unwrap();
assert!(warning.contains(&format!("{MAX_FORM_DEPTH} deep")) && warning.contains(&MAX_FORM_VISITS.to_string()), "{warning}");
assert!(warning.contains("truncated_pages"), "{warning}");
}
let html = std::fs::read_to_string(dir.join("deep.html")).unwrap();
let tails: Vec<usize> = html.split("<hr>").map(|page| page.matches("TAIL").count()).collect();
assert_eq!(tails, [1, 0, 1], "{html}");
assert_eq!(html.matches("Head").count(), 3, "{html}");
let fine = ok(&mut a, "doc_open", json!({ "path": "fine.pdf" }))["doc"].as_u64().unwrap();
let r = ok(&mut a, "doc_export_office", json!({ "doc": fine, "path": "fine.html" }));
assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(false), &json!([])), "{r}");
assert!(r.get("warnings").is_none(), "{r}");
assert!(std::fs::read_to_string(dir.join("fine.html")).unwrap().contains("TAIL"));
}
/// #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() {
@@ -6697,3 +6765,34 @@ fn a_root_less_write_output_is_atomic_too() {
assert!(matches!(&e, ToolError::Failed(m) if m.contains("is a folder")), "{e:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn text_box_and_callout_fill_and_border_through_tools() {
let dir = workdir("textbox-fill");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for kind in ["textbox", "callout"] {
let mut args = json!({ "doc": doc, "page": 1, "type": kind, "rect": [80, 40, 180, 100], "contents": "Example" });
if kind == "callout" {
args["to"] = json!([20, 120]);
}
let added = ok(&mut a, "comment_add", args);
let comment = &added["comment"];
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": comment["index"], "fill": "white", "width": 3 }));
}
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "filled.pdf" }));
let mut session = pdfcraft_engine::Session::new();
let bytes = std::sync::Arc::new(std::fs::read(dir.join("filled.pdf")).unwrap());
let saved = session.open("filled.pdf", None, bytes, None).unwrap();
for index in 0..2 {
let props = session.get(saved).unwrap().comment_props(0, index).unwrap();
assert_eq!((props.fill, props.width), (Some([1.0; 3]), Some(3.0)));
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": index + 1, "fill": "none" }));
}
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "transparent.pdf" }));
let bytes = std::sync::Arc::new(std::fs::read(dir.join("transparent.pdf")).unwrap());
let saved = session.open("transparent.pdf", None, bytes, None).unwrap();
for index in 0..2 {
assert_eq!(session.get(saved).unwrap().comment_props(0, index).unwrap().fill, None);
}
}
+3 -3
View File
@@ -300,7 +300,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
/// The forms being read (cycle guard) and how many were entered (the page's budget).
struct Forms {
path: Vec<ObjRef>,
visits: usize,
budget: crate::text::FormBudget,
}
fn walk(
doc: &Document,
@@ -317,7 +317,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
let ctm = st.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.visits) {
if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.budget) {
if let Some(r) = form.obj {
forms.path.push(r);
}
@@ -367,7 +367,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut out = Vec::new();
let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
let mut forms = Forms { path: Vec::new(), visits: 0 };
let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() };
let pieces = joined(&streams(doc, &p.dict));
walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?;
Ok(out)
+2 -2
View File
@@ -859,8 +859,8 @@ pub mod indic;
mod linebreak;
pub mod text;
pub use text::{
BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks,
text_lines,
BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, clear_block, reading_blocks,
reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
};
pub mod added;
pub use added::{
+243
View File
@@ -904,6 +904,92 @@ fn text_page(content: &str) -> Document {
Document::open(Arc::new(out)).unwrap()
}
#[test]
fn tracked_text_keeps_words_in_paragraphs_and_objects() {
let expected = "TRACKED SAMPLE TEXT";
for spacing in [1.5, 2.0001, 3.0] {
for scale in [50.0, 100.0, 150.0] {
for positioned in [false, true] {
let mut show = String::new();
if !positioned {
show.push('[');
}
for (i, c) in expected.chars().enumerate() {
if positioned {
show.push_str(&format!("({c}) Tj "));
if i + 1 < expected.len() {
show.push_str(&format!("[{}] TJ ", -spacing * 100.0));
}
} else {
show.push_str(&format!("({c}) "));
if i + 1 < expected.len() {
show.push_str(&format!("{} ", -spacing * 100.0));
}
}
}
if !positioned {
show.push_str("] TJ");
}
let doc = text_page(&format!("BT /F1 10 Tf {scale} Tz 72 700 Td {show} ET"));
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, expected, "{spacing}, {scale}, {positioned}");
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected));
}
}
let doc = text_page(&format!("BT /F1 10 Tf {spacing} Tc 72 700 Td ({expected}) Tj ET"));
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
}
}
#[test]
fn single_glyph_words_and_cells_are_not_tracking() {
for (content, expected) in [
("BT /F1 10 Tf 72 700 Td [(a) -1000 (b) -1000 (c) -1000 (d)] TJ ET", "a b c d"),
("BT /F1 10 Tf 72 700 Td [(1) -2500 (2) -2500 (3) -2500 (4) -2500 (5)] TJ ET", "1 2 3 4 5"),
("BT /F1 10 Tf 72 700 Td [(A) -300 (B) -1200 (C) -1200 (D) -1200 (E) -300 (F) -1200 (G)] TJ ET", "A B C D E F G"),
] {
assert_eq!(text::text_blocks(&text_page(content), 0).unwrap()[0].text, expected, "{content}");
}
let mut s = String::from("BT /F1 10 Tf ");
for (i, c) in "12345".chars().enumerate() {
s.push_str(&format!("1 0 0 1 {} 700 Tm ({c}) Tj ", 72 + i * 12));
}
s.push_str("ET");
assert_eq!(text::text_blocks(&text_page(&s), 0).unwrap()[0].text, "1 2 3 4 5");
}
#[test]
fn tracked_text_preserves_larger_word_gaps() {
for show in [
"[(T) -300 (E) -300 (S) -300 (T) -600 (W) -300 (O) -300 (R) -300 (D)] TJ",
"(T) Tj [-300] TJ (E) Tj [-300] TJ (S) Tj [-300] TJ (T) Tj [-600] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj",
"[(TEST) -300 (WORD)] TJ",
"(TEST) Tj [-300] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj",
] {
let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET"));
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, "TEST WORD", "{show}");
}
}
#[test]
fn tracked_text_positions_do_not_count_character_spacing_twice() {
let expected = "TRACKED SAMPLE TEXT";
for char_spacing in [0.0, 2.0] {
for transform in ["1 0 0 1 0 0", "0 1 -1 0 800 0"] {
let mut content = format!("q {transform} cm BT /F1 10 Tf {char_spacing} Tc ");
let mut x = 72.0;
for c in expected.chars() {
content.push_str(&format!("1 0 0 1 {x:.4} 700 Tm ({c}) Tj "));
x += pdfcraft_fonts::helvetica_width(&c.to_string(), 10.0) + 2.0001;
}
content.push_str("ET Q");
let doc = text_page(&content);
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected);
assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected));
}
}
}
fn descriptor_text_page() -> Document {
let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET";
let objs: Vec<String> = vec![
@@ -2896,6 +2982,88 @@ fn form_xobjects_that_fan_out_stop_at_the_visit_budget() {
assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed());
}
/// #3120: a one-page document whose page draws "Secret", then `content`. `forms` are (name,
/// content) form XObjects, which the page and every form can draw, as they can a 1×1 image `/Im`.
fn page_with_forms(content: &str, forms: &[(String, String)]) -> Document {
let xobjects: String = forms.iter().enumerate().map(|(i, (name, _))| format!("/{name} {} 0 R ", 7 + i)).collect();
let resources = format!("<< /Font << /F1 5 0 R >> /XObject << /Im 6 0 R {xobjects}>> >>");
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources {resources} /Contents 4 0 R >>"),
stream("", &format!("BT /F1 24 Tf 72 700 Td (Secret) Tj ET\n{content}")),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"),
];
objs.extend(forms.iter().map(|(_, body)| stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources {resources}"), body)));
build(&objs.iter().map(String::as_str).collect::<Vec<_>>())
}
/// #3120: export reads text from forms as deep and as often as the renderer draws them, and says
/// when a limit left some out: a text form drawn 5 000 times (past the old budget of 4 096) or
/// forms nested 13 deep (past the old 12) lost the form drawn after them, silently.
#[test]
fn reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops() {
// Covers "Secret" with a black box and writes "TAIL": drawn last, so a reader that stops
// early misses it.
const TAIL: &str = "0 g 70 692 90 30 re f BT /F1 24 Tf 72 600 Td (TAIL) Tj ET";
let tail = |doc: &Document| {
let read = text::reading_blocks_checked(doc, 0).unwrap();
(read.blocks.iter().any(|b| b.text.contains("TAIL")), read.truncated)
};
let form = |name: &str, body: &str| (name.to_string(), body.to_string());
let rows =
|n: usize| page_with_forms(&format!("{}/C Do", "/R Do ".repeat(n)), &[form("R", "BT /F1 6 Tf 300 100 Td (row) Tj ET"), form("C", TAIL)]);
assert_eq!(tail(&rows(5_000)), (true, false));
// The innermost form also draws an image: an image at the depth limit is read, and leaves
// nothing out.
let chain = |depth: usize| {
let mut forms: Vec<_> = (1..depth).map(|i| form(&format!("D{i}"), &format!("/D{} Do", i + 1))).collect();
forms.push(form(&format!("D{depth}"), &format!("{TAIL} q 20 0 0 10 5 5 cm /Im Do Q")));
page_with_forms("/D1 Do", &forms)
};
assert_eq!(tail(&chain(13)), (true, false));
// The renderer's limit, written out so the test does not move with the constant.
assert_eq!(tail(&chain(50)), (true, false));
assert_eq!(tail(&chain(51)), (false, true));
let deepest = chain(text::MAX_FORM_DEPTH);
assert_eq!(tail(&deepest), (true, false));
assert_eq!(images::reading_images(&deepest, 0).unwrap().len(), 1);
let deep = chain(text::MAX_FORM_DEPTH + 1);
assert_eq!(tail(&deep), (false, true));
assert!(images::reading_images(&deep, 0).unwrap().is_empty());
// A wrapper form A draws a dot form B `n` times, then C. A, the dots and C use the page's
// budget up exactly; one dot more leaves C unread.
let fanout =
|n: usize| page_with_forms("/A Do", &[form("A", &format!("{}/C Do", "/B Do ".repeat(n))), form("B", "0 g 10 10 1 1 re f"), form("C", TAIL)]);
assert_eq!(tail(&fanout(text::MAX_FORM_VISITS - 2)), (true, false));
let many = fanout(text::MAX_FORM_VISITS - 1);
assert_eq!(tail(&many), (false, true));
assert!(images::reading_images(&many, 0).unwrap().is_empty());
// Without the flag, reading is unchanged.
let blocks: Vec<String> = text::reading_blocks(&many, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(blocks, ["Secret"]);
}
/// #3120: with the renderer's limits, forms that each draw the next a hundred times, 12 levels
/// deep (100^11 forms) or past the depth cap, still stop at the page's budget quickly, and say
/// that text was left out.
#[test]
fn forms_that_fan_out_a_hundred_ways_stop_quickly_and_say_so() {
for levels in [12, text::MAX_FORM_DEPTH + 1] {
let mut forms: Vec<(String, String)> = (1..levels).map(|i| (format!("N{i}"), format!("/N{} Do ", i + 1).repeat(100))).collect();
forms.push((format!("N{levels}"), "BT /F1 10 Tf 10 10 Td (leaf) Tj ET".to_string()));
let doc = page_with_forms("/N1 Do", &forms);
let start = std::time::Instant::now();
assert!(text::reading_blocks_checked(&doc, 0).unwrap().truncated, "{levels} levels");
assert!(images::reading_images(&doc, 0).unwrap().is_empty());
assert!(start.elapsed() < std::time::Duration::from_secs(30), "{levels} levels took {:?}", start.elapsed());
}
}
/// Forms that draw themselves or each other, have no resources, a malformed matrix, or are
/// missing are read once (or skipped) without panicking or looping.
#[test]
@@ -4975,3 +5143,78 @@ fn flattening_comments_keeps_annotations_that_are_not_comments() {
let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect();
assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget");
}
/// A page whose text is a clipping mask (`mode` Tr) with a fill painted through it.
fn clipping_text_page(mode: i64) -> Document {
text_page(&format!("q\nBT {mode} Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n"))
}
/// Rewriting text that is a clipping path destroyed the artwork it masked: the glyphs went, the
/// mask with them, and the fill behind flooded its own rectangle, in a file that still opened
/// without a warning (#3429). Photoshop exports draw every text object this way. The rewrite is
/// refused instead, as `clear_block` already refuses a preview.
#[test]
fn rewriting_clipping_path_text_is_refused_for_every_clipping_mode() {
for mode in [4, 5, 6, 7] {
let before = text::text_lines(&clipping_text_page(mode), 0).unwrap();
assert_eq!(before.len(), 1, "mode {mode}: the mask is one line");
let mut doc = clipping_text_page(mode);
let line = text::replace_line(&mut doc, 0, 0, "EDITED");
assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {line:?}");
let mut doc = clipping_text_page(mode);
let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default());
assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {block:?}");
// Refused means untouched: the mask is still there to shape what is painted through it.
let after = text::text_lines(&doc, 0).unwrap();
assert_eq!(after.len(), 1, "mode {mode}");
assert_eq!(after[0].text, "MASK", "mode {mode}");
}
}
/// The modes that paint ink are still editable: only 4 to 7 add to the clip path.
#[test]
fn painting_modes_are_still_rewritten() {
for mode in [0, 1, 2] {
let mut doc = clipping_text_page(mode);
assert!(text::replace_line(&mut doc, 0, 0, "EDITED").is_ok(), "mode {mode}");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "EDITED", "mode {mode}");
}
}
/// A line's runs need not share a rendering mode: nothing in the join requires it, so a line that
/// starts as ink can continue as a mask. The refusal reads the whole line, not the mode its first
/// run set -- otherwise the rewrite passed the guard and took the mask away with the ink.
#[test]
fn a_line_that_starts_as_ink_and_continues_as_a_mask_is_refused() {
let content = "q\nBT /F1 48 Tf 40 60 Td 0 Tr (TITLE) Tj 7 Tr (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n";
let before = text::text_lines(&text_page(content), 0).unwrap();
assert_eq!(before.len(), 1, "the two runs are one line: {:?}", before.iter().map(|l| &l.text).collect::<Vec<_>>());
assert_eq!(before[0].text, "TITLEMASK", "and the mask is the second half of it");
let mut doc = text_page(content);
let line = text::replace_line(&mut doc, 0, 0, "EDITED");
assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}");
let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default());
assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "TITLEMASK", "refused means untouched");
}
/// Another drawing of the line on the same baseline goes with it, so that the replacement is all
/// that shows -- and that copy can be the mask while the line being replaced is ink. Dropping it
/// destroys the artwork as surely as rewriting the mask itself, so this is refused too.
#[test]
fn a_clipping_copy_on_the_same_baseline_is_refused() {
let content = "q\nBT 0 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\nBT 7 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n";
let before = text::text_lines(&text_page(content), 0).unwrap();
assert_eq!(before.len(), 2, "two drawings of the same line");
let mut doc = text_page(content);
let line = text::replace_line(&mut doc, 0, 0, "EDITED");
assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}");
let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default());
assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}");
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched");
}
+175 -28
View File
@@ -68,6 +68,10 @@ pub struct TextLine {
/// Some of the line's text is `/ActualText`, which is already in logical order: visual-order
/// Indic recovery would scramble it.
actual_text: bool,
/// Whether any of the line's runs draws in a clipping mode (`4 Tr` to `7 Tr`). The join below
/// does not require one rendering mode, so a line that starts as ink can continue as a mask;
/// [`refuse_clipping_text`] reads this rather than the origin's mode alone.
clips: bool,
}
impl TextLine {
@@ -635,6 +639,7 @@ struct Shown {
glyph_count: usize,
/// Advance in text space, including spacing and TJ adjustments.
advance: f64,
tracking: f64,
/// The text matrix where it starts (text space), and the state there and at its `BT`.
tm: Matrix,
tlm: Matrix,
@@ -702,6 +707,77 @@ impl Pen {
}
}
/// Use the lower third of letter gaps, not word gaps. A pair of isolated labels is not
/// evidence of tracking; unadjusted neighbours inside a string contribute zero too.
/// The tracking a run of glyphs was set with: the lower third of its gaps, when that is at
/// most half an em (`limit`). A wider "tracking" is word or cell spacing between one-glyph
/// words (`a b c`, a column of digits or Y/N ticks), which must keep its spaces.
fn typical_tracking(gaps: &mut [f64], limit: f64) -> f64 {
if gaps.len() < 3 {
return 0.0;
}
let at = gaps.len() / 3;
gaps.select_nth_unstable_by(at, f64::total_cmp);
gaps.get(at).copied().filter(|n| n.is_finite() && *n <= limit).unwrap_or(0.0).max(0.0)
}
fn tj_tracking(pieces: &[Object], metrics: &Metrics, size: f64, scale: f64) -> f64 {
let mut gaps = Vec::new();
let mut previous = false;
let mut gap = 0.0;
for piece in pieces {
if let Object::String(s) = piece {
for (code, len) in metrics.codes(&s.bytes) {
let blank = metrics.is_space(code, len) || metrics.text_of(code).is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
if previous && !blank {
gaps.push(gap);
}
previous = !blank;
gap = 0.0;
}
} else if let Some(n) = piece.as_f64() {
gap += -n / 1000.0 * size * scale;
}
}
typical_tracking(&mut gaps, size * scale.abs() * 0.5)
}
fn positioned_tracking(shown: &mut [Shown]) {
let mut start = 0;
while start < shown.len() {
let mut end = start.saturating_add(1);
while let (Some(prev), Some(next)) = (shown.get(end.saturating_sub(1)), shown.get(end)) {
if prev.glyph_count != 1
|| next.glyph_count != 1
|| prev.font != next.font
|| prev.scope != next.scope
|| (prev.size - next.size).abs() > 0.01
|| prev.state.char_spacing != next.state.char_spacing
|| prev.state.scale != next.state.scale
|| !Pen::after(prev).continues(next, 1.5).0
{
break;
}
end = end.saturating_add(1);
}
if let Some(run) = shown.get_mut(start..end) {
let mut gaps: Vec<f64> = run
.windows(2)
.filter_map(|pair| {
let [prev, next] = pair else { return None };
(!prev.text.trim().is_empty() && !next.text.trim().is_empty()).then(|| Pen::after(prev).continues(next, 0.0).1)
})
.collect();
let limit = run.first().map_or(0.0, |s| s.size * s.state.scale.abs() * 0.5);
let tracking = typical_tracking(&mut gaps, limit);
for s in run.iter_mut().skip(1) {
s.tracking = tracking;
}
}
start = end;
}
}
/// The graphics state carried from one of a page's content streams to the next: the streams
/// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
@@ -883,6 +959,8 @@ fn interpret(
b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
// Tc is already included in the pen advance; TJ may encode tracking instead.
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
let trm0 = tm.then(&ts.ctm);
let start = trm0.apply(0.0, ts.rise);
let mut text = String::new();
@@ -924,12 +1002,12 @@ fn interpret(
if let Some(n) = other.as_f64() {
let dx = -n / 1000.0 * ts.size * ts.scale;
// A large gap inside TJ reads as a space.
if dx > ts.size * 0.2 && !text.ends_with(' ') {
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
// Kerning keeps a word together; a jump either way of more
// than 0.2 em ends it (whatever the sign of the size or `Tz`).
if dx.abs() > (ts.size * ts.scale).abs() * 0.2 {
// Tracking and kerning keep a word together; a larger jump
// ends it (whatever the sign of the size or `Tz`).
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
in_word = false;
}
x_text += dx;
@@ -976,6 +1054,7 @@ fn interpret(
extra_ops: Vec::new(),
glyph_count,
advance: x_text,
tracking: 0.0,
tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]),
state: ts.clone(),
tlm,
@@ -1009,6 +1088,7 @@ fn interpret(
carry.ts = ts;
carry.stack = stack;
carry.marked = marked;
positioned_tracking(&mut out);
out
}
@@ -1057,7 +1137,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
});
if joins && let Some(l) = lines.last_mut() {
let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1);
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
l.text.push(' ');
}
l.text.push_str(&s.text);
@@ -1069,6 +1149,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
l.decodable &= s.decodable;
l.visual_order |= s.visual_order;
l.actual_text |= s.actual_text;
l.clips |= s.state.render_mode >= 4;
// Spaces may use a separate subset font; they do not change the visible style.
} else {
lines.push(TextLine {
@@ -1089,6 +1170,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
scope: s.scope,
logical_order: s.logical_order,
actual_text: s.actual_text,
clips: s.state.render_mode >= 4,
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
@@ -1147,7 +1229,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
});
if joins && let Some(l) = lines.last_mut() {
let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1);
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
l.text.push(' ');
}
l.text.push_str(&s.text);
@@ -1159,6 +1241,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
l.decodable &= s.decodable;
l.visual_order |= s.visual_order;
l.actual_text |= s.actual_text;
l.clips |= s.state.render_mode >= 4;
} else {
lines.push(TextLine {
text: s.text.clone(),
@@ -1178,6 +1261,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
scope: s.scope,
logical_order: s.logical_order,
actual_text: s.actual_text,
clips: s.state.render_mode >= 4,
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
origin: Origin {
tm: s.tm.0,
@@ -1195,13 +1279,27 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
*last = Some(Pen::after(&s));
}
/// How deep form XObjects nest before reading stops (they may also refer to themselves).
pub(crate) const MAX_FORM_DEPTH: usize = 12;
/// How deep form XObjects nest before reading stops (they may also refer to themselves). The
/// renderer stops at the same depth, so export reads the text the page shows (#3120).
pub const MAX_FORM_DEPTH: usize = 50;
/// The most form XObjects one page's walk enters, at any depth. The depth cap alone still lets
/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 12
/// levels), which would hang export on a hostile PDF.
pub(crate) const MAX_FORM_VISITS: usize = 4096;
/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 50
/// levels), which would hang export on a hostile PDF. The renderer paints as many forms nested
/// in forms; this budget also counts the forms the page draws itself, so a page that draws one
/// a million times stays bounded too. A label or table cell drawn as a form a few thousand
/// times is read in full (#3120).
pub const MAX_FORM_VISITS: usize = 50_000;
/// One page's walk through the form XObjects it draws: how many it entered (the page's
/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread.
#[derive(Default)]
pub(crate) struct FormBudget {
visits: usize,
/// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past
/// [`MAX_FORM_VISITS`]: what it draws is missing from the walk.
pub(crate) truncated: bool,
}
/// A form XObject a page draws: its object, content, resources and its matrix composed with the
/// CTM at the `Do`.
@@ -1213,17 +1311,19 @@ pub(crate) struct FormCall {
}
/// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names
/// and forms already being read (`path`), nested too deep, or past the page's `visits` budget
/// ([`MAX_FORM_VISITS`]); each form entered counts one visit.
/// and forms already being read (`path`), and for forms nested too deep or past the page's
/// budget, which `budget` records as truncated; each form entered counts one visit.
pub(crate) fn form_call(
doc: &Document,
resources: &Dict,
name: &[u8],
ctm: Matrix,
path: &[pdfcraft_cos::ObjRef],
visits: &mut usize,
budget: &mut FormBudget,
) -> Option<FormCall> {
if path.len() >= MAX_FORM_DEPTH || *visits >= MAX_FORM_VISITS {
let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS;
// Once a limit has cut the page short, refusing the rest costs nothing more.
if limited && budget.truncated {
return None;
}
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?;
@@ -1236,6 +1336,11 @@ pub(crate) fn form_call(
if s.dict.name(b"Subtype") != Some(b"Form") {
return None;
}
// Only a form left unread loses content; an image at the limit is still read by its caller.
if limited {
budget.truncated = true;
return None;
}
let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| {
let a = m.as_array()?;
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
@@ -1243,7 +1348,7 @@ pub(crate) fn form_call(
});
// A form without its own resources uses the ones it was drawn with (PDF 1.1 files).
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
*visits = visits.saturating_add(1);
budget.visits = budget.visits.saturating_add(1);
Some(FormCall {
obj,
data: s.decoded().unwrap_or_default(),
@@ -1254,14 +1359,15 @@ pub(crate) fn form_call(
/// The lines a page shows as it reads, including text drawn by form XObjects (which
/// [`text_lines`] leaves out because it can only rewrite the page's own streams). Lines from a
/// form carry a stream number past the page's streams, one per form drawn.
fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError> {
/// form carry a stream number past the page's streams, one per form drawn. The flag is `true`
/// when forms past [`MAX_FORM_DEPTH`] or [`MAX_FORM_VISITS`] were left unread.
fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine>, bool), EditError> {
struct Walk<'a> {
doc: &'a Document,
lines: Vec<TextLine>,
next_stream: usize,
path: Vec<pdfcraft_cos::ObjRef>,
visits: usize,
budget: FormBudget,
}
/// `stream` gives the stream number of the operator at an index of `ops`.
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) {
@@ -1291,7 +1397,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
}
}
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) {
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.visits) else { return };
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return };
let ops = parse(&form.data).ops;
// The form starts with the graphics state at its `Do`, its own matrix applied.
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new() };
@@ -1308,7 +1414,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict);
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 };
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() };
let mut carry = Carry::new();
// Read the page's streams as the one stream they are, in one pass, as rendering and editing
// do: a comment, an operator's operands or a marked-content sequence (its /ActualText too)
@@ -1324,7 +1430,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
l.text = crate::indic::to_logical(&l.text);
}
lines.retain(|l| !l.text.trim().is_empty());
Ok(lines)
Ok((lines, w.budget.truncated))
}
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
@@ -1739,11 +1845,33 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS)
}
/// Refuses a rewrite that would remove text drawn as a clipping path (`4 Tr` to `7 Tr`). Such
/// glyphs are not ink but a mask, and whatever is painted through them takes their shape.
/// Removing the glyphs removes the mask, and the fill or image behind floods its own rectangle
/// instead -- the artwork is destroyed, in a file that still opens without a warning (#3429).
/// `clear_block` already refuses these for the same reason.
///
/// Every line the rewrite drops is checked, not only the one being replaced: a line's runs need
/// not share a rendering mode (see [`TextLine::clips`]), and [`coincident_ops`] drops a whole
/// overlapping line without regard to its mode -- so a mask could go with text that is ink.
fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet<usize>) -> Result<(), EditError> {
if lines.iter().any(|l| l.clips && l.ops.iter().any(|op| dropped.contains(op))) {
return Err(EditError::Invalid("text used as a clipping path cannot be edited: its glyphs mask the artwork behind them".into()));
}
Ok(())
}
/// [`replace_line`] with the most unique characters one generated font may hold (tests lower it).
pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result<LineEdit, EditError> {
let text = text.replace(['\n', '\r'], " ");
let lines = text_lines(doc, page)?;
let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?;
// Copies of the line drawn on the same baseline go with it, so the replacement is all that
// shows. Which operators go is settled here, before any font is generated, so that a refusal
// leaves the document untouched.
let mut drops = coincident_ops(&lines, &[&target]);
drops.extend(target.ops.iter().copied());
refuse_clipping_text(&lines, &drops)?;
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
@@ -1817,10 +1945,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
}
}
}
// Rebuild: the line's first operator becomes the replacement, its others go. Copies of the
// line drawn on the same baseline go too, so the replacement is all that shows.
let mut drops = coincident_ops(&lines, &[&target]);
drops.extend(target.ops.iter().copied());
// Rebuild: the line's first operator becomes the replacement, its others go.
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
let contents = rewritten_contents(doc, &streams, new_data);
let page_ref = p.obj;
@@ -1940,9 +2065,27 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
/// The paragraphs on a page as a reader sees them, including text drawn by form XObjects
/// (Export to Word, HTML and RTF). Read-only: indexes into this list are not
/// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`].
/// [`reading_blocks_checked`] also says whether text was left out.
pub fn reading_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditError> {
let lines = reading_lines(doc, page)?;
Ok(regions::group_reading_blocks(&lines))
reading_blocks_checked(doc, page).map(|r| r.blocks)
}
/// A page's paragraphs as a reader sees them ([`reading_blocks_checked`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ReadingBlocks {
/// The paragraphs, as [`reading_blocks`] returns them.
pub blocks: Vec<TextBlock>,
/// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than
/// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their
/// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses
/// their images too.
pub truncated: bool,
}
/// [`reading_blocks`], saying whether the limits that bound reading left text out.
pub fn reading_blocks_checked(doc: &Document, page: usize) -> Result<ReadingBlocks, EditError> {
let (lines, truncated) = reading_lines(doc, page)?;
Ok(ReadingBlocks { blocks: regions::group_reading_blocks(&lines), truncated })
}
// An ideographic space carries intentional Japanese spacing, including paragraph indentation.
@@ -2063,6 +2206,10 @@ fn rewrite_block_from(
let blocks = group_blocks(&lines);
let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect();
// One clipping run is enough: rewriting the paragraph would drop its mask with the rest.
let mut dropped = coincident_ops(&lines, &members);
dropped.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
refuse_clipping_text(&lines, &dropped)?;
let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?;
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
+42 -8
View File
@@ -4,6 +4,9 @@
use std::sync::Arc;
pub use pdfcraft_compare::{Change, Comparison, Kind, Side};
// How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages
// that draw more.
pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape};
@@ -151,12 +154,31 @@ impl OfficeFormat {
}
}
/// A Word, PowerPoint, HTML or RTF export ([`crate::Document::export_office_checked`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OfficeExport {
/// The exported file.
pub bytes: Vec<u8>,
/// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them
/// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so
/// the text and images of those forms are missing from `bytes`.
pub truncated_pages: Vec<usize>,
}
impl crate::Document {
/// The pages as paragraphs and images (for Word, HTML and RTF export), including what
/// form XObjects draw.
pub fn export_pages(&self) -> Vec<pdfcraft_export::Page> {
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return Vec::new() };
self.info
self.export_pages_checked().0
}
/// [`Self::export_pages`], and the pages whose forms were left partly unread
/// ([`OfficeExport::truncated_pages`]).
fn export_pages_checked(&self) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) };
let mut truncated = Vec::new();
let pages = self
.info
.pages
.iter()
.enumerate()
@@ -171,8 +193,12 @@ impl crate::Document {
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()
let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default();
if reading.truncated {
truncated.push(i);
}
let blocks = reading
.blocks
.into_iter()
.filter(|b| !b.text.trim().is_empty())
.map(|b| {
@@ -204,18 +230,26 @@ impl crate::Document {
.collect();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images }
})
.collect()
.collect();
(pages, truncated)
}
/// The document as a Word, PowerPoint, HTML or RTF file.
pub fn export_office(&self, format: OfficeFormat) -> Vec<u8> {
let pages = self.export_pages();
self.export_office_checked(format).bytes
}
/// [`Self::export_office`], and the pages whose text it had to leave out
/// ([`OfficeExport::truncated_pages`]).
pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport {
let (pages, truncated_pages) = self.export_pages_checked();
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
match format {
let bytes = match format {
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
}
};
OfficeExport { bytes, truncated_pages }
}
}
+212 -27
View File
@@ -9,6 +9,7 @@
//! writes them back when a form is authored. Other scripts are kept untouched and reported as
//! unsupported; they need the JavaScript engine (M6.4).
use std::collections::VecDeque;
use std::fmt::Write as _;
/// The Format tab.
@@ -545,24 +546,55 @@ fn weekday(y: i32, m: u32, d: u32) -> usize {
(y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize
}
/// The order of the year/month/day fields in a format, as letters. A run of literal digits
/// (the fixed year in "dd.mm.26") is `'#'` with its value; the others carry 0.
fn order(fmt: &str) -> Vec<(char, u32)> {
let mut out: Vec<(char, u32)> = Vec::new();
/// One field of a date format, in the order the format has them.
struct Part {
/// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in
/// "dd.mm.26") is `'#'`.
kind: char,
/// The value of a `'#'` run; 0 for the others.
literal: u32,
/// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for
/// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is
/// a name ("mmm").
width: usize,
/// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits
/// can hold both.
joined: bool,
}
/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in
/// order.
fn order(fmt: &str) -> Vec<Part> {
let mut out: Vec<Part> = Vec::new();
// Weekday names (ddd, dddd) are shown, not typed: leave them out.
let mut cleaned = fmt.replace("dddd", " ");
cleaned = cleaned.replace("ddd", " ");
let mut prev = ' ';
// The letter of the last field's current run and its length so far.
let mut run: Option<(char, usize)> = None;
let fixed = |k: char, n: usize| match (k, n) {
('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n,
_ => 0,
};
for c in cleaned.chars() {
let continues = run.is_some_and(|(r, _)| r == c);
if let Some((r, n)) = run
&& !continues
{
// The run ended; it was the last field's width.
if let Some(last) = out.last_mut().filter(|p| p.kind != '#') {
last.width = fixed(if r == 'h' { 'H' } else { r }, n);
}
run = None;
}
if let Some(digit) = c.to_digit(10) {
match out.last_mut() {
Some(('#', n)) if prev.is_ascii_digit() => *n = n.saturating_mul(10).saturating_add(digit),
_ => out.push(('#', digit)),
Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit),
_ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }),
}
prev = c;
continue;
}
prev = c;
let k = match c {
'y' => 'y',
'm' => 'm',
@@ -570,11 +602,28 @@ fn order(fmt: &str) -> Vec<(char, u32)> {
'H' | 'h' => 'H',
'M' => 'M',
's' => 's',
_ => continue,
_ => {
prev = c;
continue;
}
};
if out.last().map(|&(l, _)| l) != Some(k) {
out.push((k, 0));
if continues {
run = run.map(|(r, n)| (r, n.saturating_add(1)));
} else {
// A field starting right where the last one's letters stopped is joined to it.
let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's');
if out.last().map(|p| p.kind) != Some(k) {
if let Some(last) = out.last_mut().filter(|p| p.kind != '#') {
last.joined = adjacent;
}
out.push(Part { kind: k, literal: 0, width: 0, joined: false });
}
run = Some((c, 1));
}
prev = c;
}
if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) {
last.width = fixed(if r == 'h' { 'H' } else { r }, n);
}
out
}
@@ -591,16 +640,17 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit()));
let pm = lower.contains("pm") || bare_p;
let am = lower.contains("am");
// Tokens: numbers and month names.
let mut nums: Vec<u32> = Vec::new();
// Tokens: runs of digits (kept as text, so a run can be split between fields typed without
// separators) and month names.
let mut nums: VecDeque<String> = VecDeque::new();
let mut month_name: Option<u32> = None;
let mut cur = String::new();
let flush = |cur: &mut String, nums: &mut Vec<u32>, month: &mut Option<u32>| {
let flush = |cur: &mut String, nums: &mut VecDeque<String>, month: &mut Option<u32>| {
if cur.is_empty() {
return;
}
if let Ok(n) = cur.parse::<u32>() {
nums.push(n);
if cur.starts_with(|c: char| c.is_ascii_digit()) {
nums.push_back(std::mem::take(cur));
} else if cur.len() >= 3
&& let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase()))
{
@@ -622,34 +672,76 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
}
flush(&mut cur, &mut nums, &mut month_name);
let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 };
let mut it = nums.into_iter().peekable();
// The next number for a field. A field joined to the next one in the format ("yyyymmdd")
// takes only its own width from a longer run of digits and leaves the rest for the next.
// A run typed on its own is split only when fewer runs were typed than the fields of its kind
// still to fill (`need`, this one included: date fields for a date field, time fields for a
// time field, since times may be left out), and only when its rest can fill the next field
// (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its
// four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left
// of a split run (`tail`: the front of `nums` is one) goes on being split along the fields
// written side by side, and must fill its last field exactly, so "202610018" leaves "018" for
// dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a
// rest has the wrong width.
let next_number = |nums: &mut VecDeque<String>, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option<u32> {
let run = nums.pop_front()?;
let was_tail = std::mem::replace(tail, false);
let short = nums.len() < need.saturating_sub(1);
let digits = match (run.get(..part.width), run.get(part.width..)) {
(Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => {
nums.push_front(rest.to_string());
*tail = true;
head
}
_ if was_tail && part.width > 0 && run.len() != part.width => return None,
_ => run.as_str(),
};
digits.parse::<u32>().ok()
};
let parts = order(fmt);
let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none());
let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's');
// The fields of each kind still to fill: [date, time].
let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()];
let mut tail = false;
let mut have_y = false;
for (k, literal) in order(fmt) {
match k {
for (i, part) in parts.iter().enumerate() {
let kind = usize::from(is_time(part));
let need = wanted.get(kind).copied().unwrap_or(0);
let next_width = parts.get(i + 1).map_or(0, |p| p.width);
if takes_number(part)
&& let Some(left) = wanted.get_mut(kind)
{
*left = left.saturating_sub(1);
}
let take = |nums: &mut VecDeque<String>, tail: &mut bool| next_number(nums, tail, part, need, next_width);
match part.kind {
// Literal digits in the format may be typed or left out ("15.05.26" or "15.05"
// for "dd.mm.26"); a different number there is left over and rejected below.
'#' if it.peek() == Some(&literal) => {
it.next();
'#' if nums.front().and_then(|n| n.parse::<u32>().ok()) == Some(part.literal) => {
nums.pop_front();
tail = false;
}
'm' => {
dt.m = match month_name {
Some(m) => m,
None => it.next()?,
None => take(&mut nums, &mut tail)?,
}
}
'd' => dt.d = it.next()?,
'd' => dt.d = take(&mut nums, &mut tail)?,
'y' => {
let y = i32::try_from(it.next()?).ok()?;
let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?;
dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y };
have_y = true;
}
'H' => dt.hh = it.next().unwrap_or(0),
'M' => dt.mm = it.next().unwrap_or(0),
's' => dt.ss = it.next().unwrap_or(0),
// Times may be left out; a number that is there must be readable.
'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
_ => {}
}
}
if it.next().is_some() {
if !nums.is_empty() {
return None;
}
if !have_y {
@@ -1309,3 +1401,96 @@ mod pm_marker_tests {
assert_eq!(parse_date("05/09/2026 5:30 pm", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17));
}
}
#[cfg(test)]
mod undelimited_tests {
use super::*;
fn ymd(dt: Option<DateTime>) -> Option<(i32, u32, u32)> {
dt.map(|d| (d.y, d.m, d.d))
}
/// Fields written side by side in the format ("yyyymmdd") take their fixed widths from one
/// run of typed digits. Every such date used to be refused, because the run was read as a
/// single number.
#[test]
fn digits_typed_without_separators_fill_adjacent_fields() {
assert_eq!(ymd(parse_date("20261018", "yyyymmdd")), Some((2026, 10, 18)));
assert_eq!(ymd(parse_date("261018", "yymmdd")), Some((2026, 10, 18)));
assert_eq!(ymd(parse_date("10182026", "mmddyyyy")), Some((2026, 10, 18)));
assert_eq!(ymd(parse_date("18102026", "ddmmyyyy")), Some((2026, 10, 18)));
let f = Format::Date("yyyymmdd".into());
assert_eq!(keystroke(&f, "Date", "20261018"), Ok("20261018".into()));
assert_eq!(format_value(&f, "20261018"), "20261018");
}
#[test]
fn separators_are_still_accepted_in_an_undelimited_format() {
assert_eq!(ymd(parse_date("2026-10-18", "yyyymmdd")), Some((2026, 10, 18)));
assert_eq!(ymd(parse_date("2026 1018", "yyyymmdd")), Some((2026, 10, 18)));
}
/// Separators mean one number per field, as before: a four-digit year typed with them stays
/// whole in a two-digit-year format. Only a run covering several fields is split.
#[test]
fn a_run_is_split_only_when_it_has_to_fill_several_fields() {
assert_eq!(ymd(parse_date("2026-10-18", "yymmdd")), Some((2026, 10, 18)));
assert_eq!(ymd(parse_date("2026-10", "yymm")), Some((2026, 10, 1)));
assert_eq!(ymd(parse_date("2610", "yymm")), Some((2026, 10, 1)));
assert_eq!(ymd(parse_date("202610 18", "yyyymmdd")), Some((2026, 10, 18)));
assert_eq!(ymd(parse_date("18Oct2026", "ddmmmyyyy")), Some((2026, 10, 18)));
}
#[test]
fn impossible_undelimited_dates_are_refused() {
let f = Format::Date("yyyymmdd".into());
assert!(keystroke(&f, "Date", "20261340").is_err(), "no month 13");
assert!(keystroke(&f, "Date", "20260230").is_err(), "no 30 February");
assert!(keystroke(&f, "Date", "2026101899").is_err(), "digits left over make no day");
assert!(keystroke(&f, "Date", "2026").is_err(), "a year alone is not a date");
}
/// What is left of a split run fills its field exactly: "202610018" leaves "018" for the
/// day, one digit too many, and used to pass as 18 October. Numbers typed on their own
/// keep their leeway.
#[test]
fn the_rest_of_a_split_run_must_fill_its_field_exactly() {
assert_eq!(parse_date("202610018", "yyyymmdd"), None, "one digit too many");
assert_eq!(parse_date("2026101", "yyyymmdd"), None, "one digit too few");
assert_eq!(parse_date("2026101814305", "yyyymmddHHMM"), None, "one digit too many in the time");
assert_eq!(ymd(parse_date("2026 10 018", "yyyymmdd")), Some((2026, 10, 18)), "typed on its own, 018 is still a day");
assert_eq!(parse_date("202610181430", "yyyymmddHHMM").map(|d| (d.d, d.hh, d.mm)), Some((18, 14, 30)));
}
/// Times may be left out, so a number typed on its own isn't split for them: "018" is the
/// day in "2026 10 018" even when the format goes on to HHMM.
#[test]
fn a_number_typed_on_its_own_is_split_only_when_the_rest_fills_the_next_field() {
let dt = parse_date("2026 10 018", "yyyymmddHHMM").expect("a date with the time left out");
assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0));
assert_eq!(ymd(parse_date("20261018", "yyyymmddHHMM")), Some((2026, 10, 18)));
assert_eq!(parse_date("2026 10 18 1430", "yyyymmdd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30)));
}
/// The date's runs are matched against the date's fields, the time's against the time's:
/// optional times left out don't make a separated four-digit year look like it needs
/// splitting, and a time typed in one run still fills HHMM.
#[test]
fn date_and_time_fields_are_counted_apart() {
let dt = parse_date("2026-10-18", "yymmddHHMM").expect("a date with the time left out");
assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0));
assert_eq!(parse_date("20261018 1430", "yyyymmddHHMM").map(|d| (d.y, d.m, d.d, d.hh, d.mm)), Some((2026, 10, 18, 14, 30)));
assert_eq!(parse_date("2026-10-18 1430", "yyyy-mm-dd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30)));
assert_eq!(parse_date("20261018143005", "yyyymmddHHMMss").map(|d| (d.d, d.hh, d.mm, d.ss)), Some((18, 14, 30, 5)));
}
/// A run of more digits than fit in a u32 ("yyyymmddHHMMss") is split, not dropped.
#[test]
fn long_runs_of_digits_split_into_dates_and_times() {
let dt = parse_date("20261018143005", "yyyymmddHHMMss").expect("valid date and time");
assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm, dt.ss), (2026, 10, 18, 14, 30, 5));
assert_eq!(parse_date("1430", "HHMM").map(|d| (d.hh, d.mm)), Some((14, 30)));
assert_eq!(parse_date("99999999999999999999", "yyyymmdd"), None);
assert_eq!(parse_date("2026 99999999999", "yyyy"), None, "an unreadable number is left over, not ignored");
}
}
+2
View File
@@ -23,6 +23,8 @@
//! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency
//! only, never compiled into the product.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod pixels;
use std::fmt;
+12
View File
@@ -201,6 +201,10 @@ pub fn decode_pam_gray(bytes: &[u8]) -> Option<(usize, usize, Vec<u8>)> {
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
// `windows(0)` panics.
if needle.is_empty() {
return None;
}
haystack.windows(needle.len()).position(|w| w == needle)
}
@@ -404,6 +408,14 @@ mod tests {
assert_eq!((w, h, px.len()), (4, 2, 8));
}
/// An empty needle is not found, rather than panicking in `windows(0)`.
#[test]
fn find_with_an_empty_needle_is_none() {
assert_eq!(find(b"abc", b""), None);
assert_eq!(find(b"", b""), None);
assert_eq!(find(b"abcabc", b"ca"), Some(2));
}
/// Malformed or hostile headers return `None` rather than panicking or inventing a size.
#[test]
fn hostile_pgm_headers_are_rejected() {
+43 -6
View File
@@ -1334,6 +1334,10 @@ mod tests {
assert!(parser.acquire(|| panic!("must not retry"), || false).unwrap().is_none());
}
/// The waits here only guard against a hang; they don't time anything. The initializer is
/// held until the end of the test, so a waiter that can't cancel waits forever and fails the
/// guard however long it is, and a correct one returns as soon as its thread runs. Two
/// seconds was too short for a busy FreeBSD VM in CI to start the threads (#1970).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn waiting_worker_can_cancel_before_initializer_returns() {
@@ -1341,6 +1345,7 @@ mod tests {
sync::{atomic::AtomicUsize, mpsc},
time::Duration,
};
const HANG_GUARD: Duration = Duration::from_secs(600);
let parser = Arc::new(PoolParser::default());
let (started_tx, started_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
@@ -1350,14 +1355,16 @@ mod tests {
parser.acquire(
|| {
started_tx.send(()).unwrap();
release_rx.recv().unwrap();
// A failed test drops the sender: that ends the hold, it is not a
// second failure.
let _ = release_rx.recv();
Pdf::new(ONE_PAGE.to_vec()).ok()
},
|| false,
)
})
};
started_rx.recv_timeout(Duration::from_secs(2)).unwrap();
started_rx.recv_timeout(HANG_GUARD).expect("the initializer starts");
let cancelled = Arc::new(AtomicBool::new(false));
let checks = Arc::new(AtomicUsize::new(0));
let (done_tx, done_rx) = mpsc::channel();
@@ -1374,13 +1381,13 @@ mod tests {
done_tx.send(result.unwrap().is_none()).unwrap();
})
};
let deadline = std::time::Instant::now() + Duration::from_secs(2);
let deadline = std::time::Instant::now() + HANG_GUARD;
while checks.load(Ordering::Relaxed) == 0 {
assert!(std::time::Instant::now() < deadline);
std::thread::yield_now();
assert!(std::time::Instant::now() < deadline, "the waiter never checked for cancellation");
std::thread::sleep(Duration::from_millis(1));
}
cancelled.store(true, Ordering::Release);
assert!(done_rx.recv_timeout(Duration::from_secs(2)).unwrap());
assert!(done_rx.recv_timeout(HANG_GUARD).expect("the waiter cancels while the initializer is held"));
waiter.join().unwrap();
release_tx.send(()).unwrap();
assert!(initializer.join().unwrap().unwrap().is_some());
@@ -3419,6 +3426,36 @@ trailer << /Root 1 0 R >>
}
}
/// A shaded Type 3 glyph must use bounds in device coordinates at every position.
#[test]
fn shaded_type3_glyph_draws_at_multiple_positions() {
let content = "BT /F 30 Tf 1 0 0 1 50 70 Tm (A) Tj 1 0 0 1 220 70 Tm (A) Tj ET";
let glyph = "100 0 d0 0 0 100 100 re W n /S sh";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Resources << /Font << /F 5 0 R >> >> /Contents 4 0 R >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /A 6 0 R >> /Encoding << /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [100] /Resources << /Shading << /S 7 0 R >> >> >> endobj
6 0 obj << /Length {} >> stream
{glyph}
endstream endobj
7 0 obj << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> endobj
trailer << /Root 1 0 R >>
%%EOF", content.len(), glyph.len()
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
for x in [65usize, 235] {
let pixel = &page.rgba[(65 * 300 + x) * 4..][..4];
assert!(pixel[0] < 240 && pixel[2] < 240 && pixel[1] < 10, "glyph at {x}: {pixel:?}");
}
}
/// From `cargo xtask fuzz`: a Type 3 font without /Resources inherits the page's, where its
/// own name is defined, and its glyph shows text in itself (vendored patch:
/// `MAX_PAINT_NESTING`). Must terminate; a normal Type 3 glyph must still paint.
+2
View File
@@ -25,6 +25,8 @@
//! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency
//! only, never compiled into the product.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// A small PDF with a classic cross-reference table, built one object at a time.
///
/// Objects are numbered from 1 in the order they are added, all at generation 0, so the first
+17 -5
View File
@@ -4509,11 +4509,8 @@ fn notices(
// A form whose scripts were turned off no longer calculates or answers its buttons: that
// comes before what else there is to say about the document.
let scripts_off = doc.xfa_scripts_off();
let msg = if scripts_off {
Some(("triangle-alert", crate::js_ui::scripts_off_notice(), true))
} else if secured {
Some(("lock", tl!("This document is secured. Some changes are restricted by its security settings.").to_string(), false))
} else if let Some(x) = xfa {
// What the document is as a form, if it is one: what the bar says first, secured or not.
let form = if let Some(x) = xfa {
let lang = crate::i18n::current();
let pages = crate::i18n::trn(lang, x.pages as u64, "{n} page", "{n} pages");
let fields = crate::i18n::trn(lang, x.fields as u64, "{n} field", "{n} fields");
@@ -4544,6 +4541,21 @@ fn notices(
crate::i18n::fmt(tl!("This document contains {n} interactive form fields."), &[("n", &info.fields.len().to_string())]),
true,
))
} else {
None
};
let msg = if scripts_off {
Some(("triangle-alert", crate::js_ui::scripts_off_notice(), true))
} else if secured {
// A secured form still says what it is, and keeps Highlight fields: the security
// sentence follows the form's.
let security = tl!("This document is secured. Some changes are restricted by its security settings.");
Some(match form {
Some((icon, text, fields)) => (icon, format!("{text} {security}"), fields),
None => ("lock", security.to_string(), false),
})
} else if form.is_some() {
form
} else if repaired {
Some(("bandage", tl!("This file was damaged and has been repaired. Saving keeps the repaired version.").to_string(), false))
} else if !info.warnings.is_empty() {
+3 -3
View File
@@ -109,8 +109,7 @@ impl PdfCraftApp {
style.fill = p.fill;
}
self.comment_prefs.set_style(tool, style);
// Opacity and width go through the clamping setters: the dialog allows 0% opacity, and a
// default that draws invisible comments can't be found again.
// Keep invisible defaults out of tools where a zero stroke would make them disappear.
self.comment_prefs.set_opacity(tool, p.opacity);
if let Some(w) = p.width {
self.comment_prefs.set_width(tool, w);
@@ -240,9 +239,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
e.opacity = pct / 100.0;
}
ui.end_row();
let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 };
if let Some(w) = e.width.as_mut() {
ui.label(tl!("Thickness"));
ui.add(egui::Slider::new(w, 0.5..=12.0).step_by(0.5).suffix(" pt"));
ui.add(egui::Slider::new(w, min_width..=12.0).step_by(0.5).suffix(" pt"));
ui.end_row();
}
if let Some(ends) = e.endings.as_mut() {
+11 -6
View File
@@ -188,12 +188,17 @@ impl CommentTool {
/// Whether the line-thickness control applies.
pub fn has_width(self) -> bool {
self.draws() || self.clicks_points()
self.draws() || self.clicks_points() || matches!(self, Self::TextBox | Self::Callout)
}
/// Whether the fill-colour control applies: shapes with an interior (#686).
/// Text comments may have no border while retaining visible text or fill.
fn min_width(self) -> f64 {
if matches!(self, Self::TextBox | Self::Callout) { 0.0 } else { 0.5 }
}
/// Whether the fill-colour control applies to this tool.
pub fn has_fill(self) -> bool {
matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud)
matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud | Self::TextBox | Self::Callout)
}
/// A placeholder shape of this kind (for per-tool default styles).
@@ -284,7 +289,7 @@ impl CommentPrefs {
pub fn set_width(&mut self, tool: CommentTool, w: f64) {
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
s.width = w.clamp(0.5, 12.0);
s.width = w.clamp(tool.min_width(), 12.0);
}
}
@@ -295,7 +300,7 @@ impl CommentPrefs {
}
}
/// The tool's fill (`None`: no fill). Only shapes with an interior keep one.
/// The tool's fill (`None`: no fill).
pub fn set_fill(&mut self, tool: CommentTool, fill: Option<Rgb>) {
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
s.fill = if tool.has_fill() { fill.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0))) } else { None };
@@ -1899,7 +1904,7 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m
ui.set_min_width(180.0);
let mut w = style.width;
ui.label(egui::RichText::new(tl!("Line thickness")).font(theme::semibold(12.0)));
if ui.add(egui::Slider::new(&mut w, 0.5..=12.0).step_by(0.5).suffix(" pt")).changed() {
if ui.add(egui::Slider::new(&mut w, tool.min_width()..=12.0).step_by(0.5).suffix(" pt")).changed() {
prefs.set_width(tool, w);
}
});
+1
View File
@@ -1420,6 +1420,7 @@ mod revert_draft_tests {
press: None,
calendar: None,
calendar_rect: None,
calendar_text: None,
}
}
+16 -4
View File
@@ -38,6 +38,9 @@ pub struct Focus {
/// Date fields: the month the calendar shows (year, month 1–12), and where it was drawn.
pub calendar: Option<(i32, u32)>,
pub calendar_rect: Option<egui::Rect>,
/// The text the calendar last saw, so it moves to a typed date once, when the typing becomes
/// a whole date, and otherwise keeps the month its arrows turned to.
pub calendar_text: Option<String>,
}
#[derive(Clone, Debug, Default)]
@@ -90,6 +93,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus {
press: None,
calendar: None,
calendar_rect: None,
calendar_text: None,
}
}
@@ -255,11 +259,19 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun
fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &str, today: (i64, u32, u32)) -> Option<String> {
use pdfcraft_engine::form_scripts::{DateTime, format_date, parse_date};
let fx = view.forms.focus.as_mut()?;
let (mut y, mut m) = fx.calendar.unwrap_or_else(|| match parse_date(&fx.text, fmt) {
Some(d) => (d.y, d.m),
None => (today.0 as i32, today.1),
});
let current = parse_date(&fx.text, fmt);
// A whole date typed since the last frame moves the calendar to it; half a date, or text
// that hasn't changed, leaves it on the month it shows (the one the arrows turned to).
let changed = fx.calendar_text.as_deref() != Some(fx.text.as_str());
let (mut y, mut m) = match (fx.calendar, current) {
(Some(_), Some(d)) if changed => (d.y, d.m),
(Some(shown), _) => shown,
(None, Some(d)) => (d.y, d.m),
(None, None) => (today.0 as i32, today.1),
};
if changed {
fx.calendar_text = Some(fx.text.clone());
}
let mut picked = None;
let area = egui::Area::new(egui::Id::new(("date-picker", view.id.0)))
.order(egui::Order::Foreground)
+9 -1
View File
@@ -2801,7 +2801,15 @@ impl PdfCraftApp {
// properties; Escape cancels it rather than clearing a selection), and nothing may run
// underneath it. They are read here, before the canvas can consume them.
if self.close_request.is_some() {
if let Some(choice) = dialogs::save_prompt_key(ctx) {
// These dialogs are drawn after the save prompt and own the keyboard while open.
// Don't let Escape or Return resolve unsaved work underneath one.
let covered = self.pending_link.is_some()
|| self.pending_remote_link.is_some()
|| self.pending_full_screen.is_some()
|| self.updates.open
|| self.dialog.is_some()
|| self.unlock_prompt.is_some();
if !covered && let Some(choice) = dialogs::save_prompt_key(ctx) {
self.resolve_close(ctx, choice);
}
return;
+74
View File
@@ -1571,3 +1571,77 @@ fn text_box_properties_change_the_font_size() {
assert_eq!(doc.comment_props(0, 0).unwrap().font_size, Some(30.0));
assert_eq!(doc.can_undo(), Some("Change comment properties"));
}
#[test]
fn text_box_and_callout_properties_include_fill_and_thickness() {
use pdfcraft_engine::{Edit, LineEnding, NewAnnotation, Shape};
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|_| {});
for (tool, shape) in [
(CommentTool::TextBox, Shape::TextBox { rect: [40.0, 600.0, 240.0, 640.0], font_size: 12.0 }),
(
CommentTool::Callout,
Shape::Callout {
rect: [40.0, 500.0, 240.0, 540.0],
knee: [30.0, 460.0],
point: [10.0, 440.0],
font_size: 12.0,
ending: LineEnding::OpenArrow,
},
),
] {
let index = comments(&h).len();
h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
page: 0,
shape,
style: Default::default(),
contents: "Hello".into(),
author: "Ada".into(),
}));
h.run_steps(2);
h.state_mut().open_comment_props(0, index);
h.run_steps(2);
h.get_by_label("Fill colour");
{
let draft = h.state_mut().comment_props.as_mut().unwrap();
assert!(draft.original.fillable && draft.original.width.is_some());
draft.edited.fill = Some([1.0; 3]);
draft.edited.width = Some(3.0);
}
h.get_by_label("OK").click();
h.run_steps(3);
let doc = h.state().session.get(h.state().views[0].id).unwrap();
let p = doc.comment_props(0, index).unwrap();
assert_eq!((p.fill, p.width), (Some([1.0; 3]), Some(3.0)));
h.state_mut().make_comment_default(0, index);
assert_eq!(h.state().comment_prefs.style(tool).fill, Some([1.0; 3]));
assert_eq!(h.state().comment_prefs.style(tool).width, 3.0);
}
}
#[test]
fn text_comments_can_save_a_zero_width_default() {
use pdfcraft_engine::{Edit, NewAnnotation, Shape};
use pdfcraft_ui_egui::comments::CommentTool;
let mut h = harness(|_| {});
let shape = Shape::TextBox { rect: [40.0, 600.0, 240.0, 640.0], font_size: 12.0 };
let mut style = pdfcraft_engine::Style::default_for(&shape);
style.width = 0.0;
h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: "Hello".into(), author: "Ada".into() }));
h.run_steps(2);
h.state_mut().open_comment_props(0, 0);
h.run_steps(2);
assert_eq!(h.state().comment_props.as_ref().unwrap().edited.width, Some(0.0));
h.get_by_label("OK").click();
h.run_steps(2);
h.state_mut().make_comment_default(0, 0);
assert_eq!(h.state().comment_prefs.style(CommentTool::TextBox).width, 0.0);
h.state_mut().comment_prefs.set_width(CommentTool::Callout, 0.0);
h.state_mut().comment_prefs.set_width(CommentTool::Rectangle, 0.0);
assert_eq!(h.state().comment_prefs.style(CommentTool::Callout).width, 0.0);
assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).width, 0.5);
let mut restored = PdfCraftApp::new();
restored.restore(&h.state().persist());
assert_eq!(restored.comment_prefs.style(CommentTool::TextBox).width, 0.0);
assert_eq!(restored.comment_prefs.style(CommentTool::Callout).width, 0.0);
}
+25 -1
View File
@@ -2024,7 +2024,12 @@ fn protected(user: &str, owner: &str, permissions: i32) -> Vec<u8> {
}
fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap();
encrypted(fixture(2), algorithm, user, owner, permissions)
}
/// `bytes` rewritten encrypted.
fn encrypted(bytes: Vec<u8>, algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm,
user_password: user,
@@ -2146,6 +2151,25 @@ fn remove_security_asks_for_the_permissions_password_first() {
assert!(doc.dirty && doc.security_summary().is_none(), "security is removed (written by the next save)");
}
/// A secured form still says it is one and offers Highlight fields: the security notice joins
/// the form's instead of replacing it.
#[test]
fn a_secured_form_shows_its_form_notice_and_its_security() {
// Changing and assembling withheld, filling allowed (as government forms are often set).
let bytes = encrypted(include_bytes!("data/form.pdf").to_vec(), pdfcraft_cos::Algorithm::Aes256, "", "owner", -1036);
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, bytes).unwrap();
app
});
h.run_steps(4);
h.get_by_label_contains("interactive form fields. This document is secured. Some changes are restricted by its security settings.");
h.get_by_label("Highlight fields");
h.get_by_label("Security settings");
h.get_by_label("Unlock…");
}
#[test]
fn restricted_documents_show_a_notice_and_block_page_changes() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
+64
View File
@@ -185,6 +185,70 @@ fn date_fields_offer_a_calendar() {
assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "picked in the field's format");
}
/// The calendar used to keep the month it opened on, so a date typed while it was open showed
/// only after leaving the field and coming back.
#[test]
fn the_calendar_follows_a_complete_date_typed_while_it_is_open() {
use egui_kittest::kittest::Queryable;
let mut h = harness();
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
page: 0,
rect: [50.0, 40.0, 200.0, 60.0],
kind: pdfcraft_engine::NewField::Date,
name: Some("due".into()),
});
h.run_steps(4);
click_field(&mut h, "due", 0);
let (y, m, _) = h.state().session.today();
let month =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][(m - 1) as usize];
h.get_by_label(&format!("{month} {y}"));
// Half a date leaves the calendar where it is.
h.event(egui::Event::Text("03/1".into()));
h.run_steps(2);
h.get_by_label(&format!("{month} {y}"));
// The whole date moves it there.
h.event(egui::Event::Text("4/2025".into()));
h.run_steps(2);
h.get_by_label("March 2025");
// The arrows still turn the months, and the calendar stays on their month while the text
// doesn't change.
h.get_by_label("›").click();
h.run_steps(3);
h.get_by_label("April 2025");
}
/// A date format with its fields side by side ("yyyymmdd") refused every typed date, and its
/// calendar opened on today's month.
#[test]
fn an_undelimited_date_field_takes_a_typed_date_and_its_calendar_opens_on_it() {
use egui_kittest::kittest::Queryable;
let mut h = harness();
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
page: 0,
rect: [50.0, 40.0, 200.0, 60.0],
kind: pdfcraft_engine::NewField::Date,
name: Some("due".into()),
});
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps {
name: "due".into(),
props: Box::new(pdfcraft_engine::FieldProps {
format: Some(pdfcraft_engine::form_scripts::Format::Date("yyyymmdd".into())),
..Default::default()
}),
}));
h.run_steps(4);
click_field(&mut h, "due", 0);
h.event(egui::Event::Text("20250314".into()));
h.run_steps(2);
h.key_press(egui::Key::Tab);
h.run_steps(4);
assert_eq!(h.state().toast, None, "the typed date was not refused");
assert_eq!(value(&h, "due"), ["20250314"]);
click_field(&mut h, "due", 0);
h.get_by_label("March 2025");
}
#[test]
fn lithuanian_calendar_localizes_months_and_weekdays_without_changing_field_values() {
use egui_kittest::kittest::Queryable;
+21
View File
@@ -354,6 +354,27 @@ fn a_middle_click_closes_the_tab() {
assert!(h.state().close_request.is_some());
}
#[test]
fn a_link_prompt_above_a_save_prompt_owns_its_keys() {
for key in [egui::Key::Escape, egui::Key::Enter] {
let mut h = harness(|app| {
app.open_bytes("first.pdf", None, FIXTURE.to_vec()).unwrap();
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
app.close_request = Some(pdfcraft_ui_egui::CloseRequest::Tab(app.views[0].id));
});
h.run_steps(3);
h.state_mut().pending_link =
Some(pdfcraft_ui_egui::PendingLink { url: "https://example.org".to_owned(), origin: pdfcraft_ui_egui::LinkOrigin::Link });
h.run_steps(3);
h.key_press(key);
h.run_steps(3);
assert_eq!(h.state().pending_link.is_some(), key == egui::Key::Enter);
assert!(h.state().close_request.is_some(), "the save prompt stays open after {key:?}");
assert_eq!(h.state().views.len(), 1, "the edited document remains open");
assert!(h.state().last_opened_url.is_none());
}
}
#[test]
fn overflowing_tabs_can_be_reached_by_scrolling() {
let mut h = harness(|app| {
+19
View File
@@ -212,3 +212,22 @@ fn scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back() {
assert!(h.state().views[0].notice_dismissed);
assert!(!says_scripts_are_off(&h.state().toast), "{:?}", h.state().toast);
}
#[test]
fn a_secured_dynamic_xfa_form_shows_its_layout_and_its_security() {
// Changing and assembling withheld, filling allowed.
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(2)))).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: -1036,
encrypt_metadata: true,
seed: [4; 32],
})
.unwrap();
let h = open(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
h.get_by_label_contains("laid out from its template: 2 pages, 11 fields. This document is secured.");
h.get_by_label("Highlight fields");
h.get_by_label("Security settings");
}
+48 -1
View File
@@ -8,6 +8,53 @@ Supply `--root` at most once, followed by a nonempty directory path. Missing ope
repeated roots and another long option in place of the directory are refused before any
script is read or server is started. Prefix a directory name beginning with `--` with `./`.
## Install and register
From the repository root, with Rust 1.95+ and a native linker installed
(see [development](development.md) for build and environment notes):
```sh
cargo install --path apps/pdfcraft-cli --locked
# POSIX shells, for Cargo's default install location:
export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH"
pdfcraft-cli --help
pdfcraft-cli mcp --help
```
The default features include MCP; do not pass `--no-default-features` for this use.
Cargo installs a binary, not a symlink into `target/`, so it survives `cargo clean`.
Re-run the install command after updating the checkout.
By default the executable goes in `$CARGO_HOME/bin` (`~/.cargo/bin` when unset), or
`%USERPROFILE%\.cargo\bin` on Windows. If you set `--root`, `CARGO_INSTALL_ROOT`, or
Cargo's `install.root`, use that installation root's `bin` directory instead. Add it
to the `PATH` inherited by the MCP client, not just an interactive shell, and restart
the client after changing its environment.
Register the installed command explicitly (replace `/work/project` with an existing folder):
```sh
claude mcp add pdfcraft -- pdfcraft-cli mcp --root /work/project
```
Other MCP clients can use this bare-name configuration:
```json
{"mcpServers":{"pdfcraft":{"command":"pdfcraft-cli","args":["mcp","--root","/work/project"]}}}
```
Linux `.deb` and `.rpm` packages already install `/usr/bin/pdfcraft-cli`; those users
can skip the source install. The [downloads list](../README.md#downloads) also includes
standalone Linux CLI tarballs and a macOS CLI zip; unpack and copy the executable into
a directory on the client's `PATH`. Installing does not start or register a server:
MCP remains opt-in and uses stdio only.
If a client reports `ENOENT` or "Executable not found in $PATH", verify the install with
`command -v pdfcraft-cli` (PowerShell: `Get-Command pdfcraft-cli`) in the client's
environment. If that environment cannot be changed, set JSON `command` to the absolute
installed binary path, not a literal `~` or `$HOME` expression. Avoid symlinks into
`target/release`: cleaning the build directory breaks them. A source build alone does
not put the CLI on `PATH`.
## Confining file access
```sh
@@ -15,7 +62,7 @@ pdfcraft-cli mcp --automation-read-root /work/project --automation-write-root /w
```
```json
{"mcpServers": {"pdfcraft": {"command": "/abs/path/target/release/pdfcraft-cli",
{"mcpServers": {"pdfcraft": {"command": "pdfcraft-cli",
"args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}}
```
+3 -3
View File
@@ -113,7 +113,7 @@ total matches the parity estimate.
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
@@ -138,13 +138,13 @@ total matches the parity estimate.
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| 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). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
| 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). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. |
| 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`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). 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, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. 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). 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); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| 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); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| 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). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, 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. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
+13 -13
View File
@@ -1802,8 +1802,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes"]
notes = ""
evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/ui-egui/tests/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/ui_group/xfa.rs::a_secured_dynamic_xfa_form_shows_its_layout_and_its_security"]
notes = "On a secured form (fields or XFA), the security sentence follows the form's notice, which keeps Highlight fields and the XFA layout notes."
[[feature]]
id = "view.pdfa-view-mode"
@@ -3860,8 +3860,8 @@ milestone = "M5"
status = "shipped"
commands = ["comment.list"]
tools = ["comment_edit"]
evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::text_box_properties_change_the_font_size", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/automation.rs::text_box_font_size_through_the_tool"]
notes = "Appearance (colour, fill colour for rectangles/ovals/polygons, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)"
evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::text_box_properties_change_the_font_size", "crates/ui-egui/tests/comments.rs::text_box_and_callout_properties_include_fill_and_thickness", "crates/annot/src/tests.rs::text_boxes_and_callouts_can_change_fill_and_border", "crates/automation/tests/automation.rs::text_box_and_callout_fill_and_border_through_tools", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/automation.rs::text_box_font_size_through_the_tool"]
notes = "Appearance (colour, fill colour for rectangles/ovals/polygons/text boxes/callouts, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)"
[[feature]]
id = "comment.properties-bar"
@@ -4392,8 +4392,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/view.rs::field_highlighting_is_remembered"]
notes = "remembered for the next document and session; required fields get a red border (round for radio buttons); the tint is drawn over the field, where Acrobat paints the highlight colour behind it"
evidence = ["crates/ui-egui/tests/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/view.rs::field_highlighting_is_remembered", "crates/ui-egui/tests/editing.rs::a_secured_form_shows_its_form_notice_and_its_security"]
notes = "offered on secured forms too; remembered for the next document and session; required fields get a red border (round for radio buttons); the tint is drawn over the field, where Acrobat paints the highlight colour behind it"
[[feature]]
id = "form.field-appearance-display"
@@ -4524,8 +4524,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props", "form_fill"]
evidence = ["crates/forms/src/af.rs::dates_times_and_masks", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"]
notes = "AFDate_FormatEx/KeystrokeEx, AFDate_Format presets, AFTime_Format(Ex): lenient parsing, month and day names, impossible dates refused"
evidence = ["crates/forms/src/af.rs::dates_times_and_masks", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/af.rs::digits_typed_without_separators_fill_adjacent_fields"]
notes = "AFDate_FormatEx/KeystrokeEx, AFDate_Format presets, AFTime_Format(Ex): lenient parsing, month and day names, impossible dates refused; digits typed without separators fill fields the format writes side by side by their fixed widths (20261018 for yyyymmdd, 1430 for HHMM). A run of digits is split only where the format itself has no separator: 20261018 typed into a yyyy-mm-dd field is still refused"
[[feature]]
id = "form.af-special"
@@ -4620,8 +4620,8 @@ milestone = "M6"
status = "shipped"
commands = []
tools = ["form_fill"]
evidence = ["crates/ui-egui/tests/forms.rs::date_fields_offer_a_calendar"]
notes = "a month calendar under date fields; the pick is written in the field's own date format"
evidence = ["crates/ui-egui/tests/forms.rs::date_fields_offer_a_calendar", "crates/ui-egui/tests/forms.rs::the_calendar_follows_a_complete_date_typed_while_it_is_open"]
notes = "a month calendar under date fields; the pick is written in the field's own date format; it opens on the field's date and moves to a whole date typed while it is open, staying on the month its arrows turned to otherwise"
[[feature]]
id = "form.auto-complete"
@@ -5221,7 +5221,7 @@ status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"]
notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)"
[[feature]]
id = "form.xfa-formcalc"
@@ -7088,8 +7088,8 @@ milestone = "M10"
status = "partial"
commands = ["export.html"]
tools = ["doc_export_office"]
evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"]
notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Not yet: multi-page folder output, CSS from fonts."
evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"]
notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts."
[[feature]]
id = "create.export-text"
+2
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -168,9 +168,10 @@ impl<'a> Type3<'a> {
is_shape_glyph
};
// PdfCraft patch: context clips and shading bounds are in device space; FontBBox is in glyph space.
let mut context = Context::new_with(
state.ctm,
self.font_bbox,
root_transform.transform_rect_bbox(self.font_bbox),
&glyph.cache,
glyph.xref,
glyph.settings.clone(),