Merge upstream main dab31491 into pr/attachments

On top of the merge with 932bf9c6; no conflicts. ATTRIBUTION.toml is upstream's with every sha256 recomputed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Osvaldo Duran
2026-10-18 17:27:27 -04:00
co-authored by Claude Opus 5.5
43 changed files with 1671 additions and 259 deletions
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directories:
- "/"
- "/crates/filters/fuzz"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
Generated
+2
View File
@@ -4026,6 +4026,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-engine",
"pdfcraft-platform",
"pdfcraft-preflight",
"pdfcraft-render",
"rustix 1.1.5",
"serde_json",
@@ -4135,6 +4136,7 @@ dependencies = [
"pdfcraft-xfdf",
"png 0.18.1",
"thiserror 2.0.21",
"tiff",
"unicode-segmentation",
"url",
]
+5
View File
@@ -39,6 +39,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (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)
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
@@ -169,6 +170,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
- **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.
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
+1
View File
@@ -18,6 +18,7 @@ serde_json = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-audit = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-preflight = { workspace = true }
pdfcraft-automation = { workspace = true }
pdfcraft-platform = { workspace = true }
base64 = "0.22"
+10 -1
View File
@@ -57,6 +57,7 @@ macro_rules! mcp_usage {
const USAGE: &str = concat!(
"\
pdfcraft-cli info <file.pdf> [--password PW] document summary as JSON
(includes any declared PDF/A or PDF/UA level)
pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
@@ -315,6 +316,10 @@ fn info(args: &[String]) -> Result<(), CliError> {
})
})
.flatten();
// What the document declares as a standard, read the way the Standards panel reads it, so
// `info` and the app agree. A file that declares nothing leaves these null and empty.
let declared =
pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
let json = serde_json::json!({
"file": path,
"pdf_version": info.pdf_version,
@@ -324,6 +329,9 @@ fn info(args: &[String]) -> Result<(), CliError> {
"first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
"pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())),
"pdfua": declared.pdfua,
"output_intents": declared.output_intents,
"xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
"xfa_layout": xfa_layout,
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
@@ -614,7 +622,8 @@ fn render(args: &[String]) -> Result<(), CliError> {
return Err(e.into());
}
let bytes = match format {
Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?,
// With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so.
Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?,
None => {
// PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it.
let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes();
+87
View File
@@ -0,0 +1,87 @@
//! `info` reports the standard a document declares, as the Standards panel does (#3319). A file
//! carrying `pdfaid:part` and `pdfaid:conformance` in its XMP metadata was summarised with no
//! mention of PDF/A at all, though the app reads the same declaration and shows it.
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
/// A one-page PDF, with `metadata` as the catalog's XMP stream when given.
fn fixture(metadata: Option<&str>) -> Vec<u8> {
let content = b"0 0 0 rg 10 10 40 20 re f";
let catalog = match metadata {
Some(_) => "<< /Type /Catalog /Pages 2 0 R /Metadata 5 0 R >>".to_string(),
None => "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
};
let mut objs: Vec<Vec<u8>> = vec![
catalog.into_bytes(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 4 0 R >>".to_vec(),
format!("<< /Length {} >> stream\n", content.len()).into_bytes(),
];
objs[3].extend_from_slice(content);
objs[3].extend_from_slice(b"\nendstream");
if let Some(xmp) = metadata {
let mut stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >> stream\n", xmp.len()).into_bytes();
stream.extend_from_slice(xmp.as_bytes());
stream.extend_from_slice(b"\nendstream");
objs.push(stream);
}
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", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for off in &offsets {
out.extend_from_slice(format!("{off: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
}
fn xmp(part: &str, conformance: &str) -> String {
format!(
"<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
<rdf:Description rdf:about=\"\" xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\">\n\
<pdfaid:part>{part}</pdfaid:part>\n <pdfaid:conformance>{conformance}</pdfaid:conformance>\n\
</rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>"
)
}
fn info_of(bytes: &[u8], name: &str) -> serde_json::Value {
let dir = std::env::temp_dir().join(format!("pdfcraft-info-standards-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("f.pdf");
std::fs::write(&path, bytes).unwrap();
let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let json = serde_json::from_slice(&out.stdout).expect("info prints JSON");
let _ = std::fs::remove_dir_all(&dir);
json
}
#[test]
fn info_names_the_declared_pdfa_level() {
let declared = info_of(&fixture(Some(&xmp("2", "B"))), "pdfa2b");
assert_eq!(declared["pdfa"], serde_json::json!("PDF/A-2b"), "{declared}");
assert_eq!(declared["pdfua"], serde_json::Value::Null);
assert_eq!(declared["output_intents"], serde_json::json!([]));
// Lower-case conformance in the file still reads as the level's own spelling.
assert_eq!(info_of(&fixture(Some(&xmp("3", "a"))), "pdfa3a")["pdfa"], serde_json::json!("PDF/A-3a"));
}
#[test]
fn info_declares_nothing_for_a_plain_pdf() {
let plain = info_of(&fixture(None), "plain");
assert_eq!(plain["pdfa"], serde_json::Value::Null, "{plain}");
assert_eq!(plain["pdfua"], serde_json::Value::Null);
// The rest of the summary is unaffected.
assert_eq!(plain["pages"], serde_json::json!(1));
assert_eq!(plain["pdf_version"], serde_json::json!("1.7"));
}
+11
View File
@@ -56,6 +56,17 @@ fn jpeg_and_tiff_names_get_those_formats() {
assert!(bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*"), "not a TIFF: {:?}", &bytes[..4]);
}
#[test]
fn the_file_records_the_dpi_it_was_rendered_at() {
// The engine's tests check each format; this checks that `--dpi` reaches the file.
let pdf = fixture("dpi");
let jpg = tmp("dpi", "p1.jpg");
assert!(render(&pdf, &jpg).status.success());
let bytes = std::fs::read(&jpg).unwrap();
assert_eq!(&bytes[6..11], b"JFIF\0");
assert_eq!(bytes[13..18], [1, 0, 36, 0, 36], "JFIF density in dots per inch");
}
#[test]
fn a_pam_name_still_gets_the_netpbm_stream() {
let pdf = fixture("pam");
+3
View File
@@ -145,3 +145,6 @@ sync_github_name = false
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
[people.MortenHusted]
sync_github_name = true
+15 -1
View File
@@ -64,6 +64,20 @@ semantics; they never silently act on partial text.
## Shared field properties
For an individual push button, `form_set_props` also edits its visible normal label:
```json
{"doc": 1, "field": "clear_button", "caption": "Clear Form", "widget": 1}
```
`widget` is a 1-based index (default 1), used only with `caption`; an empty caption clears the
label. Neither renames the field. `form_fields` returns the first widget's `caption` and all
`captions` as `[{"widget": 1, "caption": "Clear Form"}]`. Other widgets and icon-only layouts
keep their settings. CLI and MCP use the same edit, including undo/redo and save/reopen.
The desktop exposes it as **Button Properties → Options → Label**. Rollover/down labels and
icon layout are not edited here; the existing caption font and unsupported-character warning
policy apply.
`form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names):
```json
@@ -75,7 +89,7 @@ The batch is atomic and creates one undo step. A missing or locked field, invali
unknown argument or malformed appearance refuses the whole edit. Set `locked: false` to
unlock and change fields together. Only supplied properties change: each field/widget keeps
its other colours, fonts, values and unknown PDF dictionary keys. `"none"` removes a border
or fill colour. Names, rectangles and rotations require the single-field form. Results keep
or fill colour. Names, rectangles, rotations and captions require the single-field form. Results keep
`field` for single edits and return `fields` for batches. CLI and MCP use the same tool.
## MCP server
+26 -1
View File
@@ -233,6 +233,19 @@ impl Automation {
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
}
match f.kind {
FormFieldKind::PushButton => {
o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default()));
o.insert(
"captions".into(),
json!(
f.widgets
.iter()
.enumerate()
.map(|(i, w)| { json!({"widget": i.saturating_add(1), "caption": w.caption.as_deref().unwrap_or_default()}) })
.collect::<Vec<_>>()
),
);
}
FormFieldKind::Radio => {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
}
@@ -327,6 +340,9 @@ impl Automation {
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
if a.get("widget").is_some() && a.get("caption").is_none() {
return Err(bad("widget requires caption"));
}
let single = a.opt_str("field")?;
let bulk = a.get("fields").is_some();
if single.is_some() == bulk {
@@ -344,7 +360,7 @@ impl Automation {
return Err(bad(format!("field {name:?} is listed more than once")));
}
}
for key in ["name", "rect", "rotation"] {
for key in ["name", "rect", "rotation", "caption", "widget"] {
if a.get(key).is_some() {
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
}
@@ -398,6 +414,15 @@ impl Automation {
rect,
name: a.opt_str("name")?.map(str::to_owned),
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
caption: a
.opt_str("caption")?
.map(|caption| -> Result<_> {
let wi = a.opt_int("widget")?.unwrap_or(1);
let wi =
usize::try_from(wi).ok().and_then(|wi| wi.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
Ok((wi, caption.to_owned()))
})
.transpose()?,
read_only: a.opt_bool("read_only")?,
required: a.opt_bool("required")?,
multiline: a.opt_bool("multiline")?,
+4 -2
View File
@@ -327,7 +327,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "from", "to"],
)),
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags.")
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
@@ -380,7 +380,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_set_props",
"Field properties",
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
)
.with(schema(
json!({
@@ -389,6 +389,8 @@ pub fn tools() -> Vec<ToolDef> {
"fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
"name": { "type": "string" },
"tooltip": { "type": "string" },
"caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." },
"widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." },
"read_only": { "type": "boolean" },
"required": { "type": "boolean" },
"multiline": { "type": "boolean" },
+91
View File
@@ -0,0 +1,91 @@
//! Editing a button's visible label through the CLI/MCP tool table (#2892).
use pdfcraft_automation::{Automation, Content};
use pdfcraft_engine::{DocId, Edit, NewField, Session};
use serde_json::{Value, json};
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
match a.call(name, &args).unwrap().remove(0) {
Content::Json(value) => value,
other => panic!("expected JSON: {other:?}"),
}
}
#[test]
fn button_caption_can_be_changed_after_creation() {
let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut session = Session::new();
let bytes = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open("buttons.pdf", None, bytes, None).unwrap();
session
.apply(
id,
Edit::AddField {
page: 0,
rect: [40.0, 300.0, 200.0, 330.0],
kind: NewField::Button { caption: String::new() },
name: Some("clear_button".into()),
},
)
.unwrap();
std::fs::write(dir.join("buttons.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000);
let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap();
let before = a.session().save_bytes(DocId(id)).unwrap();
let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
assert_eq!(fields["fields"][0]["caption"], "Clear Form");
assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}]));
assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties"));
let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance");
tool(&mut a, "edit_undo", json!({"doc": id}));
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
tool(&mut a, "edit_redo", json!({"doc": id}));
tool(&mut a, "doc_save", json!({"doc": id, "path": "labelled.pdf"}));
let saved = std::sync::Arc::new(std::fs::read(dir.join("labelled.pdf")).unwrap());
let doc = pdfcraft_cos::Document::open(saved).unwrap();
let root = doc.get(doc.root().unwrap());
let form = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap());
let button = doc.resolve(&fields.as_array().unwrap()[0]);
let mk = doc.resolve(button.as_dict().unwrap().get(b"MK").unwrap());
assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form");
assert_eq!(doc.resolve(button.as_dict().unwrap().get(b"T").unwrap()).as_string().unwrap().to_text(), "clear_button");
let reopened = tool(&mut a, "doc_open", json!({"path": "labelled.pdf"}))["doc"].as_u64().unwrap();
let fields = tool(&mut a, "form_fields", json!({"doc": reopened}));
assert_eq!(fields["fields"][0]["caption"], "Clear Form");
let (_, _, pixels) = a.render_pixels(reopened, 1, 1.0).unwrap();
assert_eq!(pixels, labelled_pixels);
let out = tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "提交"}));
assert!(out["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).any(|w| w.contains("U+63D0")));
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
assert_eq!(fields["fields"][0]["caption"], "提交", "unsupported characters are retained and reported");
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
for invalid in [
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 0}),
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": -1}),
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 2}),
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": i64::MAX}),
json!({"doc": id, "field": "clear_button", "caption": 42}),
json!({"doc": id, "field": "clear_button", "widget": 1, "tooltip": "bad"}),
json!({"doc": id, "fields": ["clear_button"], "caption": "bad"}),
] {
let before = a.session().save_bytes(DocId(id)).unwrap();
assert!(a.call("form_set_props", &invalid).is_err(), "{invalid}");
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before, "{invalid}");
}
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": true}));
let before = a.session().save_bytes(DocId(id)).unwrap();
assert!(a.call("form_set_props", &json!({"doc": id, "field": "clear_button", "caption": "bad"})).is_err());
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": false}));
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": ""}));
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
assert_eq!(fields["fields"][0]["caption"], "");
let (_, _, pixels) = a.render_pixels(id, 1, 1.0).unwrap();
assert_eq!(pixels, empty_pixels);
std::fs::remove_dir_all(dir).unwrap();
}
+19 -2
View File
@@ -337,11 +337,14 @@ impl<'a> Lexer<'a> {
}
return Ok(Object::Real(0.0));
}
match s.trim_start_matches('+').parse::<i64>() {
// Tolerate a doubled sign ("--280") like the real branch does.
let t = s.trim_start_matches('+');
let cleaned = t.strip_prefix('-').filter(|r| r.starts_with('-')).unwrap_or(t);
match cleaned.parse::<i64>() {
Ok(v) => Ok(Object::Int(v)),
Err(_) => {
// Out-of-range integers are treated as reals (§7.3.3 allows implementation limits).
s.parse::<f64>().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
cleaned.parse::<f64>().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
}
}
}
@@ -475,6 +478,20 @@ mod tests {
Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}"))
}
#[test]
fn doubled_sign_integer_reads_like_the_real_form() {
// Some writers emit a TJ kerning as "--280"; the real form "--280.0" is already read.
assert_eq!(obj("--280.0"), Object::Real(-280.0));
assert_eq!(obj("--280"), Object::Int(-280));
match obj("[(Hello) --280 (World)]") {
Object::Array(items) => {
assert_eq!(items.len(), 3);
assert_eq!(items[1], Object::Int(-280));
}
other => panic!("not an array: {other:?}"),
}
}
#[test]
fn scalars() {
assert_eq!(obj("true"), Object::Bool(true));
+153 -177
View File
@@ -4,8 +4,8 @@
//! or delete. Only the stream that draws it is rewritten, as a new object.
use kurbo::{BezPath, Point, Shape};
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_content::{Matrix, Op, Pieces, parse};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use std::sync::Arc;
use crate::EditError;
@@ -30,6 +30,8 @@ pub struct PageImage {
/// Pixel size; zero for vector/grouped Form artwork.
pub width: u32,
pub height: u32,
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
/// operators of the page's streams joined ([`Pieces`]).
stream: usize,
op: usize,
/// CTM before `Do`, excluding a Form's own Matrix.
@@ -90,6 +92,11 @@ fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
.collect()
}
/// The page's streams joined into the one stream they are: an operator may span two of them.
fn joined(streams: &[(Object, Vec<u8>)]) -> Pieces {
Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>())
}
fn page_of(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
}
@@ -214,72 +221,71 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
let p = page_of(doc, page)?;
let xo = xobjects(doc, &p.dict);
let mut out = Vec::new();
// The graphics state carries over from one stream to the next.
// The streams are one stream in pieces, so the graphics state and operators carry over.
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
for (i, op) in parse(&data).ops.iter().enumerate() {
state.apply(op)?;
let ctm = state.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
let Some(resource) = xo.get(name) else { continue };
let obj = doc.resolve(resource);
let Object::Stream(s) = &*obj else { continue };
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
let (matrix, bounds) = if is_form {
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
v.as_array().and_then(|a| {
if a.len() != 4 {
return None;
}
let mut b = [0.0; 4];
for (n, v) in b.iter_mut().zip(a) {
*n = v.as_f64()?;
}
Some(b)
})
}) else {
continue;
};
let matrix = match s.dict.get(b"Matrix") {
Some(v) => {
let v = doc.resolve(v);
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
m
let pieces = joined(&streams(doc, &p.dict));
for (i, op) in pieces.parse().ops.iter().enumerate() {
state.apply(op)?;
let ctm = state.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
let Some(resource) = xo.get(name) else { continue };
let obj = doc.resolve(resource);
let Object::Stream(s) = &*obj else { continue };
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
let (matrix, bounds) = if is_form {
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
v.as_array().and_then(|a| {
if a.len() != 4 {
return None;
}
None => Matrix::IDENTITY,
};
(matrix.then(&ctm), bounds)
} else if s.dict.name(b"Subtype") == Some(b"Image") {
(ctm, [0.0, 0.0, 1.0, 1.0])
} else {
let mut b = [0.0; 4];
for (n, v) in b.iter_mut().zip(a) {
*n = v.as_f64()?;
}
Some(b)
})
}) else {
continue;
};
let rect = matrix.bbox(bounds);
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|| bounds[2] <= bounds[0]
|| bounds[3] <= bounds[1]
|| rect[2] <= rect[0]
|| rect[3] <= rect[1]
{
continue;
}
out.push(PageImage {
rect,
visible_rect: intersection(rect, state.placement.clip),
clip_rect: Some(state.placement.clip),
matrix: matrix.0,
name: String::from_utf8_lossy(name).into_owned(),
object: resource.as_ref(),
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
stream: si,
op: i,
draw_matrix: ctm.0,
clips: state.placement.clips.clone(),
});
let matrix = match s.dict.get(b"Matrix") {
Some(v) => {
let v = doc.resolve(v);
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
m
}
None => Matrix::IDENTITY,
};
(matrix.then(&ctm), bounds)
} else if s.dict.name(b"Subtype") == Some(b"Image") {
(ctm, [0.0, 0.0, 1.0, 1.0])
} else {
continue;
};
let rect = matrix.bbox(bounds);
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|| bounds[2] <= bounds[0]
|| bounds[3] <= bounds[1]
|| rect[2] <= rect[0]
|| rect[3] <= rect[1]
{
continue;
}
out.push(PageImage {
rect,
visible_rect: intersection(rect, state.placement.clip),
clip_rect: Some(state.placement.clip),
matrix: matrix.0,
name: String::from_utf8_lossy(name).into_owned(),
object: resource.as_ref(),
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
stream: pieces.piece_of(op.span.start),
op: i,
draw_matrix: ctm.0,
clips: state.placement.clips.clone(),
});
}
}
Ok(out)
@@ -298,15 +304,15 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
}
fn walk(
doc: &Document,
data: &[u8],
ops: &[Op],
resources: &Dict,
st: &mut ImageState,
stream: usize,
stream: &dyn Fn(&Op) -> usize,
forms: &mut Forms,
out: &mut Vec<PageImage>,
) -> Result<(), EditError> {
let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
for (i, op) in parse(data).ops.iter().enumerate() {
for (i, op) in ops.iter().enumerate() {
st.apply(op)?;
let ctm = st.placement.matrix;
if op.is("Do") {
@@ -325,7 +331,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
);
let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4]));
let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone());
walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out)?;
let piece = stream(op);
walk(doc, &parse(&form.data).ops, &form.resources, &mut inner, &|_| piece, forms, out)?;
if form.obj.is_some() {
forms.path.pop();
}
@@ -347,7 +354,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
is_form: false,
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
stream,
stream: stream(op),
op: i,
draw_matrix: ctm.0,
clips: st.placement.clips.clone(),
@@ -361,9 +368,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
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 };
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?;
}
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)
}
@@ -450,9 +456,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?;
let p = page_of(doc, page)?;
let all = streams(doc, &p.dict);
let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
let mut ops = parse(&data).ops;
if ops.get(img.op).is_none() {
let pieces = joined(&all);
let ops = pieces.parse().ops;
if !ops.get(img.op).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation is missing".into()));
}
let mut resources = None;
@@ -479,16 +485,10 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
vec![Op::new("Do", vec![Object::name(&name)])]
}
};
ops.splice(img.op..=img.op, replacement);
let mut dict = match &*doc.resolve(&obj) {
Object::Stream(s) => s.dict.clone(),
_ => Dict::new(),
};
dict.remove(b"Length");
let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&ops))));
let contents: Vec<Object> = all.iter().enumerate().map(|(i, (o, _))| if i == img.stream { Object::Ref(new) } else { o.clone() }).collect();
let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) });
let contents = crate::text::rewritten_contents(doc, &all, changed);
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), if let [one] = contents.as_slice() { one.clone() } else { Object::Array(contents) });
d.set(b"Contents".to_vec(), contents);
if let Some(res) = resources {
d.set(b"Resources".to_vec(), Object::Dict(res));
}
@@ -599,22 +599,12 @@ fn transform_image(
if !transform.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform must be finite and no larger than 1e12".into()));
}
let mut offset = 0_usize;
for (_, data) in streams.iter().take(image.stream) {
offset = offset
.checked_add(data.len())
.and_then(|n| n.checked_add(1))
.ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
}
let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>());
let pieces = joined(streams);
let ops = pieces.parse().ops;
let target = ops
.iter()
.position(|op| op.span.start == span && op.is("Do"))
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
let target = image.op;
if !ops.get(target).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation could not be located".into()));
}
// A private path crop moves with its image regardless of intervening state operators.
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
@@ -677,22 +667,12 @@ fn crop_image(
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
return Err(EditError::Invalid("the crop box has no area".into()));
}
let mut offset = 0_usize;
for (_, data) in streams.iter().take(image.stream) {
offset = offset
.checked_add(data.len())
.and_then(|n| n.checked_add(1))
.ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
}
let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>());
let pieces = joined(streams);
let ops = pieces.parse().ops;
let target = ops
.iter()
.position(|op| op.span.start == span && op.is("Do"))
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
let target = image.op;
if !ops.get(target).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation could not be located".into()));
}
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
@@ -758,76 +738,72 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
}
plans.insert((image.stream, image.op), (movement, placement, offset));
plans.insert(image.op, (movement, placement, offset));
}
let mut data = Vec::with_capacity(all.len());
let pieces = joined(&all);
let ops = pieces.parse().ops;
let mut inside_text = false;
for (stream, (_, bytes)) in all.iter().enumerate() {
let pieces = pdfcraft_content::Pieces::join(&[bytes.as_slice()]);
let ops = pieces.parse().ops;
for (index, op) in ops.iter().enumerate() {
if op.is("BT") {
inside_text = true;
} else if op.is("ET") {
inside_text = false;
} else if inside_text && plans.contains_key(&(stream, index)) {
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
}
for (index, op) in ops.iter().enumerate() {
if op.is("BT") {
inside_text = true;
} else if op.is("ET") {
inside_text = false;
} else if inside_text && plans.contains_key(&index) {
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
}
// Images alone in their own `q re W n cm Do Q` take the clip with them.
let mut clips = std::collections::BTreeMap::new();
for index in 0..ops.len() {
let Some(&(_, placement, offset)) = plans.get(&(stream, index)) else { continue };
let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
return Err(EditError::Invalid("the image move is too large".into()));
};
plans.remove(&(stream, index));
// A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
// same CTM as `prev` followed by another `cm c`, without one more operator per move.
let folded = at
.checked_sub(1)
.and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
.filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
match folded {
Some((i, m)) => clips.insert((stream, i), (m, false)),
None => clips.insert((stream, at), (c, true)),
};
}
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
let mut folds = std::collections::BTreeMap::new();
for index in 0..ops.len() {
let Some(&(movement, _, _)) = plans.get(&(stream, index)) else { continue };
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
let folded = movement.then(&cm);
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
}
plans.remove(&(stream, index));
folds.insert(at, folded);
}
let changed = crate::text::splice(&pieces, &ops, |index| {
if let Some((c, keep)) = clips.remove(&(stream, index)) {
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
}
if let Some(m) = folds.remove(&index) {
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
}
let Some((matrix, _, _)) = plans.remove(&(stream, index)) else { return (Vec::new(), true) };
let original = ops.get(index).cloned().into_iter();
// Local translations can be tiny under a large CTM. Preserve their precision
// rather than rounding them to zero or amplifying an error on the page.
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
out.extend(original);
out.push(Op::new("Q", vec![]));
(out, false)
});
data.push(changed.into_iter().next().unwrap_or_default());
}
if !plans.is_empty() {
// Images alone in their own `q re W n cm Do Q` take the clip with them.
let mut clips = std::collections::BTreeMap::new();
for index in 0..ops.len() {
let Some(&(_, placement, offset)) = plans.get(&index) else { continue };
let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
return Err(EditError::Invalid("the image move is too large".into()));
};
plans.remove(&index);
// A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
// same CTM as `prev` followed by another `cm c`, without one more operator per move.
let folded = at
.checked_sub(1)
.and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
.filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
match folded {
Some((i, m)) => clips.insert(i, (m, false)),
None => clips.insert(at, (c, true)),
};
}
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
let mut folds = std::collections::BTreeMap::new();
for index in 0..ops.len() {
let Some(&(movement, _, _)) = plans.get(&index) else { continue };
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
let folded = movement.then(&cm);
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
}
plans.remove(&index);
folds.insert(at, folded);
}
if plans.keys().any(|index| !ops.get(*index).is_some_and(|op| op.is("Do"))) {
return Err(EditError::Invalid("a selected image operation is missing".into()));
}
let data = crate::text::splice(&pieces, &ops, |index| {
if let Some((c, keep)) = clips.remove(&index) {
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
}
if let Some(m) = folds.remove(&index) {
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
}
let Some((matrix, _, _)) = plans.remove(&index) else { return (Vec::new(), true) };
let original = ops.get(index).cloned().into_iter();
// Local translations can be tiny under a large CTM. Preserve their precision
// rather than rounding them to zero or amplifying an error on the page.
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
out.extend(original);
out.push(Op::new("Q", vec![]));
(out, false)
});
let contents = crate::text::rewritten_contents(doc, &all, data);
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
+53 -19
View File
@@ -8,7 +8,8 @@
//! `/PCMark` entry (also in the stream dictionary) names the kind of mark, which is how Update and
//! Remove find them again without decompressing page content. When
//! anything is drawn on top, the page's original content is wrapped in `q … Q` (two tiny streams)
//! so its graphics state can't leak into the mark.
//! so its graphics state cannot reach the mark. The closing stream also ends known unfinished
//! marked-content sequences before the new mark.
//!
//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`.
@@ -332,12 +333,16 @@ const WRAP_OPEN: &[u8] = b"q %PdfCraft\n";
const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n";
const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
/// Whether `bytes` are a wrapper's close: [`WRAP_CLOSE`] after a `Q` for each `q` the wrapped
/// content left open, or [`OLD_WRAP_CLOSE`].
/// Whether `bytes` are a wrapper's close: canonical `EMC` closures, then the existing
/// graphics closures. Legacy closes without the leading newline remain valid on their own.
fn is_wrap_close(bytes: &[u8]) -> bool {
let mut rest = bytes;
while let Some(shorter) = rest.strip_prefix(b"\nEMC".as_slice()) {
rest = shorter;
}
let legacy = rest.len() == bytes.len();
loop {
if rest == WRAP_CLOSE || rest == OLD_WRAP_CLOSE {
if rest == WRAP_CLOSE || (legacy && rest == OLD_WRAP_CLOSE) {
return true;
}
match rest.strip_prefix(b"\nQ".as_slice()) {
@@ -387,11 +392,13 @@ enum Isolation {
/// `suffix`: the stream from which the content leaves graphics state set (or a comment open)
/// that a mark on top must not inherit, so it needs a wrapper from there to the end.
/// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes.
Clean { suffix: Option<usize>, wrappers: Vec<(usize, usize)> },
/// `marked`: unfinished marked-content sequences to close before a new mark.
Clean { suffix: Option<usize>, wrappers: Vec<(usize, usize)>, marked: usize },
/// The content could not be read that precisely (it doesn't decode, is too large, or has
/// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is
/// the count of `q` left open at the end, when known.
Unknown { unclosed: usize },
/// the count of `q` left open at the end, when known. `marked` counts known unfinished
/// marked-content sequences independently of graphics malformation, or zero when unknown.
Unknown { unclosed: usize, marked: usize },
}
const ISOLATION_MAX_STREAMS: usize = 100_000;
@@ -400,7 +407,7 @@ const ISOLATION_MAX_OPS: usize = 1_000_000;
const ISOLATION_MAX_DEPTH: usize = 10_000;
fn isolation(doc: &Document, list: &[Object]) -> Isolation {
let unknown = Isolation::Unknown { unclosed: 0 };
let unknown = Isolation::Unknown { unclosed: 0, marked: 0 };
if list.len() > ISOLATION_MAX_STREAMS {
return unknown;
}
@@ -422,14 +429,17 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
let edges = pieces.comment_edges(&parsed);
let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false);
let mut stack: Vec<usize> = Vec::new();
let (mut marked, mut marked_known) = (0usize, true);
let mut split = 0;
let mut previous_last = None;
let mut wrappers = Vec::new();
for op in &parsed.ops {
let (first, last) = pieces.pieces_of(op);
// A wrapper can only go in before a whole stream, at the top level, between operators,
// and where no comment runs into the stream (it would swallow the wrapper's `q`).
// A wrapper can only go in before a whole stream, outside graphics and marked-content
// scopes, between operators, where no comment would swallow the wrapper's `q`.
if stack.is_empty()
&& marked_known
&& marked == 0
&& !text_open
&& !path_open
&& !dirty
@@ -439,6 +449,26 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
split = first;
}
previous_last = Some(last);
// Match the reader's tolerant operands: BMC uses the last name, BDC the last
// name/object pair, and EMC ignores surplus operands. Invalid begins are ignored.
let begin_marked = match op.op.as_slice() {
b"BMC" => op.operands.last().is_some_and(|o| matches!(o, Object::Name(_))),
b"BDC" => op.operands.len().checked_sub(2).and_then(|i| op.operands.get(i)).is_some_and(|o| matches!(o, Object::Name(_))),
_ => false,
};
if marked_known {
if begin_marked {
if marked < ISOLATION_MAX_DEPTH {
marked += 1;
} else {
// Once the budget is exceeded, later EMCs cannot restore a known depth.
marked_known = false;
marked = 0;
}
} else if op.is("EMC") {
marked = marked.saturating_sub(1);
}
}
match op.op.as_slice() {
b"BT" if !text_open => text_open = true,
b"ET" if text_open => text_open = false,
@@ -468,11 +498,11 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
_ => {}
}
}
if malformed || text_open || path_open || !stack.is_empty() {
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() } };
if malformed || text_open || path_open || !stack.is_empty() || !marked_known {
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked };
}
let comment_open = edges.last().is_some_and(|edge| edge.1);
Isolation::Clean { suffix: (dirty || comment_open).then_some(split), wrappers }
Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked }
}
/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the
@@ -484,21 +514,22 @@ fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, conten
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including
/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of
/// content that leaves state set is wrapped in q/Q, once.
/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content
/// sequences are closed at the end too, so hidden optional content cannot hide the new mark.
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
let mut list = contents(doc, page)?;
let wrap = if behind {
None
} else {
match isolation(doc, &list) {
Isolation::Clean { suffix, .. } => suffix.map(|split| (split, 0)),
Isolation::Clean { suffix, marked, .. } => suffix.map(|split| (split, 0, marked)),
// Content that can't be read precisely is wrapped whole, as before: after the
// behind-marks at the start. A `q` it leaves open is closed at its end, where
// closing it changes nothing already drawn.
Isolation::Unknown { unclosed } => {
Isolation::Unknown { unclosed, marked } => {
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len());
(split < list.len()).then_some((split, unclosed))
(split < list.len()).then_some((split, unclosed, marked))
}
}
};
@@ -506,8 +537,9 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
if behind {
list.insert(0, mark);
} else {
if let Some((split, unclosed)) = wrap {
let mut close_bytes = b"\nQ".repeat(unclosed);
if let Some((split, unclosed, marked)) = wrap {
let mut close_bytes = b"\nEMC".repeat(marked);
close_bytes.extend_from_slice(&b"\nQ".repeat(unclosed));
close_bytes.extend_from_slice(WRAP_CLOSE);
let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec()))));
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes))));
@@ -836,5 +868,7 @@ pub use added::{
update_content,
};
#[cfg(test)]
mod marked_isolation_tests;
#[cfg(test)]
mod tests;
+122
View File
@@ -0,0 +1,122 @@
use super::*;
fn fixture(parts: &[&[u8]]) -> Document {
let mut doc = Document::new_empty();
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
let streams = parts.iter().map(|data| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))))).collect();
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(pages));
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
page.set(b"Contents".to_vec(), Object::Array(streams));
let page = doc.add(page);
doc.update_dict(pages, |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
d.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
doc
}
fn placed(parts: &[&[u8]]) -> (Document, Vec<Object>) {
let mut doc = fixture(parts);
stamp(&mut doc, 0, "Probe", b"q Q\n".to_vec()).unwrap();
let page = page_list(&doc).remove(0);
let list = contents(&mut doc, &page).unwrap();
(doc, list)
}
fn close(parts: &[&[u8]]) -> Vec<u8> {
let (doc, list) = placed(parts);
let object = &list[list.len() - 2];
assert_eq!(stream_tag(&doc, object).as_deref(), Some("Wrap"));
let bytes = stream_bytes(&doc, object).unwrap();
assert_eq!(wrapper_role(&doc, object), Some(false));
bytes
}
#[test]
fn closes_only_marked_operators_not_strings_names_comments_or_image_samples() {
for tail in
[b"/Span << /Text (BMC BDC EMC) >> DP".as_slice(), b"/BMC /EMC DP", b"% BMC BDC EMC", b"BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n"]
{
let mut input = b"/OC /L1 BDC\n".to_vec();
input.extend_from_slice(tail);
assert_eq!(close(&[&input]), b"\nEMC\nQ %PdfCraft\n", "{tail:?}");
}
}
#[test]
fn safe_suffix_stays_outside_marked_content_across_balanced_graphics() {
let (doc, list) = placed(&[b"q /OC /L1 BDC Q", b"50 Tz"]);
assert_eq!(stream_bytes(&doc, &list[0]).unwrap(), WRAP_OPEN);
assert_eq!(stream_bytes(&doc, &list[1]).unwrap(), b"q /OC /L1 BDC Q");
assert_eq!(stream_bytes(&doc, &list[list.len() - 2]).unwrap(), b"\nEMC\nQ %PdfCraft\n");
assert_eq!(close(&[b"q /OC /L1 BDC Q"]), b"\nEMC\nQ %PdfCraft\n");
}
#[test]
fn known_marked_depth_survives_unknown_graphics_and_tolerant_operands() {
for (input, expected) in [
(b"/OC /L1 BDC q".as_slice(), b"\nEMC\nQ\nQ %PdfCraft\n".as_slice()),
(b"Q /OC /L1 BDC", b"\nEMC\nQ %PdfCraft\n"),
(b"/OC /L1 BDC BT", b"\nEMC\nQ %PdfCraft\n"),
(b"/OC /L1 BDC 0 0 m", b"\nEMC\nQ %PdfCraft\n"),
(b"/OC /L1 BDC 123 BMC /Artifact BDC", b"\nEMC\nQ %PdfCraft\n"),
(b"EMC 123 /OC /L1 BDC 123 /Artifact BMC", b"\nEMC\nEMC\nQ %PdfCraft\n"),
(b"/OC /L1 BDC 123 EMC", b"\nQ %PdfCraft\n"),
(b"/Span 123 BDC", b"\nEMC\nQ %PdfCraft\n"),
] {
assert_eq!(close(&[input]), expected, "{input:?}");
}
}
#[test]
fn closes_cross_stream_begins_without_closing_balanced_sequences_twice() {
assert_eq!(close(&[b"/OC", b"/L1 BDC"]), b"\nEMC\nQ %PdfCraft\n");
assert_eq!(close(&[b"/OC /L1 BDC", b"EMC"]), WRAP_CLOSE);
assert_eq!(close(&[b"/OC /L1 BDC % EMC", b"EMC\n"]), b"\nEMC\nQ %PdfCraft\n");
}
#[test]
fn recognizes_only_canonical_new_closes_and_retains_legacy_closes() {
for bytes in [WRAP_CLOSE, OLD_WRAP_CLOSE, b"\nQ\nQ %PdfCraft\n", b"\nEMC\nQ %PdfCraft\n", b"\nEMC\nEMC\nQ\nQ %PdfCraft\n"] {
assert!(is_wrap_close(bytes), "{bytes:?}");
}
for bytes in [
b"\nEMCQ %PdfCraft\n".as_slice(),
b"\nEMC\nQQ %PdfCraft\n",
b"\nQ\nEMC\nQ %PdfCraft\n",
b"EMC\nQ %PdfCraft\n",
b"\nEMC 1 w\nQ %PdfCraft\n",
b"\nEMC\nQ %PdfCraft\nq Q",
] {
assert!(!is_wrap_close(bytes), "{bytes:?}");
}
}
#[test]
fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() {
let maximum = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH);
let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH);
expected.extend_from_slice(WRAP_CLOSE);
assert_eq!(close(&[&maximum]), expected);
let mut combined = maximum.clone();
combined.extend_from_slice(&b"q\n".repeat(ISOLATION_MAX_DEPTH));
let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH);
expected.extend_from_slice(&b"\nQ".repeat(ISOLATION_MAX_DEPTH));
expected.extend_from_slice(WRAP_CLOSE);
assert_eq!(close(&[&combined]), expected);
assert!(expected.len() < 64 * 1024, "maximum generated close remains recognizable for removal");
let mut doc = fixture(&[&combined]);
let page = page_list(&doc).remove(0);
let original = contents(&mut doc, &page).unwrap();
place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q Q\n".to_vec(), false).unwrap();
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(contents(&mut doc, &page).unwrap(), original, "maximum combined close can be removed without nesting wrappers");
for suffix in [b"/Artifact BMC\n".as_slice(), b"/Artifact BMC\nEMC\n", b"/Artifact BMC\nEMC\nEMC\n"] {
let mut over = maximum.clone();
over.extend_from_slice(suffix);
assert_eq!(close(&[&over]), WRAP_CLOSE, "overflow cannot resume speculative depth tracking after EMC");
}
}
+176
View File
@@ -1473,6 +1473,65 @@ fn skia_subset_page() -> Document {
doc
}
/// #2616: a subset whose ToUnicode has no space (as LaTeX and Sphinx output often has, spacing
/// words with TJ kerning) makes finding the space width read every font on the page. That ran
/// for every character of a multi-row draft, so each keystroke in a long paragraph took half a
/// second. It runs once per font now, however long the paragraph.
#[test]
fn a_long_draft_in_a_font_without_a_space_works_out_the_space_width_once() {
// Two rows of one paragraph (14 pt apart) in the Chrome-style subset of `skia_subset_page`,
// whose ToUnicode here has no entry for the space glyph.
let mut doc =
text_page("BT /F3 12 Tf 72 700 Td <0177031602E001FB03050255> Tj ET BT /F3 12 Tf 72 686 Td <008D0266035402FE01FB030503E6> Tj ET");
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
let unicode = doc.add(Object::Stream(Stream::flate(
Dict::new(),
b"1 begincodespacerange <0000> <FFFF> endcodespacerange 12 beginbfchar <0177> <0050> <0316> <006F> <02E0> <006C> <01FB> <0061> <0305> <006E> <0255> <0064> <008D> <0047> <0266> <0065> <0354> <0072> <02FE> <006D> <03E6> <0079> <1000> <007A> endbfchar",
)));
let widths: Vec<Object> =
[(0x8D, 746), (0x177, 639), (0x1FB, 551), (0x255, 612), (0x266, 583), (0x2E0, 242), (0x2FE, 876), (0x305, 591), (0x316, 600)]
.into_iter()
.chain([(0x354, 376), (0x3E6, 515)])
.flat_map(|(cid, width)| [Object::Int(cid), Object::Array(vec![Object::Int(width)])])
.collect();
let descriptor = Dict::from_iter([
(b"Type".to_vec(), Object::name("FontDescriptor")),
(b"FontName".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"Flags".to_vec(), Object::Int(32)),
(b"FontFile2".to_vec(), Object::Ref(program)),
]);
let cid_font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("CIDFontType2")),
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"CIDToGIDMap".to_vec(), Object::name("Identity")),
(b"DW".to_vec(), Object::Int(562)),
(b"W".to_vec(), Object::Array(widths)),
(b"FontDescriptor".to_vec(), Object::Dict(descriptor)),
]);
let font = Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type0")),
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
(b"Encoding".to_vec(), Object::name("Identity-H")),
(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid_font)])),
(b"ToUnicode".to_vec(), Object::Ref(unicode)),
]);
let p = pdfcraft_model::pages(&doc)[0].obj;
let fonts = Dict::from_iter([(b"F3".to_vec(), Object::Dict(font))]);
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))])))).unwrap();
let blocks = text::text_blocks(&doc, 0).unwrap();
assert_eq!(blocks.len(), 1, "one paragraph: {:?}", blocks.iter().map(|b| &b.text).collect::<Vec<_>>());
let draft = "Poland and Germany
"
.repeat(20);
text::SOURCE_SPACE_CALLS.with(|c| c.set(0));
let layout = text::text_edit_layout(&doc, 0, 0, draft.trim_end(), &text::BlockStyle::default()).unwrap();
let calls = text::SOURCE_SPACE_CALLS.with(std::cell::Cell::get);
assert!(layout.draft_rect.is_some_and(|r| r[2] > r[0]), "laid out: {:?}", layout.draft_rect);
assert!(calls <= 8, "the space width was worked out {calls} times for {} characters", draft.len());
}
#[test]
fn a_subset_keeps_its_font_for_glyphs_left_to_the_default_width() {
let doc = skia_subset_page();
@@ -3377,6 +3436,55 @@ fn latin_ligature_actual_text_stays_on_its_line() {
}
}
/// #3110: `/ActualText` may come from a named property list (`/Span /MC0 BDC`) looked up in the
/// page's or the form's `/Properties`, as well as from an inline one.
#[test]
fn actual_text_from_named_property_list() {
let properties = "/Properties << /MC0 << /ActualText (Hello World) >> /MC1 << /Lang (en) >> >>";
let page_with = |content: &str| {
build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> {properties} >> /Contents 4 0 R >>"),
&stream("", content),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
])
};
for (content, expected) in [
("/Span /MC0 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"),
("/Span <</ActualText (Hello World)>> BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"),
("/Span /MC1 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"),
("/Span /Missing BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"),
] {
let doc = page_with(content);
for doc in [&doc, &reopen(&doc)] {
let lines = text::text_lines(doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), [expected], "{content}");
assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected, "{content}");
let reading: Vec<String> = text::reading_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, [expected], "{content}");
}
}
// A form's named list is looked up in the form's own /Properties, not the page's.
let fm = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /Properties << /MC0 << /ActualText (Form text) >> >> >>",
"/Span /MC0 BDC BT /F1 24 Tf 72 600 Td (Glyphs) Tj ET EMC",
);
let doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
&format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> {properties} >> /Contents 4 0 R >>"
),
&stream("", "/Fm1 Do"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
&fm,
]);
let reading: Vec<String> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(reading, ["Form text"]);
}
#[test]
fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() {
let hex: String = "שלום".encode_utf16().map(|u| format!("{u:04X}")).collect();
@@ -3820,6 +3928,74 @@ fn private_image_crop_with_intervening_state_resizes_across_streams() {
}
}
/// [`image_page`] with `pieces` as its `/Contents` array and a Form /Fm0 drawing 72,500–172,600.
fn split_image_page(pieces: &[&[u8]]) -> Document {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let mut form = Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([72, 500, 172, 600].map(Object::Int).to_vec()));
let form = doc.add(Object::Stream(Stream::flate(form, b"0.5 g 72 500 100 100 re f")));
let image = doc.get(pdfcraft_model::pages(&doc)[0].obj);
let mut res = image.as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| r.as_dict().cloned()).unwrap();
let mut xo = res.get(b"XObject").and_then(|x| x.as_dict().cloned()).unwrap();
xo.set(b"Fm0".to_vec(), Object::Ref(form));
res.set(b"XObject".to_vec(), Object::Dict(xo));
let refs = pieces.iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect();
doc.update_dict(page, |d| {
d.set(b"Contents".to_vec(), Object::Array(refs));
d.set(b"Resources".to_vec(), Object::Dict(res));
})
.unwrap();
doc
}
#[test]
fn an_image_whose_operator_starts_the_next_stream_can_be_selected_moved_and_deleted() {
// #3113: an operator's operands end one piece and its keyword starts the next.
let square = [72.0, 500.0, 172.0, 600.0];
let cases: [(&[&[u8]], bool); 4] = [
(&[b"q 100 0 0 100 72 500 cm /Im0", b"Do Q"], false),
(&[b"q 100 0 0 100 72 500", b"cm /Im0 Do Q"], false),
(&[b"q /Fm0", b"Do Q"], true),
(&[b"q 100 0 0 100 72 500 cm /Im0 Do Q"], false),
];
for (pieces, is_form) in cases {
let doc = split_image_page(pieces);
let label: Vec<_> = pieces.iter().map(|p| String::from_utf8_lossy(p)).collect();
let images = images::page_images(&doc, 0).unwrap();
assert_eq!(images.len(), 1, "{label:?}");
assert!(close(images[0].rect, square), "{label:?}: {:?}", images[0].rect);
assert_eq!(images[0].is_form, is_form);
assert!(images[0].contains([122.0, 550.0]), "{label:?}");
if !is_form {
let read = images::reading_images(&doc, 0).unwrap();
assert!(read.len() == 1 && close(read[0].rect, square), "{label:?}: {read:?}");
}
let mut deleted = doc.clone();
images::change_image(&mut deleted, 0, 0, &images::ImageChange::Delete).unwrap();
let deleted = reopen(&deleted);
assert!(images::page_images(&deleted, 0).unwrap().is_empty(), "{label:?}");
let (joined, ops) = joined_ops(&deleted);
assert!(!ops.iter().any(|o| o.is("Do")), "{joined}");
let target = [300.0, 500.0, 400.0, 600.0];
let mut moved = doc.clone();
images::change_image(&mut moved, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(square, target))).unwrap();
let moved = reopen(&moved);
let after = images::page_images(&moved, 0).unwrap();
assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&moved).0);
let mut shifted = doc.clone();
images::translate_images(&mut shifted, 0, &[0], [228.0, 0.0]).unwrap();
let shifted = reopen(&shifted);
let after = images::page_images(&shifted, 0).unwrap();
assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&shifted).0);
}
}
#[test]
fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() {
let mut doc = image_page();
+23 -7
View File
@@ -740,7 +740,8 @@ fn interpret(
cache: &mut HashMap<Vec<u8>, (Rc<Metrics>, Option<String>)>,
carry: &mut Carry,
mut invoked: Option<&mut Vec<Invocation>>,
logical_text: bool,
// `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`.
logical_text: Option<&Dict>,
) -> Vec<Shown> {
let mut out: Vec<Shown> = Vec::new();
let mut ts = carry.ts.clone();
@@ -757,11 +758,16 @@ fn interpret(
// artifacts and inner spans, which inherit their enclosing region.
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
marked.push(if region { Some(i) } else { marked.last().copied().flatten() });
let logical = op.operands.get(1).and_then(Object::as_dict).and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
// The property list is inline or named in the resources' /Properties (§14.6.2).
let list = logical_text.and_then(|properties| match op.operands.get(1) {
Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)),
other => other.and_then(Object::as_dict).cloned(),
});
let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()),
_ => None,
});
actual.push(if logical_text { logical.map(|s| (out.len(), i, s)) } else { None });
actual.push(logical.map(|s| (out.len(), i, s)));
}
b"EMC" => {
marked.pop();
@@ -1018,7 +1024,8 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
// joined, so an operator whose operands end one piece still has them.
let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true);
let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default();
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, Some(&properties));
let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended
for mut s in shown {
// A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a
@@ -1261,7 +1268,8 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let mut cache = HashMap::new();
let mut invoked = Vec::new();
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true);
let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default();
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), Some(&properties));
let mut last = None;
let mut last_stream = None;
let mut forms = invoked.into_iter().peekable();
@@ -1661,7 +1669,15 @@ fn source_encodes(metrics: &Metrics, text: &str) -> bool {
text.split(' ').all(|word| metrics.encode(word).is_some())
}
#[cfg(test)]
thread_local! {
/// How often [`source_space`] ran on this thread, for the test that pins it out of per-character loops.
pub(crate) static SOURCE_SPACE_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn source_space(doc: &Document, fonts: &Dict, metrics: &Metrics) -> f64 {
#[cfg(test)]
SOURCE_SPACE_CALLS.with(|c| c.set(c.get().saturating_add(1)));
let width = |m: &Metrics| m.encode(" ").map(|b| m.codes(&b).iter().map(|(c, _)| m.width(*c)).sum::<f64>());
if let Some(w) = width(metrics) {
return w;
@@ -1891,7 +1907,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
let streams = content_streams(doc, &p.dict);
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false);
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None);
let mut patches = HashMap::new();
for s in shown.into_iter().filter(|s| drops.contains(&s.op)) {
if s.state.render_mode >= 4 {
@@ -2548,7 +2564,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false);
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None);
let mut patches = HashMap::new();
// Keep full precision: a tiny rounded text-space change can be magnified by a CTM,
// and restoring rise/spacing must not alter following unselected text.
+17 -3
View File
@@ -489,6 +489,20 @@ pub(super) fn rewrite_styled_block(
};
// A source run may carry TJ kerning/justification that is absent from its text state.
// Keep its measured advance when rebuilding the run instead of making it wrap on entry.
// Each font's space width, worked out once: when a font can't show a space, finding one
// reads every font on the page, and doing that for every character of a paragraph made each
// keystroke in a long paragraph take half a second (#2616).
// Only for the fonts the paragraph uses, when first asked: working out every page font's width
// up front would scan the page once per font for a short edit on a page of many fonts.
let spaces: std::cell::RefCell<HashMap<String, f64>> = std::cell::RefCell::new(HashMap::new());
let space_of = |name: &str, m: &Metrics| {
if let Some(w) = spaces.borrow().get(name) {
return *w;
}
let w = source_space(doc, &fonts, m);
spaces.borrow_mut().insert(name.to_owned(), w);
w
};
let mut calibration = HashMap::new();
for (index, source) in lines.iter().enumerate() {
if let Some(m) = metrics.get(&source.font) {
@@ -496,7 +510,7 @@ pub(super) fn rewrite_styled_block(
m,
&source.text,
source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s),
source_space(doc, &fonts, m),
space_of(&source.font, m),
source.origin.state.char_spacing,
source.origin.state.word_spacing,
) * source.origin.state.scale
@@ -513,7 +527,7 @@ pub(super) fn rewrite_styled_block(
m,
&item.0.to_string(),
size_of(source, item.1),
source_space(doc, &fonts, m),
space_of(&item.2, m),
overrides
.get(&item.1)
.and_then(|r| r.char_spacing)
@@ -778,7 +792,7 @@ pub(super) fn rewrite_styled_block(
m,
&string,
size_of(source, item.1),
source_space(doc, &fonts, m),
spaces.borrow().get(&item.2).copied().unwrap_or_else(|| source_space(doc, &fonts, m)),
state.char_spacing,
state.word_spacing,
)?);
+3
View File
@@ -37,6 +37,9 @@ pdfcraft-model = { workspace = true }
pdfcraft-geom = { workspace = true }
thiserror = { workspace = true }
png = "0.18"
# Already in the graph (through image and pdfcraft-create): image's TiffEncoder can't set the
# resolution or the compression of an exported page.
tiff = "0.11"
# Already in the graph (url and idna through webbrowser, icu_properties through boa): they show
# the host of a document's link the way the browser reads it (links::display_host).
url = "2.5"
+31 -15
View File
@@ -93,14 +93,14 @@ impl Exporter {
encode_png(r.width, r.height, &r.rgba)
}
/// Page `page` as an image file of `format`.
/// Page `page` as an image file of `format`, which says the resolution it was drawn at.
pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result<Vec<u8>, String> {
self.check(page)?;
let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
if let Some(e) = r.error {
return Err(format!("page {}: {e}", page + 1));
}
encode_image(r.width, r.height, &r.rgba, format)
encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi))
}
/// The reading-order text of a page.
@@ -127,12 +127,14 @@ impl Exporter {
}
}
/// Premultiplied RGBA → an image file of `format`.
pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result<Vec<u8>, String> {
/// Premultiplied RGBA → an image file of `format`. A JPEG or TIFF says it is `dpi` dots per inch,
/// so other programs show and print it at the page's size. A PNG says nothing: its `pHYs` counts
/// pixels per metre, so 72 dpi would read back as 72.009 and a page made from it would shrink.
pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat, dpi: f64) -> Result<Vec<u8>, String> {
match format {
ImageFormat::Png => encode_png(width, height, premultiplied),
ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality),
ImageFormat::Tiff => encode_tiff(width, height, premultiplied),
ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality, dpi),
ImageFormat::Tiff => encode_tiff(width, height, premultiplied, dpi),
}
}
@@ -199,23 +201,37 @@ fn over_white(premultiplied: &[u8]) -> Vec<u8> {
.collect()
}
/// Premultiplied RGBA → baseline JPEG (pages are opaque: transparency becomes white paper).
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8) -> Result<Vec<u8>, String> {
/// Premultiplied RGBA → baseline JPEG at `dpi` (pages are opaque: transparency becomes white paper).
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result<Vec<u8>, String> {
use image::ImageEncoder;
use image::codecs::jpeg::{JpegEncoder, PixelDensity};
let rgb = over_white(premultiplied);
let mut out = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100))
.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8)
.map_err(|e| e.to_string())?;
let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
// The JFIF density is whole dots per inch.
enc.set_pixel_density(PixelDensity::dpi(dpi.round() as u16));
enc.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
Ok(out)
}
/// Premultiplied RGBA → TIFF (RGB, LZW-compressed by the encoder's default).
pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>, String> {
use image::ImageEncoder;
/// Premultiplied RGBA → TIFF (RGB, LZW-compressed) at `dpi`. Written with `tiff` directly:
/// `image`'s `TiffEncoder` always says 1 × 1 with no unit and leaves the strips uncompressed.
/// No predictor: LZW alone is the compression TIFF readers support most widely after none.
pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> Result<Vec<u8>, String> {
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8};
use tiff::tags::{ResolutionUnit, Tag};
let err = |e: tiff::TiffError| e.to_string();
let rgb = over_white(premultiplied);
let mut out = std::io::Cursor::new(Vec::new());
image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw);
let mut image = tiff.new_image::<RGB8>(width, height).map_err(err)?;
// In hundredths: a page drawn at a lowered resolution (`Exporter::dpi_used`) is rarely a whole number.
let resolution = Rational { n: (dpi * 100.0).round() as u32, d: 100 };
let tags = image.encoder();
tags.write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).map_err(err)?;
tags.write_tag(Tag::XResolution, resolution.clone()).map_err(err)?;
tags.write_tag(Tag::YResolution, resolution).map_err(err)?;
image.write_data(&rgb).map_err(err)?;
Ok(out.into_inner())
}
+42
View File
@@ -2574,6 +2574,48 @@ fn pages_export_as_png_jpeg_and_tiff() {
let tif = ex.image(0, 144.0, ImageFormat::Tiff).unwrap();
let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap();
assert_eq!((decoded.width(), decoded.height()), (400, 600));
let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(&tif)).unwrap();
let compression = tags.get_tag_u32(tiff::tags::Tag::Compression).unwrap();
let predictor = tags.get_tag_u32(tiff::tags::Tag::Predictor).unwrap();
assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor");
}
/// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution.
fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) {
use tiff::{decoder::ifd::Value, tags::Tag};
let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap();
let mut res = |tag| match d.get_tag(tag).unwrap() {
Value::Rational(n, d) => f64::from(n) / f64::from(d),
v => panic!("{tag:?} is {v:?}"),
};
let (x, y) = (res(Tag::XResolution), res(Tag::YResolution));
(d.get_tag_u32(Tag::ResolutionUnit).unwrap(), x, y)
}
/// An exported TIFF or JPEG says the resolution it was drawn at, so other programs show and print it
/// at the page's size. A TIFF said 1 × 1 with no unit, and a JPEG said nothing.
#[test]
fn exported_pages_record_the_resolution_they_were_drawn_at() {
use crate::export::{Exporter, ImageFormat};
let (s, id) = session_with(1);
let mut ex = Exporter::new(s.get(id).unwrap());
assert_eq!(tiff_resolution(&ex.image(0, 144.0, ImageFormat::Tiff).unwrap()), (2, 144.0, 144.0));
let jpg = ex.image(0, 144.0, ImageFormat::Jpeg { quality: 85 }).unwrap();
assert_eq!(&jpg[6..11], b"JFIF\0");
assert_eq!(jpg[13..18], [1, 0, 144, 0, 144], "JFIF density in dots per inch");
// pHYs counts pixels per metre: 72 dpi would read back as 72.009, and a page made from the PNG
// (doc_create from images) would come out 0.01% small.
let png = ex.image(0, 72.0, ImageFormat::Png).unwrap();
assert!(png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap().info().pixel_dims.is_none(), "a PNG states no resolution");
// A page too large for the renderer at the asked resolution says the one it was drawn at.
let mut s = Session::new().with_clock(|| 1_700_000_000);
let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap();
let id = s.open("wide.pdf", None, bytes, None).unwrap();
let mut ex = Exporter::new(s.get(id).unwrap());
let used = ex.dpi_used(0, 600.0);
let (unit, x, y) = tiff_resolution(&ex.image(0, 600.0, ImageFormat::Tiff).unwrap());
assert!(unit == 2 && (x - used).abs() < 0.01 && (y - used).abs() < 0.01, "{x} × {y}, drawn at {used}");
}
#[test]
@@ -0,0 +1,274 @@
//! Contributor-original pages. Added marks must not inherit an unfinished hidden layer.
use std::sync::Arc;
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
use pdfcraft_engine::{AddedText, Edit, HeaderFooter, Session, Watermark};
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
const VISIBLE: &str = "BT /F1 18 Tf 72 700 Td (Visible text) Tj ET\n";
const HIDDEN: &str = "BT /F1 18 Tf 72 600 Td (Hidden note) Tj ET\n";
fn fixture(parts: &[String]) -> Arc<Vec<u8>> {
let mut doc = Document::new_empty();
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Ref(doc.add(font)));
let mut group = Dict::new();
group.set(b"Type".to_vec(), Object::name("OCG"));
group.set(b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Hidden layer")));
let group = doc.add(group);
let mut properties = Dict::new();
properties.set(b"L1".to_vec(), Object::Ref(group));
let mut resources = Dict::new();
resources.set(b"Font".to_vec(), Object::Dict(fonts));
resources.set(b"Properties".to_vec(), Object::Dict(properties));
let mut config = Dict::new();
config.set(b"OFF".to_vec(), Object::Array(vec![Object::Ref(group)]));
let mut oc = Dict::new();
oc.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(group)]));
oc.set(b"D".to_vec(), Object::Dict(config));
doc.update_dict(doc.root().unwrap(), |d| d.set(b"OCProperties".to_vec(), Object::Dict(oc))).unwrap();
let streams = parts.iter().map(|s| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), s.as_bytes().to_vec()))))).collect();
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(pages));
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
page.set(b"Resources".to_vec(), Object::Dict(resources));
page.set(b"Contents".to_vec(), Object::Array(streams));
let page = doc.add(page);
doc.update_dict(pages, |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
d.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())
}
fn render(bytes: Arc<Vec<u8>>) -> (Vec<u8>, String) {
let mut renderer = PageRenderer::new(bytes, Default::default());
let pixels = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(pixels.error.is_none(), "{:?}", pixels.error);
let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() });
assert!(text.error.is_none(), "{:?}", text.error);
let text = text.text.unwrap();
(pixels.rgba.to_vec(), text.text_of(0..text.glyphs.len()))
}
fn edit(kind: &str) -> Edit {
match kind {
"HEADER" | "000001" => {
let mut settings = HeaderFooter::default();
if kind == "HEADER" {
settings.text[1] = "HEADER".into();
} else {
settings.text[5] = "<<Bates Number#6#1##>>".into();
}
Edit::AddHeaderFooter { pages: vec![0], settings, replace: true }
}
"WATERMARK" => Edit::AddWatermark {
pages: vec![0],
settings: Watermark { text: kind.into(), opacity: 1.0, rotation: 0.0, font_size: 18.0, ..Default::default() },
replace: true,
file: None,
},
"ADDED" => Edit::AddText { page: 0, text: AddedText { text: kind.into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() } },
_ => panic!("unknown mark"),
}
}
fn add_and_save(input: Arc<Vec<u8>>, kind: &str) -> Arc<Vec<u8>> {
let mut session = Session::new();
let id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
session.apply(id, edit(kind)).unwrap();
let saved = session.save_bytes(id).unwrap();
assert!(saved.starts_with(&input), "incremental save preserves the original file bytes");
assert_original_streams(&input, &saved);
let mut reopened = Session::new();
let id = reopened.open("saved.pdf", None, saved.clone(), None).unwrap();
assert_eq!(reopened.save_bytes(id).unwrap().as_slice(), saved.as_slice());
saved
}
#[test]
fn all_four_mark_paths_remain_visible_after_saved_unfinished_marked_content() {
let mut failures = Vec::new();
for (case, opening, hidden) in [("hidden", "/OC /L1 BDC\n", true), ("artifact", "/Artifact BMC\n", false), ("balanced", "/OC /L1 BDC\n", true)] {
let closed = fixture(&[format!("{VISIBLE}{opening}{HIDDEN}EMC\n")]);
let input = if case == "balanced" { closed.clone() } else { fixture(&[format!("{VISIBLE}{opening}{HIDDEN}")]) };
let original = render(input.clone());
assert!(original.1.contains("Visible text"));
assert_eq!(original.1.contains("Hidden note"), !hidden);
for kind in ["HEADER", "000001", "WATERMARK", "ADDED"] {
let saved = add_and_save(input.clone(), kind);
let actual = render(saved);
let expected_bytes = add_and_save(closed.clone(), kind);
let expected = render(expected_bytes);
let mut wanted = vec!["Visible text", kind];
if !hidden {
wanted.push("Hidden note");
}
wanted.sort_unstable();
let pass = actual.0 == expected.0 && actual.1 == expected.1 && lines(&actual.1) == wanted;
eprintln!("{case}/{kind}: visible={} closed-control-pixels={}", actual.1.contains(kind), actual.0 == expected.0);
if !pass {
failures.push(format!("{case}/{kind}: {:?}", actual.1));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn stream_list(doc: &Document) -> Vec<Object> {
let page = pdfcraft_model::pages(doc).remove(0);
let contents = doc.resolve(page.dict.get(b"Contents").unwrap());
match &*contents {
Object::Array(items) => items.clone(),
_ => vec![page.dict.get(b"Contents").unwrap().clone()],
}
}
fn assert_original_streams(before: &Arc<Vec<u8>>, after: &Arc<Vec<u8>>) {
let original = Document::open(before.clone()).unwrap();
let saved = Document::open(after.clone()).unwrap();
let saved_list = stream_list(&saved);
for reference in stream_list(&original) {
assert!(saved_list.contains(&reference), "the original stream stays on the page");
assert_eq!(original.resolve(&reference), saved.resolve(&reference), "original encoded stream object remains unchanged");
}
}
fn lines(text: &str) -> Vec<&str> {
let mut lines: Vec<_> = text.lines().filter(|line| !line.is_empty()).collect();
lines.sort_unstable();
lines
}
#[test]
fn nested_stream_edges_and_malformed_graphics_keep_hidden_content_hidden() {
let cases: Vec<(&str, Vec<String>, usize)> = vec![
("nested", vec![format!("{VISIBLE}/OC /L1 BDC /Artifact BMC {HIDDEN}")], 2),
("split operands", vec![format!("{VISIBLE}/OC"), format!("/L1 BDC {HIDDEN}")], 1),
("split close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}"), "EMC\n".into()], 0),
("trailing comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC")], 1),
("cross-stream comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC"), "EMC BMC\n".into()], 1),
("unclosed graphics", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}q")], 1),
("balanced graphics open marked", vec![format!("q {VISIBLE}/OC /L1 BDC {HIDDEN}Q")], 1),
("malformed graphics", vec![format!("Q {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1),
("invalid begins", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 BMC /Artifact BDC")], 1),
("extra begins", vec![format!("{VISIBLE}123 /OC /L1 BDC {HIDDEN}123 /Artifact BMC")], 2),
("extra close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 EMC")], 0),
("underflow", vec![format!("EMC {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1),
("operator strings", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}/Note <</Text (BMC BDC EMC)>> DP")], 1),
("inline samples", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n")], 1),
];
for (case, parts, close_count) in cases {
let input = fixture(&parts);
let mut closed = parts.clone();
closed.push(format!("\n{}", "EMC\n".repeat(close_count)));
let actual = render(add_and_save(input, "HEADER"));
let expected = render(add_and_save(fixture(&closed), "HEADER"));
assert_eq!(lines(&actual.1), lines("Visible text\nHEADER"), "{case}");
assert!(actual.0 == expected.0, "{case}: pixels equal the explicitly closed control");
}
}
#[test]
fn replacing_and_removing_marks_restores_the_original_stream_list() {
let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
let original = Document::open(input.clone()).unwrap();
for kind in ["HEADER", "000001", "WATERMARK"] {
let mut session = Session::new();
let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
for _ in 0..3 {
session.apply(id, edit(kind)).unwrap();
let saved = session.save_bytes(id).unwrap();
let page = Document::open(saved.clone()).unwrap();
assert_eq!(stream_list(&page).len(), 4, "{kind}: updating does not add wrappers");
assert_original_streams(&input, &saved);
assert_eq!(lines(&render(saved.clone()).1), lines(&format!("Visible text\n{kind}")));
session = Session::new();
id = session.open("saved.pdf", None, saved, None).unwrap();
}
let mark_kind = if kind == "WATERMARK" { pdfcraft_edit::MarkKind::Watermark } else { pdfcraft_edit::MarkKind::HeaderFooter };
session.apply(id, Edit::RemoveMarks { kind: mark_kind }).unwrap();
let saved = session.save_bytes(id).unwrap();
let page = Document::open(saved.clone()).unwrap();
assert_eq!(stream_list(&page), stream_list(&original), "{kind}: removal restores original references");
assert_original_streams(&input, &saved);
assert!(render(saved) == render(input.clone()), "{kind}: removal restores original pixels and text");
session.undo(id).unwrap();
assert_eq!(lines(&render(session.save_bytes(id).unwrap()).1), lines(&format!("Visible text\n{kind}")));
session.redo(id).unwrap();
assert!(render(session.save_bytes(id).unwrap()) == render(input.clone()), "redo removal restores the original rendering");
}
}
#[test]
fn added_update_delete_and_readd_preserve_existing_wrapper_behavior() {
let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
let mut session = Session::new();
let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
for _ in 0..3 {
session.apply(id, edit("ADDED")).unwrap();
assert_eq!(session.get(id).unwrap().added().len(), 1);
let text = AddedText { text: "UPDATED".into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() };
session.apply(id, Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_edit::Content::Text(text) }).unwrap();
let saved = session.save_bytes(id).unwrap();
assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nUPDATED"));
assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 4);
assert_original_streams(&input, &saved);
session = Session::new();
id = session.open("saved.pdf", None, saved, None).unwrap();
session.apply(id, Edit::DeleteContent { page: 0, index: 0 }).unwrap();
let saved = session.save_bytes(id).unwrap();
assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 3, "Added deletion retains the established wrappers");
assert!(render(saved.clone()) == render(input.clone()), "deleting Added restores the original rendering");
assert_original_streams(&input, &saved);
session = Session::new();
id = session.open("saved.pdf", None, saved, None).unwrap();
}
}
#[test]
fn an_existing_behind_watermark_does_not_inherit_or_reveal_hidden_content() {
let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
let mut session = Session::new();
let id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
session
.apply(
id,
Edit::AddWatermark {
pages: vec![0],
settings: Watermark { text: "BEHIND".into(), behind: true, font_size: 18.0, opacity: 1.0, rotation: 0.0, ..Default::default() },
replace: false,
file: None,
},
)
.unwrap();
session.apply(id, edit("HEADER")).unwrap();
let saved = session.save_bytes(id).unwrap();
assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nBEHIND\nHEADER"));
assert_original_streams(&input, &saved);
}
#[test]
fn new_mark_closes_hidden_content_appended_after_an_old_wrapper() {
let bytes = fixture(&["q %PdfCraft\n".into(), VISIBLE.into(), "Q %PdfCraft\n".into(), format!("/OC /L1 BDC {HIDDEN}")]);
let mut doc = Document::open(bytes).unwrap();
let list = stream_list(&doc);
for index in [0, 2] {
let reference = list[index].as_ref().unwrap();
let Object::Stream(mut stream) = doc.get(reference).as_ref().clone() else { panic!("stream") };
stream.dict.set(b"PCMark".to_vec(), Object::name("Wrap"));
doc.set(reference, Object::Stream(stream));
}
let input = Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap());
let saved = add_and_save(input, "HEADER");
assert_eq!(lines(&render(saved).1), lines("Visible text\nHEADER"));
}
+7
View File
@@ -56,6 +56,13 @@ delete_field(&mut doc, "email")?;
Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio
buttons joining a group become kid widgets with their own export state. Renaming changes only
the last part of a dotted name.
- `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values).
`FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears
that label, independently of the field name. The index is 0-based. A label-only edit redraws
just that widget, copying shared/indirect `/MK` and appearance dictionaries before editing.
Other widgets, actions, icons, rotation and unknown entries are preserved. Existing icon-only
layouts keep hiding the caption; caption rendering retains the existing Helvetica/WinAnsi
limits and reports unavailable characters as above.
## Not yet
+33 -2
View File
@@ -63,6 +63,9 @@ impl NewField {
pub struct FieldProps {
pub name: Option<String>,
pub tooltip: Option<String>,
/// Normal push-button label (`/MK /CA`) of one widget (0-based index).
/// An empty string clears the label; other widgets and icon layouts are preserved.
pub caption: Option<(usize, String)>,
pub read_only: Option<bool>,
pub required: Option<bool>,
pub multiline: Option<bool>,
@@ -657,6 +660,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
locked: false,
hidden: false,
rotation: 0,
caption: None,
list: None,
};
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export);
@@ -677,9 +681,16 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see
/// `ask_viewers_to_redraw`), as when a value is set.
pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
redraw_widgets(doc, name, None)
}
fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>) -> Result<(), FormError> {
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
let mut undrawn = false;
for w in &f.widgets {
for (index, w) in f.widgets.iter().enumerate() {
if only.is_some_and(|only| only != index) {
continue;
}
let mut missing = None;
let ap = match f.kind {
FieldKind::Text | FieldKind::Combo | FieldKind::List => {
@@ -861,6 +872,15 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
// Refuse a wrong type/index before any other requested property is written.
if let Some((wi, _)) = &props.caption {
if f.kind != FieldKind::PushButton {
return invalid("only push buttons have a caption");
}
if f.widgets.get(*wi).is_none() {
return invalid(format!("{name} has no widget {}", wi.saturating_add(1)));
}
}
// Validated before anything is written, so a refused rotation leaves the document unchanged.
// `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle.
let mut rotated_rect: Option<Option<[f64; 4]>> = None;
@@ -1178,7 +1198,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
})?;
}
redraw_field(doc, &new_name)?;
if let Some((wi, caption)) = &props.caption
&& let Some(w) = f.widgets.get(*wi)
{
// Copy an indirect/shared MK before patching it; colours, icons, rotation and
// unknown entries belong to this widget, and other owners keep their old label.
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
mk.set(b"CA".to_vec(), PdfString::text(caption));
doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
}
// A label-only edit must not replace another widget's custom appearances.
let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi);
redraw_widgets(doc, &new_name, only)?;
if props.calculate.is_some() {
crate::recalculate(doc)?;
}
+9
View File
@@ -103,6 +103,8 @@ pub struct Widget {
pub hidden: bool,
/// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0.
pub rotation: i64,
/// Push buttons: the normal label (`/MK /CA`), separate from the field's name.
pub caption: Option<String>,
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
pub list: Option<appearance::ListRows>,
}
@@ -802,6 +804,13 @@ fn walk(
locked: annot_flags & 128 != 0,
hidden: annot_flags & (2 | 32) != 0,
rotation: appearance::mk_rotation(doc, wd),
caption: (kind == FieldKind::PushButton)
.then(|| {
let mk = doc.resolve(wd.get(b"MK")?);
let value = doc.resolve(mk.as_dict()?.get(b"CA")?);
value.as_string().map(|s| s.to_text())
})
.flatten(),
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
})
})
+102
View File
@@ -87,6 +87,108 @@ fn ap(doc: &Document, w: &Widget) -> String {
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
}
#[test]
fn button_caption_preserves_other_widgets_and_shared_dictionaries() {
for indirect_caption in [false, true] {
let mut doc = one_page();
let a = add_field(&mut doc, 0, [40.0, 600.0, 200.0, 630.0], &NewField::Button { caption: "Original".into() }, Some("first")).unwrap();
let b = add_field(&mut doc, 0, [40.0, 500.0, 200.0, 530.0], &NewField::Button { caption: "Original".into() }, Some("second")).unwrap();
let all = fields(&doc);
let (first, second) = (field(&all, &a).obj, field(&all, &b).obj);
let mut parent = Dict::new();
parent.set(b"FT".to_vec(), Object::name("Btn"));
parent.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64));
parent.set(b"T".to_vec(), PdfString::text("clear_button"));
parent.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)]));
let mut action = Dict::new();
action.set(b"S".to_vec(), Object::name("ResetForm"));
parent.set(b"A".to_vec(), Object::Dict(action.clone()));
let parent = doc.add(parent);
let mut mk = Dict::new();
let ca = Object::String(PdfString::text("Original"));
let ca = if indirect_caption { Object::Ref(doc.add(ca)) } else { ca };
mk.set(b"CA".to_vec(), ca);
mk.set(b"TP".to_vec(), Object::Int(0));
mk.set(b"R".to_vec(), Object::Int(90));
mk.set(b"VendorNote".to_vec(), PdfString::text("kept"));
let mk = doc.add(mk);
let mut shared_ap = doc.get(first).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone();
let normal = shared_ap.get(b"N").unwrap().clone();
shared_ap.set(b"R".to_vec(), normal.clone());
shared_ap.set(b"D".to_vec(), normal);
shared_ap.set(b"VendorAP".to_vec(), PdfString::text("kept"));
let shared_ap = doc.add(shared_ap);
for widget in [first, second] {
doc.update_dict(widget, |d| {
for key in [b"FT".as_slice(), b"Ff", b"T"] {
d.remove(key);
}
d.set(b"Parent".to_vec(), Object::Ref(parent));
d.set(b"MK".to_vec(), Object::Ref(mk));
d.set(b"AP".to_vec(), Object::Ref(shared_ap));
})
.unwrap();
}
let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap();
doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap();
let original = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
let original = Document::open(Arc::new(original)).unwrap();
// Full saves can renumber objects: reacquire references from the reopened model.
let f = field(&fields(&original), "clear_button").clone();
let first = f.widgets[0].obj;
let mk = original.get(first).as_dict().unwrap().reference(b"MK").unwrap();
for full in [false, true] {
let mut doc = original.clone();
let untouched = doc.get(first).clone();
let shared_mk = doc.get(mk).clone();
assert_eq!(field(&fields(&doc), "clear_button").widgets[1].caption.as_deref(), Some("Original"));
set_props(&mut doc, "clear_button", &FieldProps { caption: Some((1, "Clear Form".into())), ..Default::default() }).unwrap();
assert_eq!(doc.get(first), untouched, "the other widget and all its appearances are untouched");
assert_eq!(doc.get(mk), shared_mk, "shared MK is not patched in place");
let bytes = if full {
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
} else {
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(bytes.starts_with(original.bytes().as_slice()));
assert!(bytes.len() > original.bytes().len());
bytes
};
let doc = Document::open(Arc::new(bytes)).unwrap();
let f = field(&fields(&doc), "clear_button").clone();
assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::<Vec<_>>(), [Some("Original"), Some("Clear Form")]);
assert_eq!(f.button, Some(af::ButtonAction::Reset { fields: Vec::new(), exclude: false }));
assert_eq!(doc.get(f.obj).as_dict().unwrap().get(b"A"), Some(&Object::Dict(action.clone())));
let w = doc.get(f.widgets[1].obj);
let mk = w.as_dict().unwrap().get(b"MK").unwrap().as_dict().unwrap();
assert_eq!(mk.get(b"VendorNote").unwrap().as_string().unwrap().to_text(), "kept");
assert_eq!(mk.get(b"R").unwrap().as_int(), Some(90));
assert_eq!(mk.get(b"TP").unwrap().as_int(), Some(0));
let apd = w.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap();
assert!(apd.contains(b"VendorAP"));
assert!(!apd.contains(b"R") && !apd.contains(b"D"), "stale hover/pressed text must not survive");
assert!(ap(&doc, &f.widgets[1]).contains("(Clear Form) Tj"));
}
}
}
#[test]
fn button_caption_rejects_wrong_type_and_index_before_mutation() {
let mut doc = fixture();
for (name, wi) in [("name", 0), ("agree", 0), ("go", usize::MAX)] {
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
let props = FieldProps { caption: Some((wi, "Clear Form".into())), name: Some("renamed".into()), ..Default::default() };
assert!(set_props(&mut doc, name, &props).is_err());
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
}
set_props(&mut doc, "go", &FieldProps { caption: Some((0, "提交".into())), ..Default::default() }).unwrap();
assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("提交"));
assert_eq!(undrawn_fields(&doc), [("go".to_string(), '提')]);
set_props(&mut doc, "go", &FieldProps { caption: Some((0, String::new())), ..Default::default() }).unwrap();
assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some(""));
assert!(undrawn_fields(&doc).is_empty());
assert!(!ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains(" Tj"));
}
/// Read the drawn strings together with the appearance's actual font after saving and reopening.
fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec<Vec<u8>>) {
let widget = doc.get(w.obj);
+15
View File
@@ -1933,6 +1933,8 @@ fn days_in(y: i64, m: u32) -> u32 {
}
}
const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String {
let pic: Vec<char> = picture.chars().collect();
let mut out = String::new();
@@ -1955,6 +1957,19 @@ fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String {
('D', 1) => out.push_str(&d.to_string()),
(_, _) => out.push_str(&format!("{d:02}")),
}
} else if pic[i] == 'E' {
let mut run = 1;
while i + run < pic.len() && pic[i + run] == 'E' {
run += 1;
}
i += run;
// Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday.
let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize;
match run {
1 | 2 => out.push_str(&(weekday + 1).to_string()),
3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])),
_ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")),
}
} else {
out.push(pic[i]);
i += 1;
+15
View File
@@ -636,6 +636,21 @@ mod formcalc {
assert_eq!(calc("; a comment\n// another\nSum(1,\n 2,\n 3)"), "6");
}
#[test]
fn date_pictures_render_the_weekday_letters() {
assert_eq!(calc(r#"Num2Date(45350, DateFmt(4))"#), "Thursday, February 29, 2024");
assert_eq!(calc(r#"Num2Date(45350, "EEE, YYYY-MM-DD")"#), "Thu, 2024-02-29");
assert_eq!(calc(r#"Num2Date(45350, "EEEE")"#), "Thursday");
assert_eq!(calc(r#"Num2Date(45350, "E")"#), "5");
assert_eq!(calc(r#"Num2Date(45351, "EEEE")"#), "Friday");
assert_eq!(calc(r#"Num2Date(45352, "EEEE")"#), "Saturday");
assert_eq!(calc(r#"Num2Date(45353, "EEEE")"#), "Sunday");
assert_eq!(calc(r#"Num2Date(1, "EEEE")"#), "Monday");
assert_eq!(calc(r#"Format("EEEE, YYYY-MM-DD", 45350)"#), "Thursday, 2024-02-29");
// Other letters are still literal text.
assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x");
}
#[test]
fn str_and_format_round_exact_ties_away_from_zero() {
assert_eq!(calc("Str(2.5, 3, 0)"), " 3");
+49 -1
View File
@@ -99,7 +99,8 @@ pub fn staging_suffixes() -> impl Iterator<Item = u64> {
/// a folder at the target is refused by the rename. Flushing is the staging file's own
/// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file
/// that is replaced keeps its permissions, so a private file stays private; a new file gets the
/// usual mode for a new file.
/// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where
/// the volume stores one.
pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> {
write_atomically_with(path, bytes, form, staging_suffixes(), std::fs::File::sync_all)
}
@@ -128,12 +129,17 @@ pub fn write_atomically_with(
let keep = std::fs::metadata(path).ok().map(|m| m.permissions());
#[cfg(not(unix))]
let keep = None;
// A saved file is the same document: it keeps its creation date (#2658).
#[cfg(any(windows, target_os = "macos"))]
let created = std::fs::metadata(path).ok().filter(|m| m.is_file()).and_then(|m| m.created().ok());
let (tmp, file) = create_staging_as(dir, name, form, suffixes, keep.as_ref())?;
let result = (|| {
// Closed at the end of the block, before the rename (required on Windows).
{
let mut file = file;
file.write_all(bytes)?;
#[cfg(any(windows, target_os = "macos"))]
keep_created(&file, created);
sync_staged(&file, sync_all)?;
}
std::fs::rename(&tmp, path)
@@ -144,6 +150,20 @@ pub fn write_atomically_with(
result
}
/// Give the staging file the creation date of the file it replaces, where the system keeps one
/// that a program can set (Windows, macOS). Best effort: a volume that can't store it leaves the
/// new file's own date, as before.
#[cfg(any(windows, target_os = "macos"))]
fn keep_created(file: &std::fs::File, created: Option<std::time::SystemTime>) {
if let Some(created) = created {
#[cfg(target_os = "macos")]
use std::os::macos::fs::FileTimesExt as _;
#[cfg(windows)]
use std::os::windows::fs::FileTimesExt as _;
let _ = file.set_times(std::fs::FileTimes::new().set_created(created));
}
}
/// Flush the staging file to the disk before it replaces the target. On macOS `sync_all` asks
/// for a full flush (`F_FULLFSYNC`), which SMB and FUSE volumes, such as a mounted Cryptomator
/// vault, refuse as unsupported: there a plain `fsync` is the strongest flush the volume offers.
@@ -313,4 +333,32 @@ mod tests {
assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private");
let _ = std::fs::remove_dir_all(&dir);
}
/// #2658: a save replaces the file, but the document is the same one: it keeps its creation
/// date where the system lets a program set it.
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn a_replaced_file_keeps_its_creation_date() {
#[cfg(target_os = "macos")]
use std::os::macos::fs::FileTimesExt as _;
#[cfg(windows)]
use std::os::windows::fs::FileTimesExt as _;
let dir = test_dir("created");
let target = dir.join("doc.pdf");
std::fs::write(&target, b"OLD").unwrap();
// 2020-01-02 03:04:05 UTC.
let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245);
let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap();
file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap();
drop(file);
assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set");
write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date");
// A new file gets a date of its own.
let fresh = dir.join("new.pdf");
write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap();
assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then);
let _ = std::fs::remove_dir_all(&dir);
}
}
+40
View File
@@ -3631,6 +3631,46 @@ endstream endobj
}
}
/// #3308: a fax image whose `/Rows` says fewer rows than its `/Height` (a plotter driver wrote
/// `/Rows` equal to `/Columns`) is decoded to its height: only the first `/Rows` rows were
/// decoded and the rest drawn black, as MuPDF and Acrobat don't. `/BlackIs1` turns the fixture
/// white, so rows left undecoded show as black. Also through an `/Indexed` colour space, the
/// filter's other output path.
#[test]
fn group4_fax_with_rows_below_its_height_decodes_to_the_image_height() {
const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02];
for (space, rows) in [("/DeviceGray", "/Rows 2"), ("[/Indexed /DeviceRGB 1 <000000FFFFFF>]", "/Rows 2"), ("/DeviceGray", "/Rows 4")] {
let content = "q 20 0 0 20 5 5 cm /Im1 Do Q";
let mut 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 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} /BlackIs1 true >> /Length {} >> stream
",
content.len(),
ALL_BLACK_8X4.len()
)
.into_bytes();
pdf.extend_from_slice(&ALL_BLACK_8X4);
pdf.extend_from_slice(
b"
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
);
let mut renderer = PageRenderer::new(Arc::new(pdf), 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(), "{space} {rows}: {:?}", page.error);
for y in [17, 22, 27, 32] {
assert_eq!(&page.rgba[((y * 40 + 15) * 4)..][..4], &[255, 255, 255, 255], "{space} {rows}: image row at y {y} is decoded");
}
}
}
#[test]
fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() {
use hayro_syntax::object::{FromBytes, Object};
+10 -1
View File
@@ -734,6 +734,7 @@ pub struct FieldDraft {
pub tab: FieldTab,
pub name: String,
pub tooltip: String,
pub caption: String,
pub read_only: bool,
pub required: bool,
pub multiline: bool,
@@ -805,6 +806,7 @@ impl FieldDraft {
tab: FieldTab::General,
name: f.name.rsplit('.').next().unwrap_or(&f.name).to_string(),
tooltip: f.tooltip.clone().unwrap_or_default(),
caption: f.widgets.get(widget).and_then(|w| w.caption.clone()).unwrap_or_default(),
read_only: f.read_only(),
required: f.has(pdfcraft_engine::field_flags::REQUIRED),
multiline: f.has(pdfcraft_engine::field_flags::MULTILINE),
@@ -848,7 +850,7 @@ impl FieldDraft {
fn tabs(&self) -> Vec<(FieldTab, &'static str)> {
let mut t = vec![(FieldTab::General, "General"), (FieldTab::Appearance, "Appearance"), (FieldTab::Position, "Position")];
if !matches!(self.kind, FormFieldKind::PushButton | FormFieldKind::Signature) {
if self.kind != FormFieldKind::Signature {
t.push((FieldTab::Options, "Options"));
}
if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo) {
@@ -865,6 +867,7 @@ impl FieldDraft {
let p = FieldProps {
name: (self.name.trim() != o.name).then(|| self.name.trim().to_string()),
tooltip: (self.tooltip != o.tooltip).then(|| self.tooltip.clone()),
caption: (self.caption != o.caption).then(|| (self.widget, self.caption.clone())),
read_only: ch(self.read_only, o.read_only),
required: ch(self.required, o.required),
multiline: ch(self.multiline, o.multiline),
@@ -1087,6 +1090,12 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke
FieldTab::Calculate => calculate_tab(ui, d, t),
FieldTab::Actions => actions_tab(ui, d, t),
FieldTab::Options => match d.kind {
FormFieldKind::PushButton => {
ui.horizontal(|ui| {
let l = ui.label(tl!("Label"));
ui.add(crate::widgets::line(&mut d.caption).desired_width(340.0)).labelled_by(l.id);
});
}
FormFieldKind::Text => {
use pdfcraft_engine::field_flags as ff;
egui::Grid::new("text-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
+24 -2
View File
@@ -9,6 +9,9 @@
//! Encrypted documents are stored encrypted (the working file is), so recovery never writes
//! plaintext of a protected document to disk. The web build has no recovery store yet.
//!
//! On Unix the folder is owner-only (`0o700`), so other accounts on the machine can't read the
//! snapshots of documents that aren't encrypted.
//!
//! A running instance holds an exclusively locked `instance-<pid>-<n>.lock` in the folder and
//! names it in the snapshots it writes, so a second instance can tell live autosaves from a
//! crash's leftovers and neither offers nor discards them (#2166). The OS releases the lock
@@ -75,12 +78,31 @@ impl RecoveryStore {
Self { dir: dir.into(), lock: Arc::default() }
}
/// Create the folder if it is missing. On Unix only the owner may open it: snapshots are
/// unsaved copies of open documents, and with the default mode other accounts on the
/// machine could read them. A folder an earlier version made is tightened too. A volume
/// that can't change modes (FAT, some network shares) works as before, with one warning in
/// the log.
fn create_dir(&self) -> std::io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let open_to_others = std::fs::metadata(&self.dir).is_ok_and(|m| m.permissions().mode() & 0o077 != 0);
if open_to_others && let Err(e) = std::fs::set_permissions(&self.dir, std::fs::Permissions::from_mode(0o700)) {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| log::warn!("recovery folder {} can't be made owner-only: {e}", self.dir.display()));
}
}
Ok(())
}
/// Mark the folder as in use by a running instance (see the module doc). Without a lock
/// (the folder can't be written, or the platform has no file locks) snapshots are written
/// and offered as before.
pub fn claim(&self) {
self.lock.get_or_init(|| {
if let Err(e) = std::fs::create_dir_all(&self.dir) {
if let Err(e) = self.create_dir() {
log::warn!("recovery folder {} can't be made: {e}", self.dir.display());
return None;
}
@@ -166,7 +188,7 @@ impl RecoveryStore {
/// Write (or replace) an entry. The PDF is written before its metadata, both atomically, so
/// a listed entry always has complete bytes.
pub fn write(&self, meta: &RecoveryMeta, bytes: &[u8]) -> std::io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
self.create_dir()?;
crate::editing::write_atomically(&self.dir.join(format!("{}.pdf", meta.key)).to_string_lossy(), bytes)?;
let json = serde_json::to_vec_pretty(meta).map_err(std::io::Error::other)?;
crate::editing::write_atomically(&self.dir.join(format!("{}.json", meta.key)).to_string_lossy(), &json)
+53
View File
@@ -250,6 +250,59 @@ fn options_tab_sets_the_check_box_style() {
assert_eq!(s.session.get(s.views[0].id).unwrap().field_check_style("agree"), Some(CheckStyle::Cross));
}
#[test]
fn button_label_is_editable_without_renaming_the_field() {
use pdfcraft_engine::{Edit, NewField};
let mut h = harness();
h.state_mut().apply_edit(Edit::AddField {
page: 0,
rect: [40.0, 300.0, 190.0, 330.0],
kind: NewField::Button { caption: String::new() },
name: Some("clear_button".into()),
});
h.state_mut().open_field_props("clear_button", 0);
h.run_steps(2);
h.get_by_label("Options").click();
h.run_steps(2);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").click();
h.run_steps(1);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").type_text("Clear Form");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
let app = h.state();
let id = app.views[0].id;
let field = app.session.get(id).unwrap().form.iter().find(|f| f.name == "clear_button").unwrap();
let bytes = app.session.save_bytes(id).unwrap();
let doc = pdfcraft_cos::Document::open(bytes).unwrap();
let widget = doc.get(field.widgets[0].obj);
let mk = doc.resolve(widget.as_dict().unwrap().get(b"MK").unwrap());
assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form");
assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Change field properties"));
// Read the existing label back, and leave both Cancel and an unchanged OK harmless.
let before = app.session.save_bytes(id).unwrap();
h.state_mut().open_field_props("clear_button", 0);
h.run_steps(2);
h.get_by_label("Options").click();
h.run_steps(2);
assert_eq!(h.state().field_props.as_ref().unwrap().caption, "Clear Form");
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Label").type_text("Discard this");
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(2);
assert_eq!(h.state().session.save_bytes(id).unwrap(), before);
h.state_mut().open_field_props("clear_button", 0);
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(2);
assert_eq!(h.state().session.save_bytes(id).unwrap(), before);
}
#[test]
fn dropdown_alignment_is_editable_and_preserved() {
choice_alignment(pdfcraft_engine::NewField::Combo { options: vec!["One".into(), "Two".into()], editable: false }, "Center", 1);
+20
View File
@@ -411,3 +411,23 @@ fn a_running_instances_autosaves_are_not_offered_to_a_second_instance() {
let fourth = harness(instance());
assert_eq!(fourth.state().recoverable.len(), 1, "a name outside the folder is not looked at, and the snapshot is offered");
}
/// Snapshots are unsaved copies of open documents, so on Unix only the owner may open the
/// folder: a new one is made that way, and one an earlier version left with the default mode is
/// tightened when an instance starts.
#[cfg(unix)]
#[test]
fn the_recovery_folder_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let mode = |s: &RecoveryStore| std::fs::metadata(s.dir()).unwrap().permissions().mode() & 0o777;
let fresh = store("owner-only");
crashed_session(&fresh, fixture(2), None, None);
assert_eq!(files_in(&fresh), 2);
assert_eq!(mode(&fresh), 0o700);
let earlier = store("owner-only-earlier");
std::fs::create_dir_all(earlier.dir()).unwrap();
std::fs::set_permissions(earlier.dir(), std::fs::Permissions::from_mode(0o755)).unwrap();
earlier.claim();
assert_eq!(mode(&earlier), 0o700);
}
+2
View File
@@ -202,6 +202,8 @@ fn esc(s: &str) -> String {
'\n' => o.push_str("&#10;"),
'\r' => o.push_str("&#13;"),
c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {}
// XML 1.0 noncharacters are not allowed even as raw text.
'\u{FFFE}' | '\u{FFFF}' => {}
c => o.push(c),
}
}
+16
View File
@@ -168,6 +168,22 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() {
assert_eq!(comment_view(&dst), comment_view(&src), "the pair and its thread survive");
}
#[test]
fn xml_exports_drop_xml_noncharacters() {
let mut src = filled();
set_value(&mut src, "Name", &FieldValue::Text("a\u{FFFE}b\u{FFFF}c".into())).unwrap();
let xfdf = export_xfdf(&src, false, true, "f.pdf");
let doc = roxmltree::Document::parse(&xfdf).unwrap_or_else(|e| panic!("XFDF is not well-formed: {e}: {xfdf:?}"));
let value = doc
.descendants()
.find(|n| n.has_tag_name("field") && n.attribute("name") == Some("Name"))
.and_then(|n| n.children().find(|c| c.has_tag_name("value")))
.and_then(|n| n.text());
assert_eq!(value, Some("abc"));
let data = export_data(&src, Format::Xml);
roxmltree::Document::parse(&data).unwrap_or_else(|e| panic!("XML is not well-formed: {e}: {data:?}"));
}
#[test]
fn xml_exports_preserve_whitespace_in_field_names() {
let names = ["Line\nName", "Line\tName", "Line\rName", "Line\r\nName", "\t Leading\nTrailing \t", "Line Name"];
+3 -2
View File
@@ -44,6 +44,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M13: reject malformed CLI --root options before dispatch; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (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)
Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind
it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md),
@@ -131,13 +132,13 @@ total matches the parity estimate.
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page |
| 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. 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. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<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. |
| 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. 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. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Done since: 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. (#2892). 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. |
| 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. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<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. |
| 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. Refreshed against upstream `99941daa` (2026-10-15).<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. |
| 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). 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. 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 |
+2 -2
View File
@@ -4920,8 +4920,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props"]
evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"]
notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/ui-egui/tests/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"]
notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen; icon-only layout and existing Helvetica limits retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits"
[[feature]]
id = "form.props-actions"
+2 -5
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -17,10 +17,13 @@ pub(crate) fn decode(
// PdfCraft patch: `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11, and
// the default), not zero rows. Scanners write it on Group 4 pages, which rendered blank.
// PdfCraft patch (19): fewer `/Rows` than the image's `/Height` doesn't cut the image short: a
// plotter driver wrote `/Rows` equal to `/Columns`, and the rows after it were drawn black
// (#3308). The decoder still stops where the data ends.
let rows = params
.get::<u32>(ROWS)
.filter(|&rows| rows != 0)
.unwrap_or(image_params.height);
.map_or(image_params.height, |rows| rows.max(image_params.height));
let end_of_block = params.get::<bool>(END_OF_BLOCK).unwrap_or(true);
let settings = DecodeSettings {
+64 -14
View File
@@ -534,23 +534,27 @@ pub(crate) mod flate {
/// Copies `left` bytes from `distance` back in the output, up to the limit. Returns how
/// many are still to copy. A copy whose source does not exist writes nothing.
fn copy_back(&mut self, valid: bool, distance: usize, mut left: usize) -> usize {
while left > 0 && self.output.len() < self.limit {
if valid {
let byte = self
.output
.len()
.checked_sub(distance)
.and_then(|index| self.output.get(index))
.copied();
if let Some(byte) = byte {
self.output.push(byte);
}
fn copy_back(&mut self, valid: bool, distance: usize, left: usize) -> usize {
let len = self.output.len();
// Without a source the copy writes nothing, and it runs out only at the limit.
let Some(mut start) = len.checked_sub(distance).filter(|_| valid && distance > 0) else {
return if len < self.limit { 0 } else { left };
};
// PdfCraft patch: copy in runs of up to `distance` bytes. Each run reads only bytes
// already in the output, so the bytes are the ones a byte-at-a-time copy writes.
let copy = left.min(self.limit.saturating_sub(len));
let mut done = 0;
while done < copy {
let end = start.saturating_add((copy - done).min(distance));
if end > self.output.len() {
break;
}
left -= 1;
self.output.extend_from_within(start..end);
done += end - start;
start = end;
}
left
left - done
}
fn read_dynamic_tables(&mut self) -> Option<(HuffmanTable, HuffmanTable)> {
@@ -1292,6 +1296,52 @@ mod tests {
);
}
/// PdfCraft patch: 300 zero bytes, zlib-compressed: one literal, then copies of distance 1 that
/// are longer than their distance.
const ZEROS_300: [u8; 13] = [0x78, 0xda, 0x63, 0x60, 0x18, 0x05, 0xc4, 0x02, 0x00, 0x01, 0x2c, 0x00, 0x01];
#[test]
fn back_reference_longer_than_its_distance_repeats_its_source() {
let mut state = flate::FlateState::new(&ZEROS_300);
let mut out = Vec::new();
state.decode_into(&ZEROS_300, &mut out, 1000);
assert_eq!(out, vec![0; 300]);
}
#[test]
fn back_reference_cut_by_the_limit_resumes_in_the_next_call() {
let mut state = flate::FlateState::new(&ZEROS_300);
let mut out = Vec::new();
state.decode_into(&ZEROS_300, &mut out, 100);
assert_eq!(out, vec![0; 100]);
state.decode_into(&ZEROS_300, &mut out, 1000);
assert_eq!(out, vec![0; 300]);
}
#[test]
fn back_reference_reads_bytes_written_by_an_earlier_call() {
// "abc" repeated 2000 times, zlib-compressed: copies reach 3 bytes back, and the calls below
// stop every 7 bytes, so most copies start in an earlier call's output.
const ABC_2000: [u8; 32] = [
0x78, 0xda, 0xed, 0xc2, 0x41, 0x11, 0x00, 0x00, 0x0c, 0x02, 0xa0, 0xac, 0x6a, 0xff, 0x0e, 0x0b,
0xb1, 0x2f, 0x1c, 0xe9, 0xa2, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xfe, 0x1e, 0x2f, 0x01, 0xf9, 0x59,
];
let expected = b"abc".repeat(2000);
let mut state = flate::FlateState::new(&ABC_2000);
let mut out = Vec::new();
loop {
let before = out.len();
state.decode_into(&ABC_2000, &mut out, before + 7);
if out.len() == before || out.len() >= expected.len() {
break;
}
}
assert_eq!(out, expected);
}
#[test]
fn predictor_paeth() {
predictor_test(