From 4b6c3d1e2c7124959e2580171665c10bebfce78c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 05:54:36 +0700 Subject: [PATCH] Fix #319: exports warn when type in a missing font is written in the fallback font Opening a PDF whose fonts aren't installed keeps the text live in the fallback font (as Illustrator substitutes and highlights missing fonts), and opening said so, but every export then wrote the fallback font silently ("warnings": []), so a headless convert re-set the type without a word. - Exports that draw type now say so in their warnings, naming the missing families and the fallback font: PDF (outlined or real text, also .ai and Save As), EPS, EMF/WMF, raster images and SVG with outlined or embedded fonts. Live SVG text, DXF, text and native files name the font itself and don't warn. Replacing the font (Find Font) clears it. - The fonts a document uses (text.fonts' scope, Package's font list) come from one helper. - textAs: outlines already keeps the glyphs the PDF draws (its embedded font program, installed or not): now covered by a test with an embedded serif font that isn't installed, checked against the installed font's and the fallback's outlines. - docs/mcp.md and ROADMAP describe the warning. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/engine/src/cmd/fileio/encode.rs | 18 ++++- crates/engine/src/cmd/fileio/pdf.rs | 1 + crates/engine/src/cmd/fileio/svg.rs | 6 ++ .../src/cmd/fileio/tests_pdffidelity.rs | 37 ++++++++++ crates/engine/src/cmd/fonts.rs | 39 +++++++++- crates/engine/src/cmd/package.rs | 20 +---- crates/pdf/src/tests_live_text.rs | 74 ++++++++++++++++--- docs/mcp.md | 6 +- 9 files changed, 169 insertions(+), 36 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9069bf1a..2a26a666 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS; see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, and the missing-font export warning (#319); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -141,7 +141,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. -- **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. +- **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). - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export. - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 941faf5b..fc0b564e 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -355,7 +355,8 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result> { } /// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet, -/// features approximated or left out; the other formats have none). +/// features approximated or left out; every format that draws type: fonts written in the fallback +/// font). pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec, Vec)> { let f = super::writable(C, Some(format), None)?; encode_all(doc, f.id, p)?.single(f) @@ -364,6 +365,19 @@ pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<( /// [`encode`], with every file the export writes. pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { let f = super::writable(C, Some(format), None)?; + let mut enc = encode_files(doc, f, p)?; + // Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so + // themselves: SVG only when it outlines or embeds fonts). + if (f.raster || matches!(f.id, "eps" | "emf" | "wmf")) + && let Some(w) = crate::cmd::fonts::substitution_warning(doc) + { + enc.warnings.push(w); + } + Ok(enc) +} + +/// [`encode_all`] without the font substitution warning. +fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { let doc = &*doc.without_edit_modes(); let n = doc.artboards.len(); // SVG reads its own `useArtboards` (an SVG option). @@ -374,7 +388,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { if let Some(o) = q.as_object_mut() { o.remove("useArtboards"); } - return encode_all(&single_artboard(doc, bounds, "Art"), f.id, &q); + return encode_files(&single_artboard(doc, bounds, "Art"), f, &q); } let bytes = match f.id { "vectorcraft" => super::native::encode(C, f, doc, p)?, diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index d87da6e9..409e5847 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -276,6 +276,7 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl } 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)) } diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 4dc3e970..49a05878 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -166,5 +166,11 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result> >>".into(), + ..PdfPage::new(200.0, 100.0, "BT /F1 12 Tf 10 50 Td (Hello) Tj ET") + }; + let mut s = Session::new(); + let r = open(&mut s, &pdf(&[page], None), json!({})).unwrap(); + let fonts = s.execute("text.fonts", &json!({})).unwrap(); + let family = fonts[0]["family"].as_str().unwrap().to_string(); + assert_eq!(fonts[0]["status"], "missing", "{fonts}"); + assert!(r["warnings"].to_string().contains(&family), "opening lists it: {r}"); + let warned = |s: &mut Session, p: Value| { + let w = s.execute("document.serialize", &p).unwrap()["warnings"].to_string(); + w.contains(&family) && w.contains(vectorcraft_text::FALLBACK_FAMILY) + }; + for p in [ + json!({"format": "pdf"}), + json!({"format": "pdf", "advanced": {"outlineText": false}}), + json!({"format": "png"}), + json!({"format": "eps"}), + json!({"format": "emf"}), + json!({"format": "svg", "outlineText": true}), + json!({"format": "svg", "embedFonts": true}), + ] { + assert!(warned(&mut s, p.clone()), "{p}"); + } + // Live SVG type and the native file name the font itself. + for p in [json!({"format": "svg", "outlineText": false}), json!({"format": "vectorcraft"}), json!({"format": "txt"})] { + assert!(!warned(&mut s, p.clone()), "{p}"); + } + let pdf_export = s.execute("document.exportPdf", &json!({})).unwrap(); + assert!(pdf_export["warnings"].to_string().contains(&family), "{pdf_export}"); + s.execute("text.replaceFont", &json!({"from": {"family": family}, "to": {"family": "Source Serif 4"}})).unwrap(); + assert!(!warned(&mut s, json!({"format": "pdf"})), "replaced: nothing is substituted"); +} diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index fc15d370..7e1f9c16 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -2,10 +2,10 @@ //! another everywhere or in the selection, and selecting the text that uses a font. Also the fonts //! available (the bundled and installed ones) and rescanning the installed fonts. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeId, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::typecmd::refresh_bounds; use super::*; @@ -85,6 +85,41 @@ fn scope(s: &Session, selection_only: bool) -> Result> { Ok(v) } +/// The fonts (family, style) of the type in the layers and symbols of `d`. +pub(super) fn used_fonts(d: &Document) -> BTreeSet<(String, String)> { + let mut fonts = BTreeSet::new(); + let mut add = |n: &Node| { + if let NodeKind::Text(t) = &n.kind { + fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone()))); + } + }; + d.walk(&mut add); + for s in &d.symbols { + s.art.walk(&mut add); + } + fonts +} + +/// 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 { + let db = vectorcraft_text::FontDb::global(); + let mut missing: Vec = used_fonts(d) + .into_iter() + .filter(|(family, style)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing)) + .map(|(family, _)| family) + .collect(); + // Sorted by family: its styles are neighbours. + missing.dedup(); + (!missing.is_empty()).then(|| { + format!( + "fonts that aren't available were written in the fallback font, {}: {} (install them, or replace them with Find Font)", + vectorcraft_text::FALLBACK_FAMILY, + missing.join(", ") + ) + }) +} + fn text(d: &Document, id: NodeId) -> Option<&vectorcraft_doc::TextObject> { match d.node(id).map(|n| &n.kind) { Some(NodeKind::Text(t)) => Some(t), diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index f21fbede..876853f9 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -4,12 +4,11 @@ //! report). The packaged document links to the copies (Relink); the open document doesn't change. //! Without a folder (the web, agents) the same files come back as a zip archive. -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, HashSet}; use std::io::Write; use std::path::Path; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, Node, NodeKind}; use super::fileio::{create_dir, write_file}; use super::*; @@ -51,21 +50,6 @@ fn font_ext(bytes: &[u8]) -> &'static str { } } -/// The fonts (family, style) of the type in the layers and symbols of `d`. -fn used_fonts(d: &Document) -> BTreeSet<(String, String)> { - let mut fonts = BTreeSet::new(); - let mut add = |n: &Node| { - if let NodeKind::Text(t) = &n.kind { - fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone()))); - } - }; - d.walk(&mut add); - for s in &d.symbols { - s.art.walk(&mut add); - } - fonts -} - /// The options of a package. struct Options { copy_links: bool, @@ -123,7 +107,7 @@ fn package(s: &mut Session, p: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); let mut files = HashSet::new(); lines.push("FONTS".to_string()); - for (family, style) in used_fonts(&st.doc) { + for (family, style) in super::fonts::used_fonts(&st.doc) { let font = format!("{family} {style}"); let reason = match db.face(&family, &style) { Some(f) if f.family.eq_ignore_ascii_case(&family) && f.embeddable() => { diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests_live_text.rs index b86dfd27..32ebc975 100644 --- a/crates/pdf/src/tests_live_text.rs +++ b/crates/pdf/src/tests_live_text.rs @@ -3,11 +3,13 @@ use krilla::geom::{Point as KPoint, Transform}; use krilla::page::PageSettings; use krilla::text::{Font, TextDirection}; +use kurbo::Shape; use vectorcraft_doc::{Document, NodeKind, TextObject}; use crate::*; const SOURCE_SANS: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); +const SOURCE_SERIF: &[u8] = include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf"); /// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3, and the /// same rotated 90° when `rotated`. @@ -157,21 +159,73 @@ fn a_glyph_whose_unicode_names_another_letter_stays_outlined() { assert!(r.warnings.iter().any(|w| w.contains("differ from the installed font")), "{:?}", r.warnings); } -/// Source Sans 3 renamed `SourceSans9-Regular` (a font no machine has installed). -fn uninstalled_font() -> Vec { - let mut bytes = SOURCE_SANS.to_vec(); - let utf16: Vec = "SourceSans3-Regular".encode_utf16().flat_map(u16::to_be_bytes).collect(); - let renamed16: Vec = "SourceSans9-Regular".encode_utf16().flat_map(u16::to_be_bytes).collect(); - for (from, to) in [(b"SourceSans3-Regular".to_vec(), b"SourceSans9-Regular".to_vec()), (utf16, renamed16)] { - let mut i = 0; - while let Some(at) = bytes[i..].windows(from.len()).position(|w| w == from.as_slice()) { - bytes[i + at..i + at + from.len()].copy_from_slice(&to); - i += at + from.len(); +/// `font` with each of `names` (`from`, `to`, the same length) replaced, in ASCII and UTF-16. +fn renamed(font: &[u8], names: &[(&str, &str)]) -> Vec { + let mut bytes = font.to_vec(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for &(from, to) in names { + assert_eq!(from.len(), to.len()); + for (from, to) in [(from.as_bytes().to_vec(), to.as_bytes().to_vec()), (utf16(from), utf16(to))] { + let mut i = 0; + while let Some(at) = bytes[i..].windows(from.len()).position(|w| w == from.as_slice()) { + bytes[i + at..i + at + from.len()].copy_from_slice(&to); + i += at + from.len(); + } } } bytes } +/// Source Sans 3 renamed `SourceSans9-Regular` (a font no machine has installed). +fn uninstalled_font() -> Vec { + renamed(SOURCE_SANS, &[("SourceSans3-Regular", "SourceSans9-Regular")]) +} + +/// Source Serif 4 renamed `SourceSerif9-Regular`, family Source Serif 9: a serif font that isn't +/// installed (the fallback font, Source Sans 3, is a sans). +fn uninstalled_serif() -> Vec { + renamed(SOURCE_SERIF, &[("SourceSerif4", "SourceSerif9"), ("Source Serif 4", "Source Serif 9")]) +} + +/// A page with `text` in `font` (24 pt, at 20, 50). +fn one_line_pdf(font: Vec, text: &str) -> Vec { + let mut pdf = krilla::Document::new(); + let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap()); + let mut s = page.surface(); + s.draw_text(KPoint::from_xy(20.0, 50.0), Font::new(font.into(), 0).unwrap(), 24.0, text, false, TextDirection::Auto); + s.finish(); + page.finish(); + pdf.finish().unwrap() +} + +/// The outlines text imported as (`textAs: outlines`), as one path. +fn outlines(d: &Document) -> kurbo::BezPath { + let mut all = kurbo::BezPath::new(); + d.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + all.extend(path.to_bezpath().iter()); + } + }); + all +} + +#[test] +fn outlined_text_in_a_missing_font_keeps_the_embedded_glyphs() { + let outlined = |bytes: &[u8]| { + let r = import_with_report(bytes, &ImportOptions { text_as: TextAs::Outlines, ..Default::default() }).unwrap(); + assert!(texts(&r.document).is_empty()); + outlines(&r.document) + }; + let missing = outlined(&one_line_pdf(uninstalled_serif(), "Serif Hamburg")); + let installed = outlined(&one_line_pdf(SOURCE_SERIF.to_vec(), "Serif Hamburg")); + let fallback = outlined(&one_line_pdf(SOURCE_SANS.to_vec(), "Serif Hamburg")); + let (m, i, f) = (missing.bounding_box(), installed.bounding_box(), fallback.bounding_box()); + assert!(m.width() > 50.0, "{m:?}"); + let near = |a: kurbo::Rect, b: kurbo::Rect| (a.x0 - b.x0).abs() + (a.x1 - b.x1).abs() + (a.y0 - b.y0).abs() + (a.y1 - b.y1).abs() < 0.01; + assert!(near(m, i), "the file's own serif glyphs: {m:?} vs {i:?}"); + assert!(!near(m, f), "not the fallback font's: {m:?} vs {f:?}"); +} + #[test] fn text_in_a_missing_font_stays_live_type() { for stroke_only in [false, true] { diff --git a/docs/mcp.md b/docs/mcp.md index b10b3848..5dd2b2d5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -370,8 +370,10 @@ What a PDF holds comes in as editable art: soft masks become opacity masks (an a the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout, tiling patterns become pattern swatches, patch and triangle mesh shadings become gradient meshes, and gradients keep their stop opacity and stop where the shading doesn't extend. Text becomes point type, one object per run of a line in -the file's font (by name; fonts that aren't available are listed in `warnings` and show in the fallback font) — -`textAs: "outlines"` keeps glyph outlines instead. Optional content groups (the layers of PDF and PDF-compatible `.ai` +the file's font (by name; fonts that aren't available are listed in `warnings` and show in the fallback font, and +every export that draws that type — PDF, EPS, EMF/WMF, raster images, SVG with outlined or embedded fonts — says in +its `warnings` that it wrote the fallback font) — `textAs: "outlines"` keeps the glyph outlines the file draws instead +(its embedded fonts, installed or not). Optional content groups (the layers of PDF and PDF-compatible `.ai` files) become layers with their name, visibility, print state and lock, art that is off coming in as a hidden layer; art outside them goes to a layer per page (except the opaque white page a `.ai` paints under its layers, which isn't art). `layers: false` gives one layer per page of only what shows: