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/72] 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/72] 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/72] 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/72] 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/72] 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/72] 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 d3a02f617dbb924725527ef15e076020d5f6e168 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 11:26:25 -0800
Subject: [PATCH 07/72] MCP: hidden library ids name their library in requests
and are rewritten only as whole ids
Follows #6608. ui.inspect shows a library loaded from outside the read
roots as loaded/. A request may now name the library or its
panel by that id in a panel, library or panels param, in a docking
destination (anchor, before, onto, above, below) and in
window.panel.layout's action: ui.set {panel} reopens the closed panel
instead of floating a new one. The reply to such a request, an error
included, shows the shown id.
ui.inspect rewrites only whole ids, and only in the fields that hold
library and panel ids, so a readable library's id that begins with a
hidden one keeps its path, and status text, the command palette query
and a script's report keep their text. A hidden library does not take
the id of a library loaded without a path.
ui.inspect also leaves out of recorded action steps the path and folder
params, params named like destinationFolder and the entries of a paths
list that lie outside the read roots, also inside a batch step's
commands. It hides a library id holding a path written with either
separator, and checks a folder preference against the roots as written,
without trimming spaces.
---
crates/ui-egui/src/control.rs | 231 ++++++++++++++++++++++---
crates/ui-egui/src/tests/automation.rs | 183 ++++++++++++++++++++
docs/mcp.md | 18 +-
3 files changed, 405 insertions(+), 27 deletions(-)
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index b02c3b6e1d..3cbe9f920c 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -309,10 +309,22 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"fileDialog": app.picks.waiting_kind(),
});
let ids = unreadable_library_ids(app);
- hide_library_ids(&mut inspected, &ids);
+ for field in PANEL_FIELDS.iter().chain(&LIBRARY_ID_FIELDS) {
+ if let Some(v) = inspected["ui"].get_mut(*field) {
+ hide_library_ids(v, &ids);
+ }
+ }
+ let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect();
+ if let Some(sets) = inspected["ui"].get_mut("action_sets") {
+ swap_library_params(sets, &pairs);
+ }
inspected
}
+/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold library ids or panel
+/// ids besides [`PANEL_FIELDS`]: the library panels, the Color Guide's limit and the workspaces.
+const LIBRARY_ID_FIELDS: [&str; 4] = ["library_panels", "color_guide_limit", "custom_workspaces", "workspace_changes"];
+
/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel
/// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels
/// shown as icons or collapsed, and the older floating panels' record.
@@ -322,9 +334,11 @@ const PANEL_FIELDS: [&str; 7] =
/// The ids of the loaded libraries (`loaded/`) that the UI state refers to and whose file the
/// request may not read, each with the id a reply shows instead: `loaded/`, as a library
/// loaded without a path is named. The UI state refers to a library by its bare id in a library panel
-/// and the Color Guide's limit, and by a panel id (`library::loaded/`) in the fields of
-/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a
-/// workspace's name) is not read. When another of those ids already shows that name, the next free one
+/// and the Color Guide's limit, by a panel id (`library::loaded/`) in the fields of
+/// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of
+/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an
+/// action's name) is not read. When another of those ids, or a library loaded in the session, already
+/// has that id, the next free one
/// of `loaded/ (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that
/// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id.
fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
@@ -333,6 +347,7 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
for field in PANEL_FIELDS {
collect_library_panel_ids(&ui[field], &mut ids);
}
+ collect_library_params(&ui["action_sets"], &mut ids);
for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() {
for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") {
collect_library_panel_ids(layout, &mut ids);
@@ -343,10 +358,15 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
add_library_id(id, &mut ids);
}
let mut hidden = Vec::new();
- let mut taken = std::collections::BTreeSet::new();
+ // The ids of the libraries loaded in the session, so that a hidden one does not take the id of
+ // one loaded without a path.
+ let s = &app.session;
+ let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos());
+ let mut taken: std::collections::BTreeSet = loaded.map(|info| info.id.clone()).collect();
for id in ids {
let path = &id["loaded/".len()..];
- let name = vectorcraft_engine::cmd::fileio::file_name(path);
+ // Either separator: a path saved on another platform counts as a path here too.
+ let name = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string();
if name != path && !readable(path) {
hidden.push((id, name));
} else {
@@ -402,30 +422,81 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet`) and in keys.
fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) {
- if ids.is_empty() {
+ let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect();
+ swap_library_ids(v, &pairs);
+}
+
+/// Text that a person or a script wrote, by its key: a command palette query, a script's report and a
+/// workspace's name. [`swap_library_ids`] leaves it as written.
+const TEXT_FIELDS: [&str; 3] = ["palette_query", "scriptReport", "name"];
+
+/// Writes each `from` id of `pairs` as its `to` id wherever `v` holds it as a whole id ([`swap_whole_ids`]),
+/// in strings and keys, except in the [`TEXT_FIELDS`].
+fn swap_library_ids(v: &mut Value, pairs: &[(&str, &str)]) {
+ if pairs.is_empty() {
return;
}
- let hide = |s: &mut String| {
- for (id, shown) in ids {
- if s.contains(id.as_str()) {
- *s = s.replace(id.as_str(), shown);
+ match v {
+ Value::String(s) => {
+ if let Some(swapped) = swap_whole_ids(s, pairs) {
+ *s = swapped;
}
}
- };
- match v {
- Value::String(s) => hide(s),
- Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)),
+ Value::Array(items) => items.iter_mut().for_each(|item| swap_library_ids(item, pairs)),
Value::Object(fields) => {
- for (mut key, mut item) in std::mem::take(fields) {
- hide(&mut key);
- hide_library_ids(&mut item, ids);
- fields.insert(key, item);
+ for (key, mut item) in std::mem::take(fields) {
+ if TEXT_FIELDS.contains(&key.as_str()) {
+ fields.insert(key, item);
+ continue;
+ }
+ swap_library_ids(&mut item, pairs);
+ fields.insert(swap_whole_ids(&key, pairs).unwrap_or(key), item);
}
}
_ => {}
}
}
+/// `s` with each `from` id of `pairs` written as its `to` id where it is a whole id: the whole string,
+/// or a part that starts the string or follows a `:`, `"` or `` ` `` (a panel id, a quoted id) and ends
+/// the string or a quote. An id that only begins or ends a longer one stays. `None` when nothing changed.
+fn swap_whole_ids(s: &str, pairs: &[(&str, &str)]) -> Option {
+ let mut out: Option = None;
+ for (from, to) in pairs {
+ let text = out.as_deref().unwrap_or(s);
+ if !text.contains(from) {
+ continue;
+ }
+ let mut next = String::with_capacity(text.len());
+ let mut copied = 0;
+ for (at, _) in text.match_indices(from) {
+ let end = at + from.len();
+ let starts = at == 0 || text[..at].ends_with([':', '"', '`']);
+ let ends = end == text.len() || text[end..].starts_with(['"', '`']);
+ if at >= copied && starts && ends {
+ next.push_str(&text[copied..at]);
+ next.push_str(to);
+ copied = end;
+ }
+ }
+ if copied > 0 {
+ next.push_str(&text[copied..]);
+ out = Some(next);
+ }
+ }
+ out
+}
+
+/// Does `v` hold a string or a key that names a loaded library (`loaded/…`)?
+fn names_a_loaded_library(v: &Value) -> bool {
+ match v {
+ Value::String(s) => s.contains("loaded/"),
+ Value::Array(items) => items.iter().any(names_a_loaded_library),
+ Value::Object(fields) => fields.iter().any(|(key, item)| key.contains("loaded/") || names_a_loaded_library(item)),
+ _ => false,
+ }
+}
+
/// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog.
const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
@@ -439,9 +510,43 @@ fn readable(path: &str) -> bool {
vectorcraft_engine::file_access::check_read(path).is_ok()
}
+/// Does a recorded step's param `key` hold a file or folder path: `path`, `folder` or a name ending in
+/// `Folder` (`destinationFolder`, `searchFolder`)?
+fn is_path_param(key: &str) -> bool {
+ matches!(key, "path" | "folder") || key.ends_with("Folder")
+}
+
+/// The params of each step of each action of action set `set` (`ui.action_sets`).
+fn steps_mut(set: &mut Value) -> impl Iterator- > {
+ let actions = set.get_mut("actions").and_then(Value::as_array_mut).into_iter().flatten();
+ let steps = actions.filter_map(|action| action.get_mut("steps").and_then(Value::as_array_mut)).flatten();
+ steps.filter_map(|step| step.get_mut(1).and_then(Value::as_object_mut))
+}
+
+/// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the
+/// paths the request may not read: a path param ([`is_path_param`]) and each path of a `paths` list.
+fn leave_out_unreadable_params(params: &mut serde_json::Map
) {
+ params.retain(|key, v| !(is_path_param(key) && v.as_str().is_some_and(|p| !readable(p))));
+ if let Some(Value::Array(paths)) = params.get_mut("paths") {
+ paths.retain(|p| p.as_str().is_none_or(readable));
+ }
+ for v in params.values_mut() {
+ leave_out_unreadable_values(v);
+ }
+}
+
+/// [`leave_out_unreadable_params`] on each object `v` holds.
+fn leave_out_unreadable_values(v: &mut Value) {
+ match v {
+ Value::Object(fields) => leave_out_unreadable_params(fields),
+ Value::Array(items) => items.iter_mut().for_each(leave_out_unreadable_values),
+ _ => {}
+ }
+}
+
/// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files,
-/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside
-/// the read root. The UI state itself is not changed.
+/// a library panel's file, folder preferences (saved, and in an open Preferences dialog) and the paths
+/// in recorded actions' steps outside the read root. The UI state itself is not changed.
fn leave_out_unreadable_paths(ui: &mut serde_json::Map) {
if let Some(Value::Array(files)) = ui.get_mut("recent_files") {
files.retain(|f| f.as_str().is_some_and(readable));
@@ -453,6 +558,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map) {
}
}
}
+ for step in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) {
+ leave_out_unreadable_params(step);
+ }
if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") {
leave_out_unreadable_folders(prefs);
}
@@ -468,8 +576,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map) {
/// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay.
fn leave_out_unreadable_folders(prefs: &mut serde_json::Map) {
for key in PREF_FOLDERS {
- let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue };
- if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) {
+ let Some(written) = prefs.get(key).and_then(Value::as_str) else { continue };
+ let path = written.trim();
+ if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(written) {
continue;
}
prefs.remove(key);
@@ -499,7 +608,83 @@ fn modifiers(p: &Value) -> egui::Modifiers {
egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") }
}
+/// The params that name a library or a library panel, at any depth of a request (`ui.set {panel}`,
+/// `engine.execute {params: {panel}}`, `ui.libraryPanel {panel, library}`), with the docking
+/// commands' destinations (`window.panel.move {anchor, before}`, `window.panel.float {onto, above,
+/// below}`) and a list of panels.
+const LIBRARY_PARAMS: [&str; 8] = ["panel", "library", "anchor", "before", "onto", "above", "below", "panels"];
+
+/// The param of `window.panel.layout` that holds a serialized docking action: each panel id in it is
+/// translated.
+const DOCKING_ACTION: &str = "action";
+
+/// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect`
+/// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id
+/// in a [`LIBRARY_PARAMS`] param, and the reply to such a request shows that id.
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
+ let ids = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() };
+ let shown: Vec<_> = ids.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect();
+ let mut params = req.params.clone();
+ if shown.is_empty() || !swap_library_params(&mut params, &shown) {
+ return dispatch(app, ctx, req);
+ }
+ let named = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq };
+ match dispatch(app, ctx, &named) {
+ Outcome::Done(mut reply) => {
+ hide_library_ids(&mut reply, &ids);
+ Outcome::Done(reply)
+ }
+ outcome => outcome,
+ }
+}
+
+/// Writes each `from` id of `pairs` in the [`LIBRARY_PARAMS`] params of `params`, at any depth, as its
+/// `to` id → whether one was.
+fn swap_library_params(params: &mut Value, pairs: &[(&str, &str)]) -> bool {
+ match params {
+ Value::Array(items) => items.iter_mut().fold(false, |swapped, item| swap_library_params(item, pairs) | swapped),
+ Value::Object(fields) => fields.iter_mut().fold(false, |swapped, (key, item)| {
+ let this = match item {
+ Value::String(s) if LIBRARY_PARAMS.contains(&key.as_str()) => swap_whole_ids(s, pairs).map(|to| *s = to).is_some(),
+ Value::Array(items) if LIBRARY_PARAMS.contains(&key.as_str()) => items.iter_mut().fold(false, |swapped, item| {
+ let this = item.as_str().and_then(|s| swap_whole_ids(s, pairs)).map(|to| *item = Value::String(to)).is_some();
+ this | swapped
+ }),
+ Value::Object(_) if key == DOCKING_ACTION => {
+ let before = item.clone();
+ swap_library_ids(item, pairs);
+ *item != before
+ }
+ _ => swap_library_params(item, pairs),
+ };
+ this | swapped
+ }),
+ _ => false,
+ }
+}
+
+/// Adds to `ids` the loaded library ids that the [`LIBRARY_PARAMS`] params of `v` name, at any depth:
+/// a library's id or its panel's id.
+fn collect_library_params(v: &Value, ids: &mut std::collections::BTreeSet) {
+ match v {
+ Value::Array(items) => items.iter().for_each(|item| collect_library_params(item, ids)),
+ Value::Object(fields) => {
+ for (key, item) in fields {
+ if !LIBRARY_PARAMS.contains(&key.as_str()) {
+ collect_library_params(item, ids);
+ continue;
+ }
+ for s in item.as_str().into_iter().chain(item.as_array().into_iter().flatten().filter_map(Value::as_str)) {
+ add_library_id(s, ids);
+ collect_library_panel_ids(&json!(s), ids);
+ }
+ }
+ }
+ _ => {}
+ }
+}
+
+fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
match req.method.as_str() {
diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs
index f725a0819c..f3aee6ca73 100644
--- a/crates/ui-egui/src/tests/automation.rs
+++ b/crates/ui-egui/src/tests/automation.rs
@@ -425,6 +425,124 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() {
let _ = std::fs::remove_dir_all(base);
}
+/// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before
+/// it is a relative path, outside the root, and is left out.
+#[test]
+fn ui_inspect_checks_a_folder_preference_as_written() {
+ let (mut app, tx, base) = confined("inspect-folder-space");
+ let inside = s(base.join("in"));
+ app.ui.engine_prefs = json!({"fontsFolder": inside, "templatesFolder": format!(" {inside}")});
+ let r = request(&mut app, &tx, "ui.inspect", json!({}));
+ let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap();
+ assert_eq!(prefs.get("fontsFolder").and_then(Value::as_str), Some(inside.as_str()), "{r}");
+ assert!(!prefs.contains_key("templatesFolder"), "{r}");
+ let _ = std::fs::remove_dir_all(base);
+}
+
+/// `ui.inspect` rewrites a hidden library's id only in the fields that hold library and panel ids:
+/// status text that is that id stays as written.
+#[test]
+fn ui_inspect_keeps_status_text_that_is_a_hidden_library_id() {
+ let (mut app, tx, base) = confined("inspect-status-id");
+ let theirs = format!("loaded/{}", s(base.join("outside/b.svg")));
+ app.ui.color_guide_limit = theirs.clone();
+ app.ui.status = theirs.clone();
+ let r = request(&mut app, &tx, "ui.inspect", json!({}));
+ assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}");
+ assert_eq!(r["result"]["ui"]["status"], json!(theirs), "{r}");
+ let _ = std::fs::remove_dir_all(base);
+}
+
+/// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action
+/// (its `library` and `panel` params), also when nothing else names it, and keeps the action's name.
+#[test]
+fn ui_inspect_hides_a_hidden_library_in_recorded_steps() {
+ let (mut app, tx, base) = confined("inspect-action-steps");
+ let theirs = format!("loaded/{}", s(base.join("outside/b.svg")));
+ let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({
+ "name": "set",
+ "actions": [{"name": theirs, "steps": [
+ ["swatch.library.add", {"library": theirs}],
+ ["window.panel", {"panel": format!("library:swatches:{theirs}")}],
+ ]}],
+ }))
+ .unwrap();
+ app.ui.action_sets = vec![set];
+ let r = request(&mut app, &tx, "ui.inspect", json!({}));
+ let action = &r["result"]["ui"]["action_sets"][0]["actions"][0];
+ assert_eq!(action["name"], json!(theirs), "{r}");
+ assert_eq!(
+ action["steps"],
+ json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]),
+ "{r}"
+ );
+ let _ = std::fs::remove_dir_all(base);
+}
+
+/// `ui.inspect` leaves out of a recorded action's steps the file paths (`path`, `folder`, a name ending
+/// in `Folder`, `paths`) outside the read root, also in a batch step's commands, and keeps the ones
+/// inside it.
+#[test]
+fn ui_inspect_leaves_out_recorded_paths_outside_the_read_root() {
+ let (mut app, tx, base) = confined("inspect-action-paths");
+ let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")));
+ let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({
+ "name": "set",
+ "actions": [{"name": "files", "steps": [
+ ["file.open", {"path": a}],
+ ["file.open", {"path": b}],
+ ["file.place", {"paths": [a, b]}],
+ ["batch.run", {"folder": s(base.join("outside"))}],
+ ["action.batch", {"destinationFolder": s(base.join("outside"))}],
+ ["command.batch", {"commands": [{"command": "file.open", "params": {"path": b}}]}],
+ ]}],
+ }))
+ .unwrap();
+ app.ui.action_sets = vec![set];
+ let r = request(&mut app, &tx, "ui.inspect", json!({}));
+ let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"];
+ let want = json!([
+ ["file.open", {"path": a}],
+ ["file.open", {}],
+ ["file.place", {"paths": [a]}],
+ ["batch.run", {}],
+ ["action.batch", {}],
+ ["command.batch", {"commands": [{"command": "file.open", "params": {}}]}],
+ ]);
+ assert_eq!(steps, &want, "{r}");
+ assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 6, "the UI state is not changed");
+ let _ = std::fs::remove_dir_all(base);
+}
+
+/// `ui.inspect` hides a library loaded from outside the read root whose id holds a path written with
+/// either separator, also one saved on another platform.
+#[test]
+fn ui_inspect_hides_a_library_path_with_either_separator() {
+ let (mut app, tx, base) = confined("inspect-separators");
+ app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string();
+ let r = request(&mut app, &tx, "ui.inspect", json!({}));
+ assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}");
+ app.ui.color_guide_limit = "loaded//home/someone/c.svg".to_string();
+ let r = request(&mut app, &tx, "ui.inspect", json!({}));
+ assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/c.svg", "{r}");
+ let _ = std::fs::remove_dir_all(base);
+}
+
+/// A hidden library does not take the id of a library loaded without a path under the same file
+/// name: it shows as `loaded/ (2)`, and a request that names the other library reaches it.
+#[test]
+fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() {
+ let (mut app, tx, base) = confined("inspect-loaded-id");
+ let svg = std::fs::read_to_string(base.join("in/a.svg")).unwrap();
+ let loaded = app.session.execute("swatch.library.load", &json!({"data": svg, "name": "b.svg"})).unwrap();
+ assert_eq!(loaded["library"], "loaded/b.svg");
+ let theirs = format!("loaded/{}", s(base.join("outside/b.svg")));
+ app.ui.color_guide_limit = theirs;
+ let r = request(&mut app, &tx, "ui.inspect", json!({}));
+ assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg (2)", "{r}");
+ let _ = std::fs::remove_dir_all(base);
+}
+
/// A closed panel's former place, as `ui.docking_hidden` keeps it.
fn hidden_place() -> craft_ui::docking::Location {
serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap()
@@ -519,6 +637,71 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() {
let _ = std::fs::remove_dir_all(base);
}
+/// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable
+/// library's id that begins with it stays, and a script's report line and a command palette query
+/// that hold it stay as written.
+#[test]
+fn ui_inspect_rewrites_only_whole_hidden_library_ids() {
+ let (mut app, tx, base) = confined("inspect-whole-id");
+ let hidden = format!("loaded/{}", s(base.join("i")));
+ let readable = format!("loaded/{}", s(base.join("in/a.svg")));
+ assert!(readable.starts_with(&hidden));
+ let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() };
+ app.ui.library_panels = vec![panel(&hidden), panel(&readable)];
+ let query = format!("library:swatches:{hidden}");
+ app.ui.palette_query = query.clone();
+ let text = format!("found {hidden}");
+ let source = format!("vc.log({})", json!(text));
+ app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap();
+ for _ in 0..10_000 {
+ if app.session.script_pump().is_some() {
+ break;
+ }
+ }
+ let r = request(&mut app, &tx, "ui.inspect", json!({}));
+ let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
+ assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}");
+ assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}");
+ assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}");
+ let _ = std::fs::remove_dir_all(base);
+}
+
+/// A client can name a hidden library by the id `ui.inspect` shows: `ui.set {panel}` with that panel id
+/// reopens the closed panel of the library, and the reply does not show its path.
+#[test]
+fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() {
+ let (mut app, tx, base) = confined("set-hidden-panel");
+ let theirs = format!("loaded/{}", s(base.join("outside/b.svg")));
+ let real = format!("library:swatches:{theirs}");
+ app.ui.docking_hidden.insert(real.clone(), hidden_place());
+ let r = request(&mut app, &tx, "ui.inspect", json!({}));
+ let shown = "library:swatches:loaded/b.svg";
+ assert!(r["result"]["ui"]["docking_hidden"].as_object().unwrap().contains_key(shown), "{r}");
+ let r = request(&mut app, &tx, "ui.set", json!({"panel": shown}));
+ assert_eq!(r["ok"], true, "{r}");
+ assert!(!app.ui.docking_hidden.contains_key(&real), "the closed panel is reopened: {r}");
+ let layout = crate::panel_docking::layout_of(&app.ui);
+ assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}");
+ let hidden = json!(s(base.join("outside"))).to_string();
+ assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}");
+ // A docking command takes the shown id as its destination too.
+ let r = request(
+ &mut app,
+ &tx,
+ "engine.execute",
+ json!({"command": "window.panel.move", "params": {"panel": "layers", "anchor": shown, "zone": "tab"}}),
+ );
+ assert_eq!(r["ok"], true, "{r}");
+ assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}");
+ // An error that quotes the panel shows the id the client gave, and other text is not rewritten.
+ let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"}));
+ assert_eq!(r["ok"], false, "{r}");
+ assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}");
+ let r = request(&mut app, &tx, "ui.set", json!({"status": "loaded/b.svg"}));
+ assert_eq!((r["ok"].as_bool(), app.ui.status.as_str()), (Some(true), "loaded/b.svg"), "{r}");
+ let _ = std::fs::remove_dir_all(base);
+}
+
/// A client without automation roots sees every loaded library's full id in `ui.inspect`.
#[test]
fn ui_inspect_shows_full_library_ids_without_a_read_root() {
diff --git a/docs/mcp.md b/docs/mcp.md
index 33a7e2a68c..b8265bdfd2 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -129,14 +129,24 @@ the suite) keep the agent to the folders you give it:
`file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does.
- `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the
documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`;
- and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields`
+ in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path` and `folder` params,
+ a param whose name ends in `Folder` (`destinationFolder`) and the entries of a `paths` param; and the folder
+ preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields`
(`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an
empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the
roots shows as `loaded/` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the
- Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is
- closed. When another hidden library has the same file name, the libraries take `loaded/`,
+ Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel`
+ params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with
+ either separator counts as a path, also one saved on another platform. When another hidden library has the same
+ file name, the libraries take `loaded/`,
`loaded/ (2)`, `(3)` and so on in the order of their full ids, and each one keeps its id in both
- replies. The other entries keep their order, and the app's own state is not changed.
+ replies. A hidden library skips an id that a library loaded without a path already has. The other entries keep
+ their order, and the app's own state is not changed. A request may name such a library or its panel by the id
+ shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`,
+ `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/"}`
+ reopens its panel), and the reply to such a request, an error included, shows that id too. `ui_inspect` rewrites only whole ids, and only in the fields that hold library and panel ids:
+ a longer id that begins with a hidden one, the status text (`ui.status`), the command palette query
+ (`ui.palette_query`) and a script's report (`scriptReport`) keep their text.
- Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any
of the write roots written, so one session can read a project folder and a shared assets folder, or write to an
output folder and a scratch folder, without widening a root to their common parent. A folder given twice
From 6ae7051e4e6906f6fc9f58b226f43aaffae98212 Mon Sep 17 00:00:00 2001
From: Sven Lafebre
Date: Wed, 11 Nov 2026 21:43:23 +0100
Subject: [PATCH 08/72] Fix #5111: kerning at the caret before a line's first
character moves it
At the Type tool's caret, kerning goes on the character before the caret.
At the start of the text there is none, so Alt+arrows, the Kerning field
and Reset Tracking and Kerning did nothing; at a later paragraph's start
the kerning landed on the invisible line break.
CharStyle::kerning_before (kerningBefore) is the space before a character
that starts a line by itself: the first character of the text, of a
paragraph, or after a forced line break. Shaping gives its cluster the
space and draws it that much later along the line (right to left the
space is at its right; in vertical type it moves down the column). The
caret at such a line's start sets it (edit::caret_kerning_target), in
type.step, type.resetTracking and the Character panel; text.setRangeStyle
takes it. SVG export starts the line where the character is drawn. The
Eyedropper and character styles leave it alone.
---
crates/doc/src/text/char_style.rs | 7 +
crates/doc/src/text/tabs_lists.rs | 1 +
crates/engine/src/cmd/appearance.rs | 3 +
crates/engine/src/cmd/textedit.rs | 85 ++++++----
crates/engine/src/cmd/textstyles.rs | 10 +-
crates/engine/src/cmd/textstyles/tests.rs | 4 +-
crates/engine/src/tests.rs | 1 +
crates/engine/src/tests/caret_kerning.rs | 183 ++++++++++++++++++++++
crates/svg/src/export/text.rs | 11 ++
crates/svg/tests/it/text_layout.rs | 24 +++
crates/text/src/edit.rs | 22 +++
crates/text/src/harakat.rs | 2 +
crates/text/src/layout.rs | 41 +++--
crates/text/src/shape.rs | 38 ++++-
crates/text/src/tests.rs | 1 +
crates/text/src/tests/kerning_before.rs | 121 ++++++++++++++
crates/ui-egui/src/panels/character.rs | 29 ++--
docs/mcp.md | 3 +-
docs/target-app-parity.md | 6 +-
docs/type-parity.md | 3 +-
20 files changed, 523 insertions(+), 72 deletions(-)
create mode 100644 crates/engine/src/tests/caret_kerning.rs
create mode 100644 crates/text/src/tests/kerning_before.rs
diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs
index fd828c79af..37c76c5e3a 100644
--- a/crates/doc/src/text/char_style.rs
+++ b/crates/doc/src/text/char_style.rs
@@ -28,6 +28,12 @@ pub struct CharStyle {
/// Manual pair spacing in 1/1000 em, as [`Self::tracking`]; None uses the automatic mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kerning: Option,
+ /// Space before the character in 1/1000 em, as [`Self::kerning`], when it starts a line by
+ /// itself: the first character of the text, of a paragraph, or after a forced line break. The
+ /// Type tool's caret before such a character kerns with it (#5111), since there is no character
+ /// before it whose [`Self::kerning`] could space it. Elsewhere it has no effect.
+ #[serde(default, rename = "kerningBefore", skip_serializing_if = "crate::skip::is_default")]
+ pub kerning_before: f64,
/// Use glyph shapes for automatic pair spacing; false uses font metrics.
#[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")]
pub optical_kerning: bool,
@@ -411,6 +417,7 @@ impl Default for CharStyle {
leading: None,
tracking: 0.0,
kerning: None,
+ kerning_before: 0.0,
optical_kerning: false,
baseline_shift: 0.0,
h_scale: 100.0,
diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs
index c4ec92b70b..d00005c779 100644
--- a/crates/doc/src/text/tabs_lists.rs
+++ b/crates/doc/src/text/tabs_lists.rs
@@ -356,6 +356,7 @@ impl ParaList {
position: CharPosition::Normal,
tracking: 0.0,
kerning: None,
+ kerning_before: 0.0,
optical_kerning: first.optical_kerning,
tate_chu_yoko: false,
underline: false,
diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs
index 2dfeb497d0..98eec73a3d 100644
--- a/crates/engine/src/cmd/appearance.rs
+++ b/crates/engine/src/cmd/appearance.rs
@@ -1430,6 +1430,9 @@ impl Picked {
stroke_width: st.stroke_width,
overprint_fill: st.overprint_fill,
overprint_stroke: st.overprint_stroke,
+ // The space before a line's first character belongs to where the character is,
+ // not to how it looks: a sample from one doesn't move the target's (#5111).
+ kerning_before: st.kerning_before,
..cs.clone()
};
}
diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs
index 21ed6d8023..773f2002b6 100644
--- a/crates/engine/src/cmd/textedit.rs
+++ b/crates/engine/src/cmd/textedit.rs
@@ -40,7 +40,7 @@ pub fn specs() -> Vec {
"Character",
[],
None,
- "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", 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: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), 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: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
+ "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", 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: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), 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: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_range_style
),
@@ -103,7 +103,7 @@ pub fn specs() -> Vec {
"Step Type",
[],
None,
- "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}",
+ "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the space after the character before it, or at the start of a line (of the text, a paragraph, or after a forced line break) the space before the character after it (kerningBefore, as text.setRangeStyle), and nothing at an empty line's start; leading its paragraph), else every selected type object → {ids}",
has_doc,
type_step
),
@@ -148,7 +148,7 @@ pub fn specs() -> Vec {
"Reset Tracking and Kerning",
[],
Some("Cmd+Alt+Q"),
- "{ids?: [id…]} set tracking to 0 and clear manual kerning without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning of the character before it), else in every selected type object, as one undo step → {ids}",
+ "{ids?: [id…]} set tracking to 0 and clear manual kerning (kerning and kerningBefore) without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning there, as type.step steps it), else in every selected type object, as one undo step → {ids}",
has_selection,
reset_tracking
),
@@ -430,6 +430,8 @@ pub(crate) struct CharChange {
leading: Option>,
tracking: Option,
kerning: Option>,
+ /// The space before a character that starts a line by itself (`kerningBefore`).
+ kerning_before: Option,
optical_kerning: Option,
baseline_shift: Option,
h_scale: Option,
@@ -691,7 +693,7 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result>> {
impl CharChange {
/// `setup` gives the superscript, subscript and small caps proportions.
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result {
- check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
+ check_numbers(p, &["size", "tracking", "kerningBefore", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
check_switches(
p,
&["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak", "kashidas"],
@@ -720,6 +722,7 @@ impl CharChange {
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") || v.eq_ignore_ascii_case("auto") => Some(None),
Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(cmd, "kerning must be a number, \"auto\", or \"optical\""))?)),
},
+ kerning_before: num("kerningBefore"),
optical_kerning: match p.get("kerning") {
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") => Some(true),
Some(Value::String(v)) if v.eq_ignore_ascii_case("auto") => Some(false),
@@ -777,6 +780,7 @@ impl CharChange {
&& self.leading.is_none()
&& self.tracking.is_none()
&& self.kerning.is_none()
+ && self.kerning_before.is_none()
&& self.optical_kerning.is_none()
&& self.baseline_shift.is_none()
&& self.h_scale.is_none()
@@ -883,6 +887,9 @@ impl CharChange {
if let Some(v) = self.kerning {
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
}
+ if let Some(v) = self.kerning_before {
+ st.kerning_before = v.clamp(-1000.0, 10000.0);
+ }
if let Some(v) = self.optical_kerning {
st.optical_kerning = v;
}
@@ -1694,17 +1701,16 @@ pub(crate) fn editing_story(s: &Session) -> Vec {
s.active().map(|st| super::threads::story_of(&st.doc, id)).unwrap_or_default()
}
+/// A text range a type shortcut changes: its type object, its bytes, and the caret's byte when it
+/// came from a caret ([`type_ranges`]).
+pub(crate) type TypeRange = (NodeId, usize, usize, Option);
+
/// The text ranges a type shortcut changes: the range of `id`, else all the text of the type
/// objects in `ids`, else the text the Type tool has selected (all the text of every frame of the
/// story after Select All in threaded text, [`editing_story`]), else all the text of every selected
/// type object. At a caret, `at_caret` gives the range from the plain text and the caret's byte
-/// (None: nothing changes there). Each range comes with whether it came from a caret.
-pub(crate) fn type_ranges(
- s: &mut Session,
- p: &Value,
- c: &str,
- at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>,
-) -> Result> {
+/// (None: nothing changes there). Each range comes with the caret's byte when it came from a caret.
+pub(crate) fn type_ranges(s: &mut Session, p: &Value, c: &str, at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>) -> Result> {
// (text, its range; None: all of it).
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? {
// An object that isn't type is refused as `ids` of it is.
@@ -1728,10 +1734,10 @@ pub(crate) fn type_ranges(
let text = text_ref(s, id)?.plain_text();
let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len())));
let (a, b, caret) = if a < b {
- (a, b, false)
+ (a, b, None)
} else {
match at_caret(&text, a) {
- Some((a, b)) => (a, b, true),
+ Some((x, y)) => (x, y, Some(a)),
None => continue,
}
};
@@ -1763,24 +1769,31 @@ fn type_step(s: &mut Session, p: &Value) -> Result {
Step::Tracking | Step::Kerning => prefs.tracking_increment,
Step::BaselineShift => prefs.baseline_shift_increment,
};
- // The ranges stepped. At a caret: kerning is the space after the character before it, leading
- // the paragraph's; the rest wait for a selection.
+ // The ranges stepped. At a caret: kerning is the space after the character before it, or at
+ // the start of a line the space before the character after it (#5111), leading the
+ // paragraph's; the rest wait for a selection.
let ranges = type_ranges(s, p, C, |text, a| match step {
- Step::Kerning => Some((edit::prev_char(text, a), a)),
+ Step::Kerning => edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)),
Step::Leading => {
let para = edit::paragraph_at(text, a);
Some((para.start, para.end))
}
_ => None,
})?;
+ // At a caret, the kerning stepped: (whether it is the space before the character, its value).
let mut caret_kerning = Vec::with_capacity(ranges.len());
for &(id, a, b, caret) in &ranges {
- let value = if matches!(step, Step::Kerning) && caret {
- let t = text_ref(s, id)?;
- let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
- Some(effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b))
- } else {
- None
+ let value = match caret {
+ Some(c) if matches!(step, Step::Kerning) => {
+ let t = text_ref(s, id)?;
+ if edit::caret_kerning_target(&t.plain_text(), c).is_some_and(|(_, before)| before) {
+ Some((true, edit::style_at(&t.runs, a + 1).kerning_before))
+ } else {
+ let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
+ Some((false, effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b)))
+ }
+ }
+ _ => None,
};
caret_kerning.push(value);
}
@@ -1829,10 +1842,13 @@ fn type_step(s: &mut Session, p: &Value) -> Result {
st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?);
}
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
- Step::Kerning => {
- let current = caret_kerning.get(range_index).copied().flatten().unwrap_or_else(|| st.kerning.unwrap_or(0.0));
- st.kerning = Some((current + inc).clamp(-1000.0, 10000.0));
- }
+ Step::Kerning => match caret_kerning.get(range_index).copied().flatten() {
+ Some((true, current)) => st.kerning_before = (current + inc).clamp(-1000.0, 10000.0),
+ caret => {
+ let current = caret.map_or_else(|| st.kerning.unwrap_or(0.0), |(_, v)| v);
+ st.kerning = Some((current + inc).clamp(-1000.0, 10000.0));
+ }
+ },
Step::BaselineShift => {
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
}
@@ -1849,17 +1865,22 @@ fn type_step(s: &mut Session, p: &Value) -> Result {
/// Cmd/Ctrl+Alt+Q: tracking 0 and manual kerning cleared in the type objects in
/// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret,
-/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there.
-/// The automatic method remains selected.
+/// only the kerning there changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns
+/// there: of the character before it, or at the start of a line the space before the character
+/// after it ([`edit::caret_kerning_target`]). The automatic method remains selected.
fn reset_tracking(s: &mut Session, p: &Value) -> Result {
- let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?;
+ let ranges = type_ranges(s, p, "type.resetTracking", |text, a| edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)))?;
if !ranges.is_empty() {
s.edit("Reset Tracking and Kerning", |d, _| {
for &(id, a, b, caret) in &ranges {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
- edit::style_range(&mut t.runs, a, b, |st| {
- st.kerning = None;
- if !caret {
+ let before = caret.and_then(|c| edit::caret_kerning_target(&t.plain_text(), c)).map(|(_, before)| before);
+ edit::style_range(&mut t.runs, a, b, |st| match before {
+ Some(true) => st.kerning_before = 0.0,
+ Some(false) => st.kerning = None,
+ None => {
+ st.kerning = None;
+ st.kerning_before = 0.0;
st.tracking = 0.0;
}
});
diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs
index 89ec47b276..f63f4fe9fa 100644
--- a/crates/engine/src/cmd/textstyles.rs
+++ b/crates/engine/src/cmd/textstyles.rs
@@ -174,8 +174,10 @@ fn to_attrs(v: &T) -> Map {
/// from a style instead leaves the text's own value alone.
pub fn char_attrs(v: &CharStyle) -> Map {
let mut m = to_attrs(v);
- // A chosen glyph belongs to one character, not to a reusable character style.
+ // A chosen glyph belongs to one character, and the space before a line's first character to
+ // where it stands (#5111), not to a reusable character style.
m.remove("glyphAlternate");
+ m.remove("kerningBefore");
for (k, x) in [
("font_version", json!(v.font_version)),
("fontAxes", json!(v.font_axes)),
@@ -516,6 +518,12 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
if m.contains_key("glyphAlternate") {
return Err(bad(&c, "`glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it)"));
}
+ if m.contains_key("kerningBefore") {
+ return Err(bad(
+ &c,
+ "`kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it)",
+ ));
+ }
let mut st = with_attrs(&CharStyle::default(), m, &c)?;
let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key);
for (key, value, lo, hi) in
diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs
index ab0c7d342e..53720c1ad9 100644
--- a/crates/engine/src/cmd/textstyles/tests.rs
+++ b/crates/engine/src/cmd/textstyles/tests.rs
@@ -1336,7 +1336,8 @@ fn para_style_redefined_to_the_defaults_updates_its_paragraphs() {
/// The same for character styles: Auto kerning and the Roman baseline alignment, which the
/// run's JSON leaves out, are captured and reach the style's users. A glyph choice belongs to
-/// one character, so it is not a reusable style attribute.
+/// one character, and the space before a line's first character to where it stands (#5111), so
+/// neither is a reusable style attribute.
#[test]
fn char_style_redefined_to_the_defaults_updates_its_text() {
use vectorcraft_doc::CharAlign;
@@ -1356,6 +1357,7 @@ fn char_style_redefined_to_the_defaults_updates_its_text() {
assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3]));
let mut reusable = settable::();
reusable.remove("glyphAlternate");
+ reusable.remove("kerningBefore");
assert_eq!(defined(&s, 0), reusable, "Redefine captures every reusable attribute");
s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
assert_eq!(defined(&s, 1), reusable, "New captures every reusable attribute");
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ca0501c8f7..4fa733a6b2 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -20,6 +20,7 @@ mod brushlib;
mod brushsym;
mod build;
mod cancel;
+mod caret_kerning;
mod charstroke;
pub(super) mod clip;
mod clipboard;
diff --git a/crates/engine/src/tests/caret_kerning.rs b/crates/engine/src/tests/caret_kerning.rs
new file mode 100644
index 0000000000..dca3519cb1
--- /dev/null
+++ b/crates/engine/src/tests/caret_kerning.rs
@@ -0,0 +1,183 @@
+//! Kerning at the Type tool's caret at the start of a line (#5111): with no character on the line
+//! before the caret, `type.step`, `type.resetTracking` and the Kerning field set the space before
+//! the character after it (`kerningBefore`), as one undo step, and the character moves.
+
+use serde_json::json;
+use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject};
+use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
+
+use crate::*;
+
+fn session() -> Session {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
+ s
+}
+
+fn text(s: &mut Session, t: &str) -> NodeId {
+ NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap())
+}
+
+fn obj(s: &Session, id: NodeId) -> TextObject {
+ let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") };
+ (**t).clone()
+}
+
+fn style_at(s: &Session, id: NodeId, byte: usize) -> CharStyle {
+ vectorcraft_text::edit::style_at(&obj(s, id).runs, byte + 1)
+}
+
+/// Where glyph `byte` is drawn along its line, in text space.
+fn drawn_x(s: &Session, id: NodeId, byte: usize) -> f64 {
+ let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(s, id));
+ l.glyphs.iter().find(|g| g.byte == byte).unwrap().xf.as_coeffs()[4]
+}
+
+/// The Type tool editing `id` (made by [`text`]) with its caret at byte `caret`.
+fn caret_in(s: &mut Session, id: NodeId, caret: usize) -> ViewInfo {
+ let v = ViewInfo::default();
+ s.select_tool("type", v).unwrap();
+ for k in [PointerKind::Down, PointerKind::Up] {
+ s.pointer(&PointerEvent::new(k, 102.0, 95.0), v).unwrap();
+ }
+ assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text");
+ s.set_tool_option("select", &json!({"start": caret, "end": caret}));
+ v
+}
+
+fn step_at(s: &mut Session, id: NodeId, caret: usize, by: i32) -> serde_json::Value {
+ s.execute("type.step", &json!({"id": id.0, "start": caret, "end": caret, "attribute": "kerning", "by": by})).unwrap()
+}
+
+#[test]
+fn a_step_at_the_start_of_the_text_moves_the_first_character() {
+ let mut s = session();
+ let id = text(&mut s, "AV");
+ let inc = s.prefs.tracking_increment;
+ let (a, v) = (drawn_x(&s, id, 0), drawn_x(&s, id, 1));
+ assert_eq!(step_at(&mut s, id, 0, 1)["ids"], json!([id.0]));
+ let st = style_at(&s, id, 0);
+ assert_eq!((st.kerning_before, st.kerning), (inc, None), "the space before A, not after it");
+ // A step of `inc` thousandths of the 20 pt em moves both letters along the line.
+ let moved = inc / 1000.0 * 20.0;
+ assert!((drawn_x(&s, id, 0) - a - moved).abs() < 1e-6 && (drawn_x(&s, id, 1) - v - moved).abs() < 1e-6);
+ // A negative step goes back towards the line's start, past it.
+ step_at(&mut s, id, 0, -3);
+ assert_eq!(style_at(&s, id, 0).kerning_before, -2.0 * inc);
+ assert!((drawn_x(&s, id, 0) - a + 2.0 * moved).abs() < 1e-6);
+ // One undo step each.
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(style_at(&s, id, 0).kerning_before, inc);
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(style_at(&s, id, 0).kerning_before, 0.0);
+ assert!((drawn_x(&s, id, 0) - a).abs() < 1e-6);
+}
+
+#[test]
+fn inside_a_line_a_step_still_kerns_the_character_before_the_caret() {
+ let mut s = session();
+ let id = text(&mut s, "AV");
+ step_at(&mut s, id, 1, 1);
+ let (a, v) = (style_at(&s, id, 0), style_at(&s, id, 1));
+ assert_eq!((a.kerning_before, v.kerning_before, v.kerning), (0.0, 0.0, None));
+ assert!(a.kerning.is_some(), "the pair after A");
+}
+
+#[test]
+fn the_start_of_a_paragraph_or_after_a_forced_line_break_kerns_the_character_after_it() {
+ let mut s = session();
+ let inc = s.prefs.tracking_increment;
+ let id = text(&mut s, "One\nTwo");
+ let t = drawn_x(&s, id, 4);
+ step_at(&mut s, id, 4, 1);
+ assert_eq!((style_at(&s, id, 3).kerning, style_at(&s, id, 4).kerning_before), (None, inc), "not the line break's kerning");
+ assert!(drawn_x(&s, id, 4) > t, "T moves");
+ let forced = text(&mut s, "One\u{2028}Two");
+ let after = "One\u{2028}".len();
+ step_at(&mut s, forced, after, 1);
+ assert_eq!((style_at(&s, forced, 3).kerning, style_at(&s, forced, after).kerning_before), (None, inc));
+}
+
+#[test]
+fn an_empty_line_has_nothing_to_kern() {
+ let mut s = session();
+ let id = text(&mut s, "One\n\nTwo");
+ let before = obj(&s, id).runs;
+ assert_eq!(step_at(&mut s, id, 4, 1)["ids"], json!([]));
+ assert_eq!(obj(&s, id).runs, before);
+ // Nor does the end of text after a line break.
+ let end = text(&mut s, "One\n");
+ assert_eq!(step_at(&mut s, end, 4, 1)["ids"], json!([]));
+}
+
+#[test]
+fn reset_at_the_start_of_a_line_clears_the_space_before_and_a_full_reset_clears_it_too() {
+ let mut s = session();
+ let id = text(&mut s, "AV");
+ s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": -40, "tracking": 100})).unwrap();
+ step_at(&mut s, id, 0, 2);
+ assert_ne!(style_at(&s, id, 0).kerning_before, 0.0);
+ // At the caret before A only the space before A goes; A keeps its kerning after it.
+ caret_in(&mut s, id, 0);
+ s.execute("type.resetTracking", &json!({})).unwrap();
+ let st = style_at(&s, id, 0);
+ assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, Some(-40.0), 100.0));
+ step_at(&mut s, id, 0, 2);
+ s.execute("type.resetTracking", &json!({"ids": [id.0]})).unwrap();
+ let st = style_at(&s, id, 0);
+ assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, None, 0.0), "all manual kerning");
+}
+
+#[test]
+fn set_range_style_sets_the_space_before() {
+ let mut s = session();
+ let id = text(&mut s, "AV");
+ s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 50})).unwrap();
+ assert_eq!(style_at(&s, id, 0).kerning_before, 50.0);
+ s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 20000})).unwrap();
+ assert_eq!(style_at(&s, id, 0).kerning_before, 10000.0, "kerning's limits");
+ assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": "auto"})).is_err());
+}
+
+/// The Type tool: Alt+← at the caret before the first character kerns it, and Alt+→ back.
+#[test]
+fn alt_arrows_at_the_caret_before_the_first_character_kern_it() {
+ let mut s = session();
+ let inc = s.prefs.tracking_increment;
+ let id = text(&mut s, "Hello");
+ let v = caret_in(&mut s, id, 0);
+ let alt = Mods { alt: true, ..Mods::default() };
+ s.tool_key(ToolKey::Left, alt, v).unwrap();
+ assert_eq!((style_at(&s, id, 0).kerning_before, style_at(&s, id, 0).kerning), (-inc, None));
+ s.tool_key(ToolKey::Right, alt, v).unwrap();
+ s.tool_key(ToolKey::Right, alt, v).unwrap();
+ assert_eq!(style_at(&s, id, 0).kerning_before, inc);
+}
+
+/// The Eyedropper copies how type looks, not where a line starts: a sample from a first character
+/// with a space before it leaves the target's first character where it was.
+#[test]
+fn the_eyedropper_keeps_the_targets_space_before() {
+ let mut s = session();
+ let src = text(&mut s, "Source");
+ s.execute("text.setRangeStyle", &json!({"id": src.0, "start": 0, "end": 1, "kerningBefore": 300, "tracking": 50})).unwrap();
+ let dst = text(&mut s, "Target");
+ s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
+ let st = style_at(&s, dst, 0);
+ assert_eq!((st.tracking, st.kerning_before), (50.0, 0.0), "the tracking goes, the space before doesn't");
+}
+
+/// A character style captured from a line's first character leaves its space before out, and a
+/// style can't be given one: it would space the first character of every paragraph set in it.
+#[test]
+fn a_character_style_leaves_the_space_before_out() {
+ let mut s = session();
+ let id = text(&mut s, "Source");
+ s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": 300, "tracking": 50})).unwrap();
+ s.execute("charStyle.new", &json!({"name": "Body", "id": id.0})).unwrap();
+ let attrs = s.doc().unwrap().doc.char_styles[0].attrs.clone();
+ assert_eq!(attrs.get("tracking"), Some(&json!(50.0)));
+ assert!(!attrs.contains_key("kerningBefore"), "{attrs:?}");
+ let err = s.execute("charStyle.new", &json!({"name": "Spaced", "attrs": {"kerningBefore": 100}})).unwrap_err();
+ assert!(err.to_string().contains("kerningBefore"), "{err}");
+}
diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs
index b12811338b..5ff58a97bf 100644
--- a/crates/svg/src/export/text.rs
+++ b/crates/svg/src/export/text.rs
@@ -751,6 +751,17 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
let x = g.xf.translation().x - offset;
(advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x })
}
+ // The space before a line's first character (`kerningBefore`) is in its advance and
+ // moves it along the line: a reader sets it from where it is drawn, without it.
+ None if !lay.vertical
+ && !g.rtl
+ && g.len > 0
+ && run.style.kerning_before != 0.0
+ && vectorcraft_text::edit::starts_line(&plain, g.byte) =>
+ {
+ let before = Some(g.xf.translation().x - g.origin.x).filter(|b| b.is_finite()).unwrap_or(0.0);
+ (g.advance - before, g.origin.x + before)
+ }
None => (g.advance, g.origin.x),
};
let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect();
diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs
index a031de7412..e8129a6922 100644
--- a/crates/svg/tests/it/text_layout.rs
+++ b/crates/svg/tests/it/text_layout.rs
@@ -109,6 +109,30 @@ fn rich_text_writes_spacing_shift_and_scale() {
assert_eq!(attr(tags[1], "y"), attr(tags[0], "y"));
}
+/// The space before a line's first character (`kerningBefore`, #5111) moves the line's first
+/// `` to where the character is drawn, left aligned or anchored at a centred line's centre.
+#[test]
+fn the_space_before_a_lines_first_character_moves_its_tspan() {
+ let before = CharStyle { kerning_before: 250.0, ..style(12.0) };
+ // 250/1000 em of 12 pt: 3 pt. The second paragraph's first character takes it too; the "O" after
+ // the first "H" is inside its line, so its value does nothing.
+ let runs = vec![run("HO", before.clone()), run("H\n", style(12.0)), run("H", before.clone()), run("O", style(12.0))];
+ let svg = export(&area_doc(runs, 200.0, Justify::Left), &ExportOptions::default());
+ // The first on each baseline.
+ let mut starts: Vec<(f64, f64)> = vec![];
+ for t in tspans(&svg) {
+ let (x, y) = (attr(t, "x").unwrap(), attr(t, "y").unwrap());
+ if starts.last().is_none_or(|&(_, last)| last != y) {
+ starts.push((x, y));
+ }
+ }
+ assert_eq!(starts.iter().map(|s| s.0).collect::>(), vec![3.0, 3.0], "{svg}");
+ // Centred, the line is 3 pt longer and the anchor sits at the middle of what a reader draws.
+ let svg = export(&area_doc(vec![run("H", before.clone()), run("OH", style(12.0))], 200.0, Justify::Center), &ExportOptions::default());
+ let x = tspans(&svg).iter().find_map(|t| attr(t, "x")).unwrap();
+ assert!((x - 101.5).abs() < 0.01, "{svg}");
+}
+
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
#[test]
fn proportional_metrics_write_palt() {
diff --git a/crates/text/src/edit.rs b/crates/text/src/edit.rs
index a4505f9596..c2838fbbe8 100644
--- a/crates/text/src/edit.rs
+++ b/crates/text/src/edit.rs
@@ -214,6 +214,28 @@ pub fn next_char(s: &str, i: usize) -> usize {
s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len())
}
+/// Does the character at byte `i` of `s` start a line by itself: the first character of the text,
+/// of a paragraph, or after a forced line break (U+2028)?
+pub fn starts_line(s: &str, i: usize) -> bool {
+ i == 0 || s.get(..i).is_some_and(|before| before.ends_with(['\n', '\u{2028}']))
+}
+
+/// The character whose kerning the Type tool's caret at byte `i` of `s` sets (the Kerning field,
+/// Alt+arrows, Reset Tracking and Kerning), and whether that kerning is the space before it
+/// ([`vectorcraft_doc::CharStyle::kerning_before`]) or after it
+/// ([`vectorcraft_doc::CharStyle::kerning`]). Inside a line it is the space after the character
+/// before the caret. At the start of a line ([`starts_line`]) no character on the line comes before
+/// the caret, so it is the space before the character after it (#5111). None at the start of an
+/// empty line, where no character follows either.
+pub fn caret_kerning_target(s: &str, i: usize) -> Option<(Range, bool)> {
+ let i = floor_char(s, i);
+ if !starts_line(s, i) {
+ return Some((prev_char(s, i)..i, false));
+ }
+ let c = s.get(i..)?.chars().next()?;
+ (c != '\n' && c != '\u{2028}').then(|| (i..i + c.len_utf8(), true))
+}
+
/// The start of the grapheme cluster before `i` (Left arrow): a Thai consonant with its vowel and
/// tone marks, an accented letter or an emoji sequence is passed over whole.
pub fn prev_cluster(s: &str, i: usize) -> usize {
diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs
index b7c4f1060e..b781bd8b63 100644
--- a/crates/text/src/harakat.rs
+++ b/crates/text/src/harakat.rs
@@ -51,6 +51,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
leading,
tracking,
kerning,
+ kerning_before,
optical_kerning,
baseline_shift,
h_scale,
@@ -100,6 +101,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
&& leading == &b.leading
&& tracking == &b.tracking
&& kerning == &b.kerning
+ && kerning_before == &b.kerning_before
&& optical_kerning == &b.optical_kerning
&& baseline_shift == &b.baseline_shift
&& h_scale == &b.h_scale
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index 10c44eb73e..c68a4af43a 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -99,14 +99,17 @@ fn takes_cell(g: &SGlyph) -> bool {
///
/// It stands in glyph space as it stands in vertical point type's line space: a quarter turn
/// counterclockwise from horizontal path type's pose about the center of its cell, a baseline shift
-/// kept across the column and a leading space taken off moving it along the path, then moved across
+/// kept across the column and a leading space taken off (or the space before a column's first
+/// character) moving it along the path, then moved across
/// the path as a glyph lying on its side is.
fn upright_path_turn(g: &SGlyph, em: f64, pre: Affine, local: Affine, unturned: Affine, angle: f64, advance: f64) -> (Affine, f64) {
let cell = if takes_cell(g) { upright_cell(g) } else { advance };
let (shift, unshift) = (Affine::translate((0.0, -g.bshift)), Affine::translate((0.0, g.bshift)));
let about = Point::new(cell * 0.5, -upright_centre(g, cell, em));
- let mut turn =
- Affine::translate((-g.lead, g.face.ideographic_centre() * em)) * shift * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) * unshift;
+ let mut turn = Affine::translate((g.kern_shift - g.lead, g.face.ideographic_centre() * em))
+ * shift
+ * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about)
+ * unshift;
// The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the path's end on
// a path that runs to the left: it turns half a turn about its cell's center.
let tangent = Vec2::new(angle.cos(), angle.sin());
@@ -359,19 +362,20 @@ impl Ctx<'_> {
} else {
self.db.outline(&g.face, g.gid)
};
- // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an
- // upright one in vertical type by moving it up the column once it stands upright.
+ // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier, and one
+ // moved along the line by manual kerning ([`SGlyph::kern_shift`]) that much later: an
+ // upright one in vertical type by moving it along the column once it stands upright.
let upright = self.vertical && g.tcy.is_none() && stands_upright(g) && !(self.on_path && g.inline.is_some());
- let lead = if upright { 0.0 } else { g.lead };
+ let (lead, kern_shift) = if upright { (0.0, 0.0) } else { (g.lead, g.kern_shift) };
let local = match &g.inline {
// Inline graphics: art space to glyph space (no font outline), moved along the line by
// right-to-left manual kerning as a glyph is.
- Some(ib) => Affine::translate((g.kern_shift - lead, -g.bshift)) * ib.xf,
+ Some(ib) => Affine::translate((kern_shift - lead, -g.bshift)) * ib.xf,
// Baseline shift moves the glyph across its line, and Character Rotation then turns it
// about its shifted pen position, so the shift is across the line at any rotation.
// Manual kerning and composition trim move that pen position along the line.
None => {
- Affine::translate((g.kern_shift - lead, -g.bshift))
+ Affine::translate((kern_shift - lead, -g.bshift))
* Affine::rotate(-g.rotation.to_radians())
* Affine::translate((g.dx, g.dy))
* Affine::scale_non_uniform(g.sx, g.sy)
@@ -379,7 +383,7 @@ impl Ctx<'_> {
};
// The turning point in font units, which later moves of the glyph's transform keep.
let pivot = match local.determinant() {
- det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(g.kern_shift - lead, -g.bshift),
+ det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(kern_shift - lead, -g.bshift),
_ => Point::ZERO,
};
let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 {
@@ -490,9 +494,10 @@ impl Ctx<'_> {
// space after the cell, and must not push the glyph off the centre line).
let em = self.style_at(g.byte).size;
let cell = if takes_cell(g) { upright_cell(g) } else { advance };
- // A leading space taken off moves it up the column after the turn (moving the
- // turning point instead would move it across the column too).
- let turn = Affine::translate((-g.lead, 0.0))
+ // A leading space taken off moves it up the column after the turn, and the space
+ // before a column's first character down it (moving the turning point instead
+ // would move it across the column too).
+ let turn = Affine::translate((g.kern_shift - g.lead, 0.0))
* Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em)));
m = shift * turn * unshift * m;
// Its own advance across, from where the cell centres it: where the shaper set the
@@ -527,10 +532,16 @@ impl Ctx<'_> {
};
// Where its own width starts along its advance: the shaper's placement, a leading space
// taken off and right-to-left manual kerning move the glyph along the line. An upright or
- // tate-chu-yoko glyph in vertical type starts its cell at the start of its advance.
- let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) {
+ // tate-chu-yoko glyph in vertical type starts its cell at the start of its advance, an
+ // upright one after the space before a column's first character.
+ let along = {
let [a, b, ..] = pre.as_coeffs();
- Some((g.kern_shift + g.dx - g.lead) * a.hypot(b)).filter(|s| s.is_finite()).unwrap_or(0.0)
+ a.hypot(b)
+ };
+ let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) {
+ Some((g.kern_shift + g.dx - g.lead) * along).filter(|s| s.is_finite()).unwrap_or(0.0)
+ } else if upright {
+ Some(g.kern_shift * along).filter(|s| s.is_finite()).unwrap_or(0.0)
} else {
0.0
};
diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs
index 3c56b2c914..e5d85d50fc 100644
--- a/crates/text/src/shape.rs
+++ b/crates/text/src/shape.rs
@@ -39,7 +39,9 @@ pub(crate) struct SGlyph {
pub dy: f64,
/// Right to left: the run's manual kerning, which moves the glyph right along the baseline,
/// outside its Character Rotation; with whole-point advances, the difference the kerning makes
- /// to the cluster's rounded advance. 0 otherwise.
+ /// to the cluster's rounded advance. Left to right and in vertical type: the space before a
+ /// line's first character ([`CharStyle::kerning_before`]), which moves it along the line the
+ /// same way. 0 otherwise.
pub kern_shift: f64,
/// Outline scale (font units -> points).
pub sx: f64,
@@ -522,6 +524,20 @@ pub(crate) fn shape_runs(
}
}
+/// [`CharStyle::kerning_before`] of the character at `byte` of `text` in style `st`, in points of
+/// `spacing_em`: the space before it when it starts a line by itself ([`crate::edit::starts_line`]), else 0. Its
+/// cluster takes the space in its advance, as manual kerning takes the space after a character,
+/// and left to right (and in vertical type) its glyphs are drawn that much later on the line
+/// (#5111). Right to left, the line starts at its right: the space the advance gains is already on
+/// that side.
+fn space_before(text: &str, byte: usize, st: &CharStyle, spacing_em: f64) -> f64 {
+ if st.kerning_before == 0.0 || !crate::edit::starts_line(text, byte) || text.get(byte..).is_some_and(|rest| rest.starts_with(['\n', '\u{2028}']))
+ {
+ return 0.0;
+ }
+ Some(st.kerning_before / 1000.0 * spacing_em).filter(|s| s.is_finite()).unwrap_or(0.0)
+}
+
/// Line segments of a glyph outline in font units, cached for the current shaping pass.
type Profile = Vec<(f64, f64, f64, f64)>;
type CachedProfile = (Profile, (f64, f64));
@@ -836,7 +852,8 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
/// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the
/// character style's name, leading, baseline shift, rotation, Character Alignment, Character
/// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its
-/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard
+/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`])
+/// and the tracking, as long as the tracking keeps or drops standard
/// ligatures alike ([`crate::features::ligatures_suppressed_by`]).
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
// Every field is named, so that a new attribute has to be sorted into one group or the other.
@@ -849,6 +866,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
leading: _,
tracking,
kerning: _,
+ kerning_before: _,
optical_kerning: _,
baseline_shift: _,
h_scale,
@@ -948,8 +966,9 @@ fn shaping_script(c: char) -> Script {
///
/// The art is scaled uniformly to `scale` x the font size tall, its left edge on the pen and its
/// vertical centre on the middle of the cap height raised by the inline's shift (see
-/// [`InlineArt`]). Its advance is the scaled art width plus tracking and its character's manual
-/// kerning, which right to left in horizontal type also moves the art right by the value, as it
+/// [`InlineArt`]). Its advance is the scaled art width plus tracking, its character's manual
+/// kerning and the space before it at a line's start ([`space_before`]). Right to left in
+/// horizontal type the manual kerning also moves the art right by the value, as it
/// moves a letter's glyph. The art doesn't take the run's Horizontal Scale; the tracking and the
/// kerning do, as after a letter ([`spacing_scale`]). The glyph's ascent and descent grow to the
/// art's extent so a tall graphic opens up its line. A missing symbol takes a square of the same
@@ -983,7 +1002,8 @@ fn inline_glyph(
let spacing_em = size * spacing_scale(st, vertical);
let tracking = Some(st.tracking / 1000.0 * spacing_em).filter(|t| t.is_finite()).unwrap_or(0.0);
let manual_kern = st.kerning.map(|v| v / 1000.0 * spacing_em).filter(|k| k.is_finite()).unwrap_or(0.0);
- let adv = width + tracking + manual_kern;
+ let before = space_before(text, range.start, st, spacing_em);
+ let adv = width + tracking + manual_kern + before;
SGlyph {
face: face.clone(),
gid: 0,
@@ -994,7 +1014,7 @@ fn inline_glyph(
tracking: if adv.is_finite() { tracking } else { 0.0 },
dx: 0.0,
dy: 0.0,
- kern_shift: if level.is_rtl() && !vertical { manual_kern } else { 0.0 },
+ kern_shift: if level.is_rtl() && !vertical { manual_kern } else { before },
// Not used to draw (the art has its own transform), but they make the glyph's em box
// its run's em: Character Alignment and Top-to-Top leading treat an inline graphic as a
// character of its run's size, whatever its scale (see `layout::glyph_em`).
@@ -1407,15 +1427,19 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe
adv = 0.0;
} else {
let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0);
+ let before = space_before(text, cl, gst, spacing_em);
// Manual kerning spaces a character from the next one in reading order. Right to left,
// that one is drawn to its left: the cluster's glyphs move right along the baseline by
// the value, so that the space its last glyph's advance gains is at the cluster's left.
+ // The space before a line's first character moves them the other way.
if level.is_rtl() && !feats.vertical {
kern_shift = manual_kern;
+ } else {
+ kern_shift = before;
}
if last_in_cluster {
tracked = gst.tracking / 1000.0 * spacing_em;
- adv += tracked + manual_kern;
+ adv += tracked + manual_kern + before;
}
}
out.push(SGlyph {
diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs
index 8934090d5a..cdd4248220 100644
--- a/crates/text/src/tests.rs
+++ b/crates/text/src/tests.rs
@@ -19,6 +19,7 @@ mod inline;
mod joiners;
mod justify;
mod kept_layouts;
+mod kerning_before;
mod ligature_carets;
mod path_joins;
mod ruby;
diff --git a/crates/text/src/tests/kerning_before.rs b/crates/text/src/tests/kerning_before.rs
new file mode 100644
index 0000000000..c33f3fd97a
--- /dev/null
+++ b/crates/text/src/tests/kerning_before.rs
@@ -0,0 +1,121 @@
+//! The space before a character that starts a line by itself (`CharStyle::kerning_before`, #5111):
+//! the first character of the text, of a paragraph or after a forced line break moves along its line
+//! by the value, the rest of the line follows, and the value does nothing anywhere else.
+use crate::*;
+use kurbo::Shape;
+use vectorcraft_doc::{CharStyle, Justify};
+
+fn db() -> &'static FontDb {
+ FontDb::global()
+}
+
+fn style(size: f64) -> CharStyle {
+ CharStyle { size, ..CharStyle::default() }
+}
+
+/// Where glyph `byte` is drawn along a horizontal line.
+fn drawn_x(l: &TextLayout, byte: usize) -> f64 {
+ l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap()
+}
+
+/// `t` with `kerning_before` set on the character at `byte`.
+fn spaced(t: &TextObject, byte: usize, value: f64) -> TextObject {
+ let mut t = t.clone();
+ let end = edit::next_char(&t.plain_text(), byte);
+ edit::style_range(&mut t.runs, byte, end, |st| st.kerning_before = value);
+ t
+}
+
+/// How far each of `bytes` moved along the line from `a` to `b`.
+fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec {
+ let (la, lb) = (layout(db(), a), layout(db(), b));
+ bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect()
+}
+
+fn near(values: &[f64], expected: &[f64]) -> bool {
+ values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6)
+}
+
+#[test]
+fn the_first_character_moves_along_its_line_and_the_rest_follow() {
+ let plain = TextObject::point(Point::ZERO, "HOH", style(100.0));
+ // 250/1000 em of 100 pt: 25 pt, away from the line's start; a negative value pulls it back.
+ let away = moves(&plain, &spaced(&plain, 0, 250.0), &[0, 1, 2]);
+ assert!(near(&away, &[25.0, 25.0, 25.0]), "{away:?}");
+ let back = moves(&plain, &spaced(&plain, 0, -100.0), &[0, 1, 2]);
+ assert!(near(&back, &[-10.0, -10.0, -10.0]), "{back:?}");
+ // The line is that much longer, so a centred line moves its first character half as far.
+ let mut centred = plain.clone();
+ centred.para.justify = Justify::Center;
+ let half = moves(¢red, &spaced(¢red, 0, 250.0), &[0, 2]);
+ assert!(near(&half, &[12.5, 12.5]), "{half:?}");
+}
+
+#[test]
+fn it_does_nothing_on_a_character_inside_a_line() {
+ let plain = TextObject::point(Point::ZERO, "HOH", style(100.0));
+ let inside = moves(&plain, &spaced(&plain, 1, 250.0), &[0, 1, 2]);
+ assert!(near(&inside, &[0.0, 0.0, 0.0]), "{inside:?}");
+ // Nor on the first character of a line that wrapped in area type: the line break moves.
+ let area = super::area("HOH HOH", style(50.0), Rect::new(0.0, 0.0, 180.0, 400.0), Justify::Left);
+ let wrapped = layout(db(), &area);
+ let second_line = wrapped.glyphs.iter().find(|g| g.byte == 4).unwrap().line;
+ assert_eq!(second_line, 1, "the second HOH wraps");
+ let moved = moves(&area, &spaced(&area, 4, 250.0), &[0, 4, 5]);
+ assert!(near(&moved, &[0.0, 0.0, 0.0]), "{moved:?}");
+}
+
+#[test]
+fn a_paragraph_and_a_forced_line_break_start_lines_by_themselves() {
+ let paragraphs = TextObject::point(Point::ZERO, "HO\nHO", style(100.0));
+ let moved = moves(¶graphs, &spaced(¶graphs, 3, 250.0), &[0, 1, 3, 4]);
+ assert!(near(&moved, &[0.0, 0.0, 25.0, 25.0]), "{moved:?}");
+ let forced = TextObject::point(Point::ZERO, "HO\u{2028}HO", style(100.0));
+ let after = "HO\u{2028}".len();
+ let moved = moves(&forced, &spaced(&forced, after, 250.0), &[0, after, after + 1]);
+ assert!(near(&moved, &[0.0, 25.0, 25.0]), "{moved:?}");
+ // On a line break it stays: an empty line has nothing to move.
+ let empty = TextObject::point(Point::ZERO, "HO\n\nHO", style(100.0));
+ let moved = moves(&empty, &spaced(&empty, 3, 250.0), &[0, 4]);
+ assert!(near(&moved, &[0.0, 0.0]), "{moved:?}");
+}
+
+#[test]
+fn it_takes_the_em_of_the_character_as_manual_kerning_does() {
+ // Horizontal Scale 50%: half the space, as kerning after a character takes it.
+ let narrow = TextObject::point(Point::ZERO, "HOH", CharStyle { h_scale: 50.0, ..style(100.0) });
+ let moved = moves(&narrow, &spaced(&narrow, 0, 250.0), &[0, 2]);
+ assert!(near(&moved, &[12.5, 12.5]), "{moved:?}");
+}
+
+#[test]
+fn right_to_left_the_space_is_at_the_right_of_the_first_character() {
+ // The first character in reading order is drawn rightmost; the space before it is on its right,
+ // so the two keep their distance, and the line is 25 pt longer.
+ let plain = TextObject::point(Point::ZERO, "אב", style(100.0));
+ let kerned = spaced(&plain, 0, 250.0);
+ let (a, b) = (layout(db(), &plain), layout(db(), &kerned));
+ assert!(drawn_x(&a, 0) > drawn_x(&a, 2), "right to left");
+ let gap = |l: &TextLayout| drawn_x(l, 0) - drawn_x(l, 2);
+ assert!((gap(&b) - gap(&a)).abs() < 1e-6, "{} {}", gap(&a), gap(&b));
+ let length = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0;
+ assert!((length(&b) - length(&a) - 25.0).abs() < 1e-6, "{} {}", length(&a), length(&b));
+}
+
+#[test]
+fn in_vertical_type_the_first_character_moves_down_its_column() {
+ // An upright glyph (§) and letters lying on their side both move 5 pt (250/1000 em of 20 pt)
+ // down the column, and stay in it.
+ for text in ["§§", "ab"] {
+ let mut plain = TextObject::point(Point::ZERO, text, style(20.0));
+ plain.xf = Affine::IDENTITY;
+ plain.vertical = true;
+ let kerned = spaced(&plain, 0, 250.0);
+ let (a, b) = (layout(db(), &plain), layout(db(), &kerned));
+ for i in 0..2 {
+ let (before, after) = (a.glyphs[i].outline.bounding_box(), b.glyphs[i].outline.bounding_box());
+ assert!((after.center().y - before.center().y - 5.0).abs() < 1e-6, "{text} {i}: {before:?} {after:?}");
+ assert!((after.center().x - before.center().x).abs() < 1e-6, "{text} {i}: {before:?} {after:?}");
+ }
+ }
+}
diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs
index 5f2f96a8cc..2f834a272c 100644
--- a/crates/ui-egui/src/panels/character.rs
+++ b/crates/ui-egui/src/panels/character.rs
@@ -266,8 +266,9 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
/// Set the Kerning field's `value` (a number, "auto", or "optical"). At a caret in text the Type tool edits
/// it is the pair kerning after the character before the caret, as Alt+←/→ steps it
-/// (`type.step`); nowhere to set at the start of the text. Else it is a character attribute like
-/// the others: the selected range, or all the text.
+/// (`type.step`), or at the start of a line the space before the character after it, where Auto
+/// and Optical set no space (#5111); nowhere to set at an empty line's start. Else it is a
+/// character attribute like the others: the selected range, or all the text.
fn set_kerning(app: &mut VectorcraftApp, value: Value) {
let Some((id, a, b)) = text_editing(app) else {
format(app, json!({"kerning": value}));
@@ -278,15 +279,18 @@ fn set_kerning(app: &mut VectorcraftApp, value: Value) {
return;
}
let Some(NodeKind::Text(t)) = app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) else { return };
- let start = vectorcraft_text::edit::prev_char(&t.plain_text(), a);
- if start >= a {
- return;
- }
+ let Some((target, before)) = vectorcraft_text::edit::caret_kerning_target(&t.plain_text(), a) else { return };
end_typing(app);
- app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok();
+ let params = if before {
+ json!({"id": id.0, "start": target.start, "end": target.end, "kerningBefore": value.as_f64().unwrap_or(0.0)})
+ } else {
+ json!({"id": id.0, "start": target.start, "end": target.end, "kerning": value})
+ };
+ app.run("text.setRangeStyle", params).ok();
}
-/// The pair before the caret, in 1/1000 em, using the layout the canvas draws.
+/// The kerning at the caret, in 1/1000 em, using the layout the canvas draws: the pair before it,
+/// or at the start of a line the space before the character after it.
fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> {
let (id, a, b) = text_editing(app)?;
if a != b {
@@ -294,11 +298,12 @@ fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> {
}
let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None };
let text = t.plain_text();
- let start = vectorcraft_text::edit::prev_char(&text, a);
- if start >= a {
- return None;
- }
+ let (target, before) = vectorcraft_text::edit::caret_kerning_target(&text, a)?;
+ let start = target.start;
let style = vectorcraft_text::edit::style_at(&t.runs, start + 1);
+ if before {
+ return Some((style.kerning_before, false));
+ }
if let Some(v) = style.kerning {
return Some((v, false));
}
diff --git a/docs/mcp.md b/docs/mcp.md
index 5826c5c8c4..862a05e92b 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -4773,7 +4773,8 @@ Pen's second anchor at (150, 431), in line with the square's centre.
`type.resetTracking` (Cmd+Alt+Q, `press_key {key: "Q", mods: {cmd: true, alt: true}}`) sets tracking to 0 and
clears manual kerning in the type objects in `ids`, the Type tool's selected text or the selected type objects,
keeping Optical; at a caret
- it resets only the kerning of the character before it. `type.resetHorizontalScale` (Cmd+Shift+X,
+ it resets only the kerning there: of the character before it, or at the start of a line (of the text, a paragraph,
+ or after a forced line break) the space before the character after it (`kerningBefore`). `type.resetHorizontalScale` (Cmd+Shift+X,
`press_key {key: "X", mods: {cmd: true, shift: true}}`) sets the horizontal scale to 100%, as the Character panel
shows it, in the same text; at a caret it leaves the text as it is. In the app, `type.highlightKerning` (Cmd+Alt+K),
`type.highlightTracking` (no default key) and `type.highlightFontName` (Cmd+Alt+Shift+F) show the Character panel and
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index b4c949f742..7b92606edb 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-11 · **Change:** minor (G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.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; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: 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; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; 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; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.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; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: 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; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; 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; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
>
> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369).
>
@@ -873,7 +873,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html).
- Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a new fill and a new stroke. Keyboard Shortcuts lists both under Window › Appearance.
- Cmd/Ctrl+Alt+Shift+. and , (`type.size.increaseByFive`, `type.size.decreaseByFive`) step the font size of the selected type, or of the text the Type tool has selected, by five times the Size/Leading increment (Preferences › Type); Cmd/Ctrl+Shift+. and , still step it by one increment.
- - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and clears manual kerning while keeping the automatic method, in one undo step; at a caret it resets only the kerning of the character before it.
+ - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and clears manual kerning while keeping the automatic method, in one undo step; at a caret it resets only the kerning there: of the character before it, or at the start of a line the space before the character after it (#5111).
- Cmd/Ctrl+Alt+K (`type.highlightKerning`) and Cmd/Ctrl+Alt+Shift+F (`type.highlightFontName`) show the Character panel and give its Kerning field the keyboard focus with the value selected, or open its font family menu with the search field focused, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for highlighting kerning and the font name field. The page lists the same key for selecting the tracking value; `type.highlightTracking` focuses the Tracking field the same way and has no default key in Keyboard Shortcuts.
- Cmd/Ctrl+Alt+L (`ui.newLayer`) opens Layer Options for a new layer, as an Alt-click on the Layers panel's New Layer button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a layer through the New Layer dialog box. OK adds the layer above the current layer, and Cancel adds none. Cmd/Ctrl+L still adds a layer without the dialog.
- F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new symbol. OK makes the symbol and puts an instance of it in place of the art, and Cancel makes none. With nothing selected, F8 opens no dialog, and the status bar says that nothing is selected. Shift+F8 and Cmd/Ctrl+F8 still show the Transform and Info panels.
@@ -1122,6 +1122,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`.
- **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`.
- Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291).
+- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn; vertical type moves the character down its column. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG).
- **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own: each pair moves part of the way toward the font's own stem spacing (the gap of `nn`, else `HH` or `oo`), keeps at least part of that gap where the outlines come closest, and changes by at most 0.12 em tighter or 0.06 em looser, so ordinary text keeps the font's spacing (#5336). Ideographs, kana, hangul, fullwidth and CJK punctuation forms and dashes keep the font's spacing. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`.
- **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`.
- **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`.
@@ -1981,6 +1982,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
| Date | Change | Summary |
|---|---|---|
+| 2026-11-11 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. |
| 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. |
| 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. |
| 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. |
diff --git a/docs/type-parity.md b/docs/type-parity.md
index 702ecd5048..7f0ac969f2 100644
--- a/docs/type-parity.md
+++ b/docs/type-parity.md
@@ -1,6 +1,6 @@
# Type parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
+| 2026-11-11 | minor | Kerning at the start of a line (#5111): at the Type tool's caret before the first character of the text, of a paragraph or after a forced line break, the Kerning field, Alt+arrows and Reset Tracking and Kerning set the space before the character after the caret (`CharStyle::kerning_before`, `kerningBefore` in `text.setRangeStyle`), which moves it and the rest of its line, in horizontal, right-to-left and vertical type and in SVG export. They did nothing at the text's start and kerned the invisible line break at a paragraph's. Scores and estimates unchanged. |
| 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) |
| 2026-11-11 | minor | Tracking and manual kerning in horizontally scaled type: they are thousandths of the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), so at 50% a tracking of 100 adds 0.05 em after each character, half as much as at 100%. They were added at the full em whatever the scale, so narrow tracked type drifted further right with each letter. Illustrator's own layout in users' `.ai` files (used locally, not committed) spaces such type so, and PDF scales its character spacing by its horizontal scaling alike (ISO 32000-1 §9.4.4). Inline graphics take it after them too; vertical type keeps the full em, its advances down the column not all taking the Horizontal Scale. The automatic kerning the Character panel reads, Touch Type's limits and Artwork Cleanup's Combine Point Text measure in the same em, and Scale Text Attributes now keeps tracked type where a horizontal stretch of its transform drew it. Type that has both a Horizontal Scale other than 100% and tracking or manual kerning lays out differently than before in every document. Tests `tests::tracking_and_manual_kerning_take_the_horizontal_scale`, `tests::typo::optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking`, `tests::inline::shift_tracking_and_missing_symbols`, `tests::vertical::vertical_tracking_keeps_the_em_at_any_horizontal_scale` and the engine's `horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it`, `scale_text_attributes_keeps_tracked_type_where_it_was` and `combine_puts_horizontally_scaled_pieces_back_where_they_were`. Scores and estimates unchanged. |
| 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. 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 09/72] 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 b426822a9cf33461bc4fcbfac261e39efcea66b7 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 13:06:51 -0800
Subject: [PATCH 10/72] Type: Release Type on a Path keeps the size Dynamic
Text drew the type at
Release Type on a Path copied the stored runs into point type with the
Dynamic Text flag still set, so the text dropped back to its stored size
and the file saved the flag on point type. The point type now gets the
drawn sizes and the flag is turned off.
The legacy .ai writer writes Dynamic Text type at its drawn sizes; it
now also warns that the setting itself is not written, as the editing
data writer does, whether the type is written as text or as outlines.
docs/target-app-parity.md lists reshaping or scaling the path among
VectorCraft's own rules, and a task bar comment marks leaving the item
out for threaded type as VectorCraft's own rule.
---
crates/engine/src/cmd/addons/typetools.rs | 11 ++++++---
crates/engine/src/tests/dynamic_text.rs | 20 +++++++++++++++
crates/eps/src/legacy_ai.rs | 1 +
crates/eps/src/legacy_ai/tests.rs | 30 +++++++++++++++++++++++
crates/eps/src/legacy_ai/writer.rs | 8 ++++--
crates/ui-egui/src/canvas/task_bar.rs | 3 ++-
docs/mcp.md | 12 +++++----
docs/target-app-parity.md | 2 +-
8 files changed, 74 insertions(+), 13 deletions(-)
diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs
index bc1d4e4f25..537e6bfe75 100644
--- a/crates/engine/src/cmd/addons/typetools.rs
+++ b/crates/engine/src/cmd/addons/typetools.rs
@@ -21,7 +21,7 @@ use super::super::menucmds::whole_or;
use super::super::object::choice;
use super::super::textedit::{CharChange, text_mut, text_ref, type_ranges};
use super::super::typecmd::refresh_bounds;
-use super::super::typemenu::text_ids;
+use super::super::typemenu::{bake_dynamic_text, text_ids};
use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or, targets};
use crate::{EngineError, Result, Session};
@@ -61,7 +61,7 @@ pub fn specs() -> Vec {
"Release Type on a Path…",
["Object", "Plug-ins", "Type Tools"],
None,
- "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
+ "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
has_selection,
release_path
),
@@ -230,8 +230,9 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option {
/// `t`, type on a path, as point type on one straight line: the same runs and character and
/// paragraph attributes, its baseline starting where its first character sat on the path (in the
-/// object's own text space, so its transform stays). Type on a Path Options are dropped. None for
-/// other type, or when it can't be placed.
+/// object's own text space, so its transform stays). Type on a Path Options are dropped, and
+/// Dynamic Text is turned off at the sizes it draws the text at. None for other type, or when it
+/// can't be placed.
fn released_text(t: &TextObject) -> Option {
let TextKind::OnPath { path, .. } = &t.kind else { return None };
let db = vectorcraft_text::FontDb::global();
@@ -244,6 +245,8 @@ fn released_text(t: &TextObject) -> Option {
(p.x.is_finite() && p.y.is_finite()).then_some(p)
})?;
let mut out = t.clone();
+ // Point type has no brackets to fit: it keeps the sizes the path drew it at.
+ bake_dynamic_text(&mut out);
out.kind = TextKind::Point;
out.path_effect = Default::default();
out.path_align = Default::default();
diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs
index 80ad1f1af9..f9d21a6404 100644
--- a/crates/engine/src/tests/dynamic_text.rs
+++ b/crates/engine/src/tests/dynamic_text.rs
@@ -238,6 +238,26 @@ fn svg_export_writes_the_drawn_size() {
assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}");
}
+#[test]
+fn release_type_on_a_path_keeps_the_drawn_size() {
+ let mut s = session();
+ let id = on_line(&mut s, "Release");
+ s.execute("type.dynamicText", &json!({"on": true})).unwrap();
+ let size = 12.0 * fit(&s, id);
+ assert!(size > 24.0, "the text grows on the long line: {size}");
+ let r = s.execute("addon.type.releasePath", &json!({"ids": [id.0], "mode": "text"})).unwrap();
+ let released = NodeId(r["texts"][0].as_u64().unwrap());
+ let t = text(&s, released);
+ assert!(!t.dynamic_text, "point type does not keep Dynamic Text");
+ assert!((t.runs[0].style.size - size).abs() < 1e-9, "{} vs the drawn {size}", t.runs[0].style.size);
+ assert_eq!(fit(&s, released), 1.0);
+ // Type on a path without Dynamic Text keeps its stored size.
+ let plain = on_line(&mut s, "Plain");
+ let r = s.execute("addon.type.releasePath", &json!({"ids": [plain.0], "mode": "text"})).unwrap();
+ let t = text(&s, NodeId(r["texts"][0].as_u64().unwrap()));
+ assert!(!t.dynamic_text && t.runs[0].style.size == 12.0);
+}
+
/// The id of the first text object in `s`'s document.
fn first_text(s: &Session) -> NodeId {
let mut found = None;
diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs
index 6b553b8d1a..0bf3960e86 100644
--- a/crates/eps/src/legacy_ai.rs
+++ b/crates/eps/src/legacy_ai.rs
@@ -97,6 +97,7 @@ pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines";
pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)";
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, manual kerning, optical kerning, small caps, superscript and subscript, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out";
+pub const DYNAMIC_TEXT: &str = "Dynamic Text is written as the sizes the type is drawn at (legacy .ai files don't have it)";
pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)";
pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)";
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs
index 979cd2a436..988cd9a1f0 100644
--- a/crates/eps/src/legacy_ai/tests.rs
+++ b/crates/eps/src/legacy_ai/tests.rs
@@ -1233,6 +1233,36 @@ fn a_font_height_reference_is_written_as_the_size_drawn() {
assert!((size - 20.0).abs() < 1e-6, "{size}");
}
+/// Type on a path with Dynamic Text is written at the sizes it is drawn at, with a warning that
+/// the setting itself is not written; without Dynamic Text there is no warning.
+#[test]
+fn dynamic_text_is_written_at_its_drawn_sizes_with_a_warning() {
+ let mut doc = typed();
+ assert!(!write(&doc).warnings.iter().any(|w| w == DYNAMIC_TEXT));
+ let id = texts(&doc).into_iter().find(|(_, t)| matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. })).unwrap().0.id;
+ let mut drawn = 0.0;
+ if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
+ t.runs[0].style.size = 4.0;
+ t.dynamic_text = true;
+ drawn = 4.0 * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale;
+ }
+ assert!(drawn > 4.0, "{drawn}");
+ let out = write(&doc);
+ assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings);
+ let imported = crate::import(&out.bytes).unwrap();
+ let (_, back) = texts(&imported.document).into_iter().find(|(_, t)| t.runs.iter().any(|r| r.text.contains("path"))).unwrap();
+ assert!(!back.dynamic_text);
+ assert!((back.runs[0].style.size - drawn).abs() < 1e-3, "{} vs {drawn}", back.runs[0].style.size);
+ // Type written as outlines loses the setting too, and says so.
+ if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
+ t.runs[0].text.push('\u{2192}');
+ }
+ let out = write(&doc);
+ assert!(out.warnings.iter().any(|w| w == TYPE) && out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings);
+ let out = write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() });
+ assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings);
+}
+
/// Characters with Auto leading are written with their paragraph's Auto Leading share of the
/// size: 18 pt type in paragraphs at 150% is written with 27 pt leading.
#[test]
diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs
index dc5128565d..f71018996c 100644
--- a/crates/eps/src/legacy_ai/writer.rs
+++ b/crates/eps/src/legacy_ai/writer.rs
@@ -3,8 +3,8 @@ use super::encoding::{
unit, writable,
};
use super::{
- Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient,
- GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
+ Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, DYNAMIC_TEXT, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule,
+ Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE,
LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData,
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
@@ -666,6 +666,10 @@ impl<'a> Writer<'a> {
}
let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t);
let t = &*resolved;
+ // Written as text or as outlines, the type keeps its drawn sizes but not the setting.
+ if t.dynamic_text && matches!(t.kind, TextKind::OnPath { .. }) {
+ self.warn(DYNAMIC_TEXT);
+ }
if !self.outline_text {
if writable(self.doc, t) {
return self.text_object(t);
diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs
index eb038f0790..4eb5e7dc01 100644
--- a/crates/ui-egui/src/canvas/task_bar.rs
+++ b/crates/ui-egui/src/canvas/task_bar.rs
@@ -153,8 +153,9 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())),
TextKind::OnPath { .. } => {
items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none()));
- // Dynamic Text turns on or off, as the Type menu's item does; left out for type in a thread
+ // Dynamic Text turns on or off, as the Type menu's item does
// ().
+ // Leaving it out for type in a thread is VectorCraft's own rule.
if crate::menus::enabled(app, "type.dynamicText") {
items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none()));
}
diff --git a/docs/mcp.md b/docs/mcp.md
index 5826c5c8c4..9a36ac4025 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -7317,12 +7317,12 @@ sizes do not change: turning Dynamic Text off writes the sizes the text was draw
its look, in one undo step. Point type and area type in the selection are left as they are. Type on a path in a
thread is refused with an error that names it, and the menu item and the task bar item are not offered for it;
`text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and
-list them in `dynamicTextOff`. When every object already has the setting asked for, nothing changes and no undo step
+list them in `dynamicTextOff`; `addon.type.releasePath` turns it off at the drawn sizes on the point type it makes. When every object already has the setting asked for, nothing changes and no undo step
is recorded. It replies `{on, ids, fitScale}` (the first object's factor, 1 when off), and the menu item is checked
while the first selected type on a path has it on. `document.inspect` reports `dynamicText` and `fitScale` for type
-on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS and the
-editing data for other apps write the type at its drawn sizes; the editing data does not hold the setting and says
-so in a warning.
+on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS, legacy
+`.ai` and the editing data for other apps write the type at its drawn sizes; legacy `.ai` and the editing data do not
+hold the setting, and each says so in a warning.
```json
{"name":"run_command","arguments":{"command":"type.dynamicText","params":{"on":true}}}
@@ -9009,7 +9009,9 @@ VectorCraft addition and changes no parity score.
- **The text** keeps its runs with their character attributes and its paragraph attributes, and becomes point type on
one straight line along the object's own axis, with the baseline starting where the first character sat on the path
(type with no characters starts at its start bracket). Type on a Path Options (effect, align to path, spacing, flip,
- brackets) don't apply to point type and are dropped. Without `keepOriginal` the type object itself becomes the point
+ brackets) don't apply to point type and are dropped. Type with Dynamic Text on (`type.dynamicText`) becomes point
+ type with it off, at the sizes it was drawn at: each size, leading and baseline shift is multiplied by the fit factor.
+ Without `keepOriginal` the type object itself becomes the point
type, keeping its id, so a frame of threaded text leaves its thread with the text it holds, as
`type.convertToPointType` does for area type.
- It acts on the type on a path among `ids` (or `id`, else the selection), groups included; other objects are left
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index b4c949f742..2da0c4a6a6 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1158,7 +1158,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
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 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`).
+ 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 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: reshaping or scaling the path fits the text again; 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, threading the type or Release Type on a Path keeps the drawn sizes; SVG, EPS, legacy `.ai` 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.
The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction.
Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines.
From 7844657096deb4a9f7b1c00f3feb4c112d3ec5ad Mon Sep 17 00:00:00 2001
From: Sven Lafebre
Date: Wed, 11 Nov 2026 22:09:20 +0100
Subject: [PATCH 11/72] Fix #5111: keep ligatures whole and place line starts
in SVG by value
- drawn_alike ignores kerning_before, so a space before the "f" of "fi"
no longer splits the ligature.
- SVG export takes a line's starting space from kerningBefore and the em
shaping sets the character in (vectorcraft_text::line_start_space),
not from where the glyph is drawn, which can include a shaping offset;
the reader layout for justified lines leaves the space out, so those
lines start there too.
- Type on a path writes the space as the first character's dx; a space
after a line break, which can't write, is outlined.
---
crates/svg/src/export/text.rs | 44 ++++++++++++++++++-------
crates/svg/tests/it/text_layout.rs | 30 +++++++++++++++++
crates/text/src/lib.rs | 2 +-
crates/text/src/shape.rs | 27 ++++++++++++---
crates/text/src/tests/kerning_before.rs | 15 +++++++++
docs/target-app-parity.md | 2 +-
6 files changed, 102 insertions(+), 18 deletions(-)
diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs
index 5ff58a97bf..1e5fec21bd 100644
--- a/crates/svg/src/export/text.rs
+++ b/crates/svg/src/export/text.rs
@@ -258,6 +258,19 @@ impl Writer<'_> {
self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look");
return self.text_outlines(n, t);
}
+ // The space before the first character is written as its `dx`; one before a character
+ // after a line break, which the `` writes as a space, keeps its look as outlines.
+ let plain = t.plain_text();
+ let mut at = 0;
+ let spaced_break = t.runs.iter().any(|r| {
+ let start = at;
+ at += r.text.len();
+ r.style.kerning_before != 0.0 && (start..at).any(|b| b > 0 && vectorcraft_text::line_start_space(&plain, b, &r.style, false) != 0.0)
+ });
+ if spaced_break {
+ self.warn("type on a path with space before a character after a line break is outlined to keep its look");
+ return self.text_outlines(n, t);
+ }
// A `` holds the characters of the text, not the bullets or numbers drawn
// before them.
if !lay.markers.is_empty() {
@@ -559,10 +572,17 @@ impl Writer<'_> {
let a = self.attrs(&props);
let offset = fmt_num(at * 100.0, 3);
let mut s = format!("");
- for run in &drawn.runs {
+ let plain = drawn.plain_text();
+ let first = drawn.runs.iter().position(|r| !r.text.is_empty());
+ for (i, run) in drawn.runs.iter().enumerate() {
let mut diff = run_diff(&base, self.char_props(&run.style, space));
diff.extend(direction_props(&run.style));
- let a = self.attrs(&diff);
+ let mut a = self.attrs(&diff);
+ // The space before the first character (`kerningBefore`) moves it along the path.
+ let before = if first == Some(i) { vectorcraft_text::line_start_space(&plain, 0, &run.style, false) } else { 0.0 };
+ if before != 0.0 && before.is_finite() {
+ a.push_str(&format!(" dx=\"{}\"", fmt_num(before, self.opts.decimals)));
+ }
s.push_str(&format!("{} ", xml_text(&run.text.replace('\n', " "))));
}
s.push_str(" ");
@@ -752,14 +772,11 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
(advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x })
}
// The space before a line's first character (`kerningBefore`) is in its advance and
- // moves it along the line: a reader sets it from where it is drawn, without it.
- None if !lay.vertical
- && !g.rtl
- && g.len > 0
- && run.style.kerning_before != 0.0
- && vectorcraft_text::edit::starts_line(&plain, g.byte) =>
- {
- let before = Some(g.xf.translation().x - g.origin.x).filter(|b| b.is_finite()).unwrap_or(0.0);
+ // moves it along the line: a reader sets it that far along, without it. (Shrink
+ // Text to Fit scales it with the sizes.)
+ None if !lay.vertical && !g.rtl && g.len > 0 && run.style.kerning_before != 0.0 => {
+ let before = vectorcraft_text::line_start_space(&plain, g.byte, &run.style, false) * lay.fit_scale;
+ let before = if before.is_finite() { before } else { 0.0 };
(g.advance - before, g.origin.x + before)
}
None => (g.advance, g.origin.x),
@@ -849,10 +866,13 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning:
// A reader spaces words and letters as the font does.
p.justification = vectorcraft_doc::JustificationOptions::default();
}
- if !kerning {
- for r in &mut set.runs {
+ for r in &mut set.runs {
+ if !kerning {
r.style.kerning.get_or_insert(0.0);
}
+ // A reader puts no space before a line's first character (`kerningBefore`): the segment
+ // starts that much further along instead.
+ r.style.kerning_before = 0.0;
}
let relaid;
let glyphs = if set == *t {
diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs
index e8129a6922..e858f1ba08 100644
--- a/crates/svg/tests/it/text_layout.rs
+++ b/crates/svg/tests/it/text_layout.rs
@@ -131,6 +131,36 @@ fn the_space_before_a_lines_first_character_moves_its_tspan() {
let svg = export(&area_doc(vec![run("H", before.clone()), run("OH", style(12.0))], 200.0, Justify::Center), &ExportOptions::default());
let x = tspans(&svg).iter().find_map(|t| attr(t, "x")).unwrap();
assert!((x - 101.5).abs() < 0.01, "{svg}");
+ // A justified line, which a reader's own advances place word by word, starts there too.
+ let words = vec![run("H", before.clone()), run("OH OH OH OH OH OH OH OH OH OH", style(12.0))];
+ let svg = export(&area_doc(words, 90.0, Justify::JustifyLeft), &ExportOptions::default());
+ assert_eq!(attr(tspans(&svg)[0], "x"), Some(3.0), "{svg}");
+}
+
+/// On a path, the space before the first character is its `dx` along the path. One before a
+/// character after a line break, which the `` can't write, is kept as outlines.
+#[test]
+fn the_space_before_the_first_character_on_a_path_is_its_dx() {
+ let doc = |runs: Vec| {
+ let mut t = TextObject::point(Point::ZERO, "", style(12.0));
+ t.runs = runs;
+ let mut line = BezPath::new();
+ line.move_to((20.0, 100.0));
+ line.line_to((280.0, 100.0));
+ t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
+ let mut d = Document::new(300.0, 200.0);
+ let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
+ let l = d.layers[0].id;
+ d.insert(Some(l), 0, n).unwrap();
+ vectorcraft_svg::export_full(&d, &ExportOptions::default(), None)
+ };
+ let before = CharStyle { kerning_before: 250.0, ..style(12.0) };
+ let out = doc(vec![run("HO", before.clone()), run("H", style(12.0))]);
+ let tags = tspans(&out.svg);
+ assert!(out.svg.contains(" f64
Some(st.kerning_before / 1000.0 * spacing_em).filter(|s| s.is_finite()).unwrap_or(0.0)
}
+/// The space before the character at `byte` of `text` in style `st`, in points, as the layout sets
+/// it ([`CharStyle::kerning_before`]): its thousandths of the em the character is set in, when it
+/// starts a line by itself, else 0. For writers that place a line's first character themselves.
+pub fn line_start_space(text: &str, byte: usize, st: &CharStyle, vertical: bool) -> f64 {
+ let small = st.small_caps.is_some() && !st.all_caps && text.get(byte..).and_then(|s| s.chars().next()).is_some_and(char::is_lowercase);
+ space_before(text, byte, st, glyph_size(st, small) * spacing_scale(st, vertical))
+}
+
+/// The size characters in style `st` are shaped at: Superscript, Subscript and synthesized Small
+/// Caps (`small`: a lowercase letter in them) shrink the glyphs; line metrics keep the full size.
+fn glyph_size(st: &CharStyle, small: bool) -> f64 {
+ let full = st.size.max(0.0);
+ let (pos_scale, _) = st.position.scale_shift(full);
+ let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 };
+ full * pos_scale * small_scale
+}
+
/// Line segments of a glyph outline in font units, cached for the current shaping pass.
type Profile = Vec<(f64, f64, f64, f64)>;
type CachedProfile = (Profile, (f64, f64));
@@ -833,8 +850,11 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte:
/// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that
/// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the
-/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there.
+/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. The space
+/// before a line's first character ([`space_before`]) is never read inside a cluster, which takes it
+/// from its first character, so it doesn't split a ligature either.
fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
+ let b = &CharStyle { kerning_before: a.kerning_before, ..b.clone() };
if a == b {
return true;
}
@@ -1099,9 +1119,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe
let cursive = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)).is_some_and(joins);
let full = st.size.max(0.0);
// Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size.
- let (pos_scale, pos_shift) = st.position.scale_shift(full);
- let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 };
- let size = full * pos_scale * small_scale;
+ let (_, pos_shift) = st.position.scale_shift(full);
+ let size = glyph_size(st, small);
let k = size / face.upem;
let km = full / face.upem;
let hs = st.h_scale / 100.0;
diff --git a/crates/text/src/tests/kerning_before.rs b/crates/text/src/tests/kerning_before.rs
index c33f3fd97a..f3d9e785a8 100644
--- a/crates/text/src/tests/kerning_before.rs
+++ b/crates/text/src/tests/kerning_before.rs
@@ -119,3 +119,18 @@ fn in_vertical_type_the_first_character_moves_down_its_column() {
}
}
}
+
+#[test]
+fn a_space_before_the_first_letter_of_a_ligature_keeps_it_whole() {
+ // Set on the "f" alone, as the caret before it sets it: Source Serif 4's "fi" ligature stays one
+ // glyph, and moves.
+ let serif = CharStyle { font_family: "Source Serif 4".into(), ..style(100.0) };
+ let plain = TextObject::point(Point::ZERO, "fit", serif);
+ let glyphs = |t: &TextObject| layout(db(), t).glyphs.len();
+ assert_eq!(glyphs(&plain), 2, "the font joins fi");
+ let kerned = spaced(&plain, 0, 250.0);
+ assert_eq!(glyphs(&kerned), 2, "still joined");
+ let moved = moves(&plain, &kerned, &[0, 2]);
+ assert!(near(&moved, &[25.0, 25.0]), "{moved:?}");
+ assert!((line_start_space("fit", 0, &kerned.runs[0].style, false) - 25.0).abs() < 1e-9);
+}
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 7b92606edb..b98b9bb77e 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1122,7 +1122,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`.
- **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`.
- Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291).
-- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn; vertical type moves the character down its column. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG).
+- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn, and on a path writes the space as the first character's `dx`; vertical type moves the character down its column. A space on the first letter of a ligature keeps the ligature whole. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG).
- **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own: each pair moves part of the way toward the font's own stem spacing (the gap of `nn`, else `HH` or `oo`), keeps at least part of that gap where the outlines come closest, and changes by at most 0.12 em tighter or 0.06 em looser, so ordinary text keeps the font's spacing (#5336). Ideographs, kana, hangul, fullwidth and CJK punctuation forms and dashes keep the font's spacing. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`.
- **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`.
- **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`.
From 133e2c135695b54396aa102acfe70f7a0c8c22ff Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 13:17:08 -0800
Subject: [PATCH 12/72] 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 bd9fef69b8bd83226a85e07f5ee76124a22bd615 Mon Sep 17 00:00:00 2001
From: Sven Lafebre
Date: Wed, 11 Nov 2026 22:24:49 +0100
Subject: [PATCH 13/72] Fix #5111: keep coloured harakat attached when the
first letter is spaced
paint_only ignores kerning_before, as drawn_alike does: a space before
a line's first letter, set on the letter alone, no longer splits it from
its harakat coloured apart.
---
crates/text/src/harakat.rs | 6 +++---
crates/text/src/tests/harakat.rs | 22 ++++++++++++++++++++++
2 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs
index b781bd8b63..435bbc4ed7 100644
--- a/crates/text/src/harakat.rs
+++ b/crates/text/src/harakat.rs
@@ -38,7 +38,8 @@ pub(crate) fn is_harakat(c: char) -> bool {
/// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the
/// stroke is drawn, overprint, and the character style name? Those are not read by the shaper
-/// and change no glyph, advance or position. Stricter than [`crate::shape`]'s `shapes_alike`
+/// and change no glyph, advance or position. Nor is the space before a line's first character
+/// inside a cluster, which takes it from its first character (#5111), so it is ignored too. Stricter than [`crate::shape`]'s `shapes_alike`
/// (which also ignores baseline shift, tracking, underline, …).
pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
// Exhaustive: a new attribute must explicitly keep or relax this boundary rule.
@@ -51,7 +52,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
leading,
tracking,
kerning,
- kerning_before,
+ kerning_before: _,
optical_kerning,
baseline_shift,
h_scale,
@@ -101,7 +102,6 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
&& leading == &b.leading
&& tracking == &b.tracking
&& kerning == &b.kerning
- && kerning_before == &b.kerning_before
&& optical_kerning == &b.optical_kerning
&& baseline_shift == &b.baseline_shift
&& h_scale == &b.h_scale
diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs
index e590f103c7..0aa8bf82aa 100644
--- a/crates/text/src/tests/harakat.rs
+++ b/crates/text/src/tests/harakat.rs
@@ -171,6 +171,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() {
b.stroke_dash = a.stroke_dash.clone();
b.stroke_align = a.stroke_align;
b.style_name = a.style_name.clone();
+ b.kerning_before = a.kerning_before;
*a == b
}
@@ -203,6 +204,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() {
field!(leading, Some(60.0), false);
field!(tracking, 20.0, false);
field!(kerning, Some(0.0), false);
+ field!(kerning_before, 250.0, true);
field!(optical_kerning, true, false);
field!(baseline_shift, 1.0, false);
field!(h_scale, 90.0, false);
@@ -312,6 +314,26 @@ fn harakat_ranges_normalise_their_range() {
/// the same way, and only a character in that glyph's shaping cluster: repeated glyph ids alone
/// don't do. A later in-cluster owner is kept only for a GDEF mark glyph on a harakat character;
/// everything else stays at the cluster start (conservative).
+/// A space before the line's first letter (#5111), set on the letter alone as the Type tool's caret
+/// sets it, keeps the letter's harakat coloured apart attached and in their own colour. Right to
+/// left the space is past the letter's right side, so every glyph stays where it was.
+#[test]
+fn a_space_before_the_first_letter_keeps_its_coloured_harakat() {
+ let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") else {
+ return;
+ };
+ const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8
+ let db = FontDb::global();
+ let coloured = text_with_harakat(WORD, &face.family, Some(red()));
+ let mut spaced = coloured.clone();
+ crate::edit::style_range(&mut spaced.runs, 0, 2, |st| st.kerning_before = 250.0);
+ let (a, b) = (layout(db, &coloured), layout(db, &spaced));
+ let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.xf.as_coeffs().map(f64::to_bits))).collect::>();
+ assert_eq!(placed(&a), placed(&b), "the same glyphs where they were");
+ assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "the harakat keep their colour");
+ assert!(painted(&spaced, &b, &red()) > 0, "harakat painted apart");
+}
+
#[test]
fn own_chars_need_the_same_setting_and_each_character_in_its_cluster() {
// بَبَب right to left: final beh (cluster 8), fatha and medial beh (cluster 4), fatha and
From d9f961b0c15a3e5955a9de213bfffe5ef7873f2f Mon Sep 17 00:00:00 2001
From: Sven Lafebre
Date: Wed, 11 Nov 2026 22:38:30 +0100
Subject: [PATCH 14/72] Fix #5111: check spaced harakat with the in-memory
Arabic font
The harakat test now runs on the in-memory Arabic test font in every
environment, as well as on an installed Arabic face, and checks that
the line is the space longer.
---
crates/text/src/tests/harakat.rs | 28 +++++++++++++++++++---------
1 file changed, 19 insertions(+), 9 deletions(-)
diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs
index 0aa8bf82aa..9bb7700d66 100644
--- a/crates/text/src/tests/harakat.rs
+++ b/crates/text/src/tests/harakat.rs
@@ -316,22 +316,32 @@ fn harakat_ranges_normalise_their_range() {
/// everything else stays at the cluster start (conservative).
/// A space before the line's first letter (#5111), set on the letter alone as the Type tool's caret
/// sets it, keeps the letter's harakat coloured apart attached and in their own colour. Right to
-/// left the space is past the letter's right side, so every glyph stays where it was.
+/// left the space is past the letter's right side, so every glyph stays where it was and the line
+/// is the space longer. Checked with the in-memory Arabic test font (its fatha a `GDEF` mark) in
+/// every environment, and with an installed Arabic face when there is one.
#[test]
fn a_space_before_the_first_letter_keeps_its_coloured_harakat() {
- let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") else {
- return;
- };
+ let db = FontDb::with_font_dirs(vec![]);
+ assert_eq!(db.add_font(crate::test_fonts::lam_alef_font(b"rlig").unwrap()), 1);
+ check_spaced_harakat(&db, crate::test_fonts::lam_alef_family(b"rlig"));
+ if let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") {
+ check_spaced_harakat(FontDb::global(), &face.family);
+ }
+}
+
+fn check_spaced_harakat(db: &FontDb, family: &str) {
const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8
- let db = FontDb::global();
- let coloured = text_with_harakat(WORD, &face.family, Some(red()));
+ let coloured = text_with_harakat(WORD, family, Some(red()));
let mut spaced = coloured.clone();
crate::edit::style_range(&mut spaced.runs, 0, 2, |st| st.kerning_before = 250.0);
let (a, b) = (layout(db, &coloured), layout(db, &spaced));
let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.xf.as_coeffs().map(f64::to_bits))).collect::>();
- assert_eq!(placed(&a), placed(&b), "the same glyphs where they were");
- assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "the harakat keep their colour");
- assert!(painted(&spaced, &b, &red()) > 0, "harakat painted apart");
+ assert_eq!(placed(&a), placed(&b), "{family}: the same glyphs where they were");
+ assert!(painted(&spaced, &b, &red()) > 0, "{family}: harakat painted apart");
+ assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "{family}: the harakat keep their colour");
+ // 250/1000 of the 48 pt em.
+ let length = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::();
+ assert!((length(&b) - length(&a) - 12.0).abs() < 1e-6, "{family}: {} → {}", length(&a), length(&b));
}
#[test]
From 39683ecdd90db1d3286f7a5d617c888ebc64be66 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 13:55:47 -0800
Subject: [PATCH 15/72] PDF/.ai export: fit Dynamic Text before resolving font
heights
The legacy .ai writer resolved cap-height and other font height
references to em sizes before fitting, so Dynamic Text stepped and
limited the em size instead of the size the Character panel shows, and
the type could be written smaller than it is drawn. It now fits first,
and outlines come from the type's own layout.
The editing data now also warns that Dynamic Text is not written when
the type is written as outlines.
---
crates/eps/src/ai_editing/tests.rs | 13 ++++++++++
crates/eps/src/ai_editing/writer.rs | 4 +++
crates/eps/src/legacy_ai/tests.rs | 38 +++++++++++++++++++++++++++++
crates/eps/src/legacy_ai/writer.rs | 16 ++++++------
4 files changed, 63 insertions(+), 8 deletions(-)
diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs
index 67ca443a7b..2af2ecd394 100644
--- a/crates/eps/src/ai_editing/tests.rs
+++ b/crates/eps/src/ai_editing/tests.rs
@@ -4366,6 +4366,19 @@ fn dynamic_text_is_written_at_its_drawn_sizes() {
assert!(!texts[0].dynamic_text);
let k = texts[0].xf.as_coeffs()[0];
assert_eq!(r3(texts[0].runs[0].style.size * k), r3(drawn));
+ // Type written as outlines (here on an open path whose ends meet) loses the setting too, and
+ // says so.
+ let mut doc = Document::new(600.0, 300.0);
+ let layer = doc.layers[0].id;
+ let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Grow", style(12.0, Color::BLACK));
+ let ends_meet = [(50.0, 200.0).into(), (550.0, 200.0).into(), (300.0, 50.0).into(), (50.0, 200.0).into()];
+ t.kind = TextKind::OnPath { path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&ends_meet, false)]), start: 0.0, end: None };
+ t.xf = Affine::IDENTITY;
+ t.dynamic_text = true;
+ let node = text_node(&mut doc, t);
+ doc.insert(Some(layer), usize::MAX, node).unwrap();
+ let out = export_ai_editing(&doc, &opts()).unwrap();
+ assert!(out.warnings.iter().any(|w| w == writer::TYPE) && out.warnings.iter().any(|w| w == writer::DYNAMIC_TEXT), "{:?}", out.warnings);
}
/// See-through type with the knockout shape flag comes back with the flag and writes no warning.
diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs
index afa6412561..dea4ad0c24 100644
--- a/crates/eps/src/ai_editing/writer.rs
+++ b/crates/eps/src/ai_editing/writer.rs
@@ -1257,6 +1257,10 @@ impl<'a> Writer<'a> {
return;
}
self.warn(TYPE);
+ // The outlines keep the sizes Dynamic Text draws the type at, but not the setting.
+ if matches!(&n.kind, NodeKind::Text(t) if t.dynamic_text) {
+ self.warn(DYNAMIC_TEXT);
+ }
let doc = self.doc;
let resolved = match &n.kind {
NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()),
diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs
index 988cd9a1f0..14059d33b8 100644
--- a/crates/eps/src/legacy_ai/tests.rs
+++ b/crates/eps/src/legacy_ai/tests.rs
@@ -1263,6 +1263,44 @@ fn dynamic_text_is_written_at_its_drawn_sizes_with_a_warning() {
assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings);
}
+/// Dynamic Text fits type whose size measures the cap height at the size the Character panel
+/// shows: 1,000 pt measuring a 0.7 em cap height on a long path grows to the 1,296 pt limit and is
+/// written at the em size drawn there, as text and as outlines.
+#[test]
+fn dynamic_text_with_a_font_height_reference_is_written_at_its_drawn_size() {
+ use vectorcraft_doc::{TextKind, TextObject};
+ use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font};
+ let db = vectorcraft_text::FontDb::global();
+ db.add_font(height_font(true).unwrap());
+ let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "HE", style(1000.0, red()));
+ let st = &mut t.runs[0].style;
+ st.font_family = HEIGHT_FAMILY.into();
+ st.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight;
+ let mut line = PathData::new(vec![]);
+ line.subpaths.push(vectorcraft_geom::SubPath::polyline(&[(0.0, 2000.0).into(), (100_000.0, 2000.0).into()], false));
+ t.kind = TextKind::OnPath { path: line, start: 0.0, end: None };
+ t.xf = Affine::IDENTITY;
+ t.dynamic_text = true;
+ let lay = vectorcraft_text::layout(db, &t);
+ assert!((lay.fit_scale - 1.296).abs() < 1e-9, "{}", lay.fit_scale);
+ let em = 1296.0 / 0.7;
+ let mut glyphs = kurbo::BezPath::new();
+ for g in &lay.glyphs {
+ glyphs.extend(g.outline.iter());
+ }
+ let want = glyphs.bounding_box();
+ let mut doc = Document::new(300.0, 200.0);
+ let layer = doc.layers[0].id;
+ let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
+ doc.insert(Some(layer), usize::MAX, n).unwrap();
+ let back = crate::import(&write(&doc).bytes).unwrap().document;
+ let size = texts(&back)[0].1.runs[0].style.size;
+ assert!((size - em).abs() < 1e-3, "{size} vs {em}");
+ let back = crate::import(&write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }).bytes).unwrap().document;
+ let b = children(&children(&back.layers[0])[0])[0].visual_bounds().unwrap();
+ assert!((b.height() - want.height()).abs() < 0.05 && (b.width() - want.width()).abs() < 0.05, "{b:?}, laid out at {want:?}");
+}
+
/// Characters with Auto leading are written with their paragraph's Auto Leading share of the
/// size: 18 pt type in paragraphs at 150% is written with 27 pt leading.
#[test]
diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs
index f71018996c..062ea3e9d8 100644
--- a/crates/eps/src/legacy_ai/writer.rs
+++ b/crates/eps/src/legacy_ai/writer.rs
@@ -657,15 +657,11 @@ impl<'a> Writer<'a> {
// ---------- type ----------
/// Type: a text object when the format can hold it as text, else its outlines (type with
- /// rotated characters among them, which keeps their rotation). A run whose
- /// font size measures a height other than the em box is written at the em size it is drawn
- /// at ([`vectorcraft_text::FontDb::font_heights_resolved`]).
+ /// rotated characters among them, which keeps their rotation).
fn text(&mut self, t: &TextObject) {
if !invertible(t.xf) {
return;
}
- let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t);
- let t = &*resolved;
// Written as text or as outlines, the type keeps its drawn sizes but not the setting.
if t.dynamic_text && matches!(t.kind, TextKind::OnPath { .. }) {
self.warn(DYNAMIC_TEXT);
@@ -687,14 +683,18 @@ impl<'a> Writer<'a> {
/// A text object (§15): `type To`, the text path (`a b c d tx ty startPt Tp`, an area's frame
/// or the path type runs along, `TP`), then each run's style and characters, `TO`. The matrix
/// takes text space (y up, the first baseline's start for point type) to art space; the frame
- /// and path are in art space, as the specification's examples have them (§15.5).
+ /// and path are in art space, as the specification's examples have them (§15.5). Type that
+ /// Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in, fitted at
+ /// the sizes the Character panel shows; then a run whose font size measures a height other
+ /// than the em box is written at the em size it is drawn at
+ /// ([`vectorcraft_text::FontDb::font_heights_resolved`]).
fn text_object(&mut self, t: &TextObject) {
if lost_attributes(t) {
self.warn(TYPE_ATTRIBUTES);
}
- // Type that Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in.
let fitted = vectorcraft_text::drawn(t);
- let t = &*fitted;
+ let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(&fitted);
+ let t = &*resolved;
let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let (kind, path, start) = match &t.kind {
TextKind::Point => (0, None, 0.0),
From 6a7214df4c9695c6105fb81450454132853b12a6 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Wed, 11 Nov 2026 13:39:28 -0800
Subject: [PATCH 16/72] MCP: replies hide a library path outside the read roots
wherever it appears
Under the read roots, every control reply now shows a hidden library's
id as loaded/ and its path as the file name in every string
and key: status text, dialogs, the command palette query, a script's
report, recorded steps and errors. The id and path are replaced at any
boundary, so a path between spaces, brackets or quotes, followed by a
period or by more path text (.bak), or quoted with escaped
backslashes is hidden too. An occurrence that continues into a longer
path stays only when the request may read that longer path, so a
readable library's id that begins with a hidden one keeps its text.
The libraries hidden after the request ran are hidden, and for a
request that names one, those hidden before it too. A reply with no
path separator in it is not searched. Encoded file data (dataBase64,
pngBase64) is sent unchanged.
Recorded steps in ui.inspect leave out a path outside the read roots
under path, paths, file, files, folder and any param whose name ends in
Path, Paths, File, Files or Folder (diffPath, fontFiles,
destinationFolder), and an absolute path or file: URL outside the roots
under any other param or in any list. The file: scheme is matched in
any case, a URL's %XX escapes are decoded before the check, a file: URL
inside the roots stays, and a file: URL that
names another host is always left out. file_access::is_path_key holds the list of
path params, and testkit's is_path_key uses it.
---
crates/engine/src/file_access.rs | 7 +
crates/testkit/src/strategies.rs | 12 +-
crates/ui-egui/src/control.rs | 279 ++++++++++++++++++++-----
crates/ui-egui/src/control/tests.rs | 57 +++++
crates/ui-egui/src/tests/automation.rs | 113 ++++++++--
docs/mcp.md | 16 +-
6 files changed, 411 insertions(+), 73 deletions(-)
diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs
index 27ebc9c40d..fc071ebb98 100644
--- a/crates/engine/src/file_access.rs
+++ b/crates/engine/src/file_access.rs
@@ -215,6 +215,13 @@ pub fn check_read(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Read))
}
+/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`,
+/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`,
+/// `destinationFolder`)?
+pub fn is_path_key(key: &str) -> bool {
+ matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end))
+}
+
/// May `path` be written here? Always, without roots in force.
pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write))?;
diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs
index 10b0608001..372152870d 100644
--- a/crates/testkit/src/strategies.rs
+++ b/crates/testkit/src/strategies.rs
@@ -502,15 +502,17 @@ pub fn param_keys(doc: &str) -> Vec {
out
}
-/// Parameter-name keys that name a file or folder a command may write: fuzzing these with
-/// arbitrary strings could write files anywhere, so callers substitute a temp path.
+/// Parameter-name keys that name a file or folder a command may write
+/// ([`vectorcraft_engine::file_access::is_path_key`]): fuzzing these with arbitrary strings could
+/// write files anywhere, so callers substitute a temp path.
pub fn is_path_key(k: &str) -> bool {
- matches!(k, "path" | "diffPath") || is_folder_key(k)
+ vectorcraft_engine::file_access::is_path_key(k)
}
-/// Path keys that name a folder a command creates or writes files into.
+/// Path keys that name a folder a command creates or writes files into: `folder` or a name ending in
+/// `Folder` (`destinationFolder`).
pub fn is_folder_key(k: &str) -> bool {
- matches!(k, "folder" | "destinationFolder")
+ k == "folder" || k.ends_with("Folder")
}
/// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index 3cbe9f920c..c06951cee5 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -308,23 +308,10 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
// for the person at the computer: what asked for it runs again when it answers.
"fileDialog": app.picks.waiting_kind(),
});
- let ids = unreadable_library_ids(app);
- for field in PANEL_FIELDS.iter().chain(&LIBRARY_ID_FIELDS) {
- if let Some(v) = inspected["ui"].get_mut(*field) {
- hide_library_ids(v, &ids);
- }
- }
- let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect();
- if let Some(sets) = inspected["ui"].get_mut("action_sets") {
- swap_library_params(sets, &pairs);
- }
+ hide_library_ids(&mut inspected, &unreadable_library_ids(app));
inspected
}
-/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold library ids or panel
-/// ids besides [`PANEL_FIELDS`]: the library panels, the Color Guide's limit and the workspaces.
-const LIBRARY_ID_FIELDS: [&str; 4] = ["library_panels", "color_guide_limit", "custom_workspaces", "workspace_changes"];
-
/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel
/// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels
/// shown as icons or collapsed, and the older floating panels' record.
@@ -336,9 +323,9 @@ const PANEL_FIELDS: [&str; 7] =
/// loaded without a path is named. The UI state refers to a library by its bare id in a library panel
/// and the Color Guide's limit, by a panel id (`library::loaded/`) in the fields of
/// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of
-/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an
-/// action's name) is not read. When another of those ids, or a library loaded in the session, already
-/// has that id, the next free one
+/// recorded actions' steps; the session lists the libraries it has loaded. Text the person types (a
+/// command palette query, a workspace's or an action's name) is not read. When another of those ids,
+/// or a library loaded in the session, already has that id, the next free one
/// of `loaded/ (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that
/// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id.
fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
@@ -354,15 +341,16 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> {
}
}
collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids);
- for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) {
+ // The ids of the libraries loaded in the session: a hidden one does not take the id of one loaded
+ // without a path.
+ let s = &app.session;
+ let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos());
+ let loaded: Vec<&str> = loaded.map(|info| info.id.as_str()).collect();
+ for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]).chain(loaded.iter().copied()) {
add_library_id(id, &mut ids);
}
let mut hidden = Vec::new();
- // The ids of the libraries loaded in the session, so that a hidden one does not take the id of
- // one loaded without a path.
- let s = &app.session;
- let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos());
- let mut taken: std::collections::BTreeSet = loaded.map(|info| info.id.clone()).collect();
+ let mut taken: std::collections::BTreeSet = loaded.iter().map(|id| id.to_string()).collect();
for id in ids {
let path = &id["loaded/".len()..];
// Either separator: a path saved on another platform counts as a path here too.
@@ -419,11 +407,106 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet`) and in keys.
+/// Writes each id of `ids` as the id the reply shows instead, and the path in it as the file name
+/// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id
+/// (`library:swatches:loaded/`), status text, a dialog, the command palette query, a script's
+/// report and a recorded step. An occurrence followed by a character that continues a path
+/// ([`continues_path`]) stays only when the request may read the longer path
+/// ([`readable_continuation`]): a readable library's id that begins with a hidden one keeps its path.
fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) {
- let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect();
- swap_library_ids(v, &pairs);
+ replace_in_value(v, &hidden_text(ids));
+}
+
+/// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the
+/// path in it with the file name shown, the path written with either separator too, and with each
+/// `\` doubled (a path quoted with `{:?}`).
+fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> {
+ let mut pairs: Vec<(String, String)> = Vec::new();
+ for (id, shown) in ids {
+ let path = &id["loaded/".len()..];
+ let name = shown.strip_prefix("loaded/").unwrap_or(shown);
+ let back = path.replace('/', "\\");
+ for path in [path.to_string(), path.replace('\\', "/"), back.replace('\\', "\\\\"), back] {
+ for (from, to) in [(format!("loaded/{path}"), shown.clone()), (path, name.to_string())] {
+ if !pairs.iter().any(|(f, _)| *f == from) {
+ pairs.push((from, to));
+ }
+ }
+ }
+ }
+ pairs.sort_by_key(|(from, _)| std::cmp::Reverse(from.len()));
+ pairs
+}
+
+/// Writes each `from` text of `pairs` as its `to` text in every string and key of `v`, at each
+/// occurrence not followed by a character that continues a path ([`continues_path`]) into a path the
+/// request may read ([`readable_continuation`]). Encoded file data, under a key ending in `Base64`
+/// (`dataBase64`, `pngBase64`), stays as it is.
+fn replace_in_value(v: &mut Value, pairs: &[(String, String)]) {
+ if pairs.is_empty() {
+ return;
+ }
+ match v {
+ Value::String(s) => {
+ if let Some(replaced) = replace_text(s, pairs) {
+ *s = replaced;
+ }
+ }
+ Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, pairs)),
+ Value::Object(fields) => {
+ for (key, mut item) in std::mem::take(fields) {
+ if key.ends_with("Base64") {
+ fields.insert(key, item);
+ continue;
+ }
+ replace_in_value(&mut item, pairs);
+ fields.insert(replace_text(&key, pairs).unwrap_or(key), item);
+ }
+ }
+ _ => {}
+ }
+}
+
+/// `s` with the `from` texts of `pairs` written as their `to` texts, as [`replace_in_value`] writes
+/// them. `None` when nothing changed.
+fn replace_text(s: &str, pairs: &[(String, String)]) -> Option {
+ let mut out: Option = None;
+ for (from, to) in pairs {
+ let text = out.as_deref().unwrap_or(s);
+ if from.is_empty() || !text.contains(from.as_str()) {
+ continue;
+ }
+ let mut next = String::with_capacity(text.len());
+ let mut copied = 0;
+ for (at, _) in text.match_indices(from.as_str()) {
+ let end = at + from.len();
+ if !continues_path(&text[end..]) || !readable_continuation(&text[at..]) {
+ next.push_str(&text[copied..at]);
+ next.push_str(to);
+ copied = end;
+ }
+ }
+ if copied > 0 {
+ next.push_str(&text[copied..]);
+ out = Some(next);
+ }
+ }
+ out
+}
+
+/// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`,
+/// `-` or `~`? A space, punctuation, a quote, a sentence's final period and the end of the text end it.
+fn continues_path(rest: &str) -> bool {
+ rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\'))
+}
+
+/// May the request read the longer path that `text` starts with, up to a space, a quote, a bracket,
+/// `,`, `;` or `|`, without a `loaded/` before it? A readable library's id that begins with a hidden
+/// one stays; a hidden path followed by more path text (`.bak`) is still hidden.
+fn readable_continuation(text: &str) -> bool {
+ let end = text.find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).unwrap_or(text.len());
+ let path = &text[..end];
+ readable(path.strip_prefix("loaded/").unwrap_or(path))
}
/// Text that a person or a script wrote, by its key: a command palette query, a script's report and a
@@ -510,10 +593,69 @@ fn readable(path: &str) -> bool {
vectorcraft_engine::file_access::check_read(path).is_ok()
}
-/// Does a recorded step's param `key` hold a file or folder path: `path`, `folder` or a name ending in
-/// `Folder` (`destinationFolder`, `searchFolder`)?
-fn is_path_param(key: &str) -> bool {
- matches!(key, "path" | "folder") || key.ends_with("Folder")
+/// Is `s` a path the request may not read, written as an absolute path (either separator, with or
+/// without a drive) or a `file:` URL ([`file_url_path`])? A `file:` URL that names another host is
+/// never readable.
+fn unreadable_absolute(s: &str) -> bool {
+ match file_url_path(s) {
+ Some(path) => !path.is_some_and(|p| readable(&p)),
+ None => {
+ let absolute = s.starts_with(['/', '\\']) || matches!(s.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic());
+ absolute && !readable(s)
+ }
+ }
+}
+
+/// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])?
+fn readable_value(s: &str) -> bool {
+ match file_url_path(s) {
+ Some(path) => path.is_some_and(|p| readable(&p)),
+ None => readable(s),
+ }
+}
+
+/// The local path that `s` names when it is a `file:` URL, with the scheme in any case and its `%XX`
+/// escapes decoded: `Some(None)` for a URL that names another host (`file://host/share`).
+/// `file:///C:/x` names `C:/x`.
+fn file_url_path(s: &str) -> Option> {
+ let rest = s.get(..5).filter(|scheme| scheme.eq_ignore_ascii_case("file:")).map(|_| &s[5..])?;
+ let Some(rest) = rest.strip_prefix("//") else { return Some(Some(percent_decoded(rest))) };
+ let rest = if rest.get(..9).is_some_and(|host| host.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest };
+ if !rest.starts_with('/') {
+ return Some(None);
+ }
+ let path = match rest.as_bytes() {
+ [b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => &rest[1..],
+ _ => rest,
+ };
+ Some(Some(percent_decoded(path)))
+}
+
+/// `s` with `%XX` escapes decoded, as the SVG importer decodes an href.
+fn percent_decoded(s: &str) -> String {
+ if !s.contains('%') {
+ return s.to_string();
+ }
+ let mut out = Vec::with_capacity(s.len());
+ let mut rest = s.as_bytes();
+ while let Some((&b, tail)) = rest.split_first() {
+ let hex = tail
+ .get(..2)
+ .filter(|h| h.iter().all(u8::is_ascii_hexdigit))
+ .and_then(|h| std::str::from_utf8(h).ok())
+ .and_then(|h| u8::from_str_radix(h, 16).ok());
+ match hex {
+ Some(v) if b == b'%' => {
+ out.push(v);
+ rest = tail.get(2..).unwrap_or_default();
+ }
+ _ => {
+ out.push(b);
+ rest = tail;
+ }
+ }
+ }
+ String::from_utf8_lossy(&out).into_owned()
}
/// The params of each step of each action of action set `set` (`ui.action_sets`).
@@ -524,22 +666,35 @@ fn steps_mut(set: &mut Value) -> impl Iterator- ) {
- params.retain(|key, v| !(is_path_param(key) && v.as_str().is_some_and(|p| !readable(p))));
- if let Some(Value::Array(paths)) = params.get_mut("paths") {
- paths.retain(|p| p.as_str().is_none_or(readable));
- }
- for v in params.values_mut() {
+ use vectorcraft_engine::file_access::is_path_key;
+ params.retain(|key, v| match v.as_str() {
+ Some(p) => !unreadable_absolute(p) && !(is_path_key(key) && !readable_value(p)),
+ None => true,
+ });
+ for (key, v) in params.iter_mut() {
+ if let Value::Array(paths) = v
+ && is_path_key(key)
+ {
+ paths.retain(|p| p.as_str().is_none_or(readable_value));
+ }
leave_out_unreadable_values(v);
}
}
-/// [`leave_out_unreadable_params`] on each object `v` holds.
+/// [`leave_out_unreadable_params`] on each object `v` holds, and an absolute path or a `file:` URL
+/// the request may not read taken out of each list.
fn leave_out_unreadable_values(v: &mut Value) {
match v {
Value::Object(fields) => leave_out_unreadable_params(fields),
- Value::Array(items) => items.iter_mut().for_each(leave_out_unreadable_values),
+ Value::Array(items) => {
+ items.retain(|item| !item.as_str().is_some_and(unreadable_absolute));
+ items.iter_mut().for_each(leave_out_unreadable_values);
+ }
_ => {}
}
}
@@ -620,21 +775,45 @@ const DOCKING_ACTION: &str = "action";
/// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect`
/// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id
-/// in a [`LIBRARY_PARAMS`] param, and the reply to such a request shows that id.
+/// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each
+/// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key,
+/// errors included: the libraries hidden after it ran, and before it for a request that names one.
+/// A reply with no `/` or `\` in it is not searched, since every hidden path holds a separator.
pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
- let ids = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() };
- let shown: Vec<_> = ids.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect();
- let mut params = req.params.clone();
- if shown.is_empty() || !swap_library_params(&mut params, &shown) {
+ if vectorcraft_engine::file_access::current().is_none() {
return dispatch(app, ctx, req);
}
- let named = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq };
- match dispatch(app, ctx, &named) {
- Outcome::Done(mut reply) => {
- hide_library_ids(&mut reply, &ids);
- Outcome::Done(reply)
+ let before = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() };
+ let shown: Vec<_> = before.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect();
+ let mut params = req.params.clone();
+ let outcome = if !shown.is_empty() && swap_library_params(&mut params, &shown) {
+ let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq };
+ dispatch(app, ctx, &request)
+ } else {
+ dispatch(app, ctx, req)
+ };
+ let Outcome::Done(mut reply) = outcome else { return outcome };
+ if !holds_a_separator(&reply) {
+ return Outcome::Done(reply);
+ }
+ let mut ids = unreadable_library_ids(app);
+ for pair in before {
+ if !ids.iter().any(|(id, _)| *id == pair.0) {
+ ids.push(pair);
}
- outcome => outcome,
+ }
+ hide_library_ids(&mut reply, &ids);
+ Outcome::Done(reply)
+}
+
+/// Does a string or a key of `v`, at any depth, hold a `/` or a `\`?
+fn holds_a_separator(v: &Value) -> bool {
+ let has = |s: &str| s.contains(['/', '\\']);
+ match v {
+ Value::String(s) => has(s),
+ Value::Array(items) => items.iter().any(holds_a_separator),
+ Value::Object(fields) => fields.iter().any(|(key, item)| has(key) || holds_a_separator(item)),
+ _ => false,
}
}
diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs
index 0a9c042a1e..34b96b81e0 100644
--- a/crates/ui-egui/src/control/tests.rs
+++ b/crates/ui-egui/src/control/tests.rs
@@ -12,6 +12,63 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value
r
}
+/// A reply hides a hidden library's id and path at every occurrence, in an error that wraps them in
+/// spaces, brackets or quotes, follows them with more path text, or quotes the path with `{:?}`.
+#[test]
+fn a_reply_hides_a_hidden_library_whatever_surrounds_it() {
+ let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned();
+ let id = format!("loaded/{path}");
+ let hidden = [(id.clone(), "loaded/b.svg".to_string())];
+ let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")});
+ let root = std::env::temp_dir().join(format!("vc-named-reply-root-{}", std::process::id()));
+ std::fs::create_dir_all(&root).unwrap();
+ let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
+ vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden));
+ let _ = std::fs::remove_dir_all(root);
+ assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}");
+}
+
+/// Encoded file data in a reply (`dataBase64`) stays as it is when it holds a hidden library's path.
+#[test]
+fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() {
+ let hidden = [("loaded/assets/icon".to_string(), "loaded/icon".to_string())];
+ let data = "QUJDassets/iconREVG";
+ let mut reply = json!({"ok": true, "result": {"files": [{"name": "a.png", "dataBase64": data}], "status": "from assets/icon"}});
+ let root = std::env::temp_dir().join(format!("vc-base64-root-{}", std::process::id()));
+ std::fs::create_dir_all(&root).unwrap();
+ let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap();
+ vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden));
+ let _ = std::fs::remove_dir_all(root);
+ assert_eq!(reply["result"]["files"][0]["dataBase64"], data, "{reply}");
+ assert_eq!(reply["result"]["status"], "from icon", "{reply}");
+}
+
+/// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a
+/// relative name and a path inside the root are not.
+#[test]
+fn an_absolute_path_outside_the_read_root_is_unreadable() {
+ let base = std::env::temp_dir().join(format!("vc-unreadable-absolute-{}", std::process::id()));
+ std::fs::create_dir_all(base.join("in")).unwrap();
+ let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
+ let inside = base.join("in").join("a.svg").to_string_lossy().into_owned();
+ vectorcraft_engine::file_access::confine(roots.as_ref(), || {
+ assert!(super::unreadable_absolute("/outside/root/x"));
+ assert!(super::unreadable_absolute("file:///outside/root/x"));
+ assert!(super::unreadable_absolute("FILE:///outside/root/x"));
+ assert!(super::unreadable_absolute("file://other-host/share/x"));
+ let url = if inside.starts_with('/') { format!("File://{inside}") } else { format!("File:///{}", inside.replace('\\', "/")) };
+ assert!(!super::unreadable_absolute(&url), "{url}");
+ assert!(super::readable_value(&url), "{url}");
+ let escaped = url.replacen("/in/a.svg", "/%69n/a.svg", 1);
+ assert!(!super::unreadable_absolute(&escaped) && super::readable_value(&escaped), "{escaped}");
+ assert!(super::unreadable_absolute("file:///outside%2Froot/x"));
+ assert!(!super::unreadable_absolute(""));
+ assert!(!super::unreadable_absolute("keep"));
+ assert!(!super::unreadable_absolute(&inside));
+ });
+ let _ = std::fs::remove_dir_all(base);
+}
+
#[test]
fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs
index f3aee6ca73..2db9feaec5 100644
--- a/crates/ui-egui/src/tests/automation.rs
+++ b/crates/ui-egui/src/tests/automation.rs
@@ -439,22 +439,106 @@ fn ui_inspect_checks_a_folder_preference_as_written() {
let _ = std::fs::remove_dir_all(base);
}
-/// `ui.inspect` rewrites a hidden library's id only in the fields that hold library and panel ids:
-/// status text that is that id stays as written.
+/// `ui.inspect` hides a hidden library's id and path in every string it holds: status text, the
+/// command palette query and a script's report show the id and file name shown, and the path appears
+/// nowhere in the reply.
#[test]
-fn ui_inspect_keeps_status_text_that_is_a_hidden_library_id() {
+fn ui_inspect_hides_a_hidden_library_path_in_every_string() {
let (mut app, tx, base) = confined("inspect-status-id");
- let theirs = format!("loaded/{}", s(base.join("outside/b.svg")));
+ let path = s(base.join("outside/b.svg"));
+ let theirs = format!("loaded/{path}");
app.ui.color_guide_limit = theirs.clone();
- app.ui.status = theirs.clone();
+ app.ui.status = format!("Loaded {path} (3 swatches), kept {path}.bak");
+ app.ui.palette_query = format!("\"{theirs}\"");
+ let source = format!("vc.log({})", json!(format!("read {path}.")));
+ app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap();
+ for _ in 0..10_000 {
+ if app.session.script_pump().is_some() {
+ break;
+ }
+ }
let r = request(&mut app, &tx, "ui.inspect", json!({}));
assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}");
- assert_eq!(r["result"]["ui"]["status"], json!(theirs), "{r}");
+ assert_eq!(r["result"]["ui"]["status"], "Loaded b.svg (3 swatches), kept b.svg.bak", "{r}");
+ assert_eq!(r["result"]["ui"]["palette_query"], "\"loaded/b.svg\"", "{r}");
+ assert_eq!(r["result"]["scriptReport"]["log"], json!(["read b.svg."]), "{r}");
+ let outside = json!(s(base.join("outside"))).to_string();
+ assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}");
+ let _ = std::fs::remove_dir_all(base);
+}
+
+/// The reply to a request that names a hidden library by the id `ui.inspect` shows hides that
+/// library's id wherever the reply holds it, also between spaces and brackets and where a character
+/// that continues a path follows it.
+#[test]
+fn a_reply_hides_a_library_the_request_named_by_its_shown_id_everywhere() {
+ let (mut app, tx, base) = confined("named-hidden-reply");
+ let theirs = format!("loaded/{}", s(base.join("outside/b.svg")));
+ app.ui.color_guide_limit = theirs.clone();
+ app.ui.status = format!("Not added ( {theirs} ); kept {theirs}_old");
+ let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/b.svg"}));
+ assert_eq!(r["result"]["ui"]["status"], "Not added ( loaded/b.svg ); kept loaded/b.svg_old", "{r}");
+ let outside = json!(s(base.join("outside"))).to_string();
+ assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}");
+ let _ = std::fs::remove_dir_all(base);
+}
+
+/// An error to a request that names a hidden library by the id `ui.inspect` shows names it by that id,
+/// and its path appears nowhere in the reply.
+#[test]
+fn an_error_for_a_hidden_library_named_by_its_shown_id_shows_that_id() {
+ let (mut app, tx, base) = confined("named-hidden-error");
+ app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside/b.svg")));
+ let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.colorGuideLimit", "params": {"library": "loaded/b.svg"}}));
+ assert_eq!(r["ok"], false, "{r}");
+ assert!(r["error"].as_str().is_some_and(|e| e.contains("loaded/b.svg")), "{r}");
+ let outside = json!(s(base.join("outside"))).to_string();
+ assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}");
+ let _ = std::fs::remove_dir_all(base);
+}
+
+/// `ui.inspect` leaves out of a recorded action's steps a path outside the read root under any path
+/// param (`diffPath`, `file`, a name ending in `Files`), and an absolute path or a `file:` URL outside
+/// it (the scheme in any case) under any other param or in any list. A `file:` URL inside it stays.
+#[test]
+fn ui_inspect_leaves_out_recorded_paths_under_any_key() {
+ let (mut app, tx, base) = confined("inspect-action-keys");
+ let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg")));
+ let to_url = |p: &str| if p.starts_with('/') { format!("file://{p}") } else { format!("file:///{}", p.replace('\\', "/")) };
+ let (url, url_in) = (to_url(&b), to_url(&a));
+ let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({
+ "name": "set",
+ "actions": [{"name": "files", "steps": [
+ ["document.compare", {"diffPath": b, "target": a}],
+ ["file.open", {"file": b}],
+ ["type.fonts", {"fontFiles": [a, b, url_in]}],
+ ["x.y", {"target": b, "url": url, "upper": url.replacen("file:", "FILE:", 1), "other": "keep"}],
+ ["x.z", {"list": [b, "keep"]}],
+ ["x.w", {"share": "file://other-host/share/b.svg", "other": "keep"}],
+ ["x.v", {"file": url_in}],
+ ]}],
+ }))
+ .unwrap();
+ app.ui.action_sets = vec![set];
+ let r = request(&mut app, &tx, "ui.inspect", json!({}));
+ let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"];
+ let want = json!([
+ ["document.compare", {"target": a}],
+ ["file.open", {}],
+ ["type.fonts", {"fontFiles": [a, url_in]}],
+ ["x.y", {"other": "keep"}],
+ ["x.z", {"list": ["keep"]}],
+ ["x.w", {"other": "keep"}],
+ ["x.v", {"file": url_in}],
+ ]);
+ assert_eq!(steps, &want, "{r}");
+ let outside = json!(s(base.join("outside"))).to_string();
+ assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}");
let _ = std::fs::remove_dir_all(base);
}
/// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action
-/// (its `library` and `panel` params), also when nothing else names it, and keeps the action's name.
+/// (its `library` and `panel` params) and in the action's name, also when nothing else names it.
#[test]
fn ui_inspect_hides_a_hidden_library_in_recorded_steps() {
let (mut app, tx, base) = confined("inspect-action-steps");
@@ -470,7 +554,7 @@ fn ui_inspect_hides_a_hidden_library_in_recorded_steps() {
app.ui.action_sets = vec![set];
let r = request(&mut app, &tx, "ui.inspect", json!({}));
let action = &r["result"]["ui"]["action_sets"][0]["actions"][0];
- assert_eq!(action["name"], json!(theirs), "{r}");
+ assert_eq!(action["name"], "loaded/b.svg", "{r}");
assert_eq!(
action["steps"],
json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]),
@@ -639,7 +723,7 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() {
/// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable
/// library's id that begins with it stays, and a script's report line and a command palette query
-/// that hold it stay as written.
+/// that end with it show the id shown.
#[test]
fn ui_inspect_rewrites_only_whole_hidden_library_ids() {
let (mut app, tx, base) = confined("inspect-whole-id");
@@ -648,8 +732,7 @@ fn ui_inspect_rewrites_only_whole_hidden_library_ids() {
assert!(readable.starts_with(&hidden));
let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() };
app.ui.library_panels = vec![panel(&hidden), panel(&readable)];
- let query = format!("library:swatches:{hidden}");
- app.ui.palette_query = query.clone();
+ app.ui.palette_query = format!("library:swatches:{hidden}");
let text = format!("found {hidden}");
let source = format!("vc.log({})", json!(text));
app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap();
@@ -661,8 +744,12 @@ fn ui_inspect_rewrites_only_whole_hidden_library_ids() {
let r = request(&mut app, &tx, "ui.inspect", json!({}));
let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}");
- assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}");
- assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}");
+ assert_eq!(r["result"]["scriptReport"]["log"], json!(["found loaded/i"]), "{r}");
+ assert_eq!(r["result"]["ui"]["palette_query"], "library:swatches:loaded/i", "{r}");
+ // A request that names the hidden library by the id shown keeps the readable id too.
+ let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/i"}));
+ let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
+ assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}");
let _ = std::fs::remove_dir_all(base);
}
diff --git a/docs/mcp.md b/docs/mcp.md
index b8265bdfd2..3deca5409a 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -129,8 +129,11 @@ the suite) keep the agent to the folders you give it:
`file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does.
- `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the
documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`;
- in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path` and `folder` params,
- a param whose name ends in `Folder` (`destinationFolder`) and the entries of a `paths` param; and the folder
+ in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`,
+ `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`,
+ `destinationFolder`), the entries of a list under such a param, and under any other param or in any list an
+ absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that names
+ another host is always left out); and the folder
preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields`
(`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an
empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the
@@ -144,9 +147,12 @@ the suite) keep the agent to the folders you give it:
their order, and the app's own state is not changed. A request may name such a library or its panel by the id
shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`,
`below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/
"}`
- reopens its panel), and the reply to such a request, an error included, shows that id too. `ui_inspect` rewrites only whole ids, and only in the fields that hold library and panel ids:
- a longer id that begins with a hidden one, the status text (`ui.status`), the command palette query
- (`ui.palette_query`) and a script's report (`scriptReport`) keep their text.
+ reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path
+ appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its
+ path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query
+ (`ui.palette_query`) and a script's report (`scriptReport`) included. A longer path that begins with a hidden one
+ keeps its text only when it lies inside the read roots, so a readable library's id that begins with a hidden one
+ stays. Encoded file data (`dataBase64`, `pngBase64`) is sent as it is.
- Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any
of the write roots written, so one session can read a project folder and a shared assets folder, or write to an
output folder and a scratch folder, without widening a root to their common parent. A folder given twice
From e05f40d7f8c8023408b96a5166e22025352524f6 Mon Sep 17 00:00:00 2001
From: dotCooCoo