Merge pull request #68 from storytold/pr/m4-svg-export-options
M4: SVG Options, area type and gradients in SVG export, ids and names, SVGZ (M4.26–M4.30)
This commit is contained in:
commit
25df58e722
|
|
@ -4130,7 +4130,6 @@ dependencies = [
|
|||
name = "vectorcraft-engine"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
|
|
@ -4253,10 +4252,12 @@ dependencies = [
|
|||
name = "vectorcraft-svg"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
"resvg",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"svgtypes 0.16.1",
|
||||
"thiserror 2.0.21",
|
||||
|
|
|
|||
|
|
@ -117,18 +117,19 @@ impl Gradient {
|
|||
}
|
||||
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
|
||||
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, s) in self.stops.iter().enumerate() {
|
||||
out.push((s.offset, s.color, s.opacity));
|
||||
if let Some(n) = self.stops.get(i + 1)
|
||||
&& (s.midpoint - 0.5).abs() > 1e-3
|
||||
{
|
||||
self.expanded().map(|(t, c, o, _)| (t, c, o)).collect()
|
||||
}
|
||||
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
|
||||
/// on the gradient's own stops).
|
||||
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
|
||||
self.stops.iter().enumerate().flat_map(move |(i, s)| {
|
||||
let mid = self.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
|
||||
let t = s.offset + (n.offset - s.offset) * s.midpoint;
|
||||
let (c, o) = self.sample(t);
|
||||
out.push((t, c, o));
|
||||
}
|
||||
}
|
||||
out
|
||||
(t, c, o, Some(s.midpoint))
|
||||
});
|
||||
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
|
||||
})
|
||||
}
|
||||
pub fn reverse(&mut self) {
|
||||
self.stops.reverse();
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ vectorcraft-svg = { workspace = true }
|
|||
vectorcraft-format = { workspace = true }
|
||||
vectorcraft-pdf = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Load SVG into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{svg, center?: [x, y]} replace the clipboard with the SVG's objects, centred on `center` (default: the first artboard) → {count}; then run edit.pasteInPlace",
|
||||
"{svg | dataBase64 (SVG or SVGZ bytes), center?: [x, y]} replace the clipboard with the SVG's objects, centred on `center` (default: the first artboard) → {count}; then run edit.pasteInPlace",
|
||||
has_doc,
|
||||
import_svg
|
||||
),
|
||||
|
|
@ -46,7 +46,10 @@ impl Session {
|
|||
*ch = self.clipboard.iter().cloned().map(Arc::new).collect();
|
||||
}
|
||||
d.layers = vec![Arc::new(layer)];
|
||||
Some(vectorcraft_svg::export(&d, &vectorcraft_svg::ExportOptions { artboard: None, object_ids: false, ..Default::default() }))
|
||||
Some(vectorcraft_svg::export(
|
||||
&d,
|
||||
&vectorcraft_svg::ExportOptions { artboard: None, object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() },
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,8 +58,16 @@ fn export_svg(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn import_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let svg = str_param(p, "svg").ok_or_else(|| bad("clipboard.importSvg", "missing svg"))?;
|
||||
let src = vectorcraft_svg::import(svg).map_err(|e| bad("clipboard.importSvg", e.to_string()))?;
|
||||
const C: &str = "clipboard.importSvg";
|
||||
let svg = match (str_param(p, "svg"), str_param(p, "dataBase64")) {
|
||||
(Some(svg), _) => std::borrow::Cow::Borrowed(svg),
|
||||
(None, Some(b64)) => {
|
||||
let bytes = vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad base64"))?;
|
||||
vectorcraft_svg::text_of(&bytes).map_err(|e| bad(C, e.to_string()))?.into_owned().into()
|
||||
}
|
||||
(None, None) => return Err(bad(C, "give svg, or dataBase64 of an SVG or SVGZ file")),
|
||||
};
|
||||
let src = vectorcraft_svg::import(&svg).map_err(|e| bad(C, e.to_string()))?;
|
||||
let mut nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
|
||||
if nodes.is_empty() {
|
||||
return Err(bad("clipboard.importSvg", "the SVG has no drawable objects"));
|
||||
|
|
|
|||
|
|
@ -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" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,30 +8,49 @@ use serde_json::{Value, json};
|
|||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use super::super::*;
|
||||
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, create_dir, encode, encode_with_warnings, writable, write_file, write_or_return};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, default_name, encode, encode_all, merge, writable, write_encoded,
|
||||
write_file, write_or_return,
|
||||
};
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
||||
let (bytes, warnings) = encode_with_warnings(&s.doc()?.doc, f.id, p)?;
|
||||
if f.id == "svg" {
|
||||
return Ok(json!({ "text": String::from_utf8_lossy(&bytes), "warnings": warnings }));
|
||||
let doc = &s.doc()?.doc;
|
||||
let enc = encode_all(doc, f.id, p)?;
|
||||
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
|
||||
let (main, linked) = files.split_at(enc.files.len());
|
||||
// Text formats come back as text.
|
||||
let data = |bytes: &[u8]| match f.id {
|
||||
"svg" => json!({ "text": String::from_utf8_lossy(bytes) }),
|
||||
_ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }),
|
||||
};
|
||||
let mut out = merge(data(main.first().map_or(&[][..], |m| m.1)), json!({ "warnings": enc.warnings }));
|
||||
if main.len() > 1 {
|
||||
out["files"] = main.iter().map(|(name, bytes)| merge(json!({ "name": name }), data(bytes))).collect();
|
||||
}
|
||||
Ok(json!({ "dataBase64": vectorcraft_format::base64_encode(&bytes), "warnings": warnings }))
|
||||
if !linked.is_empty() {
|
||||
out["linked"] = linked.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path");
|
||||
let f = writable("document.export", str_param(p, "format"), path)?;
|
||||
let (bytes, warnings) = encode_with_warnings(&s.doc()?.doc, f.id, p)?;
|
||||
write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings }))
|
||||
let doc = &s.doc()?.doc;
|
||||
let enc = encode_all(doc, f.id, p)?;
|
||||
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
|
||||
}
|
||||
|
||||
/// `p` without its artboard choice (for documents made of one synthetic artboard).
|
||||
/// `p` without its artboard choice, also inside its SVG options (for documents made of one
|
||||
/// synthetic artboard, and for callers that pick the artboard themselves).
|
||||
fn without_artboards(p: &Value) -> Value {
|
||||
let strip = |o: &mut serde_json::Map<String, Value>| ARTBOARD_PARAMS.iter().for_each(|k| _ = o.remove(*k));
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
for k in ARTBOARD_PARAMS {
|
||||
o.remove(k);
|
||||
strip(o);
|
||||
if let Some(svg) = o.get_mut("svg").and_then(Value::as_object_mut) {
|
||||
strip(svg);
|
||||
}
|
||||
}
|
||||
q
|
||||
|
|
@ -79,7 +98,7 @@ fn screen_format(row: &Value) -> Result<ScreenFormat> {
|
|||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if format.id == "vectorcraft" {
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg or pdf"));
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg, svgz or pdf"));
|
||||
}
|
||||
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one
|
||||
// left over from a raster row switched to SVG or PDF).
|
||||
|
|
@ -119,22 +138,9 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
// Names are compared without case: `Icon` and `icon` are one file on most desktop file systems.
|
||||
let mut names = HashSet::new();
|
||||
let mut written = HashSet::new();
|
||||
let mut files = vec![];
|
||||
for b in boards {
|
||||
let mut base: String = doc.artboards[b].name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() {
|
||||
base = format!("Artboard-{}", b + 1);
|
||||
}
|
||||
let mut name = base.clone();
|
||||
for i in 2.. {
|
||||
if names.insert(name.to_lowercase()) {
|
||||
break;
|
||||
}
|
||||
name = format!("{base}-{i}");
|
||||
}
|
||||
for (b, name) in boards.iter().copied().zip(artboard_file_names(&doc, &boards)) {
|
||||
for sf in &formats {
|
||||
let file = format!("{prefix}{name}{}.{}", sf.suffix, sf.format.extensions[0]);
|
||||
if !written.insert(file.to_lowercase()) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
//! `document.open`: every readable format into a new document.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::{Cursor, Read as _};
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -45,7 +44,7 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
|||
if vectorcraft_format::sniff(bytes) {
|
||||
return format("vectorcraft");
|
||||
}
|
||||
if bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
if vectorcraft_svg::is_svgz(bytes) {
|
||||
return format("svgz");
|
||||
}
|
||||
if bytes.starts_with(b"%PDF") {
|
||||
|
|
@ -74,11 +73,16 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
|
|||
let mut doc = match format.id {
|
||||
"vectorcraft" => vectorcraft_format::load(bytes).map_err(err)?,
|
||||
"svg" | "svgz" => {
|
||||
let text: Cow<[u8]> = if format.id == "svgz" { Cow::Owned(gunzip(bytes)?) } else { Cow::Borrowed(bytes) };
|
||||
let text = std::str::from_utf8(&text).map_err(|_| err("SVG is not UTF-8"))?;
|
||||
let (d, w) = vectorcraft_svg::import_with_report(text).map_err(err)?;
|
||||
warnings = w;
|
||||
d
|
||||
let text = vectorcraft_svg::text_of(bytes).map_err(err)?;
|
||||
// An SVG saved with Preserve Editing carries the native document: open that.
|
||||
match vectorcraft_svg::editing_data(&text).and_then(|b64| vectorcraft_format::base64_decode(&b64)) {
|
||||
Some(native) => vectorcraft_format::load(&native).map_err(err)?,
|
||||
None => {
|
||||
let (d, w) = vectorcraft_svg::import_with_report(&text).map_err(err)?;
|
||||
warnings = w;
|
||||
d
|
||||
}
|
||||
}
|
||||
}
|
||||
"pdf" | "ai" | "ait" => {
|
||||
let r = vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(err)?;
|
||||
|
|
@ -159,9 +163,3 @@ fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
|||
d.insert(Some(layer), 0, n).map_err(err)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn gunzip(bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).map_err(|e| err(format!("not a valid SVGZ file: {e}")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
//!
|
||||
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, raster images).
|
||||
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
|
||||
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
|
||||
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`.
|
||||
//! - `save`: `document.save`, `file.saveAsTemplate`.
|
||||
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
|
||||
|
|
@ -16,11 +17,14 @@ mod export;
|
|||
mod load;
|
||||
pub mod pdf;
|
||||
mod save;
|
||||
mod svg;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, encode, encode_with_warnings};
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
|
||||
pub use load::{Loaded, RasterImage, detect, load, open_bytes, raster_image};
|
||||
pub use save::{save_encoding, save_format};
|
||||
pub use svg::options_map as svg_options;
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
|
@ -41,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?} native .vectorcraft (default: the document's path) → {path}; a never-saved document without path → {dataBase64} (stays modified)",
|
||||
"{path?, format?: vectorcraft|svg|svgz (default: from the path's extension, else vectorcraft), svg?: {…SVG options, see document.formats}} (default path: the document's) → {path, linked?}; the document takes the path. An SVG save uses the given SVG options, else the ones this document was last saved with. A never-saved document without path → {dataBase64} (stays modified)",
|
||||
has_doc,
|
||||
save::save
|
||||
),
|
||||
|
|
@ -50,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Serialize Document",
|
||||
[],
|
||||
None,
|
||||
"{format?: vectorcraft (default)|svg|pdf|png|jpg|webp, …the format's options (see document.formats)} → {text, warnings} for svg, else {dataBase64, warnings}",
|
||||
"{format?: vectorcraft (default)|svg|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
has_doc,
|
||||
export::serialize
|
||||
),
|
||||
|
|
@ -59,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" (1-based; PDF writes one page per artboard, default all; the other formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), outlineText?: bool (svg), …the PDF options of document.exportPdf} → {path, format, bytes, warnings}; no path → {dataBase64, format, bytes, warnings}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
|
|
@ -68,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Export Selection…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, format?: png|jpg|webp|svg|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})",
|
||||
"{path?, format?: png|jpg|webp|svg|svgz|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})",
|
||||
has_selection,
|
||||
export::export_selection
|
||||
),
|
||||
|
|
@ -86,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; svg/pdf drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
|
||||
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|svgz|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
|
||||
has_doc,
|
||||
export::export_for_screens
|
||||
),
|
||||
|
|
@ -167,12 +171,10 @@ const RANGE: FormatOption = FormatOption {
|
|||
name: "range",
|
||||
ty: "string",
|
||||
default: "null",
|
||||
description: "1-based artboards such as \"1-3, 5\" (wins over artboards and artboard)",
|
||||
description: "1-based artboards such as \"1-3, 5\", or \"all\" (wins over artboards and artboard)",
|
||||
};
|
||||
const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" };
|
||||
const QUALITY: FormatOption = FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 1–100" };
|
||||
const OUTLINE_TEXT: FormatOption =
|
||||
FormatOption { name: "outlineText", ty: "boolean", default: "false", description: "text as glyph outlines (viewable without the fonts)" };
|
||||
|
||||
/// A format `document.open` reads but nothing writes yet.
|
||||
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
|
||||
|
|
@ -191,17 +193,17 @@ pub const FORMATS: &[Format] = &[
|
|||
raster: false,
|
||||
options: &[],
|
||||
},
|
||||
Format { id: "svg", label: "SVG", extensions: &["svg"], mime: "image/svg+xml", read: true, write: true, raster: false, options: svg::OPTIONS },
|
||||
Format {
|
||||
id: "svg",
|
||||
label: "SVG",
|
||||
extensions: &["svg"],
|
||||
id: "svgz",
|
||||
label: "SVG Compressed",
|
||||
extensions: &["svgz"],
|
||||
mime: "image/svg+xml",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: false,
|
||||
options: &[ARTBOARD, OUTLINE_TEXT],
|
||||
options: svg::OPTIONS,
|
||||
},
|
||||
reader("svgz", "SVG Compressed", &["svgz"], "image/svg+xml", false),
|
||||
Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS },
|
||||
reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false),
|
||||
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
|
||||
|
|
@ -322,22 +324,89 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
|
|||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
/// File-name parts for artboards `boards` of `doc`: the artboard's name with unsafe characters as
|
||||
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
|
||||
/// case: `Icon` and `icon` are one file on most desktop file systems).
|
||||
pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
boards
|
||||
.iter()
|
||||
.map(|&b| {
|
||||
let name = doc.artboards.get(b).map_or("", |a| a.name.as_str());
|
||||
let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() {
|
||||
base = format!("Artboard-{}", b + 1);
|
||||
}
|
||||
let mut name = base.clone();
|
||||
for i in 2.. {
|
||||
if taken.insert(name.to_lowercase()) {
|
||||
break;
|
||||
}
|
||||
name = format!("{base}-{i}");
|
||||
}
|
||||
name
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write an export's files for the destination `path` → `{path, bytes, files?, linked?, …extra}`
|
||||
/// (`files`: every file when there are several; `linked`: the images it links to); with no path
|
||||
/// → the same with `dataBase64` and `{name, dataBase64}` rows, named after `name`.
|
||||
fn write_encoded(path: Option<&str>, name: &str, doc: &vectorcraft_doc::Document, enc: &Encoded, extra: Value) -> Result<Value> {
|
||||
let files = enc.named(doc, path.unwrap_or(name));
|
||||
let (main, linked) = files.split_at(enc.files.len());
|
||||
let row = |(name, bytes): &(String, &[u8])| match path {
|
||||
Some(_) => json!(name),
|
||||
None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) }),
|
||||
};
|
||||
if path.is_some() {
|
||||
for (p, bytes) in &files {
|
||||
write_file(p, bytes)?;
|
||||
}
|
||||
}
|
||||
let Some((first, bytes)) = main.first() else { return Err(EngineError::Other("the export wrote no file".into())) };
|
||||
let mut out = match path {
|
||||
Some(_) => json!({ "path": first, "bytes": bytes.len() }),
|
||||
None => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes), "bytes": bytes.len() }),
|
||||
};
|
||||
if main.len() > 1 {
|
||||
out["files"] = main.iter().map(row).collect();
|
||||
}
|
||||
if !linked.is_empty() {
|
||||
out["linked"] = linked.iter().map(row).collect();
|
||||
}
|
||||
Ok(merge(out, extra))
|
||||
}
|
||||
|
||||
/// `a` with the fields of `b`.
|
||||
fn merge(mut a: Value, b: Value) -> Value {
|
||||
if let (Some(a), Value::Object(b)) = (a.as_object_mut(), b) {
|
||||
a.extend(b);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// A file name for bytes handed back without a path: the document's title with `ext`.
|
||||
fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String {
|
||||
let stem = std::path::Path::new(&doc.title).file_stem().map(|s| s.to_string_lossy().into_owned()).filter(|s| !s.is_empty());
|
||||
format!("{}.{ext}", stem.as_deref().unwrap_or("Untitled"))
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` → `{path, bytes, …extra}`; with no path → `{dataBase64, bytes, …extra}`.
|
||||
fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result<Value> {
|
||||
let mut out = match path {
|
||||
let out = match path {
|
||||
Some(path) => {
|
||||
write_file(path, bytes)?;
|
||||
json!({ "path": path, "bytes": bytes.len() })
|
||||
}
|
||||
None => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes), "bytes": bytes.len() }),
|
||||
};
|
||||
if let (Some(o), Value::Object(e)) = (out.as_object_mut(), extra) {
|
||||
o.extend(e);
|
||||
}
|
||||
Ok(out)
|
||||
Ok(merge(out, extra))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_pdf;
|
||||
#[cfg(test)]
|
||||
mod tests_svg;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,62 @@
|
|||
//! Native saves: `document.save` and `file.saveAsTemplate`.
|
||||
//! Saves: `document.save` (native or SVG) and `file.saveAsTemplate`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::*;
|
||||
use super::write_or_return;
|
||||
use super::{Encoded, Format, default_name, encode_all, format_for_name, writable_format, write_encoded, write_or_return};
|
||||
use crate::DocState;
|
||||
|
||||
/// The format Save writes: `format` (an id or extension), else the format the path's extension
|
||||
/// names, else native. Save writes .vectorcraft, SVG and SVGZ; other formats are exports.
|
||||
pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
|
||||
let f = match (format, path.and_then(format_for_name)) {
|
||||
(Some(_), _) => writable_format(format, None)?,
|
||||
(None, Some(f)) => f,
|
||||
(None, None) => super::format("vectorcraft").ok_or("no native format")?,
|
||||
};
|
||||
match f.id {
|
||||
"vectorcraft" | "svg" | "svgz" => Ok(f),
|
||||
_ => Err(format!("Save writes .{}, .svg or .svgz files: write {} with document.export", vectorcraft_format::EXTENSION, f.label)),
|
||||
}
|
||||
}
|
||||
|
||||
/// What Save writes for `st` in format `f` (see [`save_format`]) → the encoded file and the
|
||||
/// options to remember for the next Save. An SVG save takes the SVG options in `p`, else the ones
|
||||
/// the document was last saved with.
|
||||
pub fn save_encoding(st: &DocState, f: &Format, p: &Value) -> Result<(Encoded, Value)> {
|
||||
const C: &str = "document.save";
|
||||
if f.id == "vectorcraft" {
|
||||
return Ok((encode_all(&st.doc, f.id, p)?, Value::Null));
|
||||
}
|
||||
let given = super::svg_options(p).map_err(|e| bad(C, e))?;
|
||||
let opts = if given.is_empty() { st.save_options.clone() } else { Value::Object(given) };
|
||||
let enc = encode_all(&st.doc, f.id, &json!({ "svg": opts }))?;
|
||||
if enc.files.len() != 1 {
|
||||
return Err(bad(C, "Save writes one artboard: name one, or export several with document.export"));
|
||||
}
|
||||
Ok((enc, opts))
|
||||
}
|
||||
|
||||
pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.save";
|
||||
let path = str_param(p, "path").map(str::to_string).or_else(|| s.active().and_then(|d| d.path.clone()));
|
||||
let bytes = vectorcraft_format::save_file(&s.doc()?.doc);
|
||||
let f = save_format(str_param(p, "format"), path.as_deref()).map_err(|e| bad(C, e))?;
|
||||
let st = s.doc()?;
|
||||
let (enc, opts) = save_encoding(st, f, p)?;
|
||||
let Some(path) = path else {
|
||||
// Nowhere to save to (web, agents): hand the bytes back; the document stays modified.
|
||||
return write_or_return(None, &bytes, json!({}));
|
||||
return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, json!({}));
|
||||
};
|
||||
super::write_file(&path, &bytes)?;
|
||||
let out = write_encoded(Some(&path), &path, &st.doc, &enc, json!({}))?;
|
||||
let st = s.doc_mut()?;
|
||||
st.path = Some(path.clone());
|
||||
st.save_options = opts;
|
||||
st.mark_saved();
|
||||
Ok(json!({ "path": path }))
|
||||
let mut r = json!({ "path": path });
|
||||
if let Some(linked) = out.get("linked") {
|
||||
r["linked"] = linked.clone();
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// File → Save as Template: a native copy flagged so opening it starts a new untitled document.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
//! SVG Options: the SVG settings of `document.export`, `serialize` and `save`, read from the
|
||||
//! params' top level or from an `svg` object (which wins), and the files an SVG export writes
|
||||
//! (one per artboard, plus the images it links to).
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_svg::ExportOptions;
|
||||
|
||||
use super::encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded};
|
||||
use super::{ARTBOARD, ARTBOARDS, FormatOption, RANGE};
|
||||
|
||||
/// The options `document.formats` lists for SVG (the defaults are [`ExportOptions::default`]'s).
|
||||
pub const OPTIONS: &[FormatOption] = &[
|
||||
ARTBOARD,
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
FormatOption {
|
||||
name: "useArtboards",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "false: one SVG of the bounds of all art instead of artboards (several artboards write one file each)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "styling",
|
||||
ty: "string",
|
||||
default: "\"presentation\"",
|
||||
description: "presentation (attributes) | style (style attributes) | entities (style attributes as DOCTYPE entities) | css (internal <style> classes)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "outlineText",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "Fonts: text as glyph outlines (viewable without the fonts) instead of <text>",
|
||||
},
|
||||
FormatOption {
|
||||
name: "images",
|
||||
ty: "string",
|
||||
default: "\"embed\"",
|
||||
description: "embed (data: URIs) | link (linked images keep their file; embedded ones are written next to the SVG, or returned as `linked`)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "objectIds",
|
||||
ty: "string",
|
||||
default: "\"layerNames\"",
|
||||
description: "layerNames (readable ids from names) | minimal (only referenced ids) | unique (content-hashed prefix on every id and class)",
|
||||
},
|
||||
FormatOption { name: "decimals", ty: "integer", default: "3", description: "coordinate precision, 1–7 decimal places" },
|
||||
FormatOption { name: "minify", ty: "boolean", default: "false", description: "no XML declaration, indentation or line breaks" },
|
||||
FormatOption { name: "responsive", ty: "boolean", default: "false", description: "no width/height: the SVG scales to its container" },
|
||||
FormatOption {
|
||||
name: "preserveEditing",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "embed the native document in <metadata> so VectorCraft reopens the SVG with nothing lost",
|
||||
},
|
||||
FormatOption { name: "metadata", ty: "boolean", default: "false", description: "write <metadata> with the title and format (Dublin Core)" },
|
||||
FormatOption {
|
||||
name: "fewerTspans",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "type: one positioned <tspan> per line instead of one per style run, tab and justified word (smaller; viewers space the line themselves)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "svg",
|
||||
ty: "object",
|
||||
default: "null",
|
||||
description: "these options as one object, e.g. {styling: \"css\", decimals: 2}; it wins over top-level keys and rejects unknown ones",
|
||||
},
|
||||
];
|
||||
|
||||
/// Picks the artboards of an SVG export (beside [`ArtboardPick`]).
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct Boards {
|
||||
#[serde(flatten)]
|
||||
pick: ArtboardPick,
|
||||
use_artboards: Option<bool>,
|
||||
}
|
||||
|
||||
/// The SVG options in `p`: its top-level option keys overlaid with its `svg` object. Empty when
|
||||
/// `p` names none.
|
||||
pub fn options_map(p: &Value) -> Result<Map<String, Value>, String> {
|
||||
let mut m = Map::new();
|
||||
let Some(o) = p.as_object() else { return Ok(m) };
|
||||
for opt in OPTIONS.iter().filter(|opt| opt.name != "svg") {
|
||||
if let Some(v) = o.get(opt.name).filter(|v| !v.is_null()) {
|
||||
m.insert(opt.name.into(), v.clone());
|
||||
}
|
||||
}
|
||||
match o.get("svg") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Object(svg)) => m.extend(svg.clone()),
|
||||
Some(_) => return Err("`svg` must be an object of SVG options (see document.formats)".into()),
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// The writer options and the artboards (`None`: the art bounds) an SVG export of `doc` covers.
|
||||
fn plan(doc: &Document, p: &Value) -> Result<(ExportOptions, Vec<Option<usize>>), String> {
|
||||
let mut m = options_map(p)?;
|
||||
let picks: Map<String, Value> = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).filter_map(|k| m.remove_entry(*k)).collect();
|
||||
let boards = Boards::deserialize(Value::Object(picks)).map_err(|e| format!("SVG options: {e}"))?;
|
||||
let opts = ExportOptions::deserialize(Value::Object(m)).map_err(|e| format!("SVG options: {e}"))?;
|
||||
opts.check().map_err(|e| format!("SVG options: {e}"))?;
|
||||
let n = doc.artboards.len();
|
||||
if n == 0 || boards.use_artboards == Some(false) {
|
||||
return Ok((opts, vec![None]));
|
||||
}
|
||||
let picked = boards.pick.resolve(n)?.unwrap_or_else(|| vec![0]);
|
||||
Ok((opts, picked.into_iter().map(Some).collect()))
|
||||
}
|
||||
|
||||
/// Encode `doc` as SVG (or gzipped, SVGZ): one file per chosen artboard, with the images they
|
||||
/// link to and the writer's warnings.
|
||||
pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Encoded, String> {
|
||||
let (opts, boards) = plan(doc, p)?;
|
||||
// Preserve editing embeds the native document (once, shared by every file).
|
||||
let native = opts.preserve_editing.then(|| vectorcraft_format::save(doc, false));
|
||||
let mut enc = Encoded::default();
|
||||
for artboard in boards {
|
||||
let out = vectorcraft_svg::export_full(doc, &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
|
||||
for l in out.linked {
|
||||
if !enc.linked.iter().any(|e| e.name == l.name) {
|
||||
enc.linked.push(l);
|
||||
}
|
||||
}
|
||||
for w in out.warnings {
|
||||
if !enc.warnings.contains(&w) {
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
enc.files.push((artboard, if compressed { vectorcraft_svg::compress(&out.svg) } else { out.svg.into_bytes() }));
|
||||
}
|
||||
Ok(enc)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
use std::io::{Cursor, Write as _};
|
||||
use std::io::Cursor;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
|
@ -55,9 +55,7 @@ fn opens_every_readable_format() {
|
|||
let native = b64(&s.execute("document.serialize", &json!({})).unwrap());
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
let pdf = b64(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap());
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(svg.as_bytes()).unwrap();
|
||||
let svgz = gz.finish().unwrap();
|
||||
let svgz = vectorcraft_svg::compress(&svg);
|
||||
|
||||
for (name, bytes, format) in [
|
||||
("a.vectorcraft", native.clone(), "vectorcraft"),
|
||||
|
|
@ -156,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() {
|
|||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(ids("writable"), ["vectorcraft", "svg", "pdf", "png", "jpg", "webp"]);
|
||||
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp"]);
|
||||
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
|
||||
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||||
assert_eq!(png["options"]["scale"]["default"], 1);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
//! SVG Options through the engine: every option changes the output, artboards write one file
|
||||
//! each, junk is rejected, and Save writes SVG with remembered options.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session(artboards: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": artboards})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10.123456, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn svg(s: &mut Session, p: Value) -> String {
|
||||
let mut p = p;
|
||||
p["format"] = json!("svg");
|
||||
s.execute("document.serialize", &p).unwrap_or_else(|e| panic!("{p}: {e}"))["text"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn tmp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-svgopts-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A session whose document holds one embedded 2×2 PNG.
|
||||
fn image_session() -> Session {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 200, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "dot.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_option_changes_the_output() {
|
||||
let mut s = session(1);
|
||||
let plain = svg(&mut s, json!({}));
|
||||
assert!(plain.contains("fill=\"#") && plain.contains(" width=\"200\"") && plain.contains("id=\"Layer_1\""), "{plain}");
|
||||
let changed = |s: &mut Session, p: Value, check: &dyn Fn(&str) -> bool| {
|
||||
let out = svg(s, p.clone());
|
||||
assert_ne!(out, plain, "{p} changed nothing");
|
||||
assert!(check(&out), "{p}:\n{out}");
|
||||
};
|
||||
changed(&mut s, json!({"styling": "style"}), &|o| o.contains("style=\"fill:#"));
|
||||
changed(&mut s, json!({"styling": "entities"}), &|o| o.contains("<!ENTITY st1 ") && o.contains("style=\"&st1;\""));
|
||||
changed(&mut s, json!({"svg": {"styling": "css"}}), &|o| o.contains("<style>") && o.contains("class=\"cls-1\""));
|
||||
changed(&mut s, json!({"objectIds": "minimal"}), &|o| !o.contains(" id=\""));
|
||||
changed(&mut s, json!({"objectIds": "unique"}), &|o| o.contains(" id=\"u") && o.contains("-Layer_1\""));
|
||||
changed(&mut s, json!({"decimals": 1}), &|o| o.contains("M10.1 ") && !o.contains("10.123"));
|
||||
changed(&mut s, json!({"decimals": 6}), &|o| o.contains("10.123456"));
|
||||
changed(&mut s, json!({"minify": true}), &|o| !o.trim_end().contains('\n') && !o.contains("<?xml"));
|
||||
changed(&mut s, json!({"responsive": true}), &|o| !o.contains(" width=\"200\"") && o.contains("viewBox=\"0 0 200 100\""));
|
||||
changed(&mut s, json!({"useArtboards": false}), &|o| !o.contains("viewBox=\"0 0 200 100\""));
|
||||
changed(&mut s, json!({"metadata": true}), &|o| o.contains("<metadata>") && o.contains("<dc:format>image/svg+xml</dc:format>"));
|
||||
changed(&mut s, json!({"preserveEditing": true}), &|o| o.contains(vectorcraft_svg::EDITING_NS));
|
||||
s.execute("text.create", &json!({"x": 20, "y": 80, "text": "Hi"})).unwrap();
|
||||
let with_text = svg(&mut s, json!({}));
|
||||
assert!(with_text.contains("<text"));
|
||||
assert!(!svg(&mut s, json!({"outlineText": true})).contains("<text"), "Fonts → outlines");
|
||||
// The nested object wins over the top level.
|
||||
assert!(svg(&mut s, json!({"styling": "style", "svg": {"styling": "css"}})).contains("<style>"));
|
||||
|
||||
let mut s = image_session();
|
||||
assert!(svg(&mut s, json!({})).contains("href=\"data:image/png;base64,"));
|
||||
let r = s.execute("document.serialize", &json!({"format": "svg", "images": "link"})).unwrap();
|
||||
let name = r["linked"][0]["name"].as_str().unwrap().to_string();
|
||||
assert!(name.ends_with(".png") && r["text"].as_str().unwrap().contains(&format!("href=\"{name}\"")), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_options_are_rejected() {
|
||||
let mut s = session(1);
|
||||
for p in [
|
||||
json!({"styling": "fancy"}),
|
||||
json!({"objectIds": true}),
|
||||
json!({"decimals": 0}),
|
||||
json!({"decimals": 8}),
|
||||
json!({"svg": {"bogus": 1}}),
|
||||
json!({"svg": "css"}),
|
||||
json!({"images": "inline"}),
|
||||
json!({"useArtboards": "yes"}),
|
||||
] {
|
||||
let mut q = p.clone();
|
||||
q["format"] = json!("svg");
|
||||
assert!(s.execute("document.export", &q).is_err(), "{p} was accepted");
|
||||
}
|
||||
// Other formats ignore the SVG options.
|
||||
assert!(s.execute("document.export", &json!({"format": "png", "styling": "fancy"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_artboards_write_one_file_each() {
|
||||
let mut s = session(3);
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": "Cover"})).unwrap();
|
||||
let dir = tmp_dir("boards");
|
||||
let path = dir.join("art.svg").to_string_lossy().to_string();
|
||||
let r = s.execute("document.export", &json!({"path": path, "range": "all"})).unwrap();
|
||||
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
|
||||
let names: Vec<&str> = files.iter().map(|f| f.rsplit(['/', '\\']).next().unwrap()).collect();
|
||||
assert_eq!(names, ["art-Cover.svg", "art-Artboard-2.svg", "art-Artboard-3.svg"]);
|
||||
for f in &files {
|
||||
assert!(std::fs::read_to_string(f).unwrap().contains("viewBox=\"0 0 200 100\""), "{f}");
|
||||
}
|
||||
let r = s.execute("document.export", &json!({"format": "svg", "svg": {"range": "2-3"}})).unwrap();
|
||||
assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}");
|
||||
let r = s.execute("document.serialize", &json!({"format": "svg", "range": "all"})).unwrap();
|
||||
assert_eq!(r["files"].as_array().unwrap().len(), 3);
|
||||
assert!(r["files"][2]["text"].as_str().unwrap().starts_with("<?xml"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_images_are_written_next_to_the_svg() {
|
||||
let mut s = image_session();
|
||||
let dir = tmp_dir("linked");
|
||||
let path = dir.join("pic.svg").to_string_lossy().to_string();
|
||||
let r = s.execute("document.export", &json!({"path": path, "images": "link"})).unwrap();
|
||||
let linked = r["linked"][0].as_str().unwrap();
|
||||
assert!(image::load_from_memory(&std::fs::read(linked).unwrap()).is_ok(), "{linked}");
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let name = std::path::Path::new(linked).file_name().unwrap().to_string_lossy().to_string();
|
||||
assert!(text.contains(&format!("href=\"{name}\"")), "{text}");
|
||||
assert!(s.execute("document.exportSelection", &json!({"format": "svg", "images": "link"})).is_err(), "nowhere to put the images");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_editing_reopens_the_native_document() {
|
||||
let mut s = session(2);
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "Back"})).unwrap();
|
||||
let text = svg(&mut s, json!({"preserveEditing": true}));
|
||||
let r = s.execute("document.open", &json!({"name": "both.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes())})).unwrap();
|
||||
assert_eq!(r["format"], "svg");
|
||||
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");
|
||||
// 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();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_writes_svg_and_remembers_its_options() {
|
||||
let mut s = session(1);
|
||||
let dir = tmp_dir("save");
|
||||
let path = dir.join("doc.svg").to_string_lossy().to_string();
|
||||
let r = s.execute("document.save", &json!({"path": path, "svg": {"styling": "css", "decimals": 2}})).unwrap();
|
||||
assert_eq!(r["path"], json!(path));
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("<style>"));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.path.as_deref(), Some(path.as_str()));
|
||||
assert!(!st.is_dirty());
|
||||
s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
s.execute("document.save", &json!({})).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("<style>") && text.matches("<path").count() == 2, "Save reuses the options: {text}");
|
||||
assert!(s.execute("document.save", &json!({"path": dir.join("x.png").to_string_lossy()})).is_err(), "PNG is an export");
|
||||
// A native save switches back.
|
||||
let native = dir.join("doc.vectorcraft").to_string_lossy().to_string();
|
||||
s.execute("document.save", &json!({"path": native})).unwrap();
|
||||
assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap()));
|
||||
assert_eq!(s.doc().unwrap().save_options, Value::Null);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_list_the_svg_options_with_their_defaults() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let svg = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "svg").unwrap();
|
||||
let defaults = serde_json::to_value(vectorcraft_svg::ExportOptions::default()).unwrap();
|
||||
for (k, v) in defaults.as_object().unwrap() {
|
||||
assert_eq!(&svg["options"][k]["default"], v, "{k}");
|
||||
}
|
||||
for k in ["useArtboards", "range", "svg"] {
|
||||
assert!(svg["options"].get(k).is_some(), "{k}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_tspans_reaches_the_writer() {
|
||||
let mut s = Session::new();
|
||||
let src = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><text x="10" y="40" font-size="12">plain <tspan fill="#ff0000">red</tspan> plain</text></svg>"##;
|
||||
s.execute("document.open", &json!({"name": "two-runs.svg", "dataBase64": vectorcraft_format::base64_encode(src.as_bytes())})).unwrap();
|
||||
let placed = |svg: &str| svg.matches("<tspan x=").count();
|
||||
assert_eq!(placed(&svg(&mut s, json!({}))), 3, "a tspan per style run");
|
||||
assert_eq!(placed(&svg(&mut s, json!({"fewerTspans": true}))), 1, "a tspan per line");
|
||||
}
|
||||
|
||||
/// Gunzip SVGZ bytes.
|
||||
fn gunzip(bytes: &[u8]) -> String {
|
||||
assert!(vectorcraft_svg::is_svgz(bytes), "gzip magic");
|
||||
vectorcraft_svg::text_of(bytes).unwrap().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svgz_is_the_svg_export_gzipped() {
|
||||
let mut s = session(2);
|
||||
let opts = json!({"styling": "css", "range": "2"});
|
||||
let mut p = opts.clone();
|
||||
p["format"] = json!("svgz");
|
||||
let svgz = vectorcraft_format::base64_decode(s.execute("document.serialize", &p).unwrap()["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(gunzip(&svgz), svg(&mut s, opts));
|
||||
// The format comes from the extension too, and Save writes it.
|
||||
let dir = tmp_dir("svgz");
|
||||
let path = dir.join("art.svgz").to_string_lossy().to_string();
|
||||
s.execute("document.export", &json!({"path": path, "range": "all"})).unwrap();
|
||||
assert!(gunzip(&std::fs::read(dir.join("art-Artboard-1.svgz")).unwrap()).contains("<svg"));
|
||||
let saved = dir.join("saved.svgz").to_string_lossy().to_string();
|
||||
s.execute("document.save", &json!({"path": saved, "svg": {}})).unwrap();
|
||||
assert!(gunzip(&std::fs::read(&saved).unwrap()).contains("<svg"));
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), Some(saved.as_str()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svgz_opens_and_pastes_like_svg() {
|
||||
let mut s = session(1);
|
||||
s.execute("shape.ellipse", &json!({"x": 100, "y": 20, "width": 40, "height": 30})).unwrap();
|
||||
let text = svg(&mut s, json!({}));
|
||||
let open = |s: &mut Session, name: &str, bytes: &[u8]| {
|
||||
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
doc.title.clear();
|
||||
vectorcraft_format::save(&doc, false)
|
||||
};
|
||||
let plain = open(&mut s, "a.svg", text.as_bytes());
|
||||
// Detected by the magic bytes whatever the name says.
|
||||
assert_eq!(open(&mut s, "a.svg", &vectorcraft_svg::compress(&text)), plain);
|
||||
assert_eq!(open(&mut s, "a.svgz", &vectorcraft_svg::compress(&text)), plain);
|
||||
let r = s.execute("clipboard.importSvg", &json!({"dataBase64": vectorcraft_format::base64_encode(&vectorcraft_svg::compress(&text))})).unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
assert!(s.execute("document.open", &json!({"name": "bad.svgz", "dataBase64": vectorcraft_format::base64_encode(&[0x1f, 0x8b, 1, 2])})).is_err());
|
||||
}
|
||||
|
|
@ -111,6 +111,9 @@ pub struct DocState {
|
|||
pub mask_view: Option<NodeId>,
|
||||
/// View → Show Transparency Grid, per document (view state: not saved, not undoable).
|
||||
pub transparency_grid: bool,
|
||||
/// The SVG options this document was last saved with as SVG (Save reuses them; JSON as in
|
||||
/// `document.save {svg}`, null when none).
|
||||
pub save_options: Value,
|
||||
}
|
||||
|
||||
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
|
@ -134,6 +137,7 @@ impl DocState {
|
|||
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
mask_view: None,
|
||||
transparency_grid: false,
|
||||
save_options: Value::Null,
|
||||
}
|
||||
}
|
||||
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
|
||||
|
|
|
|||
|
|
@ -35,3 +35,5 @@ mod tests_fileio;
|
|||
mod tests_freeform;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
#[cfg(test)]
|
||||
mod tests_svg;
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ fn export_tool_lists_engine_formats_and_options() {
|
|||
let tools = tool_definitions();
|
||||
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
|
||||
let props = &export["inputSchema"]["properties"];
|
||||
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "pdf", "png", "jpg", "webp"]));
|
||||
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp"]));
|
||||
for k in ["artboard", "range", "options"] {
|
||||
assert!(props.get(k).is_some(), "export takes {k}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
//! SVG Options through the MCP `export` tool: format options pass through to the engine.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Backend, Headless, call_tool};
|
||||
|
||||
fn svg_of(h: &mut Headless, args: Value) -> String {
|
||||
let r = call_tool(h, "export", &args);
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let v: Value = serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap();
|
||||
String::from_utf8(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_tool_takes_svg_options() {
|
||||
let mut h = Headless::new();
|
||||
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 100, "height": 50}})).unwrap();
|
||||
h.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}})).unwrap();
|
||||
let plain = svg_of(&mut h, json!({"format": "svg"}));
|
||||
assert!(!plain.contains("<style>"));
|
||||
assert!(svg_of(&mut h, json!({"format": "svg", "options": {"styling": "css"}})).contains("<style>"));
|
||||
assert!(svg_of(&mut h, json!({"format": "svg", "options": {"svg": {"styling": "css", "responsive": true}}})).contains("<style>"));
|
||||
let r = call_tool(&mut h, "export", &json!({"format": "svg", "options": {"svg": {"styling": "fancy"}}}));
|
||||
assert!(r.is_error, "junk options are rejected");
|
||||
}
|
||||
|
|
@ -18,6 +18,9 @@ vectorcraft-text = { workspace = true }
|
|||
kurbo = { workspace = true }
|
||||
usvg = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
# SVGZ (gzip, pure-Rust backend: builds for wasm).
|
||||
flate2 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
# Colour / transform / length parsing for the <text> fallback (MIT OR Apache-2.0; already used by usvg).
|
||||
svgtypes = "0.16"
|
||||
|
|
|
|||
|
|
@ -10,22 +10,25 @@ use vectorcraft_doc::{CharStyle, Justify};
|
|||
use vectorcraft_effects::stroke::{self, Written, WrittenShape};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
|
||||
use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape};
|
||||
use crate::{EDITING_NS, ExportOptions, ImageMode, LinkedImage, ObjectIds, Output, Styling, base64_encode, fmt_num, fnv1a, xml_escape};
|
||||
|
||||
type Props = Vec<(&'static str, String)>;
|
||||
|
||||
/// One laid-out line of area type: baseline anchor (text space) and its pieces of each run.
|
||||
struct AreaLine<'t> {
|
||||
x: f64,
|
||||
y: f64,
|
||||
pieces: Vec<(usize, &'t str)>,
|
||||
hyphenated: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> (String, Vec<String>) {
|
||||
pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output {
|
||||
// Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result.
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
if opts.object_ids != ObjectIds::Unique {
|
||||
return write(doc, opts, native, "");
|
||||
}
|
||||
// Unique ids: every id and class name gets a prefix hashed from the output written without
|
||||
// one, so the same document always gets the same ids and different ones never share any.
|
||||
let plain = write(doc, opts, native, "");
|
||||
let prefix = format!("u{:010x}-", fnv1a(plain.svg.as_bytes()) & 0xff_ffff_ffff);
|
||||
write(doc, opts, native, &prefix)
|
||||
}
|
||||
|
||||
fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: &str) -> Output {
|
||||
let rect = opts
|
||||
.artboard
|
||||
.and_then(|i| doc.artboards.get(i))
|
||||
|
|
@ -44,12 +47,15 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> (String, Vec<Strin
|
|||
names: HashMap::new(),
|
||||
depth: 1,
|
||||
patterns: HashMap::new(),
|
||||
gradients: HashMap::new(),
|
||||
pattern_nest: 0,
|
||||
brushes: None,
|
||||
knockout: doc.page_knockout,
|
||||
anonymous: false,
|
||||
knockout_filter: None,
|
||||
warnings: vec![],
|
||||
id_prefix,
|
||||
linked: Vec::new(),
|
||||
};
|
||||
w.assign_name_ids();
|
||||
// Page Isolated Blending / Page Knockout Group: the page content is one isolated group.
|
||||
|
|
@ -70,20 +76,28 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> (String, Vec<Strin
|
|||
if !opts.minify {
|
||||
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
}
|
||||
if opts.styling == Styling::StyleEntities && !w.classes.is_empty() {
|
||||
out.push_str("<!DOCTYPE svg [");
|
||||
for (i, c) in w.classes.iter().enumerate() {
|
||||
out.push_str(&format!("{nl}{}<!ENTITY st{} \"{}\">", w.indent(1), i + 1, entity_value(c)));
|
||||
}
|
||||
out.push_str(&format!("{nl}]>{nl}"));
|
||||
}
|
||||
let (ww, hh) = (w.num(rect.width()), w.num(rect.height()));
|
||||
out.push_str("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"");
|
||||
if !opts.responsive {
|
||||
out.push_str(&format!(" width=\"{ww}\" height=\"{hh}\""));
|
||||
}
|
||||
out.push_str(&format!(" viewBox=\"0 0 {ww} {hh}\">{nl}"));
|
||||
if !w.defs.is_empty() || !w.classes.is_empty() {
|
||||
let css = opts.styling == Styling::InternalCss && !w.classes.is_empty();
|
||||
if !w.defs.is_empty() || css {
|
||||
out.push_str(&w.indent(1));
|
||||
out.push_str(&format!("<defs>{nl}"));
|
||||
if !w.classes.is_empty() {
|
||||
if css {
|
||||
out.push_str(&w.indent(2));
|
||||
out.push_str("<style>");
|
||||
for (i, c) in w.classes.iter().enumerate() {
|
||||
out.push_str(&format!("{nl}{}.cls-{}{{{}}}", w.indent(3), i + 1, xml_escape(c)));
|
||||
out.push_str(&format!("{nl}{}.{id_prefix}cls-{}{{{}}}", w.indent(3), i + 1, xml_escape(c)));
|
||||
}
|
||||
out.push_str(&format!("{nl}{}</style>{nl}", w.indent(2)));
|
||||
}
|
||||
|
|
@ -95,10 +109,37 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> (String, Vec<Strin
|
|||
out.push_str(&w.indent(1));
|
||||
out.push_str(&format!("<title>{}</title>{nl}", xml_escape(&doc.title)));
|
||||
}
|
||||
w.metadata(&mut out, native);
|
||||
out.push_str(&w.body);
|
||||
out.push_str("</svg>");
|
||||
out.push_str(nl);
|
||||
(out, w.warnings)
|
||||
Output { svg: out, linked: w.linked, warnings: w.warnings }
|
||||
}
|
||||
|
||||
/// A CSS declaration block as the literal value of an `<!ENTITY>`: quotes, `%` and markup
|
||||
/// characters become references that still read as themselves inside the `style` attribute.
|
||||
fn entity_value(decl: &str) -> String {
|
||||
let mut o = String::with_capacity(decl.len());
|
||||
for c in decl.chars() {
|
||||
match c {
|
||||
'"' => o.push_str("""),
|
||||
'%' => o.push_str("%"),
|
||||
// Doubly escaped: the entity's replacement text is parsed again where it is used.
|
||||
'&' => o.push_str("&#38;"),
|
||||
'<' => o.push_str("&#60;"),
|
||||
_ => o.push(c),
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The usual file extension of an image MIME type.
|
||||
fn image_ext(mime: &str) -> &str {
|
||||
match mime {
|
||||
"image/jpeg" => "jpg",
|
||||
"image/svg+xml" => "svg",
|
||||
m => m.strip_prefix("image/").filter(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or("bin"),
|
||||
}
|
||||
}
|
||||
|
||||
struct Writer<'a> {
|
||||
|
|
@ -115,6 +156,8 @@ struct Writer<'a> {
|
|||
depth: usize,
|
||||
/// `<pattern>` def ids by pattern name + placement.
|
||||
patterns: HashMap<String, String>,
|
||||
/// Gradient def ids by content (everything but the id).
|
||||
gradients: HashMap<String, String>,
|
||||
pattern_nest: u32,
|
||||
/// Whether the group being written is a knockout group (what its neutral children inherit).
|
||||
knockout: bool,
|
||||
|
|
@ -126,6 +169,10 @@ struct Writer<'a> {
|
|||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
/// Features written approximately (once each).
|
||||
warnings: Vec<String>,
|
||||
/// Prepended to every id and class name ([`ObjectIds::Unique`]).
|
||||
id_prefix: &'a str,
|
||||
/// Image files the SVG links to ([`ImageMode::Link`]).
|
||||
linked: Vec<LinkedImage>,
|
||||
}
|
||||
|
||||
/// How one stroke is written.
|
||||
|
|
@ -200,16 +247,29 @@ impl Writer<'_> {
|
|||
fn fresh_id(&mut self, prefix: &str) -> String {
|
||||
let mut i = 1;
|
||||
loop {
|
||||
let id = format!("{prefix}-{i}");
|
||||
let id = format!("{}{prefix}-{i}", self.id_prefix);
|
||||
if self.used_ids.insert(id.clone()) {
|
||||
return id;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
/// `base` (with the unique prefix) if free, else `base-2`, `base-3`…
|
||||
fn unique_id(&mut self, base: &str) -> String {
|
||||
let base = format!("{}{base}", self.id_prefix);
|
||||
let mut cand = base.clone();
|
||||
for i in 2.. {
|
||||
if self.used_ids.insert(cand.clone()) {
|
||||
break;
|
||||
}
|
||||
cand = format!("{base}-{i}");
|
||||
}
|
||||
cand
|
||||
}
|
||||
|
||||
/// Reserve ids for every named object up front so generated def ids never collide with them.
|
||||
fn assign_name_ids(&mut self) {
|
||||
if !self.opts.object_ids {
|
||||
if self.opts.object_ids == ObjectIds::Minimal {
|
||||
return;
|
||||
}
|
||||
let mut named: Vec<(NodeId, String)> = Vec::new();
|
||||
|
|
@ -219,20 +279,21 @@ impl Writer<'_> {
|
|||
}
|
||||
});
|
||||
for (id, base) in named {
|
||||
let mut cand = base.clone();
|
||||
let mut i = 2;
|
||||
while !self.used_ids.insert(cand.clone()) {
|
||||
cand = format!("{base}-{i}");
|
||||
i += 1;
|
||||
}
|
||||
let cand = self.unique_id(&base);
|
||||
self.names.insert(id, cand);
|
||||
}
|
||||
}
|
||||
/// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to
|
||||
/// differ from it (spaces, punctuation, duplicates, the unique prefix).
|
||||
fn id_attr(&self, n: &Node) -> String {
|
||||
if self.anonymous {
|
||||
return String::new();
|
||||
}
|
||||
self.names.get(&n.id).map(|id| format!(" id=\"{}\"", xml_escape(id))).unwrap_or_default()
|
||||
let Some(id) = self.names.get(&n.id) else { return String::new() };
|
||||
match n.name.as_deref() {
|
||||
Some(name) if name != id => format!(" id=\"{}\" data-name=\"{}\"", xml_escape(id), xml_escape(name)),
|
||||
_ => format!(" id=\"{}\"", xml_escape(id)),
|
||||
}
|
||||
}
|
||||
fn matrix(&self, m: Affine) -> String {
|
||||
let c = m.as_coeffs();
|
||||
|
|
@ -249,7 +310,7 @@ impl Writer<'_> {
|
|||
Styling::PresentationAttributes => {
|
||||
// CSS-only properties go in a style attribute.
|
||||
let (style, attrs): (Vec<_>, Vec<_>) =
|
||||
props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings"));
|
||||
props.iter().partition(|(k, _)| matches!(*k, "mix-blend-mode" | "isolation" | "font-feature-settings" | "font-kerning"));
|
||||
let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect();
|
||||
if !style.is_empty() {
|
||||
s.push_str(&format!(" style=\"{}\"", xml_escape(&css(&style))));
|
||||
|
|
@ -257,20 +318,55 @@ impl Writer<'_> {
|
|||
s
|
||||
}
|
||||
Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css(&props.iter().collect::<Vec<_>>()))),
|
||||
Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css(&props.iter().collect::<Vec<_>>()))),
|
||||
Styling::InternalCss => {
|
||||
let decl = css(&props.iter().collect::<Vec<_>>());
|
||||
let i = match self.classes.iter().position(|c| *c == decl) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.classes.push(decl);
|
||||
self.classes.len() - 1
|
||||
}
|
||||
};
|
||||
format!(" class=\"cls-{}\"", i + 1)
|
||||
let i = self.class(css(&props.iter().collect::<Vec<_>>()));
|
||||
format!(" class=\"{}cls-{i}\"", self.id_prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1-based number of a distinct declaration block (a CSS class or a style entity).
|
||||
fn class(&mut self, decl: String) -> usize {
|
||||
match self.classes.iter().position(|c| *c == decl) {
|
||||
Some(i) => i + 1,
|
||||
None => {
|
||||
self.classes.push(decl);
|
||||
self.classes.len()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `<metadata>`: the Dublin Core title and format ([`ExportOptions::metadata`]) and the native
|
||||
/// document ([`ExportOptions::preserve_editing`]).
|
||||
fn metadata(&self, out: &mut String, native: Option<&[u8]>) {
|
||||
let native = native.filter(|_| self.opts.preserve_editing);
|
||||
if !self.opts.metadata && native.is_none() {
|
||||
return;
|
||||
}
|
||||
let nl = if self.opts.minify { "" } else { "\n" };
|
||||
let mut line = |depth: usize, s: &str| {
|
||||
out.push_str(&self.indent(depth));
|
||||
out.push_str(s);
|
||||
out.push_str(nl);
|
||||
};
|
||||
line(1, "<metadata>");
|
||||
if self.opts.metadata {
|
||||
line(2, "<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">");
|
||||
line(3, "<rdf:Description rdf:about=\"\">");
|
||||
line(4, "<dc:format>image/svg+xml</dc:format>");
|
||||
if !self.doc.title.is_empty() {
|
||||
line(4, &format!("<dc:title>{}</dc:title>", xml_escape(&self.doc.title)));
|
||||
}
|
||||
line(3, "</rdf:Description>");
|
||||
line(2, "</rdf:RDF>");
|
||||
}
|
||||
if let Some(bytes) = native {
|
||||
line(2, &format!("<vectorcraft:document xmlns:vectorcraft=\"{EDITING_NS}\">{}</vectorcraft:document>", base64_encode(bytes)));
|
||||
}
|
||||
line(1, "</metadata>");
|
||||
}
|
||||
|
||||
fn node_props(n: &Node) -> Props {
|
||||
let mut p = Props::new();
|
||||
if n.opacity < 1.0 {
|
||||
|
|
@ -319,6 +415,8 @@ impl Writer<'_> {
|
|||
s
|
||||
}
|
||||
|
||||
/// A fill or stroke value. Gradients resolve unset geometry against `bounds` (the space
|
||||
/// [`Self::xf`] maps into user space, as the renderer does).
|
||||
fn paint(&mut self, p: &Paint, bounds: Option<Rect>) -> String {
|
||||
match p {
|
||||
Paint::None => "none".into(),
|
||||
|
|
@ -372,63 +470,57 @@ impl Writer<'_> {
|
|||
Some(id)
|
||||
}
|
||||
|
||||
/// The id of a `<linearGradient>`/`<radialGradient>` def for `g` resolved against `bounds`.
|
||||
/// Identical gradients share one def; a gradient from a swatch takes the swatch's name as its
|
||||
/// id. A midpoint other than halfway is written as an extra stop marked `data-vc-midpoint`
|
||||
/// (what import turns back into a midpoint).
|
||||
fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String {
|
||||
let mut geom = g.resolve(bounds);
|
||||
geom.transform(self.xf, g.gradient.kind);
|
||||
let (s, e) = (geom.start, geom.end);
|
||||
let head = match g.gradient.kind {
|
||||
// An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space.
|
||||
let focal = |w: &Self, f: Point| format!(" fx=\"{}\" fy=\"{}\"", w.num(f.x), w.num(f.y));
|
||||
let (tag, prefix, attrs) = match g.gradient.kind {
|
||||
GradientKind::Radial => {
|
||||
let id = self.fresh_id("radial-gradient");
|
||||
let r = (e - s).hypot();
|
||||
// An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space.
|
||||
let focal = |f: Point| format!(" fx=\"{}\" fy=\"{}\"", self.num(f.x), self.num(f.y));
|
||||
if (geom.aspect - 1.0).abs() < 1e-9 {
|
||||
(
|
||||
id.clone(),
|
||||
format!(
|
||||
"<radialGradient id=\"{id}\" cx=\"{}\" cy=\"{}\" r=\"{}\"{} gradientUnits=\"userSpaceOnUse\">",
|
||||
self.num(s.x),
|
||||
self.num(s.y),
|
||||
self.num(r),
|
||||
geom.focal.map(focal).unwrap_or_default()
|
||||
),
|
||||
"radialGradient",
|
||||
)
|
||||
let r = self.num((e - s).hypot());
|
||||
let attrs = if (geom.aspect - 1.0).abs() < 1e-9 {
|
||||
let f = geom.focal.map(|f| focal(self, f)).unwrap_or_default();
|
||||
format!(" cx=\"{}\" cy=\"{}\" r=\"{r}\"{f}", self.num(s.x), self.num(s.y))
|
||||
} else {
|
||||
let v = e - s;
|
||||
let m = Affine::translate(s.to_vec2()) * Affine::rotate(v.y.atan2(v.x)) * Affine::scale_non_uniform(1.0, geom.aspect);
|
||||
(
|
||||
id.clone(),
|
||||
format!(
|
||||
"<radialGradient id=\"{id}\" cx=\"0\" cy=\"0\" r=\"{}\"{} gradientTransform=\"{}\" gradientUnits=\"userSpaceOnUse\">",
|
||||
self.num(r),
|
||||
geom.focal.map(|f| focal(m.inverse() * f)).unwrap_or_default(),
|
||||
self.matrix(m)
|
||||
),
|
||||
"radialGradient",
|
||||
)
|
||||
}
|
||||
}
|
||||
GradientKind::Linear | GradientKind::Freeform => {
|
||||
let id = self.fresh_id("linear-gradient");
|
||||
(
|
||||
id.clone(),
|
||||
format!(
|
||||
"<linearGradient id=\"{id}\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" gradientUnits=\"userSpaceOnUse\">",
|
||||
self.num(s.x),
|
||||
self.num(s.y),
|
||||
self.num(e.x),
|
||||
self.num(e.y)
|
||||
),
|
||||
"linearGradient",
|
||||
)
|
||||
let f = geom.focal.map(|f| focal(self, m.inverse() * f)).unwrap_or_default();
|
||||
format!(" cx=\"0\" cy=\"0\" r=\"{r}\"{f} gradientTransform=\"{}\"", self.matrix(m))
|
||||
};
|
||||
("radialGradient", "radial-gradient", attrs)
|
||||
}
|
||||
GradientKind::Linear | GradientKind::Freeform => (
|
||||
"linearGradient",
|
||||
"linear-gradient",
|
||||
format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)),
|
||||
),
|
||||
};
|
||||
let (id, open, tag) = head;
|
||||
self.def(1, &open);
|
||||
for (off, c, o) in g.gradient.expanded_stops() {
|
||||
let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() };
|
||||
self.def(2, &format!("<stop offset=\"{}\" stop-color=\"{}\"{op}/>", fmt_num(off as f64, 4), c.to_hex()));
|
||||
let stops: Vec<String> = g
|
||||
.gradient
|
||||
.expanded()
|
||||
.map(|(offset, c, o, mid)| {
|
||||
let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() };
|
||||
let mid = mid.map(|m| format!(" data-vc-midpoint=\"{}\"", fmt_num(m as f64, 4))).unwrap_or_default();
|
||||
format!("<stop offset=\"{}\" stop-color=\"{}\"{op}{mid}/>", fmt_num(offset as f64, 4), c.to_hex())
|
||||
})
|
||||
.collect();
|
||||
let key = format!("{tag}{attrs}{}", stops.concat());
|
||||
if let Some(id) = self.gradients.get(&key) {
|
||||
return id.clone();
|
||||
}
|
||||
let id = match &g.swatch {
|
||||
Some(name) => self.unique_id(&sanitize_id(name)),
|
||||
None => self.fresh_id(prefix),
|
||||
};
|
||||
self.gradients.insert(key, id.clone());
|
||||
self.def(1, &format!("<{tag} id=\"{id}\"{attrs} gradientUnits=\"userSpaceOnUse\">"));
|
||||
for stop in &stops {
|
||||
self.def(2, stop);
|
||||
}
|
||||
self.def(1, &format!("</{tag}>"));
|
||||
id
|
||||
|
|
@ -912,8 +1004,18 @@ impl Writer<'_> {
|
|||
}
|
||||
NodeKind::Text(t) => self.text_node(n, t),
|
||||
NodeKind::Image(im) => {
|
||||
let href = match self.doc.images.get(&im.key) {
|
||||
Some(b) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)),
|
||||
let link = self.opts.images == ImageMode::Link;
|
||||
let href = match (im.link.as_ref().filter(|_| link), self.doc.images.get(&im.key)) {
|
||||
(Some(l), _) => l.clone(),
|
||||
(None, Some(b)) if !b.bytes.is_empty() && link => {
|
||||
// Named after the bytes: image keys ("raster-1"…) repeat across documents.
|
||||
let name = format!("{}.{}", b.content_key(), image_ext(&b.mime));
|
||||
if !self.linked.iter().any(|l| l.name == name) {
|
||||
self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() });
|
||||
}
|
||||
name
|
||||
}
|
||||
(None, Some(b)) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)),
|
||||
_ => match &im.link {
|
||||
Some(l) => l.clone(),
|
||||
None => return,
|
||||
|
|
@ -1034,12 +1136,16 @@ impl Writer<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
fn char_props(&mut self, st: &CharStyle) -> Props {
|
||||
/// The `<text>`/`<tspan>` properties of a character style. `space`: the text's layout bounds
|
||||
/// and the map from text space to user space, which character gradients resolve against
|
||||
/// (as on the canvas).
|
||||
fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props {
|
||||
let mut p = Props::new();
|
||||
let fam =
|
||||
if st.font_family.contains(|c: char| c.is_whitespace() || c == ',') { format!("'{}'", st.font_family) } else { st.font_family.clone() };
|
||||
p.push(("font-family", fam));
|
||||
p.push(("font-size", self.num(st.size)));
|
||||
// Vertical scale is the font size; horizontal scale stretches it (`textLength`).
|
||||
p.push(("font-size", self.num(st.size * st.v_scale / 100.0)));
|
||||
let fs = st.font_style.to_ascii_lowercase();
|
||||
if fs.contains("bold") || fs.contains("black") || fs.contains("heavy") {
|
||||
p.push(("font-weight", "bold".into()));
|
||||
|
|
@ -1047,22 +1153,26 @@ impl Writer<'_> {
|
|||
if fs.contains("italic") || fs.contains("oblique") {
|
||||
p.push(("font-style", "italic".into()));
|
||||
}
|
||||
let fill = match &st.fill {
|
||||
Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()),
|
||||
p => self.paint(p, None),
|
||||
};
|
||||
// Character paints resolve in the text's space (`to_user` maps it into user space).
|
||||
let (bounds, to_user) = space;
|
||||
let saved = std::mem::replace(&mut self.xf, to_user);
|
||||
let fill = self.paint(&st.fill, Some(bounds));
|
||||
p.push(("fill", fill));
|
||||
if st.has_stroke() {
|
||||
// Weight, cap, join, miter limit and dashes as for object strokes (a gradient: its
|
||||
// first colour, as for the fill).
|
||||
let mut layer = st.stroke_layer();
|
||||
if let Paint::Gradient(g) = &layer.paint {
|
||||
layer.paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color));
|
||||
}
|
||||
self.stroke_props(&layer, layer.width, None, &mut p);
|
||||
// Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans
|
||||
// the text grown by half the weight, as on the canvas.
|
||||
let layer = st.stroke_layer();
|
||||
self.stroke_props(&layer, layer.width, Some(layer.paint_bounds(bounds)), &mut p);
|
||||
}
|
||||
if st.tracking != 0.0 {
|
||||
p.push(("letter-spacing", self.num(st.tracking / 1000.0 * st.size)));
|
||||
self.xf = saved;
|
||||
// Tracking and manual kerning both add space after every character.
|
||||
let spacing = st.tracking + st.kerning.unwrap_or(0.0);
|
||||
if spacing != 0.0 {
|
||||
p.push(("letter-spacing", self.num(spacing / 1000.0 * st.size)));
|
||||
}
|
||||
if st.kerning.is_some() {
|
||||
// Manual kerning replaces the font's pair kerning.
|
||||
p.push(("font-kerning", "none".into()));
|
||||
}
|
||||
if !st.features.is_empty() {
|
||||
let v: Vec<String> = st
|
||||
|
|
@ -1118,7 +1228,8 @@ impl Writer<'_> {
|
|||
self.line("</g>");
|
||||
}
|
||||
|
||||
/// Text as glyph outlines: one compound path per run, painted like the run.
|
||||
/// Text as glyph outlines: one compound path per run, painted like the run (gradients span
|
||||
/// the whole text, as on the canvas).
|
||||
fn text_outlines(&mut self, n: &Node, t: &TextObject) {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let mut runs: Vec<(usize, kurbo::BezPath)> = vec![];
|
||||
|
|
@ -1132,34 +1243,127 @@ impl Writer<'_> {
|
|||
let a = self.attrs(&Self::node_props(n));
|
||||
self.line(&format!("<g{id}{a}>"));
|
||||
self.depth += 1;
|
||||
for (r, mut bp) in runs {
|
||||
// The glyphs stay in text space; the text's transform joins the document's meanwhile.
|
||||
let saved = self.xf;
|
||||
self.xf = saved * t.xf;
|
||||
for (r, bp) in runs {
|
||||
let Some(run) = t.runs.get(r) else { continue };
|
||||
bp.apply_affine(t.xf);
|
||||
let pd = PathData::from_bezpath(&bp);
|
||||
// An unnamed id: the pieces carry no id attribute (the group has it).
|
||||
let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance());
|
||||
let d = self.path_d(&pd, self.xf);
|
||||
self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, pd.bounds());
|
||||
self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds));
|
||||
}
|
||||
self.xf = saved;
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
}
|
||||
|
||||
/// Point and area type, every laid-out line at its own position: wraps, indents, tabs and
|
||||
/// spacing come out as on the canvas. Centred and right-aligned lines are anchored
|
||||
/// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font
|
||||
/// differs; other lines are placed where each style run (and, justified, each word) starts.
|
||||
fn text(&mut self, n: &Node, t: &TextObject) {
|
||||
let first = t.first_style();
|
||||
let lead = first.effective_leading();
|
||||
if let TextKind::OnPath { path, start } = &t.kind {
|
||||
return self.text_on_path(n, t, path, *start);
|
||||
}
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let lines = text_lines(t, &lay, self.opts.fewer_tspans);
|
||||
// A tab starts a new chunk at its stop: only lines without tabs can be anchored.
|
||||
let anchor = match t.para.justify {
|
||||
Justify::Center => Some(("middle", 0.5)),
|
||||
Justify::Right => Some(("end", 1.0)),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|_| !lines.iter().flatten().any(|s| s.brk));
|
||||
// The <text> element's user space is text space.
|
||||
let space = (lay.bounds, Affine::IDENTITY);
|
||||
let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: anchor.is_some() };
|
||||
let mut props = base.props.clone();
|
||||
if let Some((a, _)) = anchor {
|
||||
props.push(("text-anchor", a.into()));
|
||||
}
|
||||
props.extend(Self::node_props(n));
|
||||
let id = self.id_attr(n);
|
||||
let (m, on_path) = match &t.kind {
|
||||
// Area type lines are positioned in text space (see `area_lines`).
|
||||
TextKind::Point | TextKind::Area { .. } => (self.xf * t.xf, None),
|
||||
TextKind::OnPath { path, start } => {
|
||||
let pid = self.fresh_id("text-path");
|
||||
let d = self.path_d(path, self.xf * t.xf);
|
||||
self.def(1, &format!("<path id=\"{pid}\" d=\"{d}\"/>"));
|
||||
(Affine::IDENTITY, Some((pid, *start)))
|
||||
}
|
||||
let a = self.attrs(&props);
|
||||
let m = self.xf * t.xf;
|
||||
let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) };
|
||||
let starts: Vec<Option<(f64, f64)>> = lines.iter().map(|l| line_start(l, anchor.map(|a| a.1))).collect();
|
||||
// The `<text>` carries the first line's position too, so readers that place text by its
|
||||
// own x/y start where the first line does.
|
||||
let at = match starts.iter().flatten().next() {
|
||||
Some((x, y)) => format!(" x=\"{}\" y=\"{}\"", self.num(*x), self.num(*y)),
|
||||
None => String::new(),
|
||||
};
|
||||
let mut props = self.char_props(&first);
|
||||
let mut s = format!("<text{id}{tr}{at} xml:space=\"preserve\"{a}>");
|
||||
for (line, start) in lines.iter().zip(starts) {
|
||||
if let Some(start) = start {
|
||||
self.text_line(&mut s, t, line, start, &base);
|
||||
}
|
||||
}
|
||||
s.push_str("</text>");
|
||||
self.line(&s);
|
||||
}
|
||||
|
||||
/// One line of laid-out text starting at `start`. Default: a positioned `<tspan>` per segment
|
||||
/// (only the first of an anchored line). Fewer tspans: one positioned `<tspan>` per line with
|
||||
/// the style changes nested in it, positioned again only after tabs and justified word spaces.
|
||||
/// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted.
|
||||
fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase) {
|
||||
let fewer = self.opts.fewer_tspans;
|
||||
if fewer {
|
||||
s.push_str(&format!("<tspan x=\"{}\" y=\"{}\">", self.num(start.0), self.num(start.1)));
|
||||
}
|
||||
let mut place = !fewer;
|
||||
let mut first = true;
|
||||
// The baseline shift the current text position carries.
|
||||
let mut shift = 0.0;
|
||||
for seg in line {
|
||||
let Some(st) = t.runs.get(seg.run).map(|r| &r.style) else { continue };
|
||||
let mut attrs = String::new();
|
||||
if place {
|
||||
let (x, y) = if first { start } else { (seg.x, seg.y) };
|
||||
attrs.push_str(&format!(" x=\"{}\" y=\"{}\"", self.num(x), self.num(y)));
|
||||
shift = 0.0;
|
||||
}
|
||||
first = false;
|
||||
if st.baseline_shift != shift {
|
||||
attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift)));
|
||||
shift = st.baseline_shift;
|
||||
}
|
||||
// After a tab or a justified word space the next segment is placed.
|
||||
place = seg.brk || (!fewer && !base.anchored);
|
||||
if (st.h_scale - st.v_scale).abs() > 1e-9 {
|
||||
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance)));
|
||||
}
|
||||
if st.rotation != 0.0 {
|
||||
attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation)));
|
||||
}
|
||||
let diff = run_diff(&base.props, self.char_props(st, base.space));
|
||||
attrs.push_str(&self.attrs(&diff));
|
||||
let text = xml_escape(&seg.text);
|
||||
if attrs.is_empty() && fewer {
|
||||
s.push_str(&text);
|
||||
} else {
|
||||
s.push_str(&format!("<tspan{attrs}>{text}</tspan>"));
|
||||
}
|
||||
}
|
||||
if fewer {
|
||||
s.push_str("</tspan>");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a path: a `<textPath>` along the path (a def).
|
||||
fn text_on_path(&mut self, n: &Node, t: &TextObject, path: &PathData, start: f64) {
|
||||
let id = self.id_attr(n);
|
||||
let pid = self.fresh_id("text-path");
|
||||
let d = self.path_d(path, self.xf * t.xf);
|
||||
self.def(1, &format!("<path id=\"{pid}\" d=\"{d}\"/>"));
|
||||
// The <text> element's user space is the document's; gradients span the laid-out text.
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let space = (lay.bounds, self.xf * t.xf);
|
||||
let base = self.char_props(&t.first_style(), space);
|
||||
let mut props = base.clone();
|
||||
match t.para.justify {
|
||||
Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())),
|
||||
Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())),
|
||||
|
|
@ -1167,100 +1371,16 @@ impl Writer<'_> {
|
|||
}
|
||||
props.extend(Self::node_props(n));
|
||||
let a = self.attrs(&props);
|
||||
let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) };
|
||||
// Area type: laid-out lines (the `<text>` carries the first line's position too, so readers
|
||||
// that place text by its own x/y start where the first line does).
|
||||
let lines = matches!(t.kind, TextKind::Area { .. }).then(|| self.area_lines(t));
|
||||
let at = match lines.as_ref().and_then(|l| l.first()) {
|
||||
Some(l) => format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)),
|
||||
None => String::new(),
|
||||
};
|
||||
let mut s = format!("<text{id}{tr}{at} xml:space=\"preserve\"{a}>");
|
||||
if let Some((pid, start)) = &on_path {
|
||||
s.push_str(&format!("<textPath xlink:href=\"#{pid}\" startOffset=\"{}%\">", fmt_num(start * 100.0, 3)));
|
||||
let offset = fmt_num(start * 100.0, 3);
|
||||
let mut s = format!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">");
|
||||
for run in &t.runs {
|
||||
let diff = run_diff(&base, self.char_props(&run.style, space));
|
||||
let a = self.attrs(&diff);
|
||||
s.push_str(&format!("<tspan{a}>{}</tspan>", xml_escape(&run.text.replace('\n', " "))));
|
||||
}
|
||||
let base = self.char_props(&first);
|
||||
if let Some(lines) = lines {
|
||||
for l in lines {
|
||||
let last = l.pieces.len() - 1;
|
||||
for (k, (ri, text)) in l.pieces.into_iter().enumerate() {
|
||||
let pos = if k == 0 { format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)) } else { String::new() };
|
||||
let diff = run_diff(&base, self.char_props(&t.runs[ri].style));
|
||||
let a = self.attrs(&diff);
|
||||
let hy = if k == last && l.hyphenated { "-" } else { "" };
|
||||
s.push_str(&format!("<tspan{pos}{a}>{}{hy}</tspan>", xml_escape(text)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut line = 0usize;
|
||||
let mut pending = false;
|
||||
for run in &t.runs {
|
||||
let diff = run_diff(&base, self.char_props(&run.style));
|
||||
for (i, piece) in run.text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
line += 1;
|
||||
pending = true;
|
||||
}
|
||||
if piece.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let pos = if pending && on_path.is_none() { format!(" x=\"0\" y=\"{}\"", self.num(line as f64 * lead)) } else { String::new() };
|
||||
pending = false;
|
||||
let a = self.attrs(&diff);
|
||||
s.push_str(&format!("<tspan{pos}{a}>{}</tspan>", xml_escape(piece)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if on_path.is_some() {
|
||||
s.push_str("</textPath>");
|
||||
}
|
||||
s.push_str("</text>");
|
||||
s.push_str("</textPath></text>");
|
||||
self.line(&s);
|
||||
}
|
||||
|
||||
/// Area type as laid out. Soft line breaks are only known to the layout, so each laid-out line
|
||||
/// is placed at its baseline (text space), at its left edge, centre or right edge to match the
|
||||
/// `text-anchor` of the paragraph alignment.
|
||||
fn area_lines<'t>(&self, t: &'t TextObject) -> Vec<AreaLine<'t>> {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let mut offsets = Vec::with_capacity(t.runs.len());
|
||||
let mut off = 0;
|
||||
for r in &t.runs {
|
||||
offsets.push(off);
|
||||
off += r.text.len();
|
||||
}
|
||||
let mut out = vec![];
|
||||
for l in &lay.lines {
|
||||
// Run pieces of this line (the paragraph's `\n` is outside `start..end`).
|
||||
let mut pieces: Vec<(usize, &str)> = vec![];
|
||||
for (ri, (r, &o)) in t.runs.iter().zip(&offsets).enumerate() {
|
||||
let (a, b) = (l.start.max(o), l.end.min(o + r.text.len()));
|
||||
if a < b {
|
||||
pieces.push((ri, &r.text[a - o..b - o]));
|
||||
}
|
||||
}
|
||||
// Spaces where the line wrapped don't belong to the line.
|
||||
while let Some((_, p)) = pieces.last_mut() {
|
||||
*p = p.trim_end();
|
||||
if !p.is_empty() {
|
||||
break;
|
||||
}
|
||||
pieces.pop();
|
||||
}
|
||||
if pieces.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let x = match t.para.justify {
|
||||
Justify::Center | Justify::JustifyCenter => (l.x0 + l.x1) / 2.0,
|
||||
Justify::Right | Justify::JustifyRight => l.x1,
|
||||
_ => l.x0,
|
||||
};
|
||||
// A hyphenated break: the layout adds the hyphen as a glyph with no source text.
|
||||
let hyphenated = l.glyph_end > l.glyph_start && lay.glyphs.get(l.glyph_end - 1).is_some_and(|g| g.len == 0);
|
||||
out.push(AreaLine { x, y: l.baseline, pieces, hyphenated });
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// What the `<tspan>` of a run whose properties are `run` sets over its `<text>`'s `base`: the
|
||||
|
|
@ -1282,6 +1402,7 @@ fn initial_value(k: &str) -> Option<&'static str> {
|
|||
"stroke-linejoin" => "miter",
|
||||
"stroke-miterlimit" => "4",
|
||||
"stroke-dashoffset" | "letter-spacing" => "0",
|
||||
"font-kerning" => "auto",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -1290,3 +1411,87 @@ fn initial_value(k: &str) -> Option<&'static str> {
|
|||
fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
|
||||
effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id))
|
||||
}
|
||||
|
||||
/// What the lines of one `<text>` share.
|
||||
struct TextBase {
|
||||
/// The `<text>` element's character properties, which each `<tspan>` differs from.
|
||||
props: Props,
|
||||
/// The text's layout bounds and the map from text space to user space (character paints).
|
||||
space: (Rect, Affine),
|
||||
/// Lines anchored at their centre or right end (`text-anchor`), positioned once each.
|
||||
anchored: bool,
|
||||
}
|
||||
|
||||
/// Characters of one style on one line, written as one `<tspan>`.
|
||||
struct Segment {
|
||||
/// Index into `TextObject::runs`.
|
||||
run: usize,
|
||||
/// Where the segment starts (text space; `y` is the baseline).
|
||||
x: f64,
|
||||
y: f64,
|
||||
text: String,
|
||||
/// The laid-out width (tracking and justification included).
|
||||
advance: f64,
|
||||
/// The next segment needs its own position (after a tab, or a word space of a justified line).
|
||||
brk: bool,
|
||||
}
|
||||
|
||||
/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and,
|
||||
/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters
|
||||
/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all
|
||||
/// caps are upper case.
|
||||
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Vec<Segment>> {
|
||||
let plain = t.plain_text();
|
||||
let split_words = !fewer && !matches!(t.para.justify, Justify::Left | Justify::Center | Justify::Right);
|
||||
let mut lines = Vec::with_capacity(lay.lines.len());
|
||||
for line in &lay.lines {
|
||||
let mut segs: Vec<Segment> = Vec::new();
|
||||
let mut cluster = None;
|
||||
let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") };
|
||||
let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default();
|
||||
// Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line.
|
||||
if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) {
|
||||
while let Some((_, rest)) = glyphs.split_last().filter(|(g, _)| source(g).chars().all(|c| c.is_whitespace() && c != '\t')) {
|
||||
glyphs = rest;
|
||||
}
|
||||
}
|
||||
for g in glyphs {
|
||||
let Some(run) = t.runs.get(g.run) else { continue };
|
||||
if g.len > 0 && cluster == Some(g.byte) {
|
||||
// Another glyph of the same cluster.
|
||||
if let Some(last) = segs.last_mut() {
|
||||
last.advance += g.advance;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cluster = Some(g.byte);
|
||||
let src: String = source(g).chars().filter(|c| *c != '\u{ad}').collect();
|
||||
let piece = if run.style.all_caps { src.to_uppercase() } else { src };
|
||||
let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace));
|
||||
match segs.last_mut() {
|
||||
Some(last) if last.run == g.run && !last.brk => {
|
||||
last.text.push_str(&piece);
|
||||
last.advance += g.advance;
|
||||
last.brk = brk;
|
||||
}
|
||||
_ => segs.push(Segment { run: g.run, x: g.origin.x, y: g.origin.y, text: piece, advance: g.advance, brk }),
|
||||
}
|
||||
}
|
||||
segs.retain(|s| !s.text.is_empty());
|
||||
if !segs.is_empty() {
|
||||
lines.push(segs);
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Where a line of segments starts: its first segment's origin or, `anchor`ed (0.5: centre, 1:
|
||||
/// right end), that point of its width. `None` for an empty line.
|
||||
fn line_start(line: &[Segment], anchor: Option<f64>) -> Option<(f64, f64)> {
|
||||
let (first, last) = (line.first()?, line.last()?);
|
||||
let x = match anchor {
|
||||
Some(f) => first.x + f * (last.x + last.advance - first.x),
|
||||
None => first.x,
|
||||
};
|
||||
Some((x, first.y))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use vectorcraft_doc::{
|
|||
};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes};
|
||||
|
||||
use crate::SvgError;
|
||||
use crate::{SvgError, fnv1a};
|
||||
use css::XNode;
|
||||
use text::TextSlots;
|
||||
|
||||
|
|
@ -33,7 +33,16 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
|
|||
let (kx, ky) = units.k;
|
||||
let mut doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky);
|
||||
doc.units = units.unit;
|
||||
let mut im = Importer { doc, warnings, mask_flags: mask_flags(&xml), links, slots, patterns: HashMap::new() };
|
||||
let mut im = Importer {
|
||||
doc,
|
||||
warnings,
|
||||
mask_flags: mask_flags(&xml),
|
||||
links,
|
||||
slots,
|
||||
patterns: HashMap::new(),
|
||||
midpoints: midpoint_stops(&xml),
|
||||
labels: labels(&xml),
|
||||
};
|
||||
|
||||
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
|
||||
let mut top = tree.root();
|
||||
|
|
@ -69,7 +78,8 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
|
|||
children.extend(im.children(g, base * aff(g.transform())));
|
||||
let id = im.doc.alloc_id();
|
||||
let preset = (im.doc.layers.len() % vectorcraft_doc::LAYER_COLORS.len()) as u8;
|
||||
let mut l = Node::layer(id, g.id(), LayerColor::Preset(preset));
|
||||
let name = im.name_of(g.id()).to_string();
|
||||
let mut l = Node::layer(id, &name, LayerColor::Preset(preset));
|
||||
if let Some(ch) = l.children_mut() {
|
||||
*ch = children;
|
||||
}
|
||||
|
|
@ -140,6 +150,43 @@ struct Importer {
|
|||
slots: TextSlots,
|
||||
/// Pattern swatch made for each usvg pattern.
|
||||
patterns: HashMap<usize, String>,
|
||||
/// Midpoint stops by gradient id (see [`midpoint_stops`]).
|
||||
midpoints: HashMap<String, Vec<Option<f32>>>,
|
||||
/// Object names by element id (see [`labels`]).
|
||||
labels: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// The stops our export added for midpoints (`data-vc-midpoint`), by gradient id: for each stop,
|
||||
/// the midpoint it stands for.
|
||||
fn midpoint_stops(xml: &roxmltree::Document) -> HashMap<String, Vec<Option<f32>>> {
|
||||
xml.descendants()
|
||||
.filter(|n| matches!(n.tag_name().name(), "linearGradient" | "radialGradient"))
|
||||
.filter_map(|g| {
|
||||
let id = g.attribute("id")?;
|
||||
let marks: Vec<Option<f32>> = g
|
||||
.children()
|
||||
.filter(|c| c.tag_name().name() == "stop")
|
||||
.map(|c| c.attribute("data-vc-midpoint").and_then(|v| v.parse().ok()))
|
||||
.collect();
|
||||
marks.iter().any(Option::is_some).then(|| (id.to_string(), marks))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A gradient's stops, with the midpoint stops `marks` names folded back into the midpoints of
|
||||
/// the stops before them.
|
||||
fn gradient_stops(g: &usvg::BaseGradient, marks: Option<&[Option<f32>]>) -> Vec<GradientStop> {
|
||||
let marks = marks.filter(|m| m.len() == g.stops().len());
|
||||
let mut out: Vec<GradientStop> = Vec::with_capacity(g.stops().len());
|
||||
for (i, s) in g.stops().iter().enumerate() {
|
||||
if let (Some(mid), Some(prev)) = (marks.and_then(|m| m.get(i).copied().flatten()), out.last_mut()) {
|
||||
prev.midpoint = mid.clamp(0.0, 1.0);
|
||||
continue;
|
||||
}
|
||||
let c = s.color();
|
||||
out.push(GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The options [`crate::export::MASK_FLAGS`] records on exported `<mask>` elements, by mask id:
|
||||
|
|
@ -211,6 +258,24 @@ fn link(n: &mut Node, url: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The names apps keep beside element ids, by id: `data-name`, `inkscape:label`, `serif:id` or
|
||||
/// `aria-label` (the first one present).
|
||||
fn labels(xml: &roxmltree::Document) -> HashMap<String, String> {
|
||||
const INKSCAPE: &str = "http://www.inkscape.org/namespaces/inkscape";
|
||||
const SERIF: &str = "http://www.serif.com/";
|
||||
xml.descendants()
|
||||
.filter_map(|n| {
|
||||
let id = n.attribute("id").filter(|id| !id.is_empty())?;
|
||||
let name = n
|
||||
.attribute("data-name")
|
||||
.or_else(|| n.attribute((INKSCAPE, "label")))
|
||||
.or_else(|| n.attribute((SERIF, "id")))
|
||||
.or_else(|| n.attribute("aria-label"))?;
|
||||
Some((id.to_string(), name.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn aff(t: usvg::Transform) -> Affine {
|
||||
Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64])
|
||||
}
|
||||
|
|
@ -257,15 +322,6 @@ fn bezpath(p: &usvg::tiny_skia_path::Path, m: Affine) -> BezPath {
|
|||
bp
|
||||
}
|
||||
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn rule(r: usvg::FillRule) -> FillRule {
|
||||
match r {
|
||||
usvg::FillRule::NonZero => FillRule::NonZero,
|
||||
|
|
@ -294,11 +350,21 @@ impl Importer {
|
|||
}
|
||||
}
|
||||
|
||||
/// A gradient's stops, midpoints restored.
|
||||
fn stops(&self, g: &usvg::BaseGradient) -> Vec<GradientStop> {
|
||||
gradient_stops(g, self.midpoints.get(g.id()).map(Vec::as_slice))
|
||||
}
|
||||
|
||||
/// The object name for an element id: its label (see [`labels`]), else the id itself.
|
||||
fn name_of<'a>(&'a self, id: &'a str) -> &'a str {
|
||||
self.labels.get(id).map_or(id, String::as_str)
|
||||
}
|
||||
|
||||
fn named(&mut self, id: &str, kind: NodeKind) -> Node {
|
||||
let nid = self.doc.alloc_id();
|
||||
let mut n = Node::new(nid, kind);
|
||||
if !id.is_empty() {
|
||||
n.name = Some(id.to_string());
|
||||
n.name = Some(self.name_of(id).to_string());
|
||||
}
|
||||
n
|
||||
}
|
||||
|
|
@ -482,15 +548,6 @@ impl Importer {
|
|||
}
|
||||
|
||||
fn paint(&mut self, p: &usvg::Paint, m: Affine) -> Paint {
|
||||
let stops = |g: &usvg::BaseGradient| {
|
||||
g.stops()
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let c = s.color();
|
||||
GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) }
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let gp = |kind, stops, geom: GradientGeom| {
|
||||
Paint::Gradient(Box::new(GradientPaint {
|
||||
gradient: Gradient { kind, stops },
|
||||
|
|
@ -513,7 +570,7 @@ impl Importer {
|
|||
focal: None,
|
||||
};
|
||||
geom.transform(m * aff(lg.transform()), GradientKind::Linear);
|
||||
gp(GradientKind::Linear, stops(lg), geom)
|
||||
gp(GradientKind::Linear, self.stops(lg), geom)
|
||||
}
|
||||
usvg::Paint::RadialGradient(rg) => {
|
||||
if rg.spread_method() != usvg::SpreadMethod::Pad {
|
||||
|
|
@ -527,7 +584,7 @@ impl Importer {
|
|||
// A focal point outside the circle is pulled inside it.
|
||||
geom.set_focal(Some(Point::new(rg.fx() as f64, rg.fy() as f64)));
|
||||
geom.transform(m * aff(rg.transform()), GradientKind::Radial);
|
||||
gp(GradientKind::Radial, stops(rg), geom)
|
||||
gp(GradientKind::Radial, self.stops(rg), geom)
|
||||
}
|
||||
usvg::Paint::Pattern(pt) => self.pattern(pt, m),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
//! VectorCraft SVG import and export.
|
||||
//!
|
||||
//! * [`export`] writes a [`Document`] as SVG 1.1 with our own writer (presentation attributes,
|
||||
//! inline styles or internal CSS classes; gradients in `<defs>` with `userSpaceOnUse`; clip groups as
|
||||
//! `<clipPath>`; embedded images as `data:` URIs; text as `<text>`/`<tspan>`).
|
||||
//! * [`export`] / [`export_full`] write a [`Document`] as SVG 1.1 with our own writer, as set by
|
||||
//! [`ExportOptions`] (presentation attributes, inline styles, style entities or internal CSS
|
||||
//! classes; gradients in `<defs>` with `userSpaceOnUse`; clip groups as `<clipPath>`; images
|
||||
//! embedded as `data:` URIs or linked; text as `<text>`/`<tspan>` or outlines).
|
||||
//! * [`import`] / [`import_with_report`] parse SVG with `usvg` and convert its normalized tree into
|
||||
//! document nodes with all transforms baked into the geometry.
|
||||
//!
|
||||
|
|
@ -54,37 +55,88 @@
|
|||
mod export;
|
||||
mod import;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use vectorcraft_doc::Document;
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
|
||||
/// How style properties are written (the "Styling" export option).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
/// How style properties are written (SVG Options → Styling).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Styling {
|
||||
/// `fill="#ff0000"` attributes.
|
||||
#[default]
|
||||
#[serde(rename = "presentation")]
|
||||
PresentationAttributes,
|
||||
/// `style="fill:#ff0000"`.
|
||||
#[serde(rename = "style")]
|
||||
InlineStyle,
|
||||
/// `style="&st1;"`, each distinct declaration block an XML entity declared in the DOCTYPE.
|
||||
#[serde(rename = "entities")]
|
||||
StyleEntities,
|
||||
/// `class="cls-1"` with a `<style>` element in `<defs>`.
|
||||
#[serde(rename = "css")]
|
||||
InternalCss,
|
||||
}
|
||||
|
||||
/// SVG export options (Export As → SVG).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
/// Which `id` attributes objects get (SVG Options → Object IDs). Definitions other elements
|
||||
/// reference (gradients, clip paths, masks, patterns, filters) always have ids.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ObjectIds {
|
||||
/// Readable ids from layer and object names.
|
||||
#[default]
|
||||
LayerNames,
|
||||
/// Only the ids that are referenced.
|
||||
Minimal,
|
||||
/// Every id carries a prefix derived from the content, so SVGs inlined in one page never
|
||||
/// share an id.
|
||||
Unique,
|
||||
}
|
||||
|
||||
/// How raster images are written (SVG Options → Images).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ImageMode {
|
||||
/// `data:` URIs inside the SVG.
|
||||
#[default]
|
||||
Embed,
|
||||
/// `href` to an image file: a linked image's own file, an embedded one written next to the
|
||||
/// SVG (see [`Output::linked`]).
|
||||
Link,
|
||||
}
|
||||
|
||||
/// SVG export options (SVG Options). Deserializes from camelCase JSON with every field optional;
|
||||
/// unknown fields are rejected.
|
||||
///
|
||||
/// These defaults are the library's and the engine's (`document.export` without options):
|
||||
/// presentation attributes and a fixed size, the most portable output. The SVG Options dialog
|
||||
/// starts from Internal CSS and Responsive instead, like the reference app's Export As.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ExportOptions {
|
||||
/// Artboard index to export (sets the viewBox); `None` = the bounds of all art.
|
||||
/// Artboard index to export (sets the viewBox); `None` = the bounds of all art. Callers pick
|
||||
/// it (the engine's `artboard`/`range`/`useArtboards` params), so it is not deserialized.
|
||||
#[serde(skip)]
|
||||
pub artboard: Option<usize>,
|
||||
pub styling: Styling,
|
||||
/// Decimal places for coordinates (1–7 in the dialog; default 3).
|
||||
pub decimals: u8,
|
||||
/// Write `id` attributes from layer and object names.
|
||||
pub object_ids: bool,
|
||||
pub object_ids: ObjectIds,
|
||||
pub images: ImageMode,
|
||||
/// No indentation or newlines, no XML declaration.
|
||||
pub minify: bool,
|
||||
/// Omit `width`/`height` so the SVG scales to its container.
|
||||
pub responsive: bool,
|
||||
/// Fonts → Convert to Outlines: text becomes paths (portable, no font needed to view it).
|
||||
pub outline_text: bool,
|
||||
/// Embed the native document (passed to [`export_full`]) in `<metadata>` so VectorCraft
|
||||
/// reopens the SVG with nothing lost (see [`editing_data`]).
|
||||
pub preserve_editing: bool,
|
||||
/// Write `<metadata>` with the document's Dublin Core title and format.
|
||||
pub metadata: bool,
|
||||
/// One positioned `<tspan>` per line of type instead of one per style run, tab stop and
|
||||
/// justified word (smaller; viewers then space the line with their own font metrics).
|
||||
pub fewer_tspans: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
|
|
@ -93,28 +145,122 @@ impl Default for ExportOptions {
|
|||
artboard: Some(0),
|
||||
styling: Styling::PresentationAttributes,
|
||||
decimals: 3,
|
||||
object_ids: true,
|
||||
object_ids: ObjectIds::LayerNames,
|
||||
images: ImageMode::Embed,
|
||||
minify: false,
|
||||
responsive: false,
|
||||
outline_text: false,
|
||||
preserve_editing: false,
|
||||
metadata: false,
|
||||
fewer_tspans: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decimal places the options accept.
|
||||
pub const DECIMALS: std::ops::RangeInclusive<u8> = 1..=7;
|
||||
|
||||
impl ExportOptions {
|
||||
/// Values outside what the options allow (the decimals range).
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if DECIMALS.contains(&self.decimals) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("decimals must be {}–{} (got {})", DECIMALS.start(), DECIMALS.end(), self.decimals))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An export: the SVG text and the image files it links to.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Output {
|
||||
pub svg: String,
|
||||
/// [`ImageMode::Link`]: embedded images written as files next to the SVG, named after their
|
||||
/// content (`img….png`), so exports of different documents into one folder never clash.
|
||||
pub linked: Vec<LinkedImage>,
|
||||
/// Features the export approximates.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// An image file an exported SVG links to by `name`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LinkedImage {
|
||||
pub name: String,
|
||||
pub bytes: std::sync::Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SvgError {
|
||||
#[error("SVG parse error: {0}")]
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
/// Export a document as an SVG string.
|
||||
/// Export a document as an SVG string (linked images are not returned: see [`export_full`]).
|
||||
pub fn export(doc: &Document, opts: &ExportOptions) -> String {
|
||||
export::export(doc, opts).0
|
||||
export_full(doc, opts, None).svg
|
||||
}
|
||||
|
||||
/// Export a document as an SVG string, also returning warnings about approximated features.
|
||||
pub fn export_with_report(doc: &Document, opts: &ExportOptions) -> (String, Vec<String>) {
|
||||
export::export(doc, opts)
|
||||
let o = export_full(doc, opts, None);
|
||||
(o.svg, o.warnings)
|
||||
}
|
||||
|
||||
/// Export a document as SVG plus the image files it links to. `native`: the document in the
|
||||
/// native format, embedded when [`ExportOptions::preserve_editing`] is on.
|
||||
pub fn export_full(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output {
|
||||
export::export(doc, opts, native)
|
||||
}
|
||||
|
||||
/// The XML namespace of the editing data [`ExportOptions::preserve_editing`] embeds.
|
||||
pub const EDITING_NS: &str = "urn:vectorcraft:editing";
|
||||
|
||||
/// The native document an SVG carries (base64, as written with
|
||||
/// [`ExportOptions::preserve_editing`]), if any.
|
||||
pub fn editing_data(svg: &str) -> Option<String> {
|
||||
if !svg.contains(EDITING_NS) {
|
||||
return None;
|
||||
}
|
||||
let opts = usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
|
||||
let xml = usvg::roxmltree::Document::parse_with_options(svg, opts).ok()?;
|
||||
let el = xml.descendants().find(|n| n.tag_name().namespace() == Some(EDITING_NS) && n.tag_name().name() == "document")?;
|
||||
let data: String = el.children().filter_map(|c| c.text()).flat_map(|t| t.chars().filter(|c| !c.is_whitespace())).collect();
|
||||
Some(data).filter(|d| !d.is_empty())
|
||||
}
|
||||
|
||||
/// Does `bytes` start like gzip data (an SVGZ file)?
|
||||
pub fn is_svgz(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&[0x1f, 0x8b])
|
||||
}
|
||||
|
||||
/// Compress SVG text as SVGZ (gzip).
|
||||
pub fn compress(svg: &str) -> Vec<u8> {
|
||||
use std::io::Write as _;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::with_capacity(svg.len() / 4), flate2::Compression::default());
|
||||
// Writing to a Vec cannot fail.
|
||||
let _ = gz.write_all(svg.as_bytes());
|
||||
gz.finish().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Most bytes an SVGZ file may unpack to (a guard against decompression bombs).
|
||||
const MAX_SVGZ: u64 = 512 << 20;
|
||||
|
||||
/// SVG text from SVG or SVGZ bytes.
|
||||
pub fn text_of(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, SvgError> {
|
||||
use std::io::Read as _;
|
||||
let not_utf8 = |_| SvgError::Parse("SVG is not UTF-8".into());
|
||||
if !is_svgz(bytes) {
|
||||
return std::str::from_utf8(bytes).map(Into::into).map_err(not_utf8);
|
||||
}
|
||||
let mut raw = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes)
|
||||
.take(MAX_SVGZ + 1)
|
||||
.read_to_end(&mut raw)
|
||||
.map_err(|e| SvgError::Parse(format!("not a valid SVGZ file: {e}")))?;
|
||||
if raw.len() as u64 > MAX_SVGZ {
|
||||
return Err(SvgError::Parse(format!("the SVGZ file unpacks to more than {} MB", MAX_SVGZ >> 20)));
|
||||
}
|
||||
String::from_utf8(raw).map(Into::into).map_err(|e| not_utf8(e.utf8_error()))
|
||||
}
|
||||
|
||||
/// Import an SVG document.
|
||||
|
|
@ -127,6 +273,11 @@ pub fn import_with_report(svg: &str) -> Result<(Document, Vec<String>), SvgError
|
|||
import::import(svg)
|
||||
}
|
||||
|
||||
/// FNV-1a: a stable content hash (image keys, unique id prefixes).
|
||||
pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf29ce484222325, |h, b| (h ^ *b as u64).wrapping_mul(0x100000001b3))
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with padding).
|
||||
pub fn base64_encode(data: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
//! Gradient defs: text gradients span the laid-out text, stroke gradients the stroked area,
|
||||
//! identical gradients share a def named after their swatch, and midpoints survive re-import.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, export, import};
|
||||
|
||||
fn black_to_white() -> GradientPaint {
|
||||
GradientPaint::new(Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)],
|
||||
})
|
||||
}
|
||||
|
||||
fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec<Node>) -> Document {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
for n in nodes(&mut d) {
|
||||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The value of `name` on the first element whose tag starts with `tag`.
|
||||
fn attr(svg: &str, tag: &str, name: &str) -> f64 {
|
||||
let el = &svg[svg.find(tag).unwrap_or_else(|| panic!("no {tag}: {svg}"))..];
|
||||
let el = &el[..el.find('>').unwrap()];
|
||||
let k = format!(" {name}=\"");
|
||||
let i = el.find(&k).unwrap_or_else(|| panic!("no {name} in {el}")) + k.len();
|
||||
el[i..i + el[i..].find('"').unwrap()].parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_text_spans_its_layout() {
|
||||
let st = CharStyle { size: 24.0, fill: Paint::Gradient(Box::new(black_to_white())), ..CharStyle::default() };
|
||||
let t = TextObject::point(Point::new(30.0, 100.0), "Gradient", st);
|
||||
let bounds = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds;
|
||||
let d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))]);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.contains("fill=\"url(#linear-gradient-1)\""), "{svg}");
|
||||
// Text space is the <text> element's user space: the gradient runs across the glyphs.
|
||||
assert!((attr(&svg, "<linearGradient", "x1") - bounds.x0).abs() < 0.01, "{svg}");
|
||||
assert!((attr(&svg, "<linearGradient", "x2") - bounds.x1).abs() < 0.01, "{svg}");
|
||||
// Outlined, the same gradient sits on the outlines.
|
||||
let outlined = export(&d, &ExportOptions { outline_text: true, ..Default::default() });
|
||||
assert!((attr(&outlined, "<linearGradient", "x1") - (30.0 + bounds.x0)).abs() < 0.01, "{outlined}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_gradients_span_the_stroke() {
|
||||
let d = doc_with(|d| {
|
||||
vec![Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)),
|
||||
Appearance::basic(Paint::None, Paint::Gradient(Box::new(black_to_white())), 10.0),
|
||||
)]
|
||||
});
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.contains("stroke=\"url(#"), "{svg}");
|
||||
assert_eq!(attr(&svg, "<linearGradient", "x1"), 5.0, "bounds less half the width");
|
||||
assert_eq!(attr(&svg, "<linearGradient", "x2"), 115.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_gradients_share_a_def_named_after_the_swatch() {
|
||||
let mut g = black_to_white();
|
||||
g.swatch = Some("Night Fade".into());
|
||||
let fill = Appearance::basic(Paint::Gradient(Box::new(g.clone())), Paint::None, 0.0);
|
||||
let d = doc_with(|d| {
|
||||
let mut nodes = vec![];
|
||||
for _ in 0..3 {
|
||||
nodes.push(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), fill.clone()));
|
||||
}
|
||||
// Elsewhere, the gradient fits other bounds: a def of its own.
|
||||
nodes.push(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 200.0, 60.0)), fill.clone()));
|
||||
nodes
|
||||
});
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<linearGradient").count(), 2, "{svg}");
|
||||
assert_eq!(svg.matches("url(#Night_Fade)").count(), 3, "{svg}");
|
||||
assert!(svg.contains("id=\"Night_Fade-2\""), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoints_come_back() {
|
||||
let mut g = black_to_white();
|
||||
g.gradient.stops[0].midpoint = 0.25;
|
||||
let d = doc_with(|d| {
|
||||
vec![Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||||
Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0),
|
||||
)]
|
||||
});
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<stop ").count(), 3, "the midpoint as a stop for other viewers: {svg}");
|
||||
assert!(svg.contains("offset=\"0.25\"") && svg.contains("data-vc-midpoint=\"0.25\""), "{svg}");
|
||||
let back = import(&svg).unwrap();
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!("{svg}") };
|
||||
assert_eq!(g.gradient.stops.len(), 2);
|
||||
assert!((g.gradient.stops[0].midpoint - 0.25).abs() < 1e-6);
|
||||
assert!((g.gradient.stops[1].midpoint - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
//! Object IDs: names survive the round trip (Layer Names), Minimal writes only referenced ids,
|
||||
//! Unique ids never collide, and other apps' label attributes become object names.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, ObjectIds, Styling, export, import};
|
||||
|
||||
/// A document with a named rectangle, an unnamed gradient-filled one and a second layer.
|
||||
fn doc(title: &str, shift: f64) -> Document {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
d.title = title.into();
|
||||
let l = d.layers[0].id;
|
||||
let mut a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0 + shift, 10.0, 60.0, 60.0)), Appearance::default_art());
|
||||
a.name = Some("Logo mark (v2)".into());
|
||||
d.insert(Some(l), usize::MAX, a).unwrap();
|
||||
let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
let b = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(80.0, 10.0, 120.0, 60.0)), Appearance::basic(g, Paint::None, 0.0));
|
||||
d.insert(Some(l), usize::MAX, b).unwrap();
|
||||
d.add_layer(Some("Top & Front"));
|
||||
let top = d.layers[1].id;
|
||||
let c = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::ellipse(Rect::new(0.0, 100.0, 50.0, 150.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
d.insert(Some(top), usize::MAX, c).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Every `id="…"` value.
|
||||
fn ids(svg: &str) -> Vec<String> {
|
||||
svg.split(" id=\"").skip(1).map(|s| s[..s.find('"').unwrap()].to_string()).collect()
|
||||
}
|
||||
|
||||
fn names(d: &Document) -> Vec<String> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| v.extend(n.name.clone()));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_names_round_trip_exactly() {
|
||||
let d = doc("a", 0.0);
|
||||
let s = export(&d, &ExportOptions::default());
|
||||
assert!(s.contains("id=\"Layer_1\" data-name=\"Layer 1\""), "{s}");
|
||||
assert!(s.contains("data-name=\"Top & Front\""), "{s}");
|
||||
let back = import(&s).unwrap();
|
||||
assert_eq!(back.layers.iter().map(|l| l.name.clone().unwrap()).collect::<Vec<_>>(), ["Layer 1", "Top & Front"]);
|
||||
assert!(names(&back).contains(&"Logo mark (v2)".to_string()), "{:?}", names(&back));
|
||||
// An id that is the name already gets no data-name.
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
std::sync::Arc::make_mut(&mut d.layers[0]).name = Some("Layer_1".into());
|
||||
let s = export(&d, &ExportOptions::default());
|
||||
assert!(s.contains("<g id=\"Layer_1\">"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_writes_only_referenced_ids() {
|
||||
let s = export(&doc("a", 0.0), &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() });
|
||||
let ids = ids(&s);
|
||||
assert!(!ids.is_empty(), "the gradient keeps its id");
|
||||
for id in &ids {
|
||||
assert!(s.contains(&format!("#{id})")) || s.contains(&format!("\"#{id}\"")), "`{id}` is never referenced:\n{s}");
|
||||
}
|
||||
assert!(!s.contains("data-name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_ids_never_collide() {
|
||||
let opts = ExportOptions { object_ids: ObjectIds::Unique, styling: Styling::InternalCss, ..Default::default() };
|
||||
let a = export(&doc("a", 0.0), &opts);
|
||||
let b = export(&doc("b", 5.0), &opts);
|
||||
let (ia, ib) = (ids(&a), ids(&b));
|
||||
assert_eq!(ia.len(), ia.iter().collect::<HashSet<_>>().len(), "unique within a document");
|
||||
assert!(ia.iter().all(|i| !ib.contains(i)), "{ia:?} vs {ib:?}");
|
||||
// CSS class names are page-global too.
|
||||
let classes = |s: &str| s.split("class=\"").skip(1).map(|s| s[..s.find('"').unwrap()].to_string()).collect::<HashSet<_>>();
|
||||
assert!(classes(&a).is_disjoint(&classes(&b)));
|
||||
// The same document gets the same ids, and the names still come back.
|
||||
assert_eq!(a, export(&doc("a", 0.0), &opts));
|
||||
let back = import(&a).unwrap();
|
||||
assert_eq!(back.layers[0].name.as_deref(), Some("Layer 1"));
|
||||
assert!(names(&back).contains(&"Logo mark (v2)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_attributes_name_objects() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:serif="http://www.serif.com/" width="100" height="100">
|
||||
<g id="layer1" inkscape:label="Background">
|
||||
<rect id="r1" data-name="Sky box" x="0" y="0" width="10" height="10"/>
|
||||
<rect id="r2" serif:id="Sun disc" x="20" y="0" width="10" height="10"/>
|
||||
<rect id="r3" aria-label="Badge" x="40" y="0" width="10" height="10"/>
|
||||
<rect id="plain" x="60" y="0" width="10" height="10"/>
|
||||
</g>
|
||||
</svg>"##;
|
||||
let d = import(svg).unwrap();
|
||||
assert_eq!(d.layers[0].name.as_deref(), Some("Background"));
|
||||
assert_eq!(names(&d), ["Background", "Sky box", "Sun disc", "Badge", "plain"]);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
//! SVG Options across each other: every styling, id mode and tspan mode reads back, and linked
|
||||
//! images are named after their bytes.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, ImageBlob, ImageObject, Node, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, ImageMode, ObjectIds, Styling, export, export_full, import};
|
||||
|
||||
/// A red rectangle with an awkward name, point type and area type of two paragraphs each.
|
||||
fn doc() -> Document {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let red = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::solid(Color::BLACK), 2.0);
|
||||
let mut a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), red);
|
||||
a.name = Some("A \"quoted\" & <odd> 100%".into());
|
||||
d.insert(Some(l), usize::MAX, a).unwrap();
|
||||
let st = CharStyle { size: 12.0, font_family: "Source Sans 3".into(), fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
|
||||
let mut t = TextObject::point(Point::new(20.0, 120.0), "", st.clone());
|
||||
t.runs = vec![TextRun { text: "one two\nthree".into(), style: st.clone() }];
|
||||
let i = d.alloc_id();
|
||||
d.insert(Some(l), usize::MAX, Node::new(i, NodeKind::Text(Box::new(t.clone())))).unwrap();
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 60.0)) };
|
||||
t.xf = Affine::translate((20.0, 140.0));
|
||||
t.runs = vec![TextRun { text: "para one\npara two".into(), style: st }];
|
||||
let i = d.alloc_id();
|
||||
d.insert(Some(l), usize::MAX, Node::new(i, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_combination_reads_back() {
|
||||
let d = doc();
|
||||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::StyleEntities, Styling::InternalCss] {
|
||||
for object_ids in [ObjectIds::LayerNames, ObjectIds::Minimal, ObjectIds::Unique] {
|
||||
for (fewer_tspans, minify) in [(false, false), (true, true)] {
|
||||
let o = ExportOptions { styling, object_ids, fewer_tspans, minify, ..Default::default() };
|
||||
let s = export(&d, &o);
|
||||
let back = import(&s).unwrap_or_else(|e| panic!("{o:?}: {e}\n{s}"));
|
||||
let mut red = false;
|
||||
back.walk(|n| {
|
||||
red |= n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.paint == Paint::solid(Color::rgb8(255, 0, 0))));
|
||||
});
|
||||
assert!(red, "{o:?}: the fill came back\n{s}");
|
||||
// Paragraph breaks are new lines, never characters in a line.
|
||||
assert!(!s.contains("\n</tspan>") && !s.contains("two\n"), "{o:?}\n{s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A document with one embedded image under `key`.
|
||||
fn image_doc(key: &str, bytes: Vec<u8>) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert(key.into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(bytes) });
|
||||
let l = d.layers[0].id;
|
||||
let im = ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None };
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Image(im));
|
||||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_images_are_named_after_their_bytes() {
|
||||
let link = ExportOptions { images: ImageMode::Link, ..Default::default() };
|
||||
// Keys such as "raster-1" repeat across documents: two exports into one folder must not clash.
|
||||
let a = export_full(&image_doc("raster-1", vec![1, 2, 3]), &link, None);
|
||||
let b = export_full(&image_doc("raster-1", vec![4, 5, 6]), &link, None);
|
||||
assert_eq!((a.linked.len(), b.linked.len()), (1, 1));
|
||||
assert_ne!(a.linked[0].name, b.linked[0].name);
|
||||
assert!(a.linked[0].name.ends_with(".png") && a.svg.contains(&format!("href=\"{}\"", a.linked[0].name)), "{}", a.svg);
|
||||
// The same bytes under another key are the same file.
|
||||
assert_eq!(export_full(&image_doc("other", vec![1, 2, 3]), &link, None).linked[0].name, a.linked[0].name);
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ use proptest::prelude::*;
|
|||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::Appearance;
|
||||
use vectorcraft_geom::{PathData, Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, Styling, export, import};
|
||||
use vectorcraft_svg::{ExportOptions, ObjectIds, Styling, export, import};
|
||||
use vectorcraft_testkit::fixtures::{self, DocBuilder, art_nodes};
|
||||
use vectorcraft_testkit::invariants::check_document;
|
||||
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
|
||||
|
|
@ -15,9 +15,11 @@ use vectorcraft_testkit::strategies::{arb_closed_shape, arb_ops, arb_path_data};
|
|||
|
||||
fn all_options() -> Vec<ExportOptions> {
|
||||
let mut v = vec![];
|
||||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss] {
|
||||
for (minify, decimals, object_ids, responsive) in [(false, 3, true, false), (true, 1, false, true), (false, 7, true, true)] {
|
||||
v.push(ExportOptions { artboard: Some(0), styling, decimals, object_ids, minify, responsive, outline_text: false });
|
||||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::StyleEntities, Styling::InternalCss] {
|
||||
for (minify, decimals, object_ids, responsive) in
|
||||
[(false, 3, ObjectIds::LayerNames, false), (true, 1, ObjectIds::Minimal, true), (false, 7, ObjectIds::Unique, true)]
|
||||
{
|
||||
v.push(ExportOptions { artboard: Some(0), styling, decimals, object_ids, minify, responsive, ..Default::default() });
|
||||
}
|
||||
}
|
||||
v.push(ExportOptions { artboard: None, ..Default::default() });
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use vectorcraft_doc::{
|
|||
StrokeLayer, TextKind, TextObject,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, Styling, export, import, import_with_report};
|
||||
use vectorcraft_svg::{ExportOptions, ObjectIds, Styling, export, import, import_with_report};
|
||||
|
||||
fn doc_with(nodes: Vec<Node>) -> Document {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
|
@ -91,10 +91,11 @@ fn groups_and_names_roundtrip() {
|
|||
assert!(s.contains("id=\"My_Group\"") && s.contains("id=\"Box_B\"") && s.contains("id=\"Layer_1\""), "{s}");
|
||||
let r = roundtrip(&d);
|
||||
assert_eq!(r.node_count(), d.node_count());
|
||||
assert_eq!(r.layers[0].name.as_deref(), Some("Layer_1"));
|
||||
// data-name carries the names back exactly.
|
||||
assert_eq!(r.layers[0].name.as_deref(), Some("Layer 1"));
|
||||
let a = art(&r);
|
||||
assert!(matches!(a[0].kind, NodeKind::Group { clip: false, .. }));
|
||||
assert_eq!(a[0].name.as_deref(), Some("My_Group"));
|
||||
assert_eq!(a[0].name.as_deref(), Some("My Group"));
|
||||
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 30.0, 10.0), 0.01));
|
||||
}
|
||||
|
||||
|
|
@ -400,7 +401,7 @@ fn text_on_path_roundtrip() {
|
|||
|
||||
#[test]
|
||||
fn styling_modes_roundtrip() {
|
||||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss] {
|
||||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::StyleEntities, Styling::InternalCss] {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::basic(solid("#abcdef"), solid("#010203"), 2.0));
|
||||
let b = rect_node(&mut d, Rect::new(20.0, 0.0, 30.0, 10.0), Appearance::basic(solid("#abcdef"), solid("#010203"), 2.0));
|
||||
|
|
@ -412,6 +413,10 @@ fn styling_modes_roundtrip() {
|
|||
match styling {
|
||||
Styling::PresentationAttributes => assert!(s.contains("fill=\"#abcdef\"")),
|
||||
Styling::InlineStyle => assert!(s.contains("style=\"fill:#abcdef")),
|
||||
Styling::StyleEntities => {
|
||||
assert!(s.contains("<!DOCTYPE svg [") && s.contains("<!ENTITY st1 \"fill:#abcdef") && s.contains("style=\"&st1;\""), "{s}");
|
||||
assert_eq!(s.matches("<!ENTITY").count(), 2, "{s}");
|
||||
}
|
||||
Styling::InternalCss => {
|
||||
assert!(s.contains("<style>") && s.contains("class=\"cls-1\""));
|
||||
assert_eq!(s.matches(".cls-").count(), 2, "{s}");
|
||||
|
|
@ -445,7 +450,7 @@ fn options_decimals_minify_responsive_artboard() {
|
|||
// All art bounds: the rectangle and half its 1 pt stroke (right-angle miters stay inside).
|
||||
let s = export(&d, &ExportOptions { artboard: None, ..Default::default() });
|
||||
assert!(s.contains("viewBox=\"0 0 40.877 31\""), "{s}");
|
||||
let s = export(&d, &ExportOptions { object_ids: false, ..Default::default() });
|
||||
let s = export(&d, &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() });
|
||||
assert!(!s.contains("id=\"Layer_1\""));
|
||||
}
|
||||
|
||||
|
|
@ -760,8 +765,8 @@ fn full_document_roundtrip_is_stable() {
|
|||
let d = doc_with(vec![g]);
|
||||
let s1 = export(&d, &ExportOptions::default());
|
||||
let r = import(&s1).unwrap();
|
||||
let s2 = export(&r, &ExportOptions { object_ids: false, ..Default::default() });
|
||||
let s1b = export(&d, &ExportOptions { object_ids: false, ..Default::default() });
|
||||
let s2 = export(&r, &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() });
|
||||
let s1b = export(&d, &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() });
|
||||
let strip = |s: &str| s.lines().filter(|l| !l.contains("<title>")).collect::<Vec<_>>().join("\n");
|
||||
assert_eq!(strip(&s1b), strip(&s2));
|
||||
let _ = PathData::default();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
//! Laid-out type in SVG: every line of area type is its own positioned `<tspan>`, rich text keeps
|
||||
//! its leading, baseline shift, scaling and kerning, and a renderer with the same font draws the
|
||||
//! export where the canvas draws the text.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, export};
|
||||
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
|
||||
|
||||
fn style(size: f64) -> CharStyle {
|
||||
CharStyle { size, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }
|
||||
}
|
||||
|
||||
/// Area type `w` wide at (20, 20) in a 300 × 200 document.
|
||||
fn area_doc(runs: Vec<TextRun>, w: f64, justify: Justify) -> Document {
|
||||
let mut t = TextObject::point(Point::new(20.0, 20.0), "", style(12.0));
|
||||
t.runs = runs;
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, 160.0)) };
|
||||
t.para.justify = justify;
|
||||
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();
|
||||
d
|
||||
}
|
||||
|
||||
fn run(text: &str, st: CharStyle) -> TextRun {
|
||||
TextRun { text: text.into(), style: st }
|
||||
}
|
||||
|
||||
/// The `<tspan …>` start tags.
|
||||
fn tspans(svg: &str) -> Vec<&str> {
|
||||
svg.match_indices("<tspan").map(|(i, _)| &svg[i..i + svg[i..].find('>').unwrap()]).collect()
|
||||
}
|
||||
|
||||
fn attr(tag: &str, name: &str) -> Option<f64> {
|
||||
let k = format!(" {name}=\"");
|
||||
let i = tag.find(&k)? + k.len();
|
||||
tag[i..i + tag[i..].find('"')?].parse().ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_lines_get_one_tspan_each() {
|
||||
let d = area_doc(vec![run("alpha beta gamma delta epsilon zeta", style(12.0))], 70.0, Justify::Left);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let tags = tspans(&svg);
|
||||
let ys: Vec<f64> = tags.iter().map(|t| attr(t, "y").expect("each line is placed")).collect();
|
||||
assert!(ys.len() >= 3, "the text wraps: {svg}");
|
||||
assert!(ys.windows(2).all(|w| w[1] > w[0]), "baselines go down: {ys:?}");
|
||||
// Auto leading: 120 % of the size between baselines.
|
||||
assert!((ys[1] - ys[0] - 14.4).abs() < 0.01, "{ys:?}");
|
||||
assert!(tags.iter().all(|t| attr(t, "x") == Some(0.0)), "left aligned at the frame's edge: {svg}");
|
||||
// Reading it back gives a line per baseline, starting at the first baseline.
|
||||
let back = vectorcraft_svg::import(&svg).unwrap();
|
||||
let text = back.layers[0].children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.clone()) } else { None }).unwrap();
|
||||
assert_eq!(text.plain_text().lines().count(), ys.len(), "{:?}", text.plain_text());
|
||||
assert!((text.xf.translation().y - (20.0 + ys[0])).abs() < 0.01);
|
||||
// Centred lines are anchored at the frame's centre, so they stay centred in any font.
|
||||
let svg = export(&area_doc(vec![run("alpha beta gamma delta", style(12.0))], 70.0, Justify::Center), &ExportOptions::default());
|
||||
assert!(svg.contains("text-anchor=\"middle\""), "{svg}");
|
||||
assert!(tspans(&svg).iter().all(|t| (attr(t, "x").unwrap() - 35.0).abs() < 0.01), "{svg}");
|
||||
// Left-aligned (and justified) lines are placed absolutely.
|
||||
assert!(!export(&d, &ExportOptions::default()).contains("text-anchor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_text_writes_spacing_shift_and_scale() {
|
||||
let shifted = CharStyle { baseline_shift: 4.0, ..style(12.0) };
|
||||
let wide = CharStyle { h_scale: 150.0, ..style(12.0) };
|
||||
let kerned = CharStyle { tracking: 50.0, kerning: Some(-20.0), ..style(12.0) };
|
||||
let tall = CharStyle { v_scale: 200.0, h_scale: 200.0, leading: Some(30.0), ..style(12.0) };
|
||||
let d = area_doc(
|
||||
vec![run("Base ", style(12.0)), run("up ", shifted), run("wide ", wide), run("kern\n", kerned), run("tall", tall)],
|
||||
280.0,
|
||||
Justify::Left,
|
||||
);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let tags = tspans(&svg);
|
||||
assert_eq!(tags.len(), 5, "{svg}");
|
||||
assert_eq!(attr(tags[1], "dy"), Some(-4.0), "baseline shift: {}", tags[1]);
|
||||
assert!(attr(tags[2], "textLength").is_some() && tags[2].contains("lengthAdjust=\"spacingAndGlyphs\""), "{}", tags[2]);
|
||||
assert!(tags[3].contains("letter-spacing=\"0.36\"") && tags[3].contains("font-kerning:none"), "tracking + kerning: {}", tags[3]);
|
||||
assert!(tags[4].contains("font-size=\"24\"") && attr(tags[4], "textLength").is_none(), "uniform scale is the size: {}", tags[4]);
|
||||
assert!((attr(tags[4], "y").unwrap() - attr(tags[0], "y").unwrap() - 30.0).abs() < 0.01, "leading");
|
||||
// Segments on one baseline share its y; the shifted one is placed with dy.
|
||||
assert_eq!(attr(tags[1], "y"), attr(tags[0], "y"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_tspans_writes_one_per_line() {
|
||||
let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) };
|
||||
let runs = vec![run("one ", style(12.0)), run("two", bold), run(" three\nfour", style(12.0))];
|
||||
let d = area_doc(runs, 280.0, Justify::Left);
|
||||
let full = export(&d, &ExportOptions::default());
|
||||
let fewer = export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() });
|
||||
let placed = |s: &str| tspans(s).iter().filter(|t| attr(t, "y").is_some()).count();
|
||||
assert_eq!(placed(&full), 4, "{full}");
|
||||
assert_eq!(placed(&fewer), 2, "one positioned tspan per line: {fewer}");
|
||||
assert!(fewer.contains("font-weight=\"bold\""), "style changes stay: {fewer}");
|
||||
assert!(fewer.len() < full.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justified_lines_place_each_word() {
|
||||
let d = area_doc(vec![run("aa bb cc dd ee ff gg hh ii jj kk ll", style(12.0))], 90.0, Justify::JustifyLeft);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let first_line_y = attr(tspans(&svg)[0], "y").unwrap();
|
||||
let words = tspans(&svg).iter().filter(|t| attr(t, "y") == Some(first_line_y)).count();
|
||||
assert!(words >= 3, "{svg}");
|
||||
}
|
||||
|
||||
/// Glyph outlines of every text a usvg (with the bundled font) lays out from `svg`, in SVG
|
||||
/// user space.
|
||||
fn viewer_outlines(svg: &str) -> Vec<BezPath> {
|
||||
let mut db = usvg::fontdb::Database::new();
|
||||
db.load_font_data(std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap());
|
||||
let opt = usvg::Options { fontdb: Arc::new(db), ..Default::default() };
|
||||
let tree = usvg::Tree::from_str(svg, &opt).unwrap();
|
||||
fn walk(g: &usvg::Group, out: &mut Vec<BezPath>) {
|
||||
for n in g.children() {
|
||||
match n {
|
||||
usvg::Node::Group(g) => walk(g, out),
|
||||
usvg::Node::Text(t) => walk(t.flattened(), out),
|
||||
usvg::Node::Path(p) => {
|
||||
let m = p.abs_transform();
|
||||
let m = Affine::new([m.sx as f64, m.ky as f64, m.kx as f64, m.sy as f64, m.tx as f64, m.ty as f64]);
|
||||
let mut bp = BezPath::new();
|
||||
for s in p.data().segments() {
|
||||
use usvg::tiny_skia_path::PathSegment as S;
|
||||
let pt = |q: usvg::tiny_skia_path::Point| m * Point::new(q.x as f64, q.y as f64);
|
||||
match s {
|
||||
S::MoveTo(a) => bp.move_to(pt(a)),
|
||||
S::LineTo(a) => bp.line_to(pt(a)),
|
||||
S::QuadTo(a, b) => bp.quad_to(pt(a), pt(b)),
|
||||
S::CubicTo(a, b, c) => bp.curve_to(pt(a), pt(b), pt(c)),
|
||||
S::Close => bp.close_path(),
|
||||
}
|
||||
}
|
||||
out.push(bp);
|
||||
}
|
||||
usvg::Node::Image(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
walk(tree.root(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_viewer_draws_the_text_where_the_canvas_does() {
|
||||
// With a tab every line is placed absolutely; without one, centred and right-aligned lines are
|
||||
// anchored, and a baseline shift mid-line is undone by the next run.
|
||||
for (sep, shift) in [(" ", 0.0), (" ", 3.0)] {
|
||||
let second = |fill: Color| CharStyle { fill: Paint::solid(fill), baseline_shift: shift, ..style(14.0) };
|
||||
let runs = |second: CharStyle| {
|
||||
vec![
|
||||
run("The quick brown fox jumps over the lazy dog. ", style(14.0)),
|
||||
run(&format!("Pack my box{sep}with"), second),
|
||||
run(" five dozen jugs.", style(14.0)),
|
||||
]
|
||||
};
|
||||
for justify in [Justify::Left, Justify::Center, Justify::Right] {
|
||||
for fewer_tspans in [false, true] {
|
||||
let o = ExportOptions { fewer_tspans, ..Default::default() };
|
||||
let svg = export(&area_doc(runs(second(Color::rgb(0.8, 0.0, 0.0))), 200.0, justify), &o);
|
||||
assert!(svg.contains("fill=\"#cc0000\""), "{svg}");
|
||||
// The viewer's glyphs as black shapes, against the canvas drawing the text all black.
|
||||
let mut viewer = Document::new(300.0, 200.0);
|
||||
let l = viewer.layers[0].id;
|
||||
let outlines = viewer_outlines(&svg);
|
||||
assert!(outlines.iter().map(|b| b.elements().len()).sum::<usize>() > 200, "the viewer laid the text out");
|
||||
for bp in outlines {
|
||||
let ap = vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
let n = Node::path(viewer.alloc_id(), PathData::from_bezpath(&bp), ap);
|
||||
viewer.insert(Some(l), usize::MAX, n).unwrap();
|
||||
}
|
||||
let canvas = area_doc(runs(second(Color::BLACK)), 200.0, justify);
|
||||
assert_similar(&render_artboard(&canvas), &render_artboard(&viewer), 40.0, 0.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
|
||||
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}`
|
||||
//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG)
|
||||
//! - `app.open {path}` (any readable format) / `app.save {path?}` / `app.quit`
|
||||
//! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit`
|
||||
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
|
||||
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
|
||||
//! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through
|
||||
|
|
@ -302,7 +302,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
|
|||
}
|
||||
}
|
||||
"app.open" => wrap(app.run("file.open", json!({"path": s("path")}))),
|
||||
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
|
||||
"app.save" => wrap(app.run("file.save", if p.is_object() { p.clone() } else { json!({}) })),
|
||||
"app.export" => match s("path") {
|
||||
Some(path) => wrap(crate::io::export(app, s("format"), Some(path.to_string()), p).map(|p| json!({"path": p}))),
|
||||
// No save dialog for an agent: the bytes come back, as in headless mode.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pub mod save_style_library;
|
|||
pub mod save_swatch_library;
|
||||
mod shapes;
|
||||
pub mod spot_colors;
|
||||
pub(crate) mod svg_options;
|
||||
pub mod swatch_options;
|
||||
pub mod tile_edge_color;
|
||||
mod tools;
|
||||
|
|
@ -174,6 +175,7 @@ registry! {
|
|||
TransformEach: [transform_each::KIND] => transform_each::SPEC,
|
||||
WidthPoint: [width_point::KIND] => width_point::SPEC,
|
||||
SavePdf: [save_pdf::KIND] => save_pdf::SPEC,
|
||||
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,372 @@
|
|||
//! SVG Options (Export As SVG, Save As / Save a Copy to .svg): styling, fonts, images, object ids,
|
||||
//! decimals, minify, responsive, artboards (export) or editing data (save), and a read-only view
|
||||
//! of the code. OK remembers the choices for next time and runs the export or save command with
|
||||
//! them as `svg: {…}`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "SVG Options".into(), body, confirm, ok: Some("OK"), min_width: 380.0, max_width: Some(560.0), ..DialogSpec::FORM };
|
||||
|
||||
/// `Dialog::kind` of this dialog.
|
||||
pub const KIND: &str = "svgOptions";
|
||||
|
||||
/// What OK does: Export As SVG, Save As or Save a Copy.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Export,
|
||||
Save,
|
||||
SaveCopy,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
fn id(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Export => "export",
|
||||
Mode::Save => "save",
|
||||
Mode::SaveCopy => "copy",
|
||||
}
|
||||
}
|
||||
|
||||
fn of(d: &Dialog) -> Self {
|
||||
match d.str("mode").as_str() {
|
||||
"save" => Mode::Save,
|
||||
"copy" => Mode::SaveCopy,
|
||||
_ => Mode::Export,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// (option value, label) for each choice list.
|
||||
const STYLING: [(&str, &str); 4] = [
|
||||
("css", "Internal CSS"),
|
||||
("presentation", "Presentation Attributes"),
|
||||
("style", "Style Attributes"),
|
||||
("entities", "Style Attributes (Entity References)"),
|
||||
];
|
||||
const FONTS: [(bool, &str); 2] = [(false, "SVG"), (true, "Convert To Outlines")];
|
||||
const IMAGES: [(&str, &str); 2] = [("embed", "Embed"), ("link", "Link")];
|
||||
const OBJECT_IDS: [(&str, &str); 3] = [("layerNames", "Layer Names"), ("minimal", "Minimal"), ("unique", "Unique")];
|
||||
|
||||
/// Dialog fields that are not SVG options.
|
||||
const UI_KEYS: [&str; 5] = ["mode", "path", "showCode", "allArtboards", "range"];
|
||||
|
||||
/// The options a first SVG Options dialog starts from: the engine's defaults with Internal CSS
|
||||
/// and Responsive on (as the reference app's Export As starts).
|
||||
fn first_use() -> Map<String, Value> {
|
||||
let mut m = serde_json::to_value(vectorcraft_svg::ExportOptions::default()).ok().and_then(|v| v.as_object().cloned()).unwrap_or_default();
|
||||
m.insert("styling".into(), json!("css"));
|
||||
m.insert("responsive".into(), json!(true));
|
||||
m.insert("useArtboards".into(), json!(true));
|
||||
m
|
||||
}
|
||||
|
||||
/// Open SVG Options for `mode`, filled with the options used last (a save starts from the ones
|
||||
/// the document was last saved with). `path`: where a save goes.
|
||||
pub fn open(app: &mut VectorcraftApp, mode: Mode, path: Option<&str>) {
|
||||
let mut fields = first_use();
|
||||
let saved = app.session.active().map(|st| st.save_options.clone()).filter(|_| mode != Mode::Export);
|
||||
for last in [&app.ui.svg_options].into_iter().chain(saved.as_ref()) {
|
||||
if let Some(o) = last.as_object() {
|
||||
fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
}
|
||||
fields.insert("mode".into(), json!(mode.id()));
|
||||
fields.insert("allArtboards".into(), json!(true));
|
||||
fields.insert("range".into(), json!(""));
|
||||
if let Some(p) = path {
|
||||
fields.insert("path".into(), json!(p));
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields)));
|
||||
}
|
||||
|
||||
/// The SVG options the dialog's fields describe (artboards only when exporting, editing data
|
||||
/// only when saving).
|
||||
fn options(d: &Dialog) -> Value {
|
||||
let mode = Mode::of(d);
|
||||
let mut o: Map<String, Value> = d.fields.iter().filter(|(k, _)| !UI_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
if mode == Mode::Export {
|
||||
o.remove("preserveEditing");
|
||||
if d.bool("useArtboards") {
|
||||
let range = d.str("range");
|
||||
let range = range.trim();
|
||||
o.insert("range".into(), json!(if d.bool("allArtboards") || range.is_empty() { "all" } else { range }));
|
||||
}
|
||||
} else {
|
||||
o.remove("useArtboards");
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let opts = options(d);
|
||||
// Remembered for next time, without the document's artboard range.
|
||||
let mut last = opts.clone();
|
||||
if let Some(o) = last.as_object_mut() {
|
||||
o.remove("range");
|
||||
}
|
||||
app.ui.svg_options = last;
|
||||
app.ui.dialog = None;
|
||||
let mut params = json!({ "svg": opts });
|
||||
if let Some(p) = d.fields.get("path").filter(|p| p.is_string()) {
|
||||
params["path"] = p.clone();
|
||||
}
|
||||
let id = match Mode::of(d) {
|
||||
Mode::Export => "file.export.svg",
|
||||
Mode::Save => "file.saveAs",
|
||||
Mode::SaveCopy => "file.saveCopy",
|
||||
};
|
||||
app.run(id, params)
|
||||
}
|
||||
|
||||
/// One labelled choice row: the dropdown sets `d.fields[key]` to the chosen value.
|
||||
fn choice<V: Into<Value> + Copy>(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, choices: &[(V, &str)]) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(label).color(t.text_dim));
|
||||
let current = d.fields.get(key).cloned().unwrap_or(Value::Null);
|
||||
let shown = choices.iter().find(|(v, _)| (*v).into() == current).or(choices.first()).map_or("", |(_, l)| *l);
|
||||
let labels: Vec<&str> = choices.iter().map(|(_, l)| *l).collect();
|
||||
if let Some((v, _)) = widgets::dropdown(ui, key, shown, &labels, 250.0).and_then(|i| choices.get(i)) {
|
||||
d.fields.insert(key.into(), (*v).into());
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// A checkbox bound to `d.fields[key]`.
|
||||
fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, enabled: bool) {
|
||||
if widgets::check(ui, label, d.bool(key), enabled) {
|
||||
d.fields.insert(key.into(), json!(!d.bool(key)));
|
||||
}
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mode = Mode::of(d);
|
||||
egui::Grid::new("svg-options").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
choice(ui, d, "styling", "Styling:", &STYLING);
|
||||
choice(ui, d, "outlineText", "Font:", &FONTS);
|
||||
choice(ui, d, "images", "Images:", &IMAGES);
|
||||
choice(ui, d, "objectIds", "Object IDs:", &OBJECT_IDS);
|
||||
ui.label(egui::RichText::new("Decimal:").color(t.text_dim));
|
||||
let decimals = d.f64("decimals", 3.0);
|
||||
if let Some(v) = widgets::spin_plain(ui, "svg-decimals", decimals, "", 0, 70.0, 1.0, 1.0, &[]) {
|
||||
let (lo, hi) = (*vectorcraft_svg::DECIMALS.start() as f64, *vectorcraft_svg::DECIMALS.end() as f64);
|
||||
d.fields.insert("decimals".into(), json!(v.round().clamp(lo, hi) as u8));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
check(ui, d, "minify", "Minify", true);
|
||||
ui.add_space(12.0);
|
||||
check(ui, d, "responsive", "Responsive", true);
|
||||
});
|
||||
check(ui, d, "fewerTspans", "Fewer <tspan> Elements", !d.bool("outlineText"));
|
||||
check(ui, d, "metadata", "Include Metadata", true);
|
||||
if mode == Mode::Export {
|
||||
artboards(app, ui, d);
|
||||
} else {
|
||||
check(ui, d, "preserveEditing", "Preserve Editing Capabilities", true);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let show = d.bool("showCode");
|
||||
if widgets::secondary_button(ui, if show { "Hide Code" } else { "Show Code" }).clicked() {
|
||||
d.fields.insert("showCode".into(), json!(!show));
|
||||
}
|
||||
if show {
|
||||
ui.add_space(6.0);
|
||||
code_view(app, ui, d);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Use Artboards: all of them or a range; off = the bounds of all art.
|
||||
fn artboards(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let count = app.session.active().map_or(0, |st| st.doc.artboards.len());
|
||||
ui.add_space(4.0);
|
||||
check(ui, d, "useArtboards", "Use Artboards", count > 0);
|
||||
let on = d.bool("useArtboards") && count > 0;
|
||||
ui.add_enabled_ui(on, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(22.0);
|
||||
let all = d.bool("allArtboards");
|
||||
if ui.radio(all, "All").clicked() {
|
||||
d.fields.insert("allArtboards".into(), json!(true));
|
||||
}
|
||||
if ui.radio(!all, "Range:").clicked() {
|
||||
d.fields.insert("allArtboards".into(), json!(false));
|
||||
}
|
||||
let mut range = d.str("range");
|
||||
let edit = egui::TextEdit::singleline(&mut range).hint_text(format!("1-{count}")).desired_width(90.0);
|
||||
if ui.add_enabled(!all, edit).changed() {
|
||||
d.fields.insert("range".into(), json!(range));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The SVG the current options write for the first chosen artboard (read-only, selectable),
|
||||
/// encoded again only when the options or the document change.
|
||||
fn code_view(app: &VectorcraftApp, ui: &mut egui::Ui, d: &Dialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let mut opts = options(d);
|
||||
if let Some(o) = opts.as_object_mut()
|
||||
&& let Some(r) = o.remove("range")
|
||||
{
|
||||
// The first artboard of the range (or the first one).
|
||||
let first = r.as_str().and_then(|r| r.split([',', '-', '\u{2013}']).next()).and_then(|n| n.trim().parse::<usize>().ok());
|
||||
o.insert("artboard".into(), json!(first.map_or(0, |n| n.saturating_sub(1))));
|
||||
}
|
||||
// One cached text, replaced when the document or the options change.
|
||||
let key = egui::Id::new("svg-code");
|
||||
let sig = egui::Id::new((st.uid, st.revision, opts.to_string())).value();
|
||||
let code: Arc<String> = match ui.ctx().data(|m| m.get_temp::<(u64, Arc<String>)>(key)).filter(|(s, _)| *s == sig) {
|
||||
Some((_, c)) => c,
|
||||
None => {
|
||||
const MAX: usize = 200_000;
|
||||
let mut text = match vectorcraft_engine::cmd::fileio::encode_all(&st.doc, "svg", &json!({ "svg": opts })) {
|
||||
Ok(enc) => enc.files.into_iter().next().map(|(_, b)| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default(),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
if text.len() > MAX {
|
||||
let cut = (0..=MAX).rev().find(|i| text.is_char_boundary(*i)).unwrap_or(0);
|
||||
text.truncate(cut);
|
||||
text.push_str("\n…");
|
||||
}
|
||||
let c = Arc::new(text);
|
||||
ui.ctx().data_mut(|m| m.insert_temp(key, (sig, c.clone())));
|
||||
c
|
||||
}
|
||||
};
|
||||
widgets::list_box(ui, |ui| {
|
||||
egui::ScrollArea::both().max_height(260.0).max_width(520.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut code.as_str())
|
||||
.font(theme::mono(11.5))
|
||||
.text_color(t.text)
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(12),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
|
||||
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// An app with two artboards and a rectangle whose save dialog answers `picked` and whose
|
||||
/// writer records what it is given.
|
||||
fn app(picked: &str) -> (VectorcraftApp, Written) {
|
||||
let written = Written::default();
|
||||
let w = written.clone();
|
||||
let picked = picked.to_string();
|
||||
let services = Services {
|
||||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 120, "height": 80, "artboards": 2})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
(app, written)
|
||||
}
|
||||
|
||||
/// One headless frame of the dialog layer.
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn text(w: &Written, i: usize) -> String {
|
||||
String::from_utf8(w.borrow()[i].1.clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_as_svg_opens_the_options_and_remembers_them() {
|
||||
let (mut app, written) = app("/tmp/out.svg");
|
||||
app.run("file.export.svg", Value::Null).unwrap();
|
||||
let d = app.ui.dialog.clone().expect("SVG Options opened");
|
||||
assert_eq!(d.kind, KIND);
|
||||
assert_eq!((d.str("styling"), d.bool("responsive"), d.str("mode")), ("css".into(), true, "export".into()), "first-use defaults");
|
||||
frame(&mut app);
|
||||
// Show Code draws the SVG the options write.
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("showCode".into(), json!(true));
|
||||
frame(&mut app);
|
||||
assert!(app.ui.dialog.is_some());
|
||||
let f = &mut app.ui.dialog.as_mut().unwrap().fields;
|
||||
f.insert("decimals".into(), json!(1));
|
||||
f.insert("allArtboards".into(), json!(false));
|
||||
f.insert("range".into(), json!("2"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let svg = text(&written, 0);
|
||||
assert_eq!(written.borrow()[0].0, "/tmp/out.svg");
|
||||
assert!(svg.contains("<style>") && !svg.contains(" width=\"120\""), "{svg}");
|
||||
assert_eq!(app.ui.svg_options["decimals"], 1);
|
||||
assert!(app.ui.svg_options.get("range").is_none(), "the range belongs to the document");
|
||||
// Next time the dialog starts from these choices.
|
||||
app.run("file.export.svg", Value::Null).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("decimals", 0.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_artboards_export_one_file_each() {
|
||||
let (mut app, written) = app("/tmp/set.svg");
|
||||
app.run("file.export.svg", Value::Null).unwrap();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let names: Vec<String> = written.borrow().iter().map(|(p, _)| p.clone()).collect();
|
||||
assert_eq!(names, ["/tmp/set-Artboard-1.svg", "/tmp/set-Artboard-2.svg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_svg_asks_for_options_then_save_reuses_them() {
|
||||
let (mut app, written) = app("/tmp/doc.svg");
|
||||
let r = app.run("file.saveAs", Value::Null).unwrap();
|
||||
assert_eq!(r["dialog"], KIND);
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!((d.str("mode"), d.str("path")), ("save".into(), "/tmp/doc.svg".into()));
|
||||
frame(&mut app);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("styling".into(), json!("style"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(text(&written, 0).contains("style=\"fill:"));
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.path.as_deref(), Some("/tmp/doc.svg"));
|
||||
assert!(!st.is_dirty());
|
||||
// Save writes SVG again with the same options, no dialog.
|
||||
app.run("shape.ellipse", json!({"x": 60, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
app.run("file.save", Value::Null).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(written.borrow()[1].0, "/tmp/doc.svg");
|
||||
assert!(text(&written, 1).contains("style=\"fill:") && text(&written, 1).matches("<path").count() == 2);
|
||||
// Save a Copy (options given) leaves the document's path alone.
|
||||
app.run("file.saveCopy", json!({"path": "/tmp/copy.svg", "svg": {"minify": true}})).unwrap();
|
||||
assert!(!text(&written, 2).trim_end().contains('\n') && !text(&written, 2).contains("<?xml"), "minified");
|
||||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.svg"));
|
||||
// A native Save As needs no options.
|
||||
app.run("file.saveAs", json!({"path": "/tmp/doc.vectorcraft"})).unwrap();
|
||||
assert!(vectorcraft_format::sniff(&written.borrow()[3].1));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
//! File I/O through the injected services (pick, read, write, download); the engine's `fileio`
|
||||
//! decodes and encodes every format.
|
||||
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ImageObject, Node, NodeKind};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::dialogs::svg_options;
|
||||
|
||||
/// Open bytes of any readable format as a new document (templates open untitled); swatch and
|
||||
/// graphic style library files open in the library panel and flattener presets files are imported.
|
||||
|
|
@ -81,16 +82,32 @@ fn target_path(app: &mut VectorcraftApp, path: Option<String>, ext: &str) -> Res
|
|||
}
|
||||
}
|
||||
|
||||
/// File → Save / Save As (native format).
|
||||
pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool) -> Result<String, String> {
|
||||
/// Write every file of an export (one per artboard, linked images) for the destination `path`.
|
||||
fn write_encoded(app: &mut VectorcraftApp, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result<(), String> {
|
||||
for (p, bytes) in enc.named(doc, path) {
|
||||
write_out(app, &p, bytes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_param(p: &Value) -> Option<&str> {
|
||||
p.get("format").and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// File → Save / Save As: native, or SVG for an .svg path (with the SVG options in `params`, else
|
||||
/// the ones the document was last saved with).
|
||||
pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool, params: &Value) -> Result<String, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let existing = if save_as { None } else { st.path.clone() };
|
||||
let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?;
|
||||
let doc = app.session.active().ok_or("no document")?.doc.clone();
|
||||
let bytes = fileio::encode(&doc, "vectorcraft", &Value::Null).map_err(|e| e.to_string())?;
|
||||
write_out(app, &path, &bytes)?;
|
||||
let f = fileio::save_format(format_param(params), Some(&path))?;
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let (enc, opts) = fileio::save_encoding(st, f, params).map_err(|e| e.to_string())?;
|
||||
let doc = st.doc.clone();
|
||||
write_encoded(app, &doc, &path, &enc)?;
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.path = Some(path.clone());
|
||||
st.save_options = opts;
|
||||
st.mark_saved();
|
||||
}
|
||||
app.status(format!("Saved {path}"));
|
||||
|
|
@ -98,6 +115,27 @@ pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool) -> Re
|
|||
Ok(path)
|
||||
}
|
||||
|
||||
/// File → Save As / Save a Copy: the path (asked for when missing); an SVG path without SVG
|
||||
/// options in `p` opens SVG Options, whose OK saves. A copy leaves the document's path alone.
|
||||
pub fn save_as(app: &mut VectorcraftApp, copy: bool, p: &Value) -> Result<Value, String> {
|
||||
let path = target_path(app, p.get("path").and_then(Value::as_str).map(str::to_string), vectorcraft_format::EXTENSION)?;
|
||||
let f = fileio::save_format(format_param(p), Some(&path))?;
|
||||
let given = p.get("svg").is_some_and(|v| !v.is_null()) || !fileio::svg_options(p)?.is_empty();
|
||||
if f.id != "vectorcraft" && !given {
|
||||
let mode = if copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save };
|
||||
svg_options::open(app, mode, Some(&path));
|
||||
return Ok(json!({ "dialog": svg_options::KIND, "path": path }));
|
||||
}
|
||||
if !copy {
|
||||
return save(app, Some(path), true, p).map(|p| json!({ "path": p }));
|
||||
}
|
||||
let doc = app.session.active().ok_or("no document")?.doc.clone();
|
||||
let enc = fileio::encode_all(&doc, f.id, p).map_err(|e| e.to_string())?;
|
||||
write_encoded(app, &doc, &path, &enc)?;
|
||||
app.status(format!("Saved a copy as {path}"));
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
||||
/// Put `path` at the top of File → Open Recent Files (capped by Preferences → Recent Files).
|
||||
pub fn note_recent(app: &mut VectorcraftApp, path: &str) {
|
||||
let r = &mut app.ui.recent_files;
|
||||
|
|
@ -107,13 +145,19 @@ pub fn note_recent(app: &mut VectorcraftApp, path: &str) {
|
|||
}
|
||||
|
||||
/// Export the active document in `format` (default: the path's extension, else PNG) with the
|
||||
/// `document.export` options in `params` (artboard, range, scale…). The document keeps its path.
|
||||
/// `document.export` options in `params` (artboard, range, scale, SVG options…): every file it
|
||||
/// writes (an SVG per artboard, linked images) goes next to `path`. The document keeps its path.
|
||||
pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<String>, params: &Value) -> Result<String, String> {
|
||||
let f = fileio::writable_format(format, path.as_deref())?;
|
||||
let doc = app.session.active().ok_or("no document")?.doc.clone();
|
||||
let path = target_path(app, path, f.extensions[0])?;
|
||||
let bytes = fileio::encode(&doc, f.id, params).map_err(|e| e.to_string())?;
|
||||
write_out(app, &path, &bytes)?;
|
||||
// An SVG given a .svgz name is written compressed.
|
||||
let f = match fileio::format_for_name(&path) {
|
||||
Some(z) if f.id == "svg" && z.id == "svgz" => z,
|
||||
_ => f,
|
||||
};
|
||||
let enc = fileio::encode_all(&doc, f.id, params).map_err(|e| e.to_string())?;
|
||||
write_encoded(app, &doc, &path, &enc)?;
|
||||
app.status(format!("Exported {path}"));
|
||||
Ok(path)
|
||||
}
|
||||
|
|
@ -204,7 +248,7 @@ pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result
|
|||
let nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
|
||||
app.session.clipboard = nodes;
|
||||
// Straight to the engine: `app.run` would let the system clipboard replace these nodes.
|
||||
app.session.execute("edit.pasteInPlace", &serde_json::json!({})).map_err(|e| e.to_string())?;
|
||||
app.session.execute("edit.pasteInPlace", &json!({})).map_err(|e| e.to_string())?;
|
||||
app.sync_views();
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ mod tests_paintchips;
|
|||
#[cfg(test)]
|
||||
mod tests_recolor;
|
||||
#[cfg(test)]
|
||||
mod tests_svg;
|
||||
#[cfg(test)]
|
||||
mod tests_transparencygrid;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
|
|
|||
|
|
@ -48,9 +48,14 @@ use Item::Sep;
|
|||
/// UI-level commands: (id, label, shortcut, params doc).
|
||||
pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("file.open", "Open…", "Cmd+O", "{path?}"),
|
||||
("file.save", "Save", "Cmd+S", "{path?}"),
|
||||
("file.saveAs", "Save As…", "Cmd+Shift+S", "{path?}"),
|
||||
("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?}"),
|
||||
("file.save", "Save", "Cmd+S", "{path?, svg?: {…SVG options}} (a document saved as .svg saves as SVG again, with the same options)"),
|
||||
(
|
||||
"file.saveAs",
|
||||
"Save As…",
|
||||
"Cmd+Shift+S",
|
||||
"{path?, svg?: {…SVG options}} .vectorcraft, or .svg/.svgz (such a path without svg options opens SVG Options)",
|
||||
),
|
||||
("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?, svg?: {…SVG options}} like Save As, but the document keeps its path"),
|
||||
("file.newFromTemplate", "New from Template…", "Cmd+Shift+N", "{path?} open a template as a new untitled document"),
|
||||
("file.revert", "Revert", "F12", "{}"),
|
||||
("file.place", "Place…", "Cmd+Shift+P", "{path?}"),
|
||||
|
|
@ -87,7 +92,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
("file.clearRecent", "Clear Recent Files", "", "{}"),
|
||||
("type.findFont", "Find Font…", "", "{} open the Find Font dialog (engine: text.fonts / text.replaceFont / select.font)"),
|
||||
("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"),
|
||||
("file.export.svg", "Export As SVG…", "", "{path?, artboard?, outlineText?} (document.export options)"),
|
||||
(
|
||||
"file.export.svg",
|
||||
"Export As SVG…",
|
||||
"",
|
||||
"{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)",
|
||||
),
|
||||
("file.export.png", "Export As PNG…", "", "{path?, scale?: 1, artboard?} (document.export options)"),
|
||||
("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{} opens the dialog; with params = document.exportForScreens"),
|
||||
("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"),
|
||||
|
|
@ -385,8 +395,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
Some(path) => io::open_path(app, &path).map(|_| Value::Null),
|
||||
None => io::open_dialog(app).map(|_| Value::Null),
|
||||
},
|
||||
"file.save" => io::save(app, s("path"), false).map(|p| json!({"path": p})),
|
||||
"file.saveAs" | "file.saveCopy" => io::save(app, s("path"), true).map(|p| json!({"path": p})),
|
||||
"file.save" => io::save(app, s("path"), false, p).map(|p| json!({"path": p})),
|
||||
"file.saveAs" | "file.saveCopy" => io::save_as(app, id == "file.saveCopy", p),
|
||||
"document.exportSelection" => {
|
||||
io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p}))
|
||||
}
|
||||
|
|
@ -438,6 +448,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
None => Err("cancelled".into()),
|
||||
}
|
||||
}
|
||||
"file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"file.export.svg" => io::export(app, Some("svg"), s("path"), p).map(|p| json!({"path": p})),
|
||||
"file.exportForScreens" if p.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
let n = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0);
|
||||
|
|
|
|||
|
|
@ -260,6 +260,9 @@ pub struct UiState {
|
|||
/// swatches) or "" (none) (`ui.colorGuideLimit`).
|
||||
#[serde(default)]
|
||||
pub color_guide_limit: String,
|
||||
/// The SVG Options chosen last (`svg` object of the export/save commands; null: never used).
|
||||
#[serde(default)]
|
||||
pub svg_options: Value,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
|
|
@ -315,6 +318,7 @@ impl Default for UiState {
|
|||
library_panel: None,
|
||||
flattener_preview: Default::default(),
|
||||
color_guide_limit: String::new(),
|
||||
svg_options: Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
//! SVGZ in the app: Place takes it, and a .svgz name (Export As SVG, Save As) writes it.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::{Services, VectorcraftApp, io};
|
||||
|
||||
type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// An app with a rectangle whose save dialog answers `picked`; the writer records its files.
|
||||
fn app(picked: &str) -> (VectorcraftApp, Written) {
|
||||
let written = Written::default();
|
||||
let w = written.clone();
|
||||
let picked = picked.to_string();
|
||||
let services = Services {
|
||||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
(app, written)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svgz_places_like_svg() {
|
||||
let (mut app, _) = app("/tmp/unused.svg");
|
||||
let svg = app.session.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
io::place_bytes(&mut app, "art.svgz", &vectorcraft_svg::compress(&svg)).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 1, "the rectangle was placed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_svgz_name_writes_compressed_svg() {
|
||||
let (mut app, written) = app("/tmp/art.svgz");
|
||||
// Export As SVG, named .svgz in the save dialog.
|
||||
app.run("file.export.svg", Value::Null).unwrap();
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let (path, bytes) = written.borrow()[0].clone();
|
||||
assert_eq!(path, "/tmp/art.svgz");
|
||||
assert!(vectorcraft_svg::text_of(&bytes).unwrap().contains("<style>"), "the SVG Options apply");
|
||||
assert!(vectorcraft_svg::is_svgz(&bytes));
|
||||
// Save As .svgz asks for SVG Options, then saves compressed and keeps the path.
|
||||
let r = app.run("file.saveAs", Value::Null).unwrap();
|
||||
assert_eq!(r["path"], "/tmp/art.svgz");
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(vectorcraft_svg::is_svgz(&written.borrow()[1].1));
|
||||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/art.svgz"));
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
|
|||
let i = i.ok_or("no such document")?;
|
||||
if !d.bool("discard") {
|
||||
app.session.set_active(i);
|
||||
io::save(app, None, false)?;
|
||||
io::save(app, None, false, &Value::Null)?;
|
||||
}
|
||||
close_now(app, i)?;
|
||||
match d.str("then").as_str() {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`). `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
|
||||
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
|
||||
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
|
||||
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
|
||||
|
||||
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
|
||||
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,
|
||||
|
|
|
|||
|
|
@ -122,6 +122,14 @@ colour group (one undo step).
|
|||
{"name":"run_command","arguments":{"command":"colorTheme.addToSwatches","params":{"name":"Harbor"}}}
|
||||
```
|
||||
|
||||
SVG Options: `export` to SVG takes them in `options`, flat or as `{"svg": {…}}`: `styling` (`presentation`,
|
||||
`style`, `entities`, `css`), `outlineText`, `images` (`embed`, or `link`: embedded images are written next to the
|
||||
SVG, or returned as `linked`), `objectIds` (`layerNames`, `minimal`, `unique`), `decimals` (1–7), `minify`,
|
||||
`responsive`, `useArtboards`, `range: "all"` (one SVG per artboard, listed in `files`), `preserveEditing` (the SVG
|
||||
reopens as the full document), `metadata` and `fewerTspans` (one `<tspan>` per line of type). Unknown keys inside `svg` are rejected; `run_command document.formats`
|
||||
lists every option with its default. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open,
|
||||
place and paste). `run_command document.save {path: "x.svg", svg: {…}}` saves as SVG (or `.svgz`).
|
||||
|
||||
## Resources
|
||||
|
||||
| URI | Content |
|
||||
|
|
|
|||
Loading…
Reference in New Issue