M4: Document Properties has a Custom tab for custom properties (#4387)

* M4: Document Properties has a Custom tab for custom properties

Document Properties has a new Custom tab between Fonts and Advanced. It
lists the document's custom properties, and Name and Value fields with
Add, Update and Delete edit the list. Clicking a listed property fills
the fields with it. OK writes the changes through SetInfo in the
existing "Change document properties" step, so one undo reverts them
with the other tabs' changes.

Add is off for an empty value, a name already listed, a name that
check_custom_key refuses (its message is the button's tooltip), and a
name the file already uses for an entry the list leaves out (not text,
only padding, or past the 256 listed). A property already in the file
under a refused name or a name that is not UTF-8 is listed, and Add,
Update and Delete stay off for it, so OK cannot fail on it or write a
different key.

Clearing an /Info entry now removes it when /Info is a dictionary
written directly in the trailer. set_info_entry returned without a
change there, so a delete in the Custom tab, a cleared Description field
and a doc_set_info clear left the entry in the file. It now writes a
copy without the entry as a new indirect /Info.

The engine re-exports check_custom_key and adds Document::has_info
(organize::has_info). set_option("dialog", "custom") opens the tab.

* Contributors: add dotCooCoo (Robert Lee)

* M4: Add in the Custom tab stops at the 256 properties the tab lists

Document Properties > Custom lists at most 256 properties, so a property
added past that was saved but left out of the list when the dialog opened
again. Add is now off once the tab's list or the file's own list holds 256;
a new name goes after the file's entries, so a file with more than 256 keeps
Add off after a Delete as well.
This commit is contained in:
.CooCoo
2026-10-24 10:30:25 -04:00
committed by GitHub
parent fc91ab9250
commit 2ac1df2f1f
9 changed files with 374 additions and 7 deletions
+4
View File
@@ -176,3 +176,7 @@ real_name = "Nick Nyssen"
[people.agent-reed]
sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+6
View File
@@ -51,6 +51,7 @@ pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transitio
/// One file produced by a split: (1-based first page, last page, PDF bytes).
pub use pdfcraft_organize::LabelStyle;
pub use pdfcraft_organize::Standard;
pub use pdfcraft_organize::check_custom_key;
pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation};
pub use pdfcraft_cos::Algorithm;
@@ -895,6 +896,11 @@ impl Document {
self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key))
}
/// Whether the document information has an entry `key`, of any type, listed or not.
pub fn has_info(&self, key: &str) -> bool {
self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_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()
+13 -2
View File
@@ -672,6 +672,11 @@ pub fn info(doc: &Document, key: &str) -> Option<String> {
in_info.filter(|v| !v.trim_matches('\0').trim().is_empty()).or_else(|| xmp::info_value(doc, key))
}
/// Whether the document information has an entry `key`, of any type.
pub fn has_info(doc: &Document, key: &str) -> bool {
doc.trailer().get(b"Info").map(|o| doc.resolve(o)).is_some_and(|info| info.as_dict().is_some_and(|d| d.get(key.as_bytes()).is_some()))
}
/// Set (or clear, with an empty value) a document-information entry.
///
/// Documents with XMP metadata also carry Title, Author, Subject, Keywords, Creator and Producer
@@ -699,12 +704,18 @@ fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option<Object>) -> Resu
})?;
}
_ => {
let Some(v) = entry else { return Ok(()) };
let mut d = match doc.trailer().get(b"Info") {
Some(Object::Dict(d)) => d.clone(),
_ => Dict::new(),
};
d.set(key.to_vec(), v);
match entry {
Some(v) => d.set(key.to_vec(), v),
None => {
if d.remove(key).is_none() {
return Ok(());
}
}
}
let r = doc.add(d);
doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r));
}
+23
View File
@@ -528,6 +528,29 @@ fn custom_document_properties_are_listed_set_removed_and_their_names_checked() {
assert!(custom_info(&doc).is_empty());
}
/// An `/Info` dictionary written directly in the trailer: a cleared entry is removed, and the
/// other entries are kept.
#[test]
fn document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer() {
let b = build(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(),
body("X"),
],
"/Root 1 0 R /Info << /Title (Report) /Project (Apollo) /Revision (7) >>",
);
let mut doc = open(b);
set_info(&mut doc, "Missing", "").unwrap();
assert!(matches!(doc.trailer().get(b"Info"), Some(Object::Dict(_))), "clearing an absent entry changes nothing");
set_info(&mut doc, "Project", "").unwrap();
set_info(&mut doc, "Title", "").unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())]);
assert_eq!(info(&doc, "Title"), None);
}
/// 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.
+99
View File
@@ -36,6 +36,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some(dialog) = app.dialog else {
app.props_draft = None;
app.view_draft = None;
app.custom_draft = None;
return;
};
// Properties ▸ Fonts shows the fonts as they are now, after any content edits.
@@ -49,6 +50,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
{
app.props_draft = Some((id, INFO_KEYS.map(|k| doc.info_value(k).unwrap_or_default())));
app.view_draft = Some((id, doc.initial_view()));
app.custom_draft = Some((id, crate::CustomDraft { props: doc.custom_info(), ..Default::default() }));
}
let mut apply = false;
let mut split_now: Option<crate::SplitPlan> = None;
@@ -114,6 +116,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
(PropsTab::InitialView, tl!("Initial View")),
(PropsTab::Security, tl!("Security")),
(PropsTab::Fonts, tl!("Fonts")),
(PropsTab::Custom, tl!("Custom")),
(PropsTab::Advanced, tl!("Advanced")),
] {
if widgets::mode_tab(ui, label, tab == tb).clicked() {
@@ -345,6 +348,89 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
row(ui, &f.name, detail);
}
}
PropsTab::Custom => {
// Add takes a new name, Update changes the value of the listed property the
// Name field names, Delete removes it; OK writes the changes as one step.
let editable = doc.allows_modification();
let Some((_, d)) = app.custom_draft.as_mut() else { return };
let l = ui.label(egui::RichText::new(tl!("Name:")).color(t.text_muted));
ui.add_enabled(
editable,
widgets::line(&mut d.name).desired_width(420.0).background_color(t.field).id_salt("custom-name"),
)
.labelled_by(l.id);
ui.end_row();
let l = ui.label(egui::RichText::new(tl!("Value:")).color(t.text_muted));
ui.add_enabled(
editable,
widgets::line(&mut d.value).desired_width(420.0).background_color(t.field).id_salt("custom-value"),
)
.labelled_by(l.id);
ui.end_row();
// Untrimmed: a file's `/Project#20` is not `/Project`, and a typed space is refused.
let name = d.name.clone();
let value = d.value.trim().to_owned();
let listed = d.props.iter().position(|(k, _)| *k == name);
// An entry the list leaves out (not text, only padding, or past its 256) keeps its value.
let file_props = doc.custom_info();
let unlisted = doc.has_info(&name) && !file_props.iter().any(|(k, _)| *k == name);
let refused = pdfcraft_engine::check_custom_key(&name).err().filter(|_| !name.is_empty()).map(|e| e.to_string());
ui.label("");
// A file can hold a custom name that `check_custom_key` refuses, such as one
// with a space: the tab lists it, and Add, Update and Delete stay off for it.
let why = |b: egui::Response| match &refused {
Some(why) => b.on_disabled_hover_text(crate::i18n::words(why)),
None => b,
};
// A name that isn't UTF-8 is listed with U+FFFD and can't be written back as it is.
let editable = editable && refused.is_none() && !name.contains(char::REPLACEMENT_CHARACTER);
// `custom_info` lists at most 256 properties, and a new name goes after the file's
// entries, so Add is off once the draft or the file's own list holds 256.
let room = d.props.len() < 256 && file_props.len() < 256;
ui.horizontal(|ui| {
let add = editable && room && listed.is_none() && !unlisted && !name.is_empty() && !value.is_empty();
if why(ui.add_enabled(add, egui::Button::new(tl!("Add")))).clicked() {
d.props.push((name.clone(), value.clone()));
d.name.clear();
d.value.clear();
}
let update = editable && !value.is_empty() && listed.and_then(|n| d.props.get(n)).is_some_and(|(_, v)| *v != value);
if why(ui.add_enabled(update, egui::Button::new(tl!("Update")))).clicked()
&& let Some(p) = listed.and_then(|n| d.props.get_mut(n))
{
p.1 = value.clone();
}
if why(ui.add_enabled(editable && listed.is_some(), egui::Button::new(tl!("Delete")))).clicked()
&& let Some(n) = listed.filter(|n| *n < d.props.len())
{
d.props.remove(n);
d.name.clear();
d.value.clear();
}
});
ui.end_row();
if d.props.is_empty() {
ui.label("");
ui.label(egui::RichText::new(tl!("None")).color(t.text_faint));
ui.end_row();
}
// A click on a property fills the fields with it; values are document data.
let mut picked = None;
for (k, v) in &d.props {
if ui.selectable_label(*k == name, k.as_str()).clicked() {
picked = Some((k.clone(), v.clone()));
}
ui.scope(|ui| {
ui.set_max_width(420.0);
ui.add(egui::Label::new(v.as_str()).wrap());
});
ui.end_row();
}
if let Some((k, v)) = picked {
d.name = k;
d.value = v;
}
}
PropsTab::Advanced => {
row(ui, "PDF version", i.pdf_version.clone());
row(ui, "Location", crate::bidi::visual(doc.path.as_deref().unwrap_or_default()).into_owned());
@@ -1443,6 +1529,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
app.dialog = None;
app.props_draft = None;
app.view_draft = None;
app.custom_draft = None;
app.bulk_field_props = None;
} else {
app.dialog = Some(next);
@@ -1494,6 +1581,18 @@ fn draft_changes(app: &PdfCraftApp) -> Option<Vec<Edit>> {
{
edits.push(Edit::SetInitialView(Box::new(v.clone())));
}
// Custom: deleted properties are cleared, then added and changed ones are set.
if let Some((cid, d)) = &app.custom_draft
&& cid == id
{
let before = doc.custom_info();
for (k, _) in before.iter().filter(|(k, _)| !d.props.iter().any(|(n, _)| n == k)) {
edits.push(Edit::SetInfo { key: k.clone(), value: String::new() });
}
for (k, v) in d.props.iter().filter(|(k, v)| !before.iter().any(|(n, w)| n == k && w == v)) {
edits.push(Edit::SetInfo { key: k.clone(), value: v.clone() });
}
}
Some(edits)
}
+14
View File
@@ -374,9 +374,19 @@ pub enum PropsTab {
InitialView,
Security,
Fonts,
Custom,
Advanced,
}
/// Document Properties ▸ Custom being edited: the properties as they will be after OK, in order,
/// and the Name and Value fields.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CustomDraft {
pub props: Vec<(String, String)>,
pub name: String,
pub value: String,
}
/// Duplicate Field: the field and the pages (all, or a range, 1-based).
#[derive(Clone, Debug, PartialEq)]
pub struct DuplicateDraft {
@@ -782,6 +792,8 @@ pub struct PdfCraftApp {
pub props_draft: Option<(DocId, [String; 4])>,
/// Document Properties ▸ Initial View (and reading options) being edited.
pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>,
/// Document Properties ▸ Custom being edited.
pub custom_draft: Option<(DocId, CustomDraft)>,
/// Files picked asynchronously for combine / insert (web).
pub requests: files::Requests,
/// Native file pickers in flight (they never block the frame; see `pickers`).
@@ -1161,6 +1173,7 @@ impl PdfCraftApp {
save_override: None,
props_draft: None,
view_draft: None,
custom_draft: None,
requests: Default::default(),
#[cfg(not(target_arch = "wasm32"))]
pickers: Default::default(),
@@ -2885,6 +2898,7 @@ impl PdfCraftApp {
"properties" => Some(Dialog::Properties(PropsTab::Description)),
"security" => Some(Dialog::Properties(PropsTab::Security)),
"fonts" => Some(Dialog::Properties(PropsTab::Fonts)),
"custom" => Some(Dialog::Properties(PropsTab::Custom)),
"advanced" => Some(Dialog::Properties(PropsTab::Advanced)),
"shortcuts" => Some(Dialog::Shortcuts),
"split" => Some(Dialog::Split),
+210
View File
@@ -1153,6 +1153,216 @@ fn cancelling_properties_discards_the_draft() {
assert!(h.state().props_draft.is_none());
}
fn custom_properties(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, String)> {
let app = h.state();
app.session.get(app.views[0].id).unwrap().custom_info()
}
fn type_into(h: &mut Harness<'static, PdfCraftApp>, label: &str, text: &str) {
let field = h.get_by_role_and_label(Role::TextInput, label);
field.focus();
field.type_text(text);
h.run_steps(2);
}
fn clear(h: &mut Harness<'static, PdfCraftApp>, label: &str) {
h.get_by_role_and_label(Role::TextInput, label).focus();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.key_press(Key::Backspace);
h.run_steps(2);
}
#[test]
fn custom_properties_are_added_in_the_custom_tab_as_one_undoable_step() {
let mut h = harness(1, |app| app.set_option("dialog", "custom").unwrap());
h.get_by_label("None");
type_into(&mut h, "Name:", "Project");
type_into(&mut h, "Value:", "Apollo");
h.get_by_role_and_label(Role::Button, "Add").click();
h.run_steps(3);
assert!(custom_properties(&h).is_empty(), "nothing is written before OK");
h.get_by_label("Project");
type_into(&mut h, "Name:", "Revision");
type_into(&mut h, "Value:", "4");
h.get_by_role_and_label(Role::Button, "Add").click();
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(custom_properties(&h), [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "4".to_string())]);
let app = h.state();
assert_eq!(app.session.get(app.views[0].id).unwrap().can_undo(), Some("Change document properties"));
assert!(app.dialog.is_none() && app.custom_draft.is_none());
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(3);
assert!(custom_properties(&h).is_empty());
}
#[test]
fn custom_properties_are_changed_and_deleted_in_the_custom_tab() {
let mut h = harness(1, |app| {
for (key, value) in [("Project", "Apollo"), ("Revision", "4")] {
assert!(app.apply_edit(pdfcraft_engine::Edit::SetInfo { key: key.into(), value: value.into() }));
}
app.set_option("dialog", "custom").unwrap();
});
h.get_by_label("Revision").click();
h.run_steps(3);
h.get_by_role_and_label(Role::Button, "Delete").click();
h.run_steps(3);
h.get_by_label("Project").click();
h.run_steps(3);
assert!(h.get_by_role_and_label(Role::Button, "Update").accesskit_node().is_disabled(), "the value is unchanged");
clear(&mut h, "Value:");
type_into(&mut h, "Value:", "Apollo 2");
h.get_by_role_and_label(Role::Button, "Update").click();
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(custom_properties(&h), [("Project".to_string(), "Apollo 2".to_string())]);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.info_value("Revision"), None);
assert_eq!(doc.can_undo(), Some("Change document properties"));
}
#[test]
fn the_custom_tab_refuses_names_a_custom_property_cannot_have() {
let mut h = harness(1, |app| {
assert!(app.apply_edit(pdfcraft_engine::Edit::SetInfo { key: "Project".into(), value: "Apollo".into() }));
app.set_option("dialog", "custom").unwrap();
});
type_into(&mut h, "Value:", "x");
let add_disabled = |h: &Harness<'static, PdfCraftApp>| h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled();
assert!(add_disabled(&h), "no name");
// A standard name, a name with a space, a delimiter, and a name already listed.
for name in ["Title", "Client name", "R&D/2", "Project"] {
type_into(&mut h, "Name:", name);
assert!(add_disabled(&h), "{name} is refused");
clear(&mut h, "Name:");
}
type_into(&mut h, "Name:", "Client");
assert!(!add_disabled(&h));
assert!(h.get_by_role_and_label(Role::Button, "Delete").accesskit_node().is_disabled(), "Client is not listed");
}
#[test]
fn a_custom_property_whose_name_the_tools_refuse_is_listed_and_left_alone() {
// `/Client#20name` and `/Project#20` are valid PDF names with a space in them, which a new custom
// property can't have; `/Project#20` is not `/Project`. `/Dept#E9` is not UTF-8 and is listed as
// "Dept\u{FFFD}". `/Revision` is a number, so it isn't listed.
let pdf = String::from_utf8(fixture(1)).unwrap().replace(
"/Root 1 0 R >>\nstartxref",
"/Root 1 0 R /Info << /Client#20name (Acme) /Project#20 (Padded) /Dept#E9 (Sales) /Project (Apollo) /Revision 4 >> >>\nstartxref",
);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, pdf.into_bytes()).expect("fixture opens");
app.set_option("dialog", "custom").unwrap();
app
});
h.run_steps(4);
for name in ["Client name", "Project ", "Dept\u{FFFD}"] {
h.get_by_label(name).click();
h.run_steps(3);
for button in ["Update", "Delete"] {
assert!(h.get_by_role_and_label(Role::Button, button).accesskit_node().is_disabled(), "{button} is off for {name:?}");
}
}
clear(&mut h, "Name:");
type_into(&mut h, "Name:", "Revision");
assert!(h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled(), "Add would overwrite the number");
assert_eq!(h.state().custom_draft.as_ref().map(|(_, d)| d.value.as_str()), Some("Sales"));
h.get_by_label("Project").click();
h.run_steps(3);
clear(&mut h, "Value:");
type_into(&mut h, "Value:", "Gemini");
h.get_by_role_and_label(Role::Button, "Update").click();
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(
custom_properties(&h),
[
("Client name".to_string(), "Acme".to_string()),
("Project ".to_string(), "Padded".to_string()),
("Dept\u{FFFD}".to_string(), "Sales".to_string()),
("Project".to_string(), "Gemini".to_string())
]
);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
assert!(doc.has_info("Revision") && doc.info_value("Revision").is_none(), "the number is kept");
assert!(app.dialog.is_none());
}
#[test]
fn a_custom_property_is_deleted_from_an_info_dictionary_in_the_trailer() {
let pdf = String::from_utf8(fixture(1))
.unwrap()
.replace("/Root 1 0 R >>\nstartxref", "/Root 1 0 R /Info << /Project (Apollo) /Revision (4) >> >>\nstartxref");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, pdf.into_bytes()).expect("fixture opens");
app.set_option("dialog", "custom").unwrap();
app
});
h.run_steps(4);
h.get_by_label("Project").click();
h.run_steps(3);
h.get_by_role_and_label(Role::Button, "Delete").click();
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(custom_properties(&h), [("Revision".to_string(), "4".to_string())]);
let app = h.state();
assert_eq!(app.session.get(app.views[0].id).unwrap().can_undo(), Some("Change document properties"));
}
#[test]
fn add_stops_at_the_256_custom_properties_the_tab_lists() {
let with_props = |count: usize| {
let entries: String = (0..count).map(|n| format!("/P{n:03} (v) ")).collect();
let pdf =
String::from_utf8(fixture(1)).unwrap().replace("/Root 1 0 R >>\nstartxref", &format!("/Root 1 0 R /Info << {entries}>> >>\nstartxref"));
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, pdf.into_bytes()).expect("fixture opens");
app.set_option("dialog", "custom").unwrap();
app
});
h.run_steps(4);
h
};
let add_disabled = |h: &Harness<'static, PdfCraftApp>| h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled();
// 255 in the file: one more fits, a second does not.
let mut h = with_props(255);
type_into(&mut h, "Name:", "Client");
type_into(&mut h, "Value:", "Acme");
assert!(!add_disabled(&h));
h.get_by_role_and_label(Role::Button, "Add").click();
h.run_steps(3);
type_into(&mut h, "Name:", "Other");
type_into(&mut h, "Value:", "x");
assert!(add_disabled(&h), "the draft holds 256");
assert_eq!(h.state().custom_draft.as_ref().map(|(_, d)| d.props.len()), Some(256));
// 257 in the file: after a Delete a new name would still be past the 256 listed.
let mut h = with_props(257);
h.get_by_label("P000").click();
h.run_steps(3);
h.get_by_role_and_label(Role::Button, "Delete").click();
h.run_steps(3);
type_into(&mut h, "Name:", "Client");
type_into(&mut h, "Value:", "Acme");
assert!(add_disabled(&h), "the file lists 256");
}
#[test]
fn edit_menu_names_the_step_to_undo() {
let mut h = harness(2, |app| {
+2 -2
View File
@@ -1,6 +1,6 @@
# PDF specification and standards parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (§12.4.4: page transitions and display times read) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (§14.3: custom properties edited in Document Properties) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against
what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist:
@@ -44,7 +44,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2
| §12.9 | Measurement and geospatial | full | 2D measuring with viewports and calibration; no geospatial | `comment.geospatial` planned |
| §12.10 | Document requirements | yes | none | |
| §13 | Multimedia: sound, movies, screen, rich media, 3D (U3D, PRC) | yes | preserved untouched; not played or shown | `misc.rich-media-*`, `misc.3d-view` planned (P3) |
| §14.3 | Metadata: info dictionary, XMP | full, XMP editor | info dictionary edited, with its XMP counterparts (title, author, subject, keywords, creator tool, producer) kept in step; XMP written for PDF/A; no XMP editor or custom properties | `organize.xmp-editor` planned |
| §14.3 | Metadata: info dictionary, XMP | full, XMP editor | info dictionary edited, with its XMP counterparts (title, author, subject, keywords, creator tool, producer) kept in step; XMP written for PDF/A; custom properties listed, added, changed and deleted (Document Properties ▸ Custom, `doc_info`, `doc_set_info`); no XMP editor | `organize.custom-properties` partial, `organize.xmp-editor` planned |
| §14.6–14.8 | Marked content, logical structure, tagged PDF | full, with editing tools | read (checker, bookmarks from structure, redaction of tags); no tag editing or autotag | gap 11 |
| §14.9 | Accessibility support: alt text, ActualText, language | full | alt text figure by figure, language, title | `a11y.alt-text-workflow` |
| §14.10 | Web capture | yes | none | |
+3 -3
View File
@@ -3517,10 +3517,10 @@ area = "D"
tier = "P2"
milestone = "M4"
status = "partial"
commands = []
commands = ["file.properties"]
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/it/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"
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/it/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_added_in_the_custom_tab_as_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_changed_and_deleted_in_the_custom_tab", "crates/ui-egui/tests/it/editing.rs::the_custom_tab_refuses_names_a_custom_property_cannot_have", "crates/ui-egui/tests/it/editing.rs::a_custom_property_whose_name_the_tools_refuse_is_listed_and_left_alone", "crates/ui-egui/tests/it/editing.rs::a_custom_property_is_deleted_from_an_info_dictionary_in_the_trailer", "crates/ui-egui/tests/it/editing.rs::add_stops_at_the_256_custom_properties_the_tab_lists", "crates/organize/src/tests.rs::document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer"]
notes = "Document Properties ▸ Custom lists them, with Name and Value fields and Add, Update (the value of the listed property the Name field names) and Delete; OK writes the changes as one undo step with the other tabs' changes. A property already in the file under a name the tools refuse (a space, a delimiter) or a name that is not UTF-8 is listed and cannot be changed or deleted there, and Add is off for a name the file already uses for an entry that is not listed (not text, only padding, or past the 256 listed), and once the tab or the file lists 256 properties. 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: mirroring them into XMP (pdfx:) as Acrobat does"
[[feature]]
id = "organize.xmp-editor"