diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 9fe4665a5c..628facc1c3 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -112,6 +112,9 @@ fn preview(s: &mut Session, p: &Value) -> Result { super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?; let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?; pv.warnings.extend(type_warnings(doc, set.print_layers, &areas(&pv))); + // What the job loses comes first, then the notes on the options. + let settings = std::mem::take(&mut pv.setting_notes); + pv.warnings.extend(settings); let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?; v["settings"] = to_json(&set)?; Ok(v) @@ -199,7 +202,9 @@ fn print(s: &mut Session, p: &Value) -> Result { let mut r = vectorcraft_pdf::print(printed, &PrintOptions { settings: set, for_printer: bool_or(p, "forPrinter", false), ..Default::default() }) .map_err(|e| pdf_error(PRINT, e))?; + // What the job loses comes first, then the notes on the options. r.warnings.extend(typed); + r.warnings.extend(r.setting_notes); return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings })); } let level = match p.get("level").filter(|v| !v.is_null()) { @@ -209,12 +214,13 @@ fn print(s: &mut Session, p: &Value) -> Result { } None => Level::default(), }; - let mut warnings = vec![]; + // What the job loses, then the notes on the options: a reply lists them in that order. + let (mut warnings, mut setting_notes) = (vec![], vec![]); if !bitmap && (tile_images || doc.layers.iter().any(|l| l.shows_transparency())) { warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string()); } if !set.color.profile.trim().is_empty() && set.output.mode == vectorcraft_pdf::OutputMode::Composite { - warnings.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string()); + setting_notes.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string()); } let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness); let plan = vectorcraft_pdf::plan(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() }) @@ -244,5 +250,11 @@ fn print(s: &mut Session, p: &Value) -> Result { } let areas: Vec = plan.sheets.iter().filter_map(|sh| on_page(sh.area, sh.view * sh.place, sh.size)).collect(); warnings.extend(type_warnings(&full, which, &areas)); + for w in plan.setting_notes.iter().chain(&out.setting_notes) { + if !warnings.contains(w) && !setting_notes.contains(w) { + setting_notes.push(w.clone()); + } + } + warnings.extend(setting_notes); write_or_return(path, &out.bytes, json!({ "pages": plan.order.len(), "format": "postscript", "warnings": warnings })) } diff --git a/crates/engine/src/tests/print.rs b/crates/engine/src/tests/print.rs index 8ccd76deba..f10bf65c94 100644 --- a/crates/engine/src/tests/print.rs +++ b/crates/engine/src/tests/print.rs @@ -254,6 +254,35 @@ fn type_in_a_missing_family_warns_as_pdf_export_does() { } } +/// The warnings of `file.print` (PDF and PostScript) and `print.preview` list what the job +/// leaves out of the document or prints differently first, then the notes on the options. +#[test] +fn what_a_print_loses_comes_before_the_notes_on_the_options() { + let (mut s, _) = session(); + s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let notes = |v: &Value| -> Vec { v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() }; + // A fixed flatness isn't written to a PDF: a note on the options, made before the type's. + let fixed = json!({"settings": {"graphics": {"autoFlatness": false}}}); + for v in [print(&mut s, fixed.clone()).1, s.execute("print.preview", &fixed).unwrap()] { + let n = notes(&v); + assert!(n.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{n:?}"); + assert!(n.last().is_some_and(|w| w.starts_with("a fixed flatness")), "{n:?}"); + } + // PostScript doesn't apply the printer profile. + let profile = json!({"format": "postscript", "settings": {"color": {"profile": vectorcraft_color::cms::SRGB}}}); + let n = notes(&s.execute("file.print", &profile).unwrap()); + assert!(n.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{n:?}"); + assert!(n.last().is_some_and(|w| w.starts_with("the printer profile")), "{n:?}"); + // An artboard larger than Letter: copies per sheet that don't fit come after the art that + // doesn't print. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 700, "height": 900})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 700, "height": 900})).unwrap(); + let n = notes(&print(&mut s, json!({"settings": {"copiesPerSheet": true}})).1); + assert!(n.iter().any(|w| w.contains("part of it doesn't print")), "{n:?}"); + assert!(n.last().is_some_and(|w| w.ends_with("it prints one copy per sheet")), "{n:?}"); +} + #[test] fn type_that_does_not_fit_its_frame_is_named() { let (mut s, _) = session(); diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index 0bad9d9f60..12c71756c8 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -81,7 +81,10 @@ pub struct PrintOptions { pub struct PrintReport { pub bytes: Vec, pub pages: usize, + /// What the job leaves out of the document or prints differently. pub warnings: Vec, + /// Notes on the options: one the job didn't apply or applied another way. + pub setting_notes: Vec, } /// One page of a job, as [`preview`] lists it (one copy). @@ -129,7 +132,12 @@ pub struct PrintPreview { pub tiles: Vec, /// The inks of a separation (empty for composite output). pub inks: Vec, + /// What the job leaves out of the document or prints differently. pub warnings: Vec, + /// Notes on the options: one the job doesn't apply or applies another way. They aren't + /// serialized: a reply lists them in `warnings`, after what the job leaves out. + #[serde(skip)] + pub setting_notes: Vec, } /// A laid-out job. @@ -144,7 +152,10 @@ struct Job<'a> { plates: Vec, /// One copy's pages: (layout, plate). sheets: Vec<(usize, Option)>, + /// What the job leaves out of the document or prints differently. warnings: Vec, + /// Notes on the options: one the job doesn't apply or applies another way. + setting_notes: Vec, } /// Keep the layers of `nodes` that print as `which` says, shown (hidden ones print with All), @@ -223,11 +234,11 @@ impl<'a> Job<'a> { fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> Result { set.check()?; let doc = printed_document(doc, set); - let mut warnings = vec![]; - let (layouts, tiles) = layout::layout(&doc, set, &mut warnings)?; + let (mut warnings, mut setting_notes) = (vec![], vec![]); + let (layouts, tiles) = layout::layout(&doc, set, &mut warnings, &mut setting_notes)?; let separations = set.output.mode == OutputMode::Separations; let (inks, more) = if separations { print_inks(&doc, set) } else { Default::default() }; - warnings.extend(more); + setting_notes.extend(more); let plates: Vec = (0..inks.len()).filter(|i| inks[*i].print).collect(); let printed: Vec> = if separations { (0..plates.len()).map(Some).collect() } else { vec![None] }; if printed.is_empty() { @@ -245,18 +256,18 @@ impl<'a> Job<'a> { warnings.push("the document is RGB: separations convert its colours to CMYK with the colour settings".into()); } if !postscript && separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) { - warnings.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into()); + setting_notes.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into()); } if separations && set.advanced.print_as_bitmap { - warnings.push("Print as Bitmap is for composite output: separations print the art as it is".into()); + setting_notes.push("Print as Bitmap is for composite output: separations print the art as it is".into()); } if separations && cms::profile(set.color.profile.trim()).is_some_and(|p| p.kind != ProfileKind::Cmyk) { - warnings.push("separations separate with a CMYK profile: the RGB printer profile is left out".into()); + setting_notes.push("separations separate with a CMYK profile: the RGB printer profile is left out".into()); } if !postscript && !set.graphics.auto_flatness { - warnings.push("a fixed flatness is not written yet: the output device flattens curves".into()); + setting_notes.push("a fixed flatness is not written yet: the output device flattens curves".into()); } - Ok(Self { doc, layouts, tiles, inks, plates, sheets, warnings }) + Ok(Self { doc, layouts, tiles, inks, plates, sheets, warnings, setting_notes }) } /// The ink of plate `plate` (`None`: composite). @@ -303,7 +314,14 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result { pub title: String, /// When the job ran (Unix seconds, UTC). pub created: Option, + /// What the job leaves out of the document or prints differently. pub warnings: Vec, + /// Notes on the options: one the job doesn't apply or applies another way. + pub setting_notes: Vec, } /// Lay out printing `doc` as `opts` say, for a writer to draw. @@ -415,7 +436,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, }) .collect(); let order = job.order(set); - Ok(PrintPlan { plates, sheets, order, title, created, warnings: job.warnings, doc: job.doc }) + Ok(PrintPlan { plates, sheets, order, title, created, warnings: job.warnings, setting_notes: job.setting_notes, doc: job.doc }) } /// Landscape sheet `s` upright on the paper ([`PrintOptions::for_printer`]): a page wider than @@ -473,12 +494,18 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet = vec![]; + for m in plan.setting_notes.into_iter().chain(settings) { + if !warnings.contains(&m) && !setting_notes.contains(&m) { + setting_notes.push(m); + } + } + Ok(PrintReport { bytes, pages: plan.order.len(), warnings, setting_notes }) } /// The printer's marks of page `l` (`None` without marks), with its page information: the diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs index 3d404abab9..a88dd89950 100644 --- a/crates/pdf/src/print/layout.rs +++ b/crates/pdf/src/print/layout.rs @@ -184,8 +184,14 @@ fn too_many_pages() -> PdfError { } /// The pages of `doc` (whose layers are those that print) as `set` lays them out, the tiles of -/// each artboard when tiling, and warnings (art that doesn't fit the imageable area). -pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec) -> Result<(Vec, Vec), PdfError> { +/// each artboard when tiling. `warnings` gets what doesn't print (art outside the imageable area, +/// an artboard with no area), and `setting_notes` the copies per sheet that don't fit. +pub(crate) fn layout( + doc: &Document, + set: &PrintSettings, + warnings: &mut Vec, + setting_notes: &mut Vec, +) -> Result<(Vec, Vec), PdfError> { let marks = set.marks.printer_marks(); let bleed = set.bleed.of(doc); let (pw, ph) = set.paper(); @@ -278,7 +284,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec { - warnings.push(format!("{what} with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet")); + setting_notes.push(format!("{what} with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet")); None } Some((cols, rows)) if cols * rows > MAX_COPIES_PER_SHEET as f64 => { diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 897e7241b4..5c5e1eb2c0 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -275,11 +275,8 @@ fn an_artboard_that_does_not_fit_prints_one_copy_per_sheet() { let d = doc(700.0, 900.0, &[(Rect::new(0.0, 0.0, 700.0, 900.0), rgb(0.0, 0.0, 1.0))]); let r = job(&d, json!({"copiesPerSheet": true})); assert_eq!(r.pages, 1); - assert!( - r.warnings.iter().any(|w| w == "Artboard 1 with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"), - "{:?}", - r.warnings - ); + // A note on the options; the art that doesn't print is what the job loses. + assert_eq!(r.setting_notes, ["Artboard 1 with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"]); assert!(r.warnings.iter().any(|w| w.contains("larger than the imageable area")), "{:?}", r.warnings); assert_eq!(r.bytes, job(&d, json!({})).bytes, "printed as without copies per sheet"); assert!(preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap().sheets[0].copies.is_empty()); diff --git a/crates/pdf/src/tests/printadvanced.rs b/crates/pdf/src/tests/printadvanced.rs index f9dacb7597..8ff14f2fd1 100644 --- a/crates/pdf/src/tests/printadvanced.rs +++ b/crates/pdf/src/tests/printadvanced.rs @@ -136,5 +136,5 @@ fn separations_separate_with_a_cmyk_printer_profile() { assert_ne!(device, press, "the profile separates"); // An RGB printer profile doesn't separate. let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": cms::SRGB}})); - assert!(r.warnings.iter().any(|w| w.contains("RGB printer profile")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("RGB printer profile")), "{:?}", r.setting_notes); } diff --git a/docs/mcp.md b/docs/mcp.md index 207ca19d57..b5d28e8a70 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6902,7 +6902,11 @@ visible type that fits its frame or path, on the layers `printLayers` picks, on that print, and the type in the symbols, patterns, scatter brushes, opacity masks and placed documents of the art that prints. The warnings also give the ids of the area type and type on a path that print and whose text doesn't fit (objects of the document only, not type in the art of symbols, patterns, brushes, opacity masks or placed documents; -see [Fitting area type](#fitting-area-type-auto-size-shrink-text-to-fit-overflow)). +see [Fitting area type](#fitting-area-type-auto-size-shrink-text-to-fit-overflow)). The warnings of `file.print` +and `print.preview` list what the job leaves out of the document or prints differently first (art outside the +imageable area, type in a fallback font, transparency flattened for PostScript). The notes on the options follow: an +option not applied or applied another way, such as halftone screens, a fixed flatness or copies per sheet that don't +fit. Each kind keeps the order it is given in. ```json {"name":"run_command","arguments":{"command":"print.setup","params":{"settings":{"media":"a4","scaling":"fit","marks":{"trim":true}}}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e2f536bdeb..0c8bdb9485 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -590,7 +590,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. +- **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 and the print preview list theirs the same way: art outside the imageable area, type in a fallback font and transparency flattened for PostScript come before notes such as a fixed flatness that isn't written or copies per sheet that don't fit. 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.