From 766e291e3cf8495fb7708e0645077fe4ce57eba1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:09:32 -0800 Subject: [PATCH] Save and Export: notes of what the file loses come before notes on options Each writer marks its notes as a loss (something of the document that the file leaves out or that reaches it changed) or as a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The PDF, SVG, EPS, legacy .ai, DXF and HP-GL writers return their notes on the options in a list of their own (`setting_notes`) beside `warnings`, and the engine's `Encoded` does the same. The replies of the save and export commands, and of `clipboard.exportPdf` and `clipboard.exportPng`, list the losses first, each kind in the order the writers gave them, across every file that Export for Screens writes. The status bar names the first note, so an export to lossy WebP of type in a font that isn't installed now names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in. --- crates/cad/src/hpgl/write.rs | 18 +-- crates/cad/src/hpgl/write/tests.rs | 13 +- crates/cad/src/lib.rs | 3 + crates/cad/src/scene.rs | 18 ++- crates/cad/src/tests.rs | 21 ++-- crates/engine/src/cmd/css.rs | 16 +-- crates/engine/src/cmd/fileio.rs | 2 +- crates/engine/src/cmd/fileio/dxf.rs | 1 + crates/engine/src/cmd/fileio/encode.rs | 111 ++++++++++++++++-- crates/engine/src/cmd/fileio/eps.rs | 1 + crates/engine/src/cmd/fileio/export.rs | 4 +- crates/engine/src/cmd/fileio/hpgl.rs | 1 + crates/engine/src/cmd/fileio/legacy_ai.rs | 5 +- crates/engine/src/cmd/fileio/pdf.rs | 49 ++++---- crates/engine/src/cmd/fileio/save.rs | 21 ++-- crates/engine/src/cmd/fileio/screens.rs | 23 ++-- crates/engine/src/cmd/fileio/svg.rs | 1 + crates/engine/src/cmd/fileio/tests_dxf.rs | 15 +++ .../engine/src/cmd/fileio/tests_exportas.rs | 32 +++++ .../src/cmd/fileio/tests_pdfsecurity.rs | 18 +++ crates/engine/src/cmd/fileio/tests_screens.rs | 16 +++ crates/engine/src/cmd/rasterfx.rs | 4 +- crates/eps/src/legacy_ai.rs | 4 +- crates/eps/src/legacy_ai/tests.rs | 4 +- crates/eps/src/legacy_ai/writer.rs | 16 ++- crates/eps/src/lib.rs | 10 +- crates/eps/src/print.rs | 2 +- crates/eps/src/tests.rs | 2 +- crates/pdf/src/export/entry.rs | 22 ++-- crates/pdf/src/export/writer.rs | 14 ++- crates/pdf/src/lib.rs | 3 + crates/pdf/src/output.rs | 2 +- crates/pdf/src/post.rs | 8 +- crates/pdf/src/print.rs | 4 +- crates/pdf/src/tests/editing.rs | 3 +- crates/pdf/src/tests/layers.rs | 6 +- crates/pdf/src/tests/marks.rs | 11 +- crates/pdf/src/tests/output.rs | 2 +- crates/pdf/src/tests/pdf13.rs | 2 +- crates/pdf/src/tests/print.rs | 2 +- crates/pdf/src/tests/realtext.rs | 2 +- crates/pdf/src/tests/settings.rs | 7 +- crates/pdf/src/tests/webview.rs | 2 +- crates/svg/src/export.rs | 7 +- crates/svg/src/export/output.rs | 10 ++ crates/svg/src/export/text.rs | 2 +- crates/svg/src/lib.rs | 8 +- crates/svg/tests/it/data_attributes.rs | 2 +- crates/svg/tests/it/encoding_profile_fonts.rs | 2 +- crates/ui-egui/src/io.rs | 17 +++ crates/ui-egui/src/io/export.rs | 2 +- docs/mcp.md | 11 +- docs/target-app-parity.md | 1 + 53 files changed, 433 insertions(+), 150 deletions(-) diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 3e20c69a98..6ab80c8ba0 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -187,6 +187,9 @@ pub struct HpglOutput { pub pens: Vec<[u8; 3]>, /// What was approximated or left out. pub warnings: Vec, + /// Notes on the options: sorting the paths or drawing the inner paths first was applied only + /// in part. + pub setting_notes: Vec, } /// Write the visible art of `doc` over `opts.region` as an HP-GL plot. Live geometry effects are @@ -280,10 +283,11 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R if !by_pen.iter().all(Vec::is_empty) { pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); } + let mut setting_notes = vec![]; if opts.sort || opts.inside_first { let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); for paths in &mut by_pen { - at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); + at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut setting_notes); } } if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) { @@ -293,7 +297,7 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R Version::Hpgl => plain(&by_pen), Version::Hpgl2 => encoded(&by_pen, &pens), }; - out.push(HpglOutput { bytes, pens: pens.clone(), warnings }); + out.push(HpglOutput { bytes, pens: pens.clone(), warnings, setting_notes }); } Ok(out) } @@ -366,20 +370,20 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { /// for a closed path any of its points (where it then starts); without, the first in paint order. /// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is. /// Once `work` is spent, the paths left keep paint order, each after the paths inside it with -/// `inside_first`, with a warning. Returns where the pen stops. +/// `inside_first`, with a note in `notes`. Returns where the pen stops. fn reorder( paths: &mut Vec>, mut at: (i64, i64), work: &mut u64, nearest: bool, inside_first: bool, - warnings: &mut Vec, + notes: &mut Vec, ) -> (i64, i64) { let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect(); // The paths each path lies inside, and how many paths inside each are still to draw. let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten(); if inside_first && outer.is_none() { - warn(warnings, UNNESTED); + warn(notes, UNNESTED); } let mut waiting = vec![0usize; paths.len()]; for o in outer.iter().flatten().flatten() { @@ -430,11 +434,11 @@ fn reorder( if *work == 0 || best.0 >= left.len() { match &outer { Some(outer) => { - warn(warnings, UNSORTED_INSIDE); + warn(notes, UNSORTED_INSIDE); inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths); } None => { - warn(warnings, UNSORTED); + warn(notes, UNSORTED); paths.extend(left.into_iter().map(|(_, p, _)| p)); } } diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index c29ef67532..9cb4f7d727 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -852,7 +852,8 @@ fn sorting_measures_a_closed_path_by_its_nearest_point() { fn sorting_stops_at_its_budget_and_keeps_every_path() { let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) }); - assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [UNSORTED]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); assert_eq!(text(&out).matches("PD").count(), 600); } @@ -944,18 +945,18 @@ fn inside_first_orders_each_pens_paths() { } /// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in -/// paint order, with a warning. +/// paint order, with a note on the option. #[test] fn inside_first_past_its_budget_draws_in_paint_order() { let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED]); + assert_eq!(out.setting_notes, [UNNESTED]); let l = lefts(&out); assert_eq!(l.len(), 100); assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}"); - // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings. + // With `sort` too, the budget is spent before sorting starts: paint order, with both notes. let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED, UNSORTED]); + assert_eq!(out.setting_notes, [UNNESTED, UNSORTED]); assert_eq!(lefts(&out), l); } @@ -969,7 +970,7 @@ fn inside_first_past_its_sorting_budget_keeps_the_rings_last() { v }); let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNSORTED_INSIDE]); + assert_eq!(out.setting_notes, [UNSORTED_INSIDE]); let l = lefts(&out); assert_eq!(l.len(), 602); assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..])); diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 0ef28a4aaa..1443829806 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -410,6 +410,9 @@ pub struct DxfOutput { pub images: Vec, /// What was approximated or left out. pub warnings: Vec, + /// What was left out because an option turns it off (Include Placed Images or Include Fill + /// Hatches off). + pub setting_notes: Vec, } /// The smallest and largest `scale` (drawing units per unit). diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 7c5d8b6755..0379fc4ea5 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -520,6 +520,8 @@ pub(crate) struct Scene<'a> { /// Image keys → index into `images` (`None`: left out). by_key: HashMap>, warnings: Vec, + /// What was left out because an option asked for it ([`DxfOutput::setting_notes`]). + setting_notes: Vec, brushes: Option>, extents: Option<[f64; 4]>, /// The opacity of the containers around the current object. @@ -554,6 +556,7 @@ impl<'a> Scene<'a> { files: vec![], by_key: HashMap::new(), warnings: vec![], + setting_notes: vec![], brushes: None, extents: None, opacity: 1.0, @@ -588,15 +591,24 @@ impl<'a> Scene<'a> { metric: matches!(self.o.unit, Unit::Millimeters | Unit::Centimeters | Unit::Meters), next: self.next, }; - DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings } + DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings, setting_notes: self.setting_notes } } + /// Note `w`, once, about something of the document that the drawing leaves out or that reaches + /// it changed. A note about an option goes to [`Self::note_setting`]. fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + /// Note `w`, once, about something left out because an option turns it off. + fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } + fn handle(&mut self) -> Handle { let h = Handle(self.next); self.next = self.next.saturating_add(1); @@ -930,7 +942,7 @@ impl<'a> Scene<'a> { // The outlines close as the fill does, so an open path's fill keeps its closing edge. self.lines(&closed_fill(outline), style, 0.0); if !self.o.include_hatches { - self.warn(HATCHES_OFF); + self.note_setting(HATCHES_OFF); return; } let h = self.handle(); @@ -1321,7 +1333,7 @@ impl<'a> Scene<'a> { return; } if !self.o.include_images { - self.warn(IMAGES_OFF); + self.note_setting(IMAGES_OFF); return; } let Some(i) = self.image_def(im) else { return }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 9d2a519e1d..96c037a4a7 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -881,12 +881,13 @@ fn fills_without_hatches_keep_their_outlines() { let d = shapes_doc(); let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap(); assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle"); - assert_eq!(out.warnings, [HATCHES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [HATCHES_OFF]); let e = entities(&out); assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight"); // Hatches stay by default, with no note. let on = export(&d, &opts(&d)).unwrap(); - assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(kinds(&on).iter().any(|k| k == "HATCH") && on.setting_notes.is_empty()); // The outlines are the ones written beside hatches, also for overlapping subpaths, which are // merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way. let compound = doc_with(|d| { @@ -916,13 +917,14 @@ fn fills_without_hatches_keep_their_outlines() { assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered)); // Nothing filled: no note. let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); - assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty()); + let none = export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap(); + assert!(none.warnings.is_empty() && none.setting_notes.is_empty()); // R12 has no hatches anyway: the same outlines, and its own note alone. let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap(); let (off, on) = (r12(false), r12(true)); assert_eq!(off.bytes, on.bytes); assert_eq!(off.warnings, on.warnings); - assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(off.setting_notes.is_empty()); } #[test] @@ -957,15 +959,18 @@ fn images_can_be_left_out_with_their_files() { let p = pairs(&out.bytes); assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES"))); assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); - assert_eq!(out.warnings, [IMAGES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [IMAGES_OFF]); // An image that can't be decoded is not read when images are left out. let mut broken = d.clone(); broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None }); - assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]); + let left_out = export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap(); + assert!(left_out.warnings.is_empty(), "{:?}", left_out.warnings); + assert_eq!(left_out.setting_notes, [IMAGES_OFF]); assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded"))); // R12 has no images anyway: its own note alone. let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap(); - assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF)); + assert!(r12.warnings.iter().any(|w| w.contains("no images")) && r12.setting_notes.is_empty()); } #[test] @@ -983,7 +988,7 @@ fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_b let cnc = run(DxfPreset::Cnc.options()); assert_eq!(kinds(&cnc), ["LWPOLYLINE"]); assert!(cnc.images.is_empty()); - assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings); + assert!(cnc.setting_notes.iter().any(|w| w == IMAGES_OFF) && cnc.setting_notes.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.setting_notes); let custom = DxfPreset::Custom.options(); assert!(custom.include_images && custom.include_hatches, "Custom keeps both"); } diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index ed83a01410..9e18df76bf 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; -use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; +use super::fileio::{Note, default_name, encode_with_notes, isolated, note_texts, write_file}; use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; pub fn specs() -> Vec { @@ -100,20 +100,22 @@ fn export(s: &mut Session, p: &Value) -> Result { } let css = format!("{}\n", sheet.text()); let doc = &s.doc()?.doc; - // Encode the pictures of rasterized art, and add each of their warnings once. + // Encode the pictures of rasterized art, and add each of their warnings once. What CSS can't + // describe is a loss. let mut images = Vec::new(); - let mut warnings = sheet.warnings.clone(); + let mut notes: Vec = sheet.warnings.iter().cloned().map(Note::loss).collect(); for r in &sheet.rules { let Some(name) = &r.image else { continue }; let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?; - let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?; + let (png, given) = encode_with_notes(&d, "png", &json!({}))?; images.push((name, png)); - for w in notes { - if !warnings.contains(&w) { - warnings.push(w); + for n in given { + if !notes.iter().any(|x| x.text == n.text) { + notes.push(n); } } } + let warnings = note_texts(¬es); let n = sheet.rules.len(); match str_param(p, "path") { Some(path) => { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 329988cb34..b2644c1f29 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -66,7 +66,7 @@ use serde_json::{Value, json}; pub use action_batch::batch_overwrites_one_file; pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; -pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; +pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub(crate) use export::with_artboard_fills; pub(crate) use export::{export_crop, isolated}; diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 16cf3b2631..0fcaa4bfc0 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -241,6 +241,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option) -> enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); for img in out.images { if !enc.linked.iter().any(|l| l.name == img.name) { enc.linked.push(vectorcraft_svg::LinkedImage { name: img.name, bytes: Arc::new(img.bytes) }); diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0f91007515..1be80f2c56 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -74,18 +74,104 @@ pub struct Encoded { pub linked: Vec, /// Image maps, each written beside its image under the image's name (raster `imageMap`). pub maps: Vec, - /// The encoder's warnings (features approximated or left out, options not applied yet). + /// The encoder's warnings: what the file leaves out of the document or writes differently + /// ([`NoteKind::Loss`]). pub warnings: Vec, + /// The encoder's notes on the options: options not applied yet or applied another way, and + /// what an option turns off ([`NoteKind::Setting`]). A reply gives them after the warnings + /// ([`Self::notes`]). + pub setting_notes: Vec, /// What joins the file's stem and the artboard name when there is a file per artboard /// (default `-`; EPS uses `_`, as the reference app names them). pub joiner: Option<&'static str>, } +/// What a note an export gives with its file is about. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoteKind { + /// Something of the document (its art, type, layers or artboards) that the file leaves out or + /// that reaches it changed: the format, its version or a limit can't hold it, or the export + /// couldn't write it. + Loss, + /// An option the export didn't apply or applied another way while the art reaches the file as + /// it looks, or something left out because an option turns it off. + Setting, +} + +/// A note an export gives with its file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Note { + /// What the note is about. + pub kind: NoteKind, + /// The note, in English. + pub text: String, +} + +impl Note { + /// A note about what the file loses ([`NoteKind::Loss`]). + pub fn loss(text: impl Into) -> Self { + Self { kind: NoteKind::Loss, text: text.into() } + } + + /// A note on the options ([`NoteKind::Setting`]). + pub fn setting(text: impl Into) -> Self { + Self { kind: NoteKind::Setting, text: text.into() } + } +} + +/// The texts of `notes` in the order a reply gives them: the losses first, then the notes on the +/// options, each kind in the order given (the status bar names the first). +pub fn note_texts(notes: &[Note]) -> Vec { + let of = |kind| notes.iter().filter(move |n| n.kind == kind).map(|n| n.text.clone()); + of(NoteKind::Loss).chain(of(NoteKind::Setting)).collect() +} + +/// Add `note` to `notes` unless a note with its text is there already. +pub(super) fn add_note(notes: &mut Vec, note: Note) { + if !notes.iter().any(|n| n.text == note.text) { + notes.push(note); + } +} + impl Encoded { pub(super) fn one(bytes: Vec) -> Self { Self { files: vec![(None, bytes)], ..Self::default() } } + /// The file `bytes` with `notes`, each in the list of its kind, in the order given. + pub(super) fn one_with_notes(bytes: Vec, notes: Vec) -> Self { + let mut enc = Self::one(bytes); + for n in notes { + match n.kind { + NoteKind::Loss => enc.warnings.push(n.text), + NoteKind::Setting => enc.setting_notes.push(n.text), + } + } + enc + } + + /// Add each of `texts` to [`Self::setting_notes`] unless a note with its text is there already. + pub(super) fn add_setting_notes(&mut self, texts: impl IntoIterator) { + for t in texts { + if !self.warnings.contains(&t) && !self.setting_notes.contains(&t) { + self.setting_notes.push(t); + } + } + } + + /// Every note with its kind: the warnings, then the notes on the options that aren't also + /// warnings. + pub fn kinded_notes(&self) -> Vec { + let settings = self.setting_notes.iter().filter(|t| !self.warnings.contains(t)).map(Note::setting); + self.warnings.iter().map(Note::loss).chain(settings).collect() + } + + /// The notes' texts in the order a reply gives them ([`note_texts`]): the warnings, then the + /// notes on the options, each once. + pub fn notes(&self) -> Vec { + note_texts(&self.kinded_notes()) + } + /// Every file to write for the destination `path` (a path or a file name): the file itself /// when there is one, else `{stem}-{artboard name}.{ext}` beside it (see `joiner`; a file of no artboard /// among several, as Save's master file, is the file itself), then the linked images beside it @@ -128,13 +214,14 @@ impl Encoded { out } - /// The one file and the warnings, when the export wrote nothing else. - fn single(self, f: &Format) -> Result<(Vec, Vec)> { + /// The one file and the notes, when the export wrote nothing else. + fn single(self, f: &Format) -> Result<(Vec, Vec)> { if !self.linked.is_empty() || !self.maps.is_empty() { return Err(bad(C, format!("{} with linked images or an image map writes several files: use document.export", f.label))); } + let notes = self.kinded_notes(); match <[_; 1]>::try_from(self.files) { - Ok([(_, bytes)]) => Ok((bytes, self.warnings)), + Ok([(_, bytes)]) => Ok((bytes, notes)), Err(_) => Err(bad(C, format!("{} writes one file per artboard here: use document.export, or name one artboard", f.label))), } } @@ -419,8 +506,14 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result> { /// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet, /// features approximated or left out; every format that draws type: fonts written in the fallback -/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame). +/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame), what +/// the file loses first ([`note_texts`]). pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec, Vec)> { + encode_with_notes(doc, format, p).map(|(bytes, notes)| (bytes, note_texts(¬es))) +} + +/// Like [`encode_with_warnings`], with each note's kind ([`Encoded::kinded_notes`]). +pub fn encode_with_notes(doc: &Document, format: &str, p: &Value) -> Result<(Vec, Vec)> { let f = super::writable(C, Some(format), None)?; let enc = encode_all(doc, f.id, p)?; // The error for a version 7 .ai of several artboards names the legacy .ai. @@ -476,7 +569,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result { /// ([`vectorcraft_plugins::export`]). The plug-in is given the native file's JSON /// ([`vectorcraft_format::json_part`]) and `p`'s `params`, for the file `p`'s `path` names (else /// one named after the document) with that path's extension when the plug-in lists it, else its -/// first. Its warnings come back with the file. +/// first. Its warnings come back with the file, as losses (the plug-in gives them no kind). fn plugin_file(doc: &Document, plugin: &vectorcraft_plugins::Plugin, f: &Format, p: &Value) -> Result { use vectorcraft_plugins::{Error, export}; let doc = &*doc.without_edit_modes(); @@ -539,8 +632,8 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { "hpgl" => return super::hpgl::encode(doc, p, use_artboards), "ai" => return super::save::encode_ai(C, f, doc, p), "pdf" => { - let (bytes, warnings) = super::pdf::encode(C, doc, p)?; - return Ok(Encoded { warnings, ..Encoded::one(bytes) }); + let (bytes, notes) = super::pdf::encode(C, doc, p)?; + return Ok(Encoded::one_with_notes(bytes, notes)); } "png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" | "tga" | "psd" => { let o: RasterOptions = options(f, p)?; @@ -607,7 +700,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { let regions: Vec = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect(); enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); if format == RasterFormat::WebP && o.lossless == Some(false) { - enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into()); + enc.setting_notes.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into()); } return Ok(enc); } diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index fe962a36ad..3acd23e854 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -382,6 +382,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); } enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); Ok(enc) diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 62e5b78e6b..19e496306f 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -42,7 +42,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { } _ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }), }; - let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.warnings })); + let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.notes() })); if main.len() > 1 { out["files"] = main.iter().map(|(name, bytes)| merge(json!({ "name": name }), data(bytes))).collect(); } @@ -69,7 +69,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result { 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 })) + write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.notes() })) } /// The document an export of `st` in format `f` writes ([`export_source`]): a `.ai` or `.ait` diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs index ff6e63fbd7..1baac844e6 100644 --- a/crates/engine/src/cmd/fileio/hpgl.rs +++ b/crates/engine/src/cmd/fileio/hpgl.rs @@ -116,6 +116,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option) -> enc.warnings.push(w); } } + enc.add_setting_notes(out.setting_notes); } if enc.files.is_empty() { return Err(bad(C, "the document has no artboard")); diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 1fd3e10a9f..6ee826d5ea 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -136,10 +136,12 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } let images = see_through_images(&d, mode); if !images.is_empty() { - note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED }); if mode == SeeThrough::Flatten { + note(&mut enc.warnings, FLATTENED); flatten_images(&mut d, &images)?; } else { + // A note on the options: `transparentImages: omit` leaves them out. + enc.add_setting_notes([OMITTED.to_string()]); for (_, id) in images { let _ = d.remove(id); // Inside an image left out before: gone already. } @@ -150,6 +152,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result for w in placed.iter().chain(&out.warnings) { note(&mut enc.warnings, w); } + enc.add_setting_notes(out.setting_notes); enc.files.push((Some(b), out.bytes)); } // Expanding raster effects and flattening images draw the art, and an image the renderer diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 1f166797f8..b11251ba34 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -12,7 +12,7 @@ use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, S use super::super::flatten::{FlattenOptions, flatten_document, see_through_images}; use super::super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param}; -use super::{ArtboardPick, FormatOption, write_or_return}; +use super::{ArtboardPick, FormatOption, Note, note_texts, write_or_return}; use crate::EngineError; const C: &str = "document.exportPdf"; @@ -284,21 +284,22 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = /// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; -/// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered +/// Encode `doc` as PDF with the options in `p` → (bytes, notes). Raster effects are rendered /// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself /// when the PDF has every artboard (a PDF of some reopens as just those). -pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { +pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { encode_at(cmd, doc, p, None) } /// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing. -pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option) -> Result<(Vec, Vec)> { +pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option) -> Result<(Vec, Vec)> { // Keep the editable object tree independent of preview fallbacks used by the drawn pages. - let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); - let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; - warnings.extend(drawn_warnings); - warnings.extend(overflow_warning(cmd, &full, p)?); - Ok((bytes, warnings)) + let (full, placed) = crate::cmd::place::document::full_documents(doc); + let mut notes: Vec = placed.into_iter().map(Note::loss).collect(); + let (bytes, drawn) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; + notes.extend(drawn); + notes.extend(overflow_warning(cmd, &full, p)?.map(Note::loss)); + Ok((bytes, notes)) } /// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and @@ -321,7 +322,7 @@ pub(super) fn encode_carrying( p: &Value, native: impl FnOnce() -> Result>, private: impl FnOnce() -> Result)>>, -) -> Result<(Vec, Vec)> { +) -> Result<(Vec, Vec)> { encode_carrying_at(cmd, doc, p, None, native, private) } @@ -334,7 +335,7 @@ fn encode_carrying_at( modified: Option, native: impl FnOnce() -> Result>, private: impl FnOnce() -> Result)>>, -) -> Result<(Vec, Vec)> { +) -> Result<(Vec, Vec)> { let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?; // The pages' background colors, behind the art (the editing data keeps them as colors). let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); @@ -345,21 +346,22 @@ fn encode_carrying_at( if opts.settings.thumbnails { opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?; } - let mut warnings = vec![]; - // The status bar shows the first warning, so this one goes ahead of the flattening note. + let mut notes = vec![]; + // A loss, ahead of the flattening note: the PDF/X pages leave out the bleed that Document + // Setup gives. if opts.settings.leaves_out_bleed(doc) { - warnings.push(BLEED_LEFT_OUT.to_string()); + notes.push(Note::loss(BLEED_LEFT_OUT)); } if opts.settings.preserve_editing { if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) { opts.native = Some(native()?); if let Some((data, lost)) = private()? { opts.private_data = Some(data); - warnings.extend(lost); + notes.extend(lost.into_iter().map(Note::loss)); } } else { opts.settings.preserve_editing = false; - warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string()); + notes.push(Note::setting(EDITING_NEEDS_EVERY_ARTBOARD)); } } let set = &opts.settings; @@ -370,12 +372,13 @@ fn encode_carrying_at( vectorcraft_doc::cancel::check()?; if flattened { let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() }; - warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")); + notes.push(Note::loss(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"))); } let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?; - warnings.extend(r.warnings); - warnings.extend(crate::cmd::fonts::substitution_warning(doc)); - Ok((r.bytes, warnings)) + notes.extend(r.warnings.into_iter().map(Note::loss)); + notes.extend(r.setting_notes.into_iter().map(Note::setting)); + notes.extend(crate::cmd::fonts::substitution_warning(doc).map(Note::loss)); + Ok((r.bytes, notes)) } /// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty. @@ -479,8 +482,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { } else { q = json!({ "_displayDocTitle": 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 })) + let (bytes, notes) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?; + write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": note_texts(¬es) })) } /// `(path, value)` for every leaf of `v` that differs from `default` (`compression.color.ppi`). @@ -512,7 +515,7 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result { let set = settings_with(Q, p, saved)?; let diff = changes(&set, &preset_settings(Q, p, saved)?); let warnings = match s.active() { - Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1, + Some(st) if bool_or(p, "includeDocument", false) => note_texts(&encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1), _ => set.warnings(), }; let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 5d7783665b..c21b53da4f 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -740,12 +740,14 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res carries_editing.set(true); Ok(Some((data, resolved_warnings))) }; - let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; - warnings.extend(placed_warnings); + let (bytes, notes) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; + let mut enc = Encoded::one_with_notes(bytes, notes); + enc.warnings.extend(placed_warnings); + // A note on the options: `pdfCompatible: false` writes blank pages. if !pdf_compatible { - warnings.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string()); + enc.setting_notes.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string()); } - Ok(Encoded { warnings, ..Encoded::one(bytes) }) + Ok(enc) } /// Snapshot the active document for `plan` (stamping File Info's dates on the snapshot when the @@ -833,8 +835,8 @@ impl SaveJob { } /// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with - /// the warnings (what the format loses first, then the encoder's own notes: PDF options not - /// applied yet…, then the plan's notes on its params). + /// the notes (what the format loses first, then the encoder's own notes: PDF options not + /// applied yet…, then the plan's notes on its params, which are notes on the options). pub fn encode(&self) -> Result { let mut enc = self.encode_one(&self.doc)?; for &b in &self.boards { @@ -842,7 +844,7 @@ impl SaveJob { enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes))); } enc.warnings.splice(0..0, self.format_losses()); - enc.warnings.extend(self.plan.notes.iter().cloned()); + enc.setting_notes.extend(self.plan.notes.iter().cloned()); Ok(enc) } @@ -893,9 +895,8 @@ impl SaveJob { /// order: when `stop` returns a value, nothing is written and that value is returned. pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result { let (plan, doc) = (&self.plan, &*self.doc); - let mut enc = self.encode()?; - let warnings = std::mem::take(&mut enc.warnings); - let extra = json!({ "format": plan.format.id, "warnings": warnings }); + let enc = self.encode()?; + let extra = json!({ "format": plan.format.id, "warnings": enc.notes() }); let Some(path) = &plan.path else { let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?; out["name"] = json!(plan.name); diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b48aef68c3..62096a6f5e 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -17,12 +17,12 @@ use vectorcraft_doc::Document; use vectorcraft_geom::Rect; use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param}; -use super::encode::single_artboard; +use super::encode::{add_note, single_artboard}; use super::export::{export_crop, isolated, selected_artwork, without_artboards}; use super::naming::{self, NameParts}; use super::{ - ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge, - unique_file_names_keeping_dots, with_black_output, writable, write_file, + ARTBOARD_PARAMS, ArtboardPick, Format, Note, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_notes, merge, + note_texts, unique_file_names_keeping_dots, with_black_output, writable, write_file, }; const C: &str = "document.exportForScreens"; @@ -1046,7 +1046,8 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map Result { check_passwords(p)?; let o = ScreenParams::of(p)?; @@ -1138,18 +1139,16 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> create_dir(dir)?; } let mut out = Output::default(); - // The encoders' warnings for every file, each once. - let mut warnings: Vec = Vec::new(); + // The encoders' notes for every file, each once. + let mut notes: Vec = Vec::new(); // Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are // written on a page of their own bounds, which it doesn't grow. let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none(); let mut write = |doc: &Document, format: &str, options: &Value| -> Result> { // The steps are the files: an encoder's own steps (artboards, pages) are not reported. - let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?; - for w in notes { - if !(bleed_on_page && w == super::pdf::BLEED_LEFT_OUT) && !warnings.contains(&w) { - warnings.push(w); - } + let (bytes, given) = vectorcraft_doc::cancel::quiet(|| encode_with_notes(doc, format, options))?; + for n in given.into_iter().filter(|n| !(bleed_on_page && n.text == super::pdf::BLEED_LEFT_OUT)) { + add_note(&mut notes, n); } Ok(bytes) }; @@ -1224,6 +1223,8 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> if o.zip || folder.is_none() { vectorcraft_doc::cancel::check()?; } + // What the files lose first, across every file. + let warnings = note_texts(¬es); let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect(); if o.zip { let zip = super::zip::deflate(&out.kept, super::zip::FIXED_TIME, vectorcraft_doc::cancel::is_cancelled); diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 3123fdcfb1..d479be0f7c 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -198,6 +198,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + let Some((option, losses)) = warnings.split_last() else { panic!("no notes") }; + assert!(option.starts_with("fill hatches are left out"), "{warnings:?}"); + assert!(losses.iter().any(|w| w.starts_with("transparency needs DXF 2004")), "{warnings:?}"); + assert!(!losses.iter().any(|w| w.starts_with("fill hatches")), "each note once: {warnings:?}"); +} + /// Two placed images whose pixel data can't be decoded are left out of the drawing and get no /// linked file, and `warnings` holds the note raster exports give, once, without naming either /// image's key. diff --git a/crates/engine/src/cmd/fileio/tests_exportas.rs b/crates/engine/src/cmd/fileio/tests_exportas.rs index 09bbcf28f3..f3566e82ba 100644 --- a/crates/engine/src/cmd/fileio/tests_exportas.rs +++ b/crates/engine/src/cmd/fileio/tests_exportas.rs @@ -117,3 +117,35 @@ fn formats_list_use_artboards() { } assert!(opts("vectorcraft").get("useArtboards").is_none()); } + +/// An export's reply gives what the file loses before its notes on the options, whatever order +/// the encoder gave them: the WebP encoder notes that lossy WebP isn't applied before the export +/// notes the type it drew in the fallback font. With only notes on the options, they keep their +/// order. +#[test] +fn what_the_file_loses_comes_before_a_note_on_the_options() { + let lossy = "lossy WebP isn't available yet: the file is lossless (exact, but larger)"; + let warnings = |s: &mut Session, p: Value| -> Vec { + let r = s.execute("document.export", &p).unwrap(); + r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() + }; + let mut s = session(60.0, 40.0, 1); + assert_eq!(warnings(&mut s, json!({"format": "webp", "lossless": false})), [lossy]); + s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let w = warnings(&mut s, json!({"format": "webp", "lossless": false})); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w[0].contains("fallback font") && w[0].contains("Nonexistent Sans Probe"), "{w:?}"); + assert_eq!(w[1], lossy); + // document.serialize gives them in the same order. + let r = s.execute("document.serialize", &json!({"format": "webp", "lossless": false})).unwrap(); + assert_eq!(r["warnings"], json!(w)); +} + +#[test] +fn note_texts_give_the_losses_first_each_kind_in_order() { + use super::encode::{Note, note_texts}; + let notes = [Note::setting("s1"), Note::loss("l1"), Note::setting("s2"), Note::loss("l2")]; + assert_eq!(note_texts(¬es), ["l1", "l2", "s1", "s2"]); + assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]); + assert!(note_texts(&[]).is_empty()); +} diff --git a/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs b/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs index d8c36a9196..09505391af 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfsecurity.rs @@ -56,3 +56,21 @@ fn permissions_without_a_password_warn_and_a_password_applies_them() { let bytes = vectorcraft_format::base64_decode(locked["dataBase64"].as_str().unwrap()).unwrap(); assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt")); } + +/// The writer notes the permissions it didn't apply before the export notes the type it drew in +/// the fallback font: the reply gives what the file loses first, through both commands. +#[test] +fn the_fallback_font_note_comes_before_the_permissions_note() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + for r in [ + s.execute("document.exportPdf", &json!({"security": {"printing": "none"}})).unwrap(), + s.execute("document.export", &json!({"format": "pdf", "security": {"printing": "none"}})).unwrap(), + ] { + let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + let at = |s: &str| warnings.iter().position(|w| w.contains(s)); + let (font, permissions) = (at("Nonexistent Sans Probe"), at("permissions need a password")); + assert!(font.is_some() && permissions.is_some() && font < permissions, "{warnings:?}"); + } +} diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 86ee164998..0378478522 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -507,6 +507,22 @@ fn type_in_a_missing_family_is_named_once_for_every_format() { assert!(fallback[0].contains("Nonexistent Sans Probe"), "{fallback:?}"); } +/// What a later file loses comes before a note on the options that an earlier file gave: the +/// first artboard's WebP gives only the lossy WebP note, the second's also says its type doesn't +/// fit. +#[test] +fn what_any_file_loses_comes_before_the_notes_on_the_options() { + let mut s = session(2); + let second = s.doc().unwrap().doc.artboards[1].rect; + let area = json!({"width": 30, "height": 8}); + s.execute("text.create", &json!({"x": second.x0 + 2.0, "y": second.y0 + 2.0, "size": 12, "area": area, "text": "Ab\nCd\nEf\nGh"})).unwrap(); + let r = export(&mut s, json!({"formats": [{"format": "webp", "lossless": false}]})); + let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert_eq!(warnings.len(), 2, "{warnings:?}"); + assert!(warnings[0].contains("doesn't fit its frame"), "{warnings:?}"); + assert!(warnings[1].starts_with("lossy WebP"), "{warnings:?}"); +} + #[test] fn full_document_gives_one_file_per_format() { let mut s = session(3); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 566a16dbfd..8f5f532c51 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -645,8 +645,8 @@ pub fn export_pdf(doc: &Document, opts: &vectorcraft_pdf::PdfOptions) -> std::re export_pdf_with_report(doc, opts).map(|r| r.bytes) } -/// Like [`export_pdf`], also returning the writer's warnings (options not applied yet, features -/// approximated or left out). +/// Like [`export_pdf`], also returning the writer's warnings (features approximated or left out) +/// and its notes on the options (options not applied yet). pub fn export_pdf_with_report( doc: &Document, opts: &vectorcraft_pdf::PdfOptions, diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 2a09cf003c..993fcbd9e3 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -156,6 +156,8 @@ pub enum HiddenObjects { pub struct LegacyAiOutput { pub bytes: Vec, pub warnings: Vec, + /// Notes on what the options asked for: hidden objects left out or written visible. + pub setting_notes: Vec, } /// Whether image node `n` of `doc` is written as a 1-bit image mask in its colorize color @@ -267,7 +269,7 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result { /// The name each spot color (by its swatch's name) is written with ([`Self::spot`]). spot_names: Vec<(String, String)>, pub(super) warnings: Vec, + /// Notes on what the options asked for ([`crate::LegacyAiOutput::setting_notes`]). + pub(super) setting_notes: Vec, /// The lock state the last `A` set. locked: bool, /// Symbols and live objects being written inside one another. @@ -56,6 +58,7 @@ impl<'a> Writer<'a> { spots: vec![], spot_names: vec![], warnings: vec![], + setting_notes: vec![], locked: false, nest: 0, hidden: o.hidden, @@ -68,12 +71,21 @@ impl<'a> Writer<'a> { } } + /// Note `w`, once, about something of the document that the file leaves out or that reaches + /// it changed. A note about an option goes to [`Self::note_setting`]. fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + /// Note `w`, once, about hidden objects left out or written visible as an option asks. + fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } + fn line(&mut self, s: &str) { self.body.push_str(s); self.body.push('\n'); @@ -225,7 +237,7 @@ impl<'a> Writer<'a> { if !n.visible && !self.in_hidden { match self.hidden { HiddenObjects::Omit => { - self.warn(HIDDEN); + self.note_setting(HIDDEN); return; } HiddenObjects::Layer => { @@ -233,7 +245,7 @@ impl<'a> Writer<'a> { self.hidden_art.push((n.clone(), locked || n.locked)); return; } - HiddenObjects::Visible => self.warn(HIDDEN_SHOWN), + HiddenObjects::Visible => self.note_setting(HIDDEN_SHOWN), } } if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 586967234b..f6b7fba609 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -195,6 +195,8 @@ pub struct EpsOutput { pub bytes: Vec, /// What was approximated or left out. pub warnings: Vec, + /// Notes on the options: the preview or thumbnail asked for that was left out. + pub setting_notes: Vec, } /// The `%%BoundingBox` of `region` with PostScript's origin at document point `origin`: whole @@ -229,11 +231,11 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb let baked = vectorcraft_effects::bake_document(doc); let doc = baked.as_ref().unwrap_or(doc); let page = scene::Scene::new(doc, opts).run(); - let mut warnings = page.warnings; + let mut setting_notes = vec![]; let thumb = thumbnail.and_then(|t| match tiff::png(t) { Ok(png) => Some(png), Err(e) => { - warnings.push(format!("the thumbnail was left out: {e}")); + setting_notes.push(format!("the thumbnail was left out: {e}")); None } }); @@ -241,7 +243,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb let bytes = match (opts.preview, preview) { (Preview::None, _) => ps.into_bytes(), (_, None) => { - warnings.push("the preview was left out: nothing was rendered for it".into()); + setting_notes.push("the preview was left out: nothing was rendered for it".into()); ps.into_bytes() } (kind, Some(img)) => { @@ -249,7 +251,7 @@ pub fn export(doc: &Document, opts: &EpsOptions, preview: Option<&Raster>, thumb dos_eps(ps.as_bytes(), &tiff)? } }; - Ok(EpsOutput { bytes, warnings }) + Ok(EpsOutput { bytes, warnings: page.warnings, setting_notes }) } /// Comment lines that start a data section the file carries (`%VectorCraft_BeginData: `) diff --git a/crates/eps/src/print.rs b/crates/eps/src/print.rs index 99dffd8225..0ccd7a6331 100644 --- a/crates/eps/src/print.rs +++ b/crates/eps/src/print.rs @@ -181,7 +181,7 @@ pub fn print(pages: &[PrintPage], order: &[usize], job: &PrintJob) -> Result Result, PdfError> { export_with_report(doc, opts).map(|r| r.bytes) } -/// Like [`export`], also returning warnings: options accepted but not applied yet (see -/// [`crate::PdfSettings::warnings`]) and features approximated or dropped. +/// Like [`export`], also returning warnings, features approximated or dropped, and notes on the +/// options: options accepted but not applied yet (see [`crate::PdfSettings::warnings`]) or applied +/// another way. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { // Type in the document's composite fonts is drawn in them, whether or not an app has the document open. let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&doc.composite_fonts); @@ -63,9 +64,10 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha 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(); + let mut warnings = vec![]; + let mut settings = set.warnings(); if raised.is_some() { - warnings.push(if opts.settings.compatibility == Compatibility::Pdf14 { + settings.push(if opts.settings.compatibility == Compatibility::Pdf14 { "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6".into() } else { "PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6".into() @@ -75,7 +77,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha if let Some(native) = native { w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, now.map(date_time))); } else if set.preserve_editing { - warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into()); + settings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into()); } let mut ex = Exporter::new(doc, set); @@ -85,7 +87,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha // A knockout page group draws the layers' objects one by one, not layer by layer. ex.layers = set.writes_layers() && !doc.page_knockout; if set.writes_layers() && doc.page_knockout { - warnings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into()); + settings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into()); } ex.overprint = set.advanced.overprint == crate::Overprint::Preserve; let bleed = set.bleed_of(doc); @@ -142,7 +144,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) = match w.finish(left_out, display_doc_title) { + let (bytes, more, catalog) = match w.finish(left_out, display_doc_title) { Ok(done) => done, Err(e) => return Err(writer_error(doc, opts, left_out, &long, e)), }; @@ -155,11 +157,13 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha // 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); + settings.extend(catalog); let bytes = crate::post::user_units(bytes, &units)?; // A PDF 1.5 file written as PDF 1.6 keeps the encryption PDF 1.5 has (128-bit RC4). - let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?; + let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut settings)?; warnings.dedup(); - Ok(ExportReport { bytes, warnings }) + settings.dedup(); + Ok(ExportReport { bytes, warnings, setting_notes: settings }) } /// The artboards `opts` exports, in page order, with the boxes of their pages: Marks and Bleeds diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 027025ad2c..9f6d9d6906 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -26,6 +26,10 @@ pub(crate) struct Writer { images: Vec<(String, Image)>, } +/// What [`Writer::finish`] gives: the file, the warnings of its drawing, and the notes on the +/// output intent asked for ([`crate::ExportReport::setting_notes`]). +pub(crate) type Finished = (Vec, Vec, Vec); + /// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]), /// which [`Self::view`] maps onto the page. pub(crate) struct Sheet<'a> { @@ -252,10 +256,11 @@ impl Writer { } /// The finished file (with the output intent and Trapped entries, and checked against its - /// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK + /// standard), the warnings of its drawing, and the notes on the output intent asked for + /// ([`crate::ExportReport::setting_notes`]). 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, display_doc_title: bool) -> Result<(Vec, Vec), PdfError> { + pub(crate) fn finish(mut self, left_out: &mut HashSet, display_doc_title: bool) -> Result { let images = std::mem::take(&mut self.images); let mut bytes = self.pdf.finish().map_err(|e| { if let write::WriteError::Serialization(k) = &e @@ -270,10 +275,9 @@ 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, display_doc_title)?; - self.warnings.extend(more); + let (bytes, settings) = self.out.write_catalog(bytes, display_doc_title)?; self.warnings.extend(self.rgb_in_cmyk.warning()); - Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings)) + Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings, settings)) } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 234f4b8a8d..2ba025866d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -213,7 +213,10 @@ pub struct ImportReport { #[derive(Clone, Debug)] pub struct ExportReport { pub bytes: Vec, + /// What the file leaves out of the document or writes differently. pub warnings: Vec, + /// Notes on the options: one the export didn't apply or applied another way. + pub setting_notes: Vec, } #[derive(Debug, thiserror::Error, PartialEq)] diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 55ab42ced3..77bc7d1d66 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -349,7 +349,7 @@ impl ColorOut { } /// `pdf` with the output intent and Trapped entries, and a PDF/X file's identification - /// ([`crate::pdfx`]) → (the file, warnings). + /// ([`crate::pdfx`]) → (the file, notes on the output intent asked for). pub(crate) fn write_catalog(&self, pdf: Vec, display_doc_title: bool) -> Result<(Vec, Vec), PdfError> { let cat = self.catalog.as_ref(); if cat.is_none() && !display_doc_title { diff --git a/crates/pdf/src/post.rs b/crates/pdf/src/post.rs index e735d079fe..c6fdd8048d 100644 --- a/crates/pdf/src/post.rs +++ b/crates/pdf/src/post.rs @@ -204,13 +204,13 @@ fn linearised(pdf: &[u8], set: &PdfSettings) -> Result, PdfError> { } /// The file the export wrote (`pdf`), finished as `opts` say; `editing`: it carries the editing -/// data, to seal once the pages are final. What can't be done comes back in `warnings`. -pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, warnings: &mut Vec) -> Result, PdfError> { +/// data, to seal once the pages are final. The options it can't apply come back in `settings`. +pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, settings: &mut Vec) -> Result, PdfError> { let set = &opts.settings; let pdf = match (set.thumbnails, opts.thumbnails.is_empty()) { (true, false) => thumbnails(pdf, &opts.thumbnails, set.standard.is_pdfx())?, (true, true) => { - warnings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into()); + settings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into()); pdf } (false, _) => pdf, @@ -229,7 +229,7 @@ pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, warnings: & match linearised(&pdf, set) { Ok(out) => Ok(out), Err(e) => { - warnings.push(format!("the file isn't optimized for fast web view: {e}")); + settings.push(format!("the file isn't optimized for fast web view: {e}")); crate::encrypt::protect(pdf, set) } } diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index b11a764be2..0bad9d9f60 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -470,10 +470,10 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet assert!(r.warnings.iter().any(|x| x.contains(w)), "{v}: {:?}", r.warnings), - None => assert!(r.warnings.is_empty(), "{v}: {:?}", r.warnings), + Some(w) => assert!(r.setting_notes.iter().any(|x| x.contains(w)), "{v}: {:?}", r.setting_notes), + None => assert!(r.warnings.is_empty() && r.setting_notes.is_empty(), "{v}: {:?} {:?}", r.warnings, r.setting_notes), } } // A knockout page group draws objects, not layers. d.page_knockout = true; let r = export(&d, json!({"createLayers": true})); oc_properties(&r.bytes, None, |props, _| assert!(props.is_none())); - assert!(r.warnings.iter().any(|w| w.contains("knockout page group")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("knockout page group")), "{:?}", r.setting_notes); } #[test] diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 603138bd49..655ed43ea4 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -251,8 +251,9 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() { assert!(r.bytes.starts_with(format!("%PDF-{header}").as_bytes()), "{}", c.label()); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.938], "{}", c.label()); assert!((raw_meters(&r.bytes).0 - 20.0).abs() < 0.001, "{}", c.label()); - let raised = r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit") && w.ends_with("the file is written as PDF 1.6")); - assert_eq!(raised, c == Compatibility::Pdf15, "{}: {:?}", c.label(), r.warnings); + let raised = + r.setting_notes.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit") && w.ends_with("the file is written as PDF 1.6")); + assert_eq!(raised, c == Compatibility::Pdf15, "{}: {:?}", c.label(), r.setting_notes); } } } @@ -288,11 +289,11 @@ fn pdfa2b_at_pdf_1_4_writes_a_long_page_as_pdf_1_6_within_14400_units() { assert!(media[2] - media[0] <= 14_400.0 && media[3] - media[1] <= 14_400.0, "{side} {in_points}: {media:?}"); assert!(((media[2] - media[0]) * unit[0] - side).abs() < side * 0.001, "{side} {in_points}: {media:?} {unit:?}"); let raised = "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6"; - assert!(r.warnings.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.setting_notes); } let r = report(&sized(14_400.0, 300.0), set.clone()).unwrap(); assert!(r.bytes.starts_with(b"%PDF-1.4"), "{in_points}"); - assert!(!r.warnings.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.warnings); + assert!(!r.setting_notes.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.setting_notes); } } @@ -500,7 +501,7 @@ fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_ assert!(r.bytes.starts_with(format!("%PDF-{}", c.id()).as_bytes()), "{}", c.label()); assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{}", c.label()); assert!((raw_meters(&r.bytes).0 - 10.0).abs() < 0.001, "{}", c.label()); - assert!(!r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.warnings); + assert!(!r.setting_notes.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.setting_notes); assert_eq!(long_page_warnings(&r).len(), 1, "{}: {:?}", c.label(), r.warnings); if matches!(c, Compatibility::Pdf13 | Compatibility::Pdf14) { assert_eq!(r.bytes, report(&d, set).unwrap().bytes, "{}", c.label()); diff --git a/crates/pdf/src/tests/output.rs b/crates/pdf/src/tests/output.rs index f7732821c1..0ab4d58c18 100644 --- a/crates/pdf/src/tests/output.rs +++ b/crates/pdf/src/tests/output.rs @@ -463,7 +463,7 @@ fn pdf_a_keeps_its_own_output_intent() { let r = pdf(&d, json!({"standard": "pdfA2b", "output": {"outputIntent": GENERIC_CMYK, "trapped": true}})); let text = String::from_utf8_lossy(&r.bytes); assert!(text.contains("GTS_PDFA1") && !text.contains("GTS_PDFX") && !text.contains("/Trapped")); - assert!(r.warnings.iter().any(|w| w.contains("PDF/A")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("PDF/A")), "{:?}", r.setting_notes); } #[test] diff --git a/crates/pdf/src/tests/pdf13.rs b/crates/pdf/src/tests/pdf13.rs index 4df56f3ff0..af7bbefe5d 100644 --- a/crates/pdf/src/tests/pdf13.rs +++ b/crates/pdf/src/tests/pdf13.rs @@ -69,7 +69,7 @@ fn pdf_1_3_files_are_encrypted_with_40_bit_rc4() { let o = options(json!({"compatibility": "1.3", "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": true}})); let r = export_with_report(&doc(1.0), &o).unwrap(); assert!(String::from_utf8_lossy(&r.bytes).contains("/Filter/Standard/V 1/R 2/Length 40/P ")); - assert!(r.warnings.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.setting_notes); assert!(matches!(import(&r.bytes), Err(PdfError::NeedsPassword))); for password in ["open", "owner"] { let opened = import_with_report(&r.bytes, &ImportOptions { password: Some(password.into()), ..Default::default() }).unwrap(); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 2f59563e70..897e7241b4 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -349,7 +349,7 @@ fn every_copy_on_a_sheet_draws_the_same_symbol_instances() { w.page(&mut ex, &sheet).unwrap(); assert_eq!(ex.symbol_limit, 10, "the limit is the export's again after the sheet"); w.absorb(ex); - let (bytes, _) = w.finish(&mut std::collections::HashSet::new(), false).unwrap(); + let (bytes, ..) = w.finish(&mut std::collections::HashSet::new(), false).unwrap(); let p = pages(&bytes); let left = p[0].1.iter().filter(|(r, _)| r.x0 < 200.0).count(); assert_eq!((left, p[0].1.len() - left), (3, 3), "one instance in each copy: {:?}", p[0].1); diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs index b3e105e4f6..3db63a4b2a 100644 --- a/crates/pdf/src/tests/realtext.rs +++ b/crates/pdf/src/tests/realtext.rs @@ -689,7 +689,7 @@ fn subset_thresholds_below_100_percent_are_accepted_with_a_warning() { let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Subset", style(24.0))]); let settings: PdfSettings = serde_json::from_value(json!({"advanced": {"outlineText": false, "fontSubsetPercent": 40}})).unwrap(); let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap(); - assert!(r.warnings.iter().any(|w| w.contains("subset")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("subset")), "{:?}", r.setting_notes); assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Subset"]); } diff --git a/crates/pdf/src/tests/settings.rs b/crates/pdf/src/tests/settings.rs index 1750beebd2..e159b7b162 100644 --- a/crates/pdf/src/tests/settings.rs +++ b/crates/pdf/src/tests/settings.rs @@ -73,7 +73,7 @@ fn checks_refuse_what_the_writer_cant_honour() { } #[test] -fn options_not_applied_yet_come_back_as_warnings() { +fn options_not_applied_yet_come_back_as_setting_notes() { assert!(PdfSettings::default().warnings().is_empty()); for (v, word) in [ (json!({"createLayers": true, "compatibility": "1.4"}), "PDF 1.5"), @@ -110,7 +110,8 @@ fn options_not_applied_yet_come_back_as_warnings() { let r = export_with_report(&doc(), &PdfOptions { settings: settings(json!({"thumbnails": true, "marks": {"trim": true}})), ..Default::default() }) .unwrap(); - // The writer embeds the thumbnails it is given: without them, the one warning says so. - assert!(r.warnings.len() == 1 && r.warnings[0].contains("thumbnails need the pages drawn"), "{:?}", r.warnings); + // The writer embeds the thumbnails it is given: without them, the one note on the options says so. + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.setting_notes.len() == 1 && r.setting_notes[0].contains("thumbnails need the pages drawn"), "{:?}", r.setting_notes); assert!(r.bytes.starts_with(b"%PDF-1.7")); } diff --git a/crates/pdf/src/tests/webview.rs b/crates/pdf/src/tests/webview.rs index a40db425e7..6fc50d0d54 100644 --- a/crates/pdf/src/tests/webview.rs +++ b/crates/pdf/src/tests/webview.rs @@ -345,7 +345,7 @@ fn thumbnails_are_embedded_on_every_page() { assert_eq!(import(&r.bytes).unwrap().artboards.len(), 3); // Asked for without the pages drawn: none, with a warning. Thumbnails too large are refused. let r = export_with_report(&d, &options(json!({"thumbnails": true}))).unwrap(); - assert!(r.warnings.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none()); + assert!(r.setting_notes.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none()); o.thumbnails[1] = thumb(107, 20, 0); assert!(matches!(export(&d, &o), Err(PdfError::BadSetting(_)))); } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 8eb18626f3..667e32a4ca 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -102,6 +102,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: region_boxes: None, art_reach: HashMap::new(), warnings: vec![], + setting_notes: vec![], id_prefix, linked: Vec::new(), instance_xf: Affine::IDENTITY, @@ -137,7 +138,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: // XML names cannot use character references. Preserve names that Latin-1 cannot encode. let encoding = if opts.encoding == Encoding::Latin1 && w.non_latin1_names { - w.warn("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); + w.note_setting("Unicode data attribute names require UTF-8; SVG was written in UTF-8"); Encoding::Utf8 } else { opts.encoding @@ -223,7 +224,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.body); out.push_str(""); out.push_str(nl); - Output { svg: out, linked: w.linked, warnings: w.warnings, encoding } + Output { svg: out, linked: w.linked, warnings: w.warnings, setting_notes: w.setting_notes, encoding } } type ColorizedKey = (String, u32, u32, Option<[u8; 3]>); @@ -268,6 +269,8 @@ struct Writer<'a> { brushes: Option>, /// Features written approximately (once each). warnings: Vec, + /// Notes on how the options were applied (once each). + setting_notes: Vec, /// Prepended to every id and class name ([`ObjectIds::Unique`]). id_prefix: &'a str, /// Image files the SVG links to ([`ImageMode::Link`]). diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index 7c30f23f5a..150c3605d5 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -525,11 +525,21 @@ impl Writer<'_> { }) } + /// Note `w`, once, about something of the document that the file leaves out or that reaches + /// it changed ([`crate::Output::warnings`]). A note about an option goes to [`Self::note_setting`]. pub(super) fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + + /// Note `w`, once, about an option that wasn't applied or was applied another way while the + /// art reaches the file as it looks ([`crate::Output::setting_notes`]). + pub(super) fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } } /// An element that has both its own classes and a generated `cls-N` would write `class` twice: diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index e5a5248400..27893bbe74 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -521,7 +521,7 @@ impl Writer<'_> { continue; }; if !font.subset { - self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting")); + self.note_setting(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting")); } let shared = uses.iter().filter(|o| described(o) == described(u)).count() > 1; let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() }; diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 8a447f6c38..ef6a312fe5 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -294,6 +294,9 @@ pub struct Output { pub linked: Vec, /// Features the export approximates. pub warnings: Vec, + /// Notes on the options: one the export didn't apply or applied another way (an encoding, a + /// font embedded whole). + pub setting_notes: Vec, /// The encoding [`Self::bytes`] writes. pub encoding: Encoding, } @@ -361,10 +364,11 @@ pub fn export(doc: &Document, opts: &ExportOptions) -> String { export_full(doc, opts, None).svg } -/// Export a document as an SVG string, also returning warnings about approximated features. +/// Export a document as an SVG string, also returning warnings about approximated features, +/// then the notes on the options ([`Output::setting_notes`]). pub fn export_with_report(doc: &Document, opts: &ExportOptions) -> (String, Vec) { let o = export_full(doc, opts, None); - (o.svg, o.warnings) + (o.svg, o.warnings.into_iter().chain(o.setting_notes).collect()) } /// Export a document as SVG plus the image files it links to. `native`: the document in the diff --git a/crates/svg/tests/it/data_attributes.rs b/crates/svg/tests/it/data_attributes.rs index d652b9665f..f8151ed7da 100644 --- a/crates/svg/tests/it/data_attributes.rs +++ b/crates/svg/tests/it/data_attributes.rs @@ -98,7 +98,7 @@ fn unicode_data_names_survive_each_encoding() { } if encoding == Encoding::Latin1 { assert_eq!(out.encoding, Encoding::Utf8, "XML names cannot use character references"); - assert!(!out.warnings.is_empty()); + assert!(!out.setting_notes.is_empty()); assert!(!out.svg.contains("ISO-8859-1")); } else { assert_eq!(out.encoding, encoding); diff --git a/crates/svg/tests/it/encoding_profile_fonts.rs b/crates/svg/tests/it/encoding_profile_fonts.rs index f0ea9804d0..5468953aa4 100644 --- a/crates/svg/tests/it/encoding_profile_fonts.rs +++ b/crates/svg/tests/it/encoding_profile_fonts.rs @@ -321,7 +321,7 @@ fn font_licences_are_respected() { let size = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").len(); assert!(fonts[0].2.len() + 64 >= size, "embedded whole: {} of {size} bytes", fonts[0].2.len()); assert!(o.warnings.iter().any(|w| w.contains("Sealed Sans 3") && w.contains("doesn't allow embedding")), "{:?}", o.warnings); - assert!(o.warnings.iter().any(|w| w.contains("Wholed Sans 3") && w.contains("whole")), "{:?}", o.warnings); + assert!(o.setting_notes.iter().any(|w| w.contains("Wholed Sans 3") && w.contains("whole")), "{:?}", o.setting_notes); } proptest::proptest! { diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index dff4fa0dd2..b43193d148 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -155,6 +155,23 @@ mod tests { assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); } + /// An export whose encoder notes an option it didn't apply (lossy WebP) before what the file + /// loses (type in a font that isn't installed): the status bar names what the file loses, and + /// the hover lists it first. + #[test] + fn an_export_names_what_the_file_loses_before_a_note_on_the_options() { + let (mut app, _) = app(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let r = export(&mut app, Some("webp"), Some("/out/d.webp".into()), &json!({"lossless": false})).unwrap(); + let notes = notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert!(notes[0].contains("Nonexistent Sans Probe"), "{r}"); + assert!(notes[1].starts_with("lossy WebP"), "{r}"); + assert_eq!(app.ui.status, exported_with_notes("/out/d.webp", 2, ¬es[0])); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + } + /// #1685: a save or an export that can't write its file says so in a dialog that names the /// file and the reason and stays until it is closed; the document stays modified and keeps /// the file it had. An agent's request only gets the error, and a message that arrives while diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 03e0196267..10eee5800c 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -83,7 +83,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option 1 { out["files"] = json!(files); } diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..5c93f5dc5e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -773,7 +773,11 @@ a path whose extension names another format has it replaced by the template's extension, and any other path that does not end in that extension has it added, so `card.png` is saved as `card.vctemplate` and `card` as `card.vctemplate`; the suggested name and the Templates folder come back without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOptions {format?}` (a format's options -with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF +with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. +In the replies of the save and export commands, and of `clipboard.exportPdf` and `clipboard.exportPng`, `warnings` +lists what the file leaves out of the document or writes differently first. The notes on the options follow: an +option not applied or applied another way while the art reaches the file as it looks, or something an option turns +off, such as `includeImages: false` in DXF. Each kind keeps the order the writers give it. A document saved as SVG or PDF remembers its options, so the next save reuses them; a document not yet saved as a native file is offered the VectorCraft Options the last native Save or Save As used (not its artboards, nor Use Compression). A PDF opened with `pages`, `cropTo` or `password` is only part of the file, so Save asks for a new name instead of writing it back. @@ -885,7 +889,7 @@ Long-tail commands: PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibility`, the General toggles and the `compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf` documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`: -options accepted but not applied yet, and features approximated or left out. An image whose pixel data can't be +features approximated or left out, then options accepted but not applied yet. An image whose pixel data can't be decoded (a PNG cut short) is left out of the pages, and `warnings` holds the note "an image couldn't be decoded and is missing from the picture" once, as raster exports give it; `.ai` saves and `file.print` do the same. A standard with a PDF version it doesn't allow is refused (PDF/A-2b at 1.3 or 2.0, PDF/X-4 above 1.6, PDF/X-1a and PDF/X-3 above 1.4); @@ -6290,7 +6294,8 @@ the later file is written as `-2`, `-3`… (`Artboard-1-2.png`) and listed under rows write one file, and row folders that differ only in case (`Print`, `print`) are one folder, spelled as the first row spells it. The document remembers the last params: `document.exportSettings`. As in `document.export`, the reply's `warnings` lists the notes of every file exported (an image that couldn't be decoded, type written in the -fallback font…), each note once; the export still writes or returns every file. +fallback font…), each note once, what any of the files loses before the notes on the options; the export still +writes or returns every file. Batch exports (#1935): - **What:** `layers: [id…]` (each layer's art alone) or `selection: "one" | "each"` instead of artboards diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..87b21331f2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -580,6 +580,7 @@ since 2026-10-01. - **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged. - **Notes of a save or an export in the status bar:** After Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file or the CSS Properties panel's export, the status message names the first note and counts them all. With several notes, resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), as it lists an open's notes. Scores and estimates are unchanged. +- **What a save or an export loses, named first:** Each writer marks its notes as a loss (something of the document the file leaves out or writes differently) or a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The replies of the save and export commands list the losses first, each kind in the order given, across every file an export writes. The status message after a save or an export then names a loss when there is one: an export to lossy WebP of type in a font that isn't installed names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged.