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).
This commit is contained in:
dotCooCoo
2026-10-16 02:26:13 +07:00
committed by bflatastic
parent d069f0fe98
commit 1c30cbf88c
7 changed files with 471 additions and 9 deletions
+162 -2
View File
@@ -3,9 +3,12 @@
//! available (the bundled and installed ones) and rescanning the installed fonts.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextRun};
use vectorcraft_geom::Rect;
use super::typecmd::refresh_bounds;
use super::*;
@@ -111,11 +114,168 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
fonts
}
/// The fonts (family, style, version) of the type of `d` that prints on pages showing `areas`
/// (document space): visible type on `layers` (the layers that print) but not on a template layer
/// (of area type and type on a path that don't fit, the runs that their layout draws),
/// where what it paints ([`vectorcraft_render::painted_bounds`], its effects applied) meets an area
/// inside the clipping paths around it, the type in the opacity masks and patterns of the objects
/// that print and in the pieces their brushes draw, and the type of the symbols that their
/// instances and inline graphics show.
/// The objects of a blend, envelope, repeat or compound path, or of a layer or group with effects,
/// fills or strokes of its own, are taken with it when what it paints meets an area, and so is the
/// art of a placed document (`d` as [`Document::with_placed_art`] gives it, for that art's
/// resources).
pub(super) fn printed_fonts(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -> BTreeSet<UsedFont> {
let mut walk = Printed { d, fonts: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
for l in layers {
walk.node(l, Some(areas));
}
walk.fonts
}
/// The walk of [`printed_fonts`].
struct Printed<'a> {
d: &'a Document,
fonts: BTreeSet<UsedFont>,
/// The symbols and patterns whose art was walked.
symbols: BTreeSet<String>,
patterns: BTreeSet<String>,
}
impl Printed<'_> {
/// Adds the fonts of `n` and its descendants that print: those whose bounds meet one of `areas`,
/// or with `None` (in the art of a symbol, opacity mask, pattern or placed document, in brush
/// pieces, or in a live object) all of them but those outside the clipping path of a clip group.
fn node(&mut self, n: &Node, areas: Option<&[Rect]>) {
if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) {
return;
}
// A layer or group with effects, fills or strokes of its own prints where they paint (moved,
// shadowed…), drawn from its members' shapes.
let moved = vectorcraft_render::effects::has_container_appearance(n);
let open = !moved && matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. });
if let Some(a) = areas
&& !open
&& !self.meets(n, a)
{
return;
}
// The art that its opacity mask, pattern paints and brushed strokes draw.
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
let runs = match &n.kind {
NodeKind::Text(t) => drawn_runs(t),
_ => vec![],
};
let items: Vec<&AppearanceItem> =
n.appearance.items.iter().filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)).collect();
let patterns: Vec<&str> = items
.iter()
.map(|i| i.paint())
.chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten())
.filter_map(|p| match p {
Paint::Pattern { pattern, .. } => Some(pattern.as_str()),
_ => None,
})
.collect();
let brushed = vectorcraft_brush::has_brush(n);
if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) {
if let Some(m) = mask {
self.node(&m.art, None);
}
let d = self.d;
for name in patterns {
if self.patterns.insert(name.to_string())
&& let Some(p) = d.pattern(name)
{
for art in &p.art {
self.node(art, None);
}
}
}
// The pieces its brushes draw along the path (art and pattern brushes leave type out).
if brushed {
for piece in vectorcraft_brush::node_pieces(d, n).unwrap_or_default() {
self.node(&piece, None);
}
}
}
match &n.kind {
NodeKind::Text(_) => {
for r in runs {
self.fonts.insert((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()));
// Inline graphics draw their symbols' art.
if let Some(art) = &r.inline {
self.symbol(&art.symbol);
}
}
}
NodeKind::SymbolInstance { symbol, .. } => self.symbol(symbol),
NodeKind::PlacedDocument(p) => {
for art in p.art(n) {
self.node(&art, None);
}
}
NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } if !moved => {
// Each object prints where it lies, inside the clipping path of a clip group.
let clipped = match (clip, areas) {
(false, _) => None,
(true, Some(a)) => Some(self.clip(n, a)),
(true, None) => vectorcraft_render::painted_bounds(self.d, n).map(|c| vec![c]),
};
for c in children {
self.node(c, clipped.as_deref().or(areas));
}
}
_ => {
for c in n.children().map(Vec::as_slice).unwrap_or_default() {
self.node(c, None);
}
}
}
}
/// Adds the fonts of symbol `name`'s art, the first time it is shown.
fn symbol(&mut self, name: &str) {
let d = self.d;
if self.symbols.insert(name.to_string())
&& let Some(s) = d.symbols.iter().find(|s| s.name == name)
{
self.node(&s.art, None);
}
}
/// Whether what `n` paints, its effects applied, meets one of `areas`.
fn meets(&self, n: &Node, areas: &[Rect]) -> bool {
vectorcraft_render::painted_bounds(self.d, n).is_some_and(|b| areas.iter().any(|r| b.intersect(*r).area() > 0.0 || r.contains_rect(b)))
}
/// The parts of `areas` inside the bounds of clip group `n` (its clipping path's).
fn clip(&self, n: &Node, areas: &[Rect]) -> Vec<Rect> {
let Some(c) = vectorcraft_render::painted_bounds(self.d, n) else { return areas.to_vec() };
areas.iter().filter(|r| c.intersect(**r).area() > 0.0 || r.contains_rect(c)).map(|r| r.intersect(c)).collect()
}
}
/// The runs of `t` that print: all of them, or of area type and type on a path that don't fit, the
/// runs that their layout draws glyphs or inline graphics of.
fn drawn_runs(t: &vectorcraft_doc::TextObject) -> Vec<&TextRun> {
let drawn: Option<BTreeSet<usize>> = (!matches!(t.kind, TextKind::Point))
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t))
.filter(|lay| lay.overflow)
.map(|lay| lay.glyphs.iter().map(|g| g.run).chain(lay.inlines.iter().map(|i| i.run)).collect());
t.runs.iter().enumerate().filter(|(i, _)| drawn.as_ref().is_none_or(|d| d.contains(i))).map(|(_, r)| r).collect()
}
/// What an export that draws type (as outlines, embedded fonts or pixels) says when `d` uses font
/// families that aren't available: their type is drawn in the fallback font, not the font it names.
pub(super) fn substitution_warning(d: &Document) -> Option<String> {
substitution_warning_of(used_fonts(d))
}
/// [`substitution_warning`] for type set in `fonts`.
pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<String> {
let db = vectorcraft_text::FontDb::global();
let mut missing: Vec<String> = used_fonts(d)
let mut missing: Vec<String> = fonts
.into_iter()
.filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
.map(|(family, _, _)| family)
+43 -5
View File
@@ -6,7 +6,8 @@ use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_eps::{Level, PrintJob, PrintPage};
use vectorcraft_pdf::{PrintOptions, PrintSettings};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_pdf::{PrintLayers, PrintOptions, PrintPreview, PrintSettings};
use super::fileio::pdf::{merge, pdf_error};
use super::fileio::{extension, write_or_return};
@@ -30,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Preview",
[],
None,
"{settings?: {…settings of print.setup} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, options not applied yet…), settings} without printing",
"{settings?: {…settings of print.setup} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, options not applied yet…), settings} without printing",
has_doc,
preview
),
@@ -90,12 +91,43 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let set = settings(C, doc, p)?;
super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?;
let pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
pv.warnings.extend(fonts_warning(doc, set.print_layers, &areas(&pv)));
let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?;
v["settings"] = to_json(&set)?;
Ok(v)
}
/// The parts of the document (document space) that the pages of `pv` print.
fn areas(pv: &PrintPreview) -> Vec<Rect> {
pv.sheets
.iter()
.filter_map(|p| on_page(Rect::new(p.area[0], p.area[1], p.area[2], p.area[3]), Affine::new(p.transform), (p.width, p.height)))
.collect()
}
/// The part of `area` (document space) that lands on a `w` × `h` page placed by `to_page`
/// (document space → the page); `None` when none of it does.
fn on_page(area: Rect, to_page: Affine, (w, h): (f64, f64)) -> Option<Rect> {
let det = to_page.determinant();
if !(det.is_finite() && det.abs() > 1e-12) {
return Some(area);
}
let page = to_page.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, w, h));
let r = area.intersect(page);
(r.area() > 0.0).then_some(r)
}
/// The warning about type in `doc` that prints in the fallback font: on the layers `which` prints,
/// on pages that print `areas`.
fn fonts_warning(doc: &Document, which: PrintLayers, areas: &[Rect]) -> Option<String> {
// Placed documents print their files' art, which names the resources this adds.
let doc = doc.with_placed_art();
let mut layers = doc.layers.clone();
vectorcraft_pdf::keep_layers(&mut layers, which);
super::fonts::substitution_warning_of(super::fonts::printed_fonts(&doc, &layers, areas))
}
const PRINT: &str = "file.print";
/// Whether `file.print` writes PostScript: `format`, else a `.ps` path.
@@ -130,9 +162,13 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
// Print as Bitmap and the flattener preset (Advanced) render and flatten it first.
let prepared = super::printadvanced::prepare(PRINT, doc, &set, &s.prefs.flattener_presets, s.prefs.black_output == "rich")?;
let path = str_param(p, "path");
// Fonts are read from the type before raster effects and Print as Bitmap turn it into images.
let which = set.print_layers;
if !postscript {
let r = vectorcraft_pdf::print(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, ..Default::default() })
.map_err(|e| pdf_error(PRINT, e))?;
let printed = prepared.as_ref().unwrap_or(doc);
let fonts = fonts_warning(&full, which, &areas(&vectorcraft_pdf::preview(printed, &set).map_err(|e| pdf_error(PRINT, e))?));
let mut r = vectorcraft_pdf::print(printed, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?;
r.warnings.extend(fonts);
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()) {
@@ -175,5 +211,7 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
warnings.push(w.clone());
}
}
let areas: Vec<Rect> = plan.sheets.iter().filter_map(|sh| on_page(sh.area, sh.view * sh.place, sh.size)).collect();
warnings.extend(fonts_warning(&full, which, &areas));
write_or_return(path, &out.bytes, json!({ "pages": plan.order.len(), "format": "postscript", "warnings": warnings }))
}
+19
View File
@@ -111,3 +111,22 @@ fn raster_effects_print_as_images() {
let (bytes, _) = print(&mut s, json!({}));
assert!(String::from_utf8_lossy(&bytes).contains("/Image"));
}
#[test]
fn type_in_a_missing_family_warns_as_pdf_export_does() {
let (mut s, _) = session();
let id = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap()["id"].clone();
let fallback = |v: &Value| -> Vec<String> {
v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("fallback font")).map(str::to_string).collect()
};
let export = fallback(&s.execute("document.exportPdf", &json!({})).unwrap());
assert!(export.first().is_some_and(|w| w.contains("Nonexistent Sans Probe")), "{export:?}");
let (_, v) = print(&mut s, json!({}));
assert_eq!(fallback(&v), export);
// Type printed as an image (a raster effect, Print as Bitmap) is drawn in the fallback font too.
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
for settings in [json!({}), json!({"advanced": {"printAsBitmap": true}})] {
let (_, v) = print(&mut s, json!({ "settings": settings }));
assert_eq!(fallback(&v), export, "{settings}");
}
}
+224
View File
@@ -51,3 +51,227 @@ fn pages_place_the_artboard_on_the_trim_box() {
}
}
}
/// 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");
}
+17
View File
@@ -94,3 +94,20 @@ fn transparency_is_flattened_for_postscript() {
assert!(!v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("opaque: EPS")), "{v}");
assert_eq!(dsc(&ps, "Pages"), ["1"]);
}
#[test]
fn type_in_a_missing_family_is_named_when_its_artboard_prints() {
let mut s = session(2);
let r = s.doc().unwrap().doc.artboards[1].rect;
s.execute("text.create", &json!({"x": r.x0 + 10.0, "y": r.y0 + 50.0, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
let fallback = |v: &Value| v["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("fallback font")).count();
let (_, v) = print_ps(&mut s, json!({}));
assert_eq!(fallback(&v), 1, "{v}");
assert!(v["warnings"].to_string().contains("Nonexistent Sans Probe"), "{v}");
let (_, v) = print_ps(&mut s, json!({"settings": {"artboards": "range", "range": "1"}}));
assert_eq!(fallback(&v), 0, "{v}");
// Off the paper: 100 pt wide paper at 100% shows the middle of the 300 pt wide artboard.
let paper = json!({"artboards": "range", "range": "2", "media": "custom", "width": 100, "height": 300, "autoRotate": false});
let (_, v) = print_ps(&mut s, json!({ "settings": paper }));
assert_eq!(fallback(&v), 0, "{v}");
}
+5 -1
View File
@@ -2502,7 +2502,11 @@ documents every field:
with; without preserveNumbers CMYK colours are separated again).
Halftone screens and a fixed flatness are not written to the PDF (the output device's apply): the warnings say so,
as they do for art larger than the imageable area.
as they do for art larger than the imageable area. Type in a font family that isn't available prints in the fallback
font, and the warnings of `file.print` and `print.preview` name those families. They count only type that prints:
visible type that fits its frame or path, on the layers `printLayers` picks, on the paper of the artboards or tiles
that print, and the type in the symbols, patterns, scatter brushes, opacity masks and placed documents of the art that
prints.
```json
{"name":"run_command","arguments":{"command":"print.setup","params":{"settings":{"media":"a4","scaling":"fit","marks":{"trim":true}}}}}
+1 -1
View File
@@ -528,7 +528,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above go through each top-level layer once, checking each object that is not selected against the selected objects below it: with 100 tiles selected under 25,000 rectangles, Lock took 6.4 s and now takes under 0.1 s, and with all of 5,001 objects selected it took 8.8 s and now takes under 0.1 s.
All Artwork Above and Other Layers (Object › Lock and Object › Hide), Transform Each, Rearrange All Artboards, the width point and width profile commands, Liquify, Puppet Warp, Define Perspective Grid, Attach to Active Plane, Release with Perspective, moves and transforms in perspective, Move Plane and Move Plane to Match Object record no undo step when they change nothing.
Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing.
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). The font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux).
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font, and Print and Print Preview warn about the type that prints in it; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). The font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux).
The font database looks up a name that is neither a loaded nor an installed family among the other names of the loaded and installed fonts once for each style it is asked in, and keeps the answer until fonts are added or rescanned. Laying out a story of 1,000 paragraphs set in a font that is not installed, with 400 more fonts loaded, takes a median of 56 to 71 ms instead of 409 to 507 ms on Windows 11 (`CRAFT_FONTS_DIR=<craft-fonts checkout> cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`).
- **Files (M4):**
- PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source.