From 27ab77377b02f23f104ab2826b257f302d12818b Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Wed, 4 Nov 2026 00:27:13 +0430
Subject: [PATCH 01/15] Fix #3626: an imported document takes the title the
file carries
An SVG's root
and a PDF's XMP dc:title (else Info Title) title the imported document; a file that gives none, or only the Untitled-N name, takes its file name without the extension. Native files and documents restored from editing data keep their own title.
---
crates/engine/src/cmd/fileinfo.rs | 12 +++
crates/engine/src/cmd/fileio.rs | 1 +
crates/engine/src/cmd/fileio/formats.rs | 7 +-
crates/engine/src/cmd/fileio/load.rs | 22 ++++--
crates/engine/src/cmd/fileio/tests.rs | 3 +-
.../engine/src/cmd/fileio/tests_dxfimport.rs | 6 +-
.../engine/src/cmd/fileio/tests_epsimport.rs | 2 +-
crates/engine/src/cmd/fileio/tests_svg.rs | 2 +-
crates/engine/src/tests/fileinfo.rs | 73 ++++++++++++++++++-
crates/pdf/src/file_info.rs | 4 +
crates/pdf/src/tests/file_info.rs | 16 +++-
crates/svg/src/import.rs | 7 +-
crates/svg/src/import/layers.rs | 10 +++
docs/file-format-parity.md | 3 +-
docs/gaps.md | 3 +-
docs/mcp.md | 17 +++--
docs/target-app-parity.md | 5 +-
17 files changed, 167 insertions(+), 26 deletions(-)
diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs
index 36ccd7cdb0..3a37098b48 100644
--- a/crates/engine/src/cmd/fileinfo.rs
+++ b/crates/engine/src/cmd/fileinfo.rs
@@ -141,6 +141,18 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result {
Ok(s.to_string())
}
+/// The title `raw`, read from a file, makes a document: runs of white space (line breaks too)
+/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters
+/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the
+/// Untitled-N name New gives (what a document that never had a title writes to its files).
+pub(crate) fn imported_title(raw: &str) -> Option {
+ let words: Vec =
+ raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::()).filter(|w| !w.is_empty()).collect();
+ let title: String = words.join(" ").chars().take(MAX_TEXT).collect();
+ let title = title.trim_end();
+ super::fileio::is_own_title(title).then(|| title.to_string())
+}
+
/// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring
/// case), empty ones dropped.
fn keywords(v: &Value) -> Result> {
diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs
index 76f408d221..cbc8506fca 100644
--- a/crates/engine/src/cmd/fileio.rs
+++ b/crates/engine/src/cmd/fileio.rs
@@ -125,6 +125,7 @@ pub use commands::specs;
pub use export_names::artboard_file_names;
pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return};
use export_names::{distinct_names_by, write_encoded};
+pub(crate) use formats::is_own_title;
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable};
pub use formats::{
DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension,
diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs
index 09e8de8294..30778f6a6e 100644
--- a/crates/engine/src/cmd/fileio/formats.rs
+++ b/crates/engine/src/cmd/fileio/formats.rs
@@ -778,7 +778,12 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
/// the stored title.
pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool {
- !doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
+ is_own_title(&doc.title)
+}
+
+/// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives?
+pub(crate) fn is_own_title(title: &str) -> bool {
+ !title.trim().is_empty() && !title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option {
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index a318616d54..8a646a8e91 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -7,9 +7,10 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_render::effects::pixel::LoadedTexture;
+use super::super::fileinfo::imported_title;
use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param};
use super::pdfimport::LoadOptions;
-use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, psdread, read_file, readable_format_for_name};
+use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, file_stem, format, psdread, read_file, readable_format_for_name};
use crate::cmd::links::Resolved;
use crate::{EngineError, Prefs};
@@ -392,10 +393,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
};
// A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document
// it carries, opens with the File Info it gives (a PostScript `.ai` gives none).
+ let mut pdf_title = None;
if matches!(format.id, "pdf" | "ai" | "ait")
&& !restored
&& let Some(info) = vectorcraft_pdf::file_info(bytes, opts.password.as_deref())
{
+ pdf_title = info.title.clone();
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
}
// Threaded type read from a file flows through its frames (the threaded type of a restored
@@ -412,11 +415,18 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
- // Imports are named after the file; a native document keeps its own title (the tab shows the
- // file name once it has a path).
- if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
- doc.title = title;
- }
+ // The document is titled as the file titles it: a native document keeps its own title, one
+ // restored from the editing data a file carries and an SVG keep the title they hold (the SVG's
+ // ``), a PDF, `.ai` or `.ait` takes its XMP `dc:title` or Info `Title`. Without one the
+ // document takes the file name without its extension, as an export does (#3626). The tab shows
+ // the file name once the document has a path.
+ let own = match format.id {
+ "vectorcraft" | "template" => Some(doc.title.clone()).filter(|t| !t.is_empty()),
+ _ if restored || format.id == "svg" => imported_title(&doc.title),
+ "pdf" | "ai" | "ait" => pdf_title.as_deref().and_then(imported_title),
+ _ => None,
+ };
+ doc.title = own.unwrap_or_else(|| file_stem(&title));
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
}
diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs
index 69158c04b8..d3714e82e8 100644
--- a/crates/engine/src/cmd/fileio/tests.rs
+++ b/crates/engine/src/cmd/fileio/tests.rs
@@ -67,7 +67,8 @@ fn opens_every_readable_format() {
] {
let r = open(&mut s, name, &bytes);
assert_eq!(r["format"], format, "{name}");
- assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}");
+ // The document that wrote the file never had a title of its own, so the file gives none and the name (without its extension) titles it.
+ assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { "a" }, "{name}");
assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through");
}
for (name, kind) in [
diff --git a/crates/engine/src/cmd/fileio/tests_dxfimport.rs b/crates/engine/src/cmd/fileio/tests_dxfimport.rs
index ec7b295c23..c7de9d9e16 100644
--- a/crates/engine/src/cmd/fileio/tests_dxfimport.rs
+++ b/crates/engine/src/cmd/fileio/tests_dxfimport.rs
@@ -120,7 +120,11 @@ const MM: f64 = 72.0 / 25.4;
fn document_open_reads_a_dxf_drawing() {
let mut s = Session::new();
let r = open(&mut s, &plan(), json!({})).unwrap();
- assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("dxf"), Some("plan.dxf")));
+ assert_eq!(
+ (r["format"].as_str(), r["title"].as_str()),
+ (Some("dxf"), Some("plan")),
+ "a file that carries no title opens as its name without the extension"
+ );
assert_eq!(layer_names(&s), ["Walls", "Notes"]);
let st = s.doc().unwrap();
// Millimetres at 1:1, the artboard the art's size.
diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs
index 8c4141902c..0e9b8e805f 100644
--- a/crates/engine/src/cmd/fileio/tests_epsimport.rs
+++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs
@@ -64,7 +64,7 @@ fn other_eps_files_are_read_by_the_postscript_interpreter() {
// The page is the bounding box: the art's bounds.
assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(101.0, 51.0), "the stroke's half width rounds up to whole points");
assert_eq!(objects(&s), 1);
- assert_eq!(s.doc().unwrap().title(), "plain.eps");
+ assert_eq!(s.doc().unwrap().title(), "plain");
}
/// A CMYK image the PostScript interpreter reads keeps its ink amounts when the EPS opens and when
diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs
index 73ee604a98..ff3a73432c 100644
--- a/crates/engine/src/cmd/fileio/tests_svg.rs
+++ b/crates/engine/src/cmd/fileio/tests_svg.rs
@@ -138,7 +138,7 @@ fn preserve_editing_reopens_the_native_document() {
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.artboards.len(), 2, "both artboards came back");
assert_eq!(doc.artboards[1].name, "Back");
- assert_eq!(doc.title, "both.svg");
+ assert_eq!(doc.title, "both");
// Without it the SVG is imported (one artboard).
let text = svg(&mut s, json!({}));
s.execute("document.open", &json!({"name": "plain.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes())})).unwrap();
diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs
index c37cbfe90d..905cf910f3 100644
--- a/crates/engine/src/tests/fileinfo.rs
+++ b/crates/engine/src/tests/fileinfo.rs
@@ -161,7 +161,7 @@ fn an_exported_pdf_opens_with_its_file_info() {
assert_eq!(back["description"], "A poster\nfor the fair");
assert_eq!(back["keywords"], json!(["fair", "Poster", "print"]));
assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]);
- assert_eq!(back["title"], "poster.pdf", "the title stays the file name");
+ assert_eq!(back["title"], "Poster", "the title is the one the PDF carries, not its file name");
assert_eq!(back["copyrightStatus"], "copyrighted");
assert_eq!(back["copyrightNotice"], "(c) 2026 Ada");
assert_eq!(back["copyrightUrl"], "https://example.com/rights");
@@ -277,6 +277,77 @@ fn a_pdf_compatible_ai_opens_with_the_file_info_of_its_xmp_packet() {
assert_eq!(m.copyright_url, "");
}
+/// A one-page PDF whose trailer names an information dictionary with the entries `info`, and whose
+/// catalog has a metadata stream holding an XMP `dc:title` of `xmp_title`.
+fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec {
+ use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
+ let first = first_extra(1);
+ let mut objects = vec![];
+ let mut catalog = String::new();
+ if let Some(t) = xmp_title {
+ let xmp = format!(
+ "\
+ \
+ Zugangswege {t} \
+ "
+ );
+ objects.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()));
+ catalog = format!("/Metadata {first} 0 R ");
+ }
+ let info_at = first + objects.len();
+ if let Some(i) = info {
+ objects.push(format!("<< {i} >>"));
+ }
+ let refs: Vec<&str> = objects.iter().map(String::as_str).collect();
+ let bytes = pdf_with_catalog(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &refs, &catalog, None);
+ if info.is_none() {
+ return bytes;
+ }
+ // The trailer follows the cross-reference table, so a longer trailer moves no object.
+ String::from_utf8(bytes).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes()
+}
+
+/// The title `document.open` gives a file called `name` with content `bytes`.
+fn opened_title(name: &str, bytes: &[u8]) -> String {
+ let mut s = Session::new();
+ let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
+ assert_eq!(r["fileInfoTitle"], s.doc().unwrap().doc.title.as_str());
+ s.doc().unwrap().doc.title.clone()
+}
+
+#[test]
+fn import_uses_the_files_own_title() {
+ // SVG: the root's ``, its white space made single spaces; not a shape's tooltip.
+ let svg = |inner: &str| {
+ format!(
+ "{inner}Tooltip "
+ )
+ };
+ assert_eq!(opened_title("box.svg", svg("\n Access\n paths ").as_bytes()), "Access paths");
+ assert_eq!(
+ opened_title("box.svg", svg("Zugangswege Rechenzentrum – Prüfdiagramm ").as_bytes()),
+ "Zugangswege Rechenzentrum – Prüfdiagramm"
+ );
+ // PDF: the XMP `dc:title` (its `x-default` item) beats the Info `Title`; the Info `Title` stands alone.
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), "XMP title");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(None, Some("XMP title"))), "XMP title");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), "Info title");
+ assert_eq!(opened_title("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), "Info title");
+ // A file that gives none (or a blank one): its name without the extension, in a folder too.
+ assert_eq!(opened_title("box.svg", svg("").as_bytes()), "box");
+ assert_eq!(opened_title("/work/box.svg", svg(" ").as_bytes()), "box");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(None, None)), "plan");
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), "plan");
+ // The Untitled-N name New gives is no title (a document that never had one writes it).
+ assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), "plan");
+ // A native file keeps the title it has.
+ let mut s = session();
+ let native = vectorcraft_format::base64_encode(&b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()));
+ let mut t = Session::new();
+ t.execute("document.open", &json!({"name": "other.vectorcraft", "dataBase64": native})).unwrap();
+ assert_eq!(t.doc().unwrap().doc.title, "Poster");
+}
+
#[test]
fn png_exports_carry_text_chunks() {
let mut s = session();
diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs
index 983a7dcea7..1cb0a45273 100644
--- a/crates/pdf/src/file_info.rs
+++ b/crates/pdf/src/file_info.rs
@@ -24,6 +24,8 @@ const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace
/// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FileInfo {
+ /// XMP `dc:title` (its `x-default` item, else its first), else Info `Title`.
+ pub title: Option,
/// XMP `dc:creator` (its names joined with ", "), else Info `Author`.
pub author: Option,
/// XMP `dc:description` (its `x-default` item, else its first), else Info `Subject`.
@@ -53,6 +55,7 @@ pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo {
let mut f = packet(pdf).and_then(|p| xmp(&p)).unwrap_or_default();
let info = pdf.metadata();
let text = |b: &Option>| b.as_deref().map(text_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
+ f.title = f.title.or_else(|| text(&info.title));
f.author = f.author.or_else(|| text(&info.author));
f.description = f.description.or_else(|| text(&info.subject));
f.keywords = f.keywords.or_else(|| text(&info.keywords).map(|k| split(&k)).filter(|k| !k.is_empty()));
@@ -90,6 +93,7 @@ fn xmp(packet: &[u8]) -> Option {
let creators = get(DC, "creator");
let subjects = get(DC, "subject");
Some(FileInfo {
+ title: first(DC, "title"),
author: (!creators.is_empty()).then(|| creators.join(", ")),
description: first(DC, "description"),
keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) },
diff --git a/crates/pdf/src/tests/file_info.rs b/crates/pdf/src/tests/file_info.rs
index 7ce2f42f47..f7c5d694b6 100644
--- a/crates/pdf/src/tests/file_info.rs
+++ b/crates/pdf/src/tests/file_info.rs
@@ -45,7 +45,7 @@ fn description(attrs: &str, body: &str) -> String {
}
const INFO: &str = "/Author (Grace Hopper) /Subject /Keywords (navy; compilers, , cobol) \
- /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Ignored title)";
+ /CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Compiler manual)";
/// Unix seconds of 2026-10-05 12:03:00 UTC (14:03 at +02:00).
const CREATED: i64 = 1_791_201_780;
@@ -58,6 +58,7 @@ fn the_information_dictionary_gives_file_info() {
assert_eq!(
f,
FileInfo {
+ title: Some("Compiler manual".into()),
author: Some("Grace Hopper".into()),
description: Some("Cata log".into()),
keywords: Some(vec!["navy".into(), "compilers".into(), "cobol".into()]),
@@ -79,7 +80,8 @@ fn the_information_dictionary_gives_file_info() {
fn the_xmp_packet_gives_file_info() {
let body = description(
"xmpRights:Marked=\"False\" xmp:ModifyDate=\"2026-10-06T09:00:00.25Z\"",
- "Ada Lovelace Charles Babbage \
+ "Plakat Poster \
+ Ada Lovelace Charles Babbage \
Ein Plakat A poster\nfor the fair \
fair print \
(c) 2026 Ada \
@@ -90,6 +92,7 @@ fn the_xmp_packet_gives_file_info() {
assert_eq!(
f,
FileInfo {
+ title: Some("Poster".into()),
author: Some("Ada Lovelace, Charles Babbage".into()),
description: Some("A poster\nfor the fair".into()),
keywords: Some(vec!["fair".into(), "print".into()]),
@@ -138,6 +141,13 @@ fn the_xmp_packet_wins_where_both_give_a_field() {
assert_eq!(f.created, Some(1_791_331_200 + 5 * 3600 + 30 * 60));
assert_eq!(f.description.as_deref(), Some("Cata log"));
assert_eq!(f.modified, Some(MODIFIED));
+ // The title is the packet's where it gives one (a blank `x-default` item leaves the next one),
+ // else the dictionary's.
+ assert_eq!(f.title.as_deref(), Some("Compiler manual"));
+ let body =
+ description("", " Manuel ");
+ let f = file_info(&pdf(Some(INFO), Some(&packet(&body))), None).unwrap();
+ assert_eq!(f.title.as_deref(), Some("Manuel"));
}
#[test]
@@ -197,11 +207,13 @@ fn an_xmp_packet_nested_too_deep_leaves_the_information_dictionary() {
#[test]
fn a_written_pdf_gives_its_file_info_back() {
let mut d = Document::new(100.0, 100.0);
+ d.title = "Fair poster".into();
d.metadata.author = "Ada Lovelace".into();
d.metadata.description = "A poster\nfor the fair".into();
d.metadata.keywords = vec!["fair".into(), "print".into()];
let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap();
let f = file_info(&bytes, None).unwrap();
+ assert_eq!(f.title.as_deref(), Some("Fair poster"));
assert_eq!(f.author.as_deref(), Some("Ada Lovelace"));
assert_eq!(f.description.as_deref(), Some("A poster\nfor the fair"));
assert_eq!(f.keywords, Some(vec!["fair".to_string(), "print".into()]));
diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs
index 11795b83fa..aa625de695 100644
--- a/crates/svg/src/import.rs
+++ b/crates/svg/src/import.rs
@@ -38,7 +38,7 @@ pub(super) use importer::PT_PER_IN;
use importer::SymbolMade;
pub(super) use layers::DEFAULT_FONT_SIZE;
pub(crate) use layers::SUBLAYER;
-use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name};
+use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name, root_title};
pub(super) use prepass::Edits;
pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE};
use prepass::{labels, prepass};
@@ -142,6 +142,11 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul
let k = units.k;
let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k);
doc.units = units.unit;
+ // The file's own title, when it gives one; else the document keeps the placeholder name
+ // `Document::new` gave it, for the caller to name after the file.
+ if let Some(title) = root_title(xml.root_element()) {
+ doc.title = title;
+ }
let mut im = Importer {
doc,
warnings,
diff --git a/crates/svg/src/import/layers.rs b/crates/svg/src/import/layers.rs
index 04e3887945..6115d2e445 100644
--- a/crates/svg/src/import/layers.rs
+++ b/crates/svg/src/import/layers.rs
@@ -16,6 +16,16 @@ pub(super) fn root_layer_name(root: XNode) -> Option {
root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string)
}
+/// The title the file gives root element `root`: the text of its first `` child (the one
+/// SVG 1.1 §5.4 and SVG 2 §5.4 give a document or element; a `` inside a shape or group is
+/// that element's tooltip, not the document's), its runs of white space made single spaces. `None`
+/// without one, or when it is blank.
+pub(super) fn root_title(root: XNode) -> Option {
+ let title = root.children().find(|c| c.is_element() && c.tag_name().name() == "title" && c.tag_name().namespace() == Some(super::SVG_NS))?;
+ let text: Vec<&str> = title.descendants().filter_map(|n| n.text().filter(|_| n.is_text())).flat_map(str::split_whitespace).collect();
+ (!text.is_empty()).then(|| text.join(" "))
+}
+
/// Is element `g` a `` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or
/// the label beside it) is its name.
pub(super) fn marked_layer(g: XNode) -> bool {
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 6f02703ca5..ba1d1738e4 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File format parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -161,6 +161,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
+| 2026-11-03 | minor | An imported document takes the title its file carries: an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`; a file that gives none opens with its file name without the extension (#3626). |
| 2026-11-01 | minor | Native format: a symbol's Symbol Type (`dynamic`) and a dynamic symbol instance's appearance changes to objects of its art (`symbol_overrides`) round-trip; both are left out when unset, so other documents save unchanged (#4938). `.ai` reading and writing of dynamic symbols remains. |
| 2026-11-01 | minor | Exports draw each artboard's background color behind its art (#4937): PNG, JPEG, GIF, TIFF, WebP, BMP, Targa and PSD (a layer of its own; on a transparent background too), PDF, SVG and EPS pages, a `.ai` file's PDF pages and PostScript and PDF print; `includeArtboardFill: false` leaves it out. Files with editing data (Preserve Editing, `.ai`, EPS, native) keep it as the artboard's color and reopen without extra art. Not read from the reference app's own `.ai` data, nor written to it. DXF, EMF and WMF leave it out |
| 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged (estimated) |
diff --git a/docs/gaps.md b/docs/gaps.md
index 8b707fd9f6..016703c688 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Gaps
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -732,6 +732,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
+| 2026-11-03 | minor | An imported document takes the title its file carries (an SVG's root ``, a PDF's XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); no scored gap or estimate reassessed. |
| 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. |
| 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. |
| 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. |
diff --git a/docs/mcp.md b/docs/mcp.md
index 0ca4e32353..4911590619 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -3908,13 +3908,16 @@ They also write the `copyrightNotice` as `dc:rights`, the `copyrightUrl` as `xmp
`copyrightStatus` as `xmpRights:Marked` (`True` for copyrighted, `False` for public domain, none for unknown) to the
XMP packet.
A PDF, `.ai` or `.ait` opened from its pages or its editing copy (`document.open`,
-`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `author`,
-`description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` that its XMP
-packet (`dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, `dc:rights`,
-`xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary (`Author`,
-`Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give one. A value that
-`file.info` would refuse is left out, and the title is set as for any other open (the file name, or a new untitled
-name for a template).
+`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `title`,
+`author`, `description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified`
+that its XMP packet (`dc:title`, `dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`,
+`dc:rights`, `xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary
+(`Title`, `Author`, `Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give
+one (for `dc:title` and `dc:description`, the `x-default` language item first). An SVG opens with the `title` of its
+root `` element (not a shape's or group's). A title is read with its white space made single spaces and
+without control characters, at most 65,536 characters, as `file.info` takes one. A file that gives no title, a blank
+one or the `Untitled-N` name New gives, opens with its file name without the extension (`box.svg` opens as `box`), and a native file keeps its own
+title. A value that `file.info` would refuse is left out, and a template opens with a new untitled name.
`document.inspect` (`inspect_document`) and the `document.open` (`open_file`) reply report the File Info title as
`fileInfoTitle`. Their `title` is the document's tab name, which comes from the file name once the document is saved
to or opened from a file (with ` [Converted]` or ` [Recovered]` where that applies).
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index b4370a3a0c..b544fdf895 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -1172,7 +1172,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`).
- Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged.
- Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.
- - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`).
+ - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root ``; a file that gives no title opens with its file name without the extension, and a native file keeps its own title (#3626).
- PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests/fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/marks.rs`).
- PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`).
- Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`).
@@ -1565,6 +1565,7 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente
| Date | Change | Summary |
|---|---|---|
+| 2026-11-03 | minor | File Info on open: an imported document takes the title its file carries (an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); scores and estimates unchanged. |
| 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. |
| 2026-11-02 | minor | Wheel input over closed font-family fields changes selected text (#5253); public documentation and application choices distinguished; scores and estimates unchanged. |
| 2026-11-03 | minor | Find Font's replacement-source chooser offers Document, Recent and System (#5217); scores and estimates unchanged. |
From 15993912dee1c8960a0618b3314d3b0ad9c40686 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Wed, 4 Nov 2026 03:22:46 +0430
Subject: [PATCH 02/15] Fix #3626: .svgz keeps its title and the file-name
fallback is cleaned (review)
---
crates/engine/src/cmd/fileinfo.rs | 17 +++++++++++++++--
crates/engine/src/cmd/fileio/load.rs | 6 +++---
crates/engine/src/tests/fileinfo.rs | 22 ++++++++++++++++++++++
3 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs
index 3a37098b48..e805757b12 100644
--- a/crates/engine/src/cmd/fileinfo.rs
+++ b/crates/engine/src/cmd/fileinfo.rs
@@ -146,11 +146,24 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result {
/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the
/// Untitled-N name New gives (what a document that never had a title writes to its files).
pub(crate) fn imported_title(raw: &str) -> Option {
+ let title = cleaned_title(raw);
+ super::fileio::is_own_title(&title).then_some(title)
+}
+
+/// `raw` as `file.info` takes a title (see [`imported_title`]), empty when nothing is left. An
+/// Untitled-N name stays.
+fn cleaned_title(raw: &str) -> String {
let words: Vec =
raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::()).filter(|w| !w.is_empty()).collect();
let title: String = words.join(" ").chars().take(MAX_TEXT).collect();
- let title = title.trim_end();
- super::fileio::is_own_title(title).then(|| title.to_string())
+ title.trim_end().to_string()
+}
+
+/// The title a document without one of its own takes from its file name's `stem`: cleaned as an
+/// imported title is, so `file.info` takes it, and the Untitled-1 name a new document starts
+/// with when nothing is left of it.
+pub(crate) fn stem_title(stem: &str) -> String {
+ Some(cleaned_title(stem)).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled-1".to_string())
}
/// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index 8a646a8e91..ac46299de5 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -7,7 +7,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_render::effects::pixel::LoadedTexture;
-use super::super::fileinfo::imported_title;
+use super::super::fileinfo::{imported_title, stem_title};
use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param};
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, file_stem, format, psdread, read_file, readable_format_for_name};
@@ -422,11 +422,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
// the file name once the document has a path.
let own = match format.id {
"vectorcraft" | "template" => Some(doc.title.clone()).filter(|t| !t.is_empty()),
- _ if restored || format.id == "svg" => imported_title(&doc.title),
+ _ if restored || matches!(format.id, "svg" | "svgz") => imported_title(&doc.title),
"pdf" | "ai" | "ait" => pdf_title.as_deref().and_then(imported_title),
_ => None,
};
- doc.title = own.unwrap_or_else(|| file_stem(&title));
+ doc.title = own.unwrap_or_else(|| stem_title(&file_stem(&title)));
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
}
diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs
index 905cf910f3..4cb511b99c 100644
--- a/crates/engine/src/tests/fileinfo.rs
+++ b/crates/engine/src/tests/fileinfo.rs
@@ -348,6 +348,28 @@ fn import_uses_the_files_own_title() {
assert_eq!(t.doc().unwrap().doc.title, "Poster");
}
+#[test]
+fn compressed_svg_keeps_its_title() {
+ let svgz = |inner: &str| {
+ vectorcraft_svg::compress(&format!(
+ "{inner} "
+ ))
+ };
+ assert_eq!(opened_title("box.svgz", &svgz("Access paths ")), "Access paths");
+ assert_eq!(opened_title("box.svgz", &svgz("")), "box");
+}
+
+#[test]
+fn file_name_title_is_cleaned_like_an_imported_one() {
+ let svg = b" ";
+ // A name `file.info` would reject (white space only) or that keeps control characters.
+ assert_eq!(opened_title(" .svg", svg), "Untitled-1");
+ assert_eq!(opened_title("\u{1}\u{7f}.svg", svg), "Untitled-1");
+ assert_eq!(opened_title("bo\u{1}x two\t.svg", svg), "box two");
+ // The Untitled-N name stays the stem.
+ assert_eq!(opened_title("Untitled-7.svg", svg), "Untitled-7");
+}
+
#[test]
fn png_exports_carry_text_chunks() {
let mut s = session();
From 99be3dca579c9de2c237445d038326372a874ac9 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Wed, 4 Nov 2026 10:54:36 +0430
Subject: [PATCH 03/15] Fix #3626: the imported title goes into File Info only;
the display name is unchanged
An SVG's root and a PDF's, .ai's or .ait's XMP dc:title (else Info Title) are now
DocMetadata::title, the File Info title, not Document::title. Document::title stays what it was
on main: the file's name with its extension (a native file keeps its own), so the tab, the window
title, Save As, the default export names and Save for Web are unchanged.
- Document::info_title() is metadata.title else the document's title. file.info reports and
compares it, document.inspect and the document.open reply report it as fileInfoTitle, and the
writers of a title (PDF Title and XMP dc:title, SVG and Dublin Core, PNG text, EPS, .ai
and EMF) and has_own_title (DisplayDocTitle) use it. The PDF page-information mark and the
default file names still use the document's name.
- Changing the title in File Info sets the document's title as it always did and drops the one the
file gave; the unchanged title the dialog sends back is no edit.
- metadata.title is saved in the native format only when set (older versions ignore it) and is read
back from the editing data of an SVG, PDF, .ai or EPS; a template opens untitled.
- Reverts the file-stem title, the stem_title cleaning and the svgz branch that only existed to make
Document::title a stem, and the five assertions that change had made.
---
crates/doc/src/lib.rs | 2 +-
crates/doc/src/metadata.rs | 43 ++++-
crates/engine/src/cmd/fileinfo.rs | 45 +++--
crates/engine/src/cmd/fileio/encode.rs | 2 +-
crates/engine/src/cmd/fileio/eps.rs | 2 +-
crates/engine/src/cmd/fileio/formats.rs | 4 +-
crates/engine/src/cmd/fileio/legacy_ai.rs | 2 +-
crates/engine/src/cmd/fileio/load.rs | 33 ++--
crates/engine/src/cmd/fileio/save.rs | 2 +-
crates/engine/src/cmd/fileio/tests.rs | 3 +-
.../engine/src/cmd/fileio/tests_dxfimport.rs | 6 +-
.../engine/src/cmd/fileio/tests_epsimport.rs | 2 +-
crates/engine/src/cmd/fileio/tests_svg.rs | 2 +-
crates/engine/src/cmd/webexport.rs | 2 +-
crates/engine/src/inspect.rs | 4 +-
crates/engine/src/tests/fileinfo.rs | 169 ++++++++++++------
crates/metafile/src/lib.rs | 2 +-
crates/pdf/src/export/entry.rs | 6 +-
crates/pdf/src/tests/file_info.rs | 10 ++
crates/svg/src/export.rs | 2 +-
crates/svg/src/export/output.rs | 2 +-
crates/svg/src/import.rs | 8 +-
crates/svg/tests/it/title_control_chars.rs | 26 +++
docs/file-format-parity.md | 4 +-
docs/gaps.md | 4 +-
docs/mcp.md | 13 +-
docs/target-app-parity.md | 6 +-
27 files changed, 272 insertions(+), 134 deletions(-)
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index b59f661c21..5033e52b47 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -253,7 +253,7 @@ pub struct Document {
/// they were copied from instead of the current layer.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub paste_remembers_layers: bool,
- /// File → File Info (the title is [`Document::title`]).
+ /// File → File Info (its title is [`Document::info_title`]).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub metadata: DocMetadata,
/// Effect → Document Raster Effects Settings besides the resolution
diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs
index 151365c5d2..908b649e4d 100644
--- a/crates/doc/src/metadata.rs
+++ b/crates/doc/src/metadata.rs
@@ -1,11 +1,15 @@
//! File → File Info: the document's descriptive metadata (author, description, keywords, rating,
//! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The
-//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
-//! before File Info existed load unchanged.
+//! document title is [`Document::title`], the name it shows in its tab and offers Save As when it
+//! has no file; a title the file it was opened from gave itself is [`DocMetadata::title`], and
+//! [`Document::info_title`] is the title File Info shows and exports write. Every field is
+//! defaulted, so documents saved before File Info existed load unchanged.
use chrono::{Datelike, Timelike};
use serde::{Deserialize, Serialize};
+use crate::Document;
+
/// File Info → Copyright Status.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -44,6 +48,13 @@ pub const MAX_RATING: u8 = 5;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocMetadata {
+ /// The title the file this document was opened from gives itself (an SVG's root ``, a
+ /// PDF's XMP `dc:title` or Info `Title`), which File Info shows and exports write in place of
+ /// [`Document::title`], the document's display name. `None`: the file gave none, so the
+ /// display name is the title. Left out of a saved document when unset, so other documents
+ /// save as they did.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub title: Option,
pub author: String,
/// The author's position or job title.
pub author_title: String,
@@ -83,6 +94,16 @@ impl DocMetadata {
}
}
+impl Document {
+ /// The title File Info shows and the files an export writes carry (PDF `Title` and XMP
+ /// `dc:title`, SVG ``, PNG `Title`): the title the file this document was opened from
+ /// gave itself ([`DocMetadata::title`]), else [`Document::title`]. The display name (the tab, the
+ /// default file name) is always [`Document::title`].
+ pub fn info_title(&self) -> &str {
+ self.metadata.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&self.title)
+ }
+}
+
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
@@ -191,6 +212,24 @@ mod tests {
assert!(DocMetadata::default().png_text("").is_empty());
}
+ #[test]
+ fn the_info_title_is_the_files_own_title_else_the_display_name() {
+ let mut d = Document::new(10.0, 10.0);
+ d.title = "box.svg".into();
+ assert_eq!(d.info_title(), "box.svg");
+ d.metadata.title = Some("Access paths".into());
+ assert_eq!((d.title.as_str(), d.info_title()), ("box.svg", "Access paths"));
+ d.metadata.title = Some(" ".into());
+ assert_eq!(d.info_title(), "box.svg");
+ // Saved only when set: other documents' metadata is as before, and the field comes back.
+ d.metadata.title = None;
+ assert!(!serde_json::to_string(&d.metadata).unwrap().contains("title"));
+ d.metadata.title = Some("Access paths".into());
+ let json = serde_json::to_string(&d.metadata).unwrap();
+ assert_eq!(serde_json::from_str::(&json).unwrap().title.as_deref(), Some("Access paths"));
+ assert_eq!(serde_json::from_str::("{}").unwrap().title, None);
+ }
+
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
#[test]
diff --git a/crates/engine/src/cmd/fileinfo.rs b/crates/engine/src/cmd/fileinfo.rs
index e805757b12..9a59d0f2f8 100644
--- a/crates/engine/src/cmd/fileinfo.rs
+++ b/crates/engine/src/cmd/fileinfo.rs
@@ -1,7 +1,8 @@
//! File → File Info (`file.info`): the document title and its descriptive metadata (author,
//! description, keywords, rating, copyright), with the read-only created and modified dates. The
-//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info
-//! it gives ([`read_pdf`]).
+//! title is the one the file the document was opened from gave itself ([`DocMetadata::title`]),
+//! else the document's name. The SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or
+//! `.ai` opens with the File Info it gives ([`read_pdf`]).
use serde_json::{Map, Value, json};
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
@@ -22,7 +23,7 @@ const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artb
pub(crate) fn info_json(d: &Document) -> Value {
let m = &d.metadata;
json!({
- "title": d.title,
+ "title": d.info_title(),
"author": m.author,
"authorTitle": m.author_title,
"description": m.description,
@@ -48,14 +49,22 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result {
_ => return Err(bad(C, "params must be an object")),
};
let d = &s.doc()?.doc;
- let (mut title, mut m) = (d.title.clone(), d.metadata.clone());
+ let (mut title, mut m) = (d.info_title().to_string(), d.metadata.clone());
for (k, v) in o {
apply(&mut title, &mut m, k, v)?;
}
let d = &s.doc()?.doc;
- if title != d.title || m != d.metadata {
+ // A title File Info changes becomes the document's own, as it always has (the tab and Save As
+ // offer it where the document has no file), and replaces the one its file gave.
+ let retitled = title != d.info_title();
+ if retitled {
+ m.title = None;
+ }
+ if retitled || m != d.metadata {
s.edit("File Info", |d, _| {
- d.title = title;
+ if retitled {
+ d.title = title;
+ }
d.metadata = m;
Ok(())
})?;
@@ -64,8 +73,8 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result {
}
/// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each
-/// field that `file.info` would take (a value it refuses is left out), and the created and
-/// modified dates.
+/// field that `file.info` would take (a value it refuses is left out), its title ([`imported_title`])
+/// as the File Info title, and the created and modified dates.
pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain });
let fields = [
@@ -83,6 +92,7 @@ pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
let _ = apply(&mut title, m, k, &v);
}
}
+ m.title = f.title.as_deref().and_then(imported_title);
m.created = f.created.or(m.created);
m.modified = f.modified.or(m.modified);
}
@@ -141,29 +151,16 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result {
Ok(s.to_string())
}
-/// The title `raw`, read from a file, makes a document: runs of white space (line breaks too)
+/// The File Info title `raw`, read from a file, makes: runs of white space (line breaks too)
/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters
/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the
/// Untitled-N name New gives (what a document that never had a title writes to its files).
pub(crate) fn imported_title(raw: &str) -> Option {
- let title = cleaned_title(raw);
- super::fileio::is_own_title(&title).then_some(title)
-}
-
-/// `raw` as `file.info` takes a title (see [`imported_title`]), empty when nothing is left. An
-/// Untitled-N name stays.
-fn cleaned_title(raw: &str) -> String {
let words: Vec =
raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::()).filter(|w| !w.is_empty()).collect();
let title: String = words.join(" ").chars().take(MAX_TEXT).collect();
- title.trim_end().to_string()
-}
-
-/// The title a document without one of its own takes from its file name's `stem`: cleaned as an
-/// imported title is, so `file.info` takes it, and the Untitled-1 name a new document starts
-/// with when nothing is left of it.
-pub(crate) fn stem_title(stem: &str) -> String {
- Some(cleaned_title(stem)).filter(|t| !t.is_empty()).unwrap_or_else(|| "Untitled-1".to_string())
+ let title = title.trim_end();
+ super::fileio::is_own_title(title).then(|| title.to_string())
}
/// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring
diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs
index b125e48a88..a3da6316c5 100644
--- a/crates/engine/src/cmd/fileio/encode.rs
+++ b/crates/engine/src/cmd/fileio/encode.rs
@@ -589,7 +589,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result {
let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?;
// File Info as PNG text chunks.
let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) {
- vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title))
+ vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(doc.info_title()))
} else {
bytes
};
diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs
index f90dacfc71..7a71f4b2ec 100644
--- a/crates/engine/src/cmd/fileio/eps.rs
+++ b/crates/engine/src/cmd/fileio/eps.rs
@@ -326,7 +326,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result {
let o = EpsOptions {
region,
origin,
- title: doc.title.clone(),
+ title: doc.info_title().to_owned(),
created,
native: Some(native(&editing, if saving { None } else { board }, set.include_linked)),
..set.eps.clone()
diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs
index 30778f6a6e..289f11139b 100644
--- a/crates/engine/src/cmd/fileio/formats.rs
+++ b/crates/engine/src/cmd/fileio/formats.rs
@@ -778,7 +778,7 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
/// the stored title.
pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool {
- is_own_title(&doc.title)
+ is_own_title(doc.info_title())
}
/// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives?
@@ -790,6 +790,8 @@ pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, fo
let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?;
let mut d = doc.clone();
d.title = file_stem(path);
+ // A File Info title that is no title of its own (blank, or an Untitled-N name) would hide it.
+ d.metadata.title = None;
Some(d)
}
diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs
index d4820242d9..97e1258f67 100644
--- a/crates/engine/src/cmd/fileio/legacy_ai.rs
+++ b/crates/engine/src/cmd/fileio/legacy_ai.rs
@@ -142,7 +142,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
}
}
}
- let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text };
+ let o = vectorcraft_eps::LegacyAiOptions { title: doc.info_title().to_owned(), created, hidden, outline_text };
let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?;
for w in placed.iter().chain(&out.warnings) {
note(&mut enc.warnings, w);
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index 335a3200d1..ba01841c68 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -7,10 +7,10 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_render::effects::pixel::LoadedTexture;
-use super::super::fileinfo::{imported_title, stem_title};
+use super::super::fileinfo::imported_title;
use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param};
use super::pdfimport::LoadOptions;
-use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, file_stem, format, psdread, read_file, readable_format_for_name};
+use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, psdread, read_file, readable_format_for_name};
use crate::cmd::links::Resolved;
use crate::{EngineError, Prefs};
@@ -391,12 +391,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
};
// A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document
// it carries, opens with the File Info it gives (a PostScript `.ai` gives none).
- let mut pdf_title = None;
if matches!(format.id, "pdf" | "ai" | "ait")
&& !restored
&& let Some(info) = vectorcraft_pdf::file_info(bytes, opts.password.as_deref())
{
- pdf_title = info.title.clone();
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
}
// Threaded type read from a file flows through its frames (the threaded type of a restored
@@ -413,18 +411,18 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
- // The document is titled as the file titles it: a native document keeps its own title, one
- // restored from the editing data a file carries and an SVG keep the title they hold (the SVG's
- // ``), a PDF, `.ai` or `.ait` takes its XMP `dc:title` or Info `Title`. Without one the
- // document takes the file name without its extension, as an export does (#3626). The tab shows
- // the file name once the document has a path.
- let own = match format.id {
- "vectorcraft" | "template" => Some(doc.title.clone()).filter(|t| !t.is_empty()),
- _ if restored || matches!(format.id, "svg" | "svgz") => imported_title(&doc.title),
- "pdf" | "ai" | "ait" => pdf_title.as_deref().and_then(imported_title),
- _ => None,
- };
- doc.title = own.unwrap_or_else(|| stem_title(&file_stem(&title)));
+ // The title the file gives itself (an SVG's ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title`
+ // or Info `Title`, which the readers put in File Info; the one a native document holds) is the
+ // File Info title, cleaned as `file.info` takes one. A file restored from the editing data it
+ // carries gives the title that document had, unless that is the file's name. The document's own
+ // name stays what it has always been: the file's name (#3626).
+ let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title);
+ doc.metadata.title = doc.metadata.title.as_deref().or(held).and_then(imported_title);
+ // Imports are named after the file; a native document keeps its own title (the tab shows the
+ // file name once it has a path).
+ if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
+ doc.title = title;
+ }
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
}
@@ -540,6 +538,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option Res
// With the artboards' background colors, as the pages draw them.
let art = super::export::with_artboard_fills(art, ¶ms, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?;
let o = vectorcraft_eps::AiEditingOptions {
- title: doc.title.clone(),
+ title: doc.info_title().to_owned(),
created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time),
};
let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?;
diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs
index d3714e82e8..69158c04b8 100644
--- a/crates/engine/src/cmd/fileio/tests.rs
+++ b/crates/engine/src/cmd/fileio/tests.rs
@@ -67,8 +67,7 @@ fn opens_every_readable_format() {
] {
let r = open(&mut s, name, &bytes);
assert_eq!(r["format"], format, "{name}");
- // The document that wrote the file never had a title of its own, so the file gives none and the name (without its extension) titles it.
- assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { "a" }, "{name}");
+ assert_eq!(r["title"], if format == "vectorcraft" { "Untitled-1" } else { name }, "{name}");
assert!(s.doc().unwrap().doc.node_count() >= 2, "{name}: the rectangle came through");
}
for (name, kind) in [
diff --git a/crates/engine/src/cmd/fileio/tests_dxfimport.rs b/crates/engine/src/cmd/fileio/tests_dxfimport.rs
index c7de9d9e16..ec7b295c23 100644
--- a/crates/engine/src/cmd/fileio/tests_dxfimport.rs
+++ b/crates/engine/src/cmd/fileio/tests_dxfimport.rs
@@ -120,11 +120,7 @@ const MM: f64 = 72.0 / 25.4;
fn document_open_reads_a_dxf_drawing() {
let mut s = Session::new();
let r = open(&mut s, &plan(), json!({})).unwrap();
- assert_eq!(
- (r["format"].as_str(), r["title"].as_str()),
- (Some("dxf"), Some("plan")),
- "a file that carries no title opens as its name without the extension"
- );
+ assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("dxf"), Some("plan.dxf")));
assert_eq!(layer_names(&s), ["Walls", "Notes"]);
let st = s.doc().unwrap();
// Millimetres at 1:1, the artboard the art's size.
diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs
index 0e9b8e805f..8c4141902c 100644
--- a/crates/engine/src/cmd/fileio/tests_epsimport.rs
+++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs
@@ -64,7 +64,7 @@ fn other_eps_files_are_read_by_the_postscript_interpreter() {
// The page is the bounding box: the art's bounds.
assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(101.0, 51.0), "the stroke's half width rounds up to whole points");
assert_eq!(objects(&s), 1);
- assert_eq!(s.doc().unwrap().title(), "plain");
+ assert_eq!(s.doc().unwrap().title(), "plain.eps");
}
/// A CMYK image the PostScript interpreter reads keeps its ink amounts when the EPS opens and when
diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs
index ff3a73432c..73ee604a98 100644
--- a/crates/engine/src/cmd/fileio/tests_svg.rs
+++ b/crates/engine/src/cmd/fileio/tests_svg.rs
@@ -138,7 +138,7 @@ fn preserve_editing_reopens_the_native_document() {
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.artboards.len(), 2, "both artboards came back");
assert_eq!(doc.artboards[1].name, "Back");
- assert_eq!(doc.title, "both");
+ assert_eq!(doc.title, "both.svg");
// Without it the SVG is imported (one artboard).
let text = svg(&mut s, json!({}));
s.execute("document.open", &json!({"name": "plain.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes())})).unwrap();
diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs
index af0f313ea1..f31c82e29e 100644
--- a/crates/engine/src/cmd/webexport.rs
+++ b/crates/engine/src/cmd/webexport.rs
@@ -644,7 +644,7 @@ fn flatten(rgba: &[u8], matte: [u8; 3]) -> impl Iterator- + '_ {
/// The File Info text `s.metadata` lets into the files: `(keyword, text)`.
fn metadata(doc: &Document, s: &WebSettings) -> Vec<(&'static str, String)> {
- doc.metadata.png_text(&doc.title).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect()
+ doc.metadata.png_text(doc.info_title()).into_iter().filter(|(k, _)| s.metadata.keeps(k)).map(|(k, v)| (k, v.into_owned())).collect()
}
/// Encode straight RGBA pixels (`w`×`h`) as `s` says; `doc` gives the metadata.
diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs
index 14df8529d7..a60ffe7b14 100644
--- a/crates/engine/src/inspect.rs
+++ b/crates/engine/src/inspect.rs
@@ -270,8 +270,8 @@ fn document_base(s: &Session) -> Value {
json!({
"uid": st.uid,
"title": st.title(),
- // The File Info title (`file.info`), where `title` comes from the file name once the document has one.
- "fileInfoTitle": d.title,
+ // The File Info title (`file.info`): the title the file gave itself, else the document's name. `title` is the tab's name.
+ "fileInfoTitle": d.info_title(),
"path": st.path,
"dirty": st.is_dirty(),
"revision": st.revision,
diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs
index 4cb511b99c..249af96abc 100644
--- a/crates/engine/src/tests/fileinfo.rs
+++ b/crates/engine/src/tests/fileinfo.rs
@@ -161,7 +161,8 @@ fn an_exported_pdf_opens_with_its_file_info() {
assert_eq!(back["description"], "A poster\nfor the fair");
assert_eq!(back["keywords"], json!(["fair", "Poster", "print"]));
assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]);
- assert_eq!(back["title"], "Poster", "the title is the one the PDF carries, not its file name");
+ assert_eq!(back["title"], "Poster", "File Info shows the title the PDF carries");
+ assert_eq!(s.doc().unwrap().title(), "poster.pdf", "the document stays named after the file");
assert_eq!(back["copyrightStatus"], "copyrighted");
assert_eq!(back["copyrightNotice"], "(c) 2026 Ada");
assert_eq!(back["copyrightUrl"], "https://example.com/rights");
@@ -307,67 +308,129 @@ fn pdf_titled(info: Option<&str>, xmp_title: Option<&str>) -> Vec
{
String::from_utf8(bytes).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes()
}
-/// The title `document.open` gives a file called `name` with content `bytes`.
-fn opened_title(name: &str, bytes: &[u8]) -> String {
+/// A document called `name` with content `bytes` opened (as `document.open` with no path: the tab
+/// shows the document's name), `s` holding it.
+fn open_named(s: &mut Session, name: &str, bytes: &[u8]) -> Value {
+ s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap()
+}
+
+/// The document's name (its tab) and its File Info title.
+fn titles(s: &mut Session) -> [String; 2] {
+ let info = s.execute("file.info", &json!({})).unwrap();
+ [s.doc().unwrap().title(), info["title"].as_str().unwrap().to_string()]
+}
+
+/// What opening `bytes` as a file called `name` gives: the document's name and its File Info title.
+fn opened(name: &str, bytes: &[u8]) -> [String; 2] {
let mut s = Session::new();
- let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
- assert_eq!(r["fileInfoTitle"], s.doc().unwrap().doc.title.as_str());
- s.doc().unwrap().doc.title.clone()
+ let r = open_named(&mut s, name, bytes);
+ let t = titles(&mut s);
+ assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some(t[0].as_str()), Some(t[1].as_str())), "the open reply");
+ assert_eq!(s.doc().unwrap().doc.title, t[0], "the tab is the document's title");
+ t
+}
+
+fn svg_titled(inner: &str) -> String {
+ format!(
+ "{inner}Tooltip "
+ )
}
#[test]
-fn import_uses_the_files_own_title() {
- // SVG: the root's ``, its white space made single spaces; not a shape's tooltip.
- let svg = |inner: &str| {
- format!(
- "{inner}Tooltip "
- )
- };
- assert_eq!(opened_title("box.svg", svg("\n Access\n paths ").as_bytes()), "Access paths");
+fn import_puts_the_files_own_title_in_file_info_only() {
+ // SVG: the root's ``, its white space made single spaces (not a shape's tooltip), is the
+ // File Info title; the document keeps the file's name, with its extension.
+ assert_eq!(opened("box.svg", svg_titled("\n Access\n paths ").as_bytes()), ["box.svg", "Access paths"]);
assert_eq!(
- opened_title("box.svg", svg("Zugangswege Rechenzentrum – Prüfdiagramm ").as_bytes()),
- "Zugangswege Rechenzentrum – Prüfdiagramm"
+ opened("/work/box.svg", svg_titled("Zugangswege Rechenzentrum – Prüfdiagramm ").as_bytes()),
+ ["box.svg", "Zugangswege Rechenzentrum – Prüfdiagramm"]
);
// PDF: the XMP `dc:title` (its `x-default` item) beats the Info `Title`; the Info `Title` stands alone.
- assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), "XMP title");
- assert_eq!(opened_title("plan.pdf", &pdf_titled(None, Some("XMP title"))), "XMP title");
- assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), "Info title");
- assert_eq!(opened_title("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), "Info title");
- // A file that gives none (or a blank one): its name without the extension, in a folder too.
- assert_eq!(opened_title("box.svg", svg("").as_bytes()), "box");
- assert_eq!(opened_title("/work/box.svg", svg(" ").as_bytes()), "box");
- assert_eq!(opened_title("plan.pdf", &pdf_titled(None, None)), "plan");
- assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), "plan");
+ assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), Some("XMP title"))), ["plan.pdf", "XMP title"]);
+ assert_eq!(opened("plan.pdf", &pdf_titled(None, Some("XMP title"))), ["plan.pdf", "XMP title"]);
+ assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.pdf", "Info title"]);
+ assert_eq!(opened("plan.ai", &pdf_titled(Some("/Title (Info title)"), None)), ["plan.ai", "Info title"]);
+ // Compressed SVG too.
+ let svgz = |inner: &str| vectorcraft_svg::compress(&svg_titled(inner));
+ assert_eq!(opened("box.svgz", &svgz("Access paths ")), ["box.svgz", "Access paths"]);
+ // A file that gives none (or a blank one) gives File Info the document's name.
+ assert_eq!(opened("box.svg", svg_titled("").as_bytes()), ["box.svg", "box.svg"]);
+ assert_eq!(opened("box.svg", svg_titled(" ").as_bytes()), ["box.svg", "box.svg"]);
+ assert_eq!(opened("plan.pdf", &pdf_titled(None, None)), ["plan.pdf", "plan.pdf"]);
+ assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Author (Ada)"), None)), ["plan.pdf", "plan.pdf"]);
// The Untitled-N name New gives is no title (a document that never had one writes it).
- assert_eq!(opened_title("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), "plan");
- // A native file keeps the title it has.
- let mut s = session();
- let native = vectorcraft_format::base64_encode(&b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()));
+ assert_eq!(opened("plan.pdf", &pdf_titled(Some("/Title (Untitled-2)"), None)), ["plan.pdf", "plan.pdf"]);
+ // A template opens as a new untitled document, whatever title its file gave.
+ let [name, info] = opened("plan.ait", &pdf_titled(Some("/Title (Info title)"), None));
+ assert!(name.starts_with("Untitled-") && info == name, "{name} / {info}");
+}
+
+#[test]
+fn a_file_with_a_title_opened_from_a_path_is_named_after_the_file() {
+ let dir = std::env::temp_dir().join(format!("vc-infotitle-open-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("box.svg");
+ std::fs::write(&path, svg_titled("Access paths ")).unwrap();
+ let mut s = Session::new();
+ let r = s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap();
+ assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths")));
+ let r = s.execute("document.inspect", &json!({"depth": 0})).unwrap();
+ assert_eq!((r["title"].as_str(), r["fileInfoTitle"].as_str()), (Some("box.svg"), Some("Access paths")));
+ // The default names of what the document exports are made from the file's name, not its title.
+ assert_eq!(s.doc().unwrap().doc.title, "box.svg");
+ assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "png"), "box.png");
+ let _ = std::fs::remove_dir_all(dir);
+}
+
+#[test]
+fn file_info_edits_a_files_own_title_and_the_document_follows() {
+ let mut s = Session::new();
+ open_named(&mut s, "box.svg", svg_titled("Access paths ").as_bytes());
+ // The dialog sends back the title it was given: nothing changes (no undo step).
+ let before = s.doc().unwrap().revision;
+ let shown = s.execute("file.info", &json!({})).unwrap();
+ s.execute("file.info", &shown).unwrap();
+ assert_eq!(s.doc().unwrap().revision, before, "the title it gave is no edit");
+ assert_eq!(titles(&mut s), ["box.svg", "Access paths"]);
+ // Another title is the document's own, as ever, and the file's is gone.
+ let r = s.execute("file.info", &json!({"title": "Plan B"})).unwrap();
+ assert_eq!(r["title"], "Plan B");
+ assert_eq!(titles(&mut s), ["Plan B", "Plan B"]);
+ assert_eq!(s.doc().unwrap().doc.metadata.title, None);
+ // Undo brings the file's title back, and the name.
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(titles(&mut s), ["box.svg", "Access paths"]);
+}
+
+#[test]
+fn the_files_own_title_is_kept_by_saves_and_written_by_exports() {
+ let mut s = Session::new();
+ open_named(&mut s, "box.svg", svg_titled("Access paths ").as_bytes());
+ // The native file: its own title is the document's name, the File Info title survives.
+ let native = b64(&s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap());
let mut t = Session::new();
- t.execute("document.open", &json!({"name": "other.vectorcraft", "dataBase64": native})).unwrap();
- assert_eq!(t.doc().unwrap().doc.title, "Poster");
-}
-
-#[test]
-fn compressed_svg_keeps_its_title() {
- let svgz = |inner: &str| {
- vectorcraft_svg::compress(&format!(
- "{inner} "
- ))
- };
- assert_eq!(opened_title("box.svgz", &svgz("Access paths ")), "Access paths");
- assert_eq!(opened_title("box.svgz", &svgz("")), "box");
-}
-
-#[test]
-fn file_name_title_is_cleaned_like_an_imported_one() {
- let svg = b" ";
- // A name `file.info` would reject (white space only) or that keeps control characters.
- assert_eq!(opened_title(" .svg", svg), "Untitled-1");
- assert_eq!(opened_title("\u{1}\u{7f}.svg", svg), "Untitled-1");
- assert_eq!(opened_title("bo\u{1}x two\t.svg", svg), "box two");
- // The Untitled-N name stays the stem.
- assert_eq!(opened_title("Untitled-7.svg", svg), "Untitled-7");
+ open_named(&mut t, "other.vectorcraft", &native);
+ assert_eq!(titles(&mut t), ["box.svg", "Access paths"]);
+ // An SVG with the editing data it carries, reopened under another name.
+ let svg = s.execute("document.serialize", &json!({"format": "svg", "preserveEditing": true, "metadata": true})).unwrap()["text"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert!(svg.contains("Access paths ") && svg.contains("Access paths "), "{svg}");
+ let mut u = Session::new();
+ let r = open_named(&mut u, "again.svg", svg.as_bytes());
+ assert_eq!(r["restored"], true);
+ assert_eq!(titles(&mut u), ["again.svg", "Access paths"]);
+ // PDF: the Info `Title` and the XMP `dc:title`, shown by the viewer.
+ for pdf in pdf_texts(&mut s) {
+ assert!(pdf.contains("/Title(Access paths)") && xmp_title(&pdf).contains("Access paths"), "{}", xmp_title(&pdf));
+ assert!(pdf.contains("/ViewerPreferences<>"), "{pdf}");
+ }
+ // PNG text.
+ let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap());
+ assert!(crate::cmd::fileio::pngtext::text(&png).iter().any(|(k, v)| k == "Title" && v == "Access paths"));
+ // The default file names are made from the document's name.
+ assert_eq!(crate::cmd::fileio::default_name(&s.doc().unwrap().doc, "pdf"), "box.pdf");
}
#[test]
diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs
index 5326e2f307..a517d516a6 100644
--- a/crates/metafile/src/lib.rs
+++ b/crates/metafile/src/lib.rs
@@ -84,7 +84,7 @@ pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result emf::write(&ops, r, &doc.title),
+ Kind::Emf => emf::write(&ops, r, doc.info_title()),
Kind::Wmf => wmf::write(&ops, r, &mut warnings),
};
Ok(Output { bytes, warnings })
diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs
index 6a4294c699..54d1f207a9 100644
--- a/crates/pdf/src/export/entry.rs
+++ b/crates/pdf/src/export/entry.rs
@@ -48,7 +48,9 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
let doc = baked.as_ref().unwrap_or(doc);
let pages = pages(doc, opts)?;
- let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
+ // The file's Title is File Info's; the page information names the document, as its tab does.
+ let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.info_title().to_owned()));
+ let name = opts.title.as_deref().unwrap_or(&doc.title);
// The time of writing (none on the web) dates the editing data and the page information.
let now = vectorcraft_doc::metadata::output_time();
let created_at = opts.created.or(now);
@@ -86,7 +88,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1));
let (m, r) = (boxes.media, boxes.bleed);
- let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() };
+ let info = if marks.page_info { crate::marks::page_info(doc, name, i, now) } else { String::new() };
let art = crate::marks::art(doc, &marks, &boxes, bleed, &info);
// The page is the media box, y down from its top-left corner (like ours), in units of
// `unit` points.
diff --git a/crates/pdf/src/tests/file_info.rs b/crates/pdf/src/tests/file_info.rs
index f7c5d694b6..4c837ba67f 100644
--- a/crates/pdf/src/tests/file_info.rs
+++ b/crates/pdf/src/tests/file_info.rs
@@ -220,6 +220,16 @@ fn a_written_pdf_gives_its_file_info_back() {
assert_eq!((f.created, f.modified), (Some(CREATED), Some(CREATED)));
}
+#[test]
+fn a_written_pdf_carries_the_file_info_title_not_the_documents_name() {
+ let mut d = Document::new(100.0, 100.0);
+ d.title = "poster.svg".into();
+ d.metadata.title = Some("Fair poster".into());
+ let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap();
+ assert_eq!(file_info(&bytes, None).unwrap().title.as_deref(), Some("Fair poster"));
+ assert!(!String::from_utf8_lossy(&bytes).contains("poster.svg"));
+}
+
#[test]
fn a_written_pdf_gives_its_copyright_back() {
use vectorcraft_doc::CopyrightStatus;
diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs
index 13c4268472..e905a7189a 100644
--- a/crates/svg/src/export.rs
+++ b/crates/svg/src/export.rs
@@ -186,7 +186,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
// An artboard exported on its own is named after it (#3544), else after the document.
// A name that is blank once the characters XML can't hold are dropped falls back too.
let board = opts.artboard.filter(|_| opts.artboard_title).and_then(|i| doc.artboards.get(i)).map(|a| xml_text(&a.name));
- let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(&doc.title));
+ let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(doc.info_title()));
if !title.is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("{title} {nl}"));
diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs
index 01d33c833b..e3ad5722a1 100644
--- a/crates/svg/src/export/output.rs
+++ b/crates/svg/src/export/output.rs
@@ -88,7 +88,7 @@ fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> {
let info = &doc.metadata;
let mut terms = vec![("format", "image/svg+xml".to_string())];
let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default();
- let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())];
+ let fields = [("title", doc.info_title()), ("creator", info.author.as_str()), ("description", info.description.as_str())];
let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())];
let keywords = info.keywords.iter().map(|k| ("subject", k.as_str()));
terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string())));
diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs
index aa625de695..78e1f16a46 100644
--- a/crates/svg/src/import.rs
+++ b/crates/svg/src/import.rs
@@ -142,11 +142,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Resul
let k = units.k;
let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k);
doc.units = units.unit;
- // The file's own title, when it gives one; else the document keeps the placeholder name
- // `Document::new` gave it, for the caller to name after the file.
- if let Some(title) = root_title(xml.root_element()) {
- doc.title = title;
- }
+ // The file's own title, when it gives one, is the document's File Info title; the document's
+ // name stays what `Document::new` gave it, for the caller to name after the file.
+ doc.metadata.title = root_title(xml.root_element());
let mut im = Importer {
doc,
warnings,
diff --git a/crates/svg/tests/it/title_control_chars.rs b/crates/svg/tests/it/title_control_chars.rs
index 3318ed346f..2318eebc67 100644
--- a/crates/svg/tests/it/title_control_chars.rs
+++ b/crates/svg/tests/it/title_control_chars.rs
@@ -69,3 +69,29 @@ fn dublin_core_metadata_control_chars_are_filtered() {
assert!(svg.contains("kw "));
assert!(svg.contains("(c) me "));
}
+
+/// The root's `` is the File Info title of the imported document ([`Document::info_title`]);
+/// its name stays what `Document::new` gave it, and a shape's `` is that shape's tooltip.
+#[test]
+fn the_roots_title_is_the_file_info_title_not_the_documents_name() {
+ let svg = |inner: &str| {
+ format!(r#"{inner}Tip "#)
+ };
+ let d = import(&svg("\n Access\n paths ")).unwrap();
+ assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", Some("Access paths"), "Access paths"));
+ for none in [svg(""), svg(" ")] {
+ let d = import(&none).unwrap();
+ assert_eq!((d.title.as_str(), d.metadata.title.as_deref(), d.info_title()), ("Untitled-1", None, "Untitled-1"));
+ }
+}
+
+/// Exports write the File Info title, where the document has one, in place of its name.
+#[test]
+fn exports_write_the_file_info_title() {
+ let mut d = Document::new(200.0, 200.0);
+ d.title = "box.svg".into();
+ d.metadata.title = Some("Access paths".into());
+ let svg = export(&d, &ExportOptions { metadata: true, ..ExportOptions::default() });
+ assert!(svg.contains("Access paths ") && svg.contains("Access paths "), "{svg}");
+ assert!(!svg.contains("box.svg"), "{svg}");
+}
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 5b5101a5f2..f7ff2021c9 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File format parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; previously: native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -166,7 +166,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
-| 2026-11-03 | minor | An imported document takes the title its file carries: an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`; a file that gives none opens with its file name without the extension (#3626). |
+| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. |
| 2026-11-01 | minor | Native format: a symbol's Symbol Type (`dynamic`) and a dynamic symbol instance's appearance changes to objects of its art (`symbol_overrides`) round-trip; both are left out when unset, so other documents save unchanged (#4938). `.ai` reading and writing of dynamic symbols remains. |
| 2026-11-01 | minor | Exports draw each artboard's background color behind its art (#4937): PNG, JPEG, GIF, TIFF, WebP, BMP, Targa and PSD (a layer of its own; on a transparent background too), PDF, SVG and EPS pages, a `.ai` file's PDF pages and PostScript and PDF print; `includeArtboardFill: false` leaves it out. Files with editing data (Preserve Editing, `.ai`, EPS, native) keep it as the artboard's color and reopen without extra art. Not read from the reference app's own `.ai` data, nor written to it. DXF, EMF and WMF leave it out |
| 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged (estimated) |
diff --git a/docs/gaps.md b/docs/gaps.md
index bd4f5488f3..463fe76b82 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Gaps
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -733,7 +733,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
-| 2026-11-03 | minor | An imported document takes the title its file carries (an SVG's root ``, a PDF's XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); no scored gap or estimate reassessed. |
+| 2026-11-03 | minor | An imported document's own title (an SVG's root ``, a PDF's XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name stays the file's name (#3626); no scored gap or estimate reassessed. |
| 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. |
| 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. |
| 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. |
diff --git a/docs/mcp.md b/docs/mcp.md
index 72bf8f8885..8c2e228bd7 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -3940,9 +3940,16 @@ that its XMP packet (`dc:title`, `dc:creator`, `dc:description`, `dc:subject` or
(`Title`, `Author`, `Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give
one (for `dc:title` and `dc:description`, the `x-default` language item first). An SVG opens with the `title` of its
root `` element (not a shape's or group's). A title is read with its white space made single spaces and
-without control characters, at most 65,536 characters, as `file.info` takes one. A file that gives no title, a blank
-one or the `Untitled-N` name New gives, opens with its file name without the extension (`box.svg` opens as `box`), and a native file keeps its own
-title. A value that `file.info` would refuse is left out, and a template opens with a new untitled name.
+without control characters, at most 65,536 characters, as `file.info` takes one. A value that `file.info` would
+refuse is left out. A file that gives no title, a blank one or the `Untitled-N` name New gives leaves File Info with
+the document's name as its title, and a template opens with a new untitled name.
+The title a file gave is File Info's alone: the document's name, which is what its tab shows until it has a file and
+what Save As and the default export names start from, stays the file's name with its extension (`box.svg`), or the
+title a native file holds. File Info's `title` is the one the file gave, else the document's name. It is saved with the
+document (a native file, and the editing data of an SVG, PDF, `.ai` or EPS that carries it), and the files an export
+writes carry it (PDF `Title` and XMP `dc:title`, SVG `` and Dublin Core, PNG text, EPS and `.ai` `%%Title`),
+where the document has one. Changing the `title` with `file.info` sets the document's name, as it always did, and drops
+the title the file gave.
`document.inspect` (`inspect_document`) and the `document.open` (`open_file`) reply report the File Info title as
`fileInfoTitle`. Their `title` is the document's tab name, which comes from the file name once the document is saved
to or opened from a file (with ` [Converted]` or ` [Recovered]` where that applies).
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 412dbba039..23627465dc 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document's own title goes into File Info only; previously: Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
@@ -1180,7 +1180,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests/place.rs`).
- Separations Preview shows placed and embedded raster images on the visible plates, as it shows vector art (#4153): an RGB image's pixels are separated into process inks with the proof setup's intent, a CMYK image shows its own inks, a hidden plate's ink leaves the image, and with one plate visible the image shows that plate's ink as a grey. Raster images carry no spot ink, so a spot plate shown alone shows them as paper; a colorized image shows its color's plates. Emoji and other bitmap glyphs follow the same rule. The plate-filtered pixels and their reduced-size copies are kept apart from the image's own pixels, for each set of visible plates, proof setup and color settings, and with the preview off nothing changes. Evidence: `tests::colorize::separations_preview_shows_an_rgb_images_process_inks`, `separations_preview_shows_a_cmyk_images_own_inks`, `separations_preview_keeps_an_images_pixels_per_plate_set`. Print preview fidelity remains the open gap, so scores and hours are unchanged.
- Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests/print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.
- - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root ``; a file that gives no title opens with its file name without the extension, and a native file keeps its own title (#3626).
+ - A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the title (`dc:title`, its `x-default` item first, else Info `Title`), the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). An SVG opens with the title of its root ``. The title is File Info's alone (`DocMetadata::title`): the document's name stays the file's name with its extension, a file that gives no title leaves File Info with that name, and exports (PDF, SVG, PNG, EPS, `.ai`) and native saves carry it; changing the title in File Info sets the document's name as before (#3626).
- PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests/fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/marks.rs`).
- PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`).
- Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`).
@@ -1573,7 +1573,7 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente
| Date | Change | Summary |
|---|---|---|
-| 2026-11-03 | minor | File Info on open: an imported document takes the title its file carries (an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`), else its file name without the extension, instead of the file name with it (#3626); scores and estimates unchanged. |
+| 2026-11-03 | minor | File Info on open: an imported document's own title (an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name (tab, Save As, default export names) stays the file's name (#3626); scores and estimates unchanged. |
| 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. |
| 2026-11-02 | minor | Wheel input over closed font-family fields changes selected text (#5253); public documentation and application choices distinguished; scores and estimates unchanged. |
| 2026-11-03 | minor | Find Font's replacement-source chooser offers Document, Recent and System (#5217); scores and estimates unchanged. |
From 3a1421e95ee5b33ab813c865482d48536183f60f Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Sat, 7 Nov 2026 13:51:03 +0430
Subject: [PATCH 04/15] docs: restore the status lines damaged by merges
(main's line plus this PR's note)
---
docs/file-format-parity.md | 2 +-
docs/gaps.md | 2 +-
docs/target-app-parity.md | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 384ce0a882..a2a37c1ff7 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File format parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x; an imported document's own title goes into File Info only; previously: native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (an imported file's own title goes into File Info only (#3626); EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
diff --git a/docs/gaps.md b/docs/gaps.md
index 91d447aa27..74cc72c256 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Gaps
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; an imported document's own title goes into File Info only; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (an imported file's own title goes into File Info only (#3626); Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 0c353bb930..041226f90c 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; an imported document's own title goes into File Info only; previously: Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (an imported file's own title goes into File Info only (#3626); the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
## Feature submission evidence
From cd11b62b74a8b50c299690007bf83d1989b9f69d Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Wed, 11 Nov 2026 13:13:48 +0430
Subject: [PATCH 05/15] Keep authored File Info titles unchanged on native
restore
---
crates/engine/src/cmd/fileio/load.rs | 9 ++++-----
crates/engine/src/tests/fileinfo.rs | 14 ++++++++++++++
docs/file-format-parity.md | 3 ++-
3 files changed, 20 insertions(+), 6 deletions(-)
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index 3824dbf226..26380383bd 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -522,12 +522,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
// The title the file gives itself (an SVG's ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title`
- // or Info `Title`, which the readers put in File Info; the one a native document holds) is the
- // File Info title, cleaned as `file.info` takes one. A file restored from the editing data it
- // carries gives the title that document had, unless that is the file's name. The document's own
- // name stays what it has always been: the file's name (#3626).
+ // or Info `Title`, which the readers put in File Info) is separate from the document's name.
+ // Existing File Info is preserved exactly; only a restored document's legacy name fallback is
+ // cleaned as imported text. The document's name stays the file's name (#3626).
let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title);
- doc.metadata.title = doc.metadata.title.as_deref().or(held).and_then(imported_title);
+ doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title));
// Imports are named after the file; a native document keeps its own title (the tab shows the
// file name once it has a path).
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs
index 249af96abc..daae68d86e 100644
--- a/crates/engine/src/tests/fileinfo.rs
+++ b/crates/engine/src/tests/fileinfo.rs
@@ -402,6 +402,20 @@ fn file_info_edits_a_files_own_title_and_the_document_follows() {
assert_eq!(titles(&mut s), ["box.svg", "Access paths"]);
}
+#[test]
+fn restored_file_info_title_keeps_authored_spaces() {
+ let mut s = session();
+ std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).metadata.title = Some("Release Plan".into());
+ for format in ["vectorcraft", "svg", "pdf"] {
+ let bytes = b64(&s.execute("document.export", &json!({"format": format, "preserveEditing": true})).unwrap());
+ let mut reopened = Session::new();
+ let reply = open_named(&mut reopened, &format!("again.{format}"), &bytes);
+ assert_eq!(reply["restored"], format != "vectorcraft", "{format}");
+ assert_eq!(reopened.doc().unwrap().doc.metadata.title.as_deref(), Some("Release Plan"), "{format}");
+ assert_eq!(titles(&mut reopened)[1], "Release Plan", "{format}");
+ }
+}
+
#[test]
fn the_files_own_title_is_kept_by_saves_and_written_by_exports() {
let mut s = Session::new();
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 1b7b7ce089..159a53fe55 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File format parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (restored File Info titles retain authored spaces, #3626; previously: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). |
| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. |
| 2026-11-11 | minor | `.ai` and EPS import: a warp of type (an envelope made with a warp around type) whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the warp grid the file saves, with the same drawn-art check, budget and page comparison as #6421: where it passes, the type is editable and keeps the file's shape, and the warp's settings (style, bend) are lost. A second try is only made while more checks are left than there are envelopes of type and warped text objects still to come. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its box with strokes, where the size the file saves for it doesn't already give that box (a rule chosen by measuring real files). An envelope's grid (warp, mesh or top object) now reads its way round from all its rows and columns, not its first row alone: a mesh bent far round (its end columns leaning more than halfway over) reads the way it runs, not turned a quarter round, and a grid symmetric about its middle reads the same wherever it is on the page. Tests `tests_native::a_warp_of_type_opens_as_a_mesh_on_its_saved_grid_and_keeps_its_shape_when_its_text_changes`, `a_warp_of_stroked_type_maps_from_its_bounds_with_strokes_where_its_drawn_art_does`, `a_warp_of_type_without_a_saved_grid_keeps_its_drawn_art`, `a_warp_of_type_tried_again_leaves_the_next_envelope_its_check`, `ai::tests::a_mesh_envelope_bent_far_round_opens_the_way_it_runs`, `a_mesh_envelope_bent_halfway_round_opens_the_same_way_wherever_it_is` (part of #3179) |
| 2026-11-11 | minor | `.ai` and EPS import: type stroked in the text document keeps its stroke's cap, join and miter limit (keys 61, 62 and 64 of its character attributes, numbered as PostScript's `setlinecap` and `setlinejoin`: 0 butt or miter, 1 round, 2 projecting or bevel). They were left at a miter join with a limit of 10 whatever the file said, so type the file strokes with round joins, or with the usual limit of 4 or less, drew spikes at its sharp corners, some many points long. In users' files, used locally and never committed, the page strokes the type with the cap, join and miter limit these keys give; no file had a bevel join, which is read by the same numbering. A value that isn't one of theirs keeps the default, a miter limit past 500 is 500, and type without a stroke keeps the defaults. Dashes are not read: no file had dashed type to tell which keys hold them. Tests `ate::tests::a_run_keeps_its_stroke_cap_join_and_miter_limit`, `tests_native::stroked_type_keeps_its_cap_join_and_miter_limit`; scores unchanged |
From 54f9c03dfffd90ad3b0bc3241be7753cf2b6fe7d Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Wed, 11 Nov 2026 19:32:15 +0430
Subject: [PATCH 06/15] Normalize imported SVG titles without changing restored
metadata
---
crates/engine/src/cmd/fileio/load.rs | 7 +++++--
crates/engine/src/tests/fileinfo.rs | 17 +++++++++++++++++
docs/file-format-parity.md | 3 ++-
3 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs
index 26380383bd..97727c2251 100644
--- a/crates/engine/src/cmd/fileio/load.rs
+++ b/crates/engine/src/cmd/fileio/load.rs
@@ -523,8 +523,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result
}
// The title the file gives itself (an SVG's ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title`
// or Info `Title`, which the readers put in File Info) is separate from the document's name.
- // Existing File Info is preserved exactly; only a restored document's legacy name fallback is
- // cleaned as imported text. The document's name stays the file's name (#3626).
+ // Restored File Info is preserved exactly; newly imported SVG candidates and a restored
+ // document's legacy name fallback are cleaned as imported text. The name stays the file's (#3626).
+ if !restored && matches!(format.id, "svg" | "svgz") {
+ doc.metadata.title = doc.metadata.title.as_deref().and_then(imported_title);
+ }
let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title);
doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title));
// Imports are named after the file; a native document keeps its own title (the tab shows the
diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs
index daae68d86e..4d86a84b3c 100644
--- a/crates/engine/src/tests/fileinfo.rs
+++ b/crates/engine/src/tests/fileinfo.rs
@@ -365,6 +365,23 @@ fn import_puts_the_files_own_title_in_file_info_only() {
assert!(name.starts_with("Untitled-") && info == name, "{name} / {info}");
}
+#[test]
+fn imported_svg_titles_are_filtered_and_bounded() {
+ for (raw, expected) in [
+ ("Untitled-2".to_string(), None),
+ ("\u{7f}".to_string(), None),
+ ("Re\u{7f}lease Plan".to_string(), Some("Release Plan".to_string())),
+ ("λ".repeat(65_537), Some("λ".repeat(65_536))),
+ ] {
+ let svg = svg_titled(&format!("{raw} "));
+ for format in ["svg", "svgz"] {
+ let name = format!("box.{format}");
+ let bytes = if format == "svgz" { vectorcraft_svg::compress(&svg) } else { svg.as_bytes().to_vec() };
+ assert_eq!(opened(&name, &bytes), [name.clone(), expected.clone().unwrap_or(name)], "{format}");
+ }
+ }
+}
+
#[test]
fn a_file_with_a_title_opened_from_a_path_is_named_after_the_file() {
let dir = std::env::temp_dir().join(format!("vc-infotitle-open-{}", std::process::id()));
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 11090d9d18..7f6407f5f0 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File format parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (restored File Info titles retain authored spaces, #3626; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; previously: restored File Info titles retain authored spaces, #3626; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -339,6 +339,7 @@ Stop opacity on translated artboards remains outside this regression coverage.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). |
| 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). |
| 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. |
| 2026-11-11 | minor | `.ai` and EPS import, PDF import, SVG export and `.ai` save: type with a Horizontal Scale other than 100% and tracking or manual kerning is spaced as a `.ai`'s own page spaces it. Layout now measures tracking and manual kerning in thousandths of the em as the scale draws it (see type-parity.md, same date); they were added at the full em, so such type drifted from the page letter by letter. A `.ai`'s page writes that type's character spacing as the tracking itself in a text space as wide as the scale, which PDF scales by it (ISO 32000-1 §9.4.4). In 224 users' files (used locally, never committed), 93 render differently. Of the 25 whose render moved by more than 1% of its pixels against the page, 23 came closer. The note on the type differing from the page shows a smaller share in 5 files (11.0% to 4.4% at most) and a larger one in 2 (by 0.2 to 0.3 points). 9 more envelopes of type open live (113 to 122 of 203, none fewer in any file), and no file switched between its layers and its PDF part. PDF import: the tracking of horizontally scaled type is the file's `Tc` in thousandths of its size (it was that times the scale), the glyphs where they were (test `tests::text_placement::character_spacing_in_horizontally_scaled_type_is_the_files_tc`). SVG export writes the letter spacing in thousandths of the font size it writes (the vertical scale), so type scaled uniformly keeps its spacing in a viewer (test `text_layout::spacing_in_uniformly_scaled_type_takes_the_scale`); tracked type scaled horizontally alone still differs a little there, as before. A `.ai` saved here writes the same values as before; VectorCraft now lays them out as the file's own PDF part draws them. Scores and estimates unchanged. |
From e05f40d7f8c8023408b96a5166e22025352524f6 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 14:41:24 -0800
Subject: [PATCH 07/15] Type: Center on Top and Center on Bottom for type on a
closed path
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Type › Type on a Path › Center on Top and Center on Bottom
(type.pathCenter.top and type.pathCenter.bottom, also on the Contextual
Task Bar for type on a closed path) slide the type along its path so that
the middle of the span between its brackets lands on the path's topmost or
bottommost point as the object is drawn, after its transform. On a level
stretch, such as a rectangle's top edge, the middle of the stretch is used.
The span keeps its length, and the first paragraph is set to Center unless
it is Center or Justify Center. The type is not flipped. Type on an open or
compound path, type in a thread, and type on a path with no length or no
height are refused with an error that names them. When the type is already
centered there, no undo step is recorded. This is VectorCraft's own
behavior.
Docs: docs/mcp.md, docs/target-app-parity.md.
---
crates/engine/src/cmd/plugin_call/reviewed.rs | 2 +
crates/engine/src/cmd/typemenu.rs | 148 +++++++++++++++
crates/engine/src/tests/pathtype.rs | 178 ++++++++++++++++++
crates/geom/src/arc.rs | 134 ++++++++++++-
crates/ui-egui/src/canvas/task_bar.rs | 13 ++
crates/ui-egui/src/i18n/bg.tsv | 2 +
crates/ui-egui/src/i18n/bs.tsv | 2 +
crates/ui-egui/src/i18n/ca.tsv | 6 +
crates/ui-egui/src/i18n/cs.tsv | 6 +
crates/ui-egui/src/i18n/da.tsv | 2 +
crates/ui-egui/src/i18n/de.tsv | 6 +
crates/ui-egui/src/i18n/el.tsv | 6 +
crates/ui-egui/src/i18n/es.tsv | 6 +
crates/ui-egui/src/i18n/fi.tsv | 6 +
crates/ui-egui/src/i18n/fr.tsv | 6 +
crates/ui-egui/src/i18n/hi.tsv | 6 +
crates/ui-egui/src/i18n/hu.tsv | 6 +
crates/ui-egui/src/i18n/id.tsv | 2 +
crates/ui-egui/src/i18n/it.tsv | 6 +
crates/ui-egui/src/i18n/ja.tsv | 6 +
crates/ui-egui/src/i18n/ka.tsv | 6 +
crates/ui-egui/src/i18n/ko.tsv | 6 +
crates/ui-egui/src/i18n/nl.tsv | 2 +
crates/ui-egui/src/i18n/pl.tsv | 6 +
crates/ui-egui/src/i18n/pt-br.tsv | 6 +
crates/ui-egui/src/i18n/ru.tsv | 6 +
crates/ui-egui/src/i18n/sv.tsv | 6 +
crates/ui-egui/src/i18n/tr.tsv | 6 +
crates/ui-egui/src/i18n/uk.tsv | 6 +
crates/ui-egui/src/i18n/vi.tsv | 6 +
crates/ui-egui/src/i18n/zh-hans.tsv | 6 +
crates/ui-egui/src/i18n/zh-hant.tsv | 6 +
crates/ui-egui/src/menus/tests.rs | 52 +++++
crates/ui-egui/src/menus/tree.rs | 8 +-
crates/ui-egui/src/tests/taskbar.rs | 28 +++
docs/mcp.md | 20 ++
docs/target-app-parity.md | 1 +
37 files changed, 724 insertions(+), 2 deletions(-)
diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs
index 541389bb89..07ff0ded2e 100644
--- a/crates/engine/src/cmd/plugin_call/reviewed.rs
+++ b/crates/engine/src/cmd/plugin_call/reviewed.rs
@@ -638,6 +638,8 @@ pub(super) const ALLOWED: &[&str] = &[
"type.opticalMarginAlignment",
"type.orientation.horizontal",
"type.orientation.vertical",
+ "type.pathCenter.bottom",
+ "type.pathCenter.top",
"type.pathOptions",
"type.resetHorizontalScale",
"type.resetTracking",
diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs
index deaac8acc4..e9fd191676 100644
--- a/crates/engine/src/cmd/typemenu.rs
+++ b/crates/engine/src/cmd/typemenu.rs
@@ -60,6 +60,24 @@ pub fn specs() -> Vec {
has_selection,
path_options
),
+ cmd!(
+ "type.pathCenter.top",
+ "Center on Top",
+ ["Type", "Type on a Path"],
+ None,
+ "{ids?|id?} slide the selected type on a closed path along it so that the middle of the span between its start and end brackets lands on the path's topmost point as the object is drawn (after its transform, so a rotated, scaled or skewed path is measured as it appears; on a level stretch such as a rectangle's top edge, its middle; with several separate topmost points, the middle of the shortest part of the path that holds them all), keeping the span's length (a full turn stays a full turn), and set the first paragraph's alignment to Center unless it is Center or Justify Center, so the type is centered there. Point type and area type in the selection are left as they are. Type on an open or compound path, type in a thread, and type on a path with no length or no height are refused with an error that names them, and nothing changes. When every object is already centered there, nothing changes and no undo step is recorded → {ids, start, end} (the first object's brackets)",
+ has_closed_path_type,
+ path_center_top
+ ),
+ cmd!(
+ "type.pathCenter.bottom",
+ "Center on Bottom",
+ ["Type", "Type on a Path"],
+ None,
+ "{ids?|id?} as type.pathCenter.top, on the path's bottommost point as drawn. The type is not flipped: on the bottom of a path that runs clockwise it reads upside down until Type on a Path Options › Flip turns it → {ids, start, end} (the first object's brackets)",
+ has_closed_path_type,
+ path_center_bottom
+ ),
cmd!(
"type.dynamicText",
"Dynamic Text",
@@ -410,6 +428,136 @@ fn to_point(s: &mut Session, p: &Value) -> Result {
Ok(ids_json(&ids))
}
+/// Whether type on a path `t` runs along one closed path (no open or compound path).
+fn on_one_closed_path(t: &TextObject) -> bool {
+ matches!(&t.kind, TextKind::OnPath { path, .. } if path.subpaths.len() == 1 && path.is_closed())
+}
+
+/// Why `type.pathCenter.top` and `.bottom` refuse type on a path `ids`: those on an open or
+/// compound path, then those in a thread; None when they take every one.
+fn path_center_refusal(d: &Document, ids: &[NodeId]) -> Option {
+ let open = ids.iter().filter(|id| !path_text(d.node(**id)).is_some_and(on_one_closed_path)).map(|id| id.0.to_string()).collect::>();
+ if !open.is_empty() {
+ let open = open.join(", ");
+ return Some(format!("type on an open or compound path cannot be centered on its top or bottom: {open}"));
+ }
+ let frames = threaded_ids(d, ids);
+ (!frames.is_empty()).then(|| format!("type on a path in a thread cannot be centered on its top or bottom: {frames}"))
+}
+
+/// `type.pathCenter.top` and `.bottom` are enabled for a selection holding type on a path, all of
+/// it on one closed path and none of it in a thread.
+fn has_closed_path_type(s: &Session) -> std::result::Result<(), String> {
+ has_selection(s)?;
+ let st = s.active().ok_or("no document open")?;
+ let d = &st.doc;
+ let selected: Vec = st.selection.objects.to_vec();
+ let paths: Vec = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect();
+ if paths.is_empty() {
+ return Err("select type on a closed path".into());
+ }
+ path_center_refusal(d, &paths).map_or(Ok(()), Err)
+}
+
+/// The brackets that center type on a path `t` on its path's topmost point (`bottom`: its
+/// bottommost) as `t.xf` draws it, its span keeping its length; None for a path with no length or
+/// no height.
+fn centered_brackets(t: &TextObject, bottom: bool) -> Option<(f64, Option)> {
+ let TextKind::OnPath { path, end, .. } = &t.kind else { return None };
+ let (from, to) = t.kind.path_span()?;
+ let arc = vectorcraft_geom::ArcPath::new(path);
+ if arc.is_empty() {
+ return None;
+ }
+ let flip = if bottom { Affine::FLIP_Y } else { Affine::IDENTITY };
+ let middle = arc.topmost(flip * t.xf)? / arc.len();
+ let run = to - from;
+ let start = (middle - run * 0.5).rem_euclid(1.0).clamp(0.0, 1.0);
+ // Without an end bracket the type runs once round; a full turn's end stays on its start.
+ let end = end.map(|_| if run >= 1.0 - 1e-12 { start } else { (start + run).rem_euclid(1.0).clamp(0.0, 1.0) });
+ Some((start, end))
+}
+
+/// Whether brackets `a` and `b`, fractions of a closed path's length, are at one place within
+/// rounding (0 and 1 are one place).
+fn same_bracket(a: f64, b: f64) -> bool {
+ let d = (a - b).rem_euclid(1.0);
+ d.min(1.0 - d) <= 1e-9
+}
+
+fn path_center_top(s: &mut Session, p: &Value) -> Result {
+ path_center(s, p, false)
+}
+
+fn path_center_bottom(s: &mut Session, p: &Value) -> Result {
+ path_center(s, p, true)
+}
+
+/// Type › Type on a Path › Center on Top and Center on Bottom: slide the selected type on closed
+/// paths along them so the middle of each span lands on the path's top (`bottom`: its bottom) as
+/// drawn, and center the first paragraph, in one undo step; none when nothing changes.
+fn path_center(s: &mut Session, p: &Value, bottom: bool) -> Result {
+ let c = if bottom { "type.pathCenter.bottom" } else { "type.pathCenter.top" };
+ let centered = |j: Justify| matches!(j, Justify::Center | Justify::JustifyCenter);
+ // Each object the command changes, with its new brackets when they move and whether its first
+ // paragraph is set to Center.
+ let (ids, changes) = {
+ let d = &s.doc()?.doc;
+ let ids: Vec = texts(s, p, c)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect();
+ if ids.is_empty() {
+ return Err(bad(c, "select type on a closed path"));
+ }
+ if let Some(why) = path_center_refusal(d, &ids) {
+ return Err(bad(c, why));
+ }
+ let (mut changes, mut flat) = (vec![], vec![]);
+ for id in &ids {
+ let Some(t) = path_text(d.node(*id)) else { continue };
+ let TextKind::OnPath { start, end, .. } = &t.kind else { continue };
+ let Some((to_start, to_end)) = centered_brackets(t, bottom) else {
+ flat.push(id.0.to_string());
+ continue;
+ };
+ let stays = same_bracket(*start, to_start)
+ && match (end, to_end) {
+ (None, None) => true,
+ (Some(a), Some(b)) => same_bracket(*a, b),
+ _ => false,
+ };
+ let align = !centered(t.para_at(0).justify);
+ if !stays || align {
+ changes.push((*id, (!stays).then_some((to_start, to_end)), align));
+ }
+ }
+ if !flat.is_empty() {
+ let flat = flat.join(", ");
+ return Err(bad(c, format!("type on a path with no length or no height cannot be centered: {flat}")));
+ }
+ (ids, changes)
+ };
+ if !changes.is_empty() {
+ s.edit(if bottom { "Center on Bottom" } else { "Center on Top" }, |d, _| {
+ for (id, brackets, align) in &changes {
+ let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
+ if let (Some(b), TextKind::OnPath { start, end, .. }) = (brackets, &mut t.kind) {
+ (*start, *end) = *b;
+ }
+ if *align {
+ t.edit_paras(Some(0..1), |para| para.justify = Justify::Center);
+ }
+ refresh_bounds(t);
+ }
+ Ok(())
+ })?;
+ }
+ let d = &s.doc()?.doc;
+ let (start, end) = ids.first().and_then(|id| path_text(d.node(*id))).map_or((0.0, None), |t| match &t.kind {
+ TextKind::OnPath { start, end, .. } => (*start, *end),
+ _ => (0.0, None),
+ });
+ Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>(), "start": start, "end": end}))
+}
+
/// The options `type.pathOptions` sets; with none of them given it queries.
const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"];
diff --git a/crates/engine/src/tests/pathtype.rs b/crates/engine/src/tests/pathtype.rs
index 5ead0cbd22..8aa8c05684 100644
--- a/crates/engine/src/tests/pathtype.rs
+++ b/crates/engine/src/tests/pathtype.rs
@@ -184,6 +184,184 @@ fn a_flip_round_a_closed_path_keeps_the_type_in_place() {
assert!(origins(&s, id)[0].distance(first) < 8.0);
}
+/// A circle of radius 100 round (200, 200), starting at 9 o'clock and running clockwise, with
+/// type on it from its start.
+fn on_circle(s: &mut Session, text: &str) -> NodeId {
+ path_type(s, "M100 200 A100 100 0 1 1 300 200 A100 100 0 1 1 100 200 Z", [90.0, 200.0], text)
+}
+
+#[test]
+fn center_on_top_and_bottom_of_a_circle() {
+ let mut s = session();
+ let id = on_circle(&mut s, "OOOO");
+ assert!(near(brackets(&s, id).0, 0.0), "{:?}", brackets(&s, id));
+ let steps = undo_len(&s);
+ let r = s.execute("type.pathCenter.top", &json!({})).unwrap();
+ // Once round, its middle at 12 o'clock, a quarter of the way round: the type sits on the top, centered.
+ let (start, end) = brackets(&s, id);
+ assert!(near(start, 0.75) && end.is_none(), "{start} {end:?}");
+ assert_eq!((r["ids"].clone(), r["end"].clone()), (json!([id.0]), Value::Null));
+ assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center);
+ let top = ink(&s, id);
+ assert!((top.center().x - 200.0).abs() < 0.5 && top.y1 < 101.0, "{top:?}");
+ assert_eq!((undo_len(&s), last_step(&s)), (steps + 1, "Center on Top".to_string()));
+ // Again: nothing moves, and no undo step is recorded.
+ s.execute("type.pathCenter.top", &json!({})).unwrap();
+ assert_eq!(undo_len(&s), steps + 1);
+ // The bottom: 6 o'clock, outside the circle (the type is not flipped).
+ s.execute("type.pathCenter.bottom", &json!({})).unwrap();
+ let (start, end) = brackets(&s, id);
+ assert!(near(start, 0.25) && end.is_none(), "{start} {end:?}");
+ let bottom = ink(&s, id);
+ assert!((bottom.center().x - 200.0).abs() < 0.5 && bottom.y0 > 299.0, "{bottom:?}");
+ assert_eq!((undo_len(&s), last_step(&s)), (steps + 2, "Center on Bottom".to_string()));
+ // Justify Center stays as it is.
+ s.execute("text.setStyle", &json!({"justify": "justifyCenter"})).unwrap();
+ s.execute("type.pathCenter.top", &json!({})).unwrap();
+ assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::JustifyCenter);
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert!(near(brackets(&s, id).0, 0.25));
+ // Brackets already in place with another alignment: only the alignment changes, in one step.
+ s.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
+ let (steps, before) = (undo_len(&s), brackets(&s, id));
+ s.execute("type.pathCenter.bottom", &json!({})).unwrap();
+ assert_eq!((undo_len(&s), brackets(&s, id)), (steps + 1, before));
+ assert_eq!(text(&s, id).para_at(0).justify, vectorcraft_doc::Justify::Center);
+}
+
+#[test]
+fn center_on_top_twice_records_one_step_for_any_span() {
+ let mut s = session();
+ let id = on_circle(&mut s, "OOOO");
+ for (start, end) in [(0.0, 0.2), (0.13, 0.41), (0.37, 0.52), (0.61, 0.97), (0.89, 0.07), (0.3, 0.3001)] {
+ s.execute("type.pathOptions", &json!({"start": start, "end": end})).unwrap();
+ for c in ["type.pathCenter.top", "type.pathCenter.bottom"] {
+ s.execute(c, &json!({})).unwrap();
+ let (steps, placed) = (undo_len(&s), brackets(&s, id));
+ // Again: the brackets stay exactly where they are, and no undo step is recorded.
+ s.execute(c, &json!({})).unwrap();
+ assert_eq!((undo_len(&s), brackets(&s, id)), (steps, placed), "{c} {start} {end}");
+ }
+ }
+}
+
+#[test]
+fn center_on_top_keeps_the_span_and_measures_the_path_as_drawn() {
+ let mut s = session();
+ let id = on_circle(&mut s, "OOOO");
+ // A span of a fifth of the path keeps its length.
+ s.execute("type.pathOptions", &json!({"end": 0.2})).unwrap();
+ s.execute("type.pathCenter.top", &json!({})).unwrap();
+ let (start, end) = brackets(&s, id);
+ assert!(near(start, 0.15) && end.is_some_and(|e| near(e, 0.35)), "{start} {end:?}");
+ // Turned a quarter turn counter-clockwise, its 3 o'clock point, halfway round, is at the top.
+ s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap();
+ s.execute("object.rotate", &json!({"angle": 90})).unwrap();
+ s.execute("type.pathCenter.top", &json!({})).unwrap();
+ let (start, _) = brackets(&s, id);
+ assert!(near(start.rem_euclid(1.0), 0.0) || near(start, 1.0), "{start}");
+ let bounds = text(&s, id).bounds().unwrap();
+ let top = ink(&s, id);
+ assert!((top.center().x - bounds.center().x).abs() < 0.5 && top.y1 < bounds.y0 + 1.0, "{top:?} {bounds:?}");
+ // A rectangle: the middle of its top edge.
+ let id = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO");
+ s.execute("type.pathCenter.top", &json!({})).unwrap();
+ let (start, end) = brackets(&s, id);
+ assert!(near(start, 0.625) && end.is_none(), "{start} {end:?}");
+ assert!((ink(&s, id).center().x - 200.0).abs() < 0.5);
+}
+
+#[test]
+fn center_on_top_moves_every_selected_type_on_a_path_in_one_step() {
+ let mut s = session();
+ let a = on_circle(&mut s, "OOOO");
+ let b = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [90.0, 100.0], "OOOO");
+ let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
+ s.execute("select.set", &json!({"ids": [a.0, b.0, p]})).unwrap();
+ let steps = undo_len(&s);
+ let r = s.execute("type.pathCenter.top", &json!({})).unwrap();
+ assert_eq!(r["ids"], json!([a.0, b.0]));
+ assert!(near(brackets(&s, a).0, 0.75) && near(brackets(&s, b).0, 0.625));
+ assert_eq!(undo_len(&s), steps + 1);
+ // Dynamic Text stays on, and the layout fits the text to the span where it now runs: a fifth of
+ // the circle, at its bottom.
+ s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
+ s.execute("type.pathOptions", &json!({"start": 0, "end": 0.2})).unwrap();
+ s.execute("type.dynamicText", &json!({"on": true})).unwrap();
+ s.execute("type.pathCenter.bottom", &json!({})).unwrap();
+ assert!(text(&s, a).dynamic_text && near(brackets(&s, a).0, 0.65), "{:?}", brackets(&s, a));
+ let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, a));
+ assert!(lay.fit_scale > 1.5 && !lay.overflow, "{}", lay.fit_scale);
+ let bottom = ink(&s, a);
+ assert!((bottom.center().x - 200.0).abs() < 1.0 && bottom.y1 > 300.0, "{bottom:?}");
+ // Vertical type on a path moves the same way.
+ s.execute("type.pathOptions", &json!({"start": 0, "end": null})).unwrap();
+ s.execute("type.orientation.vertical", &json!({})).unwrap();
+ s.execute("type.pathCenter.top", &json!({})).unwrap();
+ assert!(near(brackets(&s, a).0, 0.75));
+}
+
+#[test]
+fn center_on_top_refuses_open_compound_and_threaded_type() {
+ let mut s = session();
+ let circle = on_circle(&mut s, "OOOO");
+ let line = on_line(&mut s);
+ s.execute("select.set", &json!({"ids": [circle.0, line.0]})).unwrap();
+ let steps = undo_len(&s);
+ let before = brackets(&s, circle);
+ let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string();
+ assert!(e.contains("type on an open or compound path cannot be centered on its top or bottom") && e.ends_with(&line.0.to_string()), "{e}");
+ // Nothing changes, the type on the circle included, and the same holds for an explicit list.
+ assert_eq!((undo_len(&s), brackets(&s, circle)), (steps, before));
+ let e = s.execute("type.pathCenter.bottom", &json!({"ids": [circle.0, line.0]})).unwrap_err().to_string();
+ assert!(e.contains("open or compound path") && e.ends_with(&line.0.to_string()), "{e}");
+ s.execute("select.set", &json!({"ids": [circle.0]})).unwrap();
+ for bad in [json!({"ids": "all"}), json!({"ids": [999_999]})] {
+ assert!(s.execute("type.pathCenter.top", &bad).is_err(), "{bad}");
+ }
+ assert_eq!(undo_len(&s), steps);
+ // Two circles side by side in one path: a compound path.
+ let pair = path_type(
+ &mut s,
+ "M100 450 A40 40 0 1 1 180 450 A40 40 0 1 1 100 450 Z M300 450 A40 40 0 1 1 380 450 A40 40 0 1 1 300 450 Z",
+ [90.0, 450.0],
+ "OOOO",
+ );
+ let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string();
+ assert!(e.contains("open or compound path") && e.ends_with(&pair.0.to_string()), "{e}");
+ // A closed path that is level all round has no height.
+ let flat = path_type(&mut s, "M100 400 L300 400 Z", [90.0, 400.0], "OOOO");
+ let e = s.execute("type.pathCenter.top", &json!({})).unwrap_err().to_string();
+ assert!(e.contains("type on a path with no length or no height cannot be centered") && e.ends_with(&flat.0.to_string()), "{e}");
+ // A frame of a thread is refused, alone or with other type on a closed path.
+ let threaded = on_circle(&mut s, "Threaded story");
+ let frame = s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
+ s.execute("select.set", &json!({"ids": [threaded.0, frame]})).unwrap();
+ s.execute("text.thread.create", &json!({})).unwrap();
+ s.execute("select.set", &json!({"ids": [threaded.0]})).unwrap();
+ let before = brackets(&s, threaded);
+ for ids in [json!([threaded.0]), json!([circle.0, threaded.0])] {
+ let steps = undo_len(&s);
+ let e = s.execute("type.pathCenter.top", &json!({ "ids": ids })).unwrap_err().to_string();
+ assert!(e.contains("type on a path in a thread cannot be centered on its top or bottom") && e.ends_with(&threaded.0.to_string()), "{e}");
+ assert_eq!((undo_len(&s), brackets(&s, threaded)), (steps, before));
+ }
+ // Without type on one closed path selected, the commands are not available.
+ let p = s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
+ for (ids, why) in [
+ (json!([p]), "select type on a closed path"),
+ (json!([line.0]), "open or compound path"),
+ (json!([circle.0, line.0]), "open or compound path"),
+ (json!([threaded.0]), "in a thread"),
+ ] {
+ s.execute("select.set", &json!({ "ids": ids })).unwrap();
+ for c in ["type.pathCenter.top", "type.pathCenter.bottom"] {
+ let e = s.execute(c, &json!({})).unwrap_err().to_string();
+ assert!(e.contains(why), "{c} {ids}: {e}");
+ }
+ }
+}
+
#[test]
fn align_to_path_and_spacing() {
let mut s = session();
diff --git a/crates/geom/src/arc.rs b/crates/geom/src/arc.rs
index c4fe640ae2..644dc44f6a 100644
--- a/crates/geom/src/arc.rs
+++ b/crates/geom/src/arc.rs
@@ -2,7 +2,7 @@
//! distance along it of the point nearest another (type on a path lays its glyphs out and the
//! selection tools drag its brackets by distance along the path).
-use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest, PathSeg, Point, Vec2};
+use kurbo::{Affine, ParamCurve, ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest, PathSeg, Point, Vec2};
use crate::PathData;
@@ -76,6 +76,78 @@ impl ArcPath {
pub fn fraction_at(&self, p: Point) -> Option {
(self.len > 0.0).then(|| self.nearest(p)).flatten().map(|s| (s / self.len).clamp(0.0, 1.0))
}
+
+ /// The distance along the path of the middle of its topmost points (the least y) where `xf`
+ /// draws it: the topmost point when there is one, the middle of a level stretch (a
+ /// rectangle's top edge), and when several points or stretches are topmost, the middle of the
+ /// shortest part of the path that holds them all. None for a path with no length, or a closed
+ /// path whose every point is topmost.
+ pub fn topmost(&self, xf: Affine) -> Option {
+ let drawn: Vec<(PathSeg, PathSeg, f64, f64)> = self.segs.iter().map(|&(s, c, l)| (s, xf * s, c, l)).collect();
+ // The candidates for the least y in each segment: its ends and where it turns.
+ let turns = |d: &PathSeg| [0.0, 1.0].into_iter().chain(d.extrema()).collect::>();
+ let mut bounds: Option = None;
+ let mut top = f64::INFINITY;
+ for (_, d, _, _) in &drawn {
+ let b = d.bounding_box();
+ bounds = Some(bounds.map_or(b, |a| a.union(b)));
+ top = turns(d).into_iter().map(|t| d.eval(t).y).fold(top, f64::min);
+ }
+ let b = bounds?;
+ if !(top.is_finite() && b.width().is_finite() && b.height().is_finite()) {
+ return None;
+ }
+ // Points this close to the top count as topmost: a part of the drawn size, and no less than
+ // the rounding error of coordinates this far from the origin.
+ let tol = (1e-9 * (b.width() + b.height())).max(64.0 * f64::EPSILON * b.y0.abs().max(b.y1.abs()));
+ // Stretches this close along the path meet.
+ let near = 1e-9 * self.len;
+ // The topmost points as stretches of distance along the path, in order.
+ let mut runs: Vec<(f64, f64)> = vec![];
+ for (s, d, c, l) in &drawn {
+ let db = d.bounding_box();
+ if db.y1 - db.y0 <= tol && db.y0 <= top + tol {
+ runs.push((*c, c + l));
+ continue;
+ }
+ for t in turns(d) {
+ if d.eval(t).y <= top + tol {
+ let at = c + s.subsegment(0.0..t).arclen(ACCURACY).min(*l);
+ runs.push((at, at));
+ }
+ }
+ }
+ runs.sort_by(|a, b| a.0.total_cmp(&b.0));
+ // Stretches that meet or overlap are one.
+ let mut merged: Vec<(f64, f64)> = vec![];
+ for (a, z) in runs {
+ match merged.last_mut() {
+ Some(last) if a <= last.1 + near => last.1 = last.1.max(z),
+ _ => merged.push((a, z)),
+ }
+ }
+ let (first, last) = (*merged.first()?, *merged.last()?);
+ if !self.closed {
+ return Some((first.0 + last.1) * 0.5);
+ }
+ let len = self.len;
+ // Round a closed path, the shortest part holding every stretch leaves out the longest
+ // gap between two of them (the gap from the last round to the first included).
+ let wrap = first.0 + len - last.1;
+ let (mut gap, mut from, mut to) = (wrap, first.0, last.1);
+ for w in merged.windows(2) {
+ if let [a, b] = w
+ && b.0 - a.1 > gap
+ {
+ (gap, from, to) = (b.0 - a.1, b.0, a.1);
+ }
+ }
+ if gap <= near {
+ return None;
+ }
+ let run = (to - from).rem_euclid(len);
+ Some((from + run * 0.5).rem_euclid(len))
+ }
}
#[cfg(test)]
@@ -99,4 +171,64 @@ mod tests {
assert!(sq.at(45.0).0.distance(sq.at(5.0).0) < 1e-6);
assert!(ArcPath::new(&PathData::default()).is_empty());
}
+
+ fn polyline(points: &[Point], closed: bool) -> PathData {
+ PathData::single(crate::SubPath::polyline(points, closed))
+ }
+
+ /// Where `topmost` puts the middle, drawn by `xf`.
+ fn top_point(a: &ArcPath, xf: Affine) -> Point {
+ xf * a.at(a.topmost(xf).unwrap()).0
+ }
+
+ #[test]
+ fn topmost_points_as_drawn() {
+ // A circle starts at 9 o'clock and runs clockwise (y down): its top is a quarter of the way round, its
+ // bottom three quarters.
+ let circle = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)));
+ assert!((circle.topmost(Affine::IDENTITY).unwrap() - circle.len() * 0.25).abs() < 1e-6);
+ assert!((circle.topmost(Affine::FLIP_Y).unwrap() - circle.len() * 0.75).abs() < 1e-6);
+ // As the transform draws it: turned a quarter turn the other way, 3 o'clock is at the top.
+ let turned = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(50.0, 50.0));
+ assert!((circle.topmost(turned).unwrap() - circle.len() * 0.5).abs() < 1e-6);
+ // Turned part way, the top lies inside a segment.
+ let tilted = Affine::rotate_about(0.4, Point::new(50.0, 50.0));
+ assert!(top_point(&circle, tilted).distance(Point::new(50.0, 0.0)) < 0.05, "{:?}", top_point(&circle, tilted));
+ // An ellipse squashed and skewed by its transform.
+ let skewed = Affine::skew(0.5, 0.0) * Affine::scale_non_uniform(2.0, 0.5);
+ let p = top_point(&circle, skewed);
+ let lowest = (0..3600).map(|i| (skewed * circle.at(circle.len() * i as f64 / 3600.0).0).y).fold(f64::INFINITY, f64::min);
+ assert!((p.y - lowest).abs() < 1e-3, "{p:?} {lowest}");
+ // A level top: the middle of the top edge, for a rectangle and a rounded rectangle.
+ for path in [shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), shapes::rounded_rectangle(Rect::new(10.0, 20.0, 110.0, 70.0), 8.0)] {
+ let a = ArcPath::new(&path);
+ assert!(top_point(&a, Affine::IDENTITY).distance(Point::new(60.0, 20.0)) < 1e-6, "{:?}", top_point(&a, Affine::IDENTITY));
+ assert!(top_point(&a, Affine::FLIP_Y).distance(Point::new(60.0, -70.0)) < 1e-6);
+ }
+ // Far from the origin the top is still the top edge's middle.
+ let square = ArcPath::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)));
+ let far = Affine::translate((1e12, 1e12));
+ assert!(top_point(&square, far).distance(Point::new(1e12 + 50.0, 1e12)) < 1e-3, "{:?}", top_point(&square, far));
+ // A level top across the path's start: the start in the middle of the top edge.
+ let across = ArcPath::new(&polyline(
+ &[Point::new(50.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0), Point::new(0.0, 50.0), Point::new(0.0, 0.0)],
+ true,
+ ));
+ assert!(top_point(&across, Affine::IDENTITY).distance(Point::new(50.0, 0.0)) < 1e-6);
+ // Two peaks: the middle of the shortest part that holds both, the dip between them.
+ let peaks = ArcPath::new(&polyline(
+ &[Point::new(0.0, 10.0), Point::new(2.0, 0.0), Point::new(4.0, 5.0), Point::new(6.0, 0.0), Point::new(8.0, 10.0)],
+ true,
+ ));
+ assert!(top_point(&peaks, Affine::IDENTITY).distance(Point::new(4.0, 5.0)) < 1e-6, "{:?}", top_point(&peaks, Affine::IDENTITY));
+ // An open path: the middle of its topmost points too.
+ let open = ArcPath::new(&polyline(&[Point::new(0.0, 10.0), Point::new(5.0, 0.0), Point::new(10.0, 10.0)], false));
+ assert!(top_point(&open, Affine::IDENTITY).distance(Point::new(5.0, 0.0)) < 1e-6);
+ // No top: a closed path that is level all round, and a path with no length.
+ let flat = ArcPath::new(&shapes::ellipse(Rect::new(0.0, 10.0, 100.0, 10.0)));
+ assert!(!flat.is_empty() && flat.topmost(Affine::IDENTITY).is_none());
+ assert!(ArcPath::new(&PathData::default()).topmost(Affine::IDENTITY).is_none());
+ let point = ArcPath::new(&polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], true));
+ assert!(point.topmost(Affine::IDENTITY).is_none());
+ }
}
diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs
index eb038f0790..3fe0f99a92 100644
--- a/crates/ui-egui/src/canvas/task_bar.rs
+++ b/crates/ui-egui/src/canvas/task_bar.rs
@@ -158,6 +158,7 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
if crate::menus::enabled(app, "type.dynamicText") {
items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none()));
}
+ center_on_path_items(app, &mut items);
}
}
} else if is_image {
@@ -349,6 +350,18 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
}
}
+/// The Contextual Task Bar's Center on Top and Center on Bottom, as in the Type menu, for type on a
+/// closed path.
+fn center_on_path_items(app: &VectorcraftApp, items: &mut Vec<(&'static str, &'static str, &'static str, serde_json::Value)>) {
+ for (label, icon, id) in
+ [(tl!("Center on Top"), "dc-al-top", "type.pathCenter.top"), (tl!("Center on Bottom"), "dc-al-bottom", "type.pathCenter.bottom")]
+ {
+ if crate::menus::enabled(app, id) {
+ items.push((label, icon, id, json!({})));
+ }
+ }
+}
+
/// The Contextual Task Bar's Image Trace button: a trace with the Default preset.
const TASK_BAR_IMAGE_TRACE: &str = "taskBar.imageTrace";
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index d83b5ac3c6..d1658c0e24 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -3584,6 +3584,8 @@ digits Malayalam Малаялам
Type on a Path Текст по пътя
Type on a Path Options Опции за текст по пътя
Type on a Path Options… Опции за текст по път…
+ Center on Top Центриране горе
+ Center on Bottom Центриране долу
Type on a Path Tool Инструмент „Текст по път“
Type Orientation Ориентация на текста
Type stays text, and every stroke is a line with its lineweight and dashes. Текстът остава текст, а всеки щрих е линия с дебелина и чертички.
diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv
index 663e95ceaa..f8ed0e1588 100644
--- a/crates/ui-egui/src/i18n/bs.tsv
+++ b/crates/ui-egui/src/i18n/bs.tsv
@@ -384,6 +384,8 @@
Stair Step Stepenice
Gravity Gravitacija
Type on a Path Options… Opcije teksta na putanji…
+ Center on Top Centriraj na vrhu
+ Center on Bottom Centriraj na dnu
Threaded Text Povezani tekst
Create Kreiraj
Release Selection Oslobodi odabir
diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv
index 9c60e87e00..a8c2f0e2a5 100644
--- a/crates/ui-egui/src/i18n/ca.tsv
+++ b/crates/ui-egui/src/i18n/ca.tsv
@@ -2886,6 +2886,10 @@ digits Malayalam Malaiàlam
Optical Margin Alignment Alineació òptica de marges
Dynamic Text Text dinàmic
@msg type on a path in a thread cannot use Dynamic Text: {frames} el text sobre un traçat d’un text enllaçat no pot fer servir el text dinàmic: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} el text sobre un traçat obert o compost no es pot centrar a dalt o a baix: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} el text sobre un traçat sense longitud o sense alçada no es pot centrar: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el text sobre un traçat d’un text enllaçat no es pot centrar a dalt o a baix: {frames}
+@msg select type on a closed path seleccioneu text sobre un traçat tancat
Optimize for fast web view Optimitza per a la visualització web ràpida
Optimized Optimitzat
Options Opcions
@@ -4626,6 +4630,8 @@ digits Malayalam Malaiàlam
Type on a Path Text sobre un traçat
Type on a Path Options Opcions de text sobre un traçat
Type on a Path Options… Opcions de text sobre un traçat…
+ Center on Top Centra a dalt
+ Center on Bottom Centra a baix
Type on a Path Tool Eina de text sobre un traçat
Type Optimized Optimitzat per al text
Type Orientation Orientació del text
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index e8f9666fae..af7e79a8ea 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -391,6 +391,8 @@
Stair Step Schody
Gravity Gravitace
Type on a Path Options… Možnosti textu na cestě…
+ Center on Top Vystředit nahoře
+ Center on Bottom Vystředit dole
Threaded Text Zřetězený text
Create Vytvořit
Release Selection Uvolnit výběr
@@ -408,6 +410,10 @@
Optical Margin Alignment Optické zarovnání okrajů
Dynamic Text Dynamický text
@msg type on a path in a thread cannot use Dynamic Text: {frames} text na cestě ve zřetězeném textu nemůže používat dynamický text: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} text na otevřené nebo složené cestě nelze vystředit nahoře ani dole: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} text na cestě bez délky nebo bez výšky nelze vystředit: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} text na cestě ve zřetězeném textu nelze vystředit nahoře ani dole: {frames}
+@msg select type on a closed path vyberte text na uzavřené cestě
Fill with Placeholder Text Vyplnit zástupným textem
Insert Inline Symbol Vložit symbol do textu
Show Hidden Characters Zobrazit skryté znaky
diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv
index 31915ed6bf..9c0e6205f9 100644
--- a/crates/ui-egui/src/i18n/da.tsv
+++ b/crates/ui-egui/src/i18n/da.tsv
@@ -352,6 +352,8 @@
Stair Step Trappetrin
Gravity Tyngdekraft
Type on a Path Options… Indstillinger for tekst på kurve…
+ Center on Top Centrer øverst
+ Center on Bottom Centrer nederst
Threaded Text Sammenkædet tekst
Create Opret
Release Selection Frigør markering
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 2c5424d81f..0654e68296 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -2291,6 +2291,10 @@ file-filter Templates Vorlagen
Optical Margin Alignment Optischer Randausgleich
Dynamic Text Dynamischer Text
@msg type on a path in a thread cannot use Dynamic Text: {frames} Pfadtext in verkettetem Text kann keinen dynamischen Text verwenden: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} Pfadtext auf einem offenen oder zusammengesetzten Pfad lässt sich nicht oben oder unten zentrieren: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} Pfadtext auf einem Pfad ohne Länge oder ohne Höhe lässt sich nicht zentrieren: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} Pfadtext in verkettetem Text lässt sich nicht oben oder unten zentrieren: {frames}
+@msg select type on a closed path wählen Sie Pfadtext auf einem geschlossenen Pfad aus
Optimize for fast web view Für schnelle Webanzeige optimieren
Optimized Optimiert
Options Optionen
@@ -3618,6 +3622,8 @@ effect Points Punkte
Type on a Path Pfadtext
Type on a Path Options Pfadtextoptionen
Type on a Path Options… Pfadtextoptionen…
+ Center on Top Oben zentrieren
+ Center on Bottom Unten zentrieren
Type on a Path Tool Pfadtext-Werkzeug
Type Orientation Textrichtung
Type stays text, and every stroke is a line with its lineweight and dashes. Text bleibt Text, und jede Kontur ist eine Linie mit ihrer Linienstärke und ihren Strichen.
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index 6071ed5be4..7993587dac 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -3053,6 +3053,10 @@ theme Appearance Εμφάνιση
Optical Margin Alignment Οπτική στοίχιση περιθωρίου
Dynamic Text Δυναμικό κείμενο
@msg type on a path in a thread cannot use Dynamic Text: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να χρησιμοποιήσει δυναμικό κείμενο: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} κείμενο σε ανοιχτή ή σύνθετη διαδρομή δεν μπορεί να κεντραριστεί επάνω ή κάτω: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} κείμενο σε διαδρομή χωρίς μήκος ή χωρίς ύψος δεν μπορεί να κεντραριστεί: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να κεντραριστεί επάνω ή κάτω: {frames}
+@msg select type on a closed path επιλέξτε κείμενο σε κλειστή διαδρομή
Optimize for fast web view Βελτιστοποίηση για γρήγορη προβολή στον ιστό
Options for Selection… Επιλογές για την επιλογή…
or more spaces with a tab ή περισσότερα διαστήματα με στηλοθέτη
@@ -4005,6 +4009,8 @@ digits Malayalam Μαλαγιάλαμ
Type on a Path Κείμενο σε διαδρομή
Type on a Path Options Επιλογές κειμένου σε διαδρομή
Type on a Path Options… Επιλογές κειμένου σε διαδρομή…
+ Center on Top Κεντράρισμα επάνω
+ Center on Bottom Κεντράρισμα κάτω
Type on a Path Tool Κείμενο σε διαδρομή (Type on a Path Tool)
Type Orientation Προσανατολισμός κειμένου
Type stays text, and every stroke is a line with its lineweight and dashes. Το κείμενο διατηρείται ως κείμενο και κάθε περίγραμμα γίνεται γραμμή με το πάχος και τις παύλες του.
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 4a74e3ae39..84f6114c87 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -2337,6 +2337,10 @@ font-technology Variable Variables
Optical Margin Alignment Alineación óptica de márgenes
Dynamic Text Texto dinámico
@msg type on a path in a thread cannot use Dynamic Text: {frames} el texto en trazado de un texto enlazado no puede usar texto dinámico: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} el texto en un trazado abierto o compuesto no se puede centrar arriba o abajo: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} el texto en un trazado sin longitud o sin altura no se puede centrar: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} el texto en trazado de un texto enlazado no se puede centrar arriba o abajo: {frames}
+@msg select type on a closed path seleccione texto en un trazado cerrado
Optimize for fast web view Optimizar para vista web rápida
Optimized Optimizado
Options Opciones
@@ -3672,6 +3676,8 @@ digits Malayalam Malayálam
Type on a Path Texto en trazado
Type on a Path Options Opciones de texto en trazado
Type on a Path Options… Opciones de texto en trazado…
+ Center on Top Centrar arriba
+ Center on Bottom Centrar abajo
Type on a Path Tool Herramienta Texto en trazado
Type Orientation Orientación del texto
Type stays text, and every stroke is a line with its lineweight and dashes. El texto sigue siendo texto, y cada trazo es una línea con su grosor y guiones.
diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv
index f2ec64f602..3c688e2b93 100644
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -388,6 +388,8 @@
Stair Step Porras
Gravity Painovoima
Type on a Path Options… Teksti polulla -asetukset…
+ Center on Top Keskitä ylös
+ Center on Bottom Keskitä alas
Threaded Text Ketjutettu teksti
Create Luo
Release Selection Vapauta valinta
@@ -405,6 +407,10 @@
Optical Margin Alignment Optinen reunustasaus
Dynamic Text Dynaaminen teksti
@msg type on a path in a thread cannot use Dynamic Text: {frames} ketjutetun tekstin polkuteksti ei voi käyttää dynaamista tekstiä: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} avoimella polulla tai yhdistelmäpolulla olevaa tekstiä ei voi keskittää ylös tai alas: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} polkutekstiä ei voi keskittää polulle, jolla ei ole pituutta tai korkeutta: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} ketjutetun tekstin polkutekstiä ei voi keskittää ylös tai alas: {frames}
+@msg select type on a closed path valitse suljetulla polulla oleva teksti
Fill with Placeholder Text Täytä paikkamerkkitekstillä
Insert Inline Symbol Lisää tekstinsisäinen symboli
Show Hidden Characters Näytä piilotetut merkit
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 807b3363b4..9c48aaeb56 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -2329,6 +2329,10 @@ font-technology Variable Variables
Optical Margin Alignment Alignement optique des marges
Dynamic Text Texte dynamique
@msg type on a path in a thread cannot use Dynamic Text: {frames} le texte curviligne d’un texte lié ne peut pas utiliser le texte dynamique : {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} un texte curviligne sur un tracé ouvert ou un tracé transparent ne peut pas être centré en haut ou en bas : {open}
+@msg type on a path with no length or no height cannot be centered: {flat} un texte curviligne sur un tracé sans longueur ou sans hauteur ne peut pas être centré : {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} le texte curviligne d’un texte lié ne peut pas être centré en haut ou en bas : {frames}
+@msg select type on a closed path sélectionnez un texte curviligne sur un tracé fermé
Optimize for fast web view Optimiser pour un affichage web rapide
Optimized Optimisé
Options Options
@@ -3663,6 +3667,8 @@ digits Malayalam Malayalam
Type on a Path Texte curviligne
Type on a Path Options Options de texte curviligne
Type on a Path Options… Options de texte curviligne…
+ Center on Top Centrer en haut
+ Center on Bottom Centrer en bas
Type on a Path Tool Outil Texte curviligne
Type Orientation Orientation du texte
Type stays text, and every stroke is a line with its lineweight and dashes. Le texte reste du texte, et chaque contour devient une ligne avec son épaisseur et ses tirets.
diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv
index 572a19779f..8f5e9e7121 100644
--- a/crates/ui-egui/src/i18n/hi.tsv
+++ b/crates/ui-egui/src/i18n/hi.tsv
@@ -3139,6 +3139,10 @@ antiAlias Sharp शार्प
Optical Margin Alignment ऑप्टिकल मार्जिन संरेखण
Dynamic Text डायनैमिक टेक्स्ट
@msg type on a path in a thread cannot use Dynamic Text: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप डायनैमिक टेक्स्ट का उपयोग नहीं कर सकता: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} खुले पाथ या कंपाउंड पाथ पर टाइप को ऊपर या नीचे केंद्रित नहीं किया जा सकता: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} बिना लंबाई या बिना ऊँचाई वाले पाथ पर टाइप केंद्रित नहीं किया जा सकता: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप को ऊपर या नीचे केंद्रित नहीं किया जा सकता: {frames}
+@msg select type on a closed path बंद पाथ पर टाइप चुनें
Optimize for fast web view तेजी से वेब दृश्य के लिए ऑप्टिमाइज़
Optimized ऑप्टिमाइज़्ड
Options ऑप्शन्स
@@ -5014,6 +5018,8 @@ antiAlias Sharp शार्प
Type on a Path पाथ पर टाइप
Type on a Path Options पाथ पर टाइप ऑप्शन्स
Type on a Path Options… पाथ पर टाइप ऑप्शन्स…
+ Center on Top ऊपर केंद्रित करें
+ Center on Bottom नीचे केंद्रित करें
Type on a Path Tool पाथ पर टाइप टूल
Type Optimized टाइप ऑप्टिमाइज़
Type Orientation प्रकार अभिविन्यास
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 8c2d2339bc..42b92d9e7d 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -2824,6 +2824,10 @@ file-filter Templates Sablonok
Optical Margin Alignment Optikai margóigazítás
Dynamic Text Dinamikus szöveg
@msg type on a path in a thread cannot use Dynamic Text: {frames} a láncolt szövegben lévő útvonalszöveg nem használhat dinamikus szöveget: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} nyitott vagy összetett útvonalon lévő útvonalszöveg nem igazítható középre felül vagy alul: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} a hossz vagy magasság nélküli útvonalon lévő útvonalszöveg nem igazítható középre: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} a láncolt szövegben lévő útvonalszöveg nem igazítható középre felül vagy alul: {frames}
+@msg select type on a closed path jelöljön ki zárt útvonalon lévő útvonalszöveget
Optimize for fast web view Optimalizálás gyors webes megtekintéshez
Optimized Optimalizált
Options Beállítások
@@ -4382,6 +4386,8 @@ mark-style Japanese Japán
Type on a Path Útvonalszöveg
Type on a Path Options Útvonalszöveg beállításai
Type on a Path Options… Útvonalszöveg beállításai…
+ Center on Top Középre igazítás felül
+ Center on Bottom Középre igazítás alul
Type on a Path Tool Útvonalszöveg
Type Optimized Szövegre optimalizált
Type Orientation Szöveg iránya
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
index 606ffae97c..f717f4b892 100644
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -352,6 +352,8 @@
Stair Step Undakan tangga
Gravity Gravitasi
Type on a Path Options… Opsi teks pada jalur…
+ Center on Top Pusatkan di atas
+ Center on Bottom Pusatkan di bawah
Threaded Text Teks beruntun
Create Buat
Release Selection Lepaskan seleksi
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index bf4c9e7b58..f47795e013 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -2367,6 +2367,10 @@ file-filter Templates Modelli
Optical Margin Alignment Allineamento ottico dei margini
Dynamic Text Testo dinamico
@msg type on a path in a thread cannot use Dynamic Text: {frames} il testo su tracciato in un testo concatenato non può usare il testo dinamico: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} il testo su un tracciato aperto o composto non può essere centrato in alto o in basso: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} il testo su un tracciato senza lunghezza o senza altezza non può essere centrato: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} il testo su tracciato in un testo concatenato non può essere centrato in alto o in basso: {frames}
+@msg select type on a closed path seleziona del testo su un tracciato chiuso
Optimize for fast web view Ottimizza per visualizzazione web veloce
Optimized Ottimizzato
Options Opzioni
@@ -3710,6 +3714,8 @@ digits Malayalam Malayalam
Type on a Path Testo su tracciato
Type on a Path Options Opzioni testo su tracciato
Type on a Path Options… Opzioni testo su tracciato…
+ Center on Top Centra in alto
+ Center on Bottom Centra in basso
Type on a Path Tool Strumento Testo su tracciato
Type Orientation Orientamento testo
Type stays text, and every stroke is a line with its lineweight and dashes. Il testo resta testo e ogni traccia diventa una linea con il suo spessore e i suoi tratteggi.
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index 7a12afcadb..3910041eaf 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -409,6 +409,8 @@ digits Malayalam マラヤーラム数字
Stair Step 階段
Gravity 引力
Type on a Path Options… パス上文字オプション…
+ Center on Top 上端に中央揃え
+ Center on Bottom 下端に中央揃え
Threaded Text スレッドテキストオプション
Create 作成
Release Selection 選択部分をリリース
@@ -436,6 +438,10 @@ digits Malayalam マラヤーラム数字
Optical Margin Alignment 最適なマージン揃え
Dynamic Text ダイナミックテキスト
@msg type on a path in a thread cannot use Dynamic Text: {frames} スレッドテキスト内のパス上文字にはダイナミックテキストを使用できません: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} オープンパスや複合パス上のパス上文字は上端または下端に中央揃えできません: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} 長さや高さのないパスのパス上文字は中央揃えできません: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} スレッドテキスト内のパス上文字は上端または下端に中央揃えできません: {frames}
+@msg select type on a closed path クローズパス上のパス上文字を選択してください
Fill with Placeholder Text サンプルテキストの割り付け
Insert Inline Symbol インラインシンボルを挿入
Show Hidden Characters 制御文字を表示
diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv
index c58480c426..570ca353a1 100644
--- a/crates/ui-egui/src/i18n/ka.tsv
+++ b/crates/ui-egui/src/i18n/ka.tsv
@@ -317,6 +317,8 @@
Stair Step საფეხურები
Gravity გრავიტაცია
Type on a Path Options… ტრაექტორიაზე ტექსტის პარამეტრები…
+ Center on Top ზემოთ ცენტრში მოთავსება
+ Center on Bottom ქვემოთ ცენტრში მოთავსება
Threaded Text დაკავშირებული ტექსტი
Create შექმნა
Release Selection მონიშნულის მოხსნა
@@ -334,6 +336,10 @@
Optical Margin Alignment ოპტიკური არშიის სწორება
Dynamic Text დინამიკური ტექსტი
@msg type on a path in a thread cannot use Dynamic Text: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ გამოიყენებს დინამიკურ ტექსტს: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} ღია ან შედგენილ ტრაექტორიაზე ტექსტი ვერ მოთავსდება ზემოთ ან ქვემოთ ცენტრში: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} ტექსტი ვერ მოთავსდება ცენტრში ტრაექტორიაზე, რომელსაც არ აქვს სიგრძე ან სიმაღლე: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ მოთავსდება ზემოთ ან ქვემოთ ცენტრში: {frames}
+@msg select type on a closed path მონიშნეთ დახურულ ტრაექტორიაზე ტექსტი
Fill with Placeholder Text შემავსებელი ტექსტით შევსება
Insert Inline Symbol ჩადგმული სიმბოლოს ჩასმა
Show Hidden Characters დამალული სიმბოლოების ჩვენება
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
index 39efdecf2e..3d42eb69d8 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -402,6 +402,8 @@ digits Malayalam 말라얄람어
Stair Step 계단
Gravity 중력
Type on a Path Options… 패스 위 문자 옵션…
+ Center on Top 위쪽에 가운데 맞춤
+ Center on Bottom 아래쪽에 가운데 맞춤
Threaded Text 연결된 문자
Create 만들기
Release Selection 선택 해제
@@ -429,6 +431,10 @@ digits Malayalam 말라얄람어
Optical Margin Alignment 시각적 여백 정렬
Dynamic Text 동적 텍스트
@msg type on a path in a thread cannot use Dynamic Text: {frames} 연결된 문자의 패스 위 문자에는 동적 텍스트를 사용할 수 없습니다: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} 열린 패스나 컴파운드 패스 위 문자는 위쪽이나 아래쪽에 가운데 맞춤할 수 없습니다: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} 길이나 높이가 없는 패스 위 문자는 가운데 맞춤할 수 없습니다: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 연결된 문자의 패스 위 문자는 위쪽이나 아래쪽에 가운데 맞춤할 수 없습니다: {frames}
+@msg select type on a closed path 닫힌 패스 위 문자를 선택하세요
Fill with Placeholder Text 자리 표시 문자로 채우기
Insert Inline Symbol 인라인 심볼 삽입
Show Hidden Characters 숨겨진 문자 표시
diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv
index dd1d22edc0..3ef28b6895 100644
--- a/crates/ui-egui/src/i18n/nl.tsv
+++ b/crates/ui-egui/src/i18n/nl.tsv
@@ -366,6 +366,8 @@
Stair Step Trap
Gravity Zwaartekracht
Type on a Path Options… Opties voor tekst op pad…
+ Center on Top Boven centreren
+ Center on Bottom Onder centreren
Threaded Text Gekoppelde tekst
Create Maken
Release Selection Selectie vrijgeven
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index dd850a3a4a..600552b22b 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -350,6 +350,8 @@
Stair Step Schodki
Gravity Grawitacja
Type on a Path Options… Opcje tekstu na ścieżce…
+ Center on Top Wyśrodkuj na górze
+ Center on Bottom Wyśrodkuj na dole
Threaded Text Tekst wątkowy
Create Utwórz
Release Selection Uwolnij zaznaczenie
@@ -367,6 +369,10 @@
Optical Margin Alignment Optyczne wyrównanie marginesów
Dynamic Text Tekst dynamiczny
@msg type on a path in a thread cannot use Dynamic Text: {frames} tekst na ścieżce w tekście wątkowym nie może używać tekstu dynamicznego: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} nie można wyśrodkować na górze ani na dole tekstu na ścieżce otwartej lub złożonej: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} nie można wyśrodkować tekstu na ścieżce bez długości lub bez wysokości: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} nie można wyśrodkować na górze ani na dole tekstu na ścieżce w tekście wątkowym: {frames}
+@msg select type on a closed path zaznacz tekst na zamkniętej ścieżce
Fill with Placeholder Text Wypełnij tekstem zastępczym
Insert Inline Symbol Wstaw symbol w tekście
Show Hidden Characters Pokaż ukryte znaki
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index f67ae6cda2..13e9424fdd 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -1933,6 +1933,10 @@ antiAlias Sharp Nítido
Optical Margin Alignment Alinhamento de Margem Óptica
Dynamic Text Texto Dinâmico
@msg type on a path in a thread cannot use Dynamic Text: {frames} o texto em um caminho de um texto encadeado não pode usar Texto Dinâmico: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} o texto em um caminho aberto ou composto não pode ser centralizado em cima ou embaixo: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} o texto em um caminho sem comprimento ou sem altura não pode ser centralizado: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} o texto em um caminho de um texto encadeado não pode ser centralizado em cima ou embaixo: {frames}
+@msg select type on a closed path selecione texto em um caminho fechado
Optimize for fast web view Otimizar para visualização rápida na web
Optimized Otimizado
Options Opções
@@ -3021,6 +3025,8 @@ digits Malayalam Malaiala
Type becomes outlines, and strokes a CAD line can't draw become filled shapes. O texto vira contornos, e os traços que uma linha CAD não pode desenhar viram formas preenchidas.
Type on a Path Texto em um Caminho
Type on a Path Options… Opções de Texto em um Caminho…
+ Center on Top Centralizar em Cima
+ Center on Bottom Centralizar em Baixo
Type on a Path Tool Ferramenta Texto em um Caminho
Type stays text, and every stroke is a line with its lineweight and dashes. O texto permanece texto, e todo traço é uma linha com sua espessura e tracejados.
Type: Tipo:
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 8eb5425823..e66f350c6a 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -2362,6 +2362,10 @@ file-filter Templates Шаблоны
Optical Margin Alignment Оптическое выравнивание полей
Dynamic Text Динамический текст
@msg type on a path in a thread cannot use Dynamic Text: {frames} текст по контуру в связанном тексте не может использовать динамический текст: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} нельзя центрировать вверху или внизу текст по незамкнутому или составному контуру: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} нельзя центрировать текст по контуру без длины или без высоты: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} нельзя центрировать вверху или внизу текст по контуру в связанном тексте: {frames}
+@msg select type on a closed path выделите текст по замкнутому контуру
Optimize for fast web view Оптимизировать для быстрого просмотра в web
Optimized Оптимизировано
Options Параметры
@@ -3704,6 +3708,8 @@ digits Malayalam Малаялам
Type on a Path Текст по контуру
Type on a Path Options Параметры текста по контуру
Type on a Path Options… Параметры текста по контуру…
+ Center on Top Центрировать вверху
+ Center on Bottom Центрировать внизу
Type on a Path Tool Инструмент «Текст по контуру»
Type Orientation Ориентация текста
Type stays text, and every stroke is a line with its lineweight and dashes. Текст остаётся текстом, и каждая обводка — линия с её толщиной и штрихами.
diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv
index fb2c463de0..cfdd5bef9d 100644
--- a/crates/ui-egui/src/i18n/sv.tsv
+++ b/crates/ui-egui/src/i18n/sv.tsv
@@ -2276,6 +2276,10 @@ font-technology Variable Variabla
Optical Margin Alignment Optisk marginaljustering
Dynamic Text Dynamisk text
@msg type on a path in a thread cannot use Dynamic Text: {frames} text på bana i länkad text kan inte använda dynamisk text: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} text på en öppen eller sammansatt bana kan inte centreras överst eller nederst: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} text på en bana utan längd eller utan höjd kan inte centreras: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} text på bana i länkad text kan inte centreras överst eller nederst: {frames}
+@msg select type on a closed path markera text på en sluten bana
Optimize for fast web view Optimera för snabb webbvisning
Optimized Optimerad
Options Alternativ
@@ -3593,6 +3597,8 @@ digits Malayalam Malayalam
Type on a Path Text på bana
Type on a Path Options Alternativ för text på bana
Type on a Path Options… Alternativ för text på bana…
+ Center on Top Centrera överst
+ Center on Bottom Centrera nederst
Type on a Path Tool Verktyg för text på bana
Type Orientation Textorientering
Type stays text, and every stroke is a line with its lineweight and dashes. Text förblir text, och varje kontur blir en linje med sin linjetjocklek och sina streck.
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index d45a171cc6..606e994763 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -2148,6 +2148,10 @@ shape Open Açık
Optical Margin Alignment Optik Kenar Boşluğu Hizalaması
Dynamic Text Dinamik Metin
@msg type on a path in a thread cannot use Dynamic Text: {frames} zincirli metindeki yol üzerindeki yazı dinamik metin kullanamaz: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} açık veya bileşik bir yol üzerindeki yazı üstte veya altta ortalanamaz: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} uzunluğu ya da yüksekliği olmayan yol üzerindeki yazı ortalanamaz: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} zincirli metindeki yol üzerindeki yazı üstte veya altta ortalanamaz: {frames}
+@msg select type on a closed path kapalı bir yol üzerindeki yazıyı seçin
Optimize for fast web view Hızlı web görünümü için optimize edin
Optimized Optimize edilmiş
Options Seçenekler
@@ -3481,6 +3485,8 @@ digits Malayalam Malayalam
Type on a Path Bir Yola Yazın
Type on a Path Options Yol Seçenekleri Üzerine Yazma
Type on a Path Options… Yol Seçenekleri Üzerine Yazma…
+ Center on Top Üstte ortala
+ Center on Bottom Altta ortala
Type on a Path Tool Yola Yazma Aracı
Type Orientation Tip Yönü
Type stays text, and every stroke is a line with its lineweight and dashes. Yazı metin olarak kalır ve her kontur, çizgi kalınlığı ve tireleriyle birlikte bir çizgidir.
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index acaf20b6b9..fa5cd060d6 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -2359,6 +2359,10 @@ file-filter Templates Шаблони
Optical Margin Alignment Оптичне вирівнювання полів
Dynamic Text Динамічний текст
@msg type on a path in a thread cannot use Dynamic Text: {frames} текст за контуром у пов’язаному тексті не може використовувати динамічний текст: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} не можна центрувати вгорі чи внизу текст за незамкненим або складеним контуром: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} не можна центрувати текст за контуром без довжини або без висоти: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} не можна центрувати вгорі чи внизу текст за контуром у пов’язаному тексті: {frames}
+@msg select type on a closed path виділіть текст за замкненим контуром
Optimize for fast web view Оптимізувати для швидкого перегляду web
Optimized Оптимізовано
Options Параметри
@@ -3703,6 +3707,8 @@ effect Points Точки
Type on a Path Текст за контуром
Type on a Path Options Параметри тексту за контуром
Type on a Path Options… Параметри тексту за контуром…
+ Center on Top Центрувати вгорі
+ Center on Bottom Центрувати внизу
Type on a Path Tool Інструмент «Текст за контуром»
Type Orientation Орієнтація тексту
Type stays text, and every stroke is a line with its lineweight and dashes. Текст залишається текстом, і кожне обведення — лінія з її товщиною та штрихами.
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index 4a2bbd5a62..22ccb6572a 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -2637,6 +2637,10 @@ digits Hindi Hindi
Optical Margin Alignment Căn lề quang học
Dynamic Text Văn bản động
@msg type on a path in a thread cannot use Dynamic Text: {frames} chữ trên đường dẫn trong văn bản chuỗi không thể dùng văn bản động: {frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} không thể căn giữa ở trên hoặc ở dưới chữ trên đường dẫn mở hoặc đường dẫn phức: {open}
+@msg type on a path with no length or no height cannot be centered: {flat} không thể căn giữa chữ trên đường dẫn không có độ dài hoặc không có chiều cao: {flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} không thể căn giữa ở trên hoặc ở dưới chữ trên đường dẫn trong văn bản chuỗi: {frames}
+@msg select type on a closed path hãy chọn chữ trên một đường dẫn khép kín
Optimize for fast web view Tối ưu để xem web nhanh
Optimized Đã tối ưu
Options Tùy chọn
@@ -4079,6 +4083,8 @@ mark-style Japanese Kiểu Nhật
Type on a Path Chữ trên đường dẫn
Type on a Path Options Tùy chọn chữ trên đường dẫn
Type on a Path Options… Tùy chọn chữ trên đường dẫn…
+ Center on Top Căn giữa ở trên
+ Center on Bottom Căn giữa ở dưới
Type on a Path Tool Công cụ Chữ trên đường dẫn
Type Orientation Hướng chữ
Type stays text, and every stroke is a line with its lineweight and dashes. Chữ vẫn là văn bản, và mỗi nét là đường thẳng với độ dày và nét đứt.
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 0eb7c7ebe5..a5d29edb93 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -2235,6 +2235,10 @@ file-filter Templates 模板
Optical Margin Alignment 视觉边界对齐
Dynamic Text 动态文本
@msg type on a path in a thread cannot use Dynamic Text: {frames} 串接文本中的路径文字不能使用动态文本:{frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} 开放路径或复合路径上的路径文字无法在顶部或底部居中:{open}
+@msg type on a path with no length or no height cannot be centered: {flat} 路径没有长度或没有高度时,其路径文字无法居中:{flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 串接文本中的路径文字无法在顶部或底部居中:{frames}
+@msg select type on a closed path 请选择闭合路径上的路径文字
Optimize for fast web view 优化快速 Web 查看
Optimized 优化
Options 选项
@@ -3538,6 +3542,8 @@ digits Malayalam 马拉雅拉姆数字
Type on a Path 路径文字
Type on a Path Options 路径文字选项
Type on a Path Options… 路径文字选项…
+ Center on Top 在顶部居中
+ Center on Bottom 在底部居中
Type on a Path Tool 路径文字工具
Type Orientation 文字方向
Type stays text, and every stroke is a line with its lineweight and dashes. 文字维持为文字,每个描边都是带有线宽与虚线的线条。
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 084a7e7708..7c9b433b5e 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -3307,6 +3307,10 @@ file-filter Templates 範本
Optical Margin Alignment 視覺邊界對齊
Dynamic Text 動態文字
@msg type on a path in a thread cannot use Dynamic Text: {frames} 串連文字中的路徑文字無法使用動態文字:{frames}
+@msg type on an open or compound path cannot be centered on its top or bottom: {open} 開放路徑或複合路徑上的路徑文字無法在頂端或底端置中:{open}
+@msg type on a path with no length or no height cannot be centered: {flat} 路徑沒有長度或沒有高度時,其路徑文字無法置中:{flat}
+@msg type on a path in a thread cannot be centered on its top or bottom: {frames} 串連文字中的路徑文字無法在頂端或底端置中:{frames}
+@msg select type on a closed path 請選取封閉路徑上的路徑文字
Optimize for fast web view 最佳化以便快速網頁檢視
Optimized 最佳化
Options 選項
@@ -4655,6 +4659,8 @@ digits Malayalam 馬來亞拉姆數字
Type on a Path 路徑文字
Type on a Path Options 路徑文字選項
Type on a Path Options… 路徑文字選項…
+ Center on Top 在頂端置中
+ Center on Bottom 在底端置中
Type on a Path Tool 路徑文字工具
Type Orientation 文字方向
Type stays text, and every stroke is a line with its lineweight and dashes. 文字維持為文字,每個筆畫都是帶有線寬與虛線的線條。
diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs
index a449fb0361..d38e16cffd 100644
--- a/crates/ui-egui/src/menus/tests.rs
+++ b/crates/ui-egui/src/menus/tests.rs
@@ -351,6 +351,58 @@ fn dynamic_text_is_a_checked_type_menu_item() {
assert!(!enabled(&app, id), "type on a path in a thread");
}
+/// Type › Type on a Path › Center on Top and Center on Bottom run `type.pathCenter.top` and
+/// `.bottom`, which the command palette also runs without params, and are enabled for type on a
+/// closed path only.
+#[test]
+fn center_on_top_and_bottom_are_type_on_a_path_items() {
+ let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
+ app.run("file.new", json!({})).unwrap();
+ let items: Vec<(&str, &str)> = menu_tree()
+ .into_iter()
+ .filter(|(menu, _)| *menu == "Type")
+ .flat_map(|(_, items)| items)
+ .filter_map(|item| match item {
+ Item::Sub("Type on a Path", children) => Some(children),
+ _ => None,
+ })
+ .flatten()
+ .filter_map(|item| match item {
+ Item::Cmd(label, id, Value::Null) if id.starts_with("type.pathCenter") => Some((label, id)),
+ _ => None,
+ })
+ .collect();
+ let ids = ["type.pathCenter.top", "type.pathCenter.bottom"];
+ assert_eq!(items, [("Center on Top", ids[0]), ("Center on Bottom", ids[1])]);
+ let palette = crate::palette::items();
+ for (label, id) in items {
+ assert!(palette.iter().any(|(l, i, _)| l == &format!("Type › Type on a Path › {label}") && i == id), "{label}");
+ }
+ let line = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone();
+ let open = app.run("text.createInPath", json!({"path": line, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone();
+ app.run("select.set", json!({"ids": [open]})).unwrap();
+ assert!(ids.iter().all(|id| !enabled(&app, id)), "type on an open path");
+ let square = app.run("path.create", json!({"d": "M60 200 L160 200 L160 300 L60 300 Z"})).unwrap()["id"].clone();
+ let closed = app.run("text.createInPath", json!({"path": square, "mode": "onPath", "text": "Round", "at": [60, 200]})).unwrap()["id"].clone();
+ app.run("select.set", json!({"ids": [closed]})).unwrap();
+ assert!(ids.iter().all(|id| enabled(&app, id)));
+ app.run("select.set", json!({"ids": [closed, open]})).unwrap();
+ assert!(ids.iter().all(|id| !enabled(&app, id)), "type on a closed path and on an open one");
+ // A palette pick runs the command with no params.
+ app.run("select.set", json!({"ids": [closed]})).unwrap();
+ crate::palette::run_pick(&mut app, ids[0]);
+ let doc = &app.session.active().unwrap().doc;
+ let start = match &doc.node(vectorcraft_doc::NodeId(closed.as_u64().unwrap())).unwrap().kind {
+ vectorcraft_doc::NodeKind::Text(t) => match t.kind {
+ vectorcraft_doc::TextKind::OnPath { start, .. } => start,
+ ref k => panic!("{k:?}"),
+ },
+ k => panic!("{k:?}"),
+ };
+ // Once round, the middle of the top edge an eighth of the way round.
+ assert!((start - 0.625).abs() < 1e-6, "{start}");
+}
+
/// Type › Optical Margin Alignment menu check uses `shared_text_flag`; mixed values stay unchecked.
#[test]
fn optical_margin_alignment_menu_unchecked_when_shared_flag_none() {
diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs
index 0c46950f37..3a0996c819 100644
--- a/crates/ui-egui/src/menus/tree.rs
+++ b/crates/ui-egui/src/menus/tree.rs
@@ -396,7 +396,13 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec- )> {
PATH_EFFECTS
.iter()
.map(|&(label, effect)| cp(label, "type.pathOptions", json!({ "effect": effect })))
- .chain([Sep, c("Type on a Path Options…", "type.pathOptions")])
+ .chain([
+ Sep,
+ c("Center on Top", "type.pathCenter.top"),
+ c("Center on Bottom", "type.pathCenter.bottom"),
+ Sep,
+ c("Type on a Path Options…", "type.pathOptions"),
+ ])
.collect(),
),
c("Dynamic Text", "type.dynamicText"),
diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs
index 53042baa69..4617f9ddeb 100644
--- a/crates/ui-egui/src/tests/taskbar.rs
+++ b/crates/ui-egui/src/tests/taskbar.rs
@@ -208,6 +208,34 @@ fn type_on_a_path_offers_dynamic_text() {
assert!(!has(&texts, "Dynamic Text"), "{texts:?}");
}
+/// Type on a closed path offers Center on Top and Center on Bottom; type on an open path does not.
+#[test]
+fn type_on_a_closed_path_offers_center_on_top_and_bottom() {
+ let mut app = app();
+ app.run("tool.select", json!({"tool": "selection"})).unwrap();
+ let line = id(app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap());
+ let open = id(app.run("text.createInPath", json!({"path": line, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap());
+ select(&mut app, &[open]);
+ let (_, texts) = bar(&mut app);
+ assert!(has(&texts, "Type on a Path Options") && !has(&texts, "Center on Top") && !has(&texts, "Center on Bottom"), "{texts:?}");
+ let circle = id(app.run("path.create", json!({"d": "M100 300 A100 100 0 1 1 300 300 A100 100 0 1 1 100 300 Z"})).unwrap());
+ let t = id(app.run("text.createInPath", json!({"path": circle, "mode": "onPath", "text": "Round", "at": [90, 300]})).unwrap());
+ select(&mut app, &[t]);
+ let start = |app: &VectorcraftApp| match kind(app, t) {
+ NodeKind::Text(t) => match t.kind {
+ TextKind::OnPath { start, .. } => start,
+ k => panic!("{k:?}"),
+ },
+ k => panic!("{k:?}"),
+ };
+ let (_, texts) = bar(&mut app);
+ assert!(has(&texts, "Center on Top") && has(&texts, "Center on Bottom"), "{texts:?}");
+ click(&mut app, "Center on Top");
+ assert!((start(&app) - 0.75).abs() < 1e-3, "{}", start(&app));
+ click(&mut app, "Center on Bottom");
+ assert!((start(&app) - 0.25).abs() < 1e-3, "{}", start(&app));
+}
+
/// The path's anchors (its first subpath).
fn anchors(app: &VectorcraftApp, id: u64) -> Vec
{
match kind(app, id) {
diff --git a/docs/mcp.md b/docs/mcp.md
index 5826c5c8c4..8a9f1db840 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -7301,6 +7301,26 @@ begins or ends, dragging the centre bracket slides the type along its path and,
{"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}}
```
+`type.pathCenter.top {ids?|id?}` and `type.pathCenter.bottom {ids?|id?}` (Type › Type on a Path › Center on Top and
+Center on Bottom, and the Contextual Task Bar for type on a closed path) slide the selected type on a closed path along
+it so that the middle of the span between its brackets lands on the path's topmost or bottommost point. This is
+VectorCraft's own behavior. The path is measured as the object is drawn, after its transform, so a rotated, scaled or
+skewed circle centers on its drawn top. On a level stretch, such as a rectangle's top edge, the middle of the stretch
+is used; with several separate topmost points, the middle of the shortest part of the path that holds them all is
+used. The span keeps its length, and type that runs once round stays once round. The first paragraph's alignment
+becomes Center unless it is Center or Justify Center, so the type is centered there. The type is not flipped: on the
+bottom of a circle, whose path runs clockwise, it reads upside down outside the circle until `type.pathOptions {flip:
+true}` turns it inside. Point type and area type in the selection are left as they are. Type on an open or compound
+path, type in a thread, and type on a path with no length or no height are refused with an error that names them,
+and nothing changes. The menu items are enabled, and the task bar shows them, only while every selected type on a
+path is on one closed path and none of it is in a thread. Several objects move in one undo step. When every object is
+already centered there, nothing changes and no undo step is recorded. Each replies `{ids, start, end}` with the first
+object's brackets.
+
+```json
+{"name":"run_command","arguments":{"command":"type.pathCenter.top","params":{}}}
+```
+
## Dynamic Text
`type.dynamicText {on?: bool, ids?}` (Type › Dynamic Text, and the Contextual Task Bar for type on a path) turns
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index b4c949f742..b27ef671ba 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1157,6 +1157,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them.
New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text.
With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines.
+ Type › Type on a Path › Center on Top and Center on Bottom (`type.pathCenter.top`, `type.pathCenter.bottom`, also on the Contextual Task Bar for type on a closed path) slide type on a closed path so the middle of its span lands on the path's topmost or bottommost point as drawn, keep the span's length, and set the first paragraph to Center unless it is Center or Justify Center. This is VectorCraft's own behavior (`crates/geom/src/arc.rs`, `crates/engine/src/tests/pathtype.rs`).
Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`).
Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`).
The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it.
From e609b26a95946f0330bc87ce3cb938ad9310767f Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 17:55:07 -0800
Subject: [PATCH 08/15] =?UTF-8?q?Warp:=20bend=20each=20style=20as=20one=20?=
=?UTF-8?q?B=C3=A9zier=20patch,=20and=20keep=20saved=20warps'=20look?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Each of the fifteen Warp styles now bends the bounds as one Bézier patch.
The style sets the patch's control points from the bend and the size of
the bounds, the horizontal distortion scales each column of control
points about its middle, and the vertical distortion scales each row.
The Warp effects and warp envelopes share the map (`WarpNet`). Content
with no width or no height takes the middle of the patch along that
side.
A warp carries the map it draws with as `version`. New warps, the
built-in graphic styles' Warps and warps opened from .ai files get 1.
Warps in .vectorcraft documents saved before the version existed read
as 0 and keep the earlier map, so those documents keep their look.
Edits keep a warp's version, and Warp Options editing several objects
keeps each one's own. effect.apply, effect.setParams and the warp
envelope commands take `version` 0 or 1 (`1.0` counts as 1) and refuse
any other value. An .ai file holds a warp's settings and not its
version, so a version 0 warp saved to .ai opens again with version 1.
The .ai writer compares a style's settings without the version, so
saved warps still export as editable effects.
Docs: docs/mcp.md (Envelopes), docs/effects-parity.md (Warp),
docs/target-app-parity.md.
---
crates/doc/src/hit/tests.rs | 4 +-
crates/doc/src/live.rs | 198 +++++++++++-
crates/doc/src/live/envelope.rs | 8 +
crates/doc/src/live/mesh.rs | 13 +-
crates/doc/src/live/warp.rs | 331 ++++++++++++++++++++
crates/doc/src/style_libs.rs | 28 +-
crates/effects/src/catalog.rs | 2 +-
crates/effects/src/lib.rs | 2 +-
crates/effects/src/params.rs | 27 +-
crates/effects/src/tests.rs | 51 +++
crates/effects/src/warp.rs | 35 ++-
crates/engine/src/cmd/live/commands.rs | 8 +-
crates/engine/src/cmd/live/envelope.rs | 17 +-
crates/engine/src/cmd/params.rs | 8 +
crates/engine/src/tests/effectedit.rs | 34 ++
crates/engine/src/tests/envelope.rs | 49 ++-
crates/engine/src/tests/envelope_distort.rs | 7 +-
crates/engine/src/tests/live.rs | 4 +-
crates/eps/src/ai_editing/tests.rs | 18 ++
crates/eps/src/ai_editing/writer/styles.rs | 9 +-
crates/eps/src/import/ai/envelope.rs | 1 +
crates/eps/src/import/ai/read/directives.rs | 2 +-
crates/eps/src/import/ai/style.rs | 1 +
crates/eps/src/import/ai/style/tests.rs | 2 +
crates/eps/src/import/ai/tests.rs | 14 +-
crates/eps/src/import/tests_native.rs | 9 +-
crates/render/src/live.rs | 4 +-
crates/render/src/tests/fontchange.rs | 6 +-
crates/render/src/tests/memory.rs | 4 +-
crates/render/src/tests/objectfx.rs | 2 +-
crates/render/src/tests/placed_document.rs | 2 +-
crates/tools/src/xform/eyedropper.rs | 4 +-
crates/ui-egui/src/dialogs/effect.rs | 7 +
crates/ui-egui/src/dialogs/envelope.rs | 2 +-
crates/ui-egui/src/dialogs/tests_warp.rs | 30 ++
crates/ui-egui/src/dialogs/warp_effect.rs | 8 +-
docs/effects-parity.md | 2 +-
docs/mcp.md | 14 +
docs/target-app-parity.md | 1 +
39 files changed, 906 insertions(+), 62 deletions(-)
diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs
index 2c0a6984d0..3eac4202b6 100644
--- a/crates/doc/src/hit/tests.rs
+++ b/crates/doc/src/hit/tests.rs
@@ -450,7 +450,7 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() {
envelope,
NodeKind::Envelope {
content,
- kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
+ kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }),
fidelity: 100.0,
editing: false,
options: Default::default(),
@@ -1192,7 +1192,7 @@ fn objects_at_edit_contents_uses_general_order() {
d.alloc_id(),
NodeKind::Envelope {
content: vec![inner(a), inner(b)],
- kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
+ kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }),
fidelity: 100.0,
editing: true,
options: Default::default(),
diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs
index 304c4e70f6..983b93419b 100644
--- a/crates/doc/src/live.rs
+++ b/crates/doc/src/live.rs
@@ -65,7 +65,7 @@ pub use expand::{
#[cfg(test)]
use mesh::grid_eval;
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
-pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point};
+pub use warp::{WARP_NET, WarpNet, WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, uses_net, warp_point, warp_version};
#[cfg(test)]
mod tests {
@@ -132,6 +132,200 @@ mod tests {
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
}
+
+ /// Each warp style's Bézier net, at points worked out by a separate implementation of the same
+ /// nets: (style, bend %, horizontal and vertical distortion %, vertical, box w × h, (u, v)) →
+ /// the point in the box (origin at its top left, y down).
+ #[test]
+ fn warp_nets_bend_each_style_as_its_own_patch() {
+ #[rustfmt::skip]
+ let samples = [
+ ("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -31.1605665, 30.1243045),
+ ("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 170.241998, 58.8743443),
+ ("arc", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 50.6087118, 47.6275948),
+ ("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 18.8784581, 35.5028749),
+ ("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.486168, 119.348666),
+ ("arcLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 38.1073767, 36.0911629),
+ ("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 17.3830689, 17.1599587),
+ ("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.498463, 86.7390371),
+ ("arcUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 46.1081993, 36.0252855),
+ ("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 11.6570838),
+ ("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 57.3903712),
+ ("arch", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 49.2508226, 36.2064484),
+ ("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 22.6628335),
+ ("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 116.087703),
+ ("bulge", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.965576, 36.2064484),
+ ("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 10.6489723, 25.9914004),
+ ("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 208.565464, 99.4817585),
+ ("shellLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 41.043987, 36.1302767),
+ ("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -21.8346746, 41.6042708),
+ ("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.791499, 90.3680631),
+ ("shellUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.9282896, 36.1475509),
+ ("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 55.92),
+ ("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 55.44),
+ ("flag", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 58.098, 35.91),
+ ("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 40.8864),
+ ("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 83.7792),
+ ("wave", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.33, 35.91),
+ ("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 19.632),
+ ("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 62.352),
+ ("fish", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 44.994, 35.91),
+ ("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 146.64),
+ ("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 102.48),
+ ("rise", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 118.686, 35.91),
+ ("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 9.1136, 27.2784),
+ ("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.2208, 94.1472),
+ ("fisheye", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 47.514, 35.7048),
+ ("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -0.16, 27.84),
+ ("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.48, 97.68),
+ ("inflate", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.54),
+ ("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 40.16, 27.84),
+ ("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 153.52, 97.68),
+ ("squeeze", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.0),
+ ("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 32.2472, 27.9588),
+ ("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 149.1136, 89.2224),
+ ("twist", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.56325, 33.8769),
+ ];
+ for (style, bend, dh, dv, vertical, w, h, u, v, x, y) in samples {
+ let Some(st) = WarpStyle::from_id(style) else { panic!("{style}") };
+ let p = WarpNet::new(st, bend / 100.0, dh / 100.0, dv / 100.0, w, h, vertical).at(u, v);
+ assert!(p.distance(Point::new(x, y)) < 1e-6, "{style} {bend} {dh} {dv} {vertical} ({u}, {v}): {p:?} vs ({x}, {y})");
+ }
+ }
+
+ /// Arc's edges are concentric circles: at 100 % on a 200 × 100 box the bottom edge is the half
+ /// circle through the bottom corners about the bottom's middle, and the top edge the one 100
+ /// further out. One cubic for a half circle strays up to 1.84 % of the radius from the circle.
+ #[test]
+ fn warp_net_arc_edges_are_concentric_circles() {
+ let net = WarpNet::new(WarpStyle::Arc, 1.0, 0.0, 0.0, 200.0, 100.0, false);
+ let center = Point::new(100.0, 100.0);
+ for i in 0..=20 {
+ let u = f64::from(i) / 20.0;
+ for (v, r) in [(0.0, 200.0), (1.0, 100.0)] {
+ let d = net.at(u, v).distance(center);
+ assert!((d - r).abs() < 0.0184 * r, "u {u}, v {v}: {d} from the center, not {r}");
+ // Its ends and middle lie on the circle.
+ if i % 10 == 0 {
+ assert!((d - r).abs() < 1e-9, "u {u}, v {v}: {d} from the center, not {r}");
+ }
+ }
+ }
+ // Bend 0 leaves the box as it is.
+ for st in [WarpStyle::Arc, WarpStyle::ShellLower, WarpStyle::Bulge, WarpStyle::Twist] {
+ let flat = WarpNet::new(st, 0.0, 0.0, 0.0, 200.0, 100.0, false);
+ assert!(flat.at(0.3, 0.7).distance(Point::new(60.0, 70.0)) < 1e-9, "{st:?}");
+ }
+ }
+
+ /// The horizontal distortion scales each column of the net about its middle (narrowing the left
+ /// side for a positive value), then the vertical one each row (narrowing the top).
+ #[test]
+ fn warp_net_distortion_scales_the_columns_then_the_rows() {
+ let corners = |dh: f64, dv: f64| {
+ let net = WarpNet::new(WarpStyle::Arc, 0.0, dh, dv, 200.0, 100.0, false);
+ [net.at(0.0, 0.0), net.at(1.0, 0.0), net.at(0.0, 1.0), net.at(1.0, 1.0)]
+ };
+ let near = |got: [Point; 4], want: [(f64, f64); 4]| got.iter().zip(want).all(|(p, (x, y))| p.distance(Point::new(x, y)) < 1e-9);
+ assert!(near(corners(0.5, 0.0), [(0.0, 25.0), (200.0, -25.0), (0.0, 75.0), (200.0, 125.0)]), "{:?}", corners(0.5, 0.0));
+ assert!(near(corners(0.0, 0.5), [(50.0, 0.0), (150.0, 0.0), (-50.0, 100.0), (250.0, 100.0)]), "{:?}", corners(0.0, 0.5));
+ // Both coordinates scale: after the columns the top row runs from (0, 15) to (200, -15), and
+ // it then scales by 1.4 about its middle, (100, 0).
+ assert!(near(corners(0.3, -0.4), [(-40.0, 21.0), (240.0, -21.0), (40.0, 91.0), (160.0, 109.0)]), "{:?}", corners(0.3, -0.4));
+ }
+
+ /// Numbers no warp can take still give a finite net: a bend or distortion that isn't a number
+ /// counts as 0, and out of range as its limit; a size that isn't a number as 0.
+ #[test]
+ fn warp_net_takes_any_numbers() {
+ for st in [WarpStyle::Arc, WarpStyle::ShellUpper, WarpStyle::Wave, WarpStyle::Inflate, WarpStyle::Twist] {
+ for (b, dh, w, h) in
+ [(f64::NAN, 0.2, 10.0, 10.0), (5.0, f64::INFINITY, 10.0, 0.0), (-1e300, -3.0, f64::NAN, 1e-300), (1e-300, 0.0, 0.0, 0.0)]
+ {
+ for vertical in [false, true] {
+ let p = WarpNet::new(st, b, dh, dh, w, h, vertical).at(0.3, 0.6);
+ assert!(p.x.is_finite() && p.y.is_finite(), "{st:?} {b} {dh} {w} {h}: {p:?}");
+ }
+ }
+ }
+ let clamped = WarpNet::new(WarpStyle::Flag, 7.0, 0.0, 0.0, 200.0, 100.0, false);
+ assert_eq!(clamped, WarpNet::new(WarpStyle::Flag, 1.0, 0.0, 0.0, 200.0, 100.0, false));
+ }
+
+ /// A box near the largest finite size still warps: Flag on a 1e308 × 100 box moves points only
+ /// up and down, as on a 1 × 1e-306 one scaled up, and on a 1e308 × 1e-300 box keeps the box's
+ /// height. Fisheye at 100 % on a 1.7e308 square, whose net goes past the largest finite number,
+ /// gives finite points inside the box and past it, and the unit square's points scaled up where
+ /// those are finite. Fisheye moves y by the height alone, so on boxes of the largest finite
+ /// width and a height of 1e-300 or 100 every y is the unit square's times the height, also
+ /// where x goes past the largest finite number.
+ #[test]
+ fn warp_net_bends_a_huge_box_without_overflowing() {
+ let w = 1e308;
+ let flag = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 100.0, false);
+ let small = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, 1.0, 100.0 / w, false);
+ for (u, v) in [(0.0, 0.0), (0.3, 0.6), (0.9, 1.0), (1.0, 0.5)] {
+ let (p, q) = (flag.at(u, v), small.at(u, v));
+ assert!(p.is_finite(), "({u}, {v}): {p:?}");
+ assert!((p.x - u * w).abs() <= 1e-12 * w, "({u}, {v}): {p:?}");
+ assert!((p.y - q.y * w).abs() <= 1e-9 * p.y.abs().max(1.0), "({u}, {v}): {p:?} against {q:?}");
+ }
+ let thin = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 1e-300, false);
+ assert_eq!(thin.at(0.0, 1.0), Point::new(0.0, 1e-300));
+ let fisheye = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.7e308, 1.7e308, false);
+ for (u, v) in [(0.1, 0.1), (0.8, 0.3), (1.5, 0.5), (3.0, 3.0)] {
+ let p = fisheye.at(u, v);
+ assert!(p.is_finite(), "({u}, {v}): {p:?}");
+ }
+ let unit = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.0, 1.0, false);
+ let near = |a: f64, b: f64| (a - b).abs() <= 1e-12 * b.abs().max(f64::MIN_POSITIVE);
+ let (p, q) = (fisheye.at(0.1, 0.1), unit.at(0.1, 0.1));
+ assert!(near(p.x, q.x * 1.7e308) && near(p.y, q.y * 1.7e308), "{p:?} against {q:?}");
+ // The bottom of a box with a subnormal height stays exactly at that height: the net is
+ // built on the box halved, and the height's lowest bit but one is set.
+ let low = f64::MIN_POSITIVE / 4.0 + f64::from_bits(2);
+ let sub = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, low, false).at(0.0, 1.0);
+ assert_eq!(sub.y, low, "{sub:?}");
+ for h in [1e-300, 100.0] {
+ let tall = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, h, false);
+ for (u, v) in [(0.1, 0.1), (0.9, 0.4), (0.5, 0.8)] {
+ let (p, q) = (tall.at(u, v), unit.at(u, v));
+ assert!(p.is_finite() && near(p.y, q.y * h), "h {h}, ({u}, {v}): {p:?} against {q:?}");
+ }
+ }
+ }
+
+ /// Content with no width or no height takes the middle of a warp envelope's patch along that
+ /// side: under Arc a vertical line stays vertical, under Bulge a horizontal line stays straight.
+ #[test]
+ fn warp_envelopes_bend_flat_content_as_the_middle_of_the_patch() {
+ let warp = |style: &str| EnvelopeKind::Warp { style: style.into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
+ let arc = warp("arc");
+ let map = envelope_mapper(&arc, Rect::new(100.0, 0.0, 100.0, 100.0));
+ for y in [0.0, 30.0, 100.0] {
+ let p = map(Point::new(100.0, y));
+ assert!((p.x - 100.0).abs() <= 1e-9, "{y}: {p:?}");
+ }
+ let bulge = warp("bulge");
+ let map = envelope_mapper(&bulge, Rect::new(0.0, 50.0, 200.0, 50.0));
+ for x in [0.0, 70.0, 200.0] {
+ let p = map(Point::new(x, 50.0));
+ assert!((p.y - 50.0).abs() <= 1e-9, "{x}: {p:?}");
+ }
+ }
+
+ /// A warp envelope's map version: none in a file reads as 0 (the earlier map), 0 is written
+ /// as none, so files keep their bytes, and the nets' version is written.
+ #[test]
+ fn warp_envelopes_write_their_map_version_only_when_it_has_one() {
+ let old: EnvelopeKind = serde_json::from_str(r#"{"type":"warp","style":"arc","bend":20}"#).unwrap();
+ assert!(matches!(old, EnvelopeKind::Warp { version: 0, .. }), "{old:?}");
+ assert!(!serde_json::to_string(&old).unwrap().contains("version"));
+ let new = EnvelopeKind::Warp { style: "arc".into(), bend: 20.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
+ let text = serde_json::to_string(&new).unwrap();
+ assert!(text.contains(r#""version":1"#), "{text}");
+ assert_eq!(serde_json::from_str::(&text).unwrap(), new);
+ }
use vectorcraft_geom::shapes;
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc {
@@ -537,7 +731,7 @@ mod tests {
#[test]
fn an_envelope_maps_from_its_box_with_its_margins() {
let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art());
- let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let content = [Arc::new(rect)];
let src = |content: &[Arc], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src();
// Top, bottom, left and right.
diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs
index 8abe961841..f197837020 100644
--- a/crates/doc/src/live/envelope.rs
+++ b/crates/doc/src/live/envelope.rs
@@ -3,6 +3,10 @@ use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of};
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
use vectorcraft_geom::Homography;
+fn is_zero(n: &u32) -> bool {
+ *n == 0
+}
+
/// How an envelope distorts its content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
@@ -19,6 +23,10 @@ pub enum EnvelopeKind {
v: f64,
#[serde(default = "yes")]
horizontal: bool,
+ /// The map the warp draws with: [`WARP_NET`](super::WARP_NET) for its style's Bézier net,
+ /// 0 (as in files from before the version existed) for [`warp_point`](super::warp_point).
+ #[serde(default, skip_serializing_if = "is_zero")]
+ version: u32,
},
/// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto.
Mesh {
diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs
index c59374dee9..be865e2769 100644
--- a/crates/doc/src/live/mesh.rs
+++ b/crates/doc/src/live/mesh.rs
@@ -1,5 +1,5 @@
use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at};
-use super::warp::{WarpStyle, flatten_subpath, resample, warp_point};
+use super::warp::{WarpNet, WarpStyle, flatten_subpath, resample, uses_net, warp_point};
use super::{
Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color,
one,
@@ -1731,10 +1731,19 @@ pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64)
pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box Point + 'a> {
let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height());
match kind {
- EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => {
+ EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal, version } => {
let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc);
let b = bend.clamp(-100.0, 100.0) / 100.0;
let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0);
+ if uses_net(*version) {
+ let net = WarpNet::new(st, b, dh, dv, src.width(), src.height(), !*horizontal);
+ // Content with no width or no height takes the middle of the patch along that side.
+ let (flat_u, flat_v) = (src.width() <= 0.0, src.height() <= 0.0);
+ return Box::new(move |u: f64, v: f64| {
+ let (u, v) = (if flat_u { 0.5 } else { u }, if flat_v { 0.5 } else { v });
+ frame * (src.origin() + net.at(u, v).to_vec2())
+ });
+ }
let c = src.center();
let (hw, hh) = (src.width() / 2.0, src.height() / 2.0);
let horizontal = *horizontal;
diff --git a/crates/doc/src/live/warp.rs b/crates/doc/src/live/warp.rs
index 6d7392ac5f..08bef91140 100644
--- a/crates/doc/src/live/warp.rs
+++ b/crates/doc/src/live/warp.rs
@@ -109,6 +109,337 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f6
(x2, y2)
}
+// =====================================================================================
+// Warp nets: each style as one Bézier patch over its box
+// =====================================================================================
+
+/// The `version` a Warp effect's params or a warp envelope carries for the [`WarpNet`] map. With
+/// none (or 0) a warp draws with [`warp_point`], as every warp did before, so saved documents keep
+/// their look; new and imported warps are given this one.
+pub const WARP_NET: u32 = 1;
+
+/// Does a warp of map `version` draw with [`WarpNet`]? A version newer than this build knows draws
+/// with the newest map it has.
+pub fn uses_net(version: u32) -> bool {
+ version >= WARP_NET
+}
+
+/// The map version `v` names when it is one this build draws: a whole number from 0 to
+/// [`WARP_NET`], written as an integer or as a float (`1` or `1.0`).
+pub fn warp_version(v: &serde_json::Value) -> Option {
+ let n = v.as_f64()?;
+ (n.fract() == 0.0 && (0.0..=f64::from(WARP_NET)).contains(&n)).then_some(n as u32)
+}
+
+/// A style's own control net: `rows` × `cols` points (2 to 4 each) of a Bézier patch, row by row
+/// from the top, in the box's coordinates (origin at its top left, y down).
+#[derive(Clone, Copy, Debug)]
+struct Net {
+ rows: usize,
+ cols: usize,
+ p: [[Point; 4]; 4],
+}
+
+impl Net {
+ /// The undistorted box `w` × `h` as a `rows` × `cols` net.
+ fn flat(w: f64, h: f64, rows: usize, cols: usize) -> Self {
+ let mut p = [[Point::ZERO; 4]; 4];
+ for (r, row) in p.iter_mut().enumerate().take(rows) {
+ for (c, q) in row.iter_mut().enumerate().take(cols) {
+ *q = Point::new(w * (c as f64 / (cols - 1) as f64), h * (r as f64 / (rows - 1) as f64));
+ }
+ }
+ Self { rows, cols, p }
+ }
+
+ /// Upside down within a box `h` high: the rows in reverse order, each point mirrored.
+ fn mirrored(self, h: f64) -> Self {
+ let mut out = self;
+ for (to, from) in out.p[..self.rows].iter_mut().zip(self.p[..self.rows].iter().rev()) {
+ for (q, f) in to.iter_mut().zip(from).take(self.cols) {
+ *q = Point::new(f.x, h - f.y);
+ }
+ }
+ out
+ }
+
+ /// Rows and columns exchanged, and x with y: a net made on the box turned on its side.
+ fn transposed(self) -> Self {
+ let mut out = Self { rows: self.cols, cols: self.rows, p: [[Point::ZERO; 4]; 4] };
+ for (r, row) in self.p[..self.rows].iter().enumerate() {
+ for (c, q) in row[..self.cols].iter().enumerate() {
+ out.p[c][r] = Point::new(q.y, q.x);
+ }
+ }
+ out
+ }
+
+ /// Each column scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from
+ /// 0 at the left column to 1 at the right: a horizontal distortion `a` (-1..1).
+ fn distort_columns(&mut self, a: f64) {
+ let (rows, cols) = (self.rows, self.cols);
+ for c in 0..cols {
+ let s = 1.0 + a * (2.0 * c as f64 / (cols - 1) as f64 - 1.0);
+ let mid = half_way(self.p[0][c], self.p[rows - 1][c]);
+ for row in &mut self.p[..rows] {
+ row[c] = mid + (row[c] - mid) * s;
+ }
+ }
+ }
+
+ /// Each row scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from 0
+ /// at the top row to 1 at the bottom: a vertical distortion `a` (-1..1).
+ fn distort_rows(&mut self, a: f64) {
+ let (rows, cols) = (self.rows, self.cols);
+ for (r, row) in self.p[..rows].iter_mut().enumerate() {
+ let s = 1.0 + a * (2.0 * r as f64 / (rows - 1) as f64 - 1.0);
+ let mid = half_way(row[0], row[cols - 1]);
+ for q in &mut row[..cols] {
+ *q = mid + (*q - mid) * s;
+ }
+ }
+ }
+
+ /// The same patch as a bicubic net (degree elevation, which leaves the surface as it is).
+ fn bicubic(self) -> [[Point; 4]; 4] {
+ let rows: Vec<[Point; 4]> = self.p[..self.rows].iter().map(|row| cubic_line(&row[..self.cols])).collect();
+ let mut out = [[Point::ZERO; 4]; 4];
+ for c in 0..4 {
+ let col: Vec = rows.iter().map(|row| row[c]).collect();
+ for (to, q) in out.iter_mut().zip(cubic_line(&col)) {
+ to[c] = q;
+ }
+ }
+ out
+ }
+}
+
+/// The point half way from `a` to `b`, as halves added: two finite points always give a finite one.
+fn half_way(a: Point, b: Point) -> Point {
+ Point::new(0.5 * a.x + 0.5 * b.x, 0.5 * a.y + 0.5 * b.y)
+}
+
+/// A Bézier curve of 2 to 4 control points as the 4 of the same curve's cubic form.
+fn cubic_line(p: &[Point]) -> [Point; 4] {
+ match *p {
+ [a, b] => [a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b],
+ [a, b, c] => [a, a.lerp(b, 2.0 / 3.0), c.lerp(b, 2.0 / 3.0), c],
+ [a, b, c, d] => [a, b, c, d],
+ _ => [Point::ZERO; 4],
+ }
+}
+
+/// The handle offsets (along the chord, across it) of a cubic circular arc over a chord `chord`
+/// long that turns through θ = |`b`| × 180°: each handle is 4/3 tan(θ/4) of the radius long, along
+/// the arc's end tangent. With the radius `chord / (2 sin(θ/2))` that length is
+/// `chord / (3 cos²(θ/4))`, which a bend near 0 leaves at a third of the chord.
+fn arc_handles(chord: f64, b: f64) -> (f64, f64) {
+ let th = b.abs().min(1.0) * PI;
+ let len = chord / (3.0 * (th / 4.0).cos().powi(2));
+ (len * (th / 2.0).cos(), len * (th / 2.0).sin())
+}
+
+/// A row from (0, `y`) to (`w`, `y`) bent into a circular arc of |`b`| × 180°: up for a positive
+/// `b` when `up`, down when not, the other way for a negative one.
+fn arc_row(w: f64, y: f64, b: f64, up: bool) -> [Point; 4] {
+ let (ax, ay) = arc_handles(w, b);
+ let dy = if (b > 0.0) == up { -ay } else { ay };
+ [Point::new(0.0, y), Point::new(ax, y + dy), Point::new(w - ax, y + dy), Point::new(w, y)]
+}
+
+/// Arc at bend |`b`| as four rows: the bottom edge bends up through the bottom corners, the top
+/// edge is the arc around the same center `h` further out, and the rows between are spaced evenly
+/// along the radii. The top corners lie `h` out from the bottom ones along the radii, which lean
+/// out by θ/2.
+fn arc_rows(w: f64, h: f64, b: f64) -> [[Point; 4]; 4] {
+ let th = b.abs().min(1.0) * PI;
+ let (s, c) = (th / 2.0).sin_cos();
+ let (tl, tr) = (Point::new(-h * s, h * (1.0 - c)), Point::new(w + h * s, h * (1.0 - c)));
+ let (ax, ay) = arc_handles(tr.x - tl.x, b);
+ let top = [tl, Point::new(tl.x + ax, tl.y - ay), Point::new(tr.x - ax, tr.y - ay), tr];
+ let bottom = arc_row(w, h, b.abs(), true);
+ let mut out = [[Point::ZERO; 4]; 4];
+ for (r, row) in out.iter_mut().enumerate() {
+ for (q, (t, u)) in row.iter_mut().zip(top.iter().zip(bottom)) {
+ *q = t.lerp(u, r as f64 / 3.0);
+ }
+ }
+ out
+}
+
+/// Warp style `style`'s own net at bend `b` (-1..1) over the box `w` × `h`, before distortion.
+/// `vertical`: the net of the box turned on its side, where Rise moves the other half.
+fn style_net(style: WarpStyle, b: f64, w: f64, h: f64, vertical: bool) -> Net {
+ use WarpStyle::*;
+ let two = |top: [Point; 4], bottom: [Point; 4]| {
+ let mut p = [[Point::ZERO; 4]; 4];
+ (p[0], p[1]) = (top, bottom);
+ Net { rows: 2, cols: 4, p }
+ };
+ let flat_row = |y: f64| [0.0, 1.0, 2.0, 3.0].map(|c| Point::new(w * c / 3.0, y));
+ match style {
+ Arc => {
+ let rows = arc_rows(w, h, b);
+ let net = two(rows[0], rows[3]);
+ if b < 0.0 { net.mirrored(h) } else { net }
+ }
+ ArcUpper => two(arc_row(w, 0.0, b, true), flat_row(h)),
+ ArcLower => two(flat_row(0.0), arc_row(w, h, b, false)),
+ Arch => {
+ let top = arc_row(w, 0.0, b, true);
+ two(top, top.map(|q| Point::new(q.x, q.y + h)))
+ }
+ Bulge => two(arc_row(w, 0.0, b, true), arc_row(w, h, b, false)),
+ ShellLower | ShellUpper => {
+ let a = arc_rows(w, h, b);
+ let mut net = Net::flat(w, h, 4, 4);
+ if b >= 0.0 {
+ // The outer arc and the ends of the row inside it, upside down for Shell Lower.
+ net.p[0] = a[0];
+ (net.p[1][0], net.p[1][3]) = (a[1][0], a[1][3]);
+ if style == ShellLower { net.mirrored(h) } else { net }
+ } else {
+ // The inner arc and the ends of the row outside it, upside down for Shell Upper.
+ net.p[3] = a[3];
+ (net.p[2][0], net.p[2][3]) = (a[2][0], a[2][3]);
+ if style == ShellUpper { net.mirrored(h) } else { net }
+ }
+ }
+ Flag | Fish | Rise => {
+ let mut net = Net::flat(w, h, 2, 4);
+ let d = 2.0 * b * h;
+ match style {
+ Flag => {
+ for row in &mut net.p[..2] {
+ (row[1].y, row[2].y) = (row[1].y + d, row[2].y - d);
+ }
+ }
+ Fish => {
+ (net.p[0][1].y, net.p[0][2].y) = (net.p[0][1].y - d, net.p[0][2].y + d);
+ (net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d);
+ }
+ _ => {
+ for row in &mut net.p[..2] {
+ if vertical {
+ (row[2].y, row[3].y) = (row[2].y - d, row[3].y - d);
+ } else {
+ (row[0].y, row[1].y) = (row[0].y + d, row[1].y + d);
+ }
+ }
+ }
+ }
+ net
+ }
+ Wave => {
+ let mut net = Net::flat(w, h, 3, 4);
+ let d = 2.0 * b * h;
+ (net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d);
+ net
+ }
+ Fisheye => {
+ let mut net = Net::flat(w, h, 4, 4);
+ let c = Point::new(w / 2.0, h / 2.0);
+ for row in &mut net.p[1..3] {
+ for q in &mut row[1..3] {
+ *q = c + (*q - c) * (1.0 + 4.0 * b);
+ }
+ }
+ net
+ }
+ Inflate | Squeeze => {
+ let mut net = Net::flat(w, h, 3, 3);
+ let side = if style == Inflate { b * w / 2.0 } else { -b * w / 2.0 };
+ net.p[0][1].y -= b * h / 2.0;
+ net.p[2][1].y += b * h / 2.0;
+ net.p[1][0].x -= side;
+ net.p[1][2].x += side;
+ net
+ }
+ Twist => {
+ // The four inner points move round: clockwise for a positive bend.
+ let mut net = Net::flat(w, h, 4, 4);
+ let (dx, dy) = (b.abs() * w, b.abs() * h);
+ if b >= 0.0 {
+ net.p[1][1].x += dx;
+ net.p[1][2].y += dy;
+ net.p[2][2].x -= dx;
+ net.p[2][1].y -= dy;
+ } else {
+ net.p[1][1].y += dy;
+ net.p[1][2].x -= dx;
+ net.p[2][2].y -= dy;
+ net.p[2][1].x += dx;
+ }
+ net
+ }
+ }
+}
+
+/// A warp as one bicubic Bézier patch over its box: the style's own net at the bend, its columns
+/// scaled by the horizontal distortion and then its rows by the vertical one. Points are in the
+/// box's coordinates (origin at its top left, y down).
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct WarpNet {
+ /// The control points over the box divided by `scale`.
+ net: [[Point; 4]; 4],
+ /// 1, or a power of two when the net over the box itself does not fit in finite numbers.
+ scale: f64,
+ w: f64,
+ h: f64,
+}
+
+impl WarpNet {
+ /// The net of `style` over a box `w` × `h` at bend `b` and distortions `dh` and `dv` (each
+ /// -1..1, clamped); `vertical` bends along the box's height.
+ pub fn new(style: WarpStyle, b: f64, dh: f64, dv: f64, w: f64, h: f64, vertical: bool) -> Self {
+ let unit = |x: f64| if x.is_finite() { x.clamp(-1.0, 1.0) } else { 0.0 };
+ let size = |x: f64| if x.is_finite() { x.max(0.0) } else { 0.0 };
+ let (b, dh, dv, w, h) = (unit(b), unit(dh), unit(dv), size(w), size(h));
+ let build = |w: f64, h: f64| {
+ let mut net = if vertical { style_net(style, b, h, w, true).transposed() } else { style_net(style, b, w, h, false) };
+ net.distort_columns(dh);
+ net.distort_rows(dv);
+ net.bicubic()
+ };
+ // A box near the largest finite size can overflow the net. The net is then built on the box
+ // divided by the smallest power of two that fits (2 to 4096), an exact division for every
+ // side above the subnormal range, and points are multiplied back as they are read.
+ let mut out = Self { net: build(w, h), scale: 1.0, w, h };
+ while out.scale < 4096.0 && !out.net.iter().flatten().all(|q| q.is_finite()) {
+ let scale = out.scale * 2.0;
+ out = Self { net: build(w / scale, h / scale), scale, w, h };
+ }
+ out
+ }
+
+ /// The point at `(u, v)` of the box (0..1 across and down; outside, the patch goes on). A
+ /// coordinate the patch would send past the largest finite number is the box's own, held to
+ /// the finite range.
+ pub fn at(&self, u: f64, v: f64) -> Point {
+ let p = self.at_scaled(u, v);
+ let held = |x: f64| if x.is_nan() { 0.0 } else { x.clamp(-f64::MAX, f64::MAX) };
+ let coord = |x: f64, flat: f64| if (x * self.scale).is_finite() { x * self.scale } else { held(flat) };
+ Point::new(coord(p.x, u * self.w), coord(p.y, v * self.h))
+ }
+
+ /// [`Self::at`] divided by the net's scale.
+ fn at_scaled(&self, u: f64, v: f64) -> Point {
+ let bern = |t: f64| {
+ let s = 1.0 - t;
+ [s * s * s, 3.0 * t * s * s, 3.0 * t * t * s, t * t * t]
+ };
+ let (bu, bv) = (bern(u), bern(v));
+ let (mut x, mut y) = (0.0, 0.0);
+ for (row, wv) in self.net.iter().zip(bv) {
+ for (q, wu) in row.iter().zip(bu) {
+ x += wv * wu * q.x;
+ y += wv * wu * q.y;
+ }
+ }
+ Point::new(x, y)
+ }
+}
+
/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them.
fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
let c = sp.segment(i);
diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs
index f64bdb4300..5b90427162 100644
--- a/crates/doc/src/style_libs.rs
+++ b/crates/doc/src/style_libs.rs
@@ -192,8 +192,14 @@ fn round(h: &str, width: f64) -> StrokeLayer {
StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..stroke(h, width) }
}
-/// Live effect `id` with `params` (the others keep the effect's defaults).
-fn fx(id: &str, params: Value) -> Effect {
+/// Live effect `id` with `params` (the others keep the effect's defaults). A Warp draws with its
+/// style's Bézier net, as a new Warp does.
+fn fx(id: &str, mut params: Value) -> Effect {
+ if id.starts_with("warp.")
+ && let Value::Object(m) = &mut params
+ {
+ m.entry("version").or_insert(json!(crate::live::WARP_NET));
+ }
Effect { id: id.into(), params, visible: true }
}
@@ -552,6 +558,24 @@ mod tests {
assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]);
}
+ /// The built-in styles' Warps draw with the map a new Warp takes.
+ #[test]
+ fn builtin_warp_styles_take_the_nets_map() {
+ let mut warps = 0;
+ for b in STYLE_LIBRARIES {
+ let lib = builtin_style_library(b.id).unwrap();
+ for g in &lib.styles {
+ let mut effects: Vec<&Effect> = g.appearance.effects.iter().collect();
+ effects.extend(g.appearance.items.iter().flat_map(|i| i.effects()));
+ for e in effects.into_iter().filter(|e| e.id.starts_with("warp.")) {
+ assert_eq!(e.params["version"], json!(crate::live::WARP_NET), "{} › {}: {}", b.id, g.name, e.id);
+ warps += 1;
+ }
+ }
+ }
+ assert!(warps >= 3, "{warps}");
+ }
+
#[test]
fn vcstyles_round_trip() {
let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone();
diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs
index 5c18f2d5f0..a827bea0e2 100644
--- a/crates/effects/src/catalog.rs
+++ b/crates/effects/src/catalog.rs
@@ -112,7 +112,7 @@ fn lengths_of(id: &str) -> Lengths {
}
}
-const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
+const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}";
const DT: &[&str] = &["Effect", "Distort & Transform"];
diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs
index 67b4b464fd..6165aa9a9b 100644
--- a/crates/effects/src/lib.rs
+++ b/crates/effects/src/lib.rs
@@ -94,7 +94,7 @@ pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
pub use trap::TRAP;
pub use vectorcraft_three_d::Mesh;
-pub use warp::{WarpStyle, warp_point};
+pub use warp::{WARP_NET, WarpNet, WarpStyle, map_version, warp_point, warp_version};
mod catalog;
mod geometry;
diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs
index 7785aff335..1e3c8c1d31 100644
--- a/crates/effects/src/params.rs
+++ b/crates/effects/src/params.rs
@@ -31,7 +31,8 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its
/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of
/// then) become the current ones that show exactly the same, unless those are given too, which win.
-/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°).
+/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°), and a Warp's
+/// map `version` given as a whole float is stored as an integer.
pub fn upgrade_params(id: &str, params: &Value) -> Value {
match params {
Value::Object(m) => {
@@ -39,6 +40,7 @@ pub fn upgrade_params(id: &str, params: &Value) -> Value {
upgrade_perspective(id, &mut m);
upgrade_light(id, &mut m);
upgrade_scribble(id, &mut m);
+ upgrade_warp_version(id, &mut m);
Value::Object(m)
}
other => other.clone(),
@@ -71,6 +73,19 @@ fn upgrade_perspective(id: &str, m: &mut Map) {
}
}
+/// A Warp's map `version` given as a whole float (`1.0`) is stored as the integer.
+fn upgrade_warp_version(id: &str, m: &mut Map) {
+ if !id.starts_with("warp.") {
+ return;
+ }
+ if let Some(v) = m.get_mut("version")
+ && !v.is_u64()
+ && let Some(n) = crate::warp::warp_version(v)
+ {
+ *v = json!(n);
+ }
+}
+
fn upgrade_scribble(id: &str, m: &mut Map) {
if id != "stylize.scribble" {
return;
@@ -91,7 +106,15 @@ pub fn new_effect(id: &str, params: &Value) -> Option {
if !catalog_index().contains_key(id) {
vectorcraft_plugins::effect::installed(id)?;
}
- Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
+ let mut params = merged_params(id, params);
+ // A new Warp takes its style's Bézier net unless a map is given; the catalog's defaults carry
+ // no version, so warps saved without one keep the earlier map.
+ if id.starts_with("warp.")
+ && let Value::Object(m) = &mut params
+ {
+ m.entry("version").or_insert(json!(crate::warp::WARP_NET));
+ }
+ Some(Effect { id: id.to_string(), params, visible: true })
}
/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see
diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs
index 532251bd73..adc518043e 100644
--- a/crates/effects/src/tests.rs
+++ b/crates/effects/src/tests.rs
@@ -209,6 +209,57 @@ fn warp_arc_bends_the_top_edge() {
assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}");
}
+/// A Warp of `version` 1 bends its bounds as its style's Bézier net; one without a version, as
+/// saved before the version existed, draws as it always did. Flag at 60 % on a 200 × 100 box: the
+/// net moves the top edge up to 6 × 0.6 × 100 × max u(1 - u)(1 - 2u) = 34.64 out, the earlier map
+/// moves it 0.6 × 25 = 15.
+#[test]
+fn a_warp_draws_with_the_map_its_version_names() {
+ let rect = shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 120.0));
+ // u(1 - u)(1 - 2u) peaks at √3 / 18.
+ let peak = 360.0 * 3f64.sqrt() / 18.0;
+ for (version, out) in [(json!(1), peak), (json!(null), 15.0), (json!(0), 15.0)] {
+ let b = run("warp.flag", json!({"bend": 60, "version": version}), &rect).bounds().unwrap();
+ assert!(close(b.y0, 20.0 - out, 0.1) && close(b.y1, 120.0 + out, 0.1), "version {version}: {b:?}");
+ assert!(close(b.x0, 10.0, 1e-9) && close(b.x1, 210.0, 1e-9), "version {version}: {b:?}");
+ }
+}
+
+/// A straight line has a box with no width or no height, and the net bends it as the middle of
+/// the patch along that side: under Arc a vertical line stays vertical, and under Bulge, Fisheye,
+/// Inflate and Squeeze a horizontal line stays straight.
+#[test]
+fn a_warp_bends_a_flat_box_as_the_middle_of_its_patch() {
+ let anchors = |p: &PathData| p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect::>();
+ let vertical = shapes::line(Point::new(100.0, 0.0), Point::new(100.0, 100.0));
+ let out = run("warp.arc", json!({"bend": 50, "version": 1}), &vertical);
+ assert!(anchors(&out).iter().all(|a| close(a.p.x, 100.0, 1e-9) && close(a.h_in.x, 100.0, 1e-9) && close(a.h_out.x, 100.0, 1e-9)), "{out:?}");
+ let horizontal = shapes::line(Point::new(0.0, 50.0), Point::new(200.0, 50.0));
+ for style in ["bulge", "fisheye", "inflate", "squeeze"] {
+ let out = run(&format!("warp.{style}"), json!({"bend": 50, "version": 1}), &horizontal);
+ assert!(
+ anchors(&out).iter().all(|a| close(a.p.y, 50.0, 1e-9) && close(a.h_in.y, 50.0, 1e-9) && close(a.h_out.y, 50.0, 1e-9)),
+ "{style}: {out:?}"
+ );
+ }
+}
+
+/// A new Warp takes the nets' version unless one is given, and the catalog's defaults carry none,
+/// so a saved warp's params don't gain one.
+#[test]
+fn new_warps_take_the_nets_version() {
+ for (s, _) in WARP_STYLES {
+ let id = format!("warp.{s}");
+ let Some(e) = crate::new_effect(&id, &json!({"bend": 20})) else { panic!("{id}") };
+ assert_eq!(e.params["version"], json!(crate::WARP_NET), "{id}");
+ let Some(e) = crate::new_effect(&id, &json!({"version": 0})) else { panic!("{id}") };
+ assert_eq!(e.params["version"], json!(0), "{id}");
+ assert!(crate::merged_params(&id, &json!({"bend": 20})).get("version").is_none(), "{id}");
+ }
+ let Some(e) = crate::new_effect("distort.twist", &json!({})) else { panic!("twist") };
+ assert!(e.params.get("version").is_none());
+}
+
#[test]
fn transform_copies_and_move() {
let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square());
diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs
index 84f196a8b4..f607abacdf 100644
--- a/crates/effects/src/warp.rs
+++ b/crates/effects/src/warp.rs
@@ -1,14 +1,22 @@
//! Warp effects: 15 envelope styles plus horizontal/vertical perspective distortion.
//!
-//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is
-//! split into short pieces whose control points are mapped (see `map_nonlinear`).
+//! A warp with `version` [`WARP_NET`] is its style's Bézier patch over the box ([`WarpNet`]);
+//! one without (saved before the version existed) is a map on normalized box coordinates
+//! (x, y) ∈ [-1, 1]² (y down), [`warp_point`]. The path is split into short pieces whose control
+//! points are mapped (see `map_nonlinear`).
use serde_json::Value;
use vectorcraft_geom::{PathData, Point, Rect};
use crate::util::{map_nonlinear_within, num, text};
-pub use vectorcraft_doc::live::{WarpStyle, warp_point};
+pub use vectorcraft_doc::live::{WARP_NET, WarpNet, WarpStyle, uses_net, warp_point, warp_version};
+
+/// The map version warp params `p` ask for: their `version`, 0 (the first map) when absent.
+pub fn map_version(p: &Value) -> u32 {
+ let v = num(p, "version", 0.0);
+ if v.is_finite() && v >= 0.0 { v.min(f64::from(u32::MAX)) as u32 } else { 0 }
+}
/// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]).
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData {
@@ -16,6 +24,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -
let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0;
let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0;
let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical");
+ let piece = b.width().hypot(b.height()) / 24.0;
+ if uses_net(map_version(p)) {
+ let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical);
+ let (w, h) = (b.width(), b.height());
+ // A box with no width or no height takes the middle of the patch along that side.
+ let f = |q: Point| {
+ let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 });
+ b.origin() + net.at(u, v).to_vec2()
+ };
+ return map_nonlinear_within(path, piece, max, f);
+ }
let c = b.center();
let hw = (b.width() / 2.0).max(1e-9);
let hh = (b.height() / 2.0).max(1e-9);
@@ -29,17 +48,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -
};
Point::new(c.x + x2 * hw, c.y + y2 * hh)
};
- let piece = b.width().hypot(b.height()) / 24.0;
map_nonlinear_within(path, piece, max, f)
}
/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with
-/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the
-/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline`
-/// tests both sides of it).
+/// no bend and no distortion. With the first map, Arc (`warp.arc`) takes a bend under 0.0001 % as
+/// none: the threshold must match the one in [`warp_point`]'s Arc case
+/// (`warp_bends_when_the_warp_moves_the_outline` tests both sides of it). With [`WarpNet`] any
+/// bend moves the outline.
pub(crate) fn bends(id: &str, p: &Value) -> bool {
let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0;
let bend = share("bend", 50.0);
- let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 };
+ let bent = if id == "warp.arc" && !uses_net(map_version(p)) { bend.abs() >= 1e-6 } else { bend != 0.0 };
bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0
}
diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs
index b69b4e869d..005d892639 100644
--- a/crates/engine/src/cmd/live/commands.rs
+++ b/crates/engine/src/cmd/live/commands.rs
@@ -136,7 +136,7 @@ pub fn specs() -> Vec {
"Make with Warp…",
["Object", "Envelope Distort"],
Some("Cmd+Alt+Shift+W"),
- "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
+ "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v, version?: 0|1 (1: each style bends the bounds as one Bézier patch; 0: VectorCraft's earlier map)} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
has_selection,
env_make_warp
),
@@ -163,7 +163,7 @@ pub fn specs() -> Vec {
"Reset with Warp…",
["Object", "Envelope Distort"],
None,
- "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content",
+ "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v, version?: as makeWithWarp (unset: a warp envelope's own, else 1)} make the selected envelopes warp envelopes, keeping their content",
has_envelope,
env_reset_warp
),
@@ -181,7 +181,7 @@ pub fn specs() -> Vec {
"Envelope Info",
[],
None,
- "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
+ "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, version (a warp's map: 1, each style as one Bézier patch; 0, the earlier map), rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
has_doc,
env_info
),
@@ -199,7 +199,7 @@ pub fn specs() -> Vec {
"Envelope Options…",
["Object", "Envelope Distort"],
None,
- "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
+ "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical, version?: 0|1 (warp envelopes' map, as makeWithWarp)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
has_doc,
env_options
),
diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs
index 1aceb49b54..a91fd8edd0 100644
--- a/crates/engine/src/cmd/live/envelope.rs
+++ b/crates/engine/src/cmd/live/envelope.rs
@@ -13,9 +13,14 @@ const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verti
fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result {
check_numbers(p, WARP_NUMBERS, cmd)?;
- let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true);
- if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur {
- (style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0);
+ // A new warp takes its style's Bézier net; an edited one keeps the map it has unless `version`
+ // names one.
+ let (mut style, mut bend, mut h, mut v, mut horizontal, mut version) = ("arc".to_string(), 50.0, 0.0, 0.0, true, live::WARP_NET);
+ if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0, version: n0 }) = cur {
+ (style, bend, h, v, horizontal, version) = (s0.clone(), *b0, *h0, *v0, *z0, *n0);
+ }
+ if let Some(n) = p.get("version") {
+ version = live::warp_version(n).ok_or_else(|| bad(cmd, format!("`version` must be 0 or {}", live::WARP_NET)))?;
}
if let Some(s) = text_param(p, "style", cmd)? {
let s = s.strip_prefix("warp.").unwrap_or(s);
@@ -32,7 +37,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result o == "horizontal",
None => given,
};
- Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
+ Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal, version })
}
/// The options and fidelity new envelopes get (Envelope Options with no envelope selected).
@@ -206,8 +211,8 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result {
return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity));
};
let v = match &**kind {
- EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
- json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal})
+ EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => {
+ json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal, "version": version})
}
EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}),
EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}),
diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs
index 4b8ae5d2be..60fa90c32c 100644
--- a/crates/engine/src/cmd/params.rs
+++ b/crates/engine/src/cmd/params.rs
@@ -317,6 +317,14 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") {
return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`"));
}
+ // A Warp's map is one this build draws: 0, the earlier map, or its style's Bézier net.
+ let newest = vectorcraft_render::effects::WARP_NET;
+ if effect.starts_with("warp.")
+ && let Some(v) = given.get("version")
+ && vectorcraft_render::effects::warp_version(v).is_none()
+ {
+ return Err(bad(id, format!("`version` must be 0 or {newest}")));
+ }
check_effect_color(id, effect, params)?;
check_effect_channel(id, effect, params)
}
diff --git a/crates/engine/src/tests/effectedit.rs b/crates/engine/src/tests/effectedit.rs
index 2d10c3f5d9..34e01bf31e 100644
--- a/crates/engine/src/tests/effectedit.rs
+++ b/crates/engine/src/tests/effectedit.rs
@@ -147,3 +147,37 @@ fn show_all_hidden_attributes() {
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, id).appearance.has_hidden());
}
+
+/// A Warp applied now takes its style's Bézier net (`version` 1) unless a map is given, an edit
+/// keeps the map the effect has (one saved before the version existed keeps the earlier map), and
+/// a map this build doesn't draw is refused.
+#[test]
+fn warps_keep_the_map_their_version_names() {
+ let (mut s, id) = session_with_rect();
+ let version = |s: &Session| node(s, id).appearance.effects[0].params.get("version").cloned();
+ run(&mut s, "effect.apply", json!({"effect": "warp.flag", "params": {"bend": 40}}));
+ assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET)));
+ run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 20}, "warpStyle": "arc"}));
+ assert_eq!(version(&s), Some(json!(vectorcraft_doc::live::WARP_NET)));
+ // As a file saved before the version existed gives it.
+ let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
+ if let Some(Value::Object(p)) = doc.node_mut(id).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) {
+ p.remove("version");
+ }
+ run(&mut s, "effect.setParams", json!({"index": 0, "params": {"bend": 10, "horizontal": 20}}));
+ assert_eq!(version(&s), None);
+ run(&mut s, "effect.setParams", json!({"index": 0, "params": {"version": 1}}));
+ assert_eq!(version(&s), Some(json!(1)));
+ run(&mut s, "effect.apply", json!({"effect": "warp.arc", "params": {"version": 0}}));
+ assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0));
+ // A whole number given as a float is that version.
+ run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 1.0}}));
+ assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(1));
+ run(&mut s, "effect.setParams", json!({"index": 1, "params": {"version": 0.0}}));
+ assert_eq!(node(&s, id).appearance.effects[1].params["version"], json!(0));
+ for bad in [json!(2), json!(-1), json!(0.5), json!("1")] {
+ assert!(s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"version": bad}})).is_err(), "{bad}");
+ assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"version": bad}})).is_err(), "{bad}");
+ }
+ assert_eq!(node(&s, id).appearance.effects.len(), 2);
+}
diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs
index e4d030d387..4ffd7a361a 100644
--- a/crates/engine/src/tests/envelope.rs
+++ b/crates/engine/src/tests/envelope.rs
@@ -123,6 +123,51 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() {
assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err());
}
+/// A new warp envelope bends with its style's Bézier net (`version` [`live::WARP_NET`]) and keeps
+/// it through Reset with Warp. One saved before the version existed keeps the earlier map when its
+/// warp or its options change.
+#[test]
+fn new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map() {
+ // The surface point a quarter of the way along the top edge.
+ let quarter = |s: &Session, e| live::EnvelopeMap::of(&node(s, e)).unwrap().surface_mesh(4, 4, vectorcraft_color::Color::BLACK).points[1].p;
+ let at = |x, y| vectorcraft_geom::Point::new(x, y);
+ let mut s = session();
+ let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40}));
+ assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
+ assert_eq!(s.execute("object.envelope.info", &json!({})).unwrap()["version"], json!(live::WARP_NET));
+ // Flag moves the top edge 6 × bend × height × u(1 - u)(1 - 2u) down at u: 22.5 at u = 1/4.
+ assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e));
+ s.execute("object.envelope.resetWithWarp", &json!({"bend": -40})).unwrap();
+ assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
+ assert!(quarter(&s, e).distance(at(150.0, 77.5)) < 1e-9, "{:?}", quarter(&s, e));
+ // As a file saved before the version existed gives it.
+ let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
+ if let Some(NodeKind::Envelope { kind, .. }) = doc.node_mut(e).map(|n| &mut n.kind)
+ && let EnvelopeKind::Warp { version, .. } = &mut **kind
+ {
+ *version = 0;
+ }
+ s.execute("object.envelope.options", &json!({"fidelity": 75})).unwrap();
+ s.execute("object.envelope.resetWithWarp", &json!({"bend": 40})).unwrap();
+ assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e));
+ // The earlier map's flag: half the bend times sin(π x), x = -1/2 here, in half heights.
+ assert!(quarter(&s, e).distance(at(150.0, 90.0)) < 1e-9, "{:?}", quarter(&s, e));
+ // `version` moves a warp to a map, and a new warp can take the earlier one.
+ s.execute("object.envelope.options", &json!({"version": 1.0})).unwrap();
+ assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: live::WARP_NET, .. }), "{:?}", kind(&s, e));
+ assert!(quarter(&s, e).distance(at(150.0, 122.5)) < 1e-9, "{:?}", quarter(&s, e));
+ s.execute("object.envelope.resetWithWarp", &json!({"version": 0})).unwrap();
+ assert!(matches!(kind(&s, e), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, e));
+ let (_, old) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "flag", "bend": 40, "version": 0}));
+ assert!(matches!(kind(&s, old), EnvelopeKind::Warp { version: 0, .. }), "{:?}", kind(&s, old));
+ for bad in [json!(2), json!(-1), json!(0.5), json!("1"), json!(null)] {
+ for cmd in ["object.envelope.makeWithWarp", "object.envelope.resetWithWarp", "object.envelope.options"] {
+ let r = s.execute(cmd, &json!({"version": bad}));
+ assert!(r.as_ref().is_err_and(|e| e.to_string().contains("`version` must be 0 or 1")), "{cmd} {bad}: {r:?}");
+ }
+ }
+}
+
#[test]
fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() {
let mut s = session();
@@ -141,9 +186,9 @@ fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() {
let content = node(&s, a);
assert_eq!((content.opacity, content.blend, content.extras().mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look");
let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") };
- // The mesh follows the arc: its outline reaches below the content's bottom edge at the sides.
+ // The mesh follows the arc: its top corners swing out past the content's sides and up.
let b = m.outline().bounds().unwrap();
- assert!(b.y1 > 201.0, "{b:?}");
+ assert!(b.x0 < 99.0 && b.x1 > 301.0 && b.y0 < 99.0, "{b:?}");
assert_eq!(s.doc().unwrap().selection.objects, ids);
}
diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs
index 8a1e46c68a..2dbef41238 100644
--- a/crates/engine/src/tests/envelope_distort.rs
+++ b/crates/engine/src/tests/envelope_distort.rs
@@ -286,17 +286,18 @@ fn symbols_bend_with_the_envelope() {
let e = arch(&mut s, 60.0, json!({}));
let pts = anchors(&evaluated(&s, e));
assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle");
- // Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art).
+ // Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art). The
+ // arch lifts the middle of the 100..200 box by about 51, so (200, 120) is inside it.
let mut r = vectorcraft_render::Renderer::new();
let region = Rect::new(0.0, 0.0, 400.0, 300.0);
- assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60);
+ assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] < 60);
s.edit("edit symbol", |d, _| {
let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default());
d.symbols[0].art = Arc::new(art);
Ok(())
})
.unwrap();
- assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 200);
+ assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 120)[1] > 200);
}
fn strokes(nodes: &[Node]) -> usize {
diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs
index 9fc790efa2..33cc5749e1 100644
--- a/crates/engine/src/tests/live.rs
+++ b/crates/engine/src/tests/live.rs
@@ -483,8 +483,8 @@ fn envelope_warp_make_and_content_stays_inside_bounds() {
let pts = all_anchors(&out);
assert!(pts.len() > 4, "curves are subdivided");
assert!(pts.iter().all(|p| b.contains(*p)), "{b:?}");
- // It really is bent: the sides moved down.
- assert!(pts.iter().any(|p| p.y > 201.0));
+ // It really is bent: the top corners swung out past the sides.
+ assert!(pts.iter().any(|p| p.x < 99.0) && pts.iter().any(|p| p.x > 301.0));
}
#[test]
diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs
index a99646c9c2..6549004c7d 100644
--- a/crates/eps/src/ai_editing/tests.rs
+++ b/crates/eps/src/ai_editing/tests.rs
@@ -6627,6 +6627,7 @@ fn every_effect_a_graphic_style_holds_is_read_back() {
for id in &ids {
cases.push((id, json!({"bend": 30.0, "horizontal": -10.0, "vertical": 20.0, "orientation": "vertical"})));
cases.push((id, json!({"bend": -30.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
+ cases.push((id, json!({"bend": 45.0, "horizontal": 15.0, "vertical": -5.0, "orientation": "horizontal", "version": 1})));
}
for (id, params) in cases {
let effect = fx(id, params.clone());
@@ -6643,6 +6644,10 @@ fn every_effect_a_graphic_style_holds_is_read_back() {
let got = &back.graphic_styles[0].appearance.effects;
assert_eq!(got.len(), 1, "{id} {params}");
assert_eq!(got[0].id, effect.id);
+ // A warp read from a file bends as its style's Bézier net.
+ if id.starts_with("warp.") {
+ assert_eq!(got[0].params["version"], json!(vectorcraft_doc::live::WARP_NET), "{id} {params}");
+ }
let (g, w) = (vectorcraft_effects::merged_params(&got[0].id, &got[0].params), vectorcraft_effects::merged_params(&effect.id, &effect.params));
for (key, value) in w.as_object().unwrap() {
// Random isn't written, and a style with it isn't read; its seed is the default.
@@ -6663,6 +6668,19 @@ fn every_effect_a_graphic_style_holds_is_read_back() {
}
}
}
+ // A warp saved before the map version existed is written with its settings too, and reads back on
+ // the nets' map.
+ let old = vectorcraft_doc::Effect { id: "warp.flag".into(), params: json!({"bend": 40.0, "horizontal": 10.0}), visible: true };
+ let mut doc = Document::new(200.0, 100.0);
+ let mut g = vectorcraft_doc::GraphicStyle::new("Old", Appearance { effects: vec![old], ..Appearance::basic(black_fill(), Paint::None, 0.0) });
+ g.id = 3;
+ *doc.graphic_styles = vec![g];
+ let out = export_ai_editing(&doc, &opts()).unwrap();
+ assert!(!out.warnings.iter().any(|w| w == writer::PANEL_STYLES), "{:?}", out.warnings);
+ let back = read_back(&doc);
+ let [warp] = back.graphic_styles[0].appearance.effects.as_slice() else { panic!("{:?}", back.graphic_styles) };
+ assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["horizontal"]), ("warp.flag", &json!(40.0), &json!(10.0)));
+ assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET));
// A Zig Zag with its 0 to 1 settings written under the size's own key, and a turn in radians.
let mut doc = Document::new(200.0, 100.0);
let effects = vec![
diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs
index c4ae9fda3b..c28c538bcf 100644
--- a/crates/eps/src/ai_editing/writer/styles.rs
+++ b/crates/eps/src/ai_editing/writer/styles.rs
@@ -889,9 +889,16 @@ pub(in crate::ai_editing) fn same_appearance(a: &Appearance, b: &Appearance) ->
}
/// Effect `e`'s settings with its defaults filled in, its blending mode in lower case and its color as
-/// `#rrggbb`.
+/// `#rrggbb`. A Warp's map `version` is left out: the file holds the warp's settings, and the reader
+/// gives every warp it reads the newest map, so a warp saved before the version existed is written
+/// with its settings as before.
fn normalized_params(e: &Effect) -> Value {
let mut p = vectorcraft_effects::merged_params(&e.id, &e.params);
+ if e.id.starts_with("warp.")
+ && let Value::Object(m) = &mut p
+ {
+ m.remove("version");
+ }
if let Some(m) = word(&p, "mode") {
p["mode"] = Value::String(m);
}
diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs
index 8b3fceecf3..50674860a6 100644
--- a/crates/eps/src/import/ai/envelope.rs
+++ b/crates/eps/src/import/ai/envelope.rs
@@ -244,6 +244,7 @@ fn warp(o: &Obj) -> Option {
h: percent("DeformHoriz")?,
v: percent("DeformVert")?,
horizontal: !vertical,
+ version: vectorcraft_doc::live::WARP_NET,
})
}
diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs
index f7df4f35d6..911d8340cc 100644
--- a/crates/eps/src/import/ai/read/directives.rs
+++ b/crates/eps/src/import/ai/read/directives.rs
@@ -1259,7 +1259,7 @@ mod tests {
fn envelopes_in_symbol_definitions_spend_the_imports_checks() {
use crate::import::ai::tests::{drawn_content, envelope_content, envelope_of, straight_mesh, warp_keys};
use vectorcraft_doc::live::EnvelopeKind;
- let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
+ let want = EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: vectorcraft_doc::live::WARP_NET };
let envelope =
envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&want));
let mut reader = Reader::new(b"");
diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs
index f15744f30c..ef661c1446 100644
--- a/crates/eps/src/import/ai/style.rs
+++ b/crates/eps/src/import/ai/style.rs
@@ -1159,6 +1159,7 @@ fn effect(f: &Filter, under: Under) -> Option {
"horizontal": pct("DeformHoriz"),
"vertical": pct("DeformVert"),
"orientation": if flag("Rotate") { "vertical" } else { "horizontal" },
+ "version": vectorcraft_doc::live::WARP_NET,
}),
)
}
diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs
index d39a87b5c0..47086ca171 100644
--- a/crates/eps/src/import/ai/style/tests.rs
+++ b/crates/eps/src/import/ai/style/tests.rs
@@ -495,6 +495,8 @@ fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() {
// The warp is the whole object's; the characters paint below every item.
let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) };
assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal")));
+ // A file's warp bends as its style's Bézier net.
+ assert_eq!(warp.params["version"], json!(vectorcraft_doc::live::WARP_NET));
assert_eq!(st.contents_index, None);
}
diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs
index 46386d2200..8fa0a58346 100644
--- a/crates/eps/src/import/ai/tests.rs
+++ b/crates/eps/src/import/ai/tests.rs
@@ -5,6 +5,7 @@ use std::sync::Arc;
use kurbo::{Affine, Point, Rect};
use vectorcraft_color::gradient::GradientKind;
use vectorcraft_color::{BlendMode, Color, Paint};
+use vectorcraft_doc::live::WARP_NET;
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Guide, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind, Unit};
use super::*;
@@ -7403,15 +7404,16 @@ fn a_warp_envelope_opens_live() {
for (style, bend, h, v, rotate, id) in
[(1, 0.5, 0.0, 0.0, 0, "arc"), (1, 0.5, -0.2, 0.3, 1, "arc"), (8, 0.5, 0.0, 0.0, 0, "flag"), (15, 0.5, 0.0, 0.0, 0, "twist")]
{
- let want = EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0 };
+ let want =
+ EnvelopeKind::Warp { style: id.into(), bend: bend * 100.0, h: h * 100.0, v: v * 100.0, horizontal: rotate == 0, version: WARP_NET };
let body =
envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(style, bend, h, v, rotate), &envelope_content(), 1, &drawn_content(&want));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
let (content, kind, fidelity, editing, options) = live_envelope(&a[0]);
- let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal } = kind else { panic!("{kind:?}") };
- assert_eq!((s.as_str(), *horizontal), (id, rotate == 0));
+ let EnvelopeKind::Warp { style: s, bend: b, h: dh, v: dv, horizontal, version } = kind else { panic!("{kind:?}") };
+ assert_eq!((s.as_str(), *horizontal, *version), (id, rotate == 0, WARP_NET));
assert!([(*b, bend), (*dh, h), (*dv, v)].iter().all(|(got, f)| (got - f * 100.0).abs() < 1e-9), "{kind:?}");
assert_eq!((fidelity, editing, options), (50.0, false, vectorcraft_doc::live::EnvelopeOptions::NEW));
assert_eq!(content.len(), 1, "{content:#?}");
@@ -7443,7 +7445,7 @@ fn warp_envelopes_read_every_style() {
"twist",
];
for (n, id) in ids.iter().enumerate() {
- let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
+ let want = EnvelopeKind::Warp { style: (*id).into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let keys = warp_keys(n as u32 + 1, 0.3, 0.0, 0.0, 0);
let r = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &keys, &envelope_content(), 1, &drawn_content(&want))));
let a = art(&r.doc);
@@ -7974,7 +7976,7 @@ fn an_envelope_that_doesnt_read_keeps_its_drawn_art() {
#[test]
fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
use vectorcraft_doc::live::EnvelopeKind;
- let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true };
+ let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &envelope_content(), 1, &drawn_content(&other));
let r = read_ok(&layer(&body));
let a = art(&r.doc);
@@ -7989,7 +7991,7 @@ fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
fn a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() {
use vectorcraft_doc::live::EnvelopeKind;
const WHITE: &str = "0 0 0 0 1 1 1 Xa\n";
- let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true };
+ let other = EnvelopeKind::Warp { style: "arc".into(), bend: -50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let content = envelope_content().replace(RED, WHITE);
let drawn = drawn_content(&other).replace(RED, WHITE);
let body = envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), &warp_keys(1, 0.5, 0.0, 0.0, 0), &content, 1, &drawn);
diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs
index 8f64280367..7353c5c190 100644
--- a/crates/eps/src/import/tests_native.rs
+++ b/crates/eps/src/import/tests_native.rs
@@ -5419,7 +5419,14 @@ fn type_envelope_text(family: &str, paint: Option<&str>) -> String {
fn arc_envelope(text: &Node, bend: f64) -> Node {
let mut text = text.clone();
text.visible = true;
- let kind = vectorcraft_doc::live::EnvelopeKind::Warp { style: "arc".into(), bend, h: 0.0, v: 0.0, horizontal: true };
+ let kind = vectorcraft_doc::live::EnvelopeKind::Warp {
+ style: "arc".into(),
+ bend,
+ h: 0.0,
+ v: 0.0,
+ horizontal: true,
+ version: vectorcraft_doc::live::WARP_NET,
+ };
Node::new(
vectorcraft_doc::NodeId(1_000_000),
NodeKind::Envelope {
diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs
index 3f5ebbb2fe..2b6610beb1 100644
--- a/crates/render/src/live.rs
+++ b/crates/render/src/live.rs
@@ -781,7 +781,7 @@ mod tests {
d.symbols.push(Symbol { dynamic: false, name: format!("S{k}"), art: Arc::new(art) });
}
let first = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S0".into(), xf: Affine::IDENTITY });
- let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(first)],
kind: Box::new(kind),
@@ -815,7 +815,7 @@ mod tests {
d.symbols.push(Symbol { dynamic: false, name: name.into(), art: art.clone() });
}
let content = ["A", "B", "C"].map(|s| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: s.into(), xf: Affine::IDENTITY })));
- let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: content.to_vec(),
kind: Box::new(kind),
diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs
index 8f6d68f575..0e7864b0b9 100644
--- a/crates/render/src/tests/fontchange.rs
+++ b/crates/render/src/tests/fontchange.rs
@@ -338,7 +338,7 @@ fn enveloped_type_redraws_when_its_font_becomes_available() {
let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 120.0)) });
let l = d.layers[0].id;
for (i, content) in [typed, shown].into_iter().enumerate() {
- let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(content)],
kind: Box::new(kind),
@@ -376,7 +376,7 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() {
let mut d = Document::new(300.0, 150.0);
let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() };
let typed = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 80.0), "Hamburg", style))));
- let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(typed)],
kind: Box::new(kind),
@@ -420,7 +420,7 @@ fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() {
const FAMILY: &str = "Mapped Serif 4";
let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() };
let t = TextObject::point(Point::new(10.0, 60.0), "Hamburg", style);
- let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = Node::new(
vectorcraft_doc::NodeId(1),
NodeKind::Envelope {
diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs
index d5e6919f0d..f95735baec 100644
--- a/crates/render/src/tests/memory.rs
+++ b/crates/render/src/tests/memory.rs
@@ -1252,7 +1252,7 @@ fn art_with_live_objects_draws_pattern_layout_edits() {
d.patterns.push(def);
let band = vectorcraft_geom::shapes::rectangle(Rect::new(-80.0, -40.0, 80.0, 40.0));
let filled = Node::path(d.alloc_id(), band, vectorcraft_doc::Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
- let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let options = EnvelopeOptions { distort_appearance: true, distort_pattern_fills: true, ..Default::default() };
let envelope = Node::new(
d.alloc_id(),
@@ -1398,7 +1398,7 @@ fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() {
/// An envelope of `d` bending `content` with an arc warp (30%); with `patterns`, Distort
/// Appearance and Distort Pattern Fills are on, and the tiles of its pattern fills bend with it.
fn arc_envelope(d: &mut Document, content: Vec, patterns: bool) -> Node {
- let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance: patterns, distort_pattern_fills: patterns, ..Default::default() };
let content = content.into_iter().map(Arc::new).collect();
Node::new(
diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs
index fe1e5ee1d1..7e4007c6ac 100644
--- a/crates/render/src/tests/objectfx.rs
+++ b/crates/render/src/tests/objectfx.rs
@@ -439,7 +439,7 @@ fn transforms_of(t: &Node) -> [(Value, Affine); 3] {
/// A 100×100 document holding `content` in an envelope that leaves it as it is (an arc with no
/// bend), with Distort Appearance on or off.
fn envelope_doc(content: Node, distort_appearance: bool) -> Document {
- let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance, ..Default::default() };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(content)],
diff --git a/crates/render/src/tests/placed_document.rs b/crates/render/src/tests/placed_document.rs
index c3c00a4cf0..3a65b1abe0 100644
--- a/crates/render/src/tests/placed_document.rs
+++ b/crates/render/src/tests/placed_document.rs
@@ -135,7 +135,7 @@ fn an_enveloped_placed_document_shows_its_file_once_read() {
let placed = d.remove(NodeId(50)).unwrap();
let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("a placed document") };
let p = (**p).clone();
- let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![placed],
kind: Box::new(kind),
diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs
index db3aad793e..32e1df2a16 100644
--- a/crates/tools/src/xform/eyedropper.rs
+++ b/crates/tools/src/xform/eyedropper.rs
@@ -1543,7 +1543,7 @@ mod tests {
let d = doc_with_live_over_green(|d| {
let a = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 10.0);
let red = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 420.0, 320.0)), a);
- let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let content = vec![std::sync::Arc::new(red)];
let n = Node::new(
d.alloc_id(),
@@ -1574,7 +1574,7 @@ mod tests {
use vectorcraft_geom::{Affine, Rect};
let d = doc_with_live_over_green(|d| {
let red = solid_rect(d, Rect::new(300.0, 300.0, 420.0, 320.0), Color::rgb(1.0, 0.0, 0.0));
- let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true };
+ let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let content = vec![std::sync::Arc::new(red)];
let options = Default::default();
Node::new(
diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs
index 3a58ee3a9e..9e5c531c94 100644
--- a/crates/ui-egui/src/dialogs/effect.rs
+++ b/crates/ui-egui/src/dialogs/effect.rs
@@ -879,6 +879,13 @@ fn command(d: &Dialog) -> (&'static str, Value) {
p["item"] = item.unwrap_or(Value::Null);
p["params"] = params;
let id = d.str("__effect");
+ // Each target keeps the map its own Warp draws with: the dialog's `version` is the first
+ // target's.
+ if super::warp_effect::is_warp(&id)
+ && let Some(Value::Object(m)) = p.get_mut("params")
+ {
+ m.remove("version");
+ }
// The displayed style comes from the first target. Preserve other targets' Warp IDs
// unless the user deliberately chooses Style, including after a preview rollback.
if super::warp_effect::is_warp(&id) && d.bool("__warp_style_changed") {
diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs
index f537595ebc..6c9d95b601 100644
--- a/crates/ui-egui/src/dialogs/envelope.rs
+++ b/crates/ui-egui/src/dialogs/envelope.rs
@@ -239,7 +239,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> {
let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?;
let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None };
let bar = match &**kind {
- EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
+ EnvelopeKind::Warp { style, bend, h, v, horizontal, .. } => {
Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal }
}
EnvelopeKind::Mesh { rows, cols, .. } => Bar::Mesh { rows: *rows, cols: *cols },
diff --git a/crates/ui-egui/src/dialogs/tests_warp.rs b/crates/ui-egui/src/dialogs/tests_warp.rs
index e7cbfcc5b7..21d3e8e1a4 100644
--- a/crates/ui-egui/src/dialogs/tests_warp.rs
+++ b/crates/ui-egui/src/dialogs/tests_warp.rs
@@ -616,3 +616,33 @@ fn warp_options_style_keyboard_enter_does_not_accept_dialog() {
assert_eq!(app.session.doc().unwrap().doc, moved);
}
}
+
+/// Warp Options editing the Warps of two objects, one new and one saved without a map version,
+/// changes the Bend of both and keeps each one's own `version`.
+#[test]
+fn warp_options_edit_keeps_each_targets_map_version() {
+ let (mut app, first) = app();
+ app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap();
+ let second = NodeId(app.run("shape.rectangle", json!({"x": 230, "y": 60, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap());
+ app.run("effect.apply", json!({"effect": "warp.arc"})).unwrap();
+ // As a file saved before the version existed gives it.
+ let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc);
+ if let Some(Value::Object(p)) = doc.node_mut(second).and_then(|n| n.appearance.effects.first_mut()).map(|e| &mut e.params) {
+ p.remove("version");
+ }
+ app.run("select.set", json!({"ids": [first.0, second.0]})).unwrap();
+ app.run("effect.dialog", json!({"effect": "warp.arc"})).unwrap();
+ assert_eq!(app.ui.dialog.as_ref().unwrap().kind, effect::EXISTS);
+ let ctx = context();
+ settle(&mut app, &ctx);
+ click_text(&mut app, &ctx, "Edit");
+ settle(&mut app, &ctx);
+ make_warp_edit(&mut app, &ctx, "bend");
+ click_text(&mut app, &ctx, "OK");
+ assert!(app.ui.dialog.is_none());
+ let doc = app.session.doc().unwrap().doc.clone();
+ let params = |id: NodeId| doc.node(id).unwrap().appearance.effects[0].params.clone();
+ assert_eq!((¶ms(first)["bend"], ¶ms(first)["version"]), (&json!(25.0), &json!(vectorcraft_doc::live::WARP_NET)));
+ assert_eq!(params(second)["bend"], json!(25.0));
+ assert!(params(second).get("version").is_none(), "{:?}", params(second));
+}
diff --git a/crates/ui-egui/src/dialogs/warp_effect.rs b/crates/ui-egui/src/dialogs/warp_effect.rs
index ff530fbdf2..e8ef2bb6b7 100644
--- a/crates/ui-egui/src/dialogs/warp_effect.rs
+++ b/crates/ui-egui/src/dialogs/warp_effect.rs
@@ -1,7 +1,7 @@
//! Effect > Warp controls. The style remains an effect ID; Envelope Warp has a different schema.
use egui::{Pos2, Rect, Stroke, vec2};
use serde_json::{Map, Value, json};
-use vectorcraft_effects::{WARP_STYLES, WarpStyle, warp_point};
+use vectorcraft_effects::{WARP_STYLES, WarpNet, WarpStyle};
use super::form;
use crate::state::Dialog;
@@ -38,6 +38,8 @@ fn shown(label: &str) -> String {
fn style_preview(ui: &mut egui::Ui, suffix: &str) {
let (rect, _) = ui.allocate_exact_size(vec2(30.0, 22.0), egui::Sense::hover());
let Some(style) = WarpStyle::from_id(suffix) else { return };
+ // The map new warps take, on a box 0.55 times as high as it is wide.
+ let net = WarpNet::new(style, 0.45, 0.0, 0.0, 2.0, 1.1, false);
let mut lines = vec![];
let mut bounds = Rect::NOTHING;
for vertical in [false, true] {
@@ -46,8 +48,8 @@ fn style_preview(ui: &mut egui::Ui, suffix: &str) {
.map(|i| {
let along = -1.0 + f64::from(i) / 8.0;
let (x, y) = if vertical { (fixed, along) } else { (along, fixed) };
- let (x, y) = warp_point(style, 0.45, 0.0, 0.0, 0.55, x, y);
- let p = Pos2::new(x as f32, y as f32);
+ let q = net.at((x + 1.0) / 2.0, (y + 1.0) / 2.0);
+ let p = Pos2::new(q.x as f32, q.y as f32);
bounds.extend_with(p);
p
})
diff --git a/docs/effects-parity.md b/docs/effects-parity.md
index 2602b78bc4..c3aa528e54 100644
--- a/docs/effects-parity.md
+++ b/docs/effects-parity.md
@@ -28,7 +28,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
| Rasterize | Rasterize… (as a live effect) | 1/1 | — |
| Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets |
| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists the document's own filters: VectorCraft ships no default filters. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning |
-| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; Arc is a circular arc in the object's own proportions (#1578) |
+| Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; each style bends the bounds as one Bézier patch (`version` 1), so Arc's edges are cubic curves close to concentric arcs; warps saved without a version keep the earlier map, where Arc is a circular arc in the object's own proportions (#1578) |
Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers
its resolution, PDF export warns once per object with the requested and the lowest achieved ppi
diff --git a/docs/mcp.md b/docs/mcp.md
index da260b6823..8003354a7a 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -8238,6 +8238,20 @@ share of the content's width or height in proportion to the length of its contro
its curve parameter, so content spreads evenly along a segment without handles. An envelope opened from an `.ai` file
keeps the corners the file saves, and they stay on the same anchors when the envelope is moved, rotated or
reflected. These are VectorCraft's own rules, and existing top-object envelopes redraw by them.
+A warp bends the content's bounds as one Bézier patch. The style sets the patch's control points from the bend and
+the size of the bounds (Arc's top and bottom edges, for example, are cubic curves close to arcs around one center), then
+the horizontal distortion scales each column of control points about its middle, and the vertical distortion scales
+each row. Content with no width or no height takes the middle of the patch along that side. New warp envelopes, and
+those opened from `.ai` files, carry `version` 1 for this map, and `object.envelope.info` reports it. Envelopes in
+`.vectorcraft` documents saved before the version existed read as `version` 0, the earlier map, and keep their look,
+and editing an envelope keeps its version. `version` (0 or 1) on `object.envelope.makeWithWarp`,
+`object.envelope.resetWithWarp` and `object.envelope.options` sets the map. The Warp effects (`warp.arc` …
+`warp.twist`) take the same map through the same `version` param: `effect.apply` gives a new Warp `version` 1 unless
+`params.version` is 0, the built-in graphic styles' Warps have 1, and Effect › Warp edits keep each Warp's own version.
+These commands refuse any other value with `` `version` must be 0 or 1 ``; `1.0` counts as 1. An `.ai` file holds a
+warp's settings and not its version, so a `version` 0 warp saved to `.ai` and opened again draws with `version` 1.
+These patches are VectorCraft's own rules; the styles and the ranges of Bend and the distortions are those of the Warp
+options (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html).
`object.envelope.info` reads the selected envelope's settings (`type`, the warp's or mesh's values, `fidelity` and
the options, `editing`), or with no envelope selected the options new envelopes get. With an envelope selected,
`object.envelope.resetWithWarp` and `object.envelope.resetWithMesh` (`rows`, `cols`, `maintainShape`: true keeps
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index cf4c0df1a9..f4c35ddd41 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -588,6 +588,7 @@ since 2026-10-01.
- **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged.
- **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim.
- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
+- **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged.
- **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged.
- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
From 5a9c297608fc5456bb682c71cbd441339d4504e7 Mon Sep 17 00:00:00 2001
From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com>
Date: Thu, 12 Nov 2026 11:36:34 +0430
Subject: [PATCH 09/15] Compile clipboard failure helper only for its callers
---
apps/vectorcraft/src/clipboard.rs | 1 +
docs/development.md | 7 ++++++-
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs
index 5756600281..c5ef4d5579 100644
--- a/apps/vectorcraft/src/clipboard.rs
+++ b/apps/vectorcraft/src/clipboard.rs
@@ -651,6 +651,7 @@ mod portable {
impl Answer {
/// The answer to `request` when it couldn't be read: `why`, or no files.
+ #[cfg(any(test, target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn failed(request: Request, why: String) -> Self {
match request {
Request::Text => Self::Text(Err(why)),
diff --git a/docs/development.md b/docs/development.md
index 2362fc6f1a..e7831adf1b 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -1,6 +1,6 @@
# Development
-> **Last updated:** 2026-11-06 · **Change:** Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending.
+> **Last updated:** 2026-11-12 · **Change:** minor (clipboard failure helper follows its Linux/BSD and test callers; previously: Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending).
> **Last updated:** 2026-11-01 · **Change:** Linux: Automatic Linux Display Server opens the window through XWayland when a pen is attached on a Wayland session, so the pen draws (#4967); `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland.
> **Last updated:** 2026-10-31 · **Change:** files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance`.
> **Last updated:** 2026-10-29 · **Change:** Linux clipboard: the Flatpak on GNOME copies and pastes text through the core Wayland protocol.
@@ -351,6 +351,10 @@ parameters. Shipped code therefore never panics. Anything that can fail returns
### Enforced by lints
+The clipboard timeout failure helper is compiled for Linux/BSD workers and unit tests; production
+macOS reads synchronously. Keep helper target gates aligned with their callers instead of disabling
+dead-code warnings across the workspace.
+
`Cargo.toml` denies these clippy lints for the whole workspace, and `cargo xtask ci` runs clippy with
`-D warnings`. The gate targets the current stable Rust (`rust-version` in `Cargo.toml` is only the
minimum that builds the app, and no toolchain is pinned): when a new stable adds lints, they are fixed
@@ -508,6 +512,7 @@ Scores and hour estimates are unchanged.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-12 | minor | Compile the clipboard failure helper only for Linux/BSD worker callers and unit tests; keep production macOS free of the unused helper without suppressing workspace dead-code warnings. |
| 2026-11-06 | minor | Localisation: Hindi catalog, zero/one plurals, desktop and web font requirements; AI/MT draft, native review pending. |
| 2026-10-31 | minor | Files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance` opts out. |
| 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME).; its reads wait 2 s at most. |
From c35d5f5b347c360e0cc2a33f6ad0082df7f7ec00 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Thu, 12 Nov 2026 01:03:41 -0800
Subject: [PATCH 10/15] Read roots: links.findFiles and image.info follow the
configured read roots
With automation read roots in force, links.findFiles and text.findFontFiles
replies, including later polls and stops of a search another client started,
leave out the files found outside the caller's read roots, a sought link's
saved path outside them and the folder searched when it is outside them. A
failed search then gives its reason without the path. The rows keep their
names, ids and status, and the counts stay.
image.info gives link: null for a linked file outside the read roots, as
links.info does. text.addFontFiles gives folder: null when the Fonts folder
is outside the read roots and names the copies there by their file names;
the fonts are still copied.
Docs: docs/mcp.md, docs/target-app-parity.md.
---
crates/engine/src/cmd/findfiles/tests.rs | 12 ++-
crates/engine/src/cmd/findfiles/walk.rs | 66 ++++++++++---
crates/engine/src/cmd/fontfiles.rs | 28 ++++--
crates/engine/src/cmd/linkfiles.rs | 23 +++--
crates/engine/src/cmd/links.rs | 4 +-
crates/engine/src/cmd/place.rs | 6 +-
crates/engine/src/tests/fontfiles.rs | 37 ++++++++
crates/engine/src/tests/linkfiles.rs | 112 ++++++++++++++++++++++-
crates/engine/tests/font_folder.rs | 7 ++
docs/mcp.md | 8 ++
docs/target-app-parity.md | 1 +
11 files changed, 266 insertions(+), 38 deletions(-)
diff --git a/crates/engine/src/cmd/findfiles/tests.rs b/crates/engine/src/cmd/findfiles/tests.rs
index ec3ce57e3a..bf51ebdac7 100644
--- a/crates/engine/src/cmd/findfiles/tests.rs
+++ b/crates/engine/src/cmd/findfiles/tests.rs
@@ -183,6 +183,16 @@ fn a_picked_folder_inside_other_apps_folders_is_refused() {
assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}");
let p = search(&root, "missing", Limits::default(), 1, &[]);
assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}");
+ // Each failure has the same reason without the folder's path.
+ let briefs = [
+ ("missing", "the folder can't be read"),
+ ("file.ttf", "the path isn't a folder"),
+ ("refused", "VectorCraft doesn't search other apps' or the system's folders"),
+ ("Users/Public/x", "VectorCraft doesn't search the Shared or Public folder in Users"),
+ ];
+ for (picked, why) in briefs {
+ assert_eq!(search(&root, picked, Limits::default(), 1, &[]).failure(), Some(why), "{picked}");
+ }
}
#[test]
@@ -362,7 +372,7 @@ fn a_picked_folder_that_cant_be_listed_fails_the_search() {
let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
if enforced {
- assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}");
+ assert!(matches!(&p.end, Some(End::Failed(e, "the folder can't be listed")) if e.contains("locked")), "{p:?}");
} else {
assert_eq!(found(&root, &p), ["locked/y.ttf"]);
}
diff --git a/crates/engine/src/cmd/findfiles/walk.rs b/crates/engine/src/cmd/findfiles/walk.rs
index 8d35515717..ae05a04838 100644
--- a/crates/engine/src/cmd/findfiles/walk.rs
+++ b/crates/engine/src/cmd/findfiles/walk.rs
@@ -23,6 +23,13 @@ const CHECK_EVERY: u64 = 1024;
/// How a search ends when every walker thread stopped on a bug.
const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)";
+/// Why a search couldn't start, without the folder's path ([`Progress::failure`]).
+const UNREADABLE: &str = "the folder can't be read";
+const NOT_A_FOLDER: &str = "the path isn't a folder";
+const SHARED: &str = "VectorCraft doesn't search the Shared or Public folder in Users";
+const APPS: &str = "VectorCraft doesn't search other apps' or the system's folders";
+const UNLISTED: &str = "the folder can't be listed";
+
/// What a search looks for.
pub trait Visitor: Send + Sync {
/// Whether the regular file named `name` is to be read: asked of the files a folder lists until
@@ -77,8 +84,9 @@ pub enum End {
/// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item
/// keeps at most [`Limits::per_item`] files, without ending the search.
Limit,
- /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search.
- Failed(String),
+ /// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. Why,
+ /// and the same without the folder's path.
+ Failed(String, &'static str),
}
/// Where a search stands ([`Search::progress`]).
@@ -114,7 +122,7 @@ impl Progress {
None => "searching",
Some(End::Done | End::Found) => "done",
Some(End::Stopped | End::Time | End::Limit) => "stopped",
- Some(End::Failed(_)) => "failed",
+ Some(End::Failed(..)) => "failed",
}
}
@@ -131,10 +139,38 @@ impl Progress {
/// Why a failed search failed.
pub fn error(&self) -> Option<&str> {
match &self.end {
- Some(End::Failed(e)) => Some(e),
+ Some(End::Failed(e, _)) => Some(e),
_ => None,
}
}
+
+ /// Why a failed search failed, without the folder's path.
+ pub fn failure(&self) -> Option<&'static str> {
+ match &self.end {
+ Some(End::Failed(_, why)) => Some(why),
+ _ => None,
+ }
+ }
+
+ /// Keep what the caller asking now may read under its read roots ([`crate::file_access::current`]),
+ /// for a reply to it: the files found inside them. Returns the folder searched (`given` until a
+ /// walker checked it) and why the search failed, when that folder lies inside them too;
+ /// otherwise no folder and why without its path ([`Self::failure`]). Without read roots in
+ /// force, everything is kept.
+ pub fn keep_readable(&mut self, given: &str) -> (Option, Option) {
+ let folder = if self.folder.as_os_str().is_empty() { given.to_string() } else { self.folder.to_string_lossy().into_owned() };
+ if crate::file_access::current().is_none() {
+ return (Some(folder), self.error().map(str::to_string));
+ }
+ // A path that isn't valid text is never shown: its lossy form names another file.
+ let readable = |p: &Path| p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok());
+ self.hits.retain(|(path, _)| readable(path));
+ if readable(Path::new(given)) && (self.folder.as_os_str().is_empty() || readable(&self.folder)) {
+ (Some(folder), self.error().map(str::to_string))
+ } else {
+ (None, self.failure().map(str::to_string))
+ }
+ }
}
/// Walker threads shared out among searches: all of them together run at most `cap` threads.
@@ -306,7 +342,7 @@ pub(crate) fn start_in(
return Err("the search couldn't start: try again in a moment".into());
}
if w.alive == 0 && w.end.is_none() {
- shared.finish(&mut w, End::Failed(INTERNAL.into()));
+ shared.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL));
}
}
Ok(Search { shared })
@@ -401,13 +437,13 @@ impl Shared {
fn prepare(&self, root: &Path) {
let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system));
let path = root.display();
- let checked = match std::fs::canonicalize(root) {
- Err(e) => Err(format!("{path}: {e}")),
- Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")),
+ let (checked, brief) = match std::fs::canonicalize(root) {
+ Err(e) => (Err(format!("{path}: {e}")), UNREADABLE),
+ Ok(folder) if !folder.is_dir() => (Err(format!("{path} is not a folder")), NOT_A_FOLDER),
Ok(folder) => match rules.refusal(&folder) {
- None => Ok(folder),
- Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")),
- Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")),
+ None => (Ok(folder), INTERNAL),
+ Some(Refusal::Shared) => (Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), SHARED),
+ Some(Refusal::Apps) => (Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), APPS),
},
};
let mut w = self.lock();
@@ -416,7 +452,7 @@ impl Shared {
w.folder = folder.clone();
w.stack.push((folder, 0));
}
- Err(e) => self.finish(&mut w, End::Failed(e)),
+ Err(e) => self.finish(&mut w, End::Failed(e, brief)),
}
}
@@ -430,7 +466,7 @@ impl Shared {
let mut w = self.lock();
// The picked folder itself: the search can't run, and says why.
if depth == 0 {
- self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display())));
+ self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()), UNLISTED));
} else {
w.unreadable += 1;
w.folders += 1;
@@ -610,7 +646,7 @@ fn walker(s: &Shared) {
if done.is_err() {
let mut w = s.lock();
match job {
- Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())),
+ Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)),
Job::List(..) => w.unreadable += 1,
}
}
@@ -640,7 +676,7 @@ impl Drop for Alive {
let mut w = self.0.lock();
w.alive = w.alive.saturating_sub(1);
if w.alive == 0 && w.end.is_none() {
- self.0.finish(&mut w, End::Failed(INTERNAL.into()));
+ self.0.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL));
}
drop(w);
self.0.pool.release(1);
diff --git a/crates/engine/src/cmd/fontfiles.rs b/crates/engine/src/cmd/fontfiles.rs
index 309347985c..0ccf1ab94a 100644
--- a/crates/engine/src/cmd/fontfiles.rs
+++ b/crates/engine/src/cmd/fontfiles.rs
@@ -76,7 +76,7 @@ pub fn specs() -> Vec {
"Find Font Files",
[],
None,
- "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
+ "{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links). With automation read roots, a reply leaves out the files found outside the caller's roots, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
always,
find
),
@@ -85,7 +85,7 @@ pub fn specs() -> Vec {
"Add Font Files",
[],
None,
- "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
+ "{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install. With automation read roots, folder is null when the Fonts folder is outside them, and a to or file path outside them is given as its file name; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
always,
add
),
@@ -275,7 +275,9 @@ fn find(s: &mut Session, p: &Value) -> Result {
/// Where the session's last search stands (`text.findFontFiles` without `folder`).
fn state(s: &Session) -> Value {
let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) };
- let p = f.search.progress();
+ let mut p = f.search.progress();
+ // Only what the caller asking now may read: a search another caller started can hold more.
+ let (folder, error) = p.keep_readable(&f.folder);
let mut files: Vec> = vec![vec![]; f.fonts.len()];
for (path, items) in &p.hits {
for i in items {
@@ -293,16 +295,18 @@ fn state(s: &Session) -> Value {
json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files })
})
.collect();
- let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
let mut out = json!({
- "id": f.id, "state": p.state(), "folder": folder, "fonts": fonts,
+ "id": f.id, "state": p.state(), "fonts": fonts,
"searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read },
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
});
+ if let Some(folder) = folder {
+ out["folder"] = json!(folder);
+ }
if let Some(why) = p.stopped() {
out["stopped"] = json!(why);
}
- if let Some(e) = p.error() {
+ if let Some(e) = error {
out["error"] = json!(e);
}
out
@@ -348,12 +352,16 @@ fn add(s: &mut Session, p: &Value) -> Result {
// VectorCraft's own Fonts folder: no automation root applies to it.
crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?;
let report = copy_into(&files, dir);
- let pairs = |v: &[(PathBuf, PathBuf)]| {
- v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::>()
+ // The caller sees the Fonts folder and the copies in it only where its read roots reach.
+ let readable = |p: &Path| crate::file_access::current().is_none() || p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok());
+ let shown = |p: &Path| -> String {
+ let shown = if readable(p) { Some(p.as_os_str()) } else { p.file_name() };
+ shown.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
};
+ let pairs = |v: &[(PathBuf, PathBuf)]| v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": shown(to) })).collect::>();
let mut out = json!({
- "folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
- "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::>(),
+ "folder": readable(dir).then(|| dir.to_string_lossy()), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
+ "skipped": report.skipped.iter().map(|(file, why)| json!({ "file": shown(file), "reason": why })).collect::>(),
});
if !report.copied.is_empty() || !report.kept.is_empty() {
// The fonts are cataloged and type set in them redraws, as Refresh Font List does it.
diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs
index 5b5a6aa48a..f2ce9d4d12 100644
--- a/crates/engine/src/cmd/linkfiles.rs
+++ b/crates/engine/src/cmd/linkfiles.rs
@@ -29,7 +29,7 @@ pub fn specs() -> Vec {
"Find Linked Files",
[],
None,
- "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits; none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
+ "{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits. With automation read roots, a reply leaves out the files found outside the caller's roots, a link's path outside them, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path?, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
always,
find
)]
@@ -52,6 +52,8 @@ pub(crate) struct LinkSearch {
pub(crate) id: u64,
/// The folder as given.
pub(crate) folder: String,
+ /// The path of the document the links were taken from, which a relative link path starts from.
+ pub(crate) doc: Option,
pub(crate) links: Vec,
/// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]).
pub(crate) sizes: Arc>>,
@@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result {
if links.is_empty() {
return Err(bad(C, if ids.is_some() { "these objects show no linked files" } else { "no linked files are missing" }));
}
+ let doc = super::links::LinkBase::of(st).doc.map(str::to_string);
// A new search stops the last one.
s.link_search = None;
let threads = s.search_threads.unwrap_or_else(findfiles::threads);
@@ -171,7 +174,7 @@ fn find(s: &mut Session, p: &Value) -> Result {
let visitor = Arc::new(LinkFiles::new(&links, sizes.clone()));
let search =
findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, 2 * links.len(), visitor).map_err(EngineError::Other)?;
- s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, links, sizes, search });
+ s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, doc, links, sizes, search });
Ok(state(s))
}
@@ -209,7 +212,9 @@ fn shared_folders(saved: &str, found: &Path) -> usize {
/// Where the session's last search stands (`links.findFiles` without `folder`).
fn state(s: &Session) -> Value {
let Some(l) = &s.link_search else { return json!({ "state": "idle", "links": [] }) };
- let p = l.search.progress();
+ let mut p = l.search.progress();
+ // Only what the caller asking now may read: a search another caller started can hold more.
+ let (folder, error) = p.keep_readable(&l.folder);
let n = l.links.len();
let sizes = lock(&l.sizes).clone();
// Per linked file: the files found, each with whether it is the same file.
@@ -267,19 +272,23 @@ fn state(s: &Session) -> Value {
f
})
.collect();
- json!({ "name": link.name, "path": link.path, "ids": link.ids, "files": files })
+ let mut row = json!({ "name": link.name, "ids": link.ids, "files": files });
+ super::links::note_path(&mut row, "path", &link.path, l.doc.as_deref());
+ row
})
.collect();
- let folder = if p.folder.as_os_str().is_empty() { l.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
let mut out = json!({
- "id": l.id, "state": p.state(), "folder": folder, "links": links,
+ "id": l.id, "state": p.state(), "links": links,
"searched": { "folders": p.folders, "files": p.files, "read": p.read },
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
});
+ if let Some(folder) = folder {
+ out["folder"] = json!(folder);
+ }
if let Some(why) = p.stopped() {
out["stopped"] = json!(why);
}
- if let Some(e) = p.error() {
+ if let Some(e) = error {
out["error"] = json!(e);
}
out
diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs
index a134eb1c55..89c0afefcf 100644
--- a/crates/engine/src/cmd/links.rs
+++ b/crates/engine/src/cmd/links.rs
@@ -898,12 +898,12 @@ fn rows<'a>(gs: impl Iterator- , doc_path: Option<&str>) -> Vec<
/// May a reply to the caller asking now carry the link path `path` (of the document saved at
/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a
/// relative path taken from the document's folder) lies inside them.
-fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
+pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok())
}
/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`.
-fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
+pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
if path_shown(path, doc_path) {
row[key] = json!(path);
} else if let Some(o) = row.as_object_mut() {
diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs
index a6e7f67286..5d56a2b646 100644
--- a/crates/engine/src/cmd/place.rs
+++ b/crates/engine/src/cmd/place.rs
@@ -87,7 +87,7 @@ pub fn specs() -> Vec
{
"Image Info",
[],
None,
- "{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
+ "{id?} an image object (default: the one selected) → {id, name, linked, link (the linked file's path; null when embedded, or when automation read roots are in force and the file is outside them), colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
has_doc,
image_info
),
@@ -891,11 +891,13 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result {
let [a, b, c, d, ..] = im.xf.as_coeffs();
let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l);
let size = n.geometric_bounds().unwrap_or_default();
+ // A linked file outside the read roots of the caller asking now is left unnamed, as in Link Info.
+ let doc_path = super::links::LinkBase::of(st).doc;
Ok(json!({
"id": id.0,
"name": n.display_name(),
"linked": im.link.is_some(),
- "link": im.link.as_ref().map(|l| &l.path),
+ "link": im.link.as_ref().map(|l| &l.path).filter(|path| super::links::path_shown(path, doc_path)),
"colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)),
"pixelWidth": im.width,
"pixelHeight": im.height,
diff --git a/crates/engine/src/tests/fontfiles.rs b/crates/engine/src/tests/fontfiles.rs
index f31df35eb1..8bef8733b5 100644
--- a/crates/engine/src/tests/fontfiles.rs
+++ b/crates/engine/src/tests/fontfiles.rs
@@ -194,6 +194,43 @@ fn a_search_finds_a_missing_fonts_file_by_its_names() {
assert!(matches!(r["state"].as_str(), Some("stopped" | "done")), "{r}");
}
+/// A caller confined by read roots sees, in the replies to a search another caller started, only
+/// the files found inside its read roots, and the folder searched and a failed search's path only
+/// when they are inside them.
+#[test]
+fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
+ let root = folder("read-roots");
+ let font = vectorcraft_testkit::fonts::renamed("Findme Sans 3");
+ let (home, wide) = (root.join("home"), root.join("lrf-wide"));
+ for dir in [&home, &wide] {
+ std::fs::create_dir_all(dir).unwrap();
+ std::fs::write(dir.join("Findme.otf"), &font).unwrap();
+ }
+ let mut s = session_for(&root);
+ let roots = |read: &[&Path]| crate::file_access::AutomationRoots::new(read, &[]).unwrap().unwrap();
+ let (narrow, broad) = (roots(&[&home]), roots(&[&root]));
+ let ask = |s: &mut Session, r: &Arc, p: Value| {
+ crate::file_access::confine(Some(r), || s.execute("text.findFontFiles", &p).unwrap())
+ };
+ // A font no file has keeps the search going through every folder.
+ let fonts = json!([{"family": "Findme Sans 3"}, {"family": "No Such Sans"}]);
+ ask(&mut s, &broad, json!({"folder": root, "fonts": fonts}));
+ finished(&mut s);
+ let all = ask(&mut s, &broad, json!({}));
+ assert_eq!((&all["fonts"][0]["files"], &all["folder"]), (&json!([home.join("Findme.otf"), wide.join("Findme.otf")]), &json!(root)), "{all}");
+ for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] {
+ assert!(!r.to_string().contains("lrf-wide") && r.get("folder").is_none(), "{r}");
+ assert_eq!((&r["fonts"][0]["files"], &r["fonts"][0]["family"]), (&json!([home.join("Findme.otf")]), &json!("Findme Sans 3")), "{r}");
+ assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}");
+ }
+ // A search that failed: the reason shows without the path to a caller outside its folder.
+ ask(&mut s, &broad, json!({"folder": wide.join("gone"), "fonts": fonts}));
+ assert!(finished(&mut s)["error"].as_str().is_some_and(|e| e.contains("lrf-wide")));
+ let r = ask(&mut s, &narrow, json!({}));
+ assert_eq!((r["state"].as_str(), r["error"].as_str(), r.get("folder")), (Some("failed"), Some("the folder can't be read"), None), "{r}");
+ let _ = std::fs::remove_dir_all(&root);
+}
+
#[test]
fn junk_search_params_never_fail_internally() {
let root = folder("junk");
diff --git a/crates/engine/src/tests/linkfiles.rs b/crates/engine/src/tests/linkfiles.rs
index 695ea41419..ef9127042a 100644
--- a/crates/engine/src/tests/linkfiles.rs
+++ b/crates/engine/src/tests/linkfiles.rs
@@ -271,7 +271,7 @@ fn stop_ends_only_the_search_it_names() {
let (tx, rx) = mpsc::channel();
let search = findfiles::start(root.clone(), s.search_rules.clone(), Limits::default(), 1, 2, Arc::new(Blocked(Mutex::new(rx)))).unwrap();
let links = vec![Sought { name: "a.png".into(), path: "/gone/a.png".into(), ids: vec![], size: None, hash: None }];
- s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), links, sizes: Arc::default(), search });
+ s.link_search = Some(LinkSearch { id: 7, folder: root.to_string_lossy().into_owned(), doc: None, links, sizes: Arc::default(), search });
let until = Instant::now() + WAIT;
while s.execute("links.findFiles", &json!({})).unwrap()["searched"]["read"] != 1 && Instant::now() < until {
std::thread::sleep(Duration::from_millis(2));
@@ -284,6 +284,116 @@ fn stop_ends_only_the_search_it_names() {
drop(tx);
}
+/// A caller confined by read roots sees, in the replies to a search another caller started and to
+/// its own, only the paths inside its read roots: the files found outside them, a link's saved path
+/// outside them and the folder searched are left out, and the rows, ids and counts stay. Without
+/// roots, the replies are as before.
+#[test]
+fn a_search_shows_a_confined_caller_only_paths_inside_its_read_roots() {
+ let root = folder("read-roots");
+ let mut s = session_for(&root);
+ let photo = png(40, 30, RED);
+ write(&root.join("lrf-away/photo.png"), &photo);
+ let id = placed(&mut s, &root.join("lrf-away/photo.png"));
+ std::fs::remove_file(root.join("lrf-away/photo.png")).unwrap();
+ // Files of the name with other contents: the search reads every folder.
+ let other = png(40, 30, BLUE);
+ let (home, wide) = (root.join("home"), root.join("lrf-wide"));
+ write(&home.join("photo.png"), &other);
+ write(&wide.join("photo.png"), &other);
+ let roots = |read: &[&Path], write: &[&Path]| crate::file_access::AutomationRoots::new(read, write).unwrap().unwrap();
+ let (narrow, write_only, broad) = (roots(&[&home], &[]), roots(&[], &[&root]), roots(&[&root], &[]));
+ let ask = |s: &mut Session, r: &Arc, p: Value| {
+ crate::file_access::confine(Some(r), || s.execute("links.findFiles", &p).unwrap())
+ };
+ let hidden = |r: &Value| {
+ let text = r.to_string();
+ assert!(!text.contains("lrf-away") && !text.contains("lrf-wide") && r.get("folder").is_none(), "{r}");
+ let row = &r["links"][0];
+ assert_eq!((row["name"].as_str(), &row["ids"], row.get("path")), (Some("photo.png"), &json!([id]), None), "{r}");
+ };
+ // Started with the broad roots, over the whole folder.
+ let r = ask(&mut s, &broad, json!({"folder": root}));
+ assert_eq!(r["folder"], json!(root), "{r}");
+ finished(&mut s);
+ let all = ask(&mut s, &broad, json!({}));
+ assert_eq!(all["links"][0]["files"].as_array().map(Vec::len), Some(2), "{all}");
+ for r in [ask(&mut s, &narrow, json!({})), ask(&mut s, &narrow, json!({"stop": true}))] {
+ hidden(&r);
+ assert_eq!(r["links"][0]["files"], json!([{"path": home.join("photo.png"), "size": other.len(), "match": "name"}]), "{r}");
+ assert_eq!((&r["state"], &r["searched"], &r["id"]), (&all["state"], &all["searched"], &all["id"]), "{r}");
+ }
+ let r = ask(&mut s, &write_only, json!({}));
+ hidden(&r);
+ assert_eq!(r["links"][0]["files"], json!([]), "{r}");
+ // Without roots, every path shows.
+ let r = s.execute("links.findFiles", &json!({})).unwrap();
+ assert_eq!((&r["folder"], &r["links"][0]["path"]), (&json!(root), &json!(root.join("lrf-away/photo.png"))), "{r}");
+ assert_eq!(r["links"][0]["files"].as_array().map(Vec::len), Some(2), "{r}");
+ // The narrow caller's own search shows the folder it gave.
+ let r = ask(&mut s, &narrow, json!({"folder": home}));
+ assert_eq!(r["folder"], json!(home), "{r}");
+ finished(&mut s);
+ let r = ask(&mut s, &narrow, json!({}));
+ assert_eq!((&r["folder"], r["links"][0].get("path")), (&json!(home), None), "{r}");
+ // A search that failed: the reason shows without the path to a caller outside its folder.
+ ask(&mut s, &broad, json!({"folder": root.join("lrf-wide/gone")}));
+ let r = finished(&mut s);
+ assert!(r["error"].as_str().is_some_and(|e| e.contains("lrf-wide")), "{r}");
+ let r = ask(&mut s, &narrow, json!({}));
+ assert_eq!((r["state"].as_str(), r["error"].as_str()), (Some("failed"), Some("the folder can't be read")), "{r}");
+ hidden(&r);
+ let _ = std::fs::remove_dir_all(&root);
+}
+
+/// A link's saved path relative to the document is shown to a confined caller when it leads to a
+/// file inside its read roots, and left out when `..` leads out of them.
+#[test]
+fn a_relative_link_path_is_judged_from_the_documents_folder() {
+ let root = folder("read-roots-relative");
+ let mut s = session_for(&root);
+ let home = root.join("home");
+ write(&root.join("lrf-away/photo.png"), &png(40, 30, RED));
+ let id = placed(&mut s, &root.join("lrf-away/photo.png"));
+ std::fs::create_dir_all(&home).unwrap();
+ s.execute("document.save", &json!({"path": home.join("poster.vectorcraft")})).unwrap();
+ let narrow = crate::file_access::AutomationRoots::new(&[&home], &[]).unwrap().unwrap();
+ for (saved, shown) in [("sub/photo.png", true), ("../lrf-away/photo.png", false)] {
+ s.edit("relative path", |d, _| {
+ if let NodeKind::Image(im) = &mut d.node_mut(NodeId(id)).unwrap().kind {
+ im.link.as_mut().unwrap().path = saved.into();
+ }
+ Ok(())
+ })
+ .unwrap();
+ let r = crate::file_access::confine(Some(&narrow), || s.execute("links.findFiles", &json!({"folder": home, "ids": [id]})).unwrap());
+ assert_eq!(r["links"][0].get("path"), shown.then(|| json!(saved)).as_ref(), "{saved}: {r}");
+ finished(&mut s);
+ }
+ let _ = std::fs::remove_dir_all(&root);
+}
+
+/// `image.info` leaves a linked file outside the confined caller's read roots unnamed (`link`
+/// null), as `links.info` leaves it out, and keeps the other fields.
+#[test]
+fn image_info_leaves_out_a_link_outside_the_read_roots() {
+ let root = folder("read-roots-info");
+ let mut s = session_for(&root);
+ let pic = root.join("lrf-away/photo.png");
+ write(&pic, &png(40, 30, RED));
+ let id = placed(&mut s, &pic);
+ std::fs::create_dir_all(root.join("home")).unwrap();
+ let narrow = crate::file_access::AutomationRoots::new(&[&root.join("home")], &[]).unwrap().unwrap();
+ let ask = |s: &mut Session, cmd: &str| crate::file_access::confine(Some(&narrow), || s.execute(cmd, &json!({"id": id})).unwrap());
+ let r = ask(&mut s, "image.info");
+ assert_eq!((&r["link"], &r["linked"], &r["pixelWidth"], &r["id"]), (&Value::Null, &json!(true), &json!(40), &json!(id)), "{r}");
+ assert!(!r.to_string().contains("lrf-away"), "{r}");
+ let info = ask(&mut s, "links.info");
+ assert!(info.get("link").is_none() && !info.to_string().contains("lrf-away"), "{info}");
+ assert_eq!(s.execute("image.info", &json!({"id": id})).unwrap()["link"], json!(pic));
+ let _ = std::fs::remove_dir_all(&root);
+}
+
#[test]
fn relinking_with_copy_puts_the_file_in_the_documents_folder() {
let root = folder("copy");
diff --git a/crates/engine/tests/font_folder.rs b/crates/engine/tests/font_folder.rs
index b58e79fc17..11d152fb4d 100644
--- a/crates/engine/tests/font_folder.rs
+++ b/crates/engine/tests/font_folder.rs
@@ -71,6 +71,13 @@ fn a_found_font_is_copied_without_replacing_a_file_and_its_type_redraws() {
assert!(st.revision > revision && !st.is_dirty());
assert_eq!(s.execute("text.missingFonts", &json!({})).unwrap()["count"], 0);
+ // A caller whose read roots hold the file but not the Fonts folder: the file is kept, and the
+ // reply names the copy by its file name only, without the folder.
+ let downloads = vectorcraft_engine::file_access::AutomationRoots::new(&[&root.join("Downloads")], &[]).unwrap();
+ let r = vectorcraft_engine::file_access::confine(downloads.as_ref(), || s.execute("text.addFontFiles", &json!({"files": [file]}))).unwrap();
+ assert_eq!((&r["folder"], &r["kept"]), (&Value::Null, &json!([{"from": file, "to": "Findme 2.otf"}])), "{r}");
+ assert!(!r.to_string().contains("App Fonts"), "{r}");
+
// The same file again is kept, not copied a third time.
let r = s.execute("text.addFontFiles", &json!({"files": [file]})).unwrap();
assert_eq!((r["copied"].clone(), r["kept"].clone()), (json!([]), json!([{"from": file, "to": fonts.join("Findme 2.otf")}])), "{r}");
diff --git a/docs/mcp.md b/docs/mcp.md
index 62b8052a2a..0a2b114487 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -201,6 +201,14 @@ the suite) keep the agent to the folders you give it:
and `modified` the document keeps of that file. Each row keeps its id, `name` (`fileName`), status and other
fields. This applies to every document, also one the person or a client with wider roots opened. The paths a
client gives `links.relink` and `links.unembed` stay in their replies, and the document's links are not changed.
+- `image.info` gives `link: null` for a linked file outside the read roots, as `links.info` leaves it out; its id,
+ name, `linked` and the other fields stay. `links.findFiles` and `text.findFontFiles` replies, including later
+ `{}` and `{stop: true}` calls on a search another client started, leave out the files found outside the read
+ roots (with their `size` and `match`), a sought link's `path` outside them, and `folder` when the folder searched
+ is outside them; `error` then gives the reason without the path (for example `the folder can't be read`). The
+ rows keep their `name`, `ids`, family, style and status, and the counts stay. `text.addFontFiles` gives
+ `folder: null` when VectorCraft's Fonts folder is outside the read roots, and only the file name in `copied` and
+ `kept` `to` and in `skipped` `file` for a path outside them; the fonts are still copied.
- `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show a folder preference outside the read roots
(`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`) as
`null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 2a1eb83381..405a4fee2d 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -763,6 +763,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`.
- `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`).
- Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`).
+ - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`.
- Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/` and keeps that id for the session, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`.
- Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`.
- Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`.
From cdcac345a9bbeb25a3678b3eb3dcaa39b1b4060b Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Thu, 12 Nov 2026 00:45:41 -0800
Subject: [PATCH 11/15] Legacy .ai: type keeps Standard Vertical Roman
Alignment and Tate-chu-yoko
Horizontal type keeps both marks, and the legacy .ai writer writes such
type as text, but the marks were dropped without a warning. Version 7
text has operators for both (section 15.3.4 of the published format
specification): Tv for Roman characters upright in vertical writing,
and TY for a Kumi block in horizontal text recorded for later vertical
use. The writer now writes 1 Tv and 0 Tv, and 1 TY and 0 TY, where a
run's mark changes, closes an open Kumi block before the text object
ends, and writes neither operator for type without the marks. The
legacy .ai reader reads both back onto the same characters. The prolog
defines Tv and TY so the file still runs as PostScript.
---
crates/eps/src/legacy_ai.rs | 5 ++-
crates/eps/src/legacy_ai/prolog.rs | 2 +-
crates/eps/src/legacy_ai/tests.rs | 53 ++++++++++++++++++++++++++++++
crates/eps/src/legacy_ai/writer.rs | 21 +++++++++++-
docs/file-format-parity.md | 3 +-
5 files changed, 80 insertions(+), 4 deletions(-)
diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs
index e256e305c2..10af4f8f9a 100644
--- a/crates/eps/src/legacy_ai.rs
+++ b/crates/eps/src/legacy_ai.rs
@@ -33,7 +33,10 @@
//! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx
//! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each
//! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`,
-//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Only the
+//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Standard
+//! Vertical Roman Alignment is written as `1 Tv` and `0 Tv`, and Tate-chu-yoko in horizontal
+//! type as a Kumi block recorded for later use, `1 TY` … `0 TY` (§15.3.4); neither operator is
+//! written for type that has neither mark. Only the
//! revisable form is written: readers lay the type out again (§15.1). Fonts are named by their
//! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to
//! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding
diff --git a/crates/eps/src/legacy_ai/prolog.rs b/crates/eps/src/legacy_ai/prolog.rs
index b0d606bba9..5ee77f89cf 100644
--- a/crates/eps/src/legacy_ai/prolog.rs
+++ b/crates/eps/src/legacy_ai/prolog.rs
@@ -106,7 +106,7 @@ VCLegacy begin
/TE {cleartomark} bd /TZ {cleartomark} bd
/To {pop} bd /Tp {7 {pop} repeat} bd /TP {} bd /TO {} bd
/Tr {pop} bd /Tf {pop pop pop pop} bd /Ts {pop} bd /Tz {pop pop} bd /Tt {pop} bd /TA {pop} bd
-/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd
+/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd /Tv {pop} bd /TY {pop} bd
% The page trailer (3): annotatepage marks nothing.
/annotatepage {} bd
end
diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs
index 43b28c934a..d0dd1f64e8 100644
--- a/crates/eps/src/legacy_ai/tests.rs
+++ b/crates/eps/src/legacy_ai/tests.rs
@@ -1193,6 +1193,59 @@ fn variable_font_settings_are_warned_as_left_out() {
assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text");
}
+/// Standard Vertical Roman Alignment is written as `Tv` and Tate-chu-yoko in horizontal type as a
+/// Kumi block recorded for later use, `TY` (§15.3.4), each where it changes: the marks read back
+/// on the same characters, a block is closed before its text object ends, and type with neither
+/// mark has neither operator.
+#[test]
+fn vertical_roman_and_tate_chu_yoko_are_written_and_read_back() {
+ use vectorcraft_doc::{TextObject, TextRun};
+ let plain = style(12.0, Paint::solid(Color::BLACK));
+ let roman = vectorcraft_doc::CharStyle { standard_vertical_roman: true, ..plain.clone() };
+ let tcy = vectorcraft_doc::CharStyle { tate_chu_yoko: true, ..plain.clone() };
+ let both = vectorcraft_doc::CharStyle { tate_chu_yoko: true, standard_vertical_roman: true, ..plain.clone() };
+ let mut doc = Document::new(300.0, 200.0);
+ let layer = doc.layers[0].id;
+ let mut marked = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "A", plain.clone());
+ for (text, st) in [("12", &tcy), ("b", &roman), ("c", &both), ("34", &tcy)] {
+ marked.runs.push(TextRun::new(text, st.clone()));
+ }
+ let next = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "D", plain.clone());
+ for t in [marked, next] {
+ let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
+ doc.insert(Some(layer), usize::MAX, n).unwrap();
+ }
+ let out = write(&doc);
+ assert!(!out.warnings.iter().any(|w| w == TYPE || w == TYPE_ATTRIBUTES), "{:?}", out.warnings);
+ let text = String::from_utf8(out.bytes.clone()).unwrap();
+ assert_eq!((text.matches("1 TY\n").count(), text.matches("0 TY\n").count()), (2, 2), "{text}");
+ assert_eq!((text.matches("1 Tv\n").count(), text.matches("0 Tv\n").count()), (1, 1), "{text}");
+ // The last block ends before its text object does.
+ assert!(text.contains("(34) Tx\n0 TY\nTO\n"), "{text}");
+ let read = texts(&crate::import(&out.bytes).unwrap().document);
+ let flags = |t: &TextObject| -> Vec<(String, bool, bool)> {
+ let mut out: Vec<(String, bool, bool)> = vec![];
+ for r in &t.runs {
+ let key = (r.style.tate_chu_yoko, r.style.standard_vertical_roman);
+ match out.last_mut() {
+ Some(last) if (last.1, last.2) == key => last.0.push_str(&r.text),
+ _ => out.push((r.text.clone(), key.0, key.1)),
+ }
+ }
+ out
+ };
+ let s = |x: &str| x.to_string();
+ assert_eq!(
+ flags(&read[0].1),
+ [(s("A"), false, false), (s("12"), true, false), (s("b"), false, true), (s("c"), true, true), (s("34"), true, false)]
+ );
+ assert_eq!(flags(&read[1].1), [(s("D"), false, false)]);
+
+ // Type with neither mark has neither operator.
+ let text = String::from_utf8(write(&typed()).bytes).unwrap();
+ assert!(!text.contains(" Tv\n") && !text.contains(" TY\n"), "{text}");
+}
+
/// A legacy `.ai` text path holds the path and where the type starts on it, and no Align to Path
/// or end bracket: type on a path with either is written as text, with the warning that they are
/// left out. An end bracket at the end of an open path, or once around a closed one, is kept. A
diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs
index 8f8fc4ccf2..9bcd5f4f5d 100644
--- a/crates/eps/src/legacy_ai/writer.rs
+++ b/crates/eps/src/legacy_ai/writer.rs
@@ -39,6 +39,10 @@ pub(super) struct Writer<'a> {
in_hidden: bool,
/// The fonts type is set in: PostScript name, and whether it's TrueType.
pub(super) fonts: Vec<(String, bool)>,
+ /// The Standard Vertical Roman Alignment the last `Tv` set (it lasts into later text objects).
+ roman: bool,
+ /// Inside a Kumi block `1 TY` began (Tate-chu-yoko in horizontal type).
+ kumi: bool,
}
impl<'a> Writer<'a> {
@@ -59,6 +63,8 @@ impl<'a> Writer<'a> {
hidden_art: vec![],
in_hidden: false,
fonts: vec![],
+ roman: false,
+ kumi: false,
}
}
@@ -815,6 +821,10 @@ impl<'a> Writer<'a> {
self.line(" Tx");
}
}
+ if self.kumi {
+ self.line("0 TY");
+ self.kumi = false;
+ }
self.line("TO");
}
@@ -834,7 +844,8 @@ impl<'a> Writer<'a> {
}
/// A run's character attributes and paint style: `Tr`, its fill and stroke, `Tf`, `Ts`, `Tz`,
- /// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`.
+ /// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`; then `Tv` and `TY`
+ /// where Standard Vertical Roman Alignment or Tate-chu-yoko changes.
fn char_style(&mut self, st: &CharStyle, p: &ParaStyle) {
let fill = !st.fill.is_none();
let stroke = st.has_stroke();
@@ -872,6 +883,14 @@ impl<'a> Writer<'a> {
let leading = st.leading.unwrap_or_else(|| p.justification.auto_leading_for(st.size));
ps::push_nums(&mut self.body, &[finite(leading, size * 1.2), finite(p.space_before, 0.0)]);
self.line("Tl");
+ if st.standard_vertical_roman != self.roman {
+ self.roman = st.standard_vertical_roman;
+ self.line(if self.roman { "1 Tv" } else { "0 Tv" });
+ }
+ if st.tate_chu_yoko != self.kumi {
+ self.kumi = st.tate_chu_yoko;
+ self.line(if self.kumi { "1 TY" } else { "0 TY" });
+ }
}
/// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 23a36a7e9a..2f2c88abeb 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -155,7 +155,8 @@ private editing data. VectorCraft:
under them, clipped to where they show, along a smooth outline traced at the image's own resolution; that art stays) or omitted (Transparent Images,
`transparentImages: "flatten"|"omit"`). Type is written as editable text objects (point type, area type and type
on a path, with their fonts' PostScript names, sizes, colours, alignment, indents, leading, tracking and
- scaling), which the reading app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline
+ scaling, and the Standard Vertical Roman Alignment and Tate-chu-yoko marks as `Tv` and `TY`), which the reading
+ app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline
graphics and gradient or pattern fills make that type outlines, and so do rotated characters (Character Rotation,
which a version 7 text object can't hold: the outlines keep the rotation, and the type is no longer editable text
in the file), and Text: Outlines (`outlineText: true`) writes
From a1a23c46e49e31e7f55db8175a6f4071b303fa52 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 21:05:53 -0800
Subject: [PATCH 12/15] Warp: the Warp effects bend an image's pixels with its
frame
A Warp effect on an image, or on a group that holds one, bent the image's
frame but drew the pixels unbent, cut by the bent frame. The image now
bends with the frame as a raster mesh warp, as an envelope bends it: after
the last effect in the list, the image is cut into 10 x 10 cells of two
triangles, each a clipped piece of the image under its own affine map,
through every Warp it took and the Transform moves between them. Effects
after a Warp reshape only the frame, and a placed gradient on a group
that holds the image follows the group's bounds as the pieces draw them.
The image itself is unchanged, so editing or removing the warp redraws
it. The images stay unbent when their pieces would make the art pass 2
million objects, and an image stays unbent when its clipping frame keeps
its shape because the warp would pass the effects' anchor budget.
- SVG export writes the image once and draws the pieces with when a
group holds pieces of one image clipped by triangles. PDF, EPS, .ai and
PNG exports write the bent pieces.
- Envelopes: a grayscale image's fill color now colorizes the pieces an
envelope cuts. Before, the pieces drew the gray image.
- Geometry effects: an image under any geometry effect keeps its fill
color and its smoothing setting. Before, it drew the gray image,
smoothed.
Docs: Warp params text, docs/mcp.md, docs/target-app-parity.md.
---
crates/doc/src/live.rs | 2 +-
crates/doc/src/live/expand.rs | 47 ++-
crates/effects/src/catalog.rs | 2 +-
crates/effects/src/reshape.rs | 296 ++++++++++++++++++-
crates/effects/src/tests.rs | 311 ++++++++++++++++++++
crates/effects/src/warp.rs | 18 +-
crates/engine/src/tests.rs | 1 +
crates/engine/src/tests/envelope_distort.rs | 29 ++
crates/engine/src/tests/warp_images.rs | 293 ++++++++++++++++++
crates/svg/src/export/nodes.rs | 19 ++
docs/mcp.md | 10 +
docs/target-app-parity.md | 1 +
12 files changed, 991 insertions(+), 38 deletions(-)
create mode 100644 crates/engine/src/tests/warp_images.rs
diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs
index cfba618bad..d1eb20ed21 100644
--- a/crates/doc/src/live.rs
+++ b/crates/doc/src/live.rs
@@ -60,7 +60,7 @@ pub use envelope::{
pub(crate) use expand::max_outset;
pub use expand::{
Hooks, MAX_GENERATED, Outliner, PatternTiles, affine_near, expand_deep, expand_deep_hooks, expand_live, expand_live_hooks, expand_live_with,
- expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, subtree_has_live,
+ expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, mesh_warp_cost, mesh_warp_image, subtree_has_live,
};
#[cfg(test)]
use mesh::grid_eval;
diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs
index 8675dcc011..7c80866ee7 100644
--- a/crates/doc/src/live/expand.rs
+++ b/crates/doc/src/live/expand.rs
@@ -178,29 +178,46 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option {
a.is_finite().then_some(a)
}
-/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into
-/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map
-/// that takes the triangle where the warp takes its corners (with Preserve Shape Using
-/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing).
-/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no
-/// seams show.
+/// Image `n` (`im`) pushed through the envelope's warp as a raster mesh warp ([`mesh_warp_image`]
+/// with `w.cells` cells, Preserve Shape Using Transparency and Anti-Alias as the envelope's
+/// options say).
fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
let k = w.cells.max(1);
- // Two triangles a cell, three objects each; an image an inner envelope already cut into
- // pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image
- // is left out.
- if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) {
+ // An image an inner envelope already cut into pieces is cut again here, so the pieces count
+ // against [`MAX_GENERATED`]. Past it, the image is left out.
+ if !Generating::charge(mesh_warp_cost(k)) {
let mut g = n.clone();
g.kind = NodeKind::Group { children: vec![], clip: false };
return g;
}
+ mesh_warp_image(n, im, w.f, k, !w.options.anti_alias, w.options.preserve_shape == PreserveShape::Transparency)
+}
+
+/// How many objects [`mesh_warp_image`] makes of one image with `cells` rows and columns: two
+/// triangles a cell, three objects each.
+pub fn mesh_warp_cost(cells: usize) -> usize {
+ let k = cells.max(1);
+ k.saturating_mul(k).saturating_mul(6)
+}
+
+/// Image `n` (`im`) pushed through point map `f` as a raster mesh warp: its frame cut into
+/// `cells`² cells of two triangles, each a clip group showing the image under the affine map
+/// that takes the triangle where `f` takes its corners (with `masked`, the image masked by the
+/// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past
+/// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the
+/// color `n` colorizes a grayscale image with. The result is a group with `n`'s id, name,
+/// transparency, opacity mask and appearance.
+pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node {
+ let k = cells.max(1);
+ // A grayscale image's fill color colorizes each piece; the group of pieces takes none.
+ let colorize = n.extras().colorize.clone();
let (pw, ph) = (im.width as f64, im.height as f64);
let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64);
let mut pieces = Vec::with_capacity(2 * k * k);
for j in 0..k {
for i in 0..k {
let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)];
- let d = s.map(|p| (w.f)(im.xf * p));
+ let d = s.map(|p| f(im.xf * p));
for t in [[0, 1, 2], [0, 2, 3]] {
let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v]));
let Some(xf) = affine_between(st, dt) else { continue };
@@ -216,8 +233,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
.collect();
let shape = PathData::single(SubPath::polyline(&tri, true));
let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
- image.extras_mut().pixelated = !w.options.anti_alias;
- if w.options.preserve_shape == PreserveShape::Transparency {
+ image.extras_mut().pixelated = pixelated;
+ image.extras_mut().colorize = colorize.clone();
+ if masked {
// Preserve Shape Using Transparency: the triangle is the piece's alpha (a
// white opacity mask) instead of its clipping path.
let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
@@ -234,6 +252,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
}
let mut g = n.clone();
g.kind = NodeKind::Group { children: pieces, clip: false };
+ if let Some(e) = g.extras_if_any() {
+ e.colorize = None;
+ }
g
}
diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs
index a827bea0e2..7cbe2b2395 100644
--- a/crates/effects/src/catalog.rs
+++ b/crates/effects/src/catalog.rs
@@ -112,7 +112,7 @@ fn lengths_of(id: &str) -> Lengths {
}
}
-const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}";
+const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds, and images bend as 10 × 10 cells of two triangles unless the pieces would make the art pass 2,000,000 objects or the image's frame keeps its shape past the anchor budget), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}";
const DT: &[&str] = &["Effect", "Distort & Transform"];
diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs
index c629cc10ca..9d93f82a79 100644
--- a/crates/effects/src/reshape.rs
+++ b/crates/effects/src/reshape.rs
@@ -4,19 +4,22 @@
//! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
-//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
-//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
+//! warp bends a line of type as a whole. An image is clipped by its reshaped frame, and a Warp
+//! effect bends its pixels with the frame ([`cut_warped_images`]). Raster effects (shadows, glows, blur,
+//! feather) stay on the result for the renderer and exporters. Type without
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
//! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
//! them ([`image_rows`]).
use std::collections::HashSet;
+use std::rc::Rc;
use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
use vectorcraft_doc::live::Hooks;
-use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
-use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
+use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind};
+use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes};
use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params};
@@ -548,7 +551,14 @@ fn as_art_opts(
let mut art = match &n.kind {
NodeKind::Image(im) => {
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
- let image = Node::new(n.id, n.kind.clone());
+ let mut image = Node::new(n.id, n.kind.clone());
+ // Its pixels draw as the image's do: colorized by a grayscale image's fill color, and
+ // smoothed or not.
+ {
+ let mut e = image.extras_mut();
+ e.colorize = n.extras().colorize.clone();
+ e.pixelated = n.extras().pixelated;
+ }
let clipped = || {
let mut clip = Node::path(n.id, frame.clone(), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
@@ -632,9 +642,9 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
/// its reference box and the art's seed (a random Transform moves the pieces together). Images and
-/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
-/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
-/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
+/// symbol instances follow a Transform effect the same way ([`transform_placed`]), and images
+/// follow a Warp effect as a raster mesh warp ([`cut_warped_images`]); other geometry effects leave them
+/// as they are. The paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns
/// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one
/// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]).
@@ -705,8 +715,9 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
let max = budget.at_least(used);
// Read before the effects run: a path's own copies don't make it art of several objects.
let copy_whole = copies_whole(art);
+ let mut warped = WarpedImages::new();
for e in fx {
- let Some(bounds) = art.geometric_bounds() else { break };
+ let Some(bounds) = warped.bounds(art) else { break };
let params = merged_params(&e.id, &e.params);
// Where a live-effect plug-in sits, which it is listed by when it is deferred.
let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e)));
@@ -739,10 +750,21 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
}
let mut groups = vec![];
if follows {
- group_marks(art, &mut vec![], &HashSet::new(), &mut groups);
+ group_marks(art, &mut vec![], &HashSet::new(), None, &mut groups);
+ // The images earlier Warps bend are measured by their pieces only when a group's
+ // gradient needs the bounds.
+ if !groups.is_empty()
+ && let Some(images) = warped.when_cut(art)
+ {
+ groups.clear();
+ group_marks(art, &mut vec![], &HashSet::new(), Some(images), &mut groups);
+ }
}
- // The ids of the paths that kept their shape (past the budget).
+ // The ids of the paths that kept their shape (past the budget), and their places in the
+ // order [`for_each_leaf`] visits the paths.
let mut kept_ids = HashSet::new();
+ let mut kept_leaves = HashSet::new();
+ let mut leaf_at = 0usize;
let transform = (e.id == "distort.transform").then(|| {
let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
let options = crate::distort::TransformOptions::of(¶ms);
@@ -757,6 +779,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
continue;
}
for_each_leaf(art, &mut |leaf| {
+ let at = leaf_at;
+ leaf_at += 1;
let Some((path, rule)) = leaf_geometry(leaf) else { return };
// What the other paths leave of the budget.
let before = Budget::of(&path);
@@ -789,6 +813,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else {
kept_ids.insert(leaf.id);
+ kept_leaves.insert(at);
return;
};
used = used.minus(before).plus(Budget::of(&out));
@@ -804,6 +829,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
if boxes {
rebase_leaves(art, &mut leaves.into_iter());
}
+ // Warp bends images too: each one's pixels follow the warp's map over the same box. The
+ // images are cut into pieces once, after the last effect ([`cut_warped_images`]). An image
+ // whose clipping frame kept its shape (past the budget) keeps its pixels as they are.
+ if let Some(style) = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id)
+ && crate::warp::bends(&e.id, ¶ms)
+ {
+ let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, ¶ms));
+ warped.reserve(art);
+ mark_warped_images(art, &f, &kept_leaves, false, &mut 0, &mut warped);
+ }
if let Some((m, copies, options, _)) = transform
&& options.objects
{
@@ -816,14 +851,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
// follow their bounds under Free Distort and Warp (they stay when no path changed them).
// Each stays when a path under it that its bounds measure kept its shape (past the budget).
let mut after = vec![];
- let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1;
+ let images = if groups.is_empty() { None } else { warped.when_cut(art) };
+ let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, images, &mut after).1;
if !groups.is_empty() {
carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed));
}
if follows && !held {
- whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed));
+ whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), warped.bounds(art), seed));
}
}
+ cut_warped_images(art, &warped);
whole
}
@@ -1445,17 +1482,25 @@ type GroupMark = (Vec, Option, bool);
/// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or
/// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that
-/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`.
-fn group_marks(n: &Node, at: &mut Vec, kept: &HashSet, out: &mut Vec) -> (Option, bool) {
+/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. An image that `images`
+/// lists is measured by the pieces it will be cut into ([`WarpedImages::image_bounds`]).
+fn group_marks(
+ n: &Node,
+ at: &mut Vec,
+ kept: &HashSet,
+ images: Option<&WarpedImages>,
+ out: &mut Vec,
+) -> (Option, bool) {
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => (n.geometric_bounds(), kept.contains(&n.id)),
+ NodeKind::Image(_) => (images.map_or_else(|| n.geometric_bounds(), |w| w.image_bounds(n)), false),
NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } if !n.extras().type_outlines => {
let layer = matches!(n.kind, NodeKind::Layer { .. });
let skip = children.len().saturating_sub(n.editing_children().len());
let (mut bounds, mut held) = (None, false);
for (i, c) in children.iter().enumerate() {
at.push(i);
- let (b, h) = group_marks(c, at, kept, out);
+ let (b, h) = group_marks(c, at, kept, images, out);
if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) {
out.push((at.clone(), b, h));
}
@@ -1590,6 +1635,225 @@ pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, ma
copy_placed(n, m, copies, &mut room);
}
+/// Rows and columns of the pieces a Warp effect cuts an image into (VectorCraft's own number).
+pub(crate) const WARP_IMAGE_CELLS: usize = 10;
+
+/// A Warp effect's point map, shared by the images it bends.
+pub(crate) type WarpMap = Rc Point>;
+
+/// One image a Warp effect bent: the image's own key, and each warp it took with the placement
+/// the image had when it took it, in order.
+type WarpedImage = (String, Vec<(WarpMap, Affine)>);
+
+/// The images Warp effects bend while [`reshape_leaves_apart`] runs the effects. Each such image's
+/// key is replaced by a key of this table ([`WarpedImages::add`]), which a copy a later Transform
+/// effect makes keeps. The images are cut into pieces once, after the last effect
+/// ([`cut_warped_images`]): the effects after a Warp reshape the image's frame, not each piece,
+/// and a later Warp or Transform moves the one set of pieces.
+pub(crate) struct WarpedImages {
+ /// What this table's keys start with: no image key in the art starts with it
+ /// ([`WarpedImages::reserve`]).
+ prefix: String,
+ /// The prefix was checked against the art's image keys.
+ reserved: bool,
+ images: Vec,
+}
+
+impl WarpedImages {
+ pub(crate) fn new() -> Self {
+ static TABLES: AtomicUsize = AtomicUsize::new(0);
+ Self { prefix: format!("\0warp{}:", TABLES.fetch_add(1, Ordering::Relaxed)), reserved: false, images: vec![] }
+ }
+
+ /// Before the first image of `art` is marked: lengthen the prefix until no image key in `art`
+ /// starts with it, so no key from the document is read as one of this table's.
+ pub(crate) fn reserve(&mut self, art: &Node) {
+ if self.reserved {
+ return;
+ }
+ self.reserved = true;
+ let mut keys = vec![];
+ art.walk(&mut |c| {
+ if let NodeKind::Image(im) = &c.kind {
+ keys.push(im.key.clone());
+ }
+ });
+ while keys.iter().any(|k| k.starts_with(self.prefix.as_str())) {
+ self.prefix.push(':');
+ }
+ }
+
+ /// What this table's keys start with.
+ #[cfg(test)]
+ pub(crate) fn prefix(&self) -> &str {
+ &self.prefix
+ }
+
+ /// The image that `key`, a key of this table, stands for.
+ fn get(&self, key: &str) -> Option<&WarpedImage> {
+ self.images.get(key.strip_prefix(self.prefix.as_str())?.parse::().ok()?)
+ }
+
+ /// Record that the image with key `key`, placed by `xf`, takes warp `f`; returns the image's
+ /// new key.
+ fn add(&mut self, key: &str, xf: Affine, f: &WarpMap) -> String {
+ let (own, mut warps) = self.get(key).cloned().unwrap_or_else(|| (key.to_string(), vec![]));
+ warps.push((f.clone(), xf));
+ self.images.push((own, warps));
+ format!("{}{}", self.prefix, self.images.len() - 1)
+ }
+
+ /// Does the art `n` have room, within `max` nodes, for the pieces of every image under it that
+ /// this table lists?
+ fn fits(&self, n: &Node, max: usize) -> bool {
+ let mut count = 0usize;
+ n.walk(&mut |c| {
+ if let NodeKind::Image(im) = &c.kind
+ && self.get(&im.key).is_some()
+ {
+ count += 1;
+ }
+ });
+ vectorcraft_doc::live::mesh_warp_cost(WARP_IMAGE_CELLS).saturating_mul(count) <= max.saturating_sub(n.count())
+ }
+
+ /// The geometric bounds of `art` as it draws after the effects so far: each image this table
+ /// lists measured by the pieces the warps and moves it took would cut it into, when the art
+ /// has room for them ([`cut_warped_images`]).
+ pub(crate) fn bounds(&self, art: &Node) -> Option {
+ if self.images.is_empty() || !self.fits(art, crate::distort::MAX_COPY_OBJECTS) {
+ return art.geometric_bounds();
+ }
+ // The pieces the images would be cut into now: each group of pieces is shown or hidden as
+ // its image is, and hidden objects are left out of a layer's bounds.
+ let mut probe = art.clone();
+ restore_warped_images(&mut probe, self, true);
+ probe.geometric_bounds()
+ }
+
+ /// The geometric bounds of image `n`: when this table lists it, measured by the pieces the
+ /// warps and moves it took would cut it into.
+ fn image_bounds(&self, n: &Node) -> Option {
+ let NodeKind::Image(im) = &n.kind else { return n.geometric_bounds() };
+ if self.get(&im.key).is_none() {
+ return n.geometric_bounds();
+ }
+ let mut probe = Node::group(n.id, vec![Arc::new(n.clone())]);
+ restore_warped_images(&mut probe, self, true);
+ probe.geometric_bounds()
+ }
+
+ /// This table when the art `art` has room for the pieces of the images it lists, so that a
+ /// measure of the art takes them bent ([`WarpedImages::bounds`]); `None` otherwise.
+ fn when_cut(&self, art: &Node) -> Option<&Self> {
+ (!self.images.is_empty() && self.fits(art, crate::distort::MAX_COPY_OBJECTS)).then_some(self)
+ }
+}
+
+/// Record in `warped` that each image under `n` takes warp `f`, walking the groups and layers
+/// [`for_each_leaf`] walks and counting the paths it passes in `at`. `kept` holds the places of
+/// the paths that kept their shape (past the budget), in the order [`for_each_leaf`] visits the
+/// paths of the art: an image in a clip group whose clipping art holds one (`held`) does not take
+/// the warp.
+pub(crate) fn mark_warped_images(n: &mut Node, f: &WarpMap, kept: &HashSet, held: bool, at: &mut usize, warped: &mut WarpedImages) {
+ let (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }) = &mut n.kind else { return };
+ let mut held = held;
+ // A clip group's first member is its clipping art: when a path in it kept its shape, it and
+ // the members it clips keep theirs.
+ if *clip && let Some(first) = children.first() {
+ let start = *at;
+ held |= (start..start + leaf_count(first)).any(|p| kept.contains(&p));
+ }
+ for c in children.iter_mut() {
+ match &c.kind {
+ NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => *at += 1,
+ NodeKind::Group { .. } | NodeKind::Layer { .. } => mark_warped_images(Arc::make_mut(c), f, kept, held, at, warped),
+ NodeKind::Image(_) if !held => {
+ if let NodeKind::Image(im) = &mut Arc::make_mut(c).kind {
+ im.key = warped.add(&im.key, im.xf, f);
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+/// Give each image under `n` that `warped` lists its own key back, and cut it into the pieces of
+/// a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`], each piece drawn as the image
+/// is, smoothed or not) through the warps it took and the moves between and after them
+/// ([`warped_map`]). The art keeps to [`crate::distort::MAX_COPY_OBJECTS`] nodes: when the pieces
+/// would pass it, no image is cut. An image whose placement has no inverse is not cut either.
+fn cut_warped_images(n: &mut Node, warped: &WarpedImages) {
+ cut_warped_images_within(n, warped, crate::distort::MAX_COPY_OBJECTS);
+}
+
+/// [`cut_warped_images`] with the art keeping to `max` nodes.
+pub(crate) fn cut_warped_images_within(n: &mut Node, warped: &WarpedImages, max: usize) {
+ if warped.images.is_empty() {
+ return;
+ }
+ let cut = warped.fits(n, max);
+ restore_warped_images(n, warped, cut);
+}
+
+/// The walk of [`cut_warped_images`]: each listed image gets its key back, and with `cut` is cut
+/// into pieces.
+fn restore_warped_images(n: &mut Node, warped: &WarpedImages, cut: bool) {
+ let Some(children) = n.children_mut() else { return };
+ for c in children.iter_mut() {
+ let NodeKind::Image(im) = &c.kind else {
+ if c.children().is_some() {
+ restore_warped_images(Arc::make_mut(c), warped, cut);
+ }
+ continue;
+ };
+ let Some((key, warps)) = warped.get(&im.key) else { continue };
+ let mut image = (**c).clone();
+ let NodeKind::Image(own) = &mut image.kind else { continue };
+ own.key = key.clone();
+ // The pieces start from the image as it was placed when it took its first warp.
+ let first = warps.first().map(|(_, at)| ImageObject { xf: *at, ..(**own).clone() });
+ let map = warped_map(warps, own.xf);
+ *c = Arc::new(match (first, map) {
+ (Some(first), Some(map)) if cut => {
+ let pixelated = image.extras().pixelated;
+ vectorcraft_doc::live::mesh_warp_image(&image, &first, &*map, WARP_IMAGE_CELLS, pixelated, false)
+ }
+ _ => image,
+ });
+ }
+}
+
+/// The map that takes a point of an image placed by the first of `warps`' placements to where the
+/// warps and the moves between them take it, for an image now placed by `placed`: each warp in
+/// turn, then the move from the placement the image had when it took that warp to the one it had
+/// at the next, or to `placed` after the last. `None` when a placement has no inverse.
+fn warped_map(warps: &[(WarpMap, Affine)], placed: Affine) -> Option Point>> {
+ let mut steps: Vec<(WarpMap, Affine)> = Vec::with_capacity(warps.len());
+ for (i, (f, at)) in warps.iter().enumerate() {
+ let det = at.determinant();
+ if !det.is_finite() || det == 0.0 {
+ return None;
+ }
+ let next = warps.get(i + 1).map_or(placed, |(_, at)| *at);
+ let m = next * at.inverse();
+ if !m.is_finite() {
+ return None;
+ }
+ steps.push((f.clone(), m));
+ }
+ Some(Box::new(move |p| steps.iter().fold(p, |q, (f, m)| *m * f(q))))
+}
+
+/// How many paths [`for_each_leaf`] visits in `n`.
+fn leaf_count(n: &Node) -> usize {
+ match &n.kind {
+ NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => 1,
+ NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().map(|c| leaf_count(c)).sum(),
+ _ => 0,
+ }
+}
+
/// An object that a Transform effect places through its transform rather than reshapes: an image
/// (pixel space to document) or a symbol instance (the symbol's art to document).
fn is_placed(n: &Node) -> bool {
diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs
index adc518043e..e63b38a462 100644
--- a/crates/effects/src/tests.rs
+++ b/crates/effects/src/tests.rs
@@ -369,6 +369,317 @@ fn stacked_transform_copies_stay_within_the_image_budget() {
assert_eq!(count(&evaluate_container(None, &g).unwrap()), 1001);
}
+/// A 40 × 40 px image placed at (0, 0) at one point a pixel, with id `id`.
+fn warp_test_image(id: u64) -> std::sync::Arc {
+ use vectorcraft_doc::{ImageObject, NodeId};
+ let im = ImageObject { key: "k".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
+ std::sync::Arc::new(Node::new(NodeId(id), NodeKind::Image(Box::new(im))))
+}
+
+/// How many images `art` holds, and the keys they have.
+fn warp_test_images(art: &Node) -> (usize, std::collections::BTreeSet) {
+ let (mut n, mut keys) = (0, std::collections::BTreeSet::new());
+ art.walk(&mut |c| {
+ if let NodeKind::Image(im) = &c.kind {
+ n += 1;
+ keys.insert(im.key.clone());
+ }
+ });
+ (n, keys)
+}
+
+#[test]
+fn warped_images_stay_within_the_object_budget() {
+ use std::rc::Rc;
+ use std::sync::Arc;
+
+ use vectorcraft_doc::NodeId;
+ let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1));
+ let cost = vectorcraft_doc::live::mesh_warp_cost(crate::reshape::WARP_IMAGE_CELLS);
+ let none = std::collections::HashSet::new();
+ let k = || std::collections::BTreeSet::from(["k".to_string()]);
+ let warp = |art: &mut Node, warps: usize| {
+ let mut warped = crate::reshape::WarpedImages::new();
+ for _ in 0..warps {
+ crate::reshape::mark_warped_images(art, &lift, &none, false, &mut 0, &mut warped);
+ }
+ warped
+ };
+ // The art has room for one image's pieces only: neither image is cut, and each gets its own
+ // key back.
+ let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]);
+ let warped = warp(&mut art, 1);
+ let max = art.count() + cost;
+ crate::reshape::cut_warped_images_within(&mut art, &warped, max);
+ assert_eq!(warp_test_images(&art), (2, k()));
+ // With room for both, each is cut into 200 pieces once, however many warps it took.
+ let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]);
+ let warped = warp(&mut art, 3);
+ let max = art.count() + 2 * cost;
+ crate::reshape::cut_warped_images_within(&mut art, &warped, max);
+ assert_eq!(warp_test_images(&art), (400, k()));
+ // Images clipped by their frames: the second frame (the second path) kept its shape, so its
+ // image stays whole, and the first is cut. The third's frame is a group of two paths, the
+ // second of them kept, with an image after them, and its image sits in a group of its own:
+ // both stay whole too.
+ let frame = |id: u64| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Default::default()));
+ let clip_group = |id: u64, children: Vec>| Arc::new(Node::new(NodeId(id), NodeKind::Group { children, clip: true }));
+ let nested = clip_group(
+ 4,
+ vec![
+ Arc::new(Node::group(NodeId(4), vec![frame(4), frame(4), warp_test_image(5)])),
+ Arc::new(Node::group(NodeId(4), vec![warp_test_image(4)])),
+ ],
+ );
+ let mut art =
+ Node::group(NodeId(1), vec![clip_group(2, vec![frame(2), warp_test_image(2)]), clip_group(3, vec![frame(3), warp_test_image(3)]), nested]);
+ let (mut at, mut warped) = (0, crate::reshape::WarpedImages::new());
+ crate::reshape::mark_warped_images(&mut art, &lift, &std::collections::HashSet::from([1, 3]), false, &mut at, &mut warped);
+ assert_eq!(at, 4);
+ crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
+ assert_eq!(warp_test_images(&art), (203, k()));
+}
+
+/// An image key from the document that looks like a key of the table is not read as one, and a
+/// hidden image stays out of the bounds the later effects take, as it does before it is warped.
+#[test]
+fn warped_images_keep_document_keys_and_hidden_images_apart() {
+ use std::rc::Rc;
+ use std::sync::Arc;
+
+ use vectorcraft_doc::NodeId;
+ let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1));
+ let none = std::collections::HashSet::new();
+ let mut warped = crate::reshape::WarpedImages::new();
+ let lookalike = format!("{}0", warped.prefix());
+ let mut b = (*warp_test_image(3)).clone();
+ if let NodeKind::Image(im) = &mut b.kind {
+ im.key = lookalike.clone();
+ }
+ let mut art = Node::group(NodeId(1), vec![warp_test_image(2), Arc::new(b)]);
+ warped.reserve(&art);
+ crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
+ crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
+ assert_eq!(warp_test_images(&art), (400, std::collections::BTreeSet::from(["k".to_string(), lookalike])));
+ // A layer of a 10 × 10 square and a hidden 40 × 40 image: the warped image is not measured.
+ let square = Arc::new(Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()));
+ let mut hidden = (*warp_test_image(5)).clone();
+ hidden.visible = false;
+ let mut layer = Node::layer(NodeId(6), "Layer 1", vectorcraft_doc::LayerColor::Preset(0));
+ if let Some(children) = layer.children_mut() {
+ *children = vec![square, Arc::new(hidden)];
+ }
+ let before = layer.geometric_bounds();
+ let mut warped = crate::reshape::WarpedImages::new();
+ warped.reserve(&layer);
+ crate::reshape::mark_warped_images(&mut layer, &lift, &none, false, &mut 0, &mut warped);
+ assert_eq!(warped.bounds(&layer), before);
+ // A shown image is measured by the pieces it is cut into.
+ let mut art = Node::group(NodeId(1), vec![warp_test_image(2)]);
+ let mut warped = crate::reshape::WarpedImages::new();
+ warped.reserve(&art);
+ crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
+ let measured = warped.bounds(&art);
+ crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
+ assert_eq!(measured, art.geometric_bounds());
+ assert!(measured.is_some_and(|b| b.y0 < -3.9), "{measured:?}");
+ // An image placed at a tiny scale (a placement with a tiny determinant) is cut too; one placed
+ // with no area is not.
+ for (scale, pieces) in [(1e-7, 200), (0.0, 1)] {
+ let mut image = (*warp_test_image(2)).clone();
+ if let NodeKind::Image(im) = &mut image.kind {
+ im.xf = Affine::scale(scale);
+ }
+ let mut art = Node::group(NodeId(1), vec![Arc::new(image)]);
+ let mut warped = crate::reshape::WarpedImages::new();
+ crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
+ crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
+ assert_eq!(warp_test_images(&art).0, pieces, "{scale}");
+ }
+}
+
+/// An image whose frame keeps its shape because the warp would pass the anchor budget keeps its
+/// pixels as they are; within the budget, both bend.
+#[test]
+fn an_image_whose_frame_passes_the_budget_stays_whole() {
+ use std::sync::Arc;
+
+ use vectorcraft_doc::NodeId;
+ let clipped = || {
+ let mut frame = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Appearance::default());
+ if let NodeKind::Path { clipping, .. } = &mut frame.kind {
+ *clipping = true;
+ }
+ Node::new(NodeId(1), NodeKind::Group { children: vec![Arc::new(frame), warp_test_image(1)], clip: true })
+ };
+ let arc = fx("warp.arc", json!({"bend": 50, "version": 1}));
+ let k = || std::collections::BTreeSet::from(["k".to_string()]);
+ let mut art = clipped();
+ crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget { anchors: 0, lines: 0 }, None);
+ assert_eq!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).map(|p| p.anchor_count()), Some(4));
+ assert_eq!(warp_test_images(&art), (1, k()));
+ let mut art = clipped();
+ crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget::EFFECTS, None);
+ assert!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).is_some_and(|p| p.anchor_count() > 4));
+ assert_eq!(warp_test_images(&art), (200, k()));
+}
+
+/// The first image under `n` (the first piece of a cut image).
+fn first_image(n: &Node) -> Option {
+ let mut first = None;
+ n.walk(&mut |c| {
+ if first.is_none()
+ && let NodeKind::Image(im) = &c.kind
+ {
+ first = Some((**im).clone());
+ }
+ });
+ first
+}
+
+#[test]
+fn effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces() {
+ let image = |effects: Vec| {
+ let mut n = (*warp_test_image(1)).clone();
+ n.appearance.effects = effects;
+ n
+ };
+ // Each piece's clipping path, and the image's frame (the outer clip group's clipping path).
+ let clips = |art: &Node| {
+ let (mut pieces, mut frame) = (vec![], None);
+ art.walk(&mut |c| {
+ if let NodeKind::Group { children, clip: true } = &c.kind
+ && let Some(path) = children.first().and_then(|p| p.path_data())
+ {
+ if matches!(children.get(1).map(|i| &i.kind), Some(NodeKind::Image(_))) && path.anchor_count() == 3 {
+ pieces.push(path.anchor_count());
+ } else if frame.is_none() {
+ frame = Some(path.anchor_count());
+ }
+ }
+ });
+ (pieces, frame)
+ };
+ let arc = fx("warp.arc", json!({"bend": 50, "version": 1}));
+ for after in [
+ fx("distort.roughen", json!({"size": 5, "detail": 10})),
+ fx("stylize.roundCorners", json!({"radius": 5})),
+ fx("path.offsetPath", json!({"offset": -2})),
+ ] {
+ let art = crate::reshape::reshape(None, &image(vec![arc.clone(), after.clone()]), None).unwrap();
+ let (pieces, frame) = clips(&art);
+ assert_eq!(pieces.len(), 200, "{}", after.id);
+ assert!(frame.is_some_and(|n| n > 4), "{}: {frame:?}", after.id);
+ assert_eq!(warp_test_images(&art), (200, std::collections::BTreeSet::from(["k".to_string()])), "{}", after.id);
+ }
+ // Two warps cut the image once, through both maps; a Transform after them moves the pieces.
+ let flag = fx("warp.flag", json!({"bend": 30, "version": 1}));
+ let mv = fx("distort.transform", json!({"moveH": 100, "moveV": 0}));
+ let art = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone()]), None).unwrap();
+ assert_eq!(warp_test_images(&art).0, 200);
+ let moved = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone(), mv]), None).unwrap();
+ assert_eq!(warp_test_images(&moved).0, 200);
+ let box1 = Rect::new(0.0, 0.0, 40.0, 40.0);
+ let f1 = crate::warp::warp_map(box1, crate::warp::WarpStyle::Arc, &merged_params("warp.arc", &arc.params));
+ // The flag bends the box the arc leaves, which the arc's frame measures.
+ let box2 = crate::reshape::reshape(None, &image(vec![arc.clone()]), None).unwrap().geometric_bounds().unwrap();
+ let f2 = crate::warp::warp_map(box2, crate::warp::WarpStyle::Flag, &merged_params("warp.flag", &flag.params));
+ // The first piece's corners: the pixels (0, 0), (4, 0) and (4, 4).
+ for (art, by) in [(&art, 0.0), (&moved, 100.0)] {
+ let piece = first_image(art).unwrap();
+ for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] {
+ let want = f2(f1(p)) + vectorcraft_geom::Vec2::new(by, 0.0);
+ assert!((piece.xf * p).distance(want) < 1e-6, "{p:?}: {:?} {want:?}", piece.xf * p);
+ }
+ }
+}
+
+/// A warp with no version (VectorCraft's earlier map) bends an image's pixels with that map, as it
+/// bends the frame.
+#[test]
+fn an_images_pixels_take_the_map_their_warps_version_names() {
+ for version in [json!(null), json!(0), json!(1)] {
+ let mut params = json!({"bend": 50});
+ if !version.is_null() {
+ params["version"] = version.clone();
+ }
+ let mut n = (*warp_test_image(1)).clone();
+ n.appearance.effects = vec![fx("warp.arc", params.clone())];
+ let art = crate::reshape::reshape(None, &n, None).unwrap();
+ let f = crate::warp::warp_map(Rect::new(0.0, 0.0, 40.0, 40.0), crate::warp::WarpStyle::Arc, &merged_params("warp.arc", ¶ms));
+ let piece = first_image(&art).unwrap();
+ for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] {
+ assert!((piece.xf * p).distance(f(p)) < 1e-6, "{version}: {p:?}");
+ }
+ // The frame has an anchor where the map takes each corner.
+ let frame = art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).unwrap();
+ for corner in [Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 40.0), Point::new(0.0, 40.0)] {
+ let near = frame.subpaths.iter().flat_map(|s| &s.anchors).any(|a| a.p.distance(f(corner)) < 1e-6);
+ assert!(near, "{version}: {corner:?}");
+ }
+ }
+}
+
+/// Under a group's Warp effect, a nested group's own placed gradient follows the nested group's
+/// bounds to where its image's pieces draw them.
+#[test]
+fn a_nested_groups_gradient_follows_its_warped_image() {
+ use std::sync::Arc;
+
+ use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
+ use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeId};
+ let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
+ let geom = GradientGeom { start: (5.0, 20.0).into(), end: (35.0, 20.0).into(), aspect: 1.0, focal: None };
+ let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
+ let mut inner = Node::group(NodeId(5), vec![warp_test_image(1)]);
+ inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed)));
+ let square = Node::path(NodeId(2), shapes::rectangle(Rect::new(60.0, 0.0, 100.0, 40.0)), Appearance::default());
+ let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(square)]);
+ g.appearance.effects.push(fx("warp.arc", json!({"bend": 50, "version": 1})));
+ let art = crate::group::evaluate_container(None, &g).unwrap();
+ assert_eq!(warp_test_images(&art).0, 200);
+ let (from, mut to) = (Rect::new(0.0, 0.0, 40.0, 40.0), None);
+ art.walk(&mut |c| {
+ if c.id == NodeId(5) && to.is_none() {
+ to = c.geometric_bounds();
+ }
+ });
+ let to = to.unwrap();
+ assert!(to.y0 < from.y0 - 1.0, "the pieces draw the image bent: {to:?}");
+ let follow = |p: Point| Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height());
+ let mut checked = 0;
+ art.walk(&mut |c| {
+ if c.id != NodeId(5) {
+ return;
+ }
+ for item in &c.appearance.items {
+ let Paint::Gradient(gp) = item.paint() else { continue };
+ let got = gp.geom.unwrap();
+ assert!(got.start.distance(follow(geom.start)) < 1e-6 && got.end.distance(follow(geom.end)) < 1e-6, "{got:?} in {to:?}");
+ checked += 1;
+ }
+ });
+ assert!(checked > 0);
+}
+
+/// An image under a geometry effect other than a Warp draws as the image does: colorized by
+/// a grayscale image's fill color, and smoothed or not.
+#[test]
+fn an_image_under_a_geometry_effect_keeps_its_color_and_smoothing() {
+ let mut n = (*warp_test_image(1)).clone();
+ n.appearance.effects = vec![fx("distort.roughen", json!({"size": 5, "detail": 10}))];
+ n.extras_mut().colorize = Some(Box::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))));
+ n.extras_mut().pixelated = true;
+ let art = crate::reshape::reshape(None, &n, None).unwrap();
+ let mut seen = vec![];
+ art.walk(&mut |c| {
+ if matches!(c.kind, NodeKind::Image(_)) {
+ seen.push((c.extras().colorize.clone(), c.extras().pixelated));
+ }
+ });
+ assert_eq!(seen, vec![(n.extras().colorize.clone(), true)]);
+}
+
/// What `effects` make of `path` within `budget`.
fn within(effects: &[Effect], path: &PathData, budget: Budget) -> PathData {
apply_geometry_within(effects, path, path.bounds().unwrap(), &GeomContext::default(), budget)
diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs
index f607abacdf..4b117d4142 100644
--- a/crates/effects/src/warp.rs
+++ b/crates/effects/src/warp.rs
@@ -20,25 +20,30 @@ pub fn map_version(p: &Value) -> u32 {
/// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]).
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData {
+ let piece = b.width().hypot(b.height()) / 24.0;
+ map_nonlinear_within(path, piece, max, warp_map(b, style, p))
+}
+
+/// The point map a warp of `style` with params `p` applies to box `b`: what [`warp`] moves each
+/// point of a path to.
+pub(crate) fn warp_map(b: Rect, style: WarpStyle, p: &Value) -> Box Point> {
let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0;
let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0;
let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0;
let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical");
- let piece = b.width().hypot(b.height()) / 24.0;
if uses_net(map_version(p)) {
let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical);
let (w, h) = (b.width(), b.height());
// A box with no width or no height takes the middle of the patch along that side.
- let f = |q: Point| {
+ return Box::new(move |q: Point| {
let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 });
b.origin() + net.at(u, v).to_vec2()
- };
- return map_nonlinear_within(path, piece, max, f);
+ });
}
let c = b.center();
let hw = (b.width() / 2.0).max(1e-9);
let hh = (b.height() / 2.0).max(1e-9);
- let f = |q: Point| {
+ Box::new(move |q: Point| {
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
let (x2, y2) = if vertical {
let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x);
@@ -47,8 +52,7 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -
warp_point(style, bend, dh, dv, hh / hw, x, y)
};
Point::new(c.x + x2 * hw, c.y + y2 * hh)
- };
- map_nonlinear_within(path, piece, max, f)
+ })
}
/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index abf65d834a..de10dc4e17 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -205,6 +205,7 @@ mod typescale;
mod typeundo;
mod units;
mod variables;
+mod warp_images;
mod webexport;
mod widthpoints;
mod widthprofiles;
diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs
index 2dbef41238..435d918552 100644
--- a/crates/engine/src/tests/envelope_distort.rs
+++ b/crates/engine/src/tests/envelope_distort.rs
@@ -154,6 +154,35 @@ fn images_bend_as_a_raster_mesh_warp() {
assert_eq!(svg.matches(" 200 && mid[1] < 60 && mid[2] < 60, "the middle rose, red: {mid:?}");
+ let inside = img.pixel(120, 120);
+ assert!(inside[0] > 200 && inside[1] < 60 && inside[2] < 60, "red inside: {inside:?}");
+}
+
/// A selected embedded 2×2 image, 80×80 pt at (100, 100): a red column left of a blue one.
fn two_color_image(s: &mut Session) -> NodeId {
let img = image::RgbaImage::from_fn(2, 2, |x, _| if x == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) });
diff --git a/crates/engine/src/tests/warp_images.rs b/crates/engine/src/tests/warp_images.rs
new file mode 100644
index 0000000000..47175fc866
--- /dev/null
+++ b/crates/engine/src/tests/warp_images.rs
@@ -0,0 +1,293 @@
+//! Effect › Warp on placed images: the pixels bend with the frame, in the canvas and in exports.
+
+use serde_json::{Value, json};
+use vectorcraft_doc::live::{WarpNet, WarpStyle, uses_net, warp_version};
+use vectorcraft_doc::{Node, NodeKind};
+use vectorcraft_geom::{Affine, Point, Rect};
+
+use crate::*;
+
+fn session() -> Session {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
+ s
+}
+
+/// The box of the image [`quarters`] places.
+const FRAME: Rect = Rect::new(100.0, 100.0, 180.0, 180.0);
+
+/// The color of the quarter of the image [`quarters`] places that holds (u, v) of its box: red top
+/// left, green top right, blue bottom left, yellow bottom right.
+fn quarter(u: f64, v: f64) -> [u8; 3] {
+ match (u < 0.5, v < 0.5) {
+ (true, true) => [255, 0, 0],
+ (false, true) => [0, 255, 0],
+ (true, false) => [0, 0, 255],
+ (false, false) => [255, 255, 0],
+ }
+}
+
+/// A selected embedded 40×40 px image filling [`FRAME`], each quarter one color ([`quarter`]).
+fn quarters(s: &mut Session) -> NodeId {
+ let img = image::RgbaImage::from_fn(40, 40, |x, y| {
+ let [r, g, b] = quarter(x as f64 / 40.0, y as f64 / 40.0);
+ image::Rgba([r, g, b, 255])
+ });
+ let mut png = vec![];
+ img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
+ s.edit("Place", |d, sel| {
+ d.images.insert("quarters".into(), vectorcraft_doc::ImageBlob::png(png));
+ let id = d.alloc_id();
+ let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0);
+ let im = vectorcraft_doc::ImageObject {
+ key: "quarters".into(),
+ width: 40,
+ height: 40,
+ xf,
+ link: None,
+ placement: Default::default(),
+ pixels: None,
+ };
+ d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
+ sel.set([id]);
+ Ok(id)
+ })
+ .unwrap()
+}
+
+/// Where an Arc warp bending `bend` % takes the point at (u, v) of [`FRAME`].
+fn arced(bend: f64, u: f64, v: f64) -> Point {
+ let net = WarpNet::new(WarpStyle::Arc, bend / 100.0, 0.0, 0.0, FRAME.width(), FRAME.height(), false);
+ FRAME.origin() + net.at(u, v).to_vec2()
+}
+
+/// Points of the image's box: near each quarter's middle and on both sides of the line between
+/// the top and bottom quarters, where a warp that moves the pixels up or down changes the color.
+const SAMPLES: [(f64, f64); 10] =
+ [(0.2, 0.2), (0.8, 0.2), (0.2, 0.8), (0.8, 0.8), (0.4, 0.4), (0.6, 0.4), (0.4, 0.6), (0.6, 0.6), (0.3, 0.62), (0.7, 0.62)];
+
+/// The document drawn over (0, 0)–(400, 400) at one pixel a point, on white.
+fn drawn(s: &Session) -> vectorcraft_render::Rendered {
+ vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 400.0, 400.0), 1.0, true)
+}
+
+/// The sample points whose color in `img` at `at(u, v)` is not the image's color at (u, v).
+fn wrong(img: &vectorcraft_render::Rendered, at: &dyn Fn(f64, f64) -> Point) -> Vec<(f64, f64, [u8; 4])> {
+ SAMPLES
+ .iter()
+ .filter_map(|&(u, v)| {
+ let p = at(u, v);
+ let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32);
+ let want = quarter(u, v);
+ let near = (0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40);
+ (!near).then_some((u, v, px))
+ })
+ .collect()
+}
+
+/// `quarters` with Effect › Warp › Arc at 50 %: the warp the effect holds, checked to be the
+/// Bézier patch [`arced`] follows.
+fn arc_image(s: &mut Session) -> NodeId {
+ let id = quarters(s);
+ s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
+ let n = s.doc().unwrap().doc.node(id).unwrap().clone();
+ let e = &n.appearance.effects[0];
+ assert!(warp_version(&e.params["version"]).is_some_and(uses_net), "{}", e.params);
+ id
+}
+
+fn decoded(v: &Value) -> Vec {
+ vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
+}
+
+#[test]
+fn an_images_pixels_bend_with_effect_warp() {
+ let mut s = session();
+ let id = arc_image(&mut s);
+ // The arc lifts the middle of the image: the line between the quarters rises there.
+ assert!(arced(50.0, 0.5, 0.6).y < FRAME.y0 + 0.5 * FRAME.height(), "{:?}", arced(50.0, 0.5, 0.6));
+ let bent = drawn(&s);
+ assert_eq!(wrong(&bent, &|u, v| arced(50.0, u, v)), vec![], "each pixel is drawn where the warp takes it");
+ // Drawn as pieces of the one image, each placed by the warp.
+ let d = &s.doc().unwrap().doc;
+ let art = vectorcraft_render::effects::reshape(Some(d), d.node(id).unwrap(), None).unwrap();
+ let mut pieces = 0;
+ art.walk(&mut |c| pieces += matches!(c.kind, NodeKind::Image(_)) as usize);
+ assert!(pieces >= 100, "{pieces} pieces");
+ // Removing the warp draws the image as it was, in one undo step.
+ let steps = s.doc().unwrap().history.undo.len();
+ s.execute("effect.remove", &json!({"index": 0})).unwrap();
+ assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
+ let plain = |u: f64, v: f64| Point::new(FRAME.x0 + u * FRAME.width(), FRAME.y0 + v * FRAME.height());
+ assert_eq!(wrong(&drawn(&s), &plain), vec![]);
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![], "undo brings the bend back");
+}
+
+/// `out`, an export of [`arc_image`]'s document named `name`, opened again: it draws the image
+/// bent.
+fn opens_bent(name: &str, out: &Value) {
+ let mut back = session();
+ back.execute("document.open", &json!({"name": name, "dataBase64": out["dataBase64"]})).unwrap();
+ assert_eq!(wrong(&drawn(&back), &|u, v| arced(50.0, u, v)), vec![], "{name}");
+}
+
+#[test]
+fn a_warped_image_keeps_its_bend_in_svg() {
+ let mut s = session();
+ arc_image(&mut s);
+ let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
+ let text = String::from_utf8(decoded(&svg)).unwrap();
+ assert_eq!(text.matches("= 100, "{images} pieces");
+ assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]);
+ s.execute("edit.undo", &json!({})).unwrap();
+ let n = s.doc().unwrap().doc.node(id).unwrap();
+ assert!(matches!(n.kind, NodeKind::Image(_)) && n.appearance.effects.len() == 1);
+ assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]);
+}
+
+/// A selected embedded grayscale 40×40 px image filling [`FRAME`], black above white, colorized
+/// red by its fill color: red above, nothing below.
+fn red_on_clear(s: &mut Session) -> NodeId {
+ let img = image::GrayImage::from_fn(40, 40, |_, y| image::Luma([if y < 20 { 0 } else { 255 }]));
+ let mut png = vec![];
+ image::DynamicImage::ImageLuma8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
+ let id = s
+ .edit("Place", |d, sel| {
+ d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(png));
+ let id = d.alloc_id();
+ let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0);
+ let im = vectorcraft_doc::ImageObject {
+ key: "gray".into(),
+ width: 40,
+ height: 40,
+ xf,
+ link: None,
+ placement: Default::default(),
+ pixels: None,
+ };
+ d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
+ sel.set([id]);
+ Ok(id)
+ })
+ .unwrap();
+ s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap();
+ id
+}
+
+#[test]
+fn a_colorized_image_keeps_its_color_when_warped() {
+ let mut s = session();
+ red_on_clear(&mut s);
+ s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
+ let img = drawn(&s);
+ let (red, white) = ([255u8, 0, 0], [255u8, 255, 255]);
+ for (u, v, want) in [(0.5, 0.35, red), (0.2, 0.2, red), (0.5, 0.65, white), (0.8, 0.8, white)] {
+ let p = arced(50.0, u, v);
+ let px = img.pixel(p.x.floor() as u32, p.y.floor() as u32);
+ assert!((0..3).all(|i| (px[i] as i32 - want[i] as i32).abs() <= 40), "({u}, {v}) at {p:?}: {px:?}");
+ }
+}
+
+#[test]
+fn a_warp_on_a_group_bends_the_image_in_it() {
+ let mut s = session();
+ let im = quarters(&mut s);
+ // A group of the image and a line below it as wide as the image: the group's box is the image's.
+ let line = s.execute("shape.line", &json!({"x1": FRAME.x0, "y1": FRAME.y1, "x2": FRAME.x1, "y2": FRAME.y1})).unwrap();
+ let line = NodeId(line["id"].as_u64().unwrap());
+ s.execute("select.set", &json!({"ids": [im.0, line.0]})).unwrap();
+ s.execute("object.group", &json!({})).unwrap();
+ s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
+ assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]);
+}
diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs
index 3ab4003d7d..c5a98aebf1 100644
--- a/crates/svg/src/export/nodes.rs
+++ b/crates/svg/src/export/nodes.rs
@@ -121,6 +121,21 @@ impl Reuse {
}
}
+/// Are `children` the pieces of a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`]):
+/// more than one, each a clip group of a triangle and one and the same image?
+fn image_pieces(children: &[std::sync::Arc]) -> bool {
+ let mut first: Option<(&str, u32, u32)> = None;
+ children.len() > 1
+ && children.iter().all(|c| {
+ let NodeKind::Group { children, clip: true } = &c.kind else { return false };
+ let [clip, im] = children.as_slice() else { return false };
+ let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&clip.kind, &im.kind) else { return false };
+ let triangle = path.subpaths.len() == 1 && path.anchor_count() == 3;
+ let image = (im.key.as_str(), im.width, im.height);
+ triangle && *first.get_or_insert(image) == image
+ })
+}
+
/// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas
/// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement
/// and the art of unlinked opacity masks.
@@ -678,6 +693,10 @@ impl Writer<'_> {
match &n.kind {
NodeKind::Layer { template: true, .. } => {}
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
+ // The pieces a Warp effect cuts an image into share its pixels, as an envelope's do.
+ if image_pieces(children) {
+ self.share_images(n);
+ }
let id = self.id_attr(n);
let a = self.attrs(&self.group_props(n));
self.line(&format!(""));
diff --git a/docs/mcp.md b/docs/mcp.md
index 58a8006d03..9db2c0e1ff 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -3332,6 +3332,16 @@ that shape, and the effects before it are left out. Its Contents row stands for
vector exports write those items as paths, and `effect.expandAppearance` turns them into objects in a group with the
image.
Use Preview Bounds measures an image by its frame.
+A Warp effect (`warp.arc` … `warp.twist`) on an image, or on a group that holds one, bends the image's pixels with its
+frame: after the last effect in the list, the image is cut into 10 × 10 cells of two triangles, each a clipped piece of
+the image under its own affine map. The cell count is VectorCraft's own. Effects after the Warp reshape only the frame,
+a second Warp bends the same pieces, and a Transform effect moves them. A placed gradient on a group that holds the
+image follows the group's bounds as the pieces draw them. The pieces keep the
+image's fill color when it colorizes a grayscale image, and so do the pieces an envelope cuts. Under any geometry
+effect, an image keeps that color and is drawn with or without smoothing as the image is. SVG writes the image once and
+draws the pieces with ``; PDF, EPS, `.ai` and PNG write the bent pieces. Removing the warp shows the image as it
+was. The images stay unbent when their pieces would make the art pass 2 million objects, and an image stays unbent when
+its clipping frame keeps its shape because the warp would pass the effects' anchor budget.
```json
{"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}}
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index b7c8e51357..98f852cc6f 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -591,6 +591,7 @@ since 2026-10-01.
- **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim.
- **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
- **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged.
+- **Warp effects bend images:** a Warp effect on an image, or on a group that holds one, bends the image's pixels with its frame, as the Warp effect is documented to distort bitmap images in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html): after the last effect, the image is cut into 10 × 10 cells of two triangles, each a clipped piece under its own affine map. Effects after the Warp reshape only the frame, a second Warp bends the same pieces, and warps saved without a version bend the pixels with their earlier map, as they bend the frame. A placed gradient on a group that holds the image follows the group's bounds as the pieces draw them. SVG writes the image once for the pieces; PDF, EPS, `.ai` and PNG write the bent pieces. The images stay unbent when their pieces would make the art pass 2 million objects, and so does an image whose frame keeps its shape past the anchor budget. A grayscale image's fill color now colorizes the pieces of a warp or an envelope, and an image under any geometry effect keeps that color and its smoothing setting. Existing documents with a Warp effect on an image, or on a group that holds one, draw differently: before, the image showed unbent, cut by the bent frame. So do existing documents with a colorized image under an envelope or a geometry effect, or an image drawn without smoothing under a geometry effect: before, those lost the color or the setting. The cell count and the limits are VectorCraft's own (effects `tests::warped_images_stay_within_the_object_budget`, `tests::warped_images_keep_document_keys_and_hidden_images_apart`, `tests::an_image_whose_frame_passes_the_budget_stays_whole`, `tests::effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces`, `tests::an_images_pixels_take_the_map_their_warps_version_names`, `tests::an_image_under_a_geometry_effect_keeps_its_color_and_smoothing`, `tests::a_nested_groups_gradient_follows_its_warped_image`; engine `warp_images::an_images_pixels_bend_with_effect_warp`, `a_warped_image_keeps_its_bend_in_svg`, `a_warped_image_keeps_its_bend_in_pdf`, `a_warped_image_keeps_its_bend_in_ai`, `a_warped_image_keeps_its_bend_in_eps`, `a_warped_image_keeps_its_bend_in_png`, `svg_writes_an_image_clipped_twice_in_a_users_group_twice`, `expand_appearance_of_a_warped_image_draws_as_the_canvas_did`, `a_colorized_image_keeps_its_color_when_warped`, `a_warp_on_a_group_bends_the_image_in_it`, `envelope_distort::a_colorized_image_keeps_its_color_in_an_envelope`). Scores and hours unchanged.
- **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged.
- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
- **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.
From 98d6cedf2530ed31dca722052ac488fcdec9a0fd Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Thu, 12 Nov 2026 01:27:01 -0800
Subject: [PATCH 13/15] Tests: Direct Selection marquee and hover and Group
Selection pass through raster effects
Six tests cover the Direct Selection marquee (anchors and crossed segments), its pointer and anchor highlight, and the Group Selection click, repeated click, marquee (default and E for enclosed objects) and hover over a drop shadow: they reach the object under the shadow or nothing. The selection passes-through line in docs/target-app-parity.md names these gestures.
---
crates/engine/src/tests/select_effects.rs | 157 ++++++++++++++++++++++
docs/target-app-parity.md | 2 +-
2 files changed, 158 insertions(+), 1 deletion(-)
diff --git a/crates/engine/src/tests/select_effects.rs b/crates/engine/src/tests/select_effects.rs
index b30d0ef70e..d2160fba34 100644
--- a/crates/engine/src/tests/select_effects.rs
+++ b/crates/engine/src/tests/select_effects.rs
@@ -234,3 +234,160 @@ fn a_marquee_that_touches_only_a_drop_shadow_does_not_select_its_object() {
// From above B, over B and the shadow: B only.
assert_eq!(press(&mut s, "selection", (105.0, 5.0), (115.0, 30.0), Mods::default()), [b]);
}
+
+/// The selection after a drag with `tool` from `from` to `to`.
+fn marquee(s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64)) -> vectorcraft_doc::Selection {
+ press(s, tool, from, to, Mods::default());
+ s.doc().unwrap().selection.clone()
+}
+
+/// The point of anchor `(si, ai)` of path `id`.
+fn anchor_point(s: &Session, id: NodeId, (si, ai): vectorcraft_doc::AnchorRef) -> Point {
+ let path = s.doc().unwrap().doc.node(id).and_then(|n| n.path_data()).unwrap();
+ path.anchors().find(|(s, i, _)| (*s, *i) == (si, ai)).map(|(.., a)| a.p).unwrap()
+}
+
+/// The anchors a marquee or a click picked on `id`, as points.
+fn picked_points(s: &Session, id: NodeId) -> Vec {
+ let refs: Vec<_> = s.doc().unwrap().selection.partial(id).into_iter().flatten().copied().collect();
+ refs.into_iter().map(|r| anchor_point(s, id, r)).collect()
+}
+
+/// The anchors the tool marks as hovered.
+fn anchor_marks(s: &mut Session) -> Vec {
+ s.overlays(ViewInfo::default())
+ .into_iter()
+ .filter_map(|o| if let vectorcraft_tools::Overlay::Anchor { p, .. } = o { Some(p) } else { None })
+ .collect()
+}
+
+fn hover(s: &mut Session, x: f64, y: f64) -> Cursor {
+ let v = ViewInfo::default();
+ s.pointer(&PointerEvent::new(PointerKind::Move, x, y), v).unwrap();
+ s.cursor(Point::new(x, y), Mods::default(), v)
+}
+
+#[test]
+fn a_direct_selection_marquee_leaves_out_a_drop_shadow() {
+ let mut s = session();
+ let (a, b) = shadow_over_b(&mut s);
+ let (a_before, b_before) = (bounds(&s, a), bounds(&s, b));
+ assert_ne!(pixel(&s, 90, 50), WHITE);
+ run(&mut s, "select.none", json!({}));
+ // Over the shadow alone: no object, no anchor, no segment.
+ let sel = marquee(&mut s, "directSelection", (85.0, 40.0), (95.0, 60.0));
+ assert!(sel.objects.is_empty() && sel.anchors.is_empty() && sel.segments.is_empty(), "{sel:?}");
+ // Over the shadow and B's upper left corner: that anchor of B, and nothing of A.
+ let sel = marquee(&mut s, "directSelection", (90.0, 5.0), (105.0, 30.0));
+ assert_eq!(sel.objects, [b]);
+ assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]);
+ // Across B's top edge under the shadow, with neither of its anchors inside: that segment.
+ run(&mut s, "select.none", json!({}));
+ assert_ne!(pixel(&s, 112, 25), BLUE);
+ let sel = marquee(&mut s, "directSelection", (110.0, 5.0), (115.0, 30.0));
+ assert_eq!(sel.objects, [b]);
+ assert!(sel.segments.contains_key(&b) && !sel.segments.contains_key(&a), "{sel:?}");
+ assert_eq!((bounds(&s, a), bounds(&s, b)), (a_before, b_before));
+}
+
+#[test]
+fn direct_selection_hover_leaves_out_a_drop_shadow_and_marks_the_anchor_under_it() {
+ let mut s = session();
+ let (_, b) = shadow_over_b(&mut s);
+ // B's upper left corner lies under the shadow.
+ assert_ne!(pixel(&s, 101, 21), BLUE);
+ run(&mut s, "select.none", json!({}));
+ s.select_tool("directSelection", ViewInfo::default()).unwrap();
+ for (x, y) in [(90.0, 50.0), (110.0, 50.0)] {
+ assert_eq!(hover(&mut s, x, y), Cursor::ArrowHollow, "({x}, {y})");
+ assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})");
+ }
+ assert_eq!(hover(&mut s, 101.0, 21.0), Cursor::DirectAnchor);
+ assert_eq!(anchor_marks(&mut s), [Point::new(100.0, 20.0)]);
+ // A press there picks that anchor of B.
+ assert_eq!(press(&mut s, "directSelection", (101.0, 21.0), (101.0, 21.0), Mods::default()), [b]);
+ assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]);
+}
+
+#[test]
+fn a_group_selection_click_on_a_drop_shadow_selects_the_object_under_it() {
+ let mut s = session();
+ let (a, b) = shadow_over_b(&mut s);
+ run(&mut s, "select.none", json!({}));
+ assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]);
+ assert!(press(&mut s, "groupSelection", (90.0, 50.0), (90.0, 50.0), Mods::default()).is_empty());
+ assert_eq!(press(&mut s, "groupSelection", (50.0, 50.0), (50.0, 50.0), Mods::default()), [a]);
+}
+
+#[test]
+fn group_selection_clicks_on_a_drop_shadow_climb_the_groups_of_the_object_under_it() {
+ let mut s = session();
+ // B and C in one group, A and D in another above it, and A's shadow over B.
+ let b = rect(&mut s, 100.0, 20.0, 60.0, 60.0, "#0000ff");
+ let c = rect(&mut s, 170.0, 20.0, 20.0, 20.0, "#0000ff");
+ run(&mut s, "select.set", json!({"ids": [b.0, c.0]}));
+ let gb = id_of(&run(&mut s, "object.group", json!({})));
+ let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000");
+ let d = rect(&mut s, 20.0, 120.0, 20.0, 20.0, "#ff0000");
+ run(&mut s, "select.set", json!({"ids": [a.0, d.0]}));
+ let ga = id_of(&run(&mut s, "object.group", json!({})));
+ shadow(&mut s, a, 40.0);
+ assert_ne!(pixel(&s, 110, 50), BLUE);
+ assert_ne!(pixel(&s, 90, 50), WHITE);
+ run(&mut s, "select.none", json!({}));
+ let click = |s: &mut Session, x, y| {
+ let mut sel = press(s, "groupSelection", (x, y), (x, y), Mods::default());
+ sel.sort_by_key(|n| n.0);
+ sel
+ };
+ assert_eq!(click(&mut s, 110.0, 50.0), [b]);
+ let mut both = vec![b, gb];
+ both.sort_by_key(|n| n.0);
+ assert_eq!(click(&mut s, 110.0, 50.0), both);
+ // With A selected, a click on its shadow over the canvas does not add A's group.
+ run(&mut s, "select.set", json!({"ids": [a.0]}));
+ assert!(click(&mut s, 90.0, 50.0).is_empty());
+ // On A itself the second click adds it.
+ assert_eq!(click(&mut s, 50.0, 50.0), [a]);
+ let mut both = vec![a, ga];
+ both.sort_by_key(|n| n.0);
+ assert_eq!(click(&mut s, 50.0, 50.0), both);
+}
+
+#[test]
+fn a_group_selection_marquee_leaves_out_a_drop_shadow() {
+ let mut s = session();
+ let (a, b) = shadow_over_b(&mut s);
+ run(&mut s, "select.none", json!({}));
+ let sel = marquee(&mut s, "groupSelection", (85.0, 40.0), (95.0, 60.0));
+ assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}");
+ // Over the shadow and B's upper left corner: that anchor of B.
+ let sel = marquee(&mut s, "groupSelection", (90.0, 5.0), (105.0, 30.0));
+ assert_eq!(sel.objects, [b]);
+ assert_eq!(picked_points(&s, b), [Point::new(100.0, 20.0)]);
+ // With E, a marquee around B that crosses A's shadow selects B whole, and not A.
+ run(&mut s, "select.none", json!({}));
+ let v = ViewInfo::default();
+ s.select_tool("groupSelection", v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Down, 95.0, 10.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Drag, 130.0, 50.0), v).unwrap();
+ s.tool_key(vectorcraft_tools::ToolKey::Letter('e'), Mods::default(), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Drag, 165.0, 90.0), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Up, 165.0, 90.0), v).unwrap();
+ let sel = s.doc().unwrap().selection.clone();
+ assert_eq!(sel.objects, [b]);
+ assert!(sel.partial(b).is_none() && sel.partial(a).is_none(), "{sel:?}");
+}
+
+#[test]
+fn group_selection_hover_over_a_drop_shadow_marks_nothing() {
+ let mut s = session();
+ let (_, b) = shadow_over_b(&mut s);
+ run(&mut s, "select.none", json!({}));
+ s.select_tool("groupSelection", ViewInfo::default()).unwrap();
+ for (x, y) in [(90.0, 50.0), (110.0, 50.0), (101.0, 21.0)] {
+ assert_eq!(hover(&mut s, x, y), Cursor::GroupSelection, "({x}, {y})");
+ assert!(anchor_marks(&mut s).is_empty(), "({x}, {y})");
+ }
+ assert_eq!(press(&mut s, "groupSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()), [b]);
+}
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 351ade4468..db12554849 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -600,7 +600,7 @@ since 2026-10-01.
- **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`.
- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`.
- **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`.
-- **Clicks pass through raster effects:** clicks with the Selection and Direct Selection tools, and the Selection tool's pointer and marquee, leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`.
+- **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`.
- **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`.
- **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`.
- **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`.
From 842f1f1b68e5411e35c51c8089ce146125d9f0f9 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Thu, 12 Nov 2026 05:01:14 -0800
Subject: [PATCH 14/15] EPS import: CIDFonts only named, over a predefined CJK
CMap, set their characters as live type
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Type in a font found by name with a predefined non-Unicode CMap, such as
`/Ryumin-Light-83pv-RKSJ-H findfont` or a `composefont` with `GBK-EUC-H`,
opened as Latin-1 letters (`ú{A` for 日本A) in a font whose style carried
the CMap name (`Light-83pv-RKSJ-H`). Only Unicode CMaps were split off the
font name, and `composefont` failed with `undefinedresource` for the others.
- The predefined CMap decoder used by legacy `.ai` type moves to
`import/predefined_cmap.rs`, and EPS import decodes these CMaps' codes
with it (Shift-JIS, EUC, GBK, Big Five, UHC and the other public CMaps).
- A font name splits at a predefined CMap unless an installed face has
the whole name. A CMap that can't be read gives a warning.
- Type over a vertical CMap (`-V`) is set as vertical type, and each code
moves the point one em down the column, the default `DW2`
displacement.
- Every show operator sets decoded type with the same spacing and shaping
as other fonts found by name.
- Decoding a string counts against the import's memory budget.
---
crates/engine/tests/import_fuzz.rs | 5 +-
crates/eps/src/import.rs | 1 +
crates/eps/src/import/ai/legacy_text.rs | 278 +-----------------
crates/eps/src/import/cid.rs | 32 +-
crates/eps/src/import/interp.rs | 4 +
crates/eps/src/import/predefined_cmap.rs | 358 +++++++++++++++++++++++
crates/eps/src/import/tests_cid.rs | 165 +++++++++++
crates/eps/src/import/text.rs | 261 ++++++++++++-----
8 files changed, 729 insertions(+), 375 deletions(-)
create mode 100644 crates/eps/src/import/predefined_cmap.rs
diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs
index 67bb04c33b..ac98db8e9c 100644
--- a/crates/engine/tests/import_fuzz.rs
+++ b/crates/engine/tests/import_fuzz.rs
@@ -2682,7 +2682,10 @@ fn arb_ps_token() -> impl Strategy {
// Local VM restored: dictionary changes undone, saves restored twice.
"/a 1 def save /a 2 def", "restore", "save dup restore", "1 dict save exch /k 1 put", "cachestatus", "/n 0 def save /n n 1 add store",
// Executable strings and keys other than names (PLRM 3rd ed., `cvx`, `load`).
- "(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit"
+ "(>>) cvx", "(1 2 add) cvx exec", "(x) cvx dup exec", "0 load", "1 { } def", "(mark) cvx cvlit",
+ // CID-keyed fonts found by name over predefined CMaps, horizontal and vertical.
+ "/Ryumin-Light-90ms-RKSJ-H", "/Ryumin-Light-EUC-V", "/Ryumin-Light-H", "/GBT-EUC-H", "/KSCms-UHC-V", "/UniJIS-UTF16-V",
+ "[/Ryumin-Light]", "composefont", "<93fa967b8540fd0d41>", ""
])
.prop_map(str::to_string),
]
diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs
index 0a5799ddf9..dc335bf1a1 100644
--- a/crates/eps/src/import.rs
+++ b/crates/eps/src/import.rs
@@ -42,6 +42,7 @@ mod interp;
mod lex;
mod native;
mod obj;
+mod predefined_cmap;
mod preview;
mod shading;
mod text;
diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs
index a1b24662ec..6cdbb96747 100644
--- a/crates/eps/src/import/ai/legacy_text.rs
+++ b/crates/eps/src/import/ai/legacy_text.rs
@@ -14,12 +14,12 @@
use std::sync::Arc;
-use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded};
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Burasagari, CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun};
use vectorcraft_geom::AnchorKind;
+use super::super::super::predefined_cmap::{self, CMapCodes, composite_font};
use super::super::obj::V;
use super::art::path_data;
use super::{FLIP, GState, HashSet, Kind, Node, NodeId, Reader};
@@ -284,259 +284,15 @@ impl TypeState {
/// How strings in a font with predefined CMap `cmap` decode; `None` when the CMap can't be read.
fn cmap_codes(&mut self, cmap: &str) -> Option> {
- if let Some(form) = unicode_cmap(cmap) {
- return Some(Arc::new(CMapCodes::Unicode(form)));
- }
if let Some((_, codes)) = self.cmaps.iter().find(|(n, _)| n == cmap) {
return codes.clone();
}
- let codes = cid_codes(cmap).map(Arc::new);
+ let codes = predefined_cmap::codes(cmap).map(Arc::new);
self.cmaps.push((cmap.to_string(), codes.clone()));
codes
}
}
-/// How a composite font's strings decode.
-#[derive(Debug)]
-enum CMapCodes {
- /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap;
- /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's
- /// code space ([`code_space`]).
- Cids { codes: Box, unicode: Box, high_bit: bool, space: Option },
- /// A predefined Unicode CMap: the codes are the characters.
- Unicode(Unicode),
-}
-
-/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the
-/// code's length in bytes.
-type CodeSpace = &'static [(&'static [u8], &'static [u8])];
-
-/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab.
-fn control(b: u8) -> Option {
- match b {
- b'\r' => Some('\n'),
- b'\t' => Some('\t'),
- _ => None,
- }
-}
-
-impl CMapCodes {
- /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a
- /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a
- /// UTF-16 or UTF-32 string).
- fn decode(&self, bytes: &[u8]) -> String {
- let (codes, unicode, high_bit, space) = match self {
- Self::Unicode(form) => {
- let unit = match form {
- Unicode::Utf16 => 2,
- Unicode::Utf8 => 1,
- Unicode::Utf32 => 4,
- };
- let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[]));
- let mut text = unicode_text(whole, *form).replace('\r', "\n");
- text.extend(rest.last().copied().and_then(control));
- return text;
- }
- Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space),
- };
- let euc: Vec;
- let bytes = if high_bit {
- euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect();
- euc.as_slice()
- } else {
- bytes
- };
- let mut out = String::new();
- let mut at = 0;
- while let Some(&first) = bytes.get(at) {
- // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every
- // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space
- // has no one-byte codes.
- if let Some(control) = control(first) {
- out.push(control);
- at += 1;
- continue;
- }
- let rest = bytes.get(at..).unwrap_or_default();
- let code = |len: u8| rest.get(..usize::from(len)).map(|b| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b)));
- let found = match space {
- // The code is as long as the code space range it falls in; a byte that starts no
- // code in the code space is skipped.
- Some(space) => {
- let Some(len) = code_len(space, rest) else {
- at += 1;
- continue;
- };
- code(len).map(|c| (len, codes.lookup_cid_code(c, len)))
- }
- None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))),
- };
- let Some((len, cid)) = found else {
- at += 1;
- continue;
- };
- at += usize::from(len);
- // A code the CMap leaves out is dropped.
- let Some(cid) = cid else { continue };
- match unicode.lookup_bf_string(cid) {
- // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in
- // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first.
- Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => out.push(char::from(first)),
- Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => out.push(c),
- Some(BfString::String(s)) => out.extend(s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)),
- _ => {}
- }
- }
- out
- }
-}
-
-/// How a predefined Unicode CMap's codes encode their characters.
-#[derive(Clone, Copy, PartialEq, Debug)]
-enum Unicode {
- /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first.
- Utf16,
- Utf8,
- /// Four bytes, the high byte first.
- Utf32,
-}
-
-/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`,
-/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V`
-/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: ).
-fn unicode_cmap(name: &str) -> Option {
- let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?;
- let rest = rest.strip_suffix("-HW").unwrap_or(rest);
- let (collection, form) = rest.rsplit_once('-')?;
- let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"];
- if !collections.contains(&collection) {
- return None;
- }
- match form {
- "UCS2" | "UTF16" => Some(Unicode::Utf16),
- "UTF8" => Some(Unicode::Utf8),
- "UTF32" => Some(Unicode::Utf32),
- _ => None,
- }
-}
-
-/// The characters string `s` encodes in Unicode form `form` (invalid codes left out).
-fn unicode_text(s: &[u8], form: Unicode) -> String {
- match form {
- Unicode::Utf16 => {
- let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b));
- char::decode_utf16(units).filter_map(Result::ok).collect()
- }
- Unicode::Utf8 => s.utf8_chunks().map(|chunk| chunk.valid()).collect(),
- Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(),
- }
-}
-
-/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose
-/// codes are CIDs of no known character collection.
-fn predefined(name: &str) -> Option> {
- match CMapName::from_bytes(name.as_bytes()) {
- CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None,
- name => Some(name),
- }
-}
-
-/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V`
-/// (): the nearest carried CMap with the same
-/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC
-/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space
-/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads.
-fn stand_in(stem: &str) -> Option> {
- Some(match stem {
- "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)),
- "78-EUC" => Some(("EUC-H", false)),
- "78" | "Add" | "Ext" | "NWP" => Some(("H", false)),
- "GB" => Some(("GB-EUC-H", true)),
- "CNS1" => Some(("CNS-EUC-H", true)),
- "KSC" => Some(("KSC-EUC-H", true)),
- "B5" => Some(("ETen-B5-H", false)),
- "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)),
- "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None,
- _ => return None,
- })
-}
-
-/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A
-/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two
-/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with
-/// its [`stand_in`].
-fn cid_codes(cmap: &str) -> Option {
- let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded);
- let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H"));
- let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"));
- let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| {
- let (name, high_bit) = stand_in(stem?).flatten()?;
- Some((read(name)?, high_bit))
- })?;
- let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?;
- let unicode = CMap::parse(load_embedded(table)?, load_embedded)?;
- // A row-and-cell code read in its EUC form falls in the EUC CMap's code space.
- let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) };
- Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space })
-}
-
-/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it
-/// (); a CMap's vertical form has its
-/// horizontal form's. `None` for a CMap not listed here.
-fn code_space(cmap: &str) -> Option {
- const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")];
- const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")];
- const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")];
- const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")];
- const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")];
- const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")];
- const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")];
- const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")];
- const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")];
- const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")];
- const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")];
- const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")];
- const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")];
- const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")];
- const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")];
- const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")];
- let stem = match cmap {
- "H" | "V" => "",
- _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?,
- };
- let stem = stem.strip_suffix("-HW").unwrap_or(stem);
- Some(match stem {
- "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC,
- _ if stem.ends_with("RKSJ") => RKSJ,
- "EUC" | "78-EUC" => EUC_JP,
- "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL,
- "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC,
- "GBpc-EUC" | "GBTpc-EUC" => GB_PC,
- "GBK-EUC" | "GBKp-EUC" => GBK,
- "GBK2K" => GBK2K,
- "B5pc" => B5_PC,
- "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5,
- "HKscs-B5" | "ETHK-B5" => HKSCS,
- "HKdlb-B5" => HKDLB,
- "HKgccs-B5" => HKGCCS,
- "CNS-EUC" => CNS_EUC,
- "KSCpc-EUC" => KSC_PC,
- "KSCms-UHC" => UHC,
- _ => return None,
- })
-}
-
-/// The length of the code string `bytes` starts with: the length of the code space range whose
-/// lowest and highest codes hold each of its bytes between them. `None` when no range does.
-fn code_len(space: CodeSpace, bytes: &[u8]) -> Option {
- space
- .iter()
- .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b))))
- .map(|(lo, _)| lo.len())
- .min()
- .and_then(|len| u8::try_from(len).ok())
-}
-
/// Encoding pairs (§3.3.1): codes, each followed by the glyph names from it on, into `table`.
fn encoding_pairs(table: &mut [Option], vals: &[V]) {
let mut code: Option = None;
@@ -556,36 +312,6 @@ fn encoding_pairs(table: &mut [Option], vals: &[V]) {
}
}
-/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a
-/// predefined Unicode CMap ([`unicode_cmap`]), one of the predefined CMaps VectorCraft carries
-/// ([`predefined`]) or one of the others ([`stand_in`]), in its horizontal (`-H`) or vertical
-/// (`-V`) form. A name ending in an Identity CMap is not read as one, nor is one that ends in an
-/// unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise end in `H`.
-fn composite_font(name: &str) -> Option<(&str, &str)> {
- if !(name.ends_with("-H") || name.ends_with("-V")) {
- return None;
- }
- // The longest CMap name first: `83pv-RKSJ-H` before `H`.
- // No predefined CMap name is longer than 32 bytes.
- for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) {
- let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue };
- if matches!(cmap, "Identity-H" | "Identity-V") {
- return None;
- }
- let encoding_part =
- base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part));
- if matches!(cmap, "H" | "V") && encoding_part {
- return None;
- }
- let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some());
- let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some());
- if unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() {
- return Some((base, cmap));
- }
- }
- None
-}
-
/// An operator's `N` operands, the last `N` values before it, when each is a finite number.
fn nums(vals: &[V]) -> Option<[f64; N]> {
let operands = vals.get(vals.len().checked_sub(N)?..)?;
diff --git a/crates/eps/src/import/cid.rs b/crates/eps/src/import/cid.rs
index e2ad169f79..a54c11ae2b 100644
--- a/crates/eps/src/import/cid.rs
+++ b/crates/eps/src/import/cid.rs
@@ -18,6 +18,7 @@ use vectorcraft_geom::BezPath;
use super::interp::{Interp, matrix_obj};
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
+use super::predefined_cmap::Unicode;
/// Most codespace ranges, character and notdef mappings a CMap may hold.
const MAX_RANGES: usize = 1 << 16;
@@ -210,16 +211,6 @@ fn number(data: &[u8], at: usize, n: usize) -> Option {
(n <= 8).then(|| bytes.iter().fold(0u64, |v, b| v << 8 | u64::from(*b)))
}
-/// How a predefined Unicode CMap's codes encode their characters.
-#[derive(Clone, Copy, PartialEq, Debug)]
-pub(super) enum Unicode {
- /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first.
- Utf16,
- Utf8,
- /// Four bytes, the high byte first.
- Utf32,
-}
-
/// The encoding of predefined Unicode CMap `name` and whether it is vertical: one of Adobe's
/// public CMap resources whose name says its codes are Unicode: `Uni` and a character
/// collection, then `-UCS2`, `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms),
@@ -246,18 +237,6 @@ pub(super) fn unicode_cmap(name: &str) -> Option<(Unicode, bool)> {
Some((form, vertical))
}
-/// The characters show string `s` encodes in Unicode form `form` (invalid codes left out).
-pub(super) fn unicode_text(s: &[u8], form: Unicode) -> String {
- match form {
- Unicode::Utf16 => {
- let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b));
- char::decode_utf16(units).filter_map(Result::ok).collect()
- }
- Unicode::Utf8 => String::from_utf8_lossy(s).chars().filter(|c| *c != char::REPLACEMENT_CHARACTER).collect(),
- Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(),
- }
-}
-
/// A predefined Identity CMap: two-byte codes, each the CID of its own value; vertical for
/// `Identity-V`.
pub(super) fn identity(name: &str, vertical: bool) -> Obj {
@@ -417,8 +396,13 @@ impl Interp<'_> {
return Ok(d.clone());
}
let text = name.text().map(|t| t.to_string()).unwrap_or_default();
- match unicode_cmap(&text) {
- Some((_, vertical)) => {
+ // Another predefined CMap the file doesn't define, without its mappings: its codes are
+ // read by its name when type is set (see `text`).
+ let predefined = unicode_cmap(&text)
+ .map(|(_, vertical)| vertical)
+ .or_else(|| super::predefined_cmap::known(&text).then(|| super::predefined_cmap::vertical(&text)));
+ match predefined {
+ Some(vertical) => {
let mut d = Dict::new();
d.insert(Key::name("CMapName"), Obj::name(&text));
d.insert(Key::name("CMapType"), Obj::Int(1));
diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs
index 992a8bbcea..58708bb97e 100644
--- a/crates/eps/src/import/interp.rs
+++ b/crates/eps/src/import/interp.rs
@@ -92,6 +92,9 @@ pub(crate) struct Interp<'a> {
pub cid_subrs: super::cid::CidSubrs,
/// The font number `usefont` gave the CMap mappings that follow.
pub cmap_font: i64,
+ /// The predefined CMaps type was set in so far, by name, with how their strings decode
+ /// (`None` when one can't be read; see `text`).
+ pub predefined_cmaps: Vec<(Rc, Option>)>,
pub g: GState,
pub saved: Vec,
pub out: Out,
@@ -232,6 +235,7 @@ impl<'a> Interp<'a> {
cff: vec![],
cid_subrs: vec![],
cmap_font: 0,
+ predefined_cmaps: vec![],
type42_counted: HashSet::new(),
g,
saved: vec![],
diff --git a/crates/eps/src/import/predefined_cmap.rs b/crates/eps/src/import/predefined_cmap.rs
new file mode 100644
index 0000000000..1a9b3b95be
--- /dev/null
+++ b/crates/eps/src/import/predefined_cmap.rs
@@ -0,0 +1,358 @@
+//! Strings in fonts over Adobe's predefined CMaps ()
+//! read as characters: the codes of a predefined Unicode CMap are the characters; those of the
+//! others map to CIDs of a character collection, whose CID → Unicode CMap gives the characters.
+//! Used by legacy `.ai` type in composite fonts and by type in CID-keyed fonts found by name.
+
+use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded};
+
+/// How a composite font's strings decode.
+#[derive(Debug)]
+pub(super) enum CMapCodes {
+ /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap;
+ /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's
+ /// code space ([`code_space`]).
+ Cids { codes: Box, unicode: Box, high_bit: bool, space: Option },
+ /// A predefined Unicode CMap: the codes are the characters.
+ Unicode(Unicode),
+}
+
+/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the
+/// code's length in bytes.
+type CodeSpace = &'static [(&'static [u8], &'static [u8])];
+
+/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab.
+fn control(b: u8) -> Option {
+ match b {
+ b'\r' => Some('\n'),
+ b'\t' => Some('\t'),
+ _ => None,
+ }
+}
+
+impl CMapCodes {
+ /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a
+ /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a
+ /// UTF-16 or UTF-32 string).
+ pub(super) fn decode(&self, bytes: &[u8]) -> String {
+ let mut text = String::new();
+ self.each_code(bytes, |_, chars| text.extend(chars));
+ text
+ }
+
+ /// The codes of string `bytes`, each with its value and the characters it stands for (as
+ /// [`Self::decode`] reads them; none for a code the CMaps don't map, or for a byte that starts
+ /// no code, which counts as a code of its own).
+ pub(super) fn codes(&self, bytes: &[u8]) -> Decoded {
+ let mut out = Decoded::default();
+ self.each_code(bytes, |code, chars| out.push(code, chars));
+ out
+ }
+
+ /// Calls `emit` with each code of string `bytes` in turn ([`Self::codes`]): its value and the
+ /// characters it stands for.
+ fn each_code(&self, bytes: &[u8], mut emit: impl FnMut(u32, &mut dyn Iterator- )) {
+ let value = |b: &[u8]| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b));
+ let (codes, unicode, high_bit, space) = match self {
+ Self::Unicode(form) => {
+ let unit = match form {
+ Unicode::Utf16 => 2,
+ Unicode::Utf8 => 1,
+ Unicode::Utf32 => 4,
+ };
+ let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[]));
+ unicode_codes(whole, *form, |code, c| emit(value(code), &mut c.map(|c| if c == '\r' { '\n' } else { c }).into_iter()));
+ if let Some(&last) = rest.last() {
+ emit(value(rest), &mut control(last).into_iter());
+ }
+ return;
+ }
+ Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space),
+ };
+ // A code's value is that of the string's bytes, also where they're read in their EUC form.
+ let original = bytes;
+ let value_at = |at: usize, len: usize| original.get(at..at + len).map_or(0, value);
+ let euc: Vec
;
+ let bytes = if high_bit {
+ euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect();
+ euc.as_slice()
+ } else {
+ bytes
+ };
+ let mut at = 0;
+ while let Some(&first) = bytes.get(at) {
+ // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every
+ // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space
+ // has no one-byte codes.
+ if let Some(control) = control(first) {
+ emit(value_at(at, 1), &mut Some(control).into_iter());
+ at += 1;
+ continue;
+ }
+ let rest = bytes.get(at..).unwrap_or_default();
+ let code = |len: u8| rest.get(..usize::from(len)).map(value);
+ let found = match space {
+ // The code is as long as the code space range it falls in; a byte that starts no
+ // code in the code space is skipped.
+ Some(space) => code_len(space, rest).and_then(|len| code(len).map(|c| (len, codes.lookup_cid_code(c, len)))),
+ None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))),
+ };
+ let Some((len, cid)) = found else {
+ emit(value_at(at, 1), &mut None.into_iter());
+ at += 1;
+ continue;
+ };
+ let value = value_at(at, usize::from(len));
+ at += usize::from(len);
+ // A code the CMap leaves out stands for no character.
+ match cid.and_then(|cid| unicode.lookup_bf_string(cid)) {
+ // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in
+ // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first.
+ Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => {
+ emit(value, &mut Some(char::from(first)).into_iter())
+ }
+ Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => emit(value, &mut Some(c).into_iter()),
+ Some(BfString::String(s)) => emit(value, &mut s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)),
+ _ => emit(value, &mut None.into_iter()),
+ }
+ }
+ }
+}
+
+/// A string's characters, and its codes: each code's value and how many of the characters it
+/// stands for, in order.
+#[derive(Debug, Default)]
+pub(super) struct Decoded {
+ pub text: String,
+ pub codes: Vec<(u32, usize)>,
+}
+
+impl Decoded {
+ /// Code `code`, standing for characters `chars`.
+ pub(super) fn push(&mut self, code: u32, chars: impl IntoIterator- ) {
+ let before = self.text.len();
+ self.text.extend(chars);
+ let n = self.text.get(before..).map_or(0, |added| added.chars().count());
+ self.codes.push((code, n));
+ }
+}
+
+/// How strings in a font over predefined CMap `cmap` decode; `None` when the CMap can't be read.
+pub(super) fn codes(cmap: &str) -> Option
{
+ match unicode_cmap(cmap) {
+ Some(form) => Some(CMapCodes::Unicode(form)),
+ None => cid_codes(cmap),
+ }
+}
+
+/// How a predefined Unicode CMap's codes encode their characters.
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub(super) enum Unicode {
+ /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first.
+ Utf16,
+ Utf8,
+ /// Four bytes, the high byte first.
+ Utf32,
+}
+
+/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`,
+/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V`
+/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: ).
+fn unicode_cmap(name: &str) -> Option {
+ let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?;
+ let rest = rest.strip_suffix("-HW").unwrap_or(rest);
+ let (collection, form) = rest.rsplit_once('-')?;
+ let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"];
+ if !collections.contains(&collection) {
+ return None;
+ }
+ match form {
+ "UCS2" | "UTF16" => Some(Unicode::Utf16),
+ "UTF8" => Some(Unicode::Utf8),
+ "UTF32" => Some(Unicode::Utf32),
+ _ => None,
+ }
+}
+
+/// Calls `out` with each code of string `s` in Unicode form `form` in turn, and the character it
+/// encodes (none for an invalid code: a lone surrogate, a byte that starts no UTF-8 character, a
+/// value past U+10FFFF).
+fn unicode_codes(s: &[u8], form: Unicode, mut out: impl FnMut(&[u8], Option)) {
+ match form {
+ Unicode::Utf16 => {
+ let unit = |i: usize| s.get(i..i + 2).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(u16::from_be_bytes);
+ let mut at = 0;
+ while let Some(u) = unit(at) {
+ let next = unit(at + 2);
+ let pair = next.filter(|n| (0xd800..0xdc00).contains(&u) && (0xdc00..0xe000).contains(n));
+ let len = if pair.is_some() { 4 } else { 2 };
+ let c = char::decode_utf16([u].into_iter().chain(pair)).next().and_then(Result::ok);
+ out(s.get(at..at + len).unwrap_or_default(), c);
+ at += len;
+ }
+ }
+ Unicode::Utf8 => {
+ let mut at = 0;
+ for chunk in s.utf8_chunks() {
+ for c in chunk.valid().chars() {
+ out(s.get(at..at + c.len_utf8()).unwrap_or_default(), Some(c));
+ at += c.len_utf8();
+ }
+ for _ in chunk.invalid() {
+ out(s.get(at..at + 1).unwrap_or_default(), None);
+ at += 1;
+ }
+ }
+ }
+ Unicode::Utf32 => {
+ for b in s.as_chunks::<4>().0 {
+ out(b.as_slice(), char::from_u32(u32::from_be_bytes(*b)));
+ }
+ }
+ }
+}
+
+/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose
+/// codes are CIDs of no known character collection.
+fn predefined(name: &str) -> Option> {
+ match CMapName::from_bytes(name.as_bytes()) {
+ CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None,
+ name => Some(name),
+ }
+}
+
+/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V`
+/// (): the nearest carried CMap with the same
+/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC
+/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space
+/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads.
+fn stand_in(stem: &str) -> Option> {
+ Some(match stem {
+ "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)),
+ "78-EUC" => Some(("EUC-H", false)),
+ "78" | "Add" | "Ext" | "NWP" => Some(("H", false)),
+ "GB" => Some(("GB-EUC-H", true)),
+ "CNS1" => Some(("CNS-EUC-H", true)),
+ "KSC" => Some(("KSC-EUC-H", true)),
+ "B5" => Some(("ETen-B5-H", false)),
+ "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)),
+ "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None,
+ _ => return None,
+ })
+}
+
+/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A
+/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two
+/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with
+/// its [`stand_in`].
+fn cid_codes(cmap: &str) -> Option {
+ let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded);
+ let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H"));
+ let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"));
+ let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| {
+ let (name, high_bit) = stand_in(stem?).flatten()?;
+ Some((read(name)?, high_bit))
+ })?;
+ let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?;
+ let unicode = CMap::parse(load_embedded(table)?, load_embedded)?;
+ // A row-and-cell code read in its EUC form falls in the EUC CMap's code space.
+ let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) };
+ Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space })
+}
+
+/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it
+/// (); a CMap's vertical form has its
+/// horizontal form's. `None` for a CMap not listed here.
+fn code_space(cmap: &str) -> Option {
+ const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")];
+ const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")];
+ const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")];
+ const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")];
+ const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")];
+ const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")];
+ const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")];
+ const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")];
+ const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")];
+ const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")];
+ const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")];
+ const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")];
+ const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")];
+ const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")];
+ const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")];
+ const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")];
+ let stem = match cmap {
+ "H" | "V" => "",
+ _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?,
+ };
+ let stem = stem.strip_suffix("-HW").unwrap_or(stem);
+ Some(match stem {
+ "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC,
+ _ if stem.ends_with("RKSJ") => RKSJ,
+ "EUC" | "78-EUC" => EUC_JP,
+ "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL,
+ "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC,
+ "GBpc-EUC" | "GBTpc-EUC" => GB_PC,
+ "GBK-EUC" | "GBKp-EUC" => GBK,
+ "GBK2K" => GBK2K,
+ "B5pc" => B5_PC,
+ "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5,
+ "HKscs-B5" | "ETHK-B5" => HKSCS,
+ "HKdlb-B5" => HKDLB,
+ "HKgccs-B5" => HKGCCS,
+ "CNS-EUC" => CNS_EUC,
+ "KSCpc-EUC" => KSC_PC,
+ "KSCms-UHC" => UHC,
+ _ => return None,
+ })
+}
+
+/// The length of the code string `bytes` starts with: the length of the code space range whose
+/// lowest and highest codes hold each of its bytes between them. `None` when no range does.
+fn code_len(space: CodeSpace, bytes: &[u8]) -> Option {
+ space
+ .iter()
+ .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b))))
+ .map(|(lo, _)| lo.len())
+ .min()
+ .and_then(|len| u8::try_from(len).ok())
+}
+
+/// Whether `cmap` names a predefined CMap: a predefined Unicode CMap ([`unicode_cmap`]), one of the
+/// predefined CMaps VectorCraft carries ([`predefined`]) or one of the others ([`stand_in`]), in
+/// its horizontal (`-H`) or vertical (`-V`) form. The Identity CMaps are not among them.
+pub(super) fn known(cmap: &str) -> bool {
+ let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some());
+ let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some());
+ unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other()
+}
+
+/// Whether predefined CMap `cmap` is a vertical CMap (`-V`, or the row-and-cell `V`).
+pub(super) fn vertical(cmap: &str) -> bool {
+ cmap == "V" || cmap.ends_with("-V")
+}
+
+/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a
+/// predefined CMap ([`known`]). A name ending in an Identity CMap is not read as one, nor is one
+/// that ends in an unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise
+/// end in `H`.
+pub(super) fn composite_font(name: &str) -> Option<(&str, &str)> {
+ if !(name.ends_with("-H") || name.ends_with("-V")) {
+ return None;
+ }
+ // The longest CMap name first: `83pv-RKSJ-H` before `H`.
+ // No predefined CMap name is longer than 32 bytes.
+ for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) {
+ let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue };
+ if matches!(cmap, "Identity-H" | "Identity-V") {
+ return None;
+ }
+ let encoding_part =
+ base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part));
+ if matches!(cmap, "H" | "V") && encoding_part {
+ return None;
+ }
+ if known(cmap) {
+ return Some((base, cmap));
+ }
+ }
+ None
+}
diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs
index 2b01b2b4f2..9e452c4bf4 100644
--- a/crates/eps/src/import/tests_cid.rs
+++ b/crates/eps/src/import/tests_cid.rs
@@ -403,6 +403,171 @@ fn named_cidfonts_over_unicode_cmaps_set_their_characters_as_type() {
check("/Ryumin-Light-UniJIS-UCS2-H findfont 20 scalefont setfont <00410042> stringwidth pop dup 5 gt exch 40 lt and");
}
+/// The text objects of an import.
+fn texts(r: &crate::Imported) -> Vec {
+ all(&r.document)
+ .iter()
+ .filter_map(|n| match &n.kind {
+ NodeKind::Text(t) => Some((**t).clone()),
+ _ => None,
+ })
+ .collect()
+}
+
+/// A CID-keyed font over one of the other predefined CMaps (Shift-JIS, EUC, GBK, Big Five, UHC,
+/// JIS row and cell: ), the file defining
+/// neither, its CIDFont only named: the codes are read by that CMap and its character
+/// collection's CIDs to Unicode, and set as type in the font of the CIDFont's name, whether
+/// found as `CIDFont-CMap` or composed. Codes the CMap doesn't map and control codes are left out.
+#[test]
+fn named_cidfonts_over_predefined_cmaps_set_their_characters_as_type() {
+ let cases: &[(&str, &str, &str)] = &[
+ // 日 is 0x93FA and 本 0x967B in Shift-JIS.
+ ("83pv-RKSJ-H", "93fa967b41", "日本A"),
+ ("90ms-RKSJ-H", "93fa", "日"),
+ ("EUC-H", "c6fc", "日"),
+ // JIS row and cell: 日 is 0x467C.
+ ("H", "467c", "日"),
+ ("GBK-EUC-H", "d6d0", "中"),
+ ("B5pc-H", "a4a4", "中"),
+ ("ETen-B5-H", "a4a4", "中"),
+ ("KSCms-UHC-H", "c7d1", "한"),
+ // A CMap read with the nearest carried one: GB 2312 row and cell in EUC form, 中 0x5650.
+ ("GB-H", "5650", "中"),
+ // 0x8540 maps to no CID, 0xFD is in no code space range, 0x0D is a control code.
+ ("90ms-RKSJ-H", "854093fa41fd0d42", "日AB"),
+ ];
+ for (cmap, hex, want) in cases {
+ let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show"));
+ assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{cmap}: {:?}", r.warnings);
+ let t = texts(&r);
+ assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), [*want], "{cmap}");
+ let style = &t[0].runs[0].style;
+ assert_eq!((style.font_family.as_str(), style.font_style.as_str(), style.size), ("Ryumin", "Light", 20.0), "{cmap}");
+ assert!(!t[0].vertical, "{cmap}");
+ }
+ let r = super::tests::read("/X /GBK-EUC-H [/NotInFile] composefont 20 scalefont setfont 10 20 moveto show");
+ assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::(), "中A");
+ // The point moves past the type, along the line.
+ check("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont <93fa967b> stringwidth abs 0.01 lt exch dup 5 gt exch 60 lt and and");
+}
+
+/// Type in a CID-keyed font over a predefined CMap, its CIDFont only named, shown with `ashow`,
+/// `widthshow` (matching a code by its value) and `xshow`, is spaced as the operator says: the
+/// point moves that far, and the characters' tracking makes up the difference, as for type in a
+/// font found by name. Over a vertical CMap the extra space runs down the column.
+#[test]
+fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() {
+ // Measured without painting.
+ let measured = |body: &str| check(&format!("gsave nulldevice {body} grestore"));
+ let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont";
+ // `ashow` adds 5 to each of the two codes.
+ measured(&format!(
+ "{set} 10 20 moveto <93fa967b> show currentpoint pop 10 20 moveto 5 0 <93fa967b> ashow currentpoint pop sub -10 sub abs 0.01 lt"
+ ));
+ let r = super::tests::read(&format!("{set} 10 20 moveto 5 0 <93fa967b> ashow"));
+ let t = texts(&r);
+ assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["日本"]);
+ assert!(t[0].runs.iter().all(|run| (run.style.tracking - 250.0).abs() < 0.5), "{:?}", t[0].runs);
+ // `widthshow` adds 7 to the space, code 0x20.
+ measured(&format!(
+ "{set} 10 20 moveto <93fa20967b> show currentpoint pop 10 20 moveto 7 0 32 <93fa20967b> widthshow currentpoint pop sub -7 sub abs 0.01 lt"
+ ));
+ // A row-and-cell code read in its EUC form keeps the value of the string's bytes: 中 0x5650.
+ let gb = "/STSong-Light-GB-H findfont 20 scalefont setfont";
+ measured(&format!(
+ "{gb} 10 20 moveto <56505650> show currentpoint pop 10 20 moveto 7 0 16#5650 <56505650> widthshow currentpoint pop sub -14 sub abs 0.01 lt"
+ ));
+ // `xshow` moves 30 a code.
+ measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt"));
+ // Down the column: `ashow`'s 5 down each code.
+ let set = "/Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont";
+ measured(&format!(
+ "{set} 10 200 moveto <93fa967b> show currentpoint exch pop 10 200 moveto 0 -5 <93fa967b> ashow currentpoint exch pop sub 10 sub abs 0.01 lt"
+ ));
+ let r = super::tests::read(&format!("{set} 10 200 moveto 0 -5 <93fa967b> ashow"));
+ let t = texts(&r);
+ assert_eq!(t.iter().map(|t| t.plain_text()).collect::(), "日本");
+ assert!(t.iter().all(|t| t.vertical), "{t:?}");
+}
+
+/// A CID-keyed font over a vertical predefined CMap (`-V`), the file defining neither, its CIDFont
+/// only named: the type is a vertical column, and the point moves down it, not along a line.
+#[test]
+fn named_cidfonts_over_vertical_cmaps_set_vertical_type() {
+ for (cmap, hex) in [("90ms-RKSJ-V", "93fa967b"), ("UniJIS-UCS2-V", "65e5672c")] {
+ let r = super::tests::read(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont 10 20 moveto <{hex}> show"));
+ let t = texts(&r);
+ assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["日本"], "{cmap}: {:?}", r.warnings);
+ assert!(t[0].vertical, "{cmap}");
+ check(&format!("/Ryumin-Light-{cmap} findfont 20 scalefont setfont <{hex}> stringwidth dup -60 gt exch -5 lt and exch abs 0.01 lt and"));
+ }
+}
+
+/// Down a column each code of a CID-keyed font over a vertical predefined CMap moves the point one
+/// em, the default vertical displacement (PDF Reference 1.7, §5.6.3, `DW2`), also two digits that
+/// vertical type sets side by side in one em: `show` and `stringwidth` move the point as far as
+/// showing the codes one at a time.
+#[test]
+fn codes_down_a_column_each_move_the_point_one_em() {
+ // Measured without painting.
+ let set = "gsave nulldevice /Ryumin-Light-90ms-RKSJ-V findfont 20 scalefont setfont";
+ check(&format!("{set} (12) stringwidth grestore -40 sub abs 0.01 lt exch abs 0.01 lt and"));
+ check(&format!("{set} 10 100 moveto (12) show currentpoint grestore 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and"));
+ check(&format!(
+ "{set} 10 100 moveto (1) show (2) show currentpoint 10 100 moveto (12) show currentpoint grestore \
+ 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and"
+ ));
+}
+
+/// Type in a CID-keyed font over a predefined CMap is set the same way by `show` and by the show
+/// operators that space it: characters the font would join in a ligature (`fi` in the bundled
+/// Source Serif 4) are set without the ligature, and the point moves as far.
+#[test]
+fn named_cid_type_is_set_alike_by_every_show_operator() {
+ let set = "/SourceSerif4-Regular-UniJIS-UCS2-H findfont 50 scalefont setfont 10 20 moveto";
+ let features = |show: &str| {
+ let r = super::tests::read(&format!("{set} <00660069> {show}"));
+ let t = texts(&r);
+ assert_eq!(t.iter().map(|t| t.plain_text()).collect::>(), ["fi"], "{show}");
+ t[0].runs.iter().map(|r| r.style.features.clone()).collect::>()
+ };
+ let plain = features("show");
+ assert_eq!(plain, features("0 0 3 -1 roll ashow"));
+ assert!(plain.iter().all(|f| f.iter().any(|f| f == "-liga")), "{plain:?}");
+ check(&format!(
+ "gsave nulldevice {set} <00660069> stringwidth 10 20 moveto 0 0 <00660069> ashow currentpoint grestore \
+ 20 sub 3 -1 roll sub abs 0.01 lt 3 1 roll 10 sub sub abs 0.01 lt and"
+ ));
+}
+
+/// A font name ending in the row-and-cell CMap `H` after a name without a hyphen stays a font of
+/// the whole name, its strings read byte by byte. Type in a predefined CMap whose mappings aren't
+/// available is left out with a warning, and a long string in a CID-keyed font over a predefined
+/// CMap shown in a loop runs out of the budget.
+#[test]
+fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() {
+ let r = super::tests::read("/Futura-H findfont 20 scalefont setfont 10 20 moveto (AB) show");
+ assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::>(), ["AB"]);
+ let r = super::tests::read("/Ryumin-Light-GBT-H findfont 20 scalefont setfont 10 20 moveto (AB) show 0 0 10 10 rectfill");
+ assert!(texts(&r).is_empty(), "{:?}", all(&r.document));
+ assert_eq!(super::tests::program_warnings(&r.warnings), [super::text::UNREAD_CMAP], "{:?}", r.warnings);
+ super::tests::runs_out_of_budget("/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont { 100000 string stringwidth pop pop } loop");
+}
+
+/// A string in the file, in a CID-keyed font over a predefined CMap, too long to decode within the
+/// memory budget stops the program with an error before it is decoded.
+#[test]
+fn huge_strings_over_predefined_cmaps_count_against_the_memory_budget() {
+ // Codes the CMap doesn't map, so nothing is laid out when the string is decoded.
+ let huge = "\u{1}".repeat(17 << 20);
+ let r = import(&super::tests::eps(&format!(
+ "0 0 10 10 rectfill /Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont 10 20 moveto ({huge}) stringwidth"
+ )))
+ .unwrap();
+ assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings);
+}
+
/// A Type 2 CIDFont the file carries (its glyph space one unit an em), over a Unicode CMap the
/// file doesn't define, sets its characters as type at the font's size, not a thousand times it.
#[test]
diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs
index 5868aa9228..ee9b0fc759 100644
--- a/crates/eps/src/import/text.rs
+++ b/crates/eps/src/import/text.rs
@@ -15,6 +15,7 @@ use super::cid::Base;
use super::graphics::MAX_APART;
use super::interp::{Interp, matrix_obj, matrix_of};
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err};
+use super::predefined_cmap::Decoded;
use super::type1::Embedded;
/// The font a program uses before it sets one.
@@ -170,8 +171,8 @@ impl Interp<'_> {
ResourceForAll => self.resource_for_all()?,
Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => {
let (s, spacing) = self.show_operands(op)?;
- if let Some((text, font)) = self.unicode_type(&s)? {
- self.show_unicode(&text, &font)?;
+ if let Some((codes, font, vertical)) = self.named_cid_type(&s)? {
+ self.show_named_cid(&codes, &font, vertical, &spacing)?;
} else if let Some(glyphs) = self.composite_glyphs(&s)? {
self.show_composite(&glyphs, &spacing)?;
} else {
@@ -282,9 +283,9 @@ impl Interp<'_> {
/// How far `s` moves the current point (user space).
fn width_of(&mut self, s: &[u8]) -> Res {
- if let Some((text, font)) = self.unicode_type(s)? {
+ if let Some((codes, font, vertical)) = self.named_cid_type(s)? {
let saved = self.g.font.replace(font);
- let r = self.set_text(&text, Point::ZERO).map(|t| t.2);
+ let r = self.named_cid_width(&codes, vertical);
self.g.font = saved;
return r;
}
@@ -395,18 +396,29 @@ impl Interp<'_> {
}
/// The font named `k`: one the program defined; else, for a name `CIDFont-CMap` whose CMap
- /// is a predefined Unicode CMap, the CID-keyed font of those two (the usual name of a
- /// CID-keyed font, as `composefont` makes them: PLRM 3rd ed., chapter 8); else a font of
- /// that name.
+ /// is a predefined CMap, the CID-keyed font of those two (the usual name of a CID-keyed font,
+ /// as `composefont` makes them: PLRM 3rd ed., chapter 8; the CMaps are Adobe's public CMap
+ /// resources, ); else a font of that name.
+ /// A name an installed face has is that face's, and a name split at a bare `H` or `V` (the
+ /// row-and-cell CMaps) needs a CIDFont name with a hyphen in it (`Ryumin-Light-H`), so
+ /// `Futura-H` stays a font of that name.
fn find_font(&mut self, k: &Obj) -> Res {
let key = k.key().ok_or(PsError::Ps("typecheck", "findfont".into()))?;
if let Some(Obj::Dict(d)) = self.fonts.borrow().get(&key) {
return Ok(d.clone());
}
let text = k.text().map(|t| t.to_string()).unwrap_or_default();
- let split =
- text.match_indices('-').map(|(i, _)| i).find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some());
- if let Some((cid_font, cmap)) = split.and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) {
+ let split = text
+ .match_indices('-')
+ .map(|(i, _)| i)
+ .find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some())
+ .and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?)))
+ .or_else(|| {
+ let (cid_font, cmap) = super::predefined_cmap::composite_font(&text)?;
+ let installed = vectorcraft_text::FontDb::global().find_postscript(&text).is_some();
+ (!installed && !(matches!(cmap, "H" | "V") && !cid_font.contains('-'))).then_some((cid_font, cmap))
+ });
+ if let Some((cid_font, cmap)) = split {
let cmap = self.find_cmap(&Obj::name(cmap))?;
let dep = self.find_cid_font(&Obj::name(cid_font))?;
return Ok(self.composed(key, cmap, vec![Obj::Dict(dep)]));
@@ -476,6 +488,15 @@ impl Interp<'_> {
/// [`Self::set_type`] for the characters `text`.
fn set_text(&mut self, text: &str, at: Point) -> Res<(TextObject, TextLayout, Vec2)> {
+ self.set_text_in(text, at, false)
+ }
+
+ /// [`Self::set_text`], as vertical type when `vertical`: a column anchored at `at`, the top
+ /// center of its first em box, that moves the current point down the column by the layout's
+ /// advances. In vertical writing the current point is a glyph's origin 1, by default half its
+ /// width across and 880 units of 1000 above its origin 0 (PDF Reference 1.7, §5.6.3 "Glyph
+ /// Metrics in CIDFonts", `DW2`).
+ fn set_text_in(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout, Vec2)> {
// Laying out type costs about as much as 300 operations, and 64 more a character
// (measured), so `{ (a) stringwidth pop pop } loop` or `cshow` on a long string ends
// within the budget like any other loop instead of running for minutes.
@@ -493,11 +514,29 @@ impl Interp<'_> {
let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() };
let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
+ t.vertical = vertical;
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
t.xf = m * Affine::scale_non_uniform(1.0 / size, -1.0 / size);
t.cached_bounds = Some(layout.bounds);
let adv: f64 = layout.glyphs.iter().map(|g| g.advance).sum();
- Ok((t, layout, f1 * Point::new(adv / size, 0.0) - f1 * Point::ZERO))
+ let moved = if vertical { Point::new(0.0, -adv / size) } else { Point::new(adv / size, 0.0) };
+ Ok((t, layout, f1 * moved - f1 * Point::ZERO))
+ }
+
+ /// [`Self::set_text_in`] for characters the file places one by one: where the layout joins
+ /// characters in a ligature, the type is set without ligatures, so each character has its own
+ /// advance.
+ fn set_placed_text(&mut self, text: &str, at: Point, vertical: bool) -> Res<(TextObject, TextLayout)> {
+ let (mut t, mut layout, _) = self.set_text_in(text, at, vertical)?;
+ let glyph_starts: std::collections::HashSet = layout.glyphs.iter().map(|g| g.byte).collect();
+ if text.char_indices().any(|(b, _)| !glyph_starts.contains(&b)) {
+ for run in &mut t.runs {
+ run.style.features.push("-liga".into());
+ }
+ layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
+ t.cached_bounds = Some(layout.bounds);
+ }
+ Ok((t, layout))
}
/// `show`: point type at the current point, which moves past it.
@@ -527,27 +566,30 @@ impl Interp<'_> {
return self.show_type42_outlines(data, glyphs, s, spacing, at, f1);
}
// Each code's character, if it has one that shows (control codes don't).
- let chars: Vec> = match &plain {
+ let chars = match &plain {
Some((_, glyphs)) => glyphs.iter().map(|g| g.ch).collect::>(),
None => self.named_chars(s).into_iter().map(Some).collect(),
+ };
+ let mut codes = Decoded::default();
+ for (c, &code) in chars.into_iter().zip(s) {
+ codes.push(u32::from(code), c.filter(|c| !c.is_control()));
}
- .into_iter()
- .map(|c| c.filter(|c| !c.is_control()))
- .collect();
- let text: String = chars.iter().flatten().collect();
- let (mut t, mut layout, _) = self.set_text(&text, at)?;
+ let widths: Option> = plain.map(|(_, glyphs)| glyphs.iter().map(|g| g.width).collect());
+ self.show_codes(&codes, widths.as_deref(), spacing, false, s)
+ }
+
+ /// The codes `codes` (each standing for some of its characters, or none) shown in the current
+ /// font as [`Self::show_plain`] shows a string's, spaced as `spacing` says, each code `widths`
+ /// wide when given (in glyph space, one unit an em), else as wide as its characters' advances;
+ /// as vertical type when `vertical`, the codes moving the point down the column. `kern` is the
+ /// string `kshow` gives its procedure the codes of.
+ fn show_codes(&mut self, codes: &Decoded, widths: Option<&[f64]>, spacing: &Spacing, vertical: bool, kern: &[u8]) -> Res {
+ let at = self.user_point()?;
+ let (_, f1) = self.font()?;
+ let text = &codes.text;
+ let (mut t, layout) = self.set_placed_text(text, at, vertical)?;
let size = t.runs.first().map_or(1.0, |r| r.style.size);
let starts: Vec = text.char_indices().map(|(b, _)| b).collect();
- // The file places each character's glyph: where the layout joins characters in a
- // ligature, the type is set without ligatures, so each character has its own advance.
- let glyph_starts: std::collections::HashSet = layout.glyphs.iter().map(|g| g.byte).collect();
- if starts.iter().any(|b| !glyph_starts.contains(b)) {
- for run in &mut t.runs {
- run.style.features.push("-liga".into());
- }
- layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
- t.cached_bounds = Some(layout.bounds);
- }
// Each character's advance in the layout (text space).
let mut natural = vec![0.0; starts.len()];
for g in &layout.glyphs {
@@ -555,29 +597,30 @@ impl Interp<'_> {
*n += g.advance;
}
}
- // Each code's move (user space) and that move in text space, along the baseline and across.
- let em = |x: f64| f1 * Point::new(x, 0.0) - f1 * Point::ZERO;
+ // Each code's move (user space) and that move in text space, along the baseline (down the
+ // column) and across.
+ let em = |x: f64| f1 * if vertical { Point::new(0.0, -x) } else { Point::new(x, 0.0) } - f1 * Point::ZERO;
let inv = f1.inverse();
- let mut moves = Vec::with_capacity(s.len());
+ let mut moves = Vec::with_capacity(codes.codes.len());
let mut k = 0;
// What `kshow`'s procedure draws goes after the type.
let start = self.out.drawn.len();
let mut pen = at;
- for (i, (&code, c)) in s.iter().zip(&chars).enumerate() {
- let own = match &plain {
- Some((_, glyphs)) => glyphs.get(i).map_or(0.0, |g| g.width),
- None if c.is_some() => natural.get(k).copied().unwrap_or(0.0) / size,
- None => 0.0,
+ for (i, &(code, n)) in codes.codes.iter().enumerate() {
+ let own = match widths {
+ Some(widths) => widths.get(i).copied().unwrap_or(0.0),
+ None => natural.get(k..k + n).map_or(0.0, |w| w.iter().sum::()) / size,
};
- k += usize::from(c.is_some());
+ k += n;
let mut d = spacing.advance(i, i64::from(code), em(own));
- d += self.kern(spacing, s, i, pen + d)?;
+ d += self.kern(spacing, kern, i, pen + d)?;
pen += d;
let e = inv * Point::new(d.x, d.y) - inv * Point::ZERO;
- if ![d.x, d.y, e.x * size, e.y * size].iter().all(|v| v.is_finite()) {
+ let (along, across) = if vertical { (-e.y * size, e.x * size) } else { (e.x * size, e.y * size) };
+ if ![d.x, d.y, along, across].iter().all(|v| v.is_finite()) {
return ps_err("undefinedresult", "a show");
}
- moves.push((d, e.x * size, e.y * size));
+ moves.push((d, along, across));
}
let total: Vec2 = moves.iter().map(|m| m.0).sum();
if !(total.x.is_finite() && total.y.is_finite()) {
@@ -589,12 +632,11 @@ impl Interp<'_> {
if across {
// Moves across the baseline (`yshow`, `xyshow`): each character where the file puts it.
let mut pen = at;
- for ((d, ..), c) in moves.iter().zip(&chars) {
- if let Some(c) = c
- && painted
- && !c.is_whitespace()
- {
- let (t, ..) = self.set_text(c.encode_utf8(&mut [0; 4]), pen)?;
+ let mut chars = text.chars();
+ for ((d, ..), &(_, n)) in moves.iter().zip(&codes.codes) {
+ let c: String = chars.by_ref().take(n).collect();
+ if painted && !c.trim().is_empty() {
+ let (t, ..) = self.set_text_in(&c, pen, vertical)?;
let clips = self.g.clips.clone();
self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips);
}
@@ -605,11 +647,17 @@ impl Interp<'_> {
// a character, before the next, are that character's.
let mut lead = Vec2::ZERO;
let mut target: Vec = Vec::with_capacity(natural.len());
- for ((d, along, _), c) in moves.iter().zip(&chars) {
- match (c, target.last_mut()) {
- (Some(_), _) => target.push(*along),
- (None, Some(last)) => *last += along,
- (None, None) => lead += *d,
+ for ((d, along, _), &(_, n)) in moves.iter().zip(&codes.codes) {
+ if n > 0 {
+ // A code of several characters: those before its last keep their advances.
+ let before = natural.get(target.len()..target.len() + n - 1).unwrap_or_default().to_vec();
+ let rest = *along - before.iter().sum::();
+ target.extend(before);
+ target.push(rest);
+ } else if let Some(last) = target.last_mut() {
+ *last += along;
+ } else {
+ lead += *d;
}
}
let tracking: Vec = target
@@ -931,23 +979,47 @@ impl Interp<'_> {
.collect())
}
- /// The characters of `s` and the font to set them in when the current font is a CID-keyed
- /// font over a predefined Unicode CMap the file doesn't define (`UniJIS-UCS2-H`), whose
- /// mappings to CIDs aren't known: its codes say the characters, set as type in the font of
- /// its CIDFont's name.
- fn unicode_type(&mut self, s: &[u8]) -> Res> {
- let Some((text, font, name)) = self.unicode_parts(s) else { return Ok(None) };
+ /// The codes of `s` with the characters each stands for, the font to set them in and whether
+ /// they run down a column, when the current font is a CID-keyed font over a predefined CMap
+ /// the file doesn't define (`UniJIS-UCS2-H`, `90ms-RKSJ-H`), whose mappings to CIDs the file
+ /// doesn't give: a Unicode CMap's codes are the characters, another CMap's are read by the CMap
+ /// of that name and its character collection's CID → Unicode CMap (a code it doesn't map
+ /// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical
+ /// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't
+ /// read is left out, with a warning.
+ fn named_cid_type(&mut self, s: &[u8]) -> Res > {
+ let Some((cmap, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) };
+ // Each code is looked up in the CMap's mappings.
+ self.spend(s.len() as u64)?;
+ // The decoded codes and characters are held while the string is shown: checked against
+ // the memory budget first, so a huge string in the file stops the program with an error.
+ let held = s.len().saturating_mul(DECODED_BYTES);
+ self.alloc(held)?;
+ self.unalloc(held);
+ let decoded = match self.predefined_codes(&cmap) {
+ Some(codes) => codes.codes(s),
+ None => {
+ self.out.warn(UNREAD_CMAP);
+ Decoded::default()
+ }
+ };
+ let mut codes = Decoded::default();
+ let mut chars = decoded.text.chars();
+ for &(code, n) in &decoded.codes {
+ codes.push(code, chars.by_ref().take(n).filter(|c| !c.is_control()));
+ }
// The composite font is copied under the CIDFont's name for every string: charged by
// its size, so a huge font dictionary shown in a loop runs out of the budget.
let entries = font.borrow().len();
self.spend(entries as u64)?;
let mut named = font.borrow().clone();
named.insert(Key::name("FontName"), name);
- Ok(Some((text, DictRef::new(named))))
+ Ok(Some((codes, DictRef::new(named), vertical)))
}
- /// The text, the composite font and the CIDFont's name of [`Self::unicode_type`].
- fn unicode_parts(&self, s: &[u8]) -> Option<(String, DictRef, Obj)> {
+ /// The CMap's name, the composite font, the CIDFont's name and the writing mode of
+ /// [`Self::named_cid_type`].
+ fn named_cid_parts(&self) -> Option<(String, DictRef, Obj, bool)> {
let font = self.g.font.clone()?;
let f = font.borrow();
if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(9.0) {
@@ -959,29 +1031,63 @@ impl Interp<'_> {
if cmap.contains_key(&Key::name("CodeSpaceRange")) {
return None;
}
- let (form, _) = super::cid::unicode_cmap(&cmap.get(&Key::name("CMapName")).and_then(Obj::text)?)?;
+ let cmap_name = cmap.get(&Key::name("CMapName")).and_then(Obj::text)?.to_string();
+ if super::cid::unicode_cmap(&cmap_name).is_none() && !super::predefined_cmap::known(&cmap_name) {
+ return None;
+ }
+ let wmode = f.get(&Key::name("WMode")).or_else(|| cmap.get(&Key::name("WMode"))).and_then(Obj::as_num);
let Some(Obj::Dict(dep)) = f.get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)) else { return None };
let name = {
let d = dep.borrow();
d.get(&Key::name("CIDFontName")).or_else(|| d.get(&Key::name("FontName"))).cloned()?
};
- Some((super::cid::unicode_text(s, form), font.clone(), name))
+ Some((cmap_name, font.clone(), name, wmode == Some(1.0)))
}
- /// `text` set as point type at the current point in font `font` (see
- /// [`Self::unicode_type`]), which moves past it.
- fn show_unicode(&mut self, text: &str, font: &DictRef) -> Res {
- let at = self.user_point()?;
- let saved = self.g.font.replace(font.clone());
- let r = self.set_text(text, at);
- self.g.font = saved;
- let (t, _, adv) = r?;
- if !self.g.paint.is_none() && !self.g.null && !text.trim().is_empty() {
- let clips = self.g.clips.clone();
- self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips);
+ /// How strings in predefined CMap `cmap` decode (a CMap other than a Unicode CMap read once a
+ /// file); `None` when VectorCraft can't read it.
+ fn predefined_codes(&mut self, cmap: &str) -> Option> {
+ if let Some((form, _)) = super::cid::unicode_cmap(cmap) {
+ return Some(Rc::new(super::predefined_cmap::CMapCodes::Unicode(form)));
}
- self.g.cur = Some(self.xf() * (at + adv));
- Ok(())
+ if let Some((_, codes)) = self.predefined_cmaps.iter().find(|(n, _)| **n == *cmap) {
+ return codes.clone();
+ }
+ let codes = super::predefined_cmap::codes(cmap).map(Rc::new);
+ // Only the names of the other predefined CMaps reach here (`named_cid_parts`), so the
+ // list stays short.
+ self.predefined_cmaps.push((Rc::from(cmap), codes.clone()));
+ codes
+ }
+
+ /// The codes `codes` (with their characters) set as point type at the current point in font
+ /// `font` (see [`Self::named_cid_type`]), running down a column when `vertical`, which moves
+ /// past them, spaced as `spacing` says, as [`Self::show_codes`] shows them. `widthshow` and
+ /// `awidthshow` match a code by its value (font number 0 times 256 plus the code: PLRM 3rd ed.,
+ /// `widthshow` in chapter 8); `kshow`'s procedure isn't run between them (a composite font's
+ /// codes aren't single bytes). Down a column each code moves the point one em, the default
+ /// vertical displacement of a CIDFont whose metrics the file doesn't give (PDF Reference 1.7,
+ /// §5.6.3, `DW2`).
+ fn show_named_cid(&mut self, codes: &Decoded, font: &DictRef, vertical: bool, spacing: &Spacing) -> Res {
+ let widths = vertical.then(|| vec![1.0; codes.codes.len()]);
+ let saved = self.g.font.replace(font.clone());
+ let r = self.show_codes(codes, widths.as_deref(), spacing, vertical, &[]);
+ self.g.font = saved;
+ r
+ }
+
+ /// How far the codes `codes` move the current point (user space) when [`Self::show_named_cid`]
+ /// shows them with no extra spacing.
+ fn named_cid_width(&mut self, codes: &Decoded, vertical: bool) -> Res {
+ let (_, f1) = self.font()?;
+ let moved = if vertical {
+ Point::new(0.0, -(codes.codes.len() as f64))
+ } else {
+ let (t, layout) = self.set_placed_text(&codes.text, Point::ZERO, false)?;
+ let size = t.runs.first().map_or(1.0, |r| r.style.size);
+ Point::new(layout.glyphs.iter().map(|g| g.advance).sum::() / size, 0.0)
+ };
+ Ok(f1 * moved - f1 * Point::ZERO)
}
/// The characters of a composite font shown at the current point, spaced as `spacing` says.
@@ -1282,6 +1388,13 @@ const MAX_TYPE1: usize = 256;
/// The warning for CID-keyed type in a CIDFont the file doesn't carry.
const MISSING_CID_FONT: &str = "CID-keyed type in a font the file doesn't carry was left out";
+/// Most bytes [`Interp::named_cid_type`] holds for each byte of a string: two lists of codes (16
+/// bytes a code) and their characters (up to 4 bytes a character, a few characters a code).
+const DECODED_BYTES: usize = 64;
+
+/// The warning for type in a predefined CMap whose mappings VectorCraft doesn't have.
+pub(super) const UNREAD_CMAP: &str = "Type in a CMap that couldn't be read was left out";
+
/// Most Type 42 fonts whose TrueType data is kept between glyphs.
const MAX_TYPE42: usize = 16;
From 6849bd54222c485e12244985f2ef7f1ddb85d46c Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Thu, 12 Nov 2026 05:12:10 -0800
Subject: [PATCH 15/15] Docs: docs/plugins.md paragraphs wrap at the file's
width
Rewrap the prose in docs/plugins.md whose lines broke well short of
the file's 120-column width or ran past it, including the Safe Mode
sentence, the permission-name and install-confirmation sentences, and
the grants.json paragraph. Each rewrapped line is at most 120 columns.
No words, punctuation, code blocks or tables change.
---
docs/plugins.md | 308 ++++++++++++++++++++++++------------------------
1 file changed, 151 insertions(+), 157 deletions(-)
diff --git a/docs/plugins.md b/docs/plugins.md
index d3af5fa06c..f616479167 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -1,9 +1,9 @@
# Plug-ins: the WebAssembly plug-in API (ABI v1)
VectorCraft does not host native plug-ins built for other illustration apps: that needs unsafe FFI into
-arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules**
-run in a sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's
-plug-in API (same sandbox, same manifest and parameter schema), extended for vector documents.
+arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules** run in a
+sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's plug-in API (same
+sandbox, same manifest and parameter schema), extended for vector documents.
There are four kinds of plug-in:
@@ -20,8 +20,8 @@ There are four kinds of plug-in:
paths and compound paths. File › Open lists their files, File › Open With lists the importers by name, and the host
hands them the file's bytes.
-This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the
-`plugin.*` commands in `crates/engine/src/cmd/plugin.rs`).
+This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the `plugin.*` commands
+in `crates/engine/src/cmd/plugin.rs`).
## Using plug-ins
@@ -44,14 +44,15 @@ An exporter adds a write-only format, `plugin.`, which `document.formats` li
its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats.
An importer adds a read-only format, `plugin.`, which `document.formats` lists with its `plugin` id. `openExtensions`
-holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format
-reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the
-one whose id sorts first opens it. `document.open {"importer": "plugin.