Merge pull request #6920 from dotCooCoo/feat/export-notes-loss-first

Save and Export: notes of what the file loses come before notes on options
This commit is contained in:
BFlat
2026-11-13 05:23:53 +07:00
committed by GitHub
53 changed files with 433 additions and 150 deletions
+11 -7
View File
@@ -187,6 +187,9 @@ pub struct HpglOutput {
pub pens: Vec<[u8; 3]>,
/// What was approximated or left out.
pub warnings: Vec<String>,
/// Notes on the options: sorting the paths or drawing the inner paths first was applied only
/// in part.
pub setting_notes: Vec<String>,
}
/// 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<Vec<(i64, i64)>>,
mut at: (i64, i64),
work: &mut u64,
nearest: bool,
inside_first: bool,
warnings: &mut Vec<String>,
notes: &mut Vec<String>,
) -> (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));
}
}
+7 -6
View File
@@ -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)][..]));
+3
View File
@@ -410,6 +410,9 @@ pub struct DxfOutput {
pub images: Vec<DxfImage>,
/// What was approximated or left out.
pub warnings: Vec<String>,
/// What was left out because an option turns it off (Include Placed Images or Include Fill
/// Hatches off).
pub setting_notes: Vec<String>,
}
/// The smallest and largest `scale` (drawing units per unit).
+15 -3
View File
@@ -520,6 +520,8 @@ pub(crate) struct Scene<'a> {
/// Image keys → index into `images` (`None`: left out).
by_key: HashMap<String, Option<usize>>,
warnings: Vec<String>,
/// What was left out because an option asked for it ([`DxfOutput::setting_notes`]).
setting_notes: Vec<String>,
brushes: Option<Vec<vectorcraft_brush::Brush>>,
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 };
+13 -8
View File
@@ -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");
}
+9 -7
View File
@@ -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, named_ids, targets};
pub fn specs() -> Vec<CommandSpec> {
@@ -118,20 +118,22 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
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<Note> = 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(&notes);
let n = sheet.rules.len();
match path {
Some(path) => {
+1 -1
View File
@@ -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};
+1
View File
@@ -241,6 +241,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) ->
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) });
+102 -9
View File
@@ -74,18 +74,104 @@ pub struct Encoded {
pub linked: Vec<vectorcraft_svg::LinkedImage>,
/// Image maps, each written beside its image under the image's name (raster `imageMap`).
pub maps: Vec<imagemap::Map>,
/// 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<String>,
/// 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<String>,
/// 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<String>) -> Self {
Self { kind: NoteKind::Loss, text: text.into() }
}
/// A note on the options ([`NoteKind::Setting`]).
pub fn setting(text: impl Into<String>) -> 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<String> {
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: Note) {
if !notes.iter().any(|n| n.text == note.text) {
notes.push(note);
}
}
impl Encoded {
pub(super) fn one(bytes: Vec<u8>) -> 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<u8>, notes: Vec<Note>) -> 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<Item = String>) {
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<Note> {
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<String> {
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<u8>, Vec<String>)> {
/// The one file and the notes, when the export wrote nothing else.
fn single(self, f: &Format) -> Result<(Vec<u8>, Vec<Note>)> {
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<Vec<u8>> {
/// 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<u8>, Vec<String>)> {
encode_with_notes(doc, format, p).map(|(bytes, notes)| (bytes, note_texts(&notes)))
}
/// 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<u8>, Vec<Note>)> {
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<Encoded> {
/// ([`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<Encoded> {
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<Encoded> {
"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<Encoded> {
let regions: Vec<Rect> = 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(&regions)));
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);
}
+1
View File
@@ -382,6 +382,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> {
enc.warnings.push(w);
}
}
enc.add_setting_notes(out.setting_notes);
}
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(&regions)));
Ok(enc)
+2 -2
View File
@@ -42,7 +42,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => 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<Value> {
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`
+1
View File
@@ -116,6 +116,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) ->
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"));
+4 -1
View File
@@ -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
+26 -23
View File
@@ -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<u8>, Vec<String>)> {
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Note>)> {
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<i64>) -> Result<(Vec<u8>, Vec<String>)> {
pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option<i64>) -> Result<(Vec<u8>, Vec<Note>)> {
// 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<Note> = 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<Vec<u8>>,
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
) -> Result<(Vec<u8>, Vec<String>)> {
) -> Result<(Vec<u8>, Vec<Note>)> {
encode_carrying_at(cmd, doc, p, None, native, private)
}
@@ -334,7 +335,7 @@ fn encode_carrying_at(
modified: Option<i64>,
native: impl FnOnce() -> Result<Vec<u8>>,
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
) -> Result<(Vec<u8>, Vec<String>)> {
) -> Result<(Vec<u8>, Vec<Note>)> {
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<Value> {
} 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(&notes) }))
}
/// `(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<Value> {
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()))?;
+11 -10
View File
@@ -752,12 +752,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, &params, native, private)?;
warnings.extend(placed_warnings);
let (bytes, notes) = super::pdf::encode_carrying(cmd, &pages, &params, 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
@@ -845,8 +847,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<Encoded> {
let mut enc = self.encode_one(&self.doc)?;
for &b in &self.boards {
@@ -854,7 +856,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)
}
@@ -905,9 +907,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<Value>, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
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);
+12 -11
View File
@@ -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";
@@ -1021,7 +1021,8 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map<Stri
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
/// so do different files that would share a name, with or without the `name` pattern. The reply's
/// `warnings` lists every warning the encoders give for the files, each once.
/// `warnings` lists every warning the encoders give for the files, each once, what the files lose
/// first ([`super::note_texts`]).
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
check_passwords(p)?;
let o = ScreenParams::of(p)?;
@@ -1140,18 +1141,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<String> = Vec::new();
// The encoders' notes for every file, each once.
let mut notes: Vec<Note> = 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<Vec<u8>> {
// 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)
};
@@ -1226,6 +1225,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(&notes);
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);
+1
View File
@@ -204,6 +204,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
enc.warnings.push(w);
}
}
enc.add_setting_notes(out.setting_notes);
enc.files.push((artboard, if compressed { vectorcraft_svg::compress_bytes(&bytes) } else { bytes }));
}
// Live type names its fonts; outlines and embedded fonts are the fallback font's.
+15
View File
@@ -194,6 +194,21 @@ fn placed_images_and_fill_hatches_can_be_left_out() {
let _ = std::fs::remove_dir_all(dir);
}
/// The reply gives what the drawings lose before a note on the options, across the files: the
/// first artboard's drawing notes the fill hatches that `includeHatches: false` leaves out, and
/// the second's also has a see-through rectangle that DXF 2000 writes opaque.
#[test]
fn what_the_drawings_lose_comes_before_what_an_option_left_out() {
let mut s = two_boards();
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
let r = s.execute("document.export", &json!({"format": "dxf", "version": "2000", "includeHatches": false, "useArtboards": true})).unwrap();
let warnings: Vec<&str> = 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.
@@ -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<String> {
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(&notes), ["l1", "l2", "s1", "s2"]);
assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]);
assert!(note_texts(&[]).is_empty());
}
@@ -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:?}");
}
}
@@ -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);
+2 -2
View File
@@ -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,
+3 -1
View File
@@ -158,6 +158,8 @@ pub enum HiddenObjects {
pub struct LegacyAiOutput {
pub bytes: Vec<u8>,
pub warnings: Vec<String>,
/// Notes on what the options asked for: hidden objects left out or written visible.
pub setting_notes: Vec<String>,
}
/// Whether image node `n` of `doc` is written as a 1-bit image mask in its colorize color
@@ -269,7 +271,7 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
// The page trailer as the specification's script has it (§3); the document trailer takes the
// prolog's dictionary off the dictionary stack (its procedure set termination, §3).
s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nend\n%%EOF\n");
Ok(LegacyAiOutput { bytes: s.into_bytes(), warnings: w.warnings })
Ok(LegacyAiOutput { bytes: s.into_bytes(), warnings: w.warnings, setting_notes: w.setting_notes })
}
#[cfg(test)]
+2 -2
View File
@@ -1894,12 +1894,12 @@ fn hidden_objects_go_to_a_hidden_layer_above_their_own() {
#[test]
fn hidden_objects_can_be_left_out_or_shown() {
let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Omit, ..LegacyAiOptions::default() });
assert!(out.warnings.iter().any(|w| w == HIDDEN), "{:?}", out.warnings);
assert!(out.setting_notes.iter().any(|w| w == HIDDEN), "{:?}", out.setting_notes);
let doc = crate::import(&out.bytes).unwrap().document;
assert_eq!(doc.layers.len(), 2);
assert_eq!(children(&children(&doc.layers[0])[0]).len(), 1);
let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Visible, ..LegacyAiOptions::default() });
assert!(out.warnings.iter().any(|w| w == HIDDEN_SHOWN), "{:?}", out.warnings);
assert!(out.setting_notes.iter().any(|w| w == HIDDEN_SHOWN), "{:?}", out.setting_notes);
let doc = crate::import(&out.bytes).unwrap().document;
assert_eq!(doc.layers.len(), 2);
let pair = children(&children(&doc.layers[0])[0]);
+14 -2
View File
@@ -24,6 +24,8 @@ pub(super) struct Writer<'a> {
/// The name each spot color (by its swatch's name) is written with ([`Self::spot`]).
spot_names: Vec<(String, String)>,
pub(super) warnings: Vec<String>,
/// Notes on what the options asked for ([`crate::LegacyAiOutput::setting_notes`]).
pub(super) setting_notes: Vec<String>,
/// 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) {
+6 -4
View File
@@ -195,6 +195,8 @@ pub struct EpsOutput {
pub bytes: Vec<u8>,
/// What was approximated or left out.
pub warnings: Vec<String>,
/// Notes on the options: the preview or thumbnail asked for that was left out.
pub setting_notes: Vec<String>,
}
/// 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: <name>`)
+1 -1
View File
@@ -181,7 +181,7 @@ pub fn print(pages: &[PrintPage], order: &[usize], job: &PrintJob) -> Result<Eps
s.push_str(page);
}
s.push_str("%%Trailer\n%%EOF\n");
Ok(EpsOutput { bytes: s.into_bytes(), warnings })
Ok(EpsOutput { bytes: s.into_bytes(), warnings, setting_notes: vec![] })
}
#[cfg(test)]
+1 -1
View File
@@ -1052,7 +1052,7 @@ fn a_tiff_preview_sits_behind_the_binary_header() {
}
// Asked for but not rendered: the file has no preview, and says so.
let out = export(&d, &EpsOptions { preview: Preview::TiffColor, ..opts(&d) }, None, None).unwrap();
assert!(out.bytes.starts_with(b"%!PS") && out.warnings.iter().any(|w| w.contains("preview")));
assert!(out.bytes.starts_with(b"%!PS") && out.setting_notes.iter().any(|w| w.contains("preview")));
}
#[test]
+13 -9
View File
@@ -7,8 +7,9 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, 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<ExportReport, PdfError> {
// 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
+9 -5
View File
@@ -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<u8>, Vec<String>, Vec<String>);
/// 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<String>, display_doc_title: bool) -> Result<(Vec<u8>, Vec<String>), PdfError> {
pub(crate) fn finish(mut self, left_out: &mut HashSet<String>, display_doc_title: bool) -> Result<Finished, 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
@@ -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))
}
}
+3
View File
@@ -213,7 +213,10 @@ pub struct ImportReport {
#[derive(Clone, Debug)]
pub struct ExportReport {
pub bytes: Vec<u8>,
/// What the file leaves out of the document or writes differently.
pub warnings: Vec<String>,
/// Notes on the options: one the export didn't apply or applied another way.
pub setting_notes: Vec<String>,
}
#[derive(Debug, thiserror::Error, PartialEq)]
+1 -1
View File
@@ -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<u8>, display_doc_title: bool) -> Result<(Vec<u8>, Vec<String>), PdfError> {
let cat = self.catalog.as_ref();
if cat.is_none() && !display_doc_title {
+4 -4
View File
@@ -204,13 +204,13 @@ fn linearised(pdf: &[u8], set: &PdfSettings) -> Result<Vec<u8>, 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<u8>, opts: &PdfOptions, editing: bool, warnings: &mut Vec<String>) -> Result<Vec<u8>, 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<u8>, opts: &PdfOptions, editing: bool, settings: &mut Vec<String>) -> Result<Vec<u8>, 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<u8>, 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)
}
}
+2 -2
View File
@@ -470,10 +470,10 @@ 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, false)?;
let (bytes, more, settings) = 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 {
for m in more.into_iter().chain(settings) {
if !warnings.contains(&m) {
warnings.push(m);
}
+2 -1
View File
@@ -118,7 +118,8 @@ fn the_legacy_name_is_read_and_plain_pdfs_have_no_editing_data() {
assert_eq!(editing(&plain), None);
assert_eq!(import_with_report(&plain, &ImportOptions::default()).unwrap().native, None);
let r = pdf_with(None, true);
assert!(r.warnings.len() == 1 && r.warnings[0].contains("Preserve editing"), "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
assert!(r.setting_notes.len() == 1 && r.setting_notes[0].contains("Preserve editing"), "{:?}", r.setting_notes);
assert_eq!(editing(&r.bytes), None);
}
+3 -3
View File
@@ -212,15 +212,15 @@ fn without_pdf_layers_the_layers_are_plain_page_content() {
oc_properties(&r.bytes, None, |props, _| has = props.is_some());
assert_eq!(has, v["createLayers"] == json!(true) && warning.is_none(), "{v}");
match warning {
Some(w) => 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]
+6 -5
View File
@@ -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::<f64>::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());
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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"]);
}
+4 -3
View File
@@ -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"));
}
+1 -1
View File
@@ -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(_))));
}
+5 -2
View File
@@ -103,6 +103,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,
@@ -142,7 +143,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
let variables = w.variable_sets();
// 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
@@ -229,7 +230,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
out.push_str(&w.body);
out.push_str("</svg>");
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]>);
@@ -274,6 +275,8 @@ struct Writer<'a> {
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// Features written approximately (once each).
warnings: Vec<String>,
/// Notes on how the options were applied (once each).
setting_notes: Vec<String>,
/// Prepended to every id and class name ([`ObjectIds::Unique`]).
id_prefix: &'a str,
/// Image files the SVG links to ([`ImageMode::Link`]).
+10
View File
@@ -528,11 +528,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:
+1 -1
View File
@@ -522,7 +522,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() };
+6 -2
View File
@@ -299,6 +299,9 @@ pub struct Output {
pub linked: Vec<LinkedImage>,
/// Features the export approximates.
pub warnings: Vec<String>,
/// Notes on the options: one the export didn't apply or applied another way (an encoding, a
/// font embedded whole).
pub setting_notes: Vec<String>,
/// The encoding [`Self::bytes`] writes.
pub encoding: Encoding,
}
@@ -366,10 +369,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<String>) {
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
+1 -1
View File
@@ -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);
@@ -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! {
+17
View File
@@ -160,6 +160,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, &notes[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
+1 -1
View File
@@ -83,7 +83,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
return Ok(json!({ "replaces": replaced }));
}
let files = write_encoded(write, &doc, &path, &enc)?;
let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings });
let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.notes() });
if files.len() > 1 {
out["files"] = json!(files);
}
+8 -3
View File
@@ -868,7 +868,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.
@@ -980,7 +984,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);
@@ -6596,7 +6600,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
+1
View File
@@ -587,6 +587,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.