Templates: Save as Template can also write a PDF-compatible .ait

File › Save as Template always wrote a .vctemplate. The save panel
offered only that type, file.saveAsTemplate ignored its format param,
and a name typed with .ait got .vctemplate added. New from Template
already opened a .ait as an untitled document.

Save as Template now also writes a .ait: the PDF-compatible file Save
As writes for .ai, carrying the document. The desktop save panel lists
VectorCraft Template first and the .ait type beside it, and the web
build's Save as Template dialog gets a Format menu with the same two
types. file.saveAsTemplate writes .ait for format: "ait", or for a path
ending in .ait when no format is given, and takes the .ai options;
file.formatOptions answers for ait. .vctemplate stays the default.
Exports and Export As still do not write .ait.

Docs: docs/mcp.md, docs/file-format-parity.md (.ait row),
docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 04:04:49 +07:00
committed by bflatastic
parent ed96a12196
commit 427cd65f30
9 changed files with 141 additions and 38 deletions
+13 -3
View File
@@ -65,8 +65,8 @@ pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, export_folder, save_filters, save_format, save_job, save_plan, save_with,
stamp_save_dates, templates_dialog_folder, templates_folder,
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, save_filters, save_format, save_job, save_plan,
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
};
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
pub(crate) use screens::{
@@ -497,7 +497,17 @@ pub const FORMATS: &[Format] = &[
raster: false,
options: native::AI_OPTIONS,
},
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
Format {
id: "ait",
label: "PDF-compatible .ait template",
extensions: &["ait"],
mime: "application/pdf",
read: true,
// Save as Template writes it, with the .ai file's options; exports don't.
write: false,
raster: false,
options: native::AI_OPTIONS,
},
Format {
id: "png",
label: "PNG",
+33 -14
View File
@@ -22,13 +22,27 @@ use crate::{DocState, Prefs};
/// they never become the document's own file.
pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai"];
/// The formats Save as Template writes, the default first: a native template, or a
/// PDF-compatible `.ait` (written as a `.ai` file is).
pub const TEMPLATE_FORMATS: &[&str] = &["template", "ait"];
/// Save-panel filters `(label, [extension])` for `first` (a format id, or a file name whose
/// extension names one): when Save writes that format, one per [`SAVE_FORMATS`] entry with it
/// leading (the panel's default type), else none (an export picks its own). Only the extension a
/// save writes (never the former native name).
pub fn save_filters(first: &str) -> Vec<(&'static str, &'static [&'static str])> {
let Some(first) = format_for_name(first).or_else(|| format(first)).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] };
let mut v: Vec<&'static Format> = SAVE_FORMATS.iter().filter_map(|id| format(id)).collect();
filters(SAVE_FORMATS, first)
}
/// [`save_filters`] for Save as Template: one per [`TEMPLATE_FORMATS`] entry.
pub fn template_filters(first: &str) -> Vec<(&'static str, &'static [&'static str])> {
filters(TEMPLATE_FORMATS, first)
}
/// One filter per format of `ids`, `first` leading; none when `first` names none of them.
fn filters(ids: &[&str], first: &str) -> Vec<(&'static str, &'static [&'static str])> {
let Some(first) = format_for_name(first).or_else(|| format(first)).filter(|f| ids.contains(&f.id)) else { return vec![] };
let mut v: Vec<&'static Format> = ids.iter().filter_map(|id| format(id)).collect();
v.sort_by_key(|f| f.id != first.id);
v.into_iter().map(|f| (f.label, &f.extensions[..1])).collect()
}
@@ -73,7 +87,7 @@ pub struct SavePlan {
pub format: &'static Format,
/// The format's options (only those its encoder reads).
pub options: Map<String, Value>,
/// Suggested file name: `<name>.<ext>`, `<name> copy.<ext>` or `<name> template.vctemplate`.
/// Suggested file name: `<name>.<ext>`, `<name> copy.<ext>` or `<name> template.<ext>`.
pub name: String,
/// Suggested folder: the document's own, or the Templates folder for a template.
pub folder: Option<String>,
@@ -194,7 +208,11 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
let st = s.doc()?;
let path = str_param(p, "path").map(str::to_string);
let format = match (mode, str_param(p, "format"), path.as_deref().and_then(format_for_name)) {
(SaveMode::Template, ..) => checked(cmd, "template")?,
// `.ait` when the format or else the path's extension names it; else a native template.
(SaveMode::Template, f, by_name) => match f.map_or(by_name, format).filter(|f| TEMPLATE_FORMATS.contains(&f.id)) {
Some(f) => f,
None => checked(cmd, "template")?,
},
(_, Some(f), _) => checked(cmd, f)?,
(_, None, Some(f)) => checked(cmd, f.id)?,
(_, None, None) => checked(cmd, st.format)?,
@@ -248,13 +266,13 @@ fn reads_option(f: &Format, key: &str) -> bool {
}
/// Formats that carry the whole native document: they lose nothing, and record links relative to
/// where they are written. A `.ai` file is a PDF carrying it.
/// where they are written. A `.ai` or `.ait` file is a PDF carrying it.
fn is_native(f: &Format) -> bool {
matches!(f.id, "vectorcraft" | "template" | "ai")
matches!(f.id, "vectorcraft" | "template" | "ai" | "ait")
}
fn is_pdf(f: &Format) -> bool {
matches!(f.id, "pdf" | "ai")
matches!(f.id, "pdf" | "ai" | "ait")
}
fn is_svg(f: &Format) -> bool {
@@ -386,7 +404,8 @@ fn blank_pages(doc: &Document) -> Document {
/// Why a `.ai` file saved without PDF content looks empty elsewhere.
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
/// The shared Save and Export encoder for PDF-compatible `.ai` files.
/// The shared Save and Export encoder for PDF-compatible `.ai` files (and `.ait` templates, the
/// same file).
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
if super::legacy_ai::wanted(cmd, p)? {
return super::legacy_ai::encode(cmd, f, doc, p);
@@ -473,10 +492,10 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
let boards = separate_boards(cmd, plan.format, &doc, &params)?;
let mut params = super::pdf::expand_preset(s, cmd, &params)?.into_owned();
let legacy = super::legacy_ai::wanted(cmd, &params)?;
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
if let (Some(o), "ai" | "ait") = (params.as_object_mut(), plan.format.id) {
// Save keeps every artboard; exports may choose a subset instead. A legacy .ai file per
// artboard keeps a range given: the artboards to save.
let chosen = legacy && o.get("range").and_then(Value::as_str).is_some_and(|r| !r.trim().is_empty());
let chosen = legacy && plan.format.id == "ai" && o.get("range").and_then(Value::as_str).is_some_and(|r| !r.trim().is_empty());
if !chosen {
o.insert("range".into(), json!("all"));
}
@@ -520,7 +539,7 @@ impl SaveJob {
/// Encode one file of `doc` in the job's format.
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
let enc = if f.id == "ai" { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
// A legacy .ai file per artboard.
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, &self.params)?;
if enc.files.len() != 1 && !per_artboard {
@@ -703,7 +722,7 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active();
let id = str_param(p, "format").or(st.map(|d| d.format)).unwrap_or("vectorcraft");
let f = format(id)
.filter(|f| f.write || SAVE_FORMATS.contains(&f.id))
.filter(|f| f.write || SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id))
.ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options);
// A native save compresses as Use Compression says, unless told otherwise.
@@ -754,7 +773,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save as Template…",
["File"],
None,
"{…as document.save} {path?, compress?, version?, preview?} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}",
"{…as document.save} {path?, format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
has_doc,
|s, p| save(s, SaveMode::Template, p)
),
@@ -781,7 +800,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Format Options",
[],
None,
"{format?: a writable format id (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else the default",
"{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else the default",
always,
format_options
),
@@ -153,6 +153,33 @@ fn ait_templates_open_their_document_untitled() {
assert_eq!(comparable(&s.doc().unwrap().doc), before);
}
/// Save as Template writes a PDF-compatible `.ait` for a `.ait` path or `format: ait`, and New
/// from Template opens it as an untitled copy of the document, layers and art included.
#[test]
fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
let mut s = rich();
s.execute("layer.new", &json!({"name": "Notes"})).unwrap();
s.execute("shape.ellipse", &json!({"x": 20, "y": 120, "width": 40, "height": 30})).unwrap();
let before = comparable(&s.doc().unwrap().doc);
assert_eq!(before.layers.len(), 2);
let path = tmp("brochure template.ait");
let r = s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
assert_eq!(r["format"], "ait", "{r}");
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some_and(|e| e.intact), "PDF-compatible, carrying the document");
assert_eq!((s.doc().unwrap().path.as_deref(), s.doc().unwrap().is_dirty()), (None, true), "the document is unchanged");
let r = s.execute("file.newFromTemplate", &json!({"path": path})).unwrap();
assert_eq!(r["format"], "ait", "{r}");
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
assert_eq!(s.doc().unwrap().path, None);
assert_eq!(comparable(&s.doc().unwrap().doc), before);
// Without a path: the bytes and a name for the Templates folder.
let r = s.execute("file.saveAsTemplate", &json!({"format": "ait"})).unwrap();
assert_eq!(r["format"], "ait", "{r}");
assert!(r["name"].as_str().is_some_and(|n| n.ends_with(" template.ait")), "{r}");
assert!(vectorcraft_pdf::editing(&b64(&r)).is_some_and(|e| e.intact));
}
#[test]
fn standards_and_screens_leave_the_editing_data_out() {
let mut s = rich();
+6 -1
View File
@@ -299,7 +299,12 @@ fn save_plans_suggest_names_and_folders() {
let p = plan(&s, SaveMode::Save, json!({}));
assert_eq!((p.path, p.name.as_str(), p.format.id, p.folder), (None, "Map.vectorcraft", "vectorcraft", None));
assert_eq!(plan(&s, SaveMode::Copy, json!({"format": "pdf"})).name, "Map copy.pdf");
assert_eq!(plan(&s, SaveMode::Template, json!({"format": "svg"})).format.id, "template", "a template is always native");
assert_eq!(plan(&s, SaveMode::Template, json!({"format": "svg"})).format.id, "template", "a template is native or .ait");
// A native template by default; .ait when the format, else the path, names it.
assert_eq!(plan(&s, SaveMode::Template, json!({})).name, "Map template.vctemplate");
assert_eq!(plan(&s, SaveMode::Template, json!({"format": "ait"})).name, "Map template.ait");
assert_eq!(plan(&s, SaveMode::Template, json!({"path": "/t/Map.ait"})).format.id, "ait");
assert_eq!(plan(&s, SaveMode::Template, json!({"path": "/t/Map.ait", "format": "template"})).format.id, "template");
assert_eq!(SaveMode::of("file.saveCopy"), Some(SaveMode::Copy));
assert_eq!(crate::cmd::fileio::save_filters("svg")[0], ("SVG", &["svg"][..]));
}
+43 -8
View File
@@ -17,7 +17,7 @@
use std::sync::OnceLock;
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format, FormatOption, SAVE_FORMATS, SaveMode};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format, FormatOption, SAVE_FORMATS, SaveMode, TEMPLATE_FORMATS};
use super::{DialogResult, DialogSpec, form};
use crate::state::Dialog;
@@ -84,7 +84,7 @@ fn shown(f: &Format) -> impl Iterator<Item = &'static FormatOption> {
!(matches!(o.ty, "array" | "object")
|| (range && o.name == "artboard")
|| (separate && o.name == "range")
|| (f.id == "ai" && o.name == "useArtboards"))
|| (matches!(f.id, "ai" | "ait") && o.name == "useArtboards"))
})
}
@@ -133,6 +133,12 @@ fn save_labels() -> &'static [&'static str] {
LABELS.get_or_init(|| SAVE_FORMATS.iter().filter_map(|id| fileio::format(id)).map(|f| f.label).collect())
}
/// The Save as Template format menu (labels in [`TEMPLATE_FORMATS`] order).
fn template_labels() -> &'static [&'static str] {
static LABELS: OnceLock<Vec<&'static str>> = OnceLock::new();
LABELS.get_or_init(|| TEMPLATE_FORMATS.iter().filter_map(|id| fileio::format(id)).map(|f| f.label).collect())
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let Some(f) = fileio::format(&d.str("format")) else { return false };
@@ -140,11 +146,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
egui::Grid::new("save-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
if d.bool("__pick") {
form::field_w(ui, d, "path", tl!("File Name:"), 240.0);
if SaveMode::of(&d.str("__action")) != Some(SaveMode::Template) {
ui.label(egui::RichText::new(tl!("Format:")).color(t.text_dim));
switch = widgets::dropdown(ui, "save-format", f.label, save_labels(), 254.0).and_then(|i| SAVE_FORMATS.get(i).copied());
ui.end_row();
}
let (ids, labels) = if SaveMode::of(&d.str("__action")) == Some(SaveMode::Template) {
(TEMPLATE_FORMATS, template_labels())
} else {
(SAVE_FORMATS, save_labels())
};
ui.label(egui::RichText::new(tl!("Format:")).color(t.text_dim));
switch = widgets::dropdown(ui, "save-format", f.label, labels, 254.0).and_then(|i| ids.get(i).copied());
ui.end_row();
}
// Version first; the legacy version takes only its own options.
let legacy = legacy_ai(d);
@@ -687,10 +696,36 @@ mod tests {
let pick = &picks.borrow()[1];
assert_eq!(pick.name, "Untitled-1 template.vctemplate");
assert_eq!(pick.folder.as_deref().map(std::path::Path::new), Some(folder.as_path()));
assert_eq!(pick.filters, [("VectorCraft Template", &["vctemplate"][..])]);
assert_eq!(pick.filters, [("VectorCraft Template", &["vctemplate"][..]), ("PDF-compatible .ait template", &["ait"][..])]);
assert_eq!(written.borrow()[1].0, "copy.vectorcraft", "the picked name is kept");
}
#[test]
fn save_as_template_writes_ait_when_it_is_picked() {
let (mut app, written, _) = desktop("flyer.ait");
app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ait").to_string_lossy().into_owned();
app.run("file.saveAsTemplate", json!({})).unwrap();
assert!(app.ui.dialog.is_none(), "a template asks no options");
let (path, bytes) = written.borrow()[0].clone();
assert_eq!(path, "flyer.ait", "the picked name is kept");
assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some(), "a PDF carrying the document");
assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty());
// The web's dialog picks the format: .vctemplate first, .ait beside it.
let (mut app, written) = web();
app.run("file.saveAsTemplate", json!({})).unwrap();
assert_eq!((heading(dialog(&app)).as_str(), dialog(&app).str("format").as_str()), ("Save as Template", "template"));
assert_eq!(template_labels(), ["VectorCraft Template", "PDF-compatible .ait template"]);
frame(&mut app, false);
let mut d = app.ui.dialog.take().unwrap();
switch_format(&mut app, &mut d, "ait");
assert_eq!((d.str("path").as_str(), d.fields["pdfCompatible"].clone()), ("Untitled-1 template.ait", json!(true)));
app.ui.dialog = Some(d);
frame(&mut app, true);
let (name, bytes) = written.borrow()[0].clone();
assert_eq!(name, "Untitled-1 template.ait");
assert!(bytes.starts_with(b"%PDF"));
}
#[test]
fn web_save_as_names_the_file_and_picks_the_format() {
let (mut app, written) = web();
+12 -10
View File
@@ -4,7 +4,7 @@
use serde_json::{Value, json};
use vectorcraft_doc::SavedView;
use vectorcraft_engine::EngineError;
use vectorcraft_engine::cmd::fileio::{self, Format, SAVE_FORMATS, SaveMode, SavePlan};
use vectorcraft_engine::cmd::fileio::{self, Format, SAVE_FORMATS, SaveMode, SavePlan, TEMPLATE_FORMATS};
use crate::background::{self, Writer};
use crate::dialogs::svg_options;
@@ -349,10 +349,11 @@ pub fn remember_view(app: &mut VectorcraftApp) {
}
}
/// A path typed in a save panel: kept when its extension names a save format (the panel's file
/// type), else `f`'s extension is added.
fn with_save_extension(path: &str, f: &Format) -> String {
with_extension(path, f.extensions[0], |g| SAVE_FORMATS.contains(&g.id))
/// A path typed in a save panel: kept when its extension names a save format, or, for Save as
/// Template (`mode`), when it names a template format (the panel's file type); otherwise `f`'s
/// extension is added.
fn with_save_extension(path: &str, f: &Format, mode: SaveMode) -> String {
with_extension(path, f.extensions[0], |g| SAVE_FORMATS.contains(&g.id) || (mode == SaveMode::Template && TEMPLATE_FORMATS.contains(&g.id)))
}
/// Write every file of an export (one per artboard, linked images) for the destination `path`.
@@ -372,8 +373,9 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result<SavePlan, Str
/// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the
/// engine command's `{path?, format?, options?, svg?}`.
///
/// When no path is known, desktop asks with a save panel (one file type per save format) and the
/// web opens the Save As dialog (file name and format). With `ask_options` and no options in `p`,
/// When no path is known, desktop asks with a save panel (one file type per save format, or one
/// per template format for Save as Template) and the web opens the Save As dialog (file name and
/// format). With `ask_options` and no options in `p`,
/// a format picked that way that has options asks for them before anything is written (SVG
/// Options, the Save PDF dialog, or the save options dialog: native and `.ai` files on Save As
/// only), and so does Save As or Save a Copy to a given SVG path. → `{path, format, warnings…}` once written, or `{pending: <dialog kind>,
@@ -396,7 +398,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
return Ok(open_options(app, mode.command(), first.format, &first.name, true));
}
let filters = match mode {
SaveMode::Template => vec![(first.format.label, first.format.extensions)],
SaveMode::Template => fileio::template_filters(first.format.id),
_ => fileio::save_filters(first.format.id),
};
let picked = pick_path(app, &FilePick { name: first.name.clone(), folder: first.folder.clone(), filters })?;
@@ -404,7 +406,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
let mut q = if p.is_object() { p.clone() } else { json!({}) };
if let Some(o) = q.as_object_mut() {
o.remove("format");
o.insert("path".into(), json!(with_save_extension(&picked, first.format)));
o.insert("path".into(), json!(with_save_extension(&picked, first.format, mode)));
}
let chosen = plan(app, mode, &acknowledged(&q))?;
if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) {
@@ -464,7 +466,7 @@ fn asks_options(mode: SaveMode, f: &Format) -> bool {
!f.options.is_empty()
&& match f.id {
"vectorcraft" | "ai" => mode == SaveMode::SaveAs,
"template" => false,
"template" | "ait" => false,
_ => true,
}
}
+2
View File
@@ -78,6 +78,8 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Format | Illustrator | Ours: open/place | Ours: save/export | Fidelity and tests |
|---|---|---|---|---|
| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778) |
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure) | PDF-compatible only | see above; no real-file corpus |
| Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) |
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
| Illustrator template `.ait` | open, save | yes | no (save as `.vctemplate`) | |
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text placement issues in some files (#722); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781) |
+3 -2
View File
@@ -403,8 +403,9 @@ markup around it: if another app changed the SVG since, `document.open` reads it
Saving beyond `save_file` goes through `run_command`: `file.saveAs {path?, format?, options?, svg?}` (the document takes
on the new file and format), `file.saveCopy` (the document keeps its path, title and modified state),
`file.saveAsTemplate` (a `.vctemplate` that opens untitled; the suggested name and the Templates folder come back
without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOptions {format?}` (a format's options
`file.saveAsTemplate {path?, format?}` (it writes a `.vctemplate`, or, for `format: "ait"` or a `.ait` path, a
PDF-compatible `.ait` written as a `.ai` file is; both open untitled; the suggested name and the Templates folder come
back without a path), `file.newFromTemplate {path}`, `file.revert` and `file.formatOptions {format?}` (a format's options
with the values a save would use). Without a path the save commands return `{dataBase64, name, folder?, warnings}`. A document saved as SVG or PDF
remembers its options, so the next save reuses them. A PDF opened with `pages`, `cropTo` or `password` is only part
of the file, so Save asks for a new name instead of writing it back.
+2
View File
@@ -539,6 +539,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source.
A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests_import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them.
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
- Save as Template writes a PDF-compatible `.ait` as well as a `.vctemplate`, which stays the default: the save panel and the web build's Save as Template dialog list both, and New from Template opens the `.ait` as an untitled document with its layers and art (`file.saveAsTemplate {format: "ait"}`, or a path ending in `.ait`).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.