mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
M4.26: SVG Options in the engine, a dialog and MCP
document.export, serialize and save, and the Save As / Save a Copy UI
commands, take the SVG options flat or as svg: {…}: styling
(presentation, style, entities, css), outlineText, images (embed or
link; linked files are written next to the SVG), objectIds (layerNames,
minimal, unique), decimals 1–7, minify, responsive, useArtboards and
range "all" (one SVG per artboard), preserveEditing (the SVG reopens as
the full document) and metadata. Unknown keys are rejected and
document.formats lists every option with its default. Results carry the
SVG writer's warnings.
Save writes SVG to an .svg path and Save again reuses its options.
Export As SVG and Save As / Save a Copy to .svg open SVG Options (first
use: Internal CSS and Responsive, then the last-used choices) with a
read-only code view. Save a Copy no longer retargets the document.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2df7edd8c9
commit
4a3859c69b
@@ -14,8 +14,8 @@ const C: &str = "document.export";
|
||||
/// The params that pick artboards (the fields of [`ArtboardPick`]).
|
||||
pub const ARTBOARD_PARAMS: [&str; 3] = ["artboard", "artboards", "range"];
|
||||
|
||||
/// Which artboards an export covers: `range` (`"1-3, 5"`, 1-based) wins over `artboards`
|
||||
/// (0-based), which wins over `artboard`.
|
||||
/// Which artboards an export covers: `range` (`"1-3, 5"`, 1-based, or `"all"`) wins over
|
||||
/// `artboards` (0-based), which wins over `artboard`.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ArtboardPick {
|
||||
@@ -28,6 +28,7 @@ impl ArtboardPick {
|
||||
/// The named artboards (`None`: none named), checked against the document's `count`.
|
||||
pub fn resolve(&self, count: usize) -> std::result::Result<Option<Vec<usize>>, String> {
|
||||
let v = match (&self.range, &self.artboards, self.artboard) {
|
||||
(Some(r), _, _) if r.trim().eq_ignore_ascii_case("all") => (0..count).collect(),
|
||||
(Some(r), _, _) => parse_range(r, count)?,
|
||||
(None, Some(a), _) => a.clone(),
|
||||
(None, None, Some(a)) => vec![a],
|
||||
@@ -53,13 +54,58 @@ impl ArtboardPick {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct SvgOptions {
|
||||
#[serde(flatten)]
|
||||
boards: ArtboardPick,
|
||||
/// Text as glyph outlines (SVG Options → Fonts → Convert to Outlines).
|
||||
outline_text: Option<bool>,
|
||||
/// What an export writes: its files (one, or one per artboard for an SVG of several artboards)
|
||||
/// and the image files an SVG links to.
|
||||
#[derive(Default)]
|
||||
pub struct Encoded {
|
||||
/// `(artboard, bytes)`; the artboard names the file when there are several.
|
||||
pub files: Vec<(Option<usize>, Vec<u8>)>,
|
||||
/// Images to write next to the file(s) (SVG `images: "link"`).
|
||||
pub linked: Vec<vectorcraft_svg::LinkedImage>,
|
||||
/// The encoder's warnings (features approximated or left out, options not applied yet).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl Encoded {
|
||||
fn one(bytes: Vec<u8>) -> Self {
|
||||
Self { files: vec![(None, bytes)], ..Self::default() }
|
||||
}
|
||||
|
||||
/// Every file to write for the destination `path` (a path or a file name): the file itself
|
||||
/// when there is one, else `{stem}-{artboard name}.{ext}` beside it, then the linked images
|
||||
/// beside it under their own names.
|
||||
pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, &'a [u8])> {
|
||||
// Siblings keep the path's own separators (this runs on every platform and on the web).
|
||||
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
|
||||
let sibling = |name: &str| format!("{dir}{name}");
|
||||
let mut out: Vec<(String, &[u8])> = match self.files.as_slice() {
|
||||
[(_, bytes)] => vec![(path.to_string(), bytes.as_slice())],
|
||||
files => {
|
||||
let boards: Vec<usize> = files.iter().map(|(b, _)| b.unwrap_or(0)).collect();
|
||||
let file = std::path::Path::new(&path[dir.len()..]);
|
||||
let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let ext = file.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
|
||||
super::artboard_file_names(doc, &boards)
|
||||
.into_iter()
|
||||
.zip(files)
|
||||
.map(|(board, (_, bytes))| (sibling(&format!("{stem}-{board}{ext}")), bytes.as_slice()))
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
out.extend(self.linked.iter().map(|l| (sibling(&l.name), l.bytes.as_slice())));
|
||||
out
|
||||
}
|
||||
|
||||
/// The one file and the warnings, when the export wrote nothing else.
|
||||
fn single(self, f: &Format) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
if !self.linked.is_empty() {
|
||||
return Err(bad(C, format!("{} with linked images writes several files: use document.export or document.save", f.label)));
|
||||
}
|
||||
match <[_; 1]>::try_from(self.files) {
|
||||
Ok([(_, bytes)]) => Ok((bytes, self.warnings)),
|
||||
Err(_) => Err(bad(C, format!("{} writes one file per artboard here: use document.export, or name one artboard", f.label))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
@@ -98,28 +144,31 @@ fn check_format_size(f: &Format, w: f64, h: f64) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Encode `doc` as `format` (an id or extension from [`super::FORMATS`]) with that format's
|
||||
/// options from `p` (see `document.formats`). Raster formats leave template layers out, and no
|
||||
/// format writes the opacity-mask editing layer.
|
||||
/// options from `p` (see `document.formats`) into one file. Raster formats leave template layers
|
||||
/// out, and no format writes the opacity-mask editing layer. Exports that write several files (an
|
||||
/// SVG per artboard, linked images) go through [`encode_all`].
|
||||
pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
encode_with_warnings(doc, format, p).map(|(bytes, _)| bytes)
|
||||
}
|
||||
|
||||
/// Like [`encode`], also returning the encoder's warnings (PDF: options not applied yet, features
|
||||
/// approximated or left out; the other formats have none).
|
||||
/// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet,
|
||||
/// features approximated or left out; the other formats have none).
|
||||
pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
encode_all(doc, f.id, p)?.single(f)
|
||||
}
|
||||
|
||||
/// [`encode`], with every file the export writes.
|
||||
pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let doc = &*doc.without_edit_modes();
|
||||
let n = doc.artboards.len();
|
||||
if f.id == "pdf" {
|
||||
return super::pdf::encode(C, doc, p);
|
||||
}
|
||||
let bytes = match f.id {
|
||||
"vectorcraft" => vectorcraft_format::save_file(doc),
|
||||
"svg" => {
|
||||
let o: SvgOptions = options(f, p)?;
|
||||
let artboard = if n == 0 { None } else { Some(boards(o.boards.one(n))?) };
|
||||
let opts = vectorcraft_svg::ExportOptions { artboard, outline_text: o.outline_text.unwrap_or(false), ..Default::default() };
|
||||
vectorcraft_svg::export(doc, &opts).into_bytes()
|
||||
"svg" => return super::svg::encode(doc, p).map_err(|e| bad(C, e)),
|
||||
"pdf" => {
|
||||
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
|
||||
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
|
||||
}
|
||||
"png" | "jpg" | "webp" => {
|
||||
let o: RasterOptions = options(f, p)?;
|
||||
@@ -137,5 +186,5 @@ pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(
|
||||
}
|
||||
_ => return Err(bad(C, format!("no encoder for {} yet", f.label))),
|
||||
};
|
||||
Ok((bytes, vec![]))
|
||||
Ok(Encoded::one(bytes))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user