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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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 5c2e182388212a4fab1e070ee17a3c285b307dfe Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 12:47:49 -0800
Subject: [PATCH 07/26] MCP: a malformed object id is refused as such, not
reported as missing
Commands that need an object id read it with `id_param`, which returns
None for a value that is not a non-negative integer, so `{"id": "5"}` or
`{"id": -1}` was refused with "missing id". They now read it with
`object_param`, which refuses such a value with "`id` must be a whole
number", naming the key. This covers `document.node`, the `path.*` anchor
and handle commands, the `text.*` range and path commands, the mesh,
envelope and slice point commands, `layer.target`, `layer.move`,
`node.move`, `select.magicWand`, and the `source`, `target`, `group` and
`path` keys of `appearance.transfer`, `appearance.copyFrom`, the
`livePaint.*` commands and `text.createInPath`.
The optional `group` of `livePaint.fill` and `parent` of `node.move` are
refused the same way when given. Before, a malformed `parent` moved the
object to the top level and a malformed `group` fell back to the
selection.
---
crates/engine/src/cmd/appearance.rs | 10 +--
crates/engine/src/cmd/buildcmds.rs | 4 +-
crates/engine/src/cmd/buildcmds/live_paint.rs | 13 +--
crates/engine/src/cmd/distortcmds.rs | 10 +--
crates/engine/src/cmd/draw2.rs | 4 +-
crates/engine/src/cmd/draw2/anchors.rs | 10 +--
crates/engine/src/cmd/draw2/cut.rs | 4 +-
crates/engine/src/cmd/draw2/freehand.rs | 9 +-
crates/engine/src/cmd/edit.rs | 6 +-
crates/engine/src/cmd/layer.rs | 19 +++--
crates/engine/src/cmd/live.rs | 2 +-
crates/engine/src/cmd/live/envelope.rs | 4 +-
crates/engine/src/cmd/live/mesh.rs | 6 +-
crates/engine/src/cmd/path.rs | 16 ++--
crates/engine/src/cmd/slices.rs | 4 +-
crates/engine/src/cmd/textedit.rs | 20 ++---
crates/engine/src/cmd/typecmd.rs | 10 +--
crates/engine/src/cmd/wand.rs | 4 +-
crates/engine/src/tests/params.rs | 84 +++++++++++++++++++
docs/mcp.md | 8 +-
20 files changed, 172 insertions(+), 75 deletions(-)
diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs
index 2dfeb497d0..7f6f8cca12 100644
--- a/crates/engine/src/cmd/appearance.rs
+++ b/crates/engine/src/cmd/appearance.rs
@@ -32,8 +32,8 @@ use super::opacitymask::percent;
use super::paint::paint_from;
use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs};
use super::{
- CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets,
- ok, paint_targets, str_param, switch_or, targets,
+ CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets,
+ object_param, ok, paint_targets, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -726,8 +726,8 @@ fn clear(n: &mut Node) {
fn transfer(s: &mut Session, p: &Value) -> Result {
const C: &str = "appearance.transfer";
- let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
- let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?;
+ let source = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?;
+ let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing `target` id"))?;
if source == target {
return Err(bad(C, "`source` and `target` are the same object"));
}
@@ -1201,7 +1201,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result {
/// Sample enabled attributes, refusing object transparency that a text range cannot store.
fn copy_from(s: &mut Session, p: &Value) -> Result {
const C: &str = "appearance.copyFrom";
- let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
+ let clicked = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?;
let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
let defaults_only = switch_or(p, "defaultsOnly", false, C)?;
if defaults_only && (reverse || append || p.get("ids").is_some()) {
diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs
index d392449247..055bd748af 100644
--- a/crates/engine/src/cmd/buildcmds.rs
+++ b/crates/engine/src/cmd/buildcmds.rs
@@ -29,8 +29,8 @@ use super::edit::{outermost, selected_roots};
use super::paint::paint_from;
use super::pathops::{Beside, shape_node};
use super::{
- CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, named_ids,
- number_or, point_param, str_param, switch_or,
+ CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, named_ids, number_or,
+ object_param, point_param, str_param, switch_or,
};
mod live_paint;
diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs
index 207182d40a..31cd4eaf40 100644
--- a/crates/engine/src/cmd/buildcmds/live_paint.rs
+++ b/crates/engine/src/cmd/buildcmds/live_paint.rs
@@ -1,7 +1,7 @@
use super::{
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point,
- Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json,
- is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
+ Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint,
+ json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
};
/// `options` as stored on a Live Paint group: none for the defaults.
@@ -274,7 +274,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result {
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
crate::cmd::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
- let group = id_param(p, "group");
+ let group = object_param(p, "group", C)?;
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
let (g, face) = s.edit("Live Paint Bucket", |d, sel| {
let gid = match group {
@@ -312,7 +312,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result {
pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result {
const C: &str = "livePaint.strokeEdge";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
- let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
+ let g = object_param(p, "group", C)?.ok_or_else(|| bad(C, "missing group"))?;
crate::cmd::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?;
@@ -335,7 +335,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result {
}
pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result {
- let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?;
+ let g = match object_param(p, "group", "livePaint.info")? {
+ Some(g) => g,
+ None => object_param(p, "id", "livePaint.info")?.ok_or_else(|| bad("livePaint.info", "missing group"))?,
+ };
let d = &s.doc()?.doc;
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?;
let faces: Vec = b::faces(n)
diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs
index 17638477a9..2dbb7a9a18 100644
--- a/crates/engine/src/cmd/distortcmds.rs
+++ b/crates/engine/src/cmd/distortcmds.rs
@@ -15,8 +15,8 @@ use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project
use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots};
use super::{
- CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param,
- matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets,
+ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, matrix_param,
+ named_id, named_ids, number_or, object_param, ok, point_indices, point_param, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -305,7 +305,7 @@ fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool {
fn width_point_set(s: &mut Session, p: &Value) -> Result {
const C: &str = "stroke.widthPoint.set";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
let (left, right) = (f64_req(p, "left", C)?, f64_req(p, "right", C)?);
if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 {
@@ -362,7 +362,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result {
fn width_point_copy(s: &mut Session, p: &Value) -> Result {
const C: &str = "stroke.widthPoint.copy";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
// When the profile comes out as it was (the copy replaces a side of a discontinuous point that has
// its widths already), the document and its undo history stay as they are.
@@ -382,7 +382,7 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result {
fn width_point_remove(s: &mut Session, p: &Value) -> Result {
const C: &str = "stroke.widthPoint.remove";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let indices = match (p.get("indices"), p.get("index")) {
(Some(a), _) => point_indices(a),
(None, Some(i)) => point_indices(&json!([i])),
diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs
index 7d1c1208d8..d469c7a613 100644
--- a/crates/engine/src/cmd/draw2.rs
+++ b/crates/engine/src/cmd/draw2.rs
@@ -18,8 +18,8 @@ use super::create::{add_look, insert_look};
use super::newart::NewArt;
use super::pathops::{Beside, remove_each};
use super::{
- CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param,
- switch_or,
+ CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, index, number_or, object_param, ok,
+ str_param, switch_or,
};
use crate::EngineError;
diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs
index dbca59b00d..10254f3cd3 100644
--- a/crates/engine/src/cmd/draw2/anchors.rs
+++ b/crates/engine/src/cmd/draw2/anchors.rs
@@ -1,7 +1,7 @@
use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound};
use super::{
Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2,
- add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or,
+ add_look, bad, check_numbers, f64_or, json, number_or, object_param, ok, po, str_param, switch_or,
};
// ---------- Curvature ----------
@@ -41,7 +41,7 @@ enum CurvatureOp {
pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result {
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
const C: &str = "path.curvatureEdit";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
check_numbers(p, &["x", "y", "t"], C)?;
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
@@ -106,7 +106,7 @@ pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result {
pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result {
const C: &str = "path.removeAnchor";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let curvature = switch_or(p, "curvature", false, C)?;
@@ -220,7 +220,7 @@ fn remove_anchors_of(
pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result {
const C: &str = "path.convertAnchor";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let smooth = str_param(p, "to") == Some("smooth");
@@ -257,7 +257,7 @@ pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result {
pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result {
const C: &str = "path.reshapeSegment";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let seg = usize_req(p, "segment", C)?;
let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95);
diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs
index 126fa57a1b..645c184b14 100644
--- a/crates/engine/src/cmd/draw2/cut.rs
+++ b/crates/engine/src/cmd/draw2/cut.rs
@@ -4,7 +4,7 @@ use super::common::{
};
use super::{
Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath,
- Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po,
+ Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po,
};
/// Split a filled path into connected pieces (each outer contour with its holes).
@@ -170,7 +170,7 @@ pub(in super::super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn(
pub(super) fn split(s: &mut Session, p: &Value) -> Result {
const C: &str = "path.split";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let anchor = usize_opt(p, "anchor", C)?;
let seg = usize_opt(p, "segment", C)?;
diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs
index 660f52121e..f13b0a0ba4 100644
--- a/crates/engine/src/cmd/draw2/freehand.rs
+++ b/crates/engine/src/cmd/draw2/freehand.rs
@@ -4,7 +4,8 @@ use super::common::{
};
use super::{
Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point,
- Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or,
+ Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, insert_look, json, number_or, object_param, po, remove_each, str_param,
+ switch_or,
};
// ---------- Pencil / Paintbrush ----------
@@ -158,7 +159,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result {
let keep_selected = switch_or(p, "keepSelected", true, C)?;
let filled = switch_or(p, "fill", false, C)?;
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
- let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
+ let id = object_param(ext, "id", C)?.ok_or_else(|| bad(C, "extend needs id"))?;
let at_start = str_param(ext, "end") == Some("start");
let (pd, _) = path_of(&s.doc()?.doc, id)?;
let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?;
@@ -179,7 +180,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result {
// open subpath, that subpath starting at the end the stroke ends on, it ends at its end).
let connect = match p.get("connect").filter(|v| !v.is_null()) {
Some(cn) => {
- let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?;
+ let other = object_param(cn, "id", C)?.ok_or_else(|| bad(C, "connect needs id"))?;
if other == id {
return Err(bad(C, "connect needs a path other than the one extended"));
}
@@ -299,7 +300,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result {
return Ok(json!({ "id": id.0 }));
}
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
- let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
+ let id = object_param(rd, "id", C)?.ok_or_else(|| bad(C, "redraw needs id"))?;
let within = number_or(rd, "within", 12.0, C)?;
if !within.is_finite() || within < 0.0 {
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index 7a8a91f0af..fe683bfa9b 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, Point, Vec2};
use super::clipboard::SwatchChoices;
use super::{
CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, clock_date, cmd, date_param,
- f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param,
- index, ok, point_param, str_param, switch_or,
+ f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index,
+ object_param, ok, point_param, str_param, switch_or,
};
use crate::{Clipboard, EngineError, inspect};
@@ -267,7 +267,7 @@ fn doc_move(s: &mut Session, p: &Value) -> Result {
}
fn doc_node(s: &mut Session, p: &Value) -> Result {
- let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
+ let id = object_param(p, "id", "document.node")?.ok_or_else(|| bad("document.node", "missing id"))?;
let mut opts = slice_opts(p, "document.node")?;
let summary = bool_or(p, "summary", false);
if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) {
diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs
index 49b1015bf4..593d325460 100644
--- a/crates/engine/src/cmd/layer.rs
+++ b/crates/engine/src/cmd/layer.rs
@@ -15,7 +15,7 @@ use vectorcraft_geom::{Affine, Rect};
use super::{
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc,
- has_selection, id_param, ids_param, index, ok, str_param, switch_or,
+ has_selection, ids_param, index, object_param, ok, str_param, switch_or,
};
use crate::EngineError;
@@ -426,7 +426,7 @@ fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result {
/// The Layers panel's target circle.
fn target(s: &mut Session, p: &Value) -> Result {
const C: &str = "layer.target";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let st = s.doc()?;
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
if !st.doc.is_editable(id) {
@@ -594,7 +594,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result {
fn set_current(s: &mut Session, p: &Value) -> Result {
const C: &str = "layer.setCurrent";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let st = s.doc_mut()?;
let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?;
st.active_layer = Some(layer);
@@ -700,7 +700,7 @@ fn in_isolation(doc: &Document, isolation: Option, id: NodeId) -> bool {
/// The selection column: select a row's art.
fn select_art(s: &mut Session, p: &Value) -> Result {
const C: &str = "layer.selectAll";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let add = switch_or(p, "add", false, C)?;
let st = s.doc()?;
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
@@ -747,9 +747,12 @@ enum Place {
/// Drag rows in the Layers panel.
fn move_rows(s: &mut Session, p: &Value) -> Result {
const C: &str = "layer.move";
- let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
+ let mut ids = match ids_param(p, "ids", C)? {
+ Some(ids) => ids,
+ None => vec![object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing ids"))?],
+ };
ids.truncate(MAX_ROWS);
- let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?;
+ let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing target"))?;
let place = match str_param(p, "place").unwrap_or("inside") {
"above" => Place::Above,
"below" => Place::Below,
@@ -831,8 +834,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result {
fn move_node(s: &mut Session, p: &Value) -> Result {
const C: &str = "node.move";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
- let parent = id_param(p, "parent");
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
+ let parent = object_param(p, "parent", C)?;
let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index);
let d = &s.doc()?.doc;
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs
index 464f241159..618865cede 100644
--- a/crates/engine/src/cmd/live.rs
+++ b/crates/engine/src/cmd/live.rs
@@ -21,7 +21,7 @@ use super::expand::gradient_sampler;
use super::pathops::Beside;
use super::{
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_param, f64_or, f64_req, finite_numbers, has_doc, has_multi,
- has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, text_param,
+ has_selection, named_ids, number_or, object_param, ok, str_param, switch_or, text_param,
};
mod blend;
diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs
index 1aceb49b54..3412023280 100644
--- a/crates/engine/src/cmd/live/envelope.rs
+++ b/crates/engine/src/cmd/live/envelope.rs
@@ -3,7 +3,7 @@ use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wr
use super::mesh::{handle_param, index_param, whole};
use super::{
Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint,
- PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param,
+ PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, json, live, object_param, ok, switch_or, text_param,
};
// ---------- Envelope ----------
@@ -419,7 +419,7 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result {
pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result {
const C: &str = "object.envelope.setMeshPoint";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let index = index_param(p, "index", C)?;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = handle_param(p, C)?;
diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs
index 70c2d9f348..187d2149f2 100644
--- a/crates/engine/src/cmd/live/mesh.rs
+++ b/crates/engine/src/cmd/live/mesh.rs
@@ -4,7 +4,7 @@ use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selec
use super::envelope::{outline_of, rows_cols};
use super::{
Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad,
- check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param,
+ check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, json, live, object_param, ok, str_param,
};
// ---------- Gradient mesh ----------
@@ -115,7 +115,7 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols:
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
/// envelope's grid too (see [`edit_grid`]).
fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result {
- let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
+ let id = object_param(p, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?;
s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f))
}
@@ -147,7 +147,7 @@ const MAX_POINTS: usize = 1 << 20;
/// points}…]}`. Each mesh comes once, with its points in order and without repeats.
fn point_items(p: &Value, cmd: &str) -> Result)>> {
let one = |q: &Value| -> Result<(NodeId, BTreeSet)> {
- let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
+ let id = object_param(q, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?;
let points = match q.get("points") {
None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]),
Some(Value::Array(v)) if v.len() <= MAX_POINTS => v
diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs
index 712ca8a179..518b2062f8 100644
--- a/crates/engine/src/cmd/path.rs
+++ b/crates/engine/src/cmd/path.rs
@@ -12,7 +12,7 @@ use super::draw2::usize_opt;
use super::pathops::Beside;
use super::{
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path,
- has_selection, id_param, ids_param, index, number_or, ok, str_param, switch_or, targets,
+ has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -168,7 +168,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<
}
fn append_anchor(s: &mut Session, p: &Value) -> Result {
- let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
+ let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = anchor_from_json(p, "path.appendAnchor")?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
s.edit("Pen", |d, sel| {
@@ -204,7 +204,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result {
/// Close the active open subpath of `id`, optionally setting the incoming handle of the first anchor.
fn close(s: &mut Session, p: &Value) -> Result {
- let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
+ let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
let h_in = match p.get("in").filter(|h| !h.is_null()) {
None => None,
@@ -297,7 +297,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
}
fn set_handle(s: &mut Session, p: &Value) -> Result {
- let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
+ let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?;
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
let out = str_param(p, "which") != Some("in");
@@ -315,7 +315,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result {
/// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point
/// tool's Shift-drag).
fn move_anchor(s: &mut Session, p: &Value) -> Result {
- let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
+ let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
@@ -335,7 +335,7 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result {
}
fn set_anchors(s: &mut Session, p: &Value) -> Result {
- let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?;
+ let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?;
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
let data = PathData::new(
subs.iter()
@@ -1556,7 +1556,7 @@ fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usiz
}
fn reshape(s: &mut Session, p: &Value) -> Result {
- let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
+ let id = object_param(p, "id", "path.reshape")?.ok_or_else(|| bad("path.reshape", "missing id"))?;
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?;
let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
@@ -1678,7 +1678,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result {
}
fn insert_anchor(s: &mut Session, p: &Value) -> Result {
- let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
+ let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs
index 06ec067934..787d48e441 100644
--- a/crates/engine/src/cmd/slices.rs
+++ b/crates/engine/src/cmd/slices.rs
@@ -13,7 +13,7 @@ use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKin
use vectorcraft_geom::{Rect, shapes};
use super::{
- CommandSpec, EngineError, NodeId, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ids_param, index,
+ CommandSpec, EngineError, NodeId, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, ids_param, index, object_param,
switch_or, targets,
};
@@ -679,7 +679,7 @@ fn create(s: &mut Session, p: &Value) -> Result {
fn set_rect(s: &mut Session, p: &Value) -> Result {
const C: &str = "object.slice.setRect";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let r = rect_param(p, C)?;
let d = &s.doc()?.doc;
if d.slice(id).is_none() {
diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs
index 21ed6d8023..4bcbd6c932 100644
--- a/crates/engine/src/cmd/textedit.rs
+++ b/crates/engine/src/cmd/textedit.rs
@@ -11,8 +11,8 @@ use super::menucmds::whole_or;
use super::stroke::StrokeChange;
use super::typecmd::refresh_bounds;
use super::{
- CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param,
- index, number_or, point_param, str_param, switch_or, targets, text_param,
+ CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, index,
+ number_or, object_param, point_param, str_param, switch_or, targets, text_param,
};
pub fn specs() -> Vec {
@@ -272,7 +272,7 @@ fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> {
fn edit_range(s: &mut Session, p: &Value) -> Result {
const C: &str = "text.editRange";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len, C)?;
@@ -337,7 +337,7 @@ fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result Result {
const C: &str = "text.startList";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let at = offset_or(p, "at", len, C)?.min(len);
@@ -365,7 +365,7 @@ fn start_list(s: &mut Session, p: &Value) -> Result {
}
fn get_range(s: &mut Session, p: &Value) -> Result {
- let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?;
+ let id = object_param(p, "id", "text.getRange")?.ok_or_else(|| bad("text.getRange", "missing `id`"))?;
if switch_or(p, "story", false, "text.getRange")? {
return get_story(s, p, id);
}
@@ -1254,7 +1254,7 @@ fn drawn_glyph(
fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result {
const C: &str = "text.setGlyphAlternate";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let gid = match p.get("gid") {
None => return Err(bad(C, "missing `gid` (a glyph id, or null for the default glyph)")),
Some(Value::Null) => None,
@@ -1344,7 +1344,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result {
fn glyph_alternates(s: &mut Session, p: &Value) -> Result {
const C: &str = "text.glyphAlternates";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let (ch, bytes, face) = glyph_target(&t, p, C)?;
let default = face.glyph_for(ch);
@@ -1359,7 +1359,7 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result {
fn touch_type(s: &mut Session, p: &Value) -> Result {
const C: &str = "text.touchType";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
check_numbers(p, &["start"], C)?;
let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?;
if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
@@ -1585,7 +1585,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result {
fn create_in_path(s: &mut Session, p: &Value) -> Result {
const C: &str = "text.createInPath";
- let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
+ let pid = object_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
let on_path = match str_param(p, "mode").unwrap_or("area") {
"area" => false,
"onPath" | "path" => true,
@@ -1975,7 +1975,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result {
fn discard_empty(s: &mut Session, p: &Value) -> Result {
const C: &str = "text.discardEmpty";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
if edit::runs_len(&text_ref(s, id)?.runs) > 0 {
return Ok(json!({"removed": false}));
}
diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs
index 6d29550fa0..bf5de3a0cb 100644
--- a/crates/engine/src/cmd/typecmd.rs
+++ b/crates/engine/src/cmd/typecmd.rs
@@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2};
use super::edit::selected_roots;
use super::pathops::{length_given, number_given, shape_node};
use super::{
- CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok,
- str_param, switch_or, targets, text_param,
+ CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param,
+ ok, str_param, switch_or, targets, text_param,
};
pub fn specs() -> Vec {
@@ -1222,7 +1222,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result {
};
let mut items = Vec::with_capacity(raw.len());
for it in raw {
- let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(it, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let mut refs = super::select::checked_refs(it.get("anchors"), C)?;
refs.sort_unstable();
refs.dedup();
@@ -1253,7 +1253,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result {
fn reshape_path(s: &mut Session, p: &Value) -> Result {
const C: &str = "text.reshapePath";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let mut refs = super::select::checked_refs(p.get("anchors"), C)?;
refs.sort_unstable();
refs.dedup();
@@ -1284,7 +1284,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result {
fn set_path_handle(s: &mut Session, p: &Value) -> Result {
const C: &str = "text.setPathHandle";
- let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
+ let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?;
let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in");
diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs
index c79ec0b86e..218871972f 100644
--- a/crates/engine/src/cmd/wand.rs
+++ b/crates/engine/src/cmd/wand.rs
@@ -8,7 +8,7 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::select::selectable_ids;
-use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, str_param};
+use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, object_param, str_param};
/// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in
/// points, opacity in percent.
@@ -152,7 +152,7 @@ fn paintable(doc: &Document) -> Vec {
}
fn magic_wand(s: &mut Session, p: &Value) -> Result {
- let id = id_param(p, "id").ok_or_else(|| bad("select.magicWand", "missing id"))?;
+ let id = object_param(p, "id", "select.magicWand")?.ok_or_else(|| bad("select.magicWand", "missing id"))?;
let st = s.doc()?;
let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone();
let w = s.menu.wand.clone();
diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs
index b0345db4ff..6850715b0e 100644
--- a/crates/engine/src/tests/params.rs
+++ b/crates/engine/src/tests/params.rs
@@ -400,6 +400,90 @@ fn stroke_set_refuses_an_unknown_key() {
}
}
+/// An object id given as something other than a whole number (`"5"`, `-1`) is refused as such,
+/// naming its key, not as a missing id; an id left out is still a missing one.
+#[test]
+fn a_malformed_object_id_is_not_reported_as_missing() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
+ let undo = |s: &Session| s.doc().unwrap().history.undo.len();
+ let before = undo(&s);
+ let points = json!([[0, 0], [10, 10], [20, 0]]);
+ for bad in [json!("5"), json!(-1)] {
+ let id = || json!({ "id": bad });
+ let with = |extra: serde_json::Value| {
+ let mut p = id();
+ p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
+ p
+ };
+ for (cmd, p, key) in [
+ ("stroke.widthPoint.set", id(), "id"),
+ ("stroke.widthPoint.copy", id(), "id"),
+ ("stroke.widthPoint.remove", id(), "id"),
+ ("path.curvatureEdit", id(), "id"),
+ ("path.removeAnchor", id(), "id"),
+ ("path.convertAnchor", id(), "id"),
+ ("path.reshapeSegment", id(), "id"),
+ ("path.split", id(), "id"),
+ ("path.appendAnchor", id(), "id"),
+ ("path.close", id(), "id"),
+ ("path.setHandle", id(), "id"),
+ ("path.moveAnchor", id(), "id"),
+ ("path.setAnchors", id(), "id"),
+ ("path.reshape", id(), "id"),
+ ("path.insertAnchor", id(), "id"),
+ ("path.freehand", json!({"points": points, "extend": {"id": bad}}), "id"),
+ ("path.freehand", json!({"points": points, "redraw": {"id": bad}}), "id"),
+ ("document.node", id(), "id"),
+ ("layer.target", id(), "id"),
+ ("layer.setCurrent", id(), "id"),
+ ("layer.selectAll", id(), "id"),
+ ("layer.move", with(json!({"target": 1})), "id"),
+ ("layer.move", json!({"ids": [1], "target": bad}), "target"),
+ ("node.move", id(), "id"),
+ ("node.move", json!({"id": 1, "parent": bad}), "parent"),
+ ("text.reshapeArea", with(json!({"anchors": []})), "id"),
+ ("text.reshapeArea", json!({"items": [{"id": bad, "anchors": []}]}), "id"),
+ ("text.reshapePath", id(), "id"),
+ ("text.setPathHandle", id(), "id"),
+ ("text.editRange", id(), "id"),
+ ("text.startList", id(), "id"),
+ ("text.getRange", id(), "id"),
+ ("text.setGlyphAlternate", id(), "id"),
+ ("text.glyphAlternates", id(), "id"),
+ ("text.touchType", id(), "id"),
+ ("text.discardEmpty", id(), "id"),
+ ("text.createInPath", json!({"path": bad}), "path"),
+ ("object.envelope.setMeshPoint", id(), "id"),
+ ("object.mesh.setPointColor", with(json!({"index": 0})), "id"),
+ ("object.mesh.movePoint", with(json!({"index": 0, "x": 0, "y": 0})), "id"),
+ ("object.mesh.movePoints", json!({"items": [{"id": bad, "index": 0}]}), "id"),
+ ("object.mesh.addLine", with(json!({"x": 0, "y": 0})), "id"),
+ ("object.mesh.deletePoint", with(json!({"index": 0})), "id"),
+ ("object.slice.setRect", id(), "id"),
+ ("appearance.transfer", json!({"source": bad, "target": 1}), "source"),
+ ("appearance.transfer", json!({"source": 1, "target": bad}), "target"),
+ ("appearance.copyFrom", json!({"source": bad}), "source"),
+ ("livePaint.strokeEdge", json!({"point": [0, 0], "group": bad}), "group"),
+ ("livePaint.fill", json!({"point": [0, 0], "group": bad}), "group"),
+ ("livePaint.info", json!({"group": bad}), "group"),
+ ("livePaint.info", id(), "id"),
+ ("select.magicWand", id(), "id"),
+ ] {
+ let e = s.execute(cmd, &p).unwrap_err().to_string();
+ // `layer.move` checks an `id` that stands in for `ids` before it runs.
+ let named = e.contains(&format!("`{key}` must be a whole number")) || (cmd == "layer.move" && e.contains("invalid object id"));
+ assert!(named, "{cmd} {p}: {e}");
+ }
+ }
+ for cmd in ["path.close", "layer.target", "text.reshapePath", "object.mesh.movePoint", "select.magicWand"] {
+ let e = s.execute(cmd, &json!({})).unwrap_err().to_string();
+ assert!(e.contains("missing"), "{cmd}: {e}");
+ }
+ assert_eq!(undo(&s), before);
+}
+
/// A command that takes the keys of one that isn't checked yet isn't checked either.
#[test]
fn a_reference_to_an_unchecked_command_is_unchecked() {
diff --git a/docs/mcp.md b/docs/mcp.md
index 5826c5c8c4..892541c6a8 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -430,7 +430,13 @@ or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseT
counterparts, `image.info`, `links.info`, `links.goTo`, `links.unembed`, `shaper.release`, `shaper.expand`,
`transparency.editOpacityMask`, `transparency.viewOpacityMask`, `symbol.edit`, the `object.blend.spine.*` commands
and `path.curvature`) refuse an `id` that is not a non-negative integer (`"abc"`, `"5"`, `true`) with an error and
-change nothing; an omitted or null `id` keeps the default. A command that leaves out an object selected inside a
+change nothing; an omitted or null `id` keeps the default. Commands that need an object id (`document.node`, the
+`path.*` anchor and handle commands, `text.reshapePath`, `text.editRange`, the `object.mesh.*` point commands,
+`layer.target`, `node.move`, `select.magicWand` and the others like them, and the `source`, `target`, `group` and
+`path` keys of `appearance.transfer`, `appearance.copyFrom`, `layer.move`, the `livePaint.*` commands and
+`text.createInPath`) refuse a value that is not a non-negative integer with "`id` must be a whole number", naming the
+key, and report a key left out or null as missing. The optional `group` of `livePaint.fill` and `parent` of
+`node.move` are refused the same way when given; left out, they keep their defaults. A command that leaves out an object selected inside a
selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes
`ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value
that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key,
From 133e2c135695b54396aa102acfe70f7a0c8c22ff Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 13:17:08 -0800
Subject: [PATCH 08/26] MCP: livePaint.info reads id when group is left out
The docs said every livePaint key left out is reported as missing. livePaint.info reads `id` when `group` is left out or null, and reports a missing group only when both are left out.
---
docs/mcp.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/mcp.md b/docs/mcp.md
index 892541c6a8..6287f5b3a7 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -436,7 +436,8 @@ change nothing; an omitted or null `id` keeps the default. Commands that need an
`path` keys of `appearance.transfer`, `appearance.copyFrom`, `layer.move`, the `livePaint.*` commands and
`text.createInPath`) refuse a value that is not a non-negative integer with "`id` must be a whole number", naming the
key, and report a key left out or null as missing. The optional `group` of `livePaint.fill` and `parent` of
-`node.move` are refused the same way when given; left out, they keep their defaults. A command that leaves out an object selected inside a
+`node.move` are refused the same way when given; left out, they keep their defaults. `livePaint.info` reads `id` when
+`group` is left out or null, and reports a missing group only when both are left out. A command that leaves out an object selected inside a
selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes
`ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value
that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key,
From e05f40d7f8c8023408b96a5166e22025352524f6 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 14:41:24 -0800
Subject: [PATCH 09/26] 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 10/26] =?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 27d63af68a0ce446c6375ead2d5390c419f2f1cb Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 21:50:45 -0800
Subject: [PATCH 11/26] HP-GL export: insideFirst cuts the paths inside a shape
before the shape
`insideFirst: true` (default false) draws each pen's path only after
the pen's paths that lie inside it: a letter's counter before the
letter, a part's holes before the part. Without `sortPaths` the paths
otherwise keep paint order; with it, the next path is the nearest of
those free to draw.
Touching outlines count as inside; crossing ones, and copies of one
outline, do not. Finding which paths lie inside which shares the plot's
work budget with sorting, and each case that runs out of it adds a
warning.
Docs: docs/mcp.md (HP-GL export), docs/target-app-parity.md.
---
crates/cad/src/hpgl/write.rs | 146 +++++++--
crates/cad/src/hpgl/write/nest.rs | 357 +++++++++++++++++++++
crates/cad/src/hpgl/write/tests.rs | 136 ++++++++
crates/engine/src/cmd/fileio/commands.rs | 2 +-
crates/engine/src/cmd/fileio/hpgl.rs | 7 +
crates/engine/src/cmd/fileio/tests_hpgl.rs | 22 +-
docs/mcp.md | 19 +-
docs/target-app-parity.md | 1 +
8 files changed, 654 insertions(+), 36 deletions(-)
create mode 100644 crates/cad/src/hpgl/write/nest.rs
diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs
index 9e77495aa2..b38abd005c 100644
--- a/crates/cad/src/hpgl/write.rs
+++ b/crates/cad/src/hpgl/write.rs
@@ -16,15 +16,19 @@
//! its center, rounded to whole units. Art past ±8,388,607 units (2²³ − 1, the smallest range
//! the HP-GL/2 reference asks a device to take for encoded polylines) from the origin is cut off.
//! The pens are drawn in turn (pen 1's paths, then pen 2's), each in paint order or, with
-//! `sort`, each next path the one that starts nearest where the pen stopped.
+//! `sort`, each next path the one that starts nearest where the pen stopped. With `inside_first`,
+//! a pen draws a path only after every path of its own that lies inside it (a letter's counter
+//! before the letter, a hole before the part).
//!
//! HP-GL writes `IN;`, then for each pen `PU;SP;` and per path `PUx,y;PDx,y,…;`, then `PU;SP0;`.
//! HP-GL/2 also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's paths
//! as one encoded polyline (`PE`, base 64).
mod clip;
+mod nest;
-use std::collections::HashSet;
+use std::cmp::Reverse;
+use std::collections::{BinaryHeap, HashSet};
use std::sync::Arc;
use kurbo::PathEl;
@@ -47,7 +51,8 @@ pub const TOLERANCE_RANGE: std::ops::RangeInclusive = 0.01..=10.0;
pub const MAX_PENS: usize = 255;
/// The most points the plots of one export hold.
const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 10_000_000 };
-/// The most point comparisons sorting may make, over the whole plot.
+/// The most work sorting paths and finding which lie inside others may take, over the whole plot:
+/// one for each point or box compared.
const MAX_SORT_WORK: u64 = if cfg!(test) { 100_000 } else { 500_000_000 };
/// The most edge tests clipping masks may take, over the whole plot.
const MAX_CLIP_TESTS: u64 = if cfg!(test) { 1_000_000 } else { 200_000_000 };
@@ -69,6 +74,8 @@ const MASK_POINTS: &str = "clipping masks past 10 million points together are le
const OUT_OF_RANGE: &str = "art past 8,388,607 plotter units (about 210 m) from the origin is cut off";
const MANY_COLORS: &str = "the art has more than 255 colors: the colors past those draw with the pen of the nearest color";
const UNSORTED: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order";
+const UNSORTED_INSIDE: &str = "the plot has too many paths to sort in full: the rest are drawn in paint order, each after the paths inside it";
+const UNNESTED: &str = "the plot has too many paths to find in full which lie inside others: from the pen where that stopped, a path may be drawn before the paths inside it";
const TOO_DEEP: &str = "symbols, blends, envelopes and repeats nested more than 8 deep were left out";
const LIVE_LEFT_OUT: &str = "blends, envelopes and repeats make too many objects: the art past 4 million objects was left out";
const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects: the instances past that were left out";
@@ -144,6 +151,8 @@ pub struct HpglOptions {
pub origin: Origin,
/// Draw each pen's paths nearest first, instead of in paint order.
pub sort: bool,
+ /// Draw a path only after the pen's paths that lie inside it.
+ pub inside_first: bool,
/// The area plotted (document space): its bottom-left corner or center is the origin, and the
/// art is cut at its edges.
pub region: Rect,
@@ -151,7 +160,15 @@ pub struct HpglOptions {
impl Default for HpglOptions {
fn default() -> Self {
- Self { version: Version::default(), tolerance: DEFAULT_TOLERANCE, pens: vec![], origin: Origin::default(), sort: false, region: Rect::ZERO }
+ Self {
+ version: Version::default(),
+ tolerance: DEFAULT_TOLERANCE,
+ pens: vec![],
+ origin: Origin::default(),
+ sort: false,
+ inside_first: false,
+ region: Rect::ZERO,
+ }
}
}
@@ -251,14 +268,10 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R
if !by_pen.iter().all(Vec::is_empty) {
pen_warnings.iter().for_each(|w| warn(&mut warnings, w));
}
- if opts.sort {
+ if opts.sort || opts.inside_first {
let (mut at, mut work) = ((0, 0), MAX_SORT_WORK);
for paths in &mut by_pen {
- let (stop, sorted) = nearest_first(paths, at, &mut work);
- at = stop;
- if !sorted {
- warn(&mut warnings, UNSORTED);
- }
+ at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings);
}
}
let bytes = match opts.version {
@@ -333,25 +346,50 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 {
dx * dx + dy * dy
}
-/// `paths` reordered from `at`: each next path is the one nearest where the pen stopped, by its
-/// start or, for an open path (then drawn backwards), its end, or for a closed path any of its
-/// points (where it then starts). Comparing points stops once `work` is spent; the paths left keep
-/// their order. Returns where the pen stops, and `false` when the work ran out.
-fn nearest_first(paths: &mut Vec>, mut at: (i64, i64), work: &mut u64) -> ((i64, i64), bool) {
- // Each path with its box.
- let mut left: Vec<_> = std::mem::take(paths)
- .into_iter()
- .map(|p| {
- let b = p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)]);
- (p, b)
- })
- .collect();
+/// `paths` (one pen's) reordered from `at`. With `nearest`, each next path is the one nearest
+/// where the pen stopped, by its start or, for an open path (then drawn backwards), its end, or
+/// for a closed path any of its points (where it then starts); without, the first in paint order.
+/// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is.
+/// Once `work` is spent, the paths left keep paint order, each after the paths inside it with
+/// `inside_first`, with a warning. Returns where the pen stops.
+fn reorder(
+ paths: &mut Vec>,
+ mut at: (i64, i64),
+ work: &mut u64,
+ nearest: bool,
+ inside_first: bool,
+ warnings: &mut Vec,
+) -> (i64, i64) {
+ let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect();
+ // The paths each path lies inside, and how many paths inside each are still to draw.
+ let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten();
+ if inside_first && outer.is_none() {
+ warn(warnings, UNNESTED);
+ }
+ let mut waiting = vec![0usize; paths.len()];
+ for o in outer.iter().flatten().flatten() {
+ if let Some(w) = waiting.get_mut(*o) {
+ *w += 1;
+ }
+ }
+ if !nearest {
+ if let Some(outer) = &outer {
+ let all = std::mem::take(paths).into_iter().enumerate().collect();
+ inner_first(all, outer, &mut waiting, paths);
+ }
+ return at;
+ }
+ // Each path with its index and box.
+ let mut left: Vec<_> = std::mem::take(paths).into_iter().zip(boxes).enumerate().map(|(i, (p, b))| (i, p, b)).collect();
while !left.is_empty() {
// (index, reversed, the closed path's point to start at, distance)
- let mut best: (usize, bool, Option, i128) = (0, false, None, i128::MAX);
- for (i, (p, b)) in left.iter().enumerate() {
+ let mut best: (usize, bool, Option, i128) = (usize::MAX, false, None, i128::MAX);
+ for (i, (j, p, b)) in left.iter().enumerate() {
let (Some(&s), Some(&e)) = (p.first(), p.last()) else { continue };
*work = work.saturating_sub(1);
+ if waiting.get(*j).is_some_and(|w| *w > 0) {
+ continue;
+ }
if s == e && p.len() > 2 {
if box_dist2(at, *b) >= best.3 {
continue;
@@ -372,11 +410,27 @@ fn nearest_first(paths: &mut Vec>, mut at: (i64, i64), work: &mu
best = (i, true, None, de);
}
}
- if *work == 0 {
- paths.extend(left.into_iter().map(|(p, _)| p));
- return (at, false);
+ // Spent, or no path free to draw (which only a fault could cause): the rest in paint
+ // order, each after the paths inside it.
+ if *work == 0 || best.0 >= left.len() {
+ match &outer {
+ Some(outer) => {
+ warn(warnings, UNSORTED_INSIDE);
+ inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths);
+ }
+ None => {
+ warn(warnings, UNSORTED);
+ paths.extend(left.into_iter().map(|(_, p, _)| p));
+ }
+ }
+ return at;
+ }
+ let (j, mut p, _) = left.remove(best.0);
+ for o in outer.as_ref().and_then(|outer| outer.get(j)).into_iter().flatten() {
+ if let Some(w) = waiting.get_mut(*o) {
+ *w = w.saturating_sub(1);
+ }
}
- let (mut p, _) = left.remove(best.0);
if best.1 {
p.reverse();
} else if let Some(k) = best.2 {
@@ -393,7 +447,39 @@ fn nearest_first(paths: &mut Vec>, mut at: (i64, i64), work: &mu
}
paths.push(p);
}
- (at, true)
+ at
+}
+
+/// Appends `left` (each path with its index among the pen's paths) to `paths` in paint order,
+/// each after the paths lying inside it: `outer` lists the paths each lies inside, and `waiting`
+/// counts, for each, the paths inside it still to draw. [`nest::outer`] puts a path only inside
+/// larger ones, so every path comes free; a path left waiting by a fault is drawn last.
+fn inner_first(left: Vec<(usize, Vec<(i64, i64)>)>, outer: &[Vec], waiting: &mut [usize], paths: &mut Vec>) {
+ let mut held: Vec>> = (0..outer.len()).map(|_| None).collect();
+ let mut free = BinaryHeap::new();
+ for (j, p) in left {
+ if let Some(h) = held.get_mut(j) {
+ if waiting.get(j).is_none_or(|w| *w == 0) {
+ free.push(Reverse(j));
+ }
+ *h = Some(p);
+ } else {
+ paths.push(p);
+ }
+ }
+ while let Some(Reverse(j)) = free.pop() {
+ let Some(p) = held.get_mut(j).and_then(Option::take) else { continue };
+ paths.push(p);
+ for &o in outer.get(j).into_iter().flatten() {
+ if let Some(w) = waiting.get_mut(o) {
+ *w = w.saturating_sub(1);
+ if *w == 0 {
+ free.push(Reverse(o));
+ }
+ }
+ }
+ }
+ paths.extend(held.into_iter().flatten());
}
/// HP-GL: `IN;`, each pen's paths as `PU` and `PD`, `PU;SP0;`.
diff --git a/crates/cad/src/hpgl/write/nest.rs b/crates/cad/src/hpgl/write/nest.rs
new file mode 100644
index 0000000000..eee4630ff3
--- /dev/null
+++ b/crates/cad/src/hpgl/write/nest.rs
@@ -0,0 +1,357 @@
+//! Which of a pen's paths lie inside which of its closed paths, in plotter units, for drawing the
+//! inner paths first.
+
+/// The box of `p`: `[x0, y0, x1, y1]`.
+pub(super) fn bounds(p: &[(i64, i64)]) -> [i64; 4] {
+ p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)])
+}
+
+/// For each of `paths` (`boxes` are their [`bounds`]), the closed paths it lies inside: none of
+/// its segments crosses the closed path's line, each part of it between the points where it meets
+/// that line lies inside the closed path (by the even-odd rule) or on the line, and at least one
+/// part lies inside. A path touching the line of the one around it is inside it, a path crossing
+/// it is not, and copies of one outline are not inside each other. A path is tested only against
+/// closed paths larger than it (by the area of their box, then the area they enclose, then paint
+/// order), so no two paths are each inside the other, and only against the closed paths in the
+/// [`Grid`] cell of its box's first corner. `None` once `work` is spent: a grid cell filled and a
+/// box compared cost one each, and each segment tested costs the closed path's points for its
+/// crossings and again for each part of it.
+pub(super) fn outer(paths: &[Vec<(i64, i64)>], boxes: &[[i64; 4]], work: &mut u64) -> Option>> {
+ let size: Vec<(i128, i128)> = paths
+ .iter()
+ .zip(boxes)
+ .map(|(p, b)| {
+ let box_area = (i128::from(b[2]) - i128::from(b[0])) * (i128::from(b[3]) - i128::from(b[1]));
+ (box_area, if closed(p) { twice_area(p) } else { 0 })
+ })
+ .collect();
+ let rings: Vec = (0..paths.len()).filter(|&i| paths.get(i).is_some_and(|p| closed(p))).collect();
+ let mut out = vec![vec![]; paths.len()];
+ if rings.is_empty() {
+ return Some(out);
+ }
+ let grid = Grid::new(&rings, boxes, work)?;
+ for (a, (p, ab)) in paths.iter().zip(boxes).enumerate() {
+ for &r in grid.at(ab[0], ab[1]) {
+ spend(work, 1)?;
+ let (Some(rb), Some(ring)) = (boxes.get(r), paths.get(r)) else { continue };
+ let smaller = (size.get(a), a) < (size.get(r), r);
+ if !smaller || rb[0] > ab[0] || rb[1] > ab[1] || rb[2] < ab[2] || rb[3] < ab[3] {
+ continue;
+ }
+ if inside(p, ring, work)?
+ && let Some(o) = out.get_mut(a)
+ {
+ o.push(r);
+ }
+ }
+ }
+ Some(out)
+}
+
+/// The most cells along each side of a [`Grid`].
+const MAX_GRID_SIDE: usize = 512;
+/// The most cells of a [`Grid`] a closed path is listed in; one whose box covers more is listed
+/// once, among the wide ones. A closed path has 4 points or more, so the grid lists no more
+/// entries than the pen has points.
+const MAX_CELLS_PER_PATH: usize = 4;
+
+/// A grid over the boxes of a pen's closed paths, about one cell for each, up to
+/// [`MAX_GRID_SIDE`] a side: each cell lists, in paint order, the closed paths whose box covers
+/// it, and the closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells are listed
+/// apart. A closed path whose box holds a point is among the wide ones or in that point's cell.
+struct Grid {
+ /// The first corner of the closed paths' boxes together.
+ x0: i128,
+ y0: i128,
+ /// Their width and height, plus one.
+ w: i128,
+ h: i128,
+ /// Cells along each side.
+ side: usize,
+ /// The cells row by row.
+ cells: Vec>,
+ /// The closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells.
+ wide: Vec,
+}
+
+impl Grid {
+ /// The grid of the closed paths `rings` (indices into `boxes`). `None` once `work` is spent.
+ fn new(rings: &[usize], boxes: &[[i64; 4]], work: &mut u64) -> Option {
+ let all = rings
+ .iter()
+ .filter_map(|&r| boxes.get(r))
+ .fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |e, b| [e[0].min(b[0]), e[1].min(b[1]), e[2].max(b[2]), e[3].max(b[3])]);
+ let side = ((rings.len() as f64).sqrt().ceil() as usize).clamp(1, MAX_GRID_SIDE);
+ let (x0, y0) = (i128::from(all[0]), i128::from(all[1]));
+ let mut grid = Self {
+ x0,
+ y0,
+ w: (i128::from(all[2]) - x0 + 1).max(1),
+ h: (i128::from(all[3]) - y0 + 1).max(1),
+ side,
+ cells: vec![vec![]; side * side],
+ wide: vec![],
+ };
+ for &r in rings {
+ let Some(b) = boxes.get(r) else { continue };
+ let (Some((cx0, cy0)), Some((cx1, cy1))) = (grid.cell(b[0], b[1]), grid.cell(b[2], b[3])) else { continue };
+ let covered = (cx1 - cx0 + 1) * (cy1 - cy0 + 1);
+ if covered > MAX_CELLS_PER_PATH {
+ spend(work, 1)?;
+ grid.wide.push(r);
+ continue;
+ }
+ spend(work, covered as u64)?;
+ for cy in cy0..=cy1 {
+ for cell in grid.cells.get_mut(cy * side + cx0..=cy * side + cx1).into_iter().flatten() {
+ cell.push(r);
+ }
+ }
+ }
+ Some(grid)
+ }
+
+ /// The column and row of the cell holding `(x, y)`, `None` outside the grid.
+ fn cell(&self, x: i64, y: i64) -> Option<(usize, usize)> {
+ let (dx, dy) = (i128::from(x) - self.x0, i128::from(y) - self.y0);
+ if dx < 0 || dy < 0 || dx >= self.w || dy >= self.h {
+ return None;
+ }
+ let side = self.side as i128;
+ Some(((dx * side / self.w) as usize, (dy * side / self.h) as usize))
+ }
+
+ /// The wide closed paths, then those listed in the cell holding `(x, y)`.
+ fn at(&self, x: i64, y: i64) -> impl Iterator- {
+ let cell = self.cell(x, y).and_then(|(cx, cy)| self.cells.get(cy * self.side + cx)).map_or(&[][..], Vec::as_slice);
+ self.wide.iter().chain(cell)
+ }
+}
+
+/// Takes `n` from `work`; `None`, with `work` at 0, when it has less.
+fn spend(work: &mut u64, n: u64) -> Option<()> {
+ let left = work.checked_sub(n);
+ *work = left.unwrap_or(0);
+ left.map(|_| ())
+}
+
+/// Does `p` start and end at one point, with three other points or more?
+fn closed(p: &[(i64, i64)]) -> bool {
+ p.len() > 3 && p.first() == p.last()
+}
+
+/// Twice the area the closed path `p` encloses.
+fn twice_area(p: &[(i64, i64)]) -> i128 {
+ let sum: i128 = p
+ .windows(2)
+ .map(|w| match w {
+ [a, b] => i128::from(a.0) * i128::from(b.1) - i128::from(b.0) * i128::from(a.1),
+ _ => 0,
+ })
+ .sum();
+ sum.abs()
+}
+
+/// Does `p` lie inside the closed path `ring`, as [`outer`] decides? `None` once `work` is spent.
+fn inside(p: &[(i64, i64)], ring: &[(i64, i64)], work: &mut u64) -> Option
{
+ let mut any = false;
+ for w in p.windows(2) {
+ let [s, e] = w else { continue };
+ spend(work, ring.len() as u64)?;
+ // Where the segment meets the line without crossing it: its ends and the ring's points on
+ // it. Between two of these the segment is all inside or all outside.
+ let mut stops = vec![*s, *e];
+ for edge in ring.windows(2) {
+ let [a, b] = edge else { continue };
+ if crosses((*s, *e), (*a, *b)) {
+ return Some(false);
+ }
+ if a != s && a != e && on((*s, *e), *a) {
+ stops.push(*a);
+ }
+ }
+ let along = |q: &(i64, i64)| {
+ (i128::from(q.0) - i128::from(s.0)) * (i128::from(e.0) - i128::from(s.0))
+ + (i128::from(q.1) - i128::from(s.1)) * (i128::from(e.1) - i128::from(s.1))
+ };
+ stops.sort_by_key(along);
+ stops.dedup();
+ for part in stops.windows(2) {
+ let [m, n] = part else { continue };
+ spend(work, ring.len() as u64)?;
+ // The part's middle, doubled so it is whole.
+ match locate((i128::from(m.0) + i128::from(n.0), i128::from(m.1) + i128::from(n.1)), ring) {
+ Some(false) => return Some(false),
+ Some(true) => any = true,
+ None => {}
+ }
+ }
+ }
+ Some(any)
+}
+
+/// Which side of the line through `a` and `b` `c` is on: 1 left, -1 right, 0 on it.
+fn side(a: (i64, i64), b: (i64, i64), c: (i64, i64)) -> i128 {
+ let d = (i128::from(b.0) - i128::from(a.0)) * (i128::from(c.1) - i128::from(a.1))
+ - (i128::from(b.1) - i128::from(a.1)) * (i128::from(c.0) - i128::from(a.0));
+ d.signum()
+}
+
+/// Do segments `u` and `v` cross at a point inside both, each passing from one side of the other
+/// to the other side?
+fn crosses(u: ((i64, i64), (i64, i64)), v: ((i64, i64), (i64, i64))) -> bool {
+ side(u.0, u.1, v.0) * side(u.0, u.1, v.1) < 0 && side(v.0, v.1, u.0) * side(v.0, v.1, u.1) < 0
+}
+
+/// Does `q` lie on segment `u`?
+fn on(u: ((i64, i64), (i64, i64)), q: (i64, i64)) -> bool {
+ let ((x0, y0), (x1, y1)) = u;
+ side(u.0, u.1, q) == 0 && q.0 >= x0.min(x1) && q.0 <= x0.max(x1) && q.1 >= y0.min(y1) && q.1 <= y0.max(y1)
+}
+
+/// Where the doubled point `q` lies against the closed path `ring`: `Some(true)` inside by the
+/// even-odd rule, `Some(false)` outside, `None` on its line.
+fn locate(q: (i128, i128), ring: &[(i64, i64)]) -> Option {
+ let mut inside = false;
+ for w in ring.windows(2) {
+ let [a, b] = w else { continue };
+ let (a, b) = ((2 * i128::from(a.0), 2 * i128::from(a.1)), (2 * i128::from(b.0), 2 * i128::from(b.1)));
+ // Which side of the segment's line `q` is on, 0 on it.
+ let side = (b.0 - a.0) * (q.1 - a.1) - (b.1 - a.1) * (q.0 - a.0);
+ if side == 0 && q.0 >= a.0.min(b.0) && q.0 <= a.0.max(b.0) && q.1 >= a.1.min(b.1) && q.1 <= a.1.max(b.1) {
+ return None;
+ }
+ // A segment crossing the level of `q` to its right.
+ if (a.1 > q.1) != (b.1 > q.1) && (side > 0) == (b.1 > a.1) {
+ inside = !inside;
+ }
+ }
+ Some(inside)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn square(x0: i64, y0: i64, x1: i64, y1: i64) -> Vec<(i64, i64)> {
+ vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]
+ }
+
+ fn nest(paths: &[Vec<(i64, i64)>]) -> Option>> {
+ let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
+ let mut work = u64::MAX;
+ outer(paths, &boxes, &mut work)
+ }
+
+ #[test]
+ fn points_inside_outside_and_on_the_line() {
+ let ring = square(0, 0, 10, 10);
+ assert_eq!(locate((10, 10), &ring), Some(true));
+ assert_eq!(locate((30, 10), &ring), Some(false));
+ assert_eq!(locate((10, -2), &ring), Some(false));
+ assert_eq!(locate((20, 10), &ring), None);
+ assert_eq!(locate((0, 0), &ring), None);
+ }
+
+ #[test]
+ fn a_path_inside_another_lies_inside_it_and_not_the_other_way() {
+ let paths = [square(0, 0, 100, 100), square(10, 10, 20, 20), vec![(30, 30), (40, 40)]];
+ assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0]]));
+ }
+
+ #[test]
+ fn touching_counts_as_inside_and_crossing_does_not() {
+ // A square with a corner on the outline's left edge, a triangle whose corners lie on the
+ // outline's edges, and a square that crosses the outline.
+ let paths = [square(0, 0, 100, 100), square(0, 40, 20, 60), vec![(0, 50), (50, 0), (100, 50), (0, 50)], square(90, 90, 120, 95)];
+ assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0], vec![]]));
+ }
+
+ /// Every point of a copy lies on the other's line, and none inside it.
+ #[test]
+ fn copies_of_one_outline_do_not_lie_inside_each_other() {
+ let paths = [square(0, 0, 10, 10), square(0, 0, 10, 10), vec![(10, 10), (10, 0), (0, 0), (0, 10), (10, 10)]];
+ assert_eq!(nest(&paths), Some(vec![vec![], vec![], vec![]]));
+ }
+
+ /// A line whose ends and middle lie inside an outline but which crosses a slot cut into it.
+ #[test]
+ fn a_line_across_a_slot_is_not_inside() {
+ let slotted = vec![(0, 0), (100, 0), (100, 100), (14, 100), (14, 40), (12, 40), (12, 100), (0, 100), (0, 0)];
+ assert_eq!(nest(&[slotted, vec![(10, 50), (40, 50)]]), Some(vec![vec![], vec![]]));
+ }
+
+ /// A line that leaves an outline and comes back through two of its corners, crossing none of
+ /// its sides, and one that runs along the bottom of a slot.
+ #[test]
+ fn a_line_through_corners_is_inside_only_where_it_stays_inside() {
+ let v_slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (15, 40), (10, 50), (10, 100), (0, 100), (0, 0)];
+ assert_eq!(nest(&[v_slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![]]));
+ let slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (10, 50), (10, 100), (0, 100), (0, 0)];
+ assert_eq!(nest(&[slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![0]]));
+ }
+
+ /// An outline that crosses itself encloses no area by its signed sum, yet a line in one of its
+ /// lobes lies inside it.
+ #[test]
+ fn a_line_inside_a_lobe_of_a_crossed_outline_lies_inside_it() {
+ let bow = vec![(0, 0), (100, 100), (0, 100), (100, 0), (0, 0)];
+ assert_eq!(twice_area(&bow), 0);
+ assert_eq!(nest(&[bow, vec![(40, 80), (60, 80)]]), Some(vec![vec![], vec![0]]));
+ }
+
+ #[test]
+ fn a_ring_inside_a_ring_inside_a_ring_lies_inside_both() {
+ let paths = [square(20, 20, 80, 80), square(0, 0, 100, 100), square(40, 40, 60, 60)];
+ assert_eq!(nest(&paths), Some(vec![vec![1], vec![], vec![0, 1]]));
+ }
+
+ /// 10,000 parts in rows, each with a hole: each hole is tested against the few closed paths
+ /// near it, not against every part.
+ #[test]
+ fn parts_in_rows_are_tested_only_against_the_closed_paths_near_them() {
+ let mut paths = vec![];
+ for row in 0..100 {
+ for col in 0..100 {
+ let (x, y) = (col * 30, row * 30);
+ paths.push(square(x, y, x + 20, y + 20));
+ paths.push(square(x + 5, y + 5, x + 15, y + 15));
+ }
+ }
+ let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
+ let mut work = 5_000_000;
+ let found = outer(&paths, &boxes, &mut work);
+ let want: Vec> = (0..paths.len()).map(|i| if i % 2 == 1 { vec![i - 1] } else { vec![] }).collect();
+ assert_eq!(found, Some(want));
+ }
+
+ /// 400 closed paths that each cover most of the grid are listed once each, not in every cell
+ /// they cover.
+ #[test]
+ fn closed_paths_covering_many_cells_are_listed_once() {
+ let mut paths: Vec> = (0..400).map(|i| square(i, i, 1000 + i, 1000 + i)).collect();
+ paths.extend((0..400).map(|i| {
+ let (x, y) = ((i % 20) * 50, (i / 20) * 50);
+ square(x, y, x + 10, y + 10)
+ }));
+ let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
+ let rings: Vec = (0..paths.len()).collect();
+ let mut work = u64::MAX;
+ let Some(grid) = Grid::new(&rings, &boxes, &mut work) else { panic!("the budget ran out") };
+ assert_eq!(grid.wide, (0..400).collect::>());
+ assert!(grid.cells.iter().map(Vec::len).sum::() <= 400 * MAX_CELLS_PER_PATH);
+ }
+
+ #[test]
+ fn finding_what_lies_inside_stops_at_its_budget() {
+ let paths: Vec> = (0..50).map(|i| square(-i, -i, 100 + i, 100 + i)).collect();
+ let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
+ let mut work = 1_000;
+ assert_eq!(outer(&paths, &boxes, &mut work), None);
+ assert_eq!(work, 0);
+ let mut work = u64::MAX;
+ let found = outer(&paths, &boxes, &mut work);
+ assert_eq!(found.map(|o| o.iter().map(Vec::len).sum::()), Some(50 * 49 / 2));
+ }
+}
diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs
index 2093fe8b48..6360cebdaf 100644
--- a/crates/cad/src/hpgl/write/tests.rs
+++ b/crates/cad/src/hpgl/write/tests.rs
@@ -819,6 +819,142 @@ fn sorting_stops_at_its_budget_and_keeps_every_path() {
assert_eq!(text(&out).matches("PD").count(), 600);
}
+/// `x` points in plotter units.
+fn plu(x: f64) -> i64 {
+ (x * 1016.0 / 72.0).round() as i64
+}
+
+/// The left edge of each path a plain HP-GL plot draws, in drawing order (plotter units).
+fn lefts(out: &HpglOutput) -> Vec {
+ let mut lefts: Vec = vec![];
+ for i in text(out).split_terminator(';') {
+ let xs = i.get(2..).unwrap_or_default().split(',').step_by(2).filter_map(|x| x.parse::().ok());
+ match i.get(..2) {
+ Some("PU") => lefts.extend(xs.min()),
+ Some("PD") => {
+ if let (Some(l), Some(x)) = (lefts.last_mut(), xs.min()) {
+ *l = (*l).min(x);
+ }
+ }
+ _ => {}
+ }
+ }
+ lefts
+}
+
+/// A letter O made as one path: its outline, then its counter.
+#[test]
+fn inside_first_cuts_a_letters_counter_before_its_outline() {
+ let d = doc_with(|d| {
+ let mut bp = rect(10.0, 10.0, 60.0, 90.0).to_bezpath();
+ bp.extend(shapes::ellipse(Rect::new(25.0, 30.0, 45.0, 70.0)).to_bezpath());
+ vec![filled(d, PathData::from_bezpath(&bp), Color::BLACK)]
+ });
+ assert_eq!(lefts(&plot(&d, &opts(&d))), [plu(10.0), plu(25.0)]);
+ assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [plu(25.0), plu(10.0)]);
+}
+
+/// A part drawn before its two holes: the holes are cut first, nearest first with `sort`.
+#[test]
+fn a_part_with_two_holes_is_cut_holes_first() {
+ let d = doc_with(|d| {
+ vec![
+ stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK),
+ stroked(d, shapes::ellipse(Rect::new(160.0, 40.0, 180.0, 60.0)), Color::BLACK),
+ stroked(d, shapes::ellipse(Rect::new(20.0, 40.0, 40.0, 60.0)), Color::BLACK),
+ ]
+ });
+ let (part, right, left) = (plu(10.0), plu(160.0), plu(20.0));
+ assert_eq!(lefts(&plot(&d, &opts(&d))), [part, right, left]);
+ assert_eq!(lefts(&plot(&d, &HpglOptions { inside_first: true, ..opts(&d) })), [right, left, part]);
+ // Sorting alone starts at the part's corner, nearest the origin; inside first, the nearest
+ // hole comes first, then the other, then the part.
+ assert_eq!(lefts(&plot(&d, &HpglOptions { sort: true, ..opts(&d) })), [part, left, right]);
+ let both = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) });
+ assert_eq!(lefts(&both), [left, right, part]);
+ assert!(both.warnings.is_empty(), "{:?}", both.warnings);
+}
+
+/// Rings inside rings are cut innermost first, and a ring with nothing inside it keeps its place
+/// in paint order.
+#[test]
+fn nested_rings_are_cut_innermost_first() {
+ let d = doc_with(|d| {
+ vec![
+ stroked(d, rect(120.0, 10.0, 190.0, 90.0), Color::BLACK),
+ stroked(d, rect(10.0, 10.0, 100.0, 90.0), Color::BLACK),
+ stroked(d, rect(20.0, 20.0, 90.0, 80.0), Color::BLACK),
+ stroked(d, rect(30.0, 30.0, 80.0, 70.0), Color::BLACK),
+ ]
+ });
+ let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) });
+ assert_eq!(lefts(&out), [plu(120.0), plu(30.0), plu(20.0), plu(10.0)]);
+}
+
+/// Each pen's paths are ordered among themselves: a red square inside a black part is drawn with
+/// the red pen, after the black part.
+#[test]
+fn inside_first_orders_each_pens_paths() {
+ let d = doc_with(|d| {
+ vec![
+ stroked(d, rect(10.0, 10.0, 190.0, 90.0), Color::BLACK),
+ stroked(d, rect(20.0, 20.0, 40.0, 40.0), Color::rgb8(255, 0, 0)),
+ stroked(d, rect(50.0, 20.0, 70.0, 40.0), Color::BLACK),
+ ]
+ });
+ let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) });
+ assert_eq!(lefts(&out), [plu(50.0), plu(10.0), plu(20.0)]);
+}
+
+/// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in
+/// paint order, with a warning.
+#[test]
+fn inside_first_past_its_budget_draws_in_paint_order() {
+ let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect());
+ let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) });
+ assert_eq!(out.warnings, [UNNESTED]);
+ let l = lefts(&out);
+ assert_eq!(l.len(), 100);
+ assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}");
+ // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings.
+ let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) });
+ assert_eq!(out.warnings, [UNNESTED, UNSORTED]);
+ assert_eq!(lefts(&out), l);
+}
+
+/// Sorting 600 lines inside two rings runs out of work: every path is drawn, the inner ring and
+/// then the outer one last.
+#[test]
+fn inside_first_past_its_sorting_budget_keeps_the_rings_last() {
+ let d = doc_with(|d| {
+ let mut v = vec![stroked(d, rect(0.5, 5.0, 150.0, 30.0), Color::BLACK), stroked(d, rect(0.7, 7.0, 140.0, 25.0), Color::BLACK)];
+ v.extend((0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)));
+ v
+ });
+ let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) });
+ assert_eq!(out.warnings, [UNSORTED_INSIDE]);
+ let l = lefts(&out);
+ assert_eq!(l.len(), 602);
+ assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..]));
+}
+
+/// Without `sort`, 1,000 lines inside a ring are drawn in paint order and the ring after them,
+/// within the budget.
+#[test]
+fn inside_first_alone_keeps_paint_order_for_many_paths() {
+ let d = doc_with(|d| {
+ let mut v = vec![stroked(d, rect(0.5, 5.0, 199.0, 30.0), Color::BLACK)];
+ v.extend((0..1000).map(|i| stroked(d, line((1.0 + 0.19 * f64::from(i), 10.0), (1.0 + 0.19 * f64::from(i), 20.0)), Color::BLACK)));
+ v
+ });
+ let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) });
+ assert!(out.warnings.is_empty(), "{:?}", out.warnings);
+ let l = lefts(&out);
+ assert_eq!(l.len(), 1001);
+ assert_eq!(l.last(), Some(&plu(0.5)));
+ assert!(l.get(..1000).is_some_and(|l| l.windows(2).all(|w| w[0] < w[1])), "{l:?}");
+}
+
#[test]
fn art_wholly_past_the_plotter_range_is_cut_off_with_a_warning() {
let d = doc_with(|d| vec![stroked(d, line((7e8, 50.0), (7e8 + 10.0, 50.0)), Color::BLACK)]);
diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs
index b518478464..d56e327228 100644
--- a/crates/engine/src/cmd/fileio/commands.rs
+++ b/crates/engine/src/cmd/fileio/commands.rs
@@ -36,7 +36,7 @@ pub fn specs() -> Vec {
"Export Document",
[],
None,
- "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path",
+ "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path",
has_doc,
export::export
),
diff --git a/crates/engine/src/cmd/fileio/hpgl.rs b/crates/engine/src/cmd/fileio/hpgl.rs
index 0016afa10e..154c9bce1d 100644
--- a/crates/engine/src/cmd/fileio/hpgl.rs
+++ b/crates/engine/src/cmd/fileio/hpgl.rs
@@ -48,6 +48,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[
default: "false",
description: "draw each pen's next path from the end nearest where the pen stopped, instead of in paint order",
},
+ FormatOption {
+ name: "insideFirst",
+ ty: "boolean",
+ default: "false",
+ description: "draw a path only after the pen's paths that lie inside it (a letter's counter before the letter, a hole before the part), the paths otherwise in paint order; with sortPaths, the nearest of the paths free to draw comes next",
+ },
];
/// The HP-GL options in `p` (the region is set per plot).
@@ -75,6 +81,7 @@ fn parse(p: &Value) -> Result {
o.origin = Origin::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Origin::ALL.map(Origin::id).join(", "))))?;
}
o.sort = switch_or(p, "sortPaths", false, C)?;
+ o.inside_first = switch_or(p, "insideFirst", false, C)?;
Ok(o)
}
diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs
index 1cb4c03b83..f4767fd81d 100644
--- a/crates/engine/src/cmd/fileio/tests_hpgl.rs
+++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs
@@ -102,7 +102,7 @@ fn the_dialogs_and_the_format_list_name_hpgl() {
let r = Session::new().execute("document.formats", &json!({})).unwrap();
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone();
assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(true), Some(false)));
- for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths"] {
+ for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths", "insideFirst"] {
assert!(f["options"].get(o).is_some(), "{o}: {f}");
}
assert_eq!(writable_format(None, Some("x/part.plt")).unwrap().id, "hpgl");
@@ -184,6 +184,7 @@ fn export_options_are_checked() {
(json!({"format": "hpgl", "pens": "#ff0000"}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
(json!({"format": "hpgl", "origin": "top"}), "`origin` must be one of bottomLeft, center"),
(json!({"format": "hpgl", "sortPaths": "yes"}), "`sortPaths` must be true or false"),
+ (json!({"format": "hpgl", "insideFirst": 1}), "`insideFirst` must be true or false"),
];
for (p, want) in cases {
let e = refused(&mut s, p.clone());
@@ -198,6 +199,25 @@ fn export_options_are_checked() {
assert_eq!(plot, "IN;PU;SP2;PU-141,141;PD-564,141,-564,564,-141,564,-141,141;PU;SP0;");
}
+/// `insideFirst` draws the square inside the other one first, though it was made last.
+#[test]
+fn inside_first_draws_the_inner_square_first() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
+ s.execute("paint.setFill", &json!({"none": true})).unwrap();
+ s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
+ s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap();
+ s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap();
+ let starts = |s: &mut Session, p: Value| {
+ let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap();
+ (plot.find("PU141,1270;").unwrap(), plot.find("PU423,988;").unwrap())
+ };
+ let (outer, inner) = starts(&mut s, json!({"format": "hpgl"}));
+ assert!(outer < inner);
+ let (outer, inner) = starts(&mut s, json!({"format": "hpgl", "insideFirst": true}));
+ assert!(inner < outer);
+}
+
/// Export Selection plots the selected square alone (the red one, made last), from the
/// bottom-left corner of its bounds.
#[test]
diff --git a/docs/mcp.md b/docs/mcp.md
index 4a73573b9b..ce29408fed 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -6388,20 +6388,31 @@ else its topmost visible fill's, where a fill or stroke at 0% opacity does not c
of its stops, a gradient mesh's the average of its points' colors (points at 0% opacity left out), and a pattern's its first visible solid fill (gray
when it has none). The pens draw in turn, each pen's paths in paint order, or with `sortPaths: true` each next path
the one that starts nearest where the pen stopped (an open path may be drawn from its other end, and a closed one
-starts at its point nearest the pen). Curves become straight moves no further than `curveTolerance` points from the
-curve (default 0.1, from 0.01 to 10). These defaults, the 255-pen limit and the rules for pen numbers and colors above
-are VectorCraft's own.
+starts at its point nearest the pen). With `insideFirst: true`, a path is drawn only after every path of the same
+pen that lies inside it, for example a letter's counter before the letter and a part's holes before the part. A
+path lies inside a closed path when it does not cross that path's line, every part of it lies inside that path (by
+the even-odd rule) or on its line, and some part lies inside: a path touching the line around it is inside, a path
+crossing it or leaving it through a corner is not, and copies of one outline are not inside each other. Without
+`sortPaths` the paths otherwise keep paint order; with it, the next path is the nearest of those free to draw.
+Curves become straight moves no further than `curveTolerance` points from the curve (default 0.1, from 0.01 to 10).
+These defaults, the 255-pen limit and the rules for pen numbers, colors and the order of paths above are
+VectorCraft's own.
`hpglVersion: "hpgl"` (the default) writes `IN;`, then for each pen `PU;SP;` and each path `PUx,y;PDx,y,…;`, and
ends with `PU;SP0;`. `"hpgl2"` also writes the palette (`NP` and a `PC` color for each pen) and draws each pen's
paths as one encoded polyline (`PE`, base 64), which takes fewer bytes than the same moves as text. `warnings` names
type drawn as outlines, inline graphics in type (left out), placed images, opacity masks, raster effects, stroke
looks that are not drawn, art past the plotter's range, colors past 255 pens, and the limits on points, clipping
-work, symbols, live art, nesting depth and sorting.
+work, symbols, live art, nesting depth and sorting. Sorting and finding which paths lie inside which share one
+work budget for the plot, 500 million box and point comparisons. When sorting runs out of it, the paths not yet
+drawn keep paint order, each after the paths inside it with `insideFirst`. When it runs out while the writer is
+finding which paths lie inside which, the paths of that pen and of the later pens keep paint order. Both cases add
+a warning.
```json
{"name":"export","arguments":{"path":"/tmp/decal.plt","format":"hpgl"}}
{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/sheet.plt","useArtboards":true,"hpglVersion":"hpgl2","pens":["#000000","#ff0000"],"sortPaths":true}}}
+{"name":"run_command","arguments":{"command":"document.export","params":{"format":"hpgl","path":"/tmp/stencil.plt","sortPaths":true,"insideFirst":true}}}
```
## TIFF, BMP and Targa export
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index d9265079ea..e4fa67b0eb 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1508,6 +1508,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`).
- HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`, or a name no other format reads when the file starts with plotter instructions) open and place as paths on one layer, the artboard round the art: each line drawn with the pen down is a stroked path in its pen's color and width, line types become dash patterns, circles, arcs, wedges and Bézier curves are curves, filled and edged rectangles, wedges and polygons (polygon mode) are filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Labels, symbol mode's symbols, raster images and hatching aren't drawn and are listed in the warnings (`crates/cad/src/hpgl/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`).
- Export As › HP-GL (`document.export {format: "hpgl"}`) writes a `.plt` plot of an artboard, each artboard or the art for pen plotters and cutters: each visible path once along its line in plotter units from the artboard's bottom-left corner or center, a pen per color or a pen table, curves flattened to a tolerance, type as outlines, clipping masks and the artboard's edges applied, and HP-GL or HP-GL/2 (pen colors and encoded polylines); plots read back by the HP-GL reader give the same lines (`crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`).
+ - HP-GL export's `insideFirst` cuts each pen's paths inside before outside: a path is drawn only after the pen's paths that lie inside it (a letter's counter, a part's holes, rings within rings), touching outlines counted as inside and crossing ones not, in paint order or nearest first with `sortPaths`, within the plot's work budget; these order rules are VectorCraft's own (`crates/cad/src/hpgl/write/nest.rs`, `crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
- TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`).
- 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`).
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 12/26] 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 ff826ed1bd81ce2401182274c59013e8d573ffe4 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 22:48:47 -0800
Subject: [PATCH 13/26] Type: Korean words break at spaces unless Korean Line
Break Anywhere is on
Korean text broke after every Hangul syllable. A paragraph now has
Korean Line Break Anywhere, set from the Paragraph panel menu (with the
East Asian options), from the East Asian Composition Settings section of
Paragraph Style Options, and with `text.setFormat {koreanLineBreakAnywhere}`.
- Off, a word that contains Hangul (the characters between spaces) breaks
only at its spaces and after East Asian punctuation, or where the word
is wider than the line. Both composers follow it, and the word takes no
hyphen. On, a line may break between any two syllables, as before.
- New type has it off. Paragraphs and text objects saved before the
option read as on, so their lines do not move; the value is saved only
when it is off.
- Paragraph styles carry it, Reset Panel turns it off, and the .ai writer
reports it as lost for Korean paragraphs.
The help page: https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/set-line-breaks-korean-text.html
The command is described in docs/mcp.md, and docs/target-app-parity.md
lists the option.
---
crates/doc/src/text.rs | 2 +-
crates/doc/src/text/para_style.rs | 20 ++++
crates/doc/src/text/tests.rs | 23 +++++
crates/engine/src/cmd/create.rs | 4 +-
crates/engine/src/cmd/panelcmds.rs | 7 +-
crates/engine/src/cmd/textstyles.rs | 1 +
crates/engine/src/cmd/typecmd/area_tests.rs | 43 ++++++++
crates/engine/src/tests/textedit.rs | 1 +
crates/eps/src/ai_editing/tests.rs | 28 ++++++
crates/eps/src/ai_editing/writer/texts.rs | 5 +
crates/text/src/layout.rs | 2 +
crates/text/src/layout/breaking.rs | 14 ++-
crates/text/src/layout/flow.rs | 5 +
crates/text/src/layout/wrapping_tests.rs | 99 +++++++++++++++++++
crates/text/src/shape.rs | 31 +++++-
crates/text/src/tests.rs | 35 +++++++
.../ui-egui/src/dialogs/para_style_options.rs | 41 ++++++--
.../src/dialogs/para_style_options/tests.rs | 23 ++++-
crates/ui-egui/src/i18n/bg.tsv | 3 +
crates/ui-egui/src/i18n/bs.tsv | 3 +
crates/ui-egui/src/i18n/ca.tsv | 3 +
crates/ui-egui/src/i18n/cs.tsv | 3 +
crates/ui-egui/src/i18n/da.tsv | 3 +
crates/ui-egui/src/i18n/de.tsv | 3 +
crates/ui-egui/src/i18n/el.tsv | 3 +
crates/ui-egui/src/i18n/es.tsv | 3 +
crates/ui-egui/src/i18n/fi.tsv | 3 +
crates/ui-egui/src/i18n/fr.tsv | 3 +
crates/ui-egui/src/i18n/hi.tsv | 3 +
crates/ui-egui/src/i18n/hu.tsv | 3 +
crates/ui-egui/src/i18n/id.tsv | 3 +
crates/ui-egui/src/i18n/it.tsv | 3 +
crates/ui-egui/src/i18n/ja.tsv | 3 +
crates/ui-egui/src/i18n/ka.tsv | 3 +
crates/ui-egui/src/i18n/ko.tsv | 3 +
crates/ui-egui/src/i18n/nl.tsv | 3 +
crates/ui-egui/src/i18n/pl.tsv | 3 +
crates/ui-egui/src/i18n/pt-br.tsv | 3 +
crates/ui-egui/src/i18n/ru.tsv | 3 +
crates/ui-egui/src/i18n/sv.tsv | 3 +
crates/ui-egui/src/i18n/tr.tsv | 3 +
crates/ui-egui/src/i18n/uk.tsv | 3 +
crates/ui-egui/src/i18n/vi.tsv | 3 +
crates/ui-egui/src/i18n/zh-hans.tsv | 3 +
crates/ui-egui/src/i18n/zh-hant.tsv | 3 +
crates/ui-egui/src/panels/paragraph.rs | 19 +++-
crates/ui-egui/src/panels/paragraph/tests.rs | 33 +++++++
docs/mcp.md | 19 ++++
docs/target-app-parity.md | 3 +-
49 files changed, 522 insertions(+), 17 deletions(-)
diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs
index 05f9c29041..da845ba739 100644
--- a/crates/doc/src/text.rs
+++ b/crates/doc/src/text.rs
@@ -52,7 +52,7 @@ pub struct TextObject {
/// the object, so the runs (and their styles) stay the text itself.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ruby: Vec,
- #[serde(default)]
+ #[serde(default = "ParaStyle::omitted")]
pub para: ParaStyle,
/// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs
index f23de1d41f..bb3725072e 100644
--- a/crates/doc/src/text/para_style.rs
+++ b/crates/doc/src/text/para_style.rs
@@ -244,9 +244,21 @@ pub struct ParaStyle {
/// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with
/// kashidas before they widen their word spaces.
pub kashida: Kashida,
+ /// Korean Line Break Anywhere (Paragraph panel menu): a line may break between any two Hangul
+ /// syllables. Off, a Korean word (the characters between spaces, when any of them is Hangul)
+ /// breaks only at its spaces and punctuation, or where it is wider than the line. New type and
+ /// imported text have it off; paragraphs saved before it existed open with it on, the line
+ /// breaks they were laid out with.
+ pub korean_line_break_anywhere: bool,
}
impl ParaStyle {
+ /// The attributes a text object saved without `para` reads with: the defaults, with Korean
+ /// Line Break Anywhere on, as a saved paragraph without that field reads.
+ pub(crate) fn omitted() -> Self {
+ Self { korean_line_break_anywhere: true, ..Self::default() }
+ }
+
/// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to
/// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range
/// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is
@@ -330,6 +342,10 @@ struct ParaStyleFile {
roman_hanging: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kashida: Kashida,
+ /// Written only when off: a paragraph saved without it was laid out breaking between Hangul
+ /// syllables.
+ #[serde(default = "crate::yes", skip_serializing_if = "crate::skip::is_true")]
+ korean_line_break_anywhere: bool,
}
impl From for ParaStyleFile {
@@ -361,6 +377,7 @@ impl From for ParaStyleFile {
kinsoku_type,
roman_hanging,
kashida,
+ korean_line_break_anywhere,
..
} = p;
Self {
@@ -386,6 +403,7 @@ impl From for ParaStyleFile {
kinsoku_type,
roman_hanging,
kashida,
+ korean_line_break_anywhere,
}
}
}
@@ -415,6 +433,7 @@ impl From for ParaStyle {
kinsoku_type,
roman_hanging,
kashida,
+ korean_line_break_anywhere,
..
} = f;
Self {
@@ -439,6 +458,7 @@ impl From for ParaStyle {
kinsoku_type,
roman_hanging,
kashida,
+ korean_line_break_anywhere,
}
}
}
diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs
index aa403b4218..3b3781f87c 100644
--- a/crates/doc/src/text/tests.rs
+++ b/crates/doc/src/text/tests.rs
@@ -184,6 +184,29 @@ fn justification_options_read_from_a_file_stay_in_range() {
assert_eq!(same, d);
}
+/// Korean Line Break Anywhere is off for a new paragraph and saved only when off; a paragraph
+/// saved before it existed, or a text object saved without paragraph attributes, reads with it on,
+/// and both values read back as saved.
+#[test]
+fn korean_line_break_anywhere_is_saved_only_when_off() {
+ let off = ParaStyle::default();
+ assert!(!off.korean_line_break_anywhere);
+ let saved = serde_json::to_value(&off).unwrap();
+ assert_eq!(saved["korean_line_break_anywhere"], false);
+ assert_eq!(serde_json::from_value::(saved).unwrap(), off);
+ let on = ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() };
+ let saved = serde_json::to_value(&on).unwrap();
+ assert!(saved.get("korean_line_break_anywhere").is_none());
+ assert_eq!(serde_json::from_value::(saved).unwrap(), on);
+ let old: ParaStyle = serde_json::from_str(r#"{"justify":"Left"}"#).unwrap();
+ assert!(old.korean_line_break_anywhere);
+ let mut saved = serde_json::to_value(TextObject::point(Point::ZERO, "가나", CharStyle::default())).unwrap();
+ assert_eq!(saved["para"]["korean_line_break_anywhere"], false);
+ saved.as_object_mut().unwrap().remove("para");
+ let old: TextObject = serde_json::from_value(saved).unwrap();
+ assert_eq!(old.para, ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() });
+}
+
/// Numbered lists: each number style writes its numbers and rolls over (letters after Z,
/// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds
/// a period, and the count runs through list paragraphs, skips empty and bulleted ones and
diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs
index 3c829e8a3e..37473cc59f 100644
--- a/crates/engine/src/cmd/create.rs
+++ b/crates/engine/src/cmd/create.rs
@@ -783,12 +783,14 @@ fn text_create(s: &mut Session, p: &Value) -> Result {
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
/// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed
-/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own.
+/// with Line-end Punctuation Half Width and Standard burasagari, and Korean words kept whole
+/// (Korean Line Break Anywhere off). Imported text keeps its own.
pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
vectorcraft_doc::ParaStyle {
justify: vectorcraft_doc::Justify::Auto,
mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf,
burasagari: vectorcraft_doc::Burasagari::Standard,
+ korean_line_break_anywhere: false,
..Default::default()
}
}
diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs
index b8bdd24d05..83a0655284 100644
--- a/crates/engine/src/cmd/panelcmds.rs
+++ b/crates/engine/src/cmd/panelcmds.rs
@@ -67,7 +67,7 @@ pub fn specs() -> Vec {
"Character / Paragraph",
[],
None,
- "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)",
+ "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)",
has_doc,
set_format
),
@@ -482,6 +482,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result {
"hyphenate",
"hyphenateCapitalized",
"romanHangingPunctuation",
+ "koreanLineBreakAnywhere",
],
C,
)?;
@@ -552,6 +553,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result {
"fontAxes",
"burasagari",
"romanHangingPunctuation",
+ "koreanLineBreakAnywhere",
"kashida",
"composer",
];
@@ -879,6 +881,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result {
if let Some(v) = flag("romanHangingPunctuation") {
para.roman_hanging = v;
}
+ if let Some(v) = flag("koreanLineBreakAnywhere") {
+ para.korean_line_break_anywhere = v;
+ }
if let Some(v) = kashida {
para.kashida = v;
}
diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs
index d30e69df68..819da12b3a 100644
--- a/crates/engine/src/cmd/textstyles.rs
+++ b/crates/engine/src/cmd/textstyles.rs
@@ -243,6 +243,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map {
("kinsoku_type", json!(v.kinsoku_type)),
("roman_hanging", json!(v.roman_hanging)),
("kashida", json!(v.kashida)),
+ ("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)),
] {
m.insert(k.into(), x);
}
diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs
index 571c9c3469..5545e1be94 100644
--- a/crates/engine/src/cmd/typecmd/area_tests.rs
+++ b/crates/engine/src/cmd/typecmd/area_tests.rs
@@ -397,6 +397,49 @@ fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() {
assert!(text(&s).para_at(1).roman_hanging);
}
+/// Paragraph panel menu › Korean Line Break Anywhere: new type has it off and saves that;
+/// `text.setFormat {koreanLineBreakAnywhere}` sets it per paragraph in one undo step and refuses a
+/// value that is not true or false, a paragraph saved before the option opens with it on, and
+/// paragraph styles carry it. `text.setRangeStyle`, a character command, does not take it.
+#[test]
+fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
+ let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "가나다\n라마"})).unwrap()["id"].as_u64().unwrap();
+ let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
+ NodeKind::Text(t) => (**t).clone(),
+ _ => panic!("text"),
+ };
+ assert!(!text(&s).para_at(0).korean_line_break_anywhere, "new type has it off");
+ assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["korean_line_break_anywhere"], false);
+ s.execute("select.set", &json!({"ids": [id]})).unwrap();
+ let steps = s.doc().unwrap().history.undo.len();
+ // Only the first paragraph (the caret's range).
+ s.execute("text.setFormat", &json!({"koreanLineBreakAnywhere": true, "start": 0, "end": 0})).unwrap();
+ assert!(text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere);
+ assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
+ assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("korean_line_break_anywhere").is_none());
+ let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap();
+ assert!(back.korean_line_break_anywhere);
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert!(!text(&s).para_at(0).korean_line_break_anywhere);
+ s.execute("edit.redo", &json!({})).unwrap();
+ assert!(text(&s).para_at(0).korean_line_break_anywhere);
+ // Saved before the option: on, the line breaks it was laid out with.
+ let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
+ assert!(old.korean_line_break_anywhere);
+ assert!(s.execute("text.setFormat", &json!({"ids": [id], "koreanLineBreakAnywhere": "yes"})).is_err());
+ assert!(s.execute("text.setRangeStyle", &json!({"id": id, "koreanLineBreakAnywhere": false})).is_err());
+ assert!(text(&s).para_at(0).korean_line_break_anywhere, "a refused call changes nothing");
+ // A paragraph style carries it.
+ s.execute("paraStyle.new", &json!({"name": "Anywhere", "attrs": {"korean_line_break_anywhere": true}})).unwrap();
+ s.execute("paraStyle.apply", &json!({"name": "Anywhere", "id": id})).unwrap();
+ assert!(text(&s).para_at(1).korean_line_break_anywhere);
+ s.execute("paraStyle.new", &json!({"name": "Words", "attrs": {"korean_line_break_anywhere": false}})).unwrap();
+ s.execute("paraStyle.apply", &json!({"name": "Words", "id": id})).unwrap();
+ assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere);
+}
+
/// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one
/// undo step, the paragraph is saved with it only when it is not None and opens with it, an
/// unknown value changes nothing, and paragraph styles carry it.
diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs
index 9eea9cf456..961e77bd32 100644
--- a/crates/engine/src/tests/textedit.rs
+++ b/crates/engine/src/tests/textedit.rs
@@ -475,6 +475,7 @@ fn type_commands_refuse_a_number_or_boolean_given_as_text() {
("text.setFormat", json!({"ids": [id.0], "hyphenate": "false"}), "hyphenate"),
("text.setFormat", json!({"ids": [id.0], "hyphenateCapitalized": 0}), "hyphenateCapitalized"),
("text.setFormat", json!({"ids": [id.0], "romanHangingPunctuation": "true"}), "romanHangingPunctuation"),
+ ("text.setFormat", json!({"ids": [id.0], "koreanLineBreakAnywhere": 1}), "koreanLineBreakAnywhere"),
("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"),
("type.opticalMarginAlignment", json!({"on": "true"}), "on"),
("type.systemLayout", json!({"on": "true"}), "on"),
diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs
index f2ba516b46..9db5617c27 100644
--- a/crates/eps/src/ai_editing/tests.rs
+++ b/crates/eps/src/ai_editing/tests.rs
@@ -3785,6 +3785,34 @@ fn an_unstroked_run_with_a_stroke_style_loses_nothing() {
}
}
+/// The text document has no Korean Line Break Anywhere: a paragraph with it on and Hangul in it
+/// is reported as left out, and comes back off; with it off, or with no Hangul, nothing is
+/// reported.
+#[test]
+fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() {
+ use vectorcraft_doc::TextObject;
+ for (text, on, reported) in [("가나다 라마", true, true), ("가나다 라마", false, false), ("Plain", true, false)] {
+ let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), text, style(14.0, Color::BLACK));
+ t.para.korean_line_break_anywhere = on;
+ let mut doc = Document::new(200.0, 200.0);
+ let layer = doc.layers[0].id;
+ let n = text_node(&mut doc, t);
+ doc.insert(Some(layer), usize::MAX, n).unwrap();
+ let out = export_ai_editing(&doc, &opts()).unwrap();
+ assert_eq!(!out.warnings.is_empty(), reported, "{text} {on}: {:?}", out.warnings);
+ let mut all = vec![];
+ paths(&read_back(&doc).layers, &mut all);
+ let paras: Vec