mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Fix PDF title display provenance
This commit is contained in:
committed by
bflatastic
parent
5e9df83dd4
commit
d548b4ff74
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user