Merge upstream main a12fe8f41 into next

This commit is contained in:
aiongg
2026-10-18 14:56:01 +00:00
9 changed files with 228 additions and 5 deletions
+1
View File
@@ -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,
});
+3
View File
@@ -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,
+1 -1
View File
@@ -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()
+68
View File
@@ -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");
+6
View File
@@ -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);
+51
View File
@@ -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)?;
+53
View File
@@ -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 {
+41
View File
@@ -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");
+4 -4
View File
@@ -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"