From 6807e757afb18b6f1e355216a4f3fe6251b44f4f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 21 Oct 2026 07:02:47 -0700 Subject: [PATCH] PDF export: PDF and .ai files carry File Info's copyright status, notice and URL The PDF writer's metadata has no copyright fields, so the XMP packet of PDF exports and PDF-compatible .ai and .ait saves left out File Info's copyright status, notice and URL. A PDF exported without its editing data opened with the status Unknown and an empty notice and URL, although the reader takes them from dc:rights, xmpRights:WebStatement and xmpRights:Marked. The export now adds those three properties to the XMP packet after the writer has written it, as it does for the modification date. Marked is True for Copyrighted and False for Public Domain, and is left out for Unknown. The notice and URL are escaped for XML, and characters XML 1.0 does not allow are left out. A document without a copyright gets none of the properties. Docs: docs/mcp.md (File Info), docs/target-app-parity.md. --- crates/engine/src/tests_fileinfo.rs | 19 +++++++ crates/pdf/src/export.rs | 2 + crates/pdf/src/file_info.rs | 4 +- crates/pdf/src/post.rs | 78 ++++++++++++++++++++++++++++- crates/pdf/src/tests_file_info.rs | 41 +++++++++++++++ docs/mcp.md | 3 ++ docs/target-app-parity.md | 1 + 7 files changed, 144 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests_fileinfo.rs b/crates/engine/src/tests_fileinfo.rs index 7b6e63485..cb42c223b 100644 --- a/crates/engine/src/tests_fileinfo.rs +++ b/crates/engine/src/tests_fileinfo.rs @@ -139,6 +139,25 @@ fn an_exported_pdf_opens_with_its_file_info() { assert_eq!(back["keywords"], json!(["fair", "Poster", "print"])); assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]); assert_eq!(back["title"], "poster.pdf", "the title stays the file name"); + assert_eq!(back["copyrightStatus"], "copyrighted"); + assert_eq!(back["copyrightNotice"], "(c) 2026 Ada"); + assert_eq!(back["copyrightUrl"], "https://example.com/rights"); +} + +#[test] +fn a_pdf_compatible_ai_carries_the_copyright() { + let mut s = session(); + let rights = json!({"copyrightStatus": "publicDomain", "copyrightNotice": "No rights reserved", "copyrightUrl": "https://example.com/cc0"}); + s.execute("file.info", &rights).unwrap(); + let path = std::env::temp_dir().join(format!("vc-fileinfo-rights-{}.ai", std::process::id())); + let saved = s.execute("document.save", &json!({"path": path.to_string_lossy()})); + let ai = std::fs::read(&path); + let _ = std::fs::remove_file(&path); + saved.unwrap(); + let f = vectorcraft_pdf::file_info(&ai.unwrap(), None).expect("a PDF-compatible .ai"); + assert_eq!(f.copyrighted, Some(false)); + assert_eq!(f.copyright_notice.as_deref(), Some("No rights reserved")); + assert_eq!(f.copyright_url.as_deref(), Some("https://example.com/cc0")); } /// Unix seconds of 2020-01-01 00:00:00 UTC. diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 77be57d01..a31f8c219 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -139,6 +139,8 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha Some(modified) => crate::post::modification_date(bytes, modified)?, None => bytes, }; + // The writer's metadata has no copyright: it is added to the XMP packet. + let bytes = crate::post::rights(bytes, &doc.metadata)?; warnings.extend(more); let bytes = crate::post::user_units(bytes, &units)?; let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?; diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index 4e858701c..c8e7e9488 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -16,9 +16,9 @@ const MAX_NESTING: usize = 256; const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; const XML: &str = "http://www.w3.org/XML/1998/namespace"; -const DC: &str = "http://purl.org/dc/elements/1.1/"; +pub(crate) const DC: &str = "http://purl.org/dc/elements/1.1/"; const XMP: &str = "http://ns.adobe.com/xap/1.0/"; // brand-ok: the XMP namespace the files use -const XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/"; // brand-ok: the XMP namespace the files use +pub(crate) const XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/"; // brand-ok: the XMP namespace the files use const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace the files use /// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text). diff --git a/crates/pdf/src/post.rs b/crates/pdf/src/post.rs index 4196696d1..eda3b9a5a 100644 --- a/crates/pdf/src/post.rs +++ b/crates/pdf/src/post.rs @@ -3,11 +3,13 @@ //! file, [`crate::linearize`]) and encryption ([`crate::encrypt`]), which a linearised file goes //! through between its two steps so that it stays linearised. Also here: what makes a file say it //! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files, the file's -//! modification date ([`modification_date`]) and the pages' user units ([`user_units`]), which the -//! export writes in that order before the thumbnails. +//! modification date ([`modification_date`]), its copyright ([`rights`]) and the pages' user units +//! ([`user_units`]), which the export writes in that order before the thumbnails. use std::borrow::Cow; +use vectorcraft_doc::{CopyrightStatus, DocMetadata}; + use crate::encrypt::Obj; use crate::linearize::{self, Parsed}; use crate::patch::Patch; @@ -121,6 +123,78 @@ pub(crate) fn modification_date(pdf: Vec, modified: i64) -> Result, patch.apply(&pdf, &xref) } +/// `pdf` with File Info's copyright (`info`) added to the catalog's XMP metadata, after the +/// writer's properties: the notice as `dc:rights`, the URL as `xmpRights:WebStatement` and the +/// status as `xmpRights:Marked` (`True` for a copyrighted work, `False` for a public-domain one, +/// none when it is unknown), as [`crate::file_info`] reads them. Unchanged when `info` gives none of +/// them or the file has no XMP metadata. +pub(crate) fn rights(pdf: Vec, info: &DocMetadata) -> Result, PdfError> { + let props = rights_properties(info); + if props.is_empty() { + return Ok(pdf); + } + let failed = |why: &str| PdfError::Write(format!("can't write the file's copyright: {why}")); + let Parsed { xref, objects, root, .. } = linearize::parse(&pdf).map_err(failed)?; + let metadata = match objects.get(&root).and_then(|c| c.value.get(b"Metadata")) { + Some(&Obj::Ref(n, _)) => objects.get(&n), + _ => None, + }; + let Some(o) = metadata else { return Ok(pdf) }; + let Some(&Obj::Int { value, start, end }) = o.value.get(b"Length") else { return Err(failed("its metadata has no direct length")) }; + let xmp = pdf.get(o.value_span.1..o.endobj).unwrap_or_default(); + let close = crate::lab_spot::rfind(xmp, b"").ok_or_else(|| failed("its metadata has no description"))?; + let at = o.value_span.1 + close; + let mut patch = Patch::new(&xref); + patch.replace(start, end, (value + props.len() as i64).to_string().into_bytes()); + patch.replace(at, at, props.into_bytes()); + patch.apply(&pdf, &xref) +} + +/// The XMP properties [`rights`] adds, each declaring its namespace; empty when `info` has no +/// copyright notice or URL and its status is unknown. +fn rights_properties(info: &DocMetadata) -> String { + use crate::file_info::{DC, XMP_RIGHTS}; + let mut out = String::new(); + let notice = info.copyright_notice.trim(); + if !notice.is_empty() { + out.push_str(&format!( + "{}", + xml_text(notice) + )); + } + let url = info.copyright_url.trim(); + if !url.is_empty() { + out.push_str(&format!("{}", xml_text(url))); + } + let marked = match info.copyright_status { + CopyrightStatus::Copyrighted => Some("True"), + CopyrightStatus::PublicDomain => Some("False"), + CopyrightStatus::Unknown => None, + }; + if let Some(m) = marked { + out.push_str(&format!("{m}")); + } + out +} + +/// `s` as XML character data: `&`, `<`, `>` and `"` escaped, a carriage return written as the +/// character reference ` `, and the characters XML 1.0 doesn't allow left out. +fn xml_text(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\r' => out.push_str(" "), + '\t' | '\n' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'.. => out.push(c), + _ => {} + } + } + out +} + /// Linearise `pdf`, encrypting it on the way as `set` says. fn linearised(pdf: &[u8], set: &PdfSettings) -> Result, PdfError> { let (prepared, plan) = linearize::prepare(pdf)?; diff --git a/crates/pdf/src/tests_file_info.rs b/crates/pdf/src/tests_file_info.rs index f860724d0..7ce2f42f4 100644 --- a/crates/pdf/src/tests_file_info.rs +++ b/crates/pdf/src/tests_file_info.rs @@ -207,3 +207,44 @@ fn a_written_pdf_gives_its_file_info_back() { assert_eq!(f.keywords, Some(vec!["fair".to_string(), "print".into()])); assert_eq!((f.created, f.modified), (Some(CREATED), Some(CREATED))); } + +#[test] +fn a_written_pdf_gives_its_copyright_back() { + use vectorcraft_doc::CopyrightStatus; + let notice = "(c) 2026 Ada & \r\nAll rights \"reserved\""; + let url = "https://example.com/rights?a=1&b=2"; + for standard in [Standard::None, Standard::PdfA2b, Standard::PdfX1a, Standard::PdfX4] { + for (status, marked) in + [(CopyrightStatus::Copyrighted, Some(true)), (CopyrightStatus::PublicDomain, Some(false)), (CopyrightStatus::Unknown, None)] + { + let mut d = Document::new(100.0, 100.0); + d.metadata.author = "Ada Lovelace".into(); + (d.metadata.copyright_status, d.metadata.copyright_notice, d.metadata.copyright_url) = (status, notice.into(), url.into()); + let mut o = PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }; + (o.settings.standard, o.settings.compatibility) = (standard, standard.version()); + let bytes = export(&d, &o).unwrap(); + let f = file_info(&bytes, None).unwrap(); + let what = format!("{standard:?} {status:?}"); + assert_eq!(f.copyright_notice.as_deref(), Some(notice), "{what}"); + assert_eq!(f.copyright_url.as_deref(), Some(url), "{what}"); + assert_eq!(f.copyrighted, marked, "{what}"); + // The writer's own properties stay readable beside them. + assert_eq!((f.author.as_deref(), f.created), (Some("Ada Lovelace"), Some(CREATED)), "{what}"); + let t = String::from_utf8_lossy(&bytes); + assert_eq!(t.contains("` and, with the `metadata` option, all of it as Dublin Core. PDF export and PDF-compatible `.ai` and `.ait` saves write the `created` date (the time of writing when the document has none) as the file's creation date and the time of writing as its modification date. +They also write the `copyrightNotice` as `dc:rights`, the `copyrightUrl` as `xmpRights:WebStatement` and the +`copyrightStatus` as `xmpRights:Marked` (`True` for copyrighted, `False` for public domain, none for unknown) to the +XMP packet. A PDF, `.ai` or `.ait` opened from its pages or its editing copy (`document.open`, `file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `author`, `description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` that its XMP diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 82288660b..9e406bfd6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -804,6 +804,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft; Windows 7 retains WMI and the installed PDF viewer. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests_print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged. - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests_file_info.rs`, `crates/engine/src/tests_fileinfo.rs`). - PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests_fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests_marks.rs`). + - PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests_file_info.rs`, `crates/engine/src/tests_fileinfo.rs`). - Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests_file_info.rs`). - `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets. - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).