Files
vectorcraft/crates/engine/src/tests_printpreview.rs
T
dotCooCoo 1c30cbf88c Print: Print and Print Preview warn when type prints in the fallback font
PDF, EPS, EMF/WMF and raster exports, and SVG exports that outline or
embed fonts, warn when type is set in a font family that isn't
available, but file.print and print.preview gave no such warning. A job
in a missing family printed in Source Sans 3 with no warning, and the
Print dialog's Summary showed none.

Both commands now add the exports' warning for the type that prints:
visible type that fits its frame or path, on the layers that Print
Layers picks, where what it paints (its effects applied) lands on the
paper of an artboard or tile that prints, inside its clipping paths.
The type in the symbols, inline graphics, patterns, scatter brushes,
opacity masks and placed documents of the art that prints counts too.
file.print reads the fonts before raster effects and Print as Bitmap
turn type into images, for PDF and PostScript output.

Docs: docs/mcp.md (Print), docs/target-app-parity.md (Fonts).
2026-10-16 02:26:13 +07:00

278 lines
16 KiB
Rust

//! `print.preview`'s pages say where the art lands (what the Print dialog's preview draws): the
//! transform from the document onto the page, the region each page prints and its trim box.
use serde_json::{Value, json};
use super::*;
/// `print.preview {settings}`'s first page.
fn first_sheet(s: &mut Session, settings: Value) -> Value {
s.execute("print.preview", &json!({ "settings": settings })).unwrap()["sheets"][0].clone()
}
/// `[a, b, c, d, e, f]` applied to (x, y).
fn apply(t: &Value, (x, y): (f64, f64)) -> (f64, f64) {
let n = |i: usize| t[i].as_f64().unwrap();
(n(0) * x + n(2) * y + n(4), n(1) * x + n(3) * y + n(5))
}
fn close(a: (f64, f64), b: (f64, f64)) -> bool {
(a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6
}
#[test]
fn pages_place_the_artboard_on_the_trim_box() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("document.setup", &json!({"bleed": 9})).unwrap();
// Centred on Letter, turned landscape for the wide artboard.
let p = first_sheet(&mut s, json!({}));
assert_eq!((p["width"].clone(), p["height"].clone()), (json!(792.0), json!(612.0)));
assert_eq!(p["trim"], json!([246.0, 206.0, 546.0, 406.0]));
assert!(close(apply(&p["transform"], (0.0, 0.0)), (246.0, 206.0)) && close(apply(&p["transform"], (300.0, 200.0)), (546.0, 406.0)));
assert_eq!(p["area"], json!([-9.0, -9.0, 309.0, 209.0]), "the artboard with the document's bleed");
// Flipped, the artboard's top-left corner lands on the trim box's bottom-right one.
let p = first_sheet(
&mut s,
json!({"autoRotate": false, "orientation": "landscapeFlipped", "scaling": "custom", "scale": {"width": 50, "height": 50}}),
);
let t = &p["trim"];
assert!(close(apply(&p["transform"], (0.0, 0.0)), (t[2].as_f64().unwrap(), t[3].as_f64().unwrap())), "{p}");
assert_eq!(p["scale"], json!([50.0, 50.0]));
// A tile prints its part of the artboard.
let v = s.execute("print.preview", &json!({"settings": {"scaling": "tileFull", "scale": {"width": 400, "height": 400}}})).unwrap();
let tiles = v["tiles"][0]["tiles"].as_array().unwrap();
for (sheet, tile) in v["sheets"].as_array().unwrap().iter().zip(tiles) {
let (a, t) = (&sheet["area"], tile);
for i in 0..4 {
let inside =
if i < 2 { a[i].as_f64().unwrap() >= t[i].as_f64().unwrap() - 1e-6 } else { a[i].as_f64().unwrap() <= t[i].as_f64().unwrap() + 1e-6 };
assert!(inside, "{a} within {t}");
}
}
}
/// Point type at (`x`, `y`) in `family` → its id.
fn type_in(s: &mut Session, x: f64, y: f64, family: &str) -> u64 {
s.execute("text.create", &json!({"x": x, "y": y, "text": "Ab", "font": family, "style": "Regular"})).unwrap()["id"].as_u64().unwrap()
}
/// The warnings in `v` about type in the fallback font.
fn fallback_warnings(v: &Value) -> Vec<&str> {
v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("fallback font")).collect()
}
/// `print.preview {settings}`'s warnings about type in the fallback font.
fn preview_fallback(s: &mut Session, settings: Value) -> Vec<String> {
let v = s.execute("print.preview", &json!({ "settings": settings })).unwrap();
fallback_warnings(&v).into_iter().map(str::to_string).collect()
}
#[test]
fn type_that_prints_in_the_fallback_font_is_named() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 2})).unwrap();
type_in(&mut s, 10.0, 50.0, vectorcraft_text::FALLBACK_FAMILY);
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new(), "an installed family");
// The warning PDF export gives.
let id = type_in(&mut s, 10.0, 100.0, "Nonexistent Sans Probe");
let export = s.execute("document.exportPdf", &json!({})).unwrap();
let warned = preview_fallback(&mut s, json!({}));
assert_eq!(warned, fallback_warnings(&export));
assert!(warned.first().is_some_and(|w| w.contains("Nonexistent Sans Probe")), "{warned:?}");
// A hidden object doesn't print.
s.execute("object.setProps", &json!({"ids": [id], "visible": false})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new());
}
#[test]
fn fallback_fonts_count_only_layers_and_artboards_that_print() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 2})).unwrap();
// On the second artboard, the type prints only when that artboard does.
let r = s.doc().unwrap().doc.artboards[1].rect;
type_in(&mut s, r.x0 + 10.0, r.y0 + 50.0, "Nonexistent Sans Probe");
assert_eq!(preview_fallback(&mut s, json!({"artboards": "range", "range": "1"})), Vec::<String>::new());
assert_eq!(preview_fallback(&mut s, json!({"artboards": "range", "range": "2"})).len(), 1);
assert_eq!(preview_fallback(&mut s, json!({})).len(), 1);
// On a hidden layer, the type prints only with Print Layers: All.
let layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
type_in(&mut s, 10.0, 50.0, "Nonexistent Sans Probe");
s.execute("layer.setProps", &json!({"id": layer, "visible": false})).unwrap();
for (layers, warns) in [("visiblePrintable", false), ("visible", false), ("all", true)] {
let warned = preview_fallback(&mut s, json!({"artboards": "range", "range": "1", "printLayers": layers}));
assert_eq!(!warned.is_empty(), warns, "{layers}");
}
// Template layers never print.
s.execute("layer.setProps", &json!({"id": layer, "template": true})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({"artboards": "range", "range": "1", "printLayers": "all"})), Vec::<String>::new());
}
#[test]
fn type_that_does_not_fit_its_frame_counts_only_where_it_shows() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let area = json!({"width": 100, "height": 20});
let p = json!({"x": 10, "y": 10, "text": "Ab\nCd\nEf\nGh", "font": vectorcraft_text::FALLBACK_FAMILY, "size": 12, "area": area});
let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap();
s.execute("text.setStyle", &json!({"ids": [id], "font": "Nonexistent Sans Probe", "start": 9, "end": 11})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new(), "only the first line shows");
s.execute("text.areaOptions", &json!({"ids": [id], "height": 150})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({})).len(), 1);
// Nor does the pattern of characters that don't show.
let tile = type_in(&mut s, 0.0, 15.0, "Pattern Probe Sans");
s.execute("select.set", &json!({"ids": [tile]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Words", "width": 40, "height": 40})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [tile]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
s.execute("text.setStyle", &json!({"ids": [id], "font": vectorcraft_text::FALLBACK_FAMILY})).unwrap();
s.execute("text.setStyle", &json!({"ids": [id], "tracking": 10, "start": 9, "end": 11})).unwrap();
s.edit("pattern", |d, _| {
if let vectorcraft_doc::NodeKind::Text(t) = &mut d.node_mut(vectorcraft_doc::NodeId(id)).unwrap().kind {
t.runs.last_mut().unwrap().style.fill = vectorcraft_color::Paint::Pattern { pattern: "Words".into(), xf: Default::default() };
}
Ok(())
})
.unwrap();
assert!(preview_fallback(&mut s, json!({}))[0].contains("Pattern Probe Sans"));
s.execute("text.areaOptions", &json!({"ids": [id], "height": 20})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new());
}
#[test]
fn type_of_an_artboard_larger_than_the_paper_counts_only_on_the_paper() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 2000, "height": 200})).unwrap();
type_in(&mut s, 1800.0, 100.0, "Nonexistent Sans Probe");
// At 100% the middle of the artboard prints on Letter paper.
assert_eq!(preview_fallback(&mut s, json!({"scaling": "none"})), Vec::<String>::new());
for scaling in ["fit", "tileFull"] {
assert_eq!(preview_fallback(&mut s, json!({ "scaling": scaling })).len(), 1, "{scaling}");
}
}
#[test]
fn symbol_instances_and_inline_graphics_print_the_type_of_their_symbol() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 2})).unwrap();
let r = s.doc().unwrap().doc.artboards[1].rect;
let id = type_in(&mut s, r.x0 + 10.0, r.y0 + 50.0, "Nonexistent Sans Probe");
s.execute("symbol.new", &json!({"name": "Label", "ids": [id]})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({"artboards": "range", "range": "1"})), Vec::<String>::new());
assert_eq!(preview_fallback(&mut s, json!({"artboards": "range", "range": "2"})).len(), 1);
// An inline graphic in type on the first artboard prints its symbol's art.
let id = type_in(&mut s, 10.0, 50.0, vectorcraft_text::FALLBACK_FAMILY);
s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "Label"})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({"artboards": "range", "range": "1"})).len(), 1);
}
#[test]
fn a_placed_document_prints_its_type() {
let mut src = Session::new();
src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
type_in(&mut src, 10.0, 30.0, "Nonexistent Sans Probe");
let path = std::env::temp_dir().join(format!("vc-printfonts-{}.vectorcraft", std::process::id()));
std::fs::write(&path, vectorcraft_format::save_file(&src.doc().unwrap().doc)).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("file.place", &json!({"path": path.to_string_lossy(), "at": [150, 100]})).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(preview_fallback(&mut s, json!({})).len(), 1);
}
#[test]
fn type_prints_where_effects_paint_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 2})).unwrap();
let r = s.doc().unwrap().doc.artboards[1].rect;
let id = type_in(&mut s, r.x0 + 10.0, r.y0 + 50.0, "Nonexistent Sans Probe");
// Onto the first artboard.
s.execute("effect.apply", &json!({"effect": "distort.transform", "ids": [id], "params": {"moveH": -r.x0, "moveV": -r.y0}})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({"artboards": "range", "range": "1"})).len(), 1);
assert_eq!(preview_fallback(&mut s, json!({"artboards": "range", "range": "2"})), Vec::<String>::new());
// A group's shadow reaching the artboard prints the type above it.
let id = type_in(&mut s, 10.0, -40.0, "Sans Probe Above");
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let group = s.execute("object.group", &json!({})).unwrap()["id"].clone();
assert!(!preview_fallback(&mut s, json!({"artboards": "range", "range": "1"}))[0].contains("Sans Probe Above"));
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [group], "params": {"x": 0, "y": 60, "blur": 0}})).unwrap();
assert!(preview_fallback(&mut s, json!({"artboards": "range", "range": "1"}))[0].contains("Sans Probe Above"));
// A hidden effect leaves each member of a group printing where it lies.
let shape = s.execute("shape.rectangle", &json!({"x": 10, "y": 120, "width": 20, "height": 20})).unwrap()["id"].clone();
let id = type_in(&mut s, r.x0 + 10.0, r.y0 + 150.0, "Sans Probe Hidden");
s.execute("select.set", &json!({"ids": [shape, id]})).unwrap();
let both = s.execute("object.group", &json!({})).unwrap()["id"].clone();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [both]})).unwrap();
s.execute("effect.setParams", &json!({"index": 0, "visible": false, "ids": [both], "item": null})).unwrap();
assert!(!preview_fallback(&mut s, json!({"artboards": "range", "range": "1"}))[0].contains("Sans Probe Hidden"));
// A group's own fill, drawn from the glyphs of the type in it and moved onto the artboard, prints it too.
let id = type_in(&mut s, 10.0, -80.0, "Sans Probe Fill");
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let group = s.execute("object.group", &json!({})).unwrap()["id"].clone();
assert!(!preview_fallback(&mut s, json!({"artboards": "range", "range": "1"}))[0].contains("Sans Probe Fill"));
s.execute("appearance.addFill", &json!({})).unwrap();
s.execute("effect.apply", &json!({"effect": "distort.transform", "ids": [group], "item": 0, "params": {"moveV": 100}})).unwrap();
assert!(preview_fallback(&mut s, json!({"artboards": "range", "range": "1"}))[0].contains("Sans Probe Fill"));
}
#[test]
fn a_pattern_prints_the_type_of_its_tile() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let id = type_in(&mut s, 0.0, 15.0, "Nonexistent Sans Probe");
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Words", "width": 40, "height": 40})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new(), "a pattern no object uses");
let shape = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 200, "height": 100})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [shape], "swatch": "Words"})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({})).len(), 1);
// A hidden fill paints nothing.
let n = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(shape.as_u64().unwrap())).unwrap().clone();
let fill = n.appearance.items.iter().position(|i| i.is_fill()).unwrap();
s.execute("appearance.setItem", &json!({"ids": [shape], "index": fill, "visible": false})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new());
}
#[test]
fn a_brushed_stroke_prints_the_type_of_its_brush() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let id = type_in(&mut s, 0.0, 15.0, "Nonexistent Sans Probe");
s.execute("brush.new", &json!({"type": "scatter", "name": "Words", "ids": [id]})).unwrap();
s.execute("brush.new", &json!({"type": "art", "name": "Bent", "ids": [id]})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let shape = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 200, "height": 100})).unwrap()["id"].clone();
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new());
// An art brush bends paths and leaves type out.
s.execute("brush.apply", &json!({"name": "Bent", "ids": [shape]})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new());
s.execute("brush.apply", &json!({"name": "Words", "ids": [shape]})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({})).len(), 1);
}
#[test]
fn type_in_an_opacity_mask_prints_and_type_clipped_away_does_not() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let shape = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 200, "height": 100})).unwrap()["id"].clone();
let mask = type_in(&mut s, 20.0, 60.0, "Nonexistent Sans Probe");
s.execute("transparency.makeOpacityMask", &json!({"ids": [shape, mask]})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({})).len(), 1, "the mask's type");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let id = type_in(&mut s, 10.0, 150.0, "Nonexistent Sans Probe");
assert_eq!(preview_fallback(&mut s, json!({})).len(), 1);
let clip = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [id, clip]})).unwrap();
let group = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone();
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new(), "outside its clipping path");
// Also in a symbol's art.
s.execute("symbol.new", &json!({"name": "Clipped", "ids": [group]})).unwrap();
assert_eq!(preview_fallback(&mut s, json!({})), Vec::<String>::new(), "outside its clipping path in a symbol");
}