mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge upstream main a12fe8f41 into next
This commit is contained in:
@@ -329,6 +329,7 @@ fn info(args: &[String]) -> Result<(), CliError> {
|
||||
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
|
||||
"links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
|
||||
"transitions": info.transitions.len(),
|
||||
"custom_properties": info.custom.len(),
|
||||
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),
|
||||
"warnings": info.warnings,
|
||||
});
|
||||
|
||||
@@ -2406,6 +2406,9 @@ fn info(d: &Document) -> Value {
|
||||
"pdf_version": i.pdf_version,
|
||||
"file_size": i.file_size,
|
||||
"title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords,
|
||||
// Custom document properties (Document Properties ▸ Custom): /Info entries beyond the
|
||||
// ones the spec names, set and cleared with doc_set_info.
|
||||
"custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::<Vec<_>>(),
|
||||
"creator": i.creator, "producer": i.producer,
|
||||
"tagged": i.tagged,
|
||||
"has_javascript": i.has_javascript,
|
||||
|
||||
@@ -156,7 +156,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords; an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.")
|
||||
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])),
|
||||
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
|
||||
.ro()
|
||||
|
||||
@@ -238,6 +238,74 @@ fn saving_a_repaired_file_puts_the_repair_on_disk() {
|
||||
assert_eq!(info["pages"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// Custom document properties through the tools: set and cleared with doc_set_info under any
|
||||
/// name the spec does not use, listed by doc_info apart from the standard entries, bad names
|
||||
/// refused without a change.
|
||||
#[test]
|
||||
fn custom_document_properties_are_set_listed_and_cleared_through_the_tools() {
|
||||
let dir = workdir("custom-info");
|
||||
std::fs::write(dir.join("a.pdf"), fixture(1)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" }));
|
||||
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "Apollo" }));
|
||||
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Revision", "value": "7" }));
|
||||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||||
assert_eq!(info["title"], "Report");
|
||||
let mut custom: Vec<(String, String)> =
|
||||
info["custom"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap().into(), c["value"].as_str().unwrap().into())).collect();
|
||||
custom.sort();
|
||||
assert_eq!(custom, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "{}", info["custom"]);
|
||||
assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "Bad Name", "value": "x" })).is_err(), "a name with a space is refused");
|
||||
assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "", "value": "x" })).is_err());
|
||||
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "" }));
|
||||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||||
assert_eq!(info["custom"].as_array().unwrap().len(), 1, "{}", info["custom"]);
|
||||
assert_eq!(info["custom"][0]["name"], "Revision");
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/custom.pdf" }));
|
||||
let mut b = auto(&dir);
|
||||
let re = ok(&mut b, "doc_open", json!({ "path": "out/custom.pdf" }))["doc"].as_u64().unwrap();
|
||||
let info = ok(&mut b, "doc_info", json!({ "doc": re }));
|
||||
assert_eq!(info["custom"][0]["value"], "7", "{}", info["custom"]);
|
||||
}
|
||||
|
||||
/// The custom list `doc_info` shows when a file opens is the one it shows after an unrelated
|
||||
/// edit: a name value, a blank or NUL-padded string and a number are judged the same way by the
|
||||
/// open-time reader and by the engine's refresh.
|
||||
#[test]
|
||||
fn custom_document_properties_do_not_change_after_an_unrelated_edit() {
|
||||
let dir = workdir("custom-info-stable");
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
|
||||
"<< /Producer (Test) /Project (Apollo) /Dept /RnD /Blank ( ) /Padded (Lab\\000\\000) /Revision 7 /Empty () /Mixed ( \\000Lab\\000 ) /Pad ( \\000 ) >>",
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join("custom.pdf"), pdf).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "custom.pdf" }))["doc"].as_u64().unwrap();
|
||||
let opened = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone();
|
||||
let names: Vec<&str> = opened.as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Project", "Padded", "Mixed"], "{opened}");
|
||||
assert_eq!(opened[1]["value"], "Lab", "{opened}");
|
||||
assert_eq!(opened[2]["value"], "Lab", "{opened}");
|
||||
ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" }));
|
||||
let edited = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone();
|
||||
assert_eq!(edited, opened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_preview_proposes_text_without_changing_the_pdf() {
|
||||
let dir = workdir("autotag-preview");
|
||||
|
||||
@@ -831,6 +831,11 @@ impl Document {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key))
|
||||
}
|
||||
|
||||
/// The custom document properties as they are now (name, text), Document Properties ▸ Custom.
|
||||
pub fn custom_info(&self) -> Vec<(String, String)> {
|
||||
self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The name shown on the tab and window: the document title when the document asks for it
|
||||
/// (Initial View ▸ Show: Document Title) and has a real one, else the file name.
|
||||
pub fn display_name(&self) -> String {
|
||||
@@ -3905,6 +3910,7 @@ impl Session {
|
||||
doc.info.keywords = info("Keywords");
|
||||
doc.info.creator = info("Creator");
|
||||
doc.info.producer = info("Producer");
|
||||
doc.info.custom = pdfcraft_organize::custom_info(&editor.cos);
|
||||
doc.info.file_size = bytes.len();
|
||||
if !doc.signatures.is_empty() {
|
||||
let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
|
||||
|
||||
@@ -563,6 +563,54 @@ pub fn insert_blank_page(doc: &mut Document, at: usize, width: f64, height: f64)
|
||||
/// Document information keys editable in Document Properties ▸ Description.
|
||||
pub const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"];
|
||||
|
||||
/// The document-information entries the spec names (ISO 32000-2 table 349). Any other key in
|
||||
/// `/Info` is a custom property (Document Properties ▸ Custom).
|
||||
pub const STANDARD_INFO_KEYS: [&str; 9] = ["Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate", "ModDate", "Trapped"];
|
||||
|
||||
/// Longest custom property name accepted, in bytes: the name length every reader is asked to
|
||||
/// accept (§7.3.5, Annex C).
|
||||
pub const MAX_CUSTOM_KEY_LEN: usize = 127;
|
||||
|
||||
/// The custom document properties: every `/Info` entry whose key the spec does not name and whose
|
||||
/// value is text, in the dictionary's order, as (name, text), the text without NUL or whitespace
|
||||
/// padding and entries that are only padding left out. At most 256 are listed.
|
||||
pub fn custom_info(doc: &Document) -> Vec<(String, String)> {
|
||||
let Some(info) = doc.trailer().get(b"Info").map(|o| doc.resolve(o)) else { return Vec::new() };
|
||||
let Some(d) = info.as_dict() else { return Vec::new() };
|
||||
d.iter()
|
||||
.filter(|(k, _)| !STANDARD_INFO_KEYS.iter().any(|s| s.as_bytes() == k.as_slice()))
|
||||
.filter_map(|(k, v)| {
|
||||
// Padding (\0s and whitespace, in any mix) is not part of the value, and a value that
|
||||
// is only padding is no property. Filtered before the cap, so blank entries can't
|
||||
// crowd real ones out.
|
||||
let text = doc.resolve(v).as_string()?.to_text();
|
||||
let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace());
|
||||
(!text.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), text.to_owned()))
|
||||
})
|
||||
.take(256)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `name` may be a custom property's key: not one the spec names (those are the
|
||||
/// Description fields), not empty, at most [`MAX_CUSTOM_KEY_LEN`] bytes, and without the
|
||||
/// characters a name cannot carry unescaped (whitespace, the delimiters `()<>[]{}/%` and `#`),
|
||||
/// so every reader shows the same name. Acrobat refuses the standard and empty names too.
|
||||
pub fn check_custom_key(name: &str) -> Result<(), OrganizeError> {
|
||||
if name.is_empty() {
|
||||
return Err(OrganizeError::Invalid("a custom property needs a name".into()));
|
||||
}
|
||||
if STANDARD_INFO_KEYS.contains(&name) {
|
||||
return Err(OrganizeError::Invalid(format!("{name} is a standard document property, not a custom one")));
|
||||
}
|
||||
if name.len() > MAX_CUSTOM_KEY_LEN {
|
||||
return Err(OrganizeError::Invalid(format!("a custom property name is at most {MAX_CUSTOM_KEY_LEN} bytes")));
|
||||
}
|
||||
if let Some(c) = name.chars().find(|c| c.is_whitespace() || c.is_control() || "()<>[]{}/%#".contains(*c)) {
|
||||
return Err(OrganizeError::Invalid(format!("a custom property name can't contain {c:?}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a document-information entry as text.
|
||||
pub fn info(doc: &Document, key: &str) -> Option<String> {
|
||||
let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?;
|
||||
@@ -575,6 +623,9 @@ pub fn info(doc: &Document, key: &str) -> Option<String> {
|
||||
/// in the XMP packet, and viewers that read XMP first show its value: the packet's property is
|
||||
/// changed (or removed) to match (see `xmp`).
|
||||
pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> {
|
||||
if !STANDARD_INFO_KEYS.contains(&key) {
|
||||
check_custom_key(key)?;
|
||||
}
|
||||
let value = value.trim();
|
||||
let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value)));
|
||||
set_info_entry(doc, key.as_bytes(), entry)?;
|
||||
|
||||
@@ -383,6 +383,59 @@ fn page_transitions_are_set_removed_and_checked() {
|
||||
assert_eq!(page_dict(&doc, 2).get(b"Dur").and_then(Object::as_f64), Some(5.0));
|
||||
}
|
||||
|
||||
/// Custom document properties (Document Properties ▸ Custom): any `/Info` key the spec does not
|
||||
/// name, set and cleared like the standard ones, listed apart from them, and with the names
|
||||
/// Acrobat refuses refused here too.
|
||||
#[test]
|
||||
fn custom_document_properties_are_listed_set_removed_and_their_names_checked() {
|
||||
let mut doc = open(fixture());
|
||||
assert!(custom_info(&doc).is_empty());
|
||||
set_info(&mut doc, "Title", "Report").unwrap();
|
||||
set_info(&mut doc, "Project", "Apollo").unwrap();
|
||||
set_info(&mut doc, "Revision", "7").unwrap();
|
||||
let mut listed = custom_info(&doc);
|
||||
listed.sort();
|
||||
assert_eq!(listed, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "standard keys are not custom");
|
||||
assert_eq!(info(&doc, "Project").as_deref(), Some("Apollo"));
|
||||
set_info(&mut doc, "Project", "").unwrap();
|
||||
let doc = save_and_reopen(&doc);
|
||||
assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())], "cleared, and the rest survives a save");
|
||||
assert_eq!(info(&doc, "Title").as_deref(), Some("Report"));
|
||||
|
||||
for bad in ["", "Title", "ModDate", "Bad Name", "a/b", "tab\tbed", "hash#tag", "(x)"] {
|
||||
assert!(check_custom_key(bad).is_err(), "{bad:?} must be refused");
|
||||
}
|
||||
let long = "x".repeat(MAX_CUSTOM_KEY_LEN + 1);
|
||||
assert!(check_custom_key(&long).is_err());
|
||||
for good in ["Project", "Révision", "部門", "key-2.0", &"y".repeat(MAX_CUSTOM_KEY_LEN)] {
|
||||
assert!(check_custom_key(good).is_ok(), "{good:?} is a usable name");
|
||||
}
|
||||
let mut doc = open(fixture());
|
||||
assert!(set_info(&mut doc, "Bad Name", "x").is_err(), "set_info checks custom names");
|
||||
assert!(custom_info(&doc).is_empty());
|
||||
}
|
||||
|
||||
/// Padding is not part of a custom value, in any mix of NULs and whitespace, a value that is only
|
||||
/// padding is not listed, and such entries don't count towards the 256 listed: a real property
|
||||
/// after 300 blank ones is still there.
|
||||
#[test]
|
||||
fn custom_document_properties_drop_padding_before_the_cap() {
|
||||
let mut doc = open(fixture());
|
||||
set_info(&mut doc, "Title", "Report").unwrap();
|
||||
let Some(Object::Ref(info_ref)) = doc.trailer().get(b"Info").cloned() else { panic!("an /Info reference") };
|
||||
let text = |s: &str| Object::String(pdfcraft_cos::PdfString { bytes: s.as_bytes().to_vec(), hex: false });
|
||||
doc.update_dict(info_ref, |d| {
|
||||
d.set(b"Mixed".to_vec(), text(" \0Lab\0 "));
|
||||
d.set(b"Pad".to_vec(), text(" \0 \0"));
|
||||
for i in 0..300 {
|
||||
d.set(format!("Blank{i}").into_bytes(), text(" "));
|
||||
}
|
||||
d.set(b"Keep".to_vec(), text("yes"));
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(custom_info(&doc), [("Mixed".to_string(), "Lab".to_string()), ("Keep".to_string(), "yes".to_string())]);
|
||||
}
|
||||
|
||||
/// A one-page document whose catalog has `xmp` as its (Flate-compressed) XMP packet, and the
|
||||
/// same title and author in `/Info`, as LibreOffice writes them.
|
||||
fn doc_with_xmp(xmp: &[u8]) -> Document {
|
||||
|
||||
@@ -56,6 +56,9 @@ pub struct DocInfo {
|
||||
pub keywords: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub producer: Option<String>,
|
||||
/// Custom document properties: `/Info` entries with a key the spec does not name and a text
|
||||
/// value, as (name, text), in the dictionary's order (at most 256).
|
||||
pub custom: Vec<(String, String)>,
|
||||
pub pdf_version: String,
|
||||
pub file_size: usize,
|
||||
pub encrypted: bool,
|
||||
@@ -616,6 +619,23 @@ impl<'a> Inspector<'a> {
|
||||
info.keywords = self.text(d, b"Keywords");
|
||||
info.creator = self.text(d, b"Creator");
|
||||
info.producer = self.text(d, b"Producer");
|
||||
const STANDARD: [&[u8]; 9] =
|
||||
[b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"];
|
||||
// The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`,
|
||||
// trimmed and without empty values): text strings only, so a name value is not a
|
||||
// custom property, and the cap counts listed entries.
|
||||
info.custom = d
|
||||
.iter()
|
||||
.filter(|(k, _)| !STANDARD.contains(&k.as_slice()))
|
||||
.filter_map(|(k, v)| match self.resolve(v) {
|
||||
Object::String(bytes, _) => {
|
||||
let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string();
|
||||
(!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.take(256)
|
||||
.collect();
|
||||
}
|
||||
let Ok(catalog) = self.doc.catalog() else { return };
|
||||
info.tagged = catalog
|
||||
@@ -2308,6 +2328,27 @@ trailer << /Root 1 0 R >>
|
||||
);
|
||||
}
|
||||
|
||||
const CUSTOM_INFO: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note <FEFF90E8> >> endobj
|
||||
trailer << /Root 1 0 R /Info 4 0 R >>
|
||||
%%EOF";
|
||||
|
||||
#[test]
|
||||
fn custom_document_properties_are_listed_apart_from_the_standard_ones() {
|
||||
let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens");
|
||||
assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test")));
|
||||
let mut custom = info.custom.clone();
|
||||
custom.sort();
|
||||
// Revision is a number, not text, so it is not listed; Trapped is a standard entry.
|
||||
assert_eq!(
|
||||
custom,
|
||||
[("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_button_layer_groups_are_read() {
|
||||
let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens");
|
||||
|
||||
@@ -3481,11 +3481,11 @@ title = "Custom document properties"
|
||||
area = "D"
|
||||
tier = "P2"
|
||||
milestone = "M4"
|
||||
status = "planned"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
tools = ["doc_info", "doc_set_info"]
|
||||
evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools"]
|
||||
notes = "Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. Not yet: the Custom tab in Document Properties, and mirroring them into XMP (pdfx:) as Acrobat does"
|
||||
|
||||
[[feature]]
|
||||
id = "organize.xmp-editor"
|
||||
|
||||
Reference in New Issue
Block a user