Fix PDF title display provenance

This commit is contained in:
Mirko Kerezovic
2026-10-23 01:01:25 +07:00
committed by bflatastic
parent 5e9df83dd4
commit d548b4ff74
9 changed files with 62 additions and 21 deletions
+24 -7
View File
@@ -19,8 +19,15 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
let expanded = expand_presets(s, "document.serialize", p)?;
let p = with_black_output(s, &expanded);
let doc = &*source(s, f, &p, "document.serialize")?;
let enc = encode_all(doc, f.id, &p)?;
let (doc, display_doc_title) = source(s, f, &p, "document.serialize")?;
let doc = &*doc;
let mut q = p.clone();
if f.id == "pdf"
&& let Some(o) = q.as_object_mut()
{
o.insert("_displayDocTitle".into(), json!(display_doc_title));
}
let enc = encode_all(doc, f.id, &q)?;
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
let (main, linked) = files.split_at(enc.files.len());
// Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes.
@@ -53,8 +60,15 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.export", str_param(p, "format"), path)?;
let expanded = expand_presets(s, "document.export", p)?;
let p = with_black_output(s, &expanded);
let doc = &*source(s, f, &p, "document.export")?;
let enc = encode_all(doc, f.id, &p)?;
let (doc, display_doc_title) = source(s, f, &p, "document.export")?;
let doc = &*doc;
let mut q = p.clone();
if f.id == "pdf"
&& let Some(o) = q.as_object_mut()
{
o.insert("_displayDocTitle".into(), json!(display_doc_title));
}
let enc = encode_all(doc, f.id, &q)?;
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
}
@@ -154,13 +168,16 @@ fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Va
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
/// with `selectionOnly` the selected objects alone.
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Cow<'a, Document>> {
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(Cow<'a, Document>, bool)> {
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
return Ok(Cow::Owned(selection(s, cmd)?.0));
let doc = selection(s, cmd)?.0;
let display_doc_title = super::has_own_title(&doc);
return Ok((Cow::Owned(doc), display_doc_title));
}
let st = s.doc()?;
let doc = export_source(st, p)?;
Ok(super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned))
let display_doc_title = super::has_own_title(&doc);
Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title))
}
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
+5 -2
View File
@@ -871,9 +871,12 @@ pub fn file_stem(name: &str) -> String {
/// document that only has the Untitled-N name New gave it is titled after its file, as its tab is
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
/// the stored title.
pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool {
!doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> {
let untitled = doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
let path = path.filter(|_| untitled && !matches!(format, "vectorcraft" | "template" | "ai"))?;
let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?;
let mut d = doc.clone();
d.title = file_stem(path);
Some(d)
+14 -2
View File
@@ -243,7 +243,14 @@ pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
let now = vectorcraft_doc::metadata::output_time();
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
Ok(PdfOptions {
settings: settings(cmd, p)?,
artboards,
display_doc_title: bool_or(p, "_displayDocTitle", super::has_own_title(doc)),
created: doc.metadata.created.or(now),
modified: now,
..Default::default()
})
}
/// Why a PDF of some artboards carries no editing data.
@@ -387,8 +394,13 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let expanded = expand_preset(s, C, p)?;
let p = &*expanded;
let st = s.doc()?;
let display_doc_title = super::has_own_title(&st.doc);
let titled = super::titled_for_export(&st.doc, st.path.as_deref(), "pdf");
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), p)?;
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("_displayDocTitle".into(), json!(display_doc_title));
}
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?;
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
}
+8
View File
@@ -310,6 +310,13 @@ fn an_untitled_document_saved_to_a_file_exports_titled_after_it() {
let dir = std::env::temp_dir().join(format!("vc-pdftitle-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Document C.vectorcraft");
// Unsaved and untitled: viewers keep showing the file name, not "Untitled-1".
let mut untitled = Session::new();
untitled.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
for pdf in pdf_texts(&mut untitled) {
assert!(!pdf.contains("/DisplayDocTitle true"), "{pdf}");
}
// Saved as a file, then exported: Untitled-1 was only the name New gave it.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
@@ -330,6 +337,7 @@ fn an_untitled_document_saved_to_a_file_exports_titled_after_it() {
s.execute("document.save", &json!({"path": dir.join("Document D.vectorcraft").to_str().unwrap()})).unwrap();
for pdf in pdf_texts(&mut s) {
assert!(pdf.contains("/Title(Poster)") && xmp_title(&pdf).contains("Poster"));
assert!(pdf.contains("/ViewerPreferences<</DisplayDocTitle true>>"), "{pdf}");
}
let _ = std::fs::remove_dir_all(dir);
}
+4 -6
View File
@@ -73,6 +73,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
let now = vectorcraft_doc::metadata::output_time();
let created_at = opts.created.or(now);
crate::pdfx::require_date(set.standard, created_at)?;
let display_doc_title = opts.display_doc_title || opts.title.as_deref().is_some_and(|title| !title.trim().is_empty());
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
// Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it).
let mut warnings = set.warnings();
@@ -132,7 +133,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
let (layers, overprinted, invisible) = (ex.layers, ex.overprinted, ex.invisible);
w.absorb(ex);
let (bytes, more) = w.finish(left_out)?;
let (bytes, more) = w.finish(left_out, display_doc_title)?;
let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted, invisible)?;
// The writer gives the file its creation date as its modification date too.
let bytes = match opts.modified.filter(|m| Some(*m) != created_at) {
@@ -233,8 +234,6 @@ pub(crate) struct Writer {
rgb_in_cmyk: RgbInCmyk,
/// A PDF 1.3 file ([`crate::PdfSettings::pdf13`]): flat, without page groups.
pdf13: bool,
/// The PDF has a document title, so viewers should display it instead of the file name.
display_doc_title: bool,
/// The document's images the exporters wrote, by key, gathered from the exporters
/// ([`Self::absorb`]): [`Self::finish`] finds the key of an image the writer couldn't decode.
images: Vec<(String, Image)>,
@@ -319,7 +318,6 @@ impl Writer {
warnings,
rgb_in_cmyk: RgbInCmyk::default(),
pdf13: set.pdf13(),
display_doc_title: !title.trim().is_empty(),
images: vec![],
})
}
@@ -428,7 +426,7 @@ impl Writer {
/// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK
/// alternates. When the writer couldn't decode one of the images, its key is added to
/// `left_out` ([`leaving_out_undecodable`]) and the writer's error is returned.
pub(crate) fn finish(mut self, left_out: &mut HashSet<String>) -> Result<(Vec<u8>, Vec<String>), PdfError> {
pub(crate) fn finish(mut self, left_out: &mut HashSet<String>, display_doc_title: bool) -> Result<(Vec<u8>, Vec<String>), PdfError> {
let images = std::mem::take(&mut self.images);
let mut bytes = self.pdf.finish().map_err(|e| {
if let write::WriteError::Serialization(k) = &e
@@ -443,7 +441,7 @@ impl Writer {
}
let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only();
let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes };
let (bytes, more) = self.out.write_catalog(bytes, self.display_doc_title)?;
let (bytes, more) = self.out.write_catalog(bytes, display_doc_title)?;
self.warnings.extend(more);
self.warnings.extend(self.rgb_in_cmyk.warning());
Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings))
+2
View File
@@ -94,6 +94,8 @@ pub struct PdfOptions {
pub artboards: Option<Vec<usize>>,
/// Document title for the metadata; `None` = the document's title.
pub title: Option<String>,
/// Ask PDF viewers to display the document title instead of the file name.
pub display_doc_title: bool,
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). PDF/A needs a date.
pub created: Option<i64>,
/// Modification date as Unix seconds (UTC); `None` = the creation date.
+1 -1
View File
@@ -426,7 +426,7 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet<Strin
for ex in exporters {
w.absorb(ex);
}
let (bytes, more) = w.finish(left_out)?;
let (bytes, more) = w.finish(left_out, false)?;
let bytes = crate::forms::finish(bytes, doc, &pdf, false, overprinted, invisible)?;
let mut warnings = plan.warnings;
for m in more {
+2 -2
View File
@@ -902,11 +902,11 @@ fn empty_and_populated_file_info_preserve_key_presence() {
for key in ["/Title", "/Author", "/Keywords", "<dc:title>", "<dc:creator>", "<pdf:Keywords>"] {
assert!(!s.contains(key), "unexpected empty metadata {key}");
}
assert!(!s.contains("/DisplayDocTitle true"), "an untitled document has no title to display");
assert!(!s.contains("/DisplayDocTitle true"), "the default export does not request displaying its metadata title");
d.title = "Synthetic title".into();
d.metadata.author = " Test author ".into();
d.metadata.keywords = vec!["test keyword".into()];
let bytes = export(&d, &PdfOptions { created: Some(1_700_000_000), ..PdfOptions::uncompressed() }).unwrap();
let bytes = export(&d, &PdfOptions { display_doc_title: true, created: Some(1_700_000_000), ..PdfOptions::uncompressed() }).unwrap();
let s = String::from_utf8_lossy(&bytes);
for key in ["/Title", "/Author", "/Keywords", "/CreationDate", "<dc:title>", "<dc:creator>", "<pdf:Keywords>"] {
assert!(s.contains(key), "missing populated metadata {key}");
+2 -1
View File
@@ -422,7 +422,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Missing:** AccessKit is on by default, so egui widgets reach screen readers, but custom panels, the canvas and its objects aren't described and nothing has been tested with VoiceOver, Narrator or Orca; keyboard-only canvas operation; high
contrast. Illustrator's own accessibility is limited, so this is mostly beyond parity.
- **Done:** PDF export writes `/ViewerPreferences << /DisplayDocTitle true >>` when the exported document has a title, so viewers and accessibility checkers use the document title instead of the file name (#3646).
- **Done:** PDF export writes `/ViewerPreferences << /DisplayDocTitle true >>` when File Info has a Document Title (or an explicit export title is supplied), so viewers and accessibility checkers use that title instead of the file name (#3646).
- **Estimate:** 15–25 h.
## Highlighted-text Eyedropper (2026-10-21)
@@ -445,6 +445,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | G19: PDF export asks viewers to display the document title only when File Info has a Document Title or an explicit export title is supplied; untitled documents keep the PDF file name in the viewer title bar (#3646); scores and estimates unchanged. |
| 2026-10-22 | minor | G4: colour emoji fonts with bitmap glyphs (`sbix`: Apple Color Emoji; `CBDT`: Noto Color Emoji) draw on the canvas and export: PDF Type 3 glyphs or images, SVG images with outlined text, EPS and legacy `.ai` images, Create Outlines images; COLR and OpenType SVG fonts remain; estimate unchanged. |
| 2026-10-22 | minor | G13: gradient Dither (30.0) in the Gradient panel, on the canvas, in raster exports and as images in SVG and PDF exports; G13 estimate 15–25 h → 14–24 h. |
| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h). |