fix(organize): Document Properties also change the XMP title and author (#1658)

`set_info` changed only `/Info`. Files that carry XMP (LibreOffice and Word
exports, PDF/A) kept the old dc:title and dc:creator, so viewers that read
XMP first kept showing the old title, and PDF/A preflight reported the two
as different (6.6.3).

The matching XMP property (Title dc:title, Author dc:creator, Subject
dc:description, Keywords pdf:Keywords, Creator xmp:CreatorTool, Producer
pdf:Producer) is now replaced where it stands, added in a description of
its own, or removed with an empty value. The packet is edited as text and
the rest of it stays byte for byte; files without XMP get none, and a
packet that isn't UTF-8 is left as it was.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
AlexandClaude Opus 5.5 authored and GitHub committed 2026-10-13 00:18:35 -04:00
1 parent e8fcf76c33
commit 9324afaa4f
7 files changed
+357 -8

No files matched your search

+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. Undoable.")
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.")
.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()
+6 -3
View File
@@ -21,6 +21,7 @@ mod outline;
mod pdfx;
mod prune;
pub mod view;
mod xmp;
pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box};
pub use dedupe::dedupe_resources;
@@ -504,12 +505,14 @@ pub fn info(doc: &Document, key: &str) -> Option<String> {
/// Set (or clear, with an empty value) a document-information entry.
///
/// Note: documents with XMP metadata also carry these values in the XMP packet; synchronising
/// XMP arrives with the `model` crate (M2) — viewers that prefer XMP may still show old values.
/// Documents with XMP metadata also carry Title, Author, Subject, Keywords, Creator and Producer
/// 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> {
let value = value.trim();
let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value)));
set_info_entry(doc, key.as_bytes(), entry)
set_info_entry(doc, key.as_bytes(), entry)?;
xmp::sync_info(doc, key, value)
}
/// Set (or remove, with `None`) a document-information entry of any type.
+1 -1
View File
@@ -191,7 +191,7 @@ fn unescape(s: &str) -> String {
}
/// `s` as XML text: markup escaped, and characters XML 1.0 does not allow left out.
fn esc(s: &str) -> String {
pub(crate) fn esc(s: &str) -> String {
s.chars()
.filter(|c| (!c.is_control() || matches!(c, '\t' | '\n' | '\r')) && !matches!(c, '\u{FFFE}' | '\u{FFFF}'))
.collect::<String>()
+95
View File
@@ -267,6 +267,101 @@ fn document_info_edits_round_trip_with_unicode() {
assert_eq!(info(&doc, "Producer"), None);
}
/// 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 {
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"),
"<< /Title (Untitled 1) /Author (Old Author) >>".into(),
],
"/Root 1 0 R /Info 5 0 R",
);
let mut doc = open(b);
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("Metadata"));
dict.set(b"Subtype".to_vec(), Object::name("XML"));
let m = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(dict, xmp)));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Metadata".to_vec(), Object::Ref(m))).unwrap();
full_roundtrip(&doc)
}
/// The catalog's metadata stream.
fn xmp_stream(doc: &Document) -> Option<pdfcraft_cos::Stream> {
match &*doc.get(catalog(doc).reference(b"Metadata")?) {
Object::Stream(s) => Some(s.clone()),
_ => None,
}
}
fn xmp_text(doc: &Document) -> Option<String> {
xmp_stream(doc).map(|s| String::from_utf8(s.decoded().unwrap()).unwrap())
}
/// The values the XMP packet gives a property: the items of its rdf:Alt or rdf:Seq, else its text.
fn xmp_values(xmp: &str, name: &str) -> Vec<String> {
let Some(start) = xmp.find(&format!("<{name}>")) else { return Vec::new() };
let end = xmp[start..].find(&format!("</{name}>")).unwrap() + start;
let inner = &xmp[start + name.len() + 2..end];
if !inner.contains("<rdf:li") {
return vec![inner.to_string()];
}
inner.split("<rdf:li").skip(1).map(|li| li[li.find('>').unwrap() + 1..li.find("</rdf:li>").unwrap()].to_string()).collect()
}
const LIBREOFFICE_XMP: &str = "<?xpacket begin=\"\u{feff}\" 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:pdf=\"http://ns.adobe.com/pdf/1.3/\"><pdf:Producer>Writer 1.0</pdf:Producer></rdf:Description>\n\
<rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n\
<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Untitled 1</rdf:li></rdf:Alt></dc:title>\n\
<dc:creator><rdf:Seq><rdf:li>Old Author</rdf:li></rdf:Seq></dc:creator>\n\
<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">Old subject</rdf:li></rdf:Alt></dc:description>\n\
</rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>";
#[test]
fn document_info_edits_update_the_xmp_packet() {
// Viewers that read XMP first (pdf.js takes dc:title) showed the old title after a change.
let mut doc = doc_with_xmp(LIBREOFFICE_XMP.as_bytes());
assert_eq!(xmp_values(&xmp_text(&doc).unwrap(), "dc:title"), ["Untitled 1"]);
set_info(&mut doc, "Title", "Sales Guide v1.0 & <draft>").unwrap();
set_info(&mut doc, "Author", "Demo Consulting").unwrap();
set_info(&mut doc, "Subject", "").unwrap();
set_info(&mut doc, "Keywords", "alpha, beta").unwrap();
set_info(&mut doc, "Rating", "5").unwrap(); // no XMP counterpart: /Info only
let doc = save_and_reopen(&doc);
assert_eq!(info(&doc, "Title").as_deref(), Some("Sales Guide v1.0 & <draft>"));
let xmp = xmp_text(&doc).unwrap();
assert_eq!(xmp_values(&xmp, "dc:title"), ["Sales Guide v1.0 &amp; &lt;draft&gt;"]);
assert_eq!(xmp_values(&xmp, "dc:creator"), ["Demo Consulting"]);
assert!(xmp_values(&xmp, "dc:description").is_empty(), "{xmp}");
assert_eq!(xmp_values(&xmp, "pdf:Keywords"), ["alpha, beta"]);
assert_eq!(xmp_values(&xmp, "pdf:Producer"), ["Writer 1.0"]);
assert!(xmp.starts_with("<?xpacket begin=") && xmp.ends_with("<?xpacket end=\"w\"?>"), "{xmp}");
// Stored unfiltered, still typed as metadata.
let dict = xmp_stream(&doc).unwrap().dict;
assert_eq!((dict.name(b"Type"), dict.name(b"Subtype"), dict.get(b"Filter")), (Some(&b"Metadata"[..]), Some(&b"XML"[..]), None));
}
#[test]
fn document_info_edits_leave_files_without_usable_xmp_alone() {
// No packet: none is added.
let mut doc = open(fixture());
set_info(&mut doc, "Title", "New").unwrap();
assert_eq!(xmp_text(&save_and_reopen(&doc)), None);
// A packet that isn't UTF-8 is not rewritten.
let mut latin1 = LIBREOFFICE_XMP.as_bytes().to_vec();
latin1.extend_from_slice(b"<!-- \xE9 -->");
let mut doc = doc_with_xmp(&latin1);
set_info(&mut doc, "Title", "New").unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(info(&doc, "Title").as_deref(), Some("New"));
assert_eq!(xmp_stream(&doc).unwrap().decoded().unwrap(), latin1);
}
#[test]
fn successive_incremental_saves_stack_revisions() {
let mut doc = open(fixture());
+251
View File
@@ -0,0 +1,251 @@
//! Keeping the catalog's XMP metadata (`/Root /Metadata`, ISO 32000-2 §14.3.2) in step with the
//! document information when Document Properties ▸ Description changes it.
//!
//! Files that carry XMP state the title and author twice, and viewers that read XMP first show
//! the packet's value, so changing only `/Info` leaves the file disagreeing with itself. Each
//! information key maps to the XMP property PDF/A pairs it with (ISO 19005-1 §6.7.3, the same
//! mapping `pdfcraft-preflight` writes): Title → `dc:title`, Author → `dc:creator`, Subject →
//! `dc:description`, Keywords → `pdf:Keywords`, Creator → `xmp:CreatorTool`, Producer →
//! `pdf:Producer`.
//!
//! The packet is edited as text, touching only the property that changed: everything else in it
//! (other schemas, padding, unknown properties) stays byte for byte. A file without XMP gets
//! none, and a packet this can't edit safely (not UTF-8, no `rdf:RDF`, too large to decode) is
//! left as it was.
use pdfcraft_cos::{Document, Object, Stream};
use crate::OrganizeError;
use crate::pdfx::esc;
/// Decoding limit for an XMP packet (they are a few kB; padding rarely takes them past 100 kB).
const MAX_XMP: usize = 16 << 20;
const DC: &str = "http://purl.org/dc/elements/1.1/";
const PDF: &str = "http://ns.adobe.com/pdf/1.3/";
const XMP: &str = "http://ns.adobe.com/xap/1.0/";
/// How an XMP property holds its value.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Shape {
/// A language alternative: `rdf:Alt` with an `x-default` item.
LangAlt,
/// An ordered array: `rdf:Seq` (the authors).
Seq,
/// Plain text.
Text,
}
/// The XMP property (qualified name, namespace) that mirrors a document information key.
fn property(key: &str) -> Option<(&'static str, &'static str, Shape)> {
Some(match key {
"Title" => ("dc:title", DC, Shape::LangAlt),
"Author" => ("dc:creator", DC, Shape::Seq),
"Subject" => ("dc:description", DC, Shape::LangAlt),
"Keywords" => ("pdf:Keywords", PDF, Shape::Text),
"Creator" => ("xmp:CreatorTool", XMP, Shape::Text),
"Producer" => ("pdf:Producer", PDF, Shape::Text),
_ => return None,
})
}
/// After the information entry `key` became `value` (empty: removed), make the catalog's XMP
/// packet say the same. Does nothing for keys XMP doesn't mirror, files without XMP, and packets
/// that can't be edited safely.
pub(crate) fn sync_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> {
let Some((name, ns, shape)) = property(key) else { return Ok(()) };
let Some(root) = doc.root() else { return Ok(()) };
// A metadata stream is always indirect (§7.3.8); anything else is left alone.
let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(()) };
let obj = doc.get(r);
let Object::Stream(stream) = &*obj else { return Ok(()) };
let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(()) };
let Ok(xmp) = String::from_utf8(bytes) else { return Ok(()) };
let Some(updated) = set_property(&xmp, name, ns, shape, value) else { return Ok(()) };
// Written unfiltered, as PDF/A asks of metadata streams, so other tools can read it.
let mut dict = stream.dict.clone();
for k in [&b"Filter"[..], b"DecodeParms", b"DL", b"Length"] {
dict.remove(k);
}
doc.set(r, Object::Stream(Stream::from_raw(dict, updated.into_bytes())));
Ok(())
}
/// `xmp` with the property `name` (namespace `ns`) set to `value`, or removed when `value` is
/// empty; `None` when nothing changes or the packet has no `rdf:RDF` to add the property to.
fn set_property(xmp: &str, name: &str, ns: &str, shape: Shape, value: &str) -> Option<String> {
let element = (!value.is_empty()).then(|| match shape {
Shape::LangAlt => format!("<{name}><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></{name}>", esc(value)),
Shape::Seq => format!("<{name}><rdf:Seq><rdf:li>{}</rdf:li></rdf:Seq></{name}>", esc(value)),
Shape::Text => format!("<{name}>{}</{name}>", esc(value)),
});
// The element form, `<dc:title>…</dc:title>` (or `<dc:title/>`): replaced where it stands, so
// it keeps the namespace declaration of the description around it.
if let Some((start, end)) = element_span(xmp, name) {
let mut out = String::with_capacity(xmp.len());
out.push_str(xmp.get(..start)?);
out.push_str(element.as_deref().unwrap_or_default());
out.push_str(xmp.get(end..)?);
return Some(out);
}
// The attribute form, `pdf:Keywords="…"` on an `rdf:Description`: only simple text can be
// written there; a structured value moves to an element of its own.
if let Some((start, value_start, value_end, quote)) = attribute_span(xmp, name) {
let mut out = String::with_capacity(xmp.len());
if shape == Shape::Text && !value.is_empty() {
let escaped = if quote == '"' { esc(value) } else { esc(value).replace('\'', "&apos;") };
out.push_str(xmp.get(..value_start)?);
out.push_str(&escaped);
out.push_str(xmp.get(value_end..)?);
return Some(out);
}
out.push_str(xmp.get(..start)?);
out.push_str(xmp.get(value_end + 1..)?);
return match element {
Some(e) => insert_description(&out, name, ns, &e),
None => Some(out),
};
}
insert_description(xmp, name, ns, element.as_deref()?)
}
/// `xmp` with a new `rdf:Description` holding `element` (and declaring its namespace) as the last
/// description of the packet.
fn insert_description(xmp: &str, name: &str, ns: &str, element: &str) -> Option<String> {
let prefix = name.split(':').next()?;
let at = xmp.rfind("</rdf:RDF>")?;
// Every description of a packet describes the same resource (XMP: one `rdf:about` value).
let about = attribute_span(xmp, "rdf:about").and_then(|(_, s, e, _)| xmp.get(s..e)).unwrap_or_default().replace('"', "&quot;");
let mut out = String::with_capacity(xmp.len() + element.len() + 128);
out.push_str(xmp.get(..at)?);
out.push_str(&format!("<rdf:Description rdf:about=\"{about}\" xmlns:{prefix}=\"{ns}\">{element}</rdf:Description>\n"));
out.push_str(xmp.get(at..)?);
Some(out)
}
/// Where the element `name` is in `xmp`, from its `<` to past its end tag (or past `/>`).
fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> {
let open = format!("<{name}");
let mut from = 0;
while let Some(i) = xmp.get(from..).and_then(|s| s.find(&open)).map(|i| i + from) {
let after = i + open.len();
from = after;
// `<dc:title` must end there: not `<dc:titles`.
match xmp.get(after..).and_then(|s| s.chars().next()) {
Some('>' | '/') => {}
Some(c) if c.is_whitespace() => {}
_ => continue,
}
let tag_end = after + xmp.get(after..)?.find('>')?;
if xmp.get(..tag_end)?.ends_with('/') {
return Some((i, tag_end + 1));
}
let close = format!("</{name}>");
let end = tag_end + xmp.get(tag_end..)?.find(&close)? + close.len();
return Some((i, end));
}
None
}
/// Where the attribute `name` is in `xmp`: its start (including the whitespace before it), the
/// start and end of its value, and its quote character. Only matches inside a start tag.
fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> {
let mut from = 0;
while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) {
from = i + name.len();
let before = xmp.get(..i)?;
// Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`).
if !before.ends_with(char::is_whitespace) || before.rfind('<') < before.rfind('>') || before.rfind('<').is_none() {
continue;
}
let rest = xmp.get(from..)?;
let trimmed = rest.trim_start();
let Some(after_eq) = trimmed.strip_prefix('=') else { continue };
let after_eq = after_eq.trim_start();
let Some(quote) = after_eq.chars().next().filter(|c| matches!(c, '"' | '\'')) else { continue };
let value_start = xmp.len() - after_eq.len() + 1;
let value_end = value_start + xmp.get(value_start..)?.find(quote)?;
let start = before.trim_end().len();
return Some((start, value_start, value_end, quote));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
/// The shape of a LibreOffice export: one description per schema, element forms.
const PACKET: &str = "<?xpacket begin=\"\u{feff}\" 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:pdf=\"http://ns.adobe.com/pdf/1.3/\">\n <pdf:Producer>Writer 1.0</pdf:Producer>\n </rdf:Description>\n\
<rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n\
<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Untitled 1</rdf:li></rdf:Alt></dc:title>\n\
<dc:creator><rdf:Seq><rdf:li>Old Author</rdf:li></rdf:Seq></dc:creator>\n\
<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">Old subject</rdf:li></rdf:Alt></dc:description>\n\
</rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>";
#[test]
fn element_values_are_replaced_in_place() {
let out = set_property(PACKET, "dc:title", DC, Shape::LangAlt, "Guide & <draft>").unwrap();
assert!(out.contains("<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Guide &amp; &lt;draft&gt;</rdf:li></rdf:Alt></dc:title>\n"), "{out}");
assert!(!out.contains("Untitled 1"));
// Everything else is untouched.
assert_eq!(out.replace("Guide &amp; &lt;draft&gt;", "Untitled 1"), PACKET);
let out = set_property(PACKET, "dc:creator", DC, Shape::Seq, "Demo Consulting").unwrap();
assert!(out.contains("<dc:creator><rdf:Seq><rdf:li>Demo Consulting</rdf:li></rdf:Seq></dc:creator>"), "{out}");
}
#[test]
fn an_empty_value_removes_the_property() {
let out = set_property(PACKET, "dc:description", DC, Shape::LangAlt, "").unwrap();
assert!(!out.contains("dc:description"), "{out}");
assert!(out.contains("Untitled 1") && out.contains("Old Author"));
// Nothing to remove: no change.
assert_eq!(set_property(PACKET, "pdf:Keywords", PDF, Shape::Text, ""), None);
}
#[test]
fn a_missing_property_gets_a_description_of_its_own() {
let out = set_property(PACKET, "pdf:Keywords", PDF, Shape::Text, "alpha, beta").unwrap();
let added =
"<rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"><pdf:Keywords>alpha, beta</pdf:Keywords></rdf:Description>\n";
assert!(out.find(added).is_some_and(|at| at > out.find("<dc:creator>").unwrap() && at < out.find("</rdf:RDF>").unwrap()), "{out}");
assert_eq!(out.replacen(added, "", 1), PACKET);
// A packet with no rdf:RDF to add to is left alone.
assert_eq!(set_property("<x:xmpmeta/>", "pdf:Keywords", PDF, Shape::Text, "alpha"), None);
}
#[test]
fn attribute_values_are_rewritten_where_they_stand() {
let packet = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"r\"><rdf:Description rdf:about='uuid:1' \
xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmp:CreatorTool='Old \"tool\"' dc:title=\"Odd\"/></rdf:RDF></x:xmpmeta>";
let out = set_property(packet, "xmp:CreatorTool", XMP, Shape::Text, "Writer's tool").unwrap();
assert!(out.contains("xmp:CreatorTool='Writer&apos;s tool'"), "{out}");
// A title can't be an attribute: it moves to an element, in a description for the same resource.
let out = set_property(packet, "dc:title", DC, Shape::LangAlt, "Guide").unwrap();
assert!(!out.contains("dc:title=\"Odd\""), "{out}");
assert!(out.contains("<rdf:Description rdf:about=\"uuid:1\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title><rdf:Alt>"), "{out}");
let out = set_property(packet, "xmp:CreatorTool", XMP, Shape::Text, "").unwrap();
assert!(out.contains("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" dc:title=\"Odd\"/>"), "{out}");
}
#[test]
fn lookalike_names_and_text_are_not_matched() {
let packet = "<rdf:RDF xmlns:rdf=\"r\"><rdf:Description rdf:about=\"\"><dc:titles>keep</dc:titles>\
<dc:subject>say dc:title=\"no\"</dc:subject><dc:title/></rdf:Description></rdf:RDF>";
let out = set_property(packet, "dc:title", DC, Shape::LangAlt, "New").unwrap();
assert!(out.contains("<dc:titles>keep</dc:titles>") && out.contains("say dc:title=\"no\""), "{out}");
assert!(out.contains("<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">New</rdf:li></rdf:Alt></dc:title></rdf:Description>"), "{out}");
}
#[test]
fn hostile_packets_do_not_panic() {
for packet in ["", "<", "<dc:title", "<dc:title>", "<dc:title x='", " dc:title=", "<a dc:title=\"", "</rdf:RDF>", "é<dc:title é>"] {
for value in ["", "v"] {
let _ = set_property(packet, "dc:title", DC, Shape::LangAlt, value);
let _ = set_property(packet, "pdf:Keywords", PDF, Shape::Text, value);
}
}
}
}
+1 -1
View File
@@ -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; XMP kept in step 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; no XMP editor or custom properties | `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 | |
+2 -2
View File
@@ -3424,8 +3424,8 @@ milestone = "M4"
status = "shipped"
commands = ["file.properties"]
tools = ["doc_set_info"]
evidence = ["crates/organize/src/tests.rs::document_info_edits_round_trip_with_unicode", "crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step"]
notes = ""
evidence = ["crates/organize/src/tests.rs::document_info_edits_round_trip_with_unicode", "crates/organize/src/tests.rs::document_info_edits_update_the_xmp_packet", "crates/ui-egui/tests/editing.rs::document_properties_edit_is_one_undoable_step"]
notes = "When the file has an XMP packet, the matching property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed or removed with the /Info entry; a packet that isn't UTF-8 is left as it was. Not yet: xmp:ModifyDate and xmp:MetadataDate on save, other languages of a title kept apart (the new value replaces them)."
[[feature]]
id = "organize.fonts-list"