Merge pull request #101 from storytold/pr/m4-svg-encoding-tiny

M4.97–M4.98: SVG encoding, Tiny 1.2, font subsets and freeform gradients
This commit is contained in:
BFlat 2026-10-05 13:35:39 +07:00 committed by GitHub
commit 819a0cb9b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1393 additions and 91 deletions

2
Cargo.lock generated
View File

@ -4301,6 +4301,7 @@ dependencies = [
"resvg",
"serde",
"serde_json",
"skrifa 0.47.0",
"svgtypes 0.16.1",
"thiserror 2.0.21",
"usvg 0.48.1",
@ -4338,6 +4339,7 @@ dependencies = [
"kurbo 0.13.1",
"log",
"skrifa 0.47.0",
"subsetter",
"vectorcraft-doc",
"vectorcraft-geom",
]

View File

@ -96,6 +96,22 @@ pub fn painted_box(bounds: Rect) -> Option<Rect> {
(side > 1e-9 && side.is_finite()).then(|| Rect::from_center_size(b.center(), (b.width().max(side * 1e-3), b.height().max(side * 1e-3))))
}
/// Device pixels per cell of the grid a freeform gradient is sampled on.
const CELL_PX: f64 = 4.0;
/// Fewest and most cells along the painted box's longer side.
const MIN_CELLS: f64 = 8.0;
const MAX_CELLS: f64 = 256.0;
/// The grid (columns, rows) a freeform gradient on box `b` (see [`painted_box`]) is sampled on
/// when the box's longer side spans `device` pixels: a cell per few pixels, in power-of-two steps
/// (so zooming reuses grids), 8 to 256 cells along the longer side.
pub fn grid_size(b: Rect, device: f64) -> (u16, u16) {
let side = b.width().max(b.height());
let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS);
let along = |len: f64| (cells * len / side).ceil().clamp(2.0, MAX_CELLS) as u16;
(along(b.width()), along(b.height()))
}
/// Where a point lies nearest on a line (see [`Freeform::nearest_on_lines`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LineHit {

View File

@ -21,9 +21,16 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
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.
// Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes.
let data = |bytes: &[u8]| match f.id {
"svg" => json!({ "text": String::from_utf8_lossy(bytes) }),
"svg" => {
let text = vectorcraft_svg::text_of(bytes).unwrap_or_else(|_| String::from_utf8_lossy(bytes));
let mut v = json!({ "text": text });
if text.as_bytes() != bytes {
v["dataBase64"] = json!(vectorcraft_format::base64_encode(bytes));
}
v
}
_ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }),
};
let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.warnings }));

View File

@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
has_doc,
export::serialize
),
@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), 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), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), 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), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@ -698,4 +698,6 @@ mod tests_text;
#[cfg(test)]
mod tests_dxf;
#[cfg(test)]
mod tests_svgenc;
#[cfg(test)]
mod tests_svgimport;

View File

@ -72,6 +72,24 @@ pub const OPTIONS: &[FormatOption] = &[
default: "false",
description: "keep hidden layers, not displayed (display:none); document.save sets it unless given",
},
FormatOption {
name: "encoding",
ty: "string",
default: "\"utf8\"",
description: "utf8 | utf16 (big-endian, with a byte order mark) | latin1 (ISO 8859-1; other characters as &#x…; references)",
},
FormatOption {
name: "profile",
ty: "string",
default: "\"svg11\"",
description: "svg11 (SVG 1.1) | tiny12 (SVG Tiny 1.2, simplified: presentation attributes only; no filters, masks, symbols, blend modes or embedded fonts)",
},
FormatOption {
name: "embedFonts",
ty: "boolean",
default: "false",
description: "embed the fonts type uses as @font-face, subset to the characters used (whole when the font's licence forbids subsetting, left out with a warning when it forbids embedding)",
},
FormatOption {
name: "svg",
ty: "object",
@ -134,7 +152,8 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
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());
let mut out = vectorcraft_svg::export_full(doc, &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
let bytes = out.take_bytes();
for l in out.linked {
if !enc.linked.iter().any(|e| e.name == l.name) {
enc.linked.push(l);
@ -145,7 +164,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
enc.warnings.push(w);
}
}
enc.files.push((artboard, if compressed { vectorcraft_svg::compress(&out.svg) } else { out.svg.into_bytes() }));
enc.files.push((artboard, if compressed { vectorcraft_svg::compress_bytes(&bytes) } else { bytes }));
}
Ok(enc)
}

View File

@ -0,0 +1,51 @@
//! SVG encodings, profiles and embedded fonts through the engine: the options reach the file,
//! files in each encoding open again, and `document.serialize` reads them as text.
use serde_json::json;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Grüße €"})).unwrap();
s
}
fn serialize(s: &mut Session, svg: serde_json::Value) -> serde_json::Value {
s.execute("document.serialize", &json!({"format": "svg", "svg": svg})).unwrap()
}
#[test]
fn each_encoding_reopens_and_serializes_as_text() {
let mut s = session();
for (encoding, name, bom) in [("utf8", "UTF-8", false), ("utf16", "UTF-16", true), ("latin1", "ISO-8859-1", false)] {
let r = serialize(&mut s, json!({"encoding": encoding}));
let text = r["text"].as_str().unwrap();
assert!(text.contains("Grüße") && text.contains(&format!("encoding=\"{name}\"")), "{text}");
let bytes = match r.get("dataBase64") {
Some(b) => vectorcraft_format::base64_decode(b.as_str().unwrap()).unwrap(),
None => text.as_bytes().to_vec(),
};
assert_eq!(bom, bytes.starts_with(&[0xfe, 0xff]), "{encoding}");
assert_eq!(r.get("dataBase64").is_some(), encoding != "utf8", "{encoding}");
for (file, data) in [("t.svg", bytes.clone()), ("t.svgz", vectorcraft_svg::compress_bytes(&bytes))] {
let mut o = Session::new();
o.execute("document.open", &json!({"name": file, "dataBase64": vectorcraft_format::base64_encode(&data)})).unwrap();
let plain = o.execute("document.inspect", &json!({})).unwrap().to_string();
assert!(plain.contains("Grüße €"), "{encoding} {file}: {plain}");
}
}
}
#[test]
fn profile_and_embedded_fonts_reach_the_writer() {
let mut s = session();
let tiny = serialize(&mut s, json!({"profile": "tiny12", "styling": "css"}));
let tiny = tiny["text"].as_str().unwrap();
assert!(tiny.contains("baseProfile=\"tiny\"") && !tiny.contains("<style>"), "{tiny}");
let fonts = serialize(&mut s, json!({"embedFonts": true}));
assert!(fonts["text"].as_str().unwrap().contains("@font-face{"));
assert!(s.execute("document.serialize", &json!({"format": "svg", "svg": {"profile": "svg2"}})).is_err(), "unknown profiles are refused");
assert!(s.execute("document.serialize", &json!({"format": "svg", "encoding": "ebcdic"})).is_err(), "unknown encodings are refused");
}

View File

@ -11,7 +11,7 @@ use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use vectorcraft_color::freeform::{painted_box, spread_scale};
use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale};
use vectorcraft_color::{Color, Freeform, GradientPaint};
use vectorcraft_geom::{Affine, Rect};
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
@ -19,11 +19,6 @@ use vello_cpu::{Pixmap, RenderContext};
use crate::ink::Ink;
/// Device pixels per grid cell.
const CELL_PX: f64 = 4.0;
/// Fewest and most cells along the box's longer side.
const MIN_CELLS: f64 = 8.0;
const MAX_CELLS: f64 = 256.0;
/// Grids kept before the cache starts over.
const MAX_GRIDS: usize = 128;
@ -69,19 +64,12 @@ pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -
Pixmap::from_parts(data, cols, rows)
}
/// Cells along a side `len` (document units) long whose box's longer side `side` gets `cells`.
fn cells_along(len: f64, side: f64, cells: f64) -> u16 {
(cells * len / side).ceil().clamp(2.0, MAX_CELLS) as u16
}
/// Set freeform gradient `g` on box `bounds` (in the paint's space, which the context's current
/// transform maps to pixels) as the context paint. Returns false for an empty box.
pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool {
let Some(b) = painted_box(bounds) else { return false };
let side = b.width().max(b.height());
let device = side * ctx.transform().determinant().abs().sqrt();
let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS);
let (cols, rows) = (cells_along(b.width(), side, cells), cells_along(b.height(), side, cells));
let device = b.width().max(b.height()) * ctx.transform().determinant().abs().sqrt();
let (cols, rows) = grid_size(b, device);
let f = g.freeform_on(b);
let k = key(&f, b, cols, rows, ink);
let pm = GRIDS.with(|c| c.borrow().get(&k).cloned()).unwrap_or_else(|| {

View File

@ -28,6 +28,8 @@ image = { workspace = true }
svgtypes = "0.16"
[dev-dependencies]
# Reading embedded font subsets back.
skrifa = { workspace = true }
resvg = { version = "0.45", default-features = false }
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }

View File

@ -1,16 +1,23 @@
//! The SVG writer.
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fmt::Write as _;
use std::sync::Arc;
use vectorcraft_color::{BlendMode, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind, TextObject,
};
use vectorcraft_doc::{CharStyle, Justify};
use vectorcraft_doc::{CharStyle, ImageBlob, Justify};
use vectorcraft_effects::stroke::{self, Written, WrittenShape};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use crate::{EDITING_NS, ExportOptions, ImageMode, LinkedImage, ObjectIds, Output, Styling, base64_encode, body_hash, fmt_num, fnv1a, xml_escape};
use vectorcraft_text::{FontDb, FontFace};
use crate::{
EDITING_NS, Encoding, ExportOptions, ImageMode, LinkedImage, ObjectIds, Output, Profile, Styling, base64_encode, body_hash, fmt_num, fnv1a,
xml_escape,
};
type Props = Vec<(&'static str, String)>;
@ -59,6 +66,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
instance_xf: Affine::IDENTITY,
symbols: HashMap::new(),
symbol_nest: 0,
fonts: Vec::new(),
};
w.assign_name_ids();
// Page Isolated Blending / Page Knockout Group: the page content is one isolated group.
@ -76,8 +84,9 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
let nl = if opts.minify { "" } else { "\n" };
let mut out = String::new();
if !opts.minify {
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
// A file in neither UTF-8 nor UTF-16 must name its encoding.
if !opts.minify || opts.encoding == Encoding::Latin1 {
out.push_str(&format!("<?xml version=\"1.0\" encoding=\"{}\"?>{nl}", opts.encoding.xml_name()));
}
if opts.styling == Styling::StyleEntities && !w.classes.is_empty() {
out.push_str("<!DOCTYPE svg [");
@ -88,19 +97,24 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
}
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 w.tiny() {
out.push_str(" version=\"1.2\" baseProfile=\"tiny\"");
}
if !opts.responsive {
out.push_str(&format!(" width=\"{ww}\" height=\"{hh}\""));
}
out.push_str(&format!(" viewBox=\"0 0 {ww} {hh}\">{nl}"));
let css = opts.styling == Styling::InternalCss && !w.classes.is_empty();
if !w.defs.is_empty() || css {
let fonts = w.font_faces();
if !w.defs.is_empty() || css || !fonts.is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("<defs>{nl}"));
if css {
if css || !fonts.is_empty() {
out.push_str(&w.indent(2));
out.push_str("<style>");
for (i, c) in w.classes.iter().enumerate() {
out.push_str(&format!("{nl}{}.{id_prefix}cls-{}{{{}}}", w.indent(3), i + 1, xml_escape(c)));
let classes = w.classes.iter().enumerate().filter(|_| css).map(|(i, c)| format!(".{id_prefix}cls-{}{{{c}}}", i + 1));
for rule in classes.chain(fonts) {
out.push_str(&format!("{nl}{}{}", w.indent(3), xml_escape(&rule)));
}
out.push_str(&format!("{nl}{}</style>{nl}", w.indent(2)));
}
@ -117,13 +131,65 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
out.push_str(&w.indent(1));
out.push_str(&format!("<desc>{}</desc>{nl}", xml_escape(&doc.metadata.description)));
}
let latin1 = opts.encoding == Encoding::Latin1;
if latin1 {
latin1_refs(&mut out);
latin1_refs(&mut w.body);
}
// The editing data carries a hash of the markup around its `<metadata>`.
let native = native.filter(|_| opts.preserve_editing).map(|bytes| (bytes, body_hash(&[&out, &w.body, "</svg>", nl])));
w.metadata(&mut out, native);
let mut meta = String::new();
w.metadata(&mut meta, native);
if latin1 {
latin1_refs(&mut meta);
}
out.push_str(&meta);
out.push_str(&w.body);
out.push_str("</svg>");
out.push_str(nl);
Output { svg: out, linked: w.linked, warnings: w.warnings }
Output { svg: out, linked: w.linked, warnings: w.warnings, encoding: opts.encoding }
}
/// Write every character of `s` beyond ISO 8859-1 as a character reference (all of them sit in
/// attribute values, text or style sheets, where references read as the character).
fn latin1_refs(s: &mut String) {
if s.chars().all(|c| u32::from(c) < 0x100) {
return;
}
let mut o = String::with_capacity(s.len() + 64);
for c in s.chars() {
if u32::from(c) < 0x100 {
o.push(c);
} else {
let _ = write!(o, "&#x{:X};", u32::from(c));
}
}
*s = o;
}
/// The weight and italic of the face style `st` asks for: what embedded fonts' `@font-face`
/// rules and the characters using them say, so each face is told apart (Semibold from Bold).
fn font_descriptor(st: &CharStyle) -> (u16, bool) {
let fs = st.font_style.to_ascii_lowercase();
(vectorcraft_text::style_weight(&st.font_style).round().clamp(1.0, 1000.0) as u16, fs.contains("italic") || fs.contains("oblique"))
}
/// `chars` (sorted) as a CSS `unicode-range` (`U+41-5A,U+61`).
fn unicode_range(chars: &BTreeSet<char>) -> String {
let mut runs: Vec<(u32, u32)> = Vec::new();
for c in chars.iter().map(|c| u32::from(*c)) {
match runs.last_mut() {
Some((_, end)) if c == *end + 1 => *end = c,
_ => runs.push((c, c)),
}
}
let one = |(a, b): (u32, u32)| if a == b { format!("U+{a:X}") } else { format!("U+{a:X}-{b:X}") };
runs.into_iter().map(one).collect::<Vec<_>>().join(",")
}
/// A CSS string.
fn css_string(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
/// A CSS declaration block as the literal value of an `<!ENTITY>`: quotes, `%` and markup
@ -191,6 +257,18 @@ struct Writer<'a> {
/// Symbols being written inside one another (instances nested deeper are left out: a symbol
/// can't contain itself).
symbol_nest: u32,
/// The characters type uses from each face ([`ExportOptions::embed_fonts`]).
fonts: Vec<FontUse>,
}
/// The characters type uses from one face, under one `@font-face` description: the family the
/// type names and the weight and italic its style asks for.
struct FontUse {
family: String,
weight: u16,
italic: bool,
face: Arc<FontFace>,
chars: BTreeSet<char>,
}
/// A symbol's `<symbol>` def and the instance transforms that can `<use>` it.
@ -320,6 +398,8 @@ fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> {
terms
}
/// What SVG Tiny 1.2 does without masks.
const TINY_MASKS: &str = "SVG Tiny 1.2 has no masks: opacity masks are left out and knockout groups written as plain groups";
/// A region covering any artwork (mask and filter extents).
const BIG: &str = "x=\"-100000\" y=\"-100000\" width=\"200000\" height=\"200000\"";
/// Attributes of every `<mask>`: user-space units, and luminance taken from the sRGB values (as the
@ -360,6 +440,14 @@ fn sanitize_id(name: &str) -> String {
}
impl Writer<'_> {
/// Writing the SVG Tiny 1.2 profile?
fn tiny(&self) -> bool {
self.opts.profile == Profile::Tiny12
}
/// Are the fonts type uses embedded?
fn embeds_fonts(&self) -> bool {
self.opts.embed_fonts && !self.opts.outline_text && !self.tiny()
}
fn num(&self, v: f64) -> String {
fmt_num(v, self.opts.decimals)
}
@ -444,14 +532,20 @@ impl Writer<'_> {
return String::new();
}
let css = |ps: &[&(&str, String)]| ps.iter().map(|(k, v)| format!("{k}:{v}")).collect::<Vec<_>>().join(";");
match self.opts.styling {
// SVG Tiny has presentation attributes alone.
let styling = if self.tiny() { Styling::PresentationAttributes } else { self.opts.styling };
match styling {
Styling::PresentationAttributes => {
// CSS-only properties go in a style attribute.
// CSS-only properties go in a style attribute (SVG Tiny has none: left out).
let (style, attrs): (Vec<_>, Vec<_>) =
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))));
if self.tiny() {
self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation and font features are left out");
} else {
s.push_str(&format!(" style=\"{}\"", xml_escape(&css(&style))));
}
}
s
}
@ -618,6 +712,10 @@ impl Writer<'_> {
/// 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 {
if g.gradient.kind == GradientKind::Freeform {
// Object fills and strokes are images (see `raster_paint`); characters' paints aren't.
self.warn("freeform gradients on characters are written as linear gradients");
}
let mut geom = g.resolve(bounds);
geom.transform(self.xf, g.gradient.kind);
let (s, e) = (geom.start, geom.end);
@ -719,13 +817,7 @@ impl Writer<'_> {
if stroke::is_plain(st) {
return StrokePlan::Written(stroke::for_writer(&BezPath::new(), st));
}
let bp = bp.get_or_insert_with(|| {
let mut bp = BezPath::new();
for p in paths {
bp.extend(p.to_bezpath());
}
bp
});
let bp = bp.get_or_insert_with(|| doc_path(paths));
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
@ -747,6 +839,16 @@ impl Writer<'_> {
self.def(1, "</clipPath>");
format!(" clip-path=\"url(#{cid})\"")
}
Some(StrokeAlign::Outside) if self.tiny() => {
// No masks: clipped by everything around the shape instead.
let cid = self.fresh_id("clip-path");
let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0);
let (x0, y0, x1, y1) = (self.num(b.x0), self.num(b.y0), self.num(b.x1), self.num(b.y1));
self.def(1, &format!("<clipPath id=\"{cid}\">"));
self.def(2, &format!("<path d=\"M{x0} {y0} H{x1} V{y1} H{x0} Z {d}\" clip-rule=\"evenodd\"/>"));
self.def(1, "</clipPath>");
format!(" clip-path=\"url(#{cid})\"")
}
Some(StrokeAlign::Outside) => {
let mid = self.fresh_id("mask");
let b = self.xf.transform_rect_bbox(reach).inflate(1.0, 1.0);
@ -790,6 +892,7 @@ impl Writer<'_> {
&& strokes <= 1
&& !items.iter().any(|i| has_raster(i.effects()))
&& items.iter().all(|i| i.blend() == BlendMode::Normal)
&& !items.iter().any(|i| freeform(i.paint()).is_some())
&& plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None })));
if simple {
let mut p = Props::new();
@ -822,12 +925,17 @@ impl Writer<'_> {
let mut p = Props::new();
match (it, plan) {
(AppearanceItem::Fill(f), _) => {
self.fill_props(f, rule, bounds, &mut p);
if f.blend != BlendMode::Normal {
p.push(("mix-blend-mode", blend_css(f.blend).into()));
let blend = (f.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(f.blend).to_string()));
if let (Some(g), Some(b)) = (freeform(&f.paint), bounds) {
p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3))));
p.extend(blend);
self.raster_paint(g, b, &[d.to_string()], rule, &p, "");
} else {
self.fill_props(f, rule, bounds, &mut p);
p.extend(blend);
let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}/>"));
}
let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}/>"));
}
(AppearanceItem::Stroke(s), Some(StrokePlan::Brush(art))) => {
// The brush art takes the stroke's opacity and blend mode as a group.
@ -849,23 +957,39 @@ impl Writer<'_> {
(AppearanceItem::Stroke(s), Some(StrokePlan::Written(w))) => {
let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default()));
let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string()));
match &w.shape {
WrittenShape::Stroke { width } => {
let opacity = (s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3)));
match (&w.shape, freeform(&s.paint).zip(paint_bounds(s))) {
(WrittenShape::Stroke { width }, Some((g, b))) => {
// The stroke's outline clips the gradient's image.
let bp = bp.get_or_insert_with(|| doc_path(paths));
let outline = stroke::line_outline(bp, s, *width, OUTLINE_TOLERANCE);
let clip = self.path_d(&PathData::from_bezpath(&outline), self.xf);
p.extend(opacity);
p.extend(blend);
self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side);
}
(WrittenShape::Fill(outlines), Some((g, b))) if s.path_gradient().is_none() => {
let clip: Vec<String> = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect();
p.extend(opacity);
p.extend(blend);
self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side);
}
(WrittenShape::Stroke { width }, _) => {
p.push(("fill", "none".into()));
self.stroke_props(s, *width, paint_bounds(s), &mut p);
p.extend(blend);
let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}{side}/>"));
}
WrittenShape::Fill(outlines) if s.path_gradient().is_some() => {
(WrittenShape::Fill(outlines), _) if s.path_gradient().is_some() => {
// A gradient along or across the stroke: slices clipped to its outlines.
if let Some(ws) = bp.as_ref().and_then(|bp| stroke::written_slices(bp, rule, s, outlines)) {
p.extend((s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3))));
p.extend(opacity);
p.extend(blend);
self.sliced(&ws, &p, &side);
}
}
WrittenShape::Fill(outlines) => {
(WrittenShape::Fill(outlines), _) => {
let paint = self.paint(&s.paint, paint_bounds(s));
let ds: Vec<String> = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect();
let opacity = (s.opacity < 1.0).then(|| fmt_num(s.opacity as f64, 3));
@ -935,6 +1059,12 @@ impl Writer<'_> {
/// An object with an opacity mask: `<g mask="url(#…)">` around the unmasked object. The mask
/// art goes in `<defs>`; no-clip adds a white backdrop and invert a colour-inverting filter.
fn masked(&mut self, n: &Node, m: &vectorcraft_doc::OpacityMask) {
if self.tiny() {
self.warn(TINY_MASKS);
let mut bare = n.clone();
bare.mask = None;
return self.node_body(&bare);
}
let mid = self.fresh_id("mask");
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
let knockout = std::mem::take(&mut self.knockout);
@ -1005,7 +1135,10 @@ impl Writer<'_> {
/// drawn through a mask of where the elements above it don't paint (the same look for Normal
/// blending). The masks nest, so element i sits inside the masks of elements i+1…n.
fn children(&mut self, children: &[std::sync::Arc<Node>]) {
if !self.knockout {
if self.knockout && self.tiny() {
self.warn(TINY_MASKS);
}
if !self.knockout || self.tiny() {
for c in children {
self.node(c);
}
@ -1152,18 +1285,10 @@ impl Writer<'_> {
}
NodeKind::Text(t) => self.text_node(n, t),
NodeKind::Image(im) => {
let link = self.opts.images == ImageMode::Link;
let href = match (im.link.as_ref().filter(|_| link), self.doc.images.get(&im.key)) {
let (doc, link) = (self.doc, self.opts.images == ImageMode::Link);
let href = match (im.link.as_ref().filter(|_| link), doc.images.get(&im.key)) {
(Some(l), _) => l.path.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)),
(None, Some(b)) if !b.bytes.is_empty() => self.blob_href(b),
_ => match &im.link {
Some(l) => l.path.clone(),
None => return,
@ -1223,11 +1348,76 @@ impl Writer<'_> {
}
}
/// The `href` of image bytes `b`: a `data:` URI, or with [`ImageMode::Link`] a file written
/// next to the SVG, named after the bytes (image keys such as "raster-1" repeat across
/// documents).
fn blob_href(&mut self, b: &ImageBlob) -> String {
if self.opts.images != ImageMode::Link {
return format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes));
}
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
}
/// A freeform gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user
/// space), which SVG can't express: an `<image>` of the colour field over its painted box,
/// sampled as the canvas samples it (at twice the box's size in points) and clipped to
/// `clip` (outlines in user space filled by `rule`), in a group with `props` and the `side`
/// attribute. Nothing is written for an empty box.
fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str) {
use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale};
let Some(b) = painted_box(bounds) else { return };
let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height()));
let field = g.freeform_on(b).field(spread_scale(b));
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let rgba: Vec<u8> = field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect();
let mut png = Vec::new();
let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return };
if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
return;
}
self.warn("freeform gradients are written as images clipped to their shapes");
let href = self.blob_href(&ImageBlob::new("image/png", png));
let cid = self.fresh_id("clip-path");
let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
self.def(1, &format!("<clipPath id=\"{cid}\">"));
for d in clip {
self.def(2, &format!("<path d=\"{d}\"{r}/>"));
}
self.def(1, "</clipPath>");
let a = self.attrs(props);
let nested = !side.is_empty();
if nested {
self.line(&format!("<g{a}{side}>"));
self.depth += 1;
self.line(&format!("<g clip-path=\"url(#{cid})\">"));
} else {
self.line(&format!("<g clip-path=\"url(#{cid})\"{a}>"));
}
self.depth += 1;
let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
let tr = if self.xf == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(self.xf)) };
self.line(&format!(
"<image x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\"{tr} preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
xml_escape(&href)
));
self.depth -= 1;
self.line("</g>");
if nested {
self.depth -= 1;
self.line("</g>");
}
}
/// The id of the `<symbol>` def instance `inst` (transform `xf`) can `<use>`, written on first
/// use. `None` when the instance needs its own copy of the art: stained by its fill, or moved
/// by a transform under which the def would not look as the canvas paints it ([`Reuse`]).
fn symbol_use(&mut self, sym: &vectorcraft_doc::Symbol, inst: &Node, xf: Affine) -> Option<String> {
if vectorcraft_brush::stain(inst).is_some() {
// SVG Tiny has no `<symbol>`.
if self.tiny() || vectorcraft_brush::stain(inst).is_some() {
return None;
}
if !self.symbols.contains_key(&sym.name) {
@ -1276,6 +1466,12 @@ impl Writer<'_> {
fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize {
use vectorcraft_effects::RasterFx;
let fx = vectorcraft_effects::raster_effects(effects);
if self.tiny() {
if !fx.is_empty() {
self.warn("SVG Tiny 1.2 has no filters: shadows, glows, blurs and feathers are left out");
}
return 0;
}
let reach: f64 = fx.iter().map(RasterFx::outset).sum::<f64>() + 2.0;
let region = n.visual_bounds().map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
for f in fx.iter().rev() {
@ -1362,10 +1558,16 @@ impl Writer<'_> {
// 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") {
let (weight, italic) = font_descriptor(st);
if self.embeds_fonts() {
// Embedded faces are told apart by their weight.
if weight != 400 {
p.push(("font-weight", weight.to_string()));
}
} else if fs.contains("bold") || fs.contains("black") || fs.contains("heavy") {
p.push(("font-weight", "bold".into()));
}
if fs.contains("italic") || fs.contains("oblique") {
if italic {
p.push(("font-style", "italic".into()));
}
// Character paints resolve in the text's space (`to_user` maps it into user space).
@ -1483,6 +1685,7 @@ impl Writer<'_> {
return self.text_on_path(n, t, path, *start);
}
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
self.note_fonts(t, &lay);
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 {
@ -1568,6 +1771,75 @@ impl Writer<'_> {
}
}
/// Record the characters of laid-out type `t` under the faces that supply them, for
/// [`Self::font_faces`] (when fonts are embedded).
fn note_fonts(&mut self, t: &TextObject, lay: &vectorcraft_text::TextLayout) {
if !self.opts.embed_fonts || self.opts.outline_text {
return;
}
if self.tiny() {
return self.warn("SVG Tiny 1.2 has no web fonts: fonts are not embedded");
}
let db = FontDb::global();
let plain = t.plain_text();
let mut face: Option<Arc<FontFace>> = None;
for g in &lay.glyphs {
let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue };
if face.as_ref().is_none_or(|f| f.id() != g.font_id) {
face = db.face_by_id(g.font_id);
}
let Some(f) = &face else { continue };
let (weight, italic) = font_descriptor(st);
let at = self.fonts.iter().position(|u| u.face.id() == f.id() && (u.weight, u.italic) == (weight, italic) && u.family == st.font_family);
let at = at.unwrap_or_else(|| {
let family = st.font_family.clone();
self.fonts.push(FontUse { family, weight, italic, face: f.clone(), chars: BTreeSet::new() });
self.fonts.len() - 1
});
let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue };
// As written: a hyphen where a line breaks, capitals for All Caps.
let src = if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") };
for c in src.chars().filter(|c| !c.is_control() && *c != '\u{ad}') {
if st.all_caps {
chars.extend(c.to_uppercase());
} else {
chars.insert(c);
}
}
}
}
/// The `@font-face` rules of the faces type uses ([`ExportOptions::embed_fonts`]): each face
/// subset to its characters (whole when its licence forbids subsetting, left out with a
/// warning when it forbids embedding). Faces sharing a description cover their own
/// characters (`unicode-range`).
fn font_faces(&mut self) -> Vec<String> {
let uses = std::mem::take(&mut self.fonts);
let mut rules = Vec::new();
for u in &uses {
let chars: Vec<char> = u.chars.iter().copied().collect();
let name = format!("{} {}", u.face.family, u.face.style);
let Some(font) = u.face.embed(&chars) else {
self.warn(&format!("the licence of the font {name} doesn't allow embedding: viewers show its type in their own fonts"));
continue;
};
if !font.subset {
self.warn(&format!("the font {name} is embedded whole: its licence doesn't allow subsetting"));
}
let shared = uses.iter().filter(|o| (&o.family, o.weight, o.italic) == (&u.family, u.weight, u.italic)).count() > 1;
let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() };
let (mime, format) = if font.cff { ("font/otf", "opentype") } else { ("font/ttf", "truetype") };
rules.push(format!(
"@font-face{{font-family:{};font-weight:{};font-style:{};src:url(data:{mime};base64,{}) format(\"{format}\"){range}}}",
css_string(&u.family),
u.weight,
if u.italic { "italic" } else { "normal" },
base64_encode(&font.data)
));
}
rules
}
/// 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);
@ -1576,6 +1848,7 @@ impl Writer<'_> {
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);
self.note_fonts(t, &lay);
let space = (lay.bounds, self.xf * t.xf);
let base = self.char_props(&t.first_style(), space);
let mut props = base.clone();
@ -1622,6 +1895,26 @@ fn initial_value(k: &str) -> Option<&'static str> {
})
}
/// A shape's paths as one path (document space).
fn doc_path(paths: &[&PathData]) -> BezPath {
let mut bp = BezPath::new();
for p in paths {
bp.extend(p.to_bezpath());
}
bp
}
/// How closely stroke outlines that clip an image follow the stroke (points).
const OUTLINE_TOLERANCE: f64 = 0.01;
/// The freeform gradient a paint is, if it is one (SVG has no freeform gradients).
fn freeform(p: &Paint) -> Option<&GradientPaint> {
match p {
Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => Some(g),
_ => None,
}
}
/// Any visible raster effect (shadow, glow, blur, feather) in `effects`?
fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id))

View File

@ -4,7 +4,9 @@
//! [`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; symbols as one
//! `<symbol>` each with a `<use>` per instance; hidden layers left out, or kept not displayed).
//! `<symbol>` each with a `<use>` per instance; hidden layers left out, or kept not displayed),
//! in UTF-8, UTF-16 or ISO 8859-1 ([`Output::bytes`]), as SVG 1.1 or a simplified SVG Tiny 1.2
//! ([`Profile`]), optionally with the fonts type uses embedded as `@font-face` subsets.
//! * [`import`] / [`import_with_report`] parse SVG with `usvg` and convert its normalized tree into
//! document nodes with all transforms baked into the geometry.
//!
@ -22,7 +24,11 @@
//! * **Multiple fills/strokes** (or per-fill/stroke blend modes) are written as a `<g>` holding one
//! `<path>` per appearance item, in paint order.
//! * Live geometry effects are written as their result and raster effects (shadows, glows, blurs)
//! as SVG filters. Freeform gradients export as linear gradients.
//! as SVG filters.
//! * **Freeform gradients**, which SVG can't express, fill or stroke objects as an `<image>` of
//! their colour field (sampled as the canvas samples it) clipped to the shape or the stroke's
//! outline; on characters they are written as linear gradients. Gradients along or across
//! strokes are written as slices of linear gradients clipped to the stroke.
//! * **Symbol instances** the shared `<symbol>` would paint differently from the canvas (stained,
//! scaled with strokes, turned or scaled with effects, brushes or live objects, or holding
//! pattern paints or unlinked masks, which stay on the page) are written as their own art.
@ -122,6 +128,48 @@ pub enum ImageMode {
Link,
}
/// The character encoding of an exported file (SVG Options → Encoding).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Encoding {
#[default]
#[serde(rename = "utf8", alias = "utf-8")]
Utf8,
/// UTF-16, big-endian, after a byte order mark.
#[serde(rename = "utf16", alias = "utf-16")]
Utf16,
/// ISO 8859-1 (Latin-1): other characters are written as character references (`&#x4E2D;`).
#[serde(rename = "latin1", alias = "iso-8859-1")]
Latin1,
}
impl Encoding {
/// The name the XML declaration gives it.
pub fn xml_name(self) -> &'static str {
match self {
Self::Utf8 => "UTF-8",
Self::Utf16 => "UTF-16",
Self::Latin1 => "ISO-8859-1",
}
}
}
/// The SVG profile written (SVG Options → SVG Profile).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Profile {
/// SVG 1.1 (full).
#[default]
#[serde(rename = "svg11", alias = "1.1")]
Svg11,
/// SVG Tiny 1.2, simplified: `version="1.2" baseProfile="tiny"`, presentation attributes only
/// (no style sheets, style attributes, blend modes or CSS-only properties), no filters, masks
/// or `<symbol>`s and no embedded fonts. Raster effects and opacity masks are left out,
/// knockout groups written as plain groups, symbol instances as their own art and strokes
/// aligned outside clipped by the area outside the shape. Clip paths, patterns and type on a
/// path are written as in SVG 1.1.
#[serde(rename = "tiny12", alias = "tiny1.2")]
Tiny12,
}
/// SVG export options (SVG Options). Deserializes from camelCase JSON with every field optional;
/// unknown fields are rejected.
///
@ -156,6 +204,13 @@ pub struct ExportOptions {
pub fewer_tspans: bool,
/// Keep hidden layers, written hidden (`display:none`), as Save does; exports leave them out.
pub hidden_layers: bool,
/// The file's character encoding (see [`Output::bytes`]).
pub encoding: Encoding,
pub profile: Profile,
/// Embed the fonts type uses as `@font-face` rules, each subset to the characters used where
/// its licence allows (whole where it forbids subsetting, left out where it forbids
/// embedding). Not with outlined text or the Tiny profile.
pub embed_fonts: bool,
}
impl Default for ExportOptions {
@ -173,6 +228,9 @@ impl Default for ExportOptions {
metadata: false,
fewer_tspans: false,
hidden_layers: false,
encoding: Encoding::Utf8,
profile: Profile::Svg11,
embed_fonts: false,
}
}
}
@ -200,6 +258,39 @@ pub struct Output {
pub linked: Vec<LinkedImage>,
/// Features the export approximates.
pub warnings: Vec<String>,
/// The encoding [`Self::bytes`] writes.
pub encoding: Encoding,
}
impl Output {
/// The file: the SVG in its encoding (UTF-16 after a byte order mark).
pub fn bytes(&self) -> Vec<u8> {
match self.encoding {
Encoding::Utf8 => self.svg.as_bytes().to_vec(),
e => e.encode_str(&self.svg),
}
}
/// [`Self::bytes`], taking the SVG text (UTF-8 files are the text itself, not a copy).
pub fn take_bytes(&mut self) -> Vec<u8> {
let svg = std::mem::take(&mut self.svg);
match self.encoding {
Encoding::Utf8 => svg.into_bytes(),
e => e.encode_str(&svg),
}
}
}
impl Encoding {
/// `svg` written in this encoding.
fn encode_str(self, svg: &str) -> Vec<u8> {
match self {
Encoding::Utf8 => svg.as_bytes().to_vec(),
Encoding::Utf16 => [0xfe, 0xff].into_iter().chain(svg.encode_utf16().flat_map(u16::to_be_bytes)).collect(),
// The writer left only Latin-1 characters (the others are character references).
Encoding::Latin1 => svg.chars().map(|c| u8::try_from(c).unwrap_or(b'?')).collect(),
}
}
}
/// An image file an exported SVG links to by `name`.
@ -306,22 +397,30 @@ pub fn is_svgz(bytes: &[u8]) -> bool {
/// Compress SVG text as SVGZ (gzip).
pub fn compress(svg: &str) -> Vec<u8> {
compress_bytes(svg.as_bytes())
}
/// Compress an SVG file (in any encoding, see [`Output::bytes`]) as SVGZ (gzip).
pub fn compress_bytes(svg: &[u8]) -> 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());
let _ = gz.write_all(svg);
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.
/// SVG text from SVG or SVGZ bytes: UTF-8, UTF-16 (after a byte order mark) or, when the XML
/// declaration says so, ISO 8859-1.
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);
return match decode(bytes)? {
Decoded::Utf8(skip) => std::str::from_utf8(bytes.get(skip..).unwrap_or_default()).map(Into::into).map_err(|_| not_utf8()),
Decoded::Text(t) => Ok(t.into()),
};
}
let mut raw = Vec::new();
flate2::read::GzDecoder::new(bytes)
@ -331,7 +430,55 @@ pub fn text_of(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, SvgError> {
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()))
match decode(&raw)? {
Decoded::Utf8(skip) => {
raw.drain(..skip);
String::from_utf8(raw).map(Into::into).map_err(|_| not_utf8())
}
Decoded::Text(t) => Ok(t.into()),
}
}
fn not_utf8() -> SvgError {
SvgError::Parse("SVG is not UTF-8, UTF-16 or ISO 8859-1".into())
}
/// How SVG bytes are read.
enum Decoded {
/// UTF-8 after this many bytes (a byte order mark).
Utf8(usize),
/// Text decoded from another encoding.
Text(String),
}
/// The encoding of SVG bytes: a byte order mark (UTF-8 or UTF-16), else the XML declaration's
/// (ISO 8859-1 by its names; anything else is read as UTF-8).
fn decode(bytes: &[u8]) -> Result<Decoded, SvgError> {
let utf16 = |rest: &[u8], unit: fn([u8; 2]) -> u16| {
let (units, odd) = rest.as_chunks::<2>();
if !odd.is_empty() {
return Err(not_utf8());
}
char::decode_utf16(units.iter().map(|u| unit(*u))).collect::<Result<String, _>>().map(Decoded::Text).map_err(|_| not_utf8())
};
match bytes {
[0xef, 0xbb, 0xbf, ..] => Ok(Decoded::Utf8(3)),
[0xfe, 0xff, rest @ ..] => utf16(rest, u16::from_be_bytes),
[0xff, 0xfe, rest @ ..] => utf16(rest, u16::from_le_bytes),
_ if declared_latin1(bytes) => Ok(Decoded::Text(bytes.iter().map(|b| char::from(*b)).collect())),
_ => Ok(Decoded::Utf8(0)),
}
}
/// Does the XML declaration at the start of `bytes` name ISO 8859-1?
fn declared_latin1(bytes: &[u8]) -> bool {
let head = bytes.get(..bytes.len().min(200)).unwrap_or_default();
let Some(decl) = head.strip_prefix(b"<?xml").and_then(|d| d.split(|b| *b == b'>').next()) else { return false };
let decl = String::from_utf8_lossy(decl).to_ascii_lowercase();
let Some(at) = decl.find("encoding") else { return false };
let value = decl.get(at + "encoding".len()..).unwrap_or("").trim_start().trim_start_matches('=').trim_start();
let name = value.trim_start_matches(['"', '\'']).split(['"', '\'', ' ', '?']).next().unwrap_or("");
matches!(name, "iso-8859-1" | "iso8859-1" | "iso_8859-1" | "latin1" | "latin-1" | "l1")
}
/// Import an SVG document.

View File

@ -0,0 +1,273 @@
//! SVG Options → Encoding, SVG Profile and embedded fonts: UTF-16 and ISO 8859-1 files read
//! back, entity styling parses in usvg, SVG Tiny 1.2 has no filters, masks, symbols or style
//! sheets, and embedded fonts hold only the glyphs used where their licence allows it.
// 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 serde_json::json;
use skrifa::raw::TableProvider;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, Node, NodeKind, OpacityMask, Symbol, TextObject, TextRun};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_svg::{Encoding, ExportOptions, Profile, Styling, compress_bytes, editing, export_full, import, text_of};
use vectorcraft_testkit::format::base64_decode;
fn add(d: &mut Document, mut n: Node) {
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap();
}
fn style(family: &str, font_style: &str) -> CharStyle {
CharStyle { size: 24.0, font_family: family.into(), font_style: font_style.into(), fill: Paint::solid(Color::BLACK), ..CharStyle::default() }
}
fn text(at: Point, runs: &[(&str, CharStyle)]) -> Node {
let mut t = TextObject::point(at, "", runs[0].1.clone());
t.runs = runs.iter().map(|(s, st)| TextRun { text: (*s).into(), style: st.clone() }).collect();
Node::new(vectorcraft_doc::NodeId(0), NodeKind::Text(Box::new(t)))
}
/// The line of type [`doc`] holds: Latin-1 characters and others the bundled fonts have.
const LINE: &str = "Grüße — 5 €";
/// A red square with a Chinese name, a Chinese title and a line of type ([`LINE`]).
fn doc() -> Document {
let mut d = Document::new(300.0, 200.0);
d.title = "Grüße 中文".into();
let red = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0);
let mut sq = Node::path(vectorcraft_doc::NodeId(0), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), red);
sq.name = Some("方块".into());
add(&mut d, sq);
add(&mut d, text(Point::new(20.0, 120.0), &[(LINE, style("Source Sans 3", "Regular"))]));
d
}
fn plain(d: &Document) -> String {
let mut out = String::new();
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
out.push_str(&t.plain_text());
}
});
out
}
fn red_fill(d: &Document) -> bool {
let mut red = false;
d.walk(|n| red |= n.appearance.fill_paint() == Paint::solid(Color::rgb8(255, 0, 0)));
red
}
#[test]
fn utf16_files_start_with_a_byte_order_mark_and_read_back() {
let o = export_full(&doc(), &ExportOptions { encoding: Encoding::Utf16, ..Default::default() }, None);
let bytes = o.bytes();
assert_eq!(&bytes[..2], &[0xfe, 0xff], "big-endian byte order mark");
assert_eq!(bytes.len(), 2 + 2 * o.svg.encode_utf16().count());
let svg = text_of(&bytes).unwrap();
assert!(svg.starts_with("<?xml version=\"1.0\" encoding=\"UTF-16\"?>"), "{svg}");
assert!(svg.contains(LINE) && svg.contains("<title>Grüße 中文</title>"), "{svg}");
let back = import(&svg).unwrap();
assert_eq!(plain(&back), LINE);
// Gzipped (SVGZ) too, and little-endian files from elsewhere.
assert_eq!(text_of(&compress_bytes(&bytes)).unwrap(), svg);
let le: Vec<u8> = [0xff, 0xfe].into_iter().chain(svg.encode_utf16().flat_map(u16::to_le_bytes)).collect();
assert_eq!(text_of(&le).unwrap(), svg);
assert!(text_of(&[0xfe, 0xff, 0xd8]).is_err(), "odd UTF-16 is refused");
}
#[test]
fn latin1_files_write_other_characters_as_references() {
let d = doc();
for (minify, styling) in [(false, Styling::PresentationAttributes), (true, Styling::StyleEntities)] {
let o = export_full(
&d,
&ExportOptions { encoding: Encoding::Latin1, minify, styling, preserve_editing: true, ..Default::default() },
Some(b"native"),
);
let bytes = o.bytes();
// The declaration names the encoding even minified; ü is one byte, € and 中 references.
assert!(bytes.starts_with(b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>"), "{}", o.svg);
assert!(bytes.windows(4).any(|w| w == b"Gr\xfc\xdf"), "{}", o.svg);
assert!(o.svg.contains("&#x2014; 5 &#x20AC;") && o.svg.contains("&#x4E2D;&#x6587;"), "{}", o.svg);
assert!(o.svg.chars().all(|c| u32::from(c) < 0x100), "{}", o.svg);
let svg = text_of(&bytes).unwrap();
assert_eq!(svg, o.svg);
let back = import(&svg).unwrap();
assert_eq!(plain(&back), LINE, "{minify}");
assert!(red_fill(&back));
// usvg reads the entities (and their references) too.
let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap();
assert!(tree.root().has_children());
// The editing data's hash covers the file as written.
assert!(editing(&svg).unwrap().intact, "{minify}");
}
}
#[test]
fn style_entities_parse_in_usvg() {
let o = export_full(&doc(), &ExportOptions { styling: Styling::StyleEntities, ..Default::default() }, None);
assert!(o.svg.contains("<!ENTITY st1 ") && o.svg.contains("style=\"&st1;\""), "{}", o.svg);
let tree = usvg::Tree::from_str(&o.svg, &usvg::Options::default()).unwrap();
let mut red = false;
fn walk(g: &usvg::Group, red: &mut bool) {
for n in g.children() {
match n {
usvg::Node::Path(p) => {
*red |= matches!(p.fill().map(|f| f.paint()), Some(usvg::Paint::Color(c)) if (c.red, c.green, c.blue) == (255, 0, 0))
}
usvg::Node::Group(g) => walk(g, red),
_ => {}
}
}
}
walk(tree.root(), &mut red);
assert!(red, "the entity's fill reached usvg: {}", o.svg);
}
#[test]
fn tiny_has_no_filters_masks_symbols_or_style_sheets() {
let mut d = Document::new(300.0, 200.0);
let fill = |r, g, b| Appearance::basic(Paint::solid(Color::rgb8(r, g, b)), Paint::None, 0.0);
// A shadowed square, a masked one in Multiply, a symbol instance and an outside stroke.
let mut a = Node::path(vectorcraft_doc::NodeId(0), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), fill(255, 0, 0));
a.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true });
add(&mut d, a);
let mut b = Node::path(vectorcraft_doc::NodeId(0), shapes::rectangle(Rect::new(80.0, 10.0, 130.0, 60.0)), fill(0, 0, 255));
b.blend = BlendMode::Multiply;
b.mask =
Some(Box::new(OpacityMask::new(Node::path(d.alloc_id(), shapes::ellipse(Rect::new(80.0, 10.0, 130.0, 60.0)), fill(255, 255, 255)), true)));
add(&mut d, b);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-5.0, -5.0, 5.0, 5.0)), fill(0, 128, 0));
d.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) });
add(&mut d, Node::new(vectorcraft_doc::NodeId(0), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((200.0, 30.0)) }));
let mut c = Node::path(
vectorcraft_doc::NodeId(0),
shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 150.0)),
Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 6.0),
);
if let Some(vectorcraft_doc::AppearanceItem::Stroke(s)) = c.appearance.items.get_mut(1) {
s.align = vectorcraft_doc::StrokeAlign::Outside;
}
add(&mut d, c);
let full = export_full(&d, &ExportOptions { styling: Styling::InternalCss, ..Default::default() }, None).svg;
assert!(full.contains("<filter") && full.contains("<mask") && full.contains("<symbol") && full.contains("<style>"), "{full}");
let o =
export_full(&d, &ExportOptions { styling: Styling::InternalCss, profile: Profile::Tiny12, embed_fonts: true, ..Default::default() }, None);
let svg = &o.svg;
assert!(svg.contains("version=\"1.2\" baseProfile=\"tiny\""), "{svg}");
for gone in ["<filter", "<mask", "<symbol", "<style", "style=", "class=", "mix-blend-mode"] {
assert!(!svg.contains(gone), "{gone}: {svg}");
}
assert!(svg.contains("clip-rule=\"evenodd\""), "the outside stroke is clipped outside the shape: {svg}");
for w in ["filters", "masks", "style sheets"] {
assert!(o.warnings.iter().any(|x| x.contains(w)), "{w}: {:?}", o.warnings);
}
let back = import(svg).unwrap();
assert!(red_fill(&back));
assert!(back.symbols.is_empty(), "the instance came back as art");
}
/// The fonts a file embeds: (font-family, weight, the font file).
fn embedded(svg: &str) -> Vec<(String, u16, Vec<u8>)> {
let css = svg.replace("&quot;", "\"");
css.split("@font-face{")
.skip(1)
.map(|r| {
let field = |k: &str| r.split(&format!("{k}:")).nth(1).unwrap().split(';').next().unwrap().to_string();
let data = r.split("base64,").nth(1).unwrap().split(')').next().unwrap();
(field("font-family"), field("font-weight").parse().unwrap(), base64_decode(data).unwrap())
})
.collect()
}
#[test]
fn embedded_fonts_hold_only_the_glyphs_used() {
let mut d = Document::new(300.0, 200.0);
add(&mut d, text(Point::new(20.0, 60.0), &[("Hello ", style("Source Sans 3", "Regular")), ("Bold", style("Source Sans 3", "Semibold"))]));
let svg = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None).svg;
let fonts = embedded(&svg);
assert_eq!(fonts.iter().map(|f| (f.0.as_str(), f.1)).collect::<Vec<_>>(), [("\"Source Sans 3\"", 400), ("\"Source Sans 3\"", 600)], "{svg}");
assert!(svg.contains("font-weight=\"600\"") && svg.contains("format(&quot;truetype&quot;)"), "{svg}");
for (_, w, data) in &fonts {
let f = skrifa::FontRef::new(data).unwrap();
let cmap = f.cmap().unwrap();
let want: &[char] = if *w == 400 { &['H', 'e', 'l', 'o', ' '] } else { &['B', 'o', 'l', 'd'] };
assert!(want.iter().all(|c| cmap.map_codepoint(*c).is_some()), "{w}");
assert!(cmap.map_codepoint('x').is_none(), "{w}: unused characters are left out");
// .notdef and the characters' own glyphs.
let unique = want.iter().collect::<std::collections::BTreeSet<_>>().len();
assert_eq!(f.maxp().unwrap().num_glyphs() as usize, unique + 1, "{w}");
}
// The weights read back as the styles.
let back = import(&svg).unwrap();
let mut styles = vec![];
back.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
styles.extend(t.runs.iter().map(|r| r.style.font_style.to_ascii_lowercase()));
}
});
assert_eq!(styles, ["regular", "semibold"]);
// Without the option, or with outlined text, nothing is embedded.
assert!(!export_full(&d, &ExportOptions::default(), None).svg.contains("@font-face"));
assert!(!export_full(&d, &ExportOptions { embed_fonts: true, outline_text: true, ..Default::default() }, None).svg.contains("@font-face"));
}
/// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original
/// name) with OS/2 `fsType` set to `fs_type`.
fn licensed_font(family: &str, fs_type: u16) -> Vec<u8> {
let mut data = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").to_vec();
let table = |data: &[u8], tag: &[u8; 4]| {
let f = skrifa::FontRef::new(data).unwrap();
let r = f.table_directory.table_records().iter().find(|r| r.tag().to_be_bytes() == *tag).unwrap();
(r.offset() as usize, r.length() as usize)
};
let (os2, _) = table(&data, b"OS/2");
data[os2 + 8..os2 + 10].copy_from_slice(&fs_type.to_be_bytes());
let (name, len) = table(&data, b"name");
assert_eq!(family.len(), "Source Sans 3".len());
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
let mut i = name;
while let Some(at) = data[i..name + len].windows(from.len()).position(|w| w == from) {
data[i + at..i + at + to.len()].copy_from_slice(&to);
i += at + to.len();
}
}
data
}
#[test]
fn font_licences_are_respected() {
let db = vectorcraft_text::FontDb::global();
db.add_font(licensed_font("Sealed Sans 3", 0x0002));
db.add_font(licensed_font("Wholed Sans 3", 0x0100));
assert!(db.has_family("Sealed Sans 3") && db.has_family("Wholed Sans 3"));
let mut d = Document::new(300.0, 200.0);
add(&mut d, text(Point::new(20.0, 60.0), &[("Restricted ", style("Sealed Sans 3", "Regular")), ("whole", style("Wholed Sans 3", "Regular"))]));
let o = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None);
let fonts = embedded(&o.svg);
assert_eq!(fonts.len(), 1, "{:?}", o.warnings);
assert_eq!(fonts[0].0, "\"Wholed Sans 3\"");
let size = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").len();
assert!(fonts[0].2.len() + 64 >= size, "embedded whole: {} of {size} bytes", fonts[0].2.len());
assert!(o.warnings.iter().any(|w| w.contains("Sealed Sans 3") && w.contains("doesn't allow embedding")), "{:?}", o.warnings);
assert!(o.warnings.iter().any(|w| w.contains("Wholed Sans 3") && w.contains("whole")), "{:?}", o.warnings);
}
proptest::proptest! {
/// Reading any bytes as SVG text never panics: byte order marks with odd or unpaired UTF-16,
/// declarations naming encodings, junk.
#[test]
fn any_bytes_decode_or_fail_cleanly(
head in proptest::sample::select(vec![&b""[..], &[0xfe, 0xff][..], &[0xff, 0xfe][..], &[0xef, 0xbb, 0xbf][..], &b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>"[..], &b"<?xml encoding='latin1"[..]]),
body in proptest::collection::vec(proptest::num::u8::ANY, 0..64),
) {
let bytes: Vec<u8> = head.iter().chain(&body).copied().collect();
let _ = text_of(&bytes);
let _ = text_of(&compress_bytes(&bytes));
}
}

View File

@ -0,0 +1,106 @@
//! Paints SVG can't express: freeform gradients fill and stroke objects as an `<image>` of their
//! colour field clipped to the shape (or the stroke's outline), and the SVG reads back looking as
//! the canvas paints it.
// 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::freeform::spread_scale;
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Point, Rect, shapes};
use vectorcraft_svg::{ExportOptions, ImageMode, export_full, import};
use vectorcraft_testkit::format::base64_decode;
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
/// Red at the top left, blue at the bottom right, a half-transparent green at the top right.
fn freeform() -> Paint {
let p = |x, y, c, opacity| FreeformPoint { opacity, spread: 0.1, ..FreeformPoint::new(Point::new(x, y), c) };
let f = Freeform {
points: vec![
p(30.0, 30.0, Color::rgb(1.0, 0.0, 0.0), 1.0),
p(170.0, 130.0, Color::rgb(0.0, 0.0, 1.0), 1.0),
p(170.0, 30.0, Color::rgb(0.0, 1.0, 0.0), 0.5),
],
..Default::default()
};
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(f);
Paint::Gradient(Box::new(g))
}
/// A 200 × 160 document holding `n`.
fn doc_with(mut n: Node) -> Document {
let mut d = Document::new(200.0, 160.0);
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
/// The data of the one embedded PNG.
fn png_of(svg: &str) -> image::RgbaImage {
let data = svg.split("data:image/png;base64,").nth(1).expect("an embedded PNG").split('"').next().unwrap();
image::load_from_memory(&base64_decode(data).unwrap()).unwrap().to_rgba8()
}
#[test]
fn a_freeform_fill_is_an_image_clipped_to_the_shape() {
let shape = shapes::ellipse(Rect::new(20.0, 20.0, 180.0, 140.0));
let d = doc_with(Node::path(NodeId(0), shape, Appearance::basic(freeform(), Paint::None, 0.0)));
let o = export_full(&d, &ExportOptions::default(), None);
let svg = &o.svg;
assert!(!svg.contains("linearGradient"), "{svg}");
assert_eq!(svg.matches("<image ").count(), 1, "{svg}");
assert!(svg.contains("<clipPath") && svg.contains("<g clip-path=\"url(#clip-path-1)\">"), "{svg}");
assert!(o.warnings.iter().any(|w| w.contains("freeform")), "{:?}", o.warnings);
// The image is the colour field over the shape's box, straight alpha: the green point is
// half transparent.
let png = png_of(svg);
let (w, h) = png.dimensions();
assert!((8..=256).contains(&w) && (8..=256).contains(&h), "{w}×{h}");
let b = Rect::new(20.0, 20.0, 180.0, 140.0);
let Paint::Gradient(g) = freeform() else { panic!("a gradient") };
let field = g.freeform.unwrap().field(spread_scale(b));
for (x, y) in [(2, 2), (w / 2, h / 2), (w - 3, 2), (w - 3, h - 3)] {
let at = Point::new(b.x0 + (x as f64 + 0.5) * b.width() / w as f64, b.y0 + (y as f64 + 0.5) * b.height() / h as f64);
let ([r, g, bl], a) = field.sample(at);
let want = [r, g, bl, a].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as i32);
let got = png.get_pixel(x, y).0.map(i32::from);
assert!(want.iter().zip(got).all(|(w, g)| (w - g).abs() <= 1), "({x}, {y}): {got:?} vs {want:?}");
}
// Read back, it looks as the canvas paints the gradient.
let back = import(svg).unwrap();
let mut images = 0;
back.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
assert_eq!(images, 1, "the image comes back (in its clip group)");
assert_similar(&render_artboard(&d), &render_artboard(&back), 12.0, 0.01);
}
#[test]
fn a_freeform_stroke_is_an_image_clipped_to_the_stroke_outline() {
let shape = shapes::rectangle(Rect::new(40.0, 40.0, 160.0, 120.0));
let mut ap = Appearance::basic(Paint::solid(Color::WHITE), freeform(), 16.0);
if let Some(AppearanceItem::Stroke(s)) = ap.items.get_mut(1) {
s.opacity = 0.8;
}
let d = doc_with(Node::path(NodeId(0), shape, ap));
let svg = export_full(&d, &ExportOptions::default(), None).svg;
assert_eq!(svg.matches("<image ").count(), 1, "{svg}");
assert!(svg.contains("opacity=\"0.8\"") && !svg.contains("linearGradient"), "{svg}");
let back = import(&svg).unwrap();
assert_similar(&render_artboard(&d), &render_artboard(&back), 12.0, 0.01);
}
#[test]
fn linked_images_write_the_gradient_next_to_the_svg_and_characters_stay_linear() {
let d = doc_with(Node::path(NodeId(0), shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 120.0)), Appearance::basic(freeform(), Paint::None, 0.0)));
let o = export_full(&d, &ExportOptions { images: ImageMode::Link, ..Default::default() }, None);
assert_eq!(o.linked.len(), 1);
assert!(o.linked[0].name.ends_with(".png") && o.svg.contains(&format!("xlink:href=\"{}\"", o.linked[0].name)), "{}", o.svg);
// Characters keep a (linear) gradient paint, with a warning.
let style = vectorcraft_doc::CharStyle { size: 30.0, fill: freeform(), ..Default::default() };
let t = vectorcraft_doc::TextObject::point(Point::new(20.0, 60.0), "Hi", style);
let o = export_full(&doc_with(Node::new(NodeId(0), NodeKind::Text(Box::new(t)))), &ExportOptions::default(), None);
assert!(o.svg.contains("linearGradient") && !o.svg.contains("<image"), "{}", o.svg);
assert!(o.warnings.iter().any(|w| w.contains("characters")), "{:?}", o.warnings);
}

View File

@ -15,3 +15,5 @@ kurbo = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
log = { workspace = true }
# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer.
subsetter = { version = "0.2", default-features = false }

238
crates/text/src/embed.rs Normal file
View File

@ -0,0 +1,238 @@
//! Fonts carried by exported documents (SVG `@font-face`): what a font's licence allows (its
//! OS/2 `fsType` flags) and font files holding only the characters a document uses.
use std::borrow::Cow;
use skrifa::MetadataProvider;
use skrifa::raw::TableProvider;
use skrifa::raw::types::Tag;
use crate::FontFace;
/// What a font's licence lets a document do with it (OS/2 `fsType`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Embedding {
/// The font may be embedded, as a subset.
Subset,
/// The font may be embedded whole only (no subsetting).
Whole,
/// Restricted licence, or bitmap embedding only: the outlines may not be embedded.
Forbidden,
}
impl Embedding {
/// The permission an OS/2 `fsType` value grants. Bits 0–3 are the usage permissions (none:
/// installable; 2: restricted; 4: preview and print; 8: editable), the most permissive one
/// winning when several are set; bit 8 forbids subsetting and bit 9 allows bitmaps only.
pub fn from_fs_type(fs: u16) -> Self {
let restricted = fs & 0x0002 != 0 && fs & 0x000c == 0;
if restricted || fs & 0x0200 != 0 {
Self::Forbidden
} else if fs & 0x0100 != 0 {
Self::Whole
} else {
Self::Subset
}
}
}
/// A font file a document carries.
#[derive(Clone, Debug)]
pub struct EmbeddedFont {
/// An OpenType file of the one face (`cmap`, `OS/2` and the other tables a viewer needs).
pub data: Vec<u8>,
/// CFF outlines (`font/otf`) rather than TrueType ones (`font/ttf`).
pub cff: bool,
/// Only the glyphs of the characters asked for; false: the whole face, as its licence asks
/// (or when it can't be subset).
pub subset: bool,
}
const CMAP: Tag = Tag::new(b"cmap");
const OS2: Tag = Tag::new(b"OS/2");
const HEAD: Tag = Tag::new(b"head");
impl FontFace {
/// What the face's licence allows (a face without an `OS/2` table states no restriction).
pub fn embedding(&self) -> Embedding {
self.skrifa().and_then(|f| f.os2().ok()).map_or(Embedding::Subset, |os2| Embedding::from_fs_type(os2.fs_type()))
}
/// The face as a font file to embed: the glyphs of `chars` alone when its licence allows
/// subsetting, else the whole face. Ligatures, kerning and other layout features are left out
/// of subsets (viewers draw each character's own glyph). `None` when the licence forbids
/// embedding or the font can't be read.
pub fn embed(&self, chars: &[char]) -> Option<EmbeddedFont> {
let font = self.skrifa()?;
let cff = [b"CFF ", b"CFF2"].iter().any(|t| font.table_data(Tag::new(t)).is_some());
let whole = || Some(EmbeddedFont { data: sfnt(font.table_directory.sfnt_version(), tables(&font))?, cff, subset: false });
match self.embedding() {
Embedding::Forbidden => None,
Embedding::Whole => whole(),
Embedding::Subset => match subset(self, &font, chars) {
Some(data) => Some(EmbeddedFont { data, cff, subset: true }),
// Outlines the subsetter can't read (CFF2): what the licence allows anyway.
None => whole(),
},
}
}
}
/// Every table of `font`, by tag.
fn tables<'a>(font: &skrifa::FontRef<'a>) -> Vec<(Tag, Cow<'a, [u8]>)> {
font.table_directory.table_records().iter().filter_map(|r| font.table_data(r.tag()).map(|d| (r.tag(), Cow::Borrowed(d.as_bytes())))).collect()
}
/// `face` reduced to the glyphs of `chars`, with a `cmap` mapping them and the face's `OS/2`.
fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char]) -> Option<Vec<u8>> {
let charmap = font.charmap();
let mut remapper = subsetter::GlyphRemapper::new();
let mut map: Vec<(u32, u16)> = chars
.iter()
.filter_map(|c| {
let gid = u16::try_from(charmap.map(*c)?.to_u32()).ok().filter(|g| *g != 0)?;
Some((*c as u32, remapper.remap(gid)))
})
.collect();
map.sort_unstable();
map.dedup_by_key(|m| m.0);
let data = subsetter::subset(face.data(), face.index(), &remapper).ok()?;
let sub = skrifa::FontRef::new(&data).ok()?;
let mut t: Vec<(Tag, Cow<'_, [u8]>)> = tables(&sub).into_iter().filter(|(tag, _)| *tag != CMAP && *tag != OS2).collect();
t.push((CMAP, Cow::Owned(cmap(&map)?)));
if let Some(os2) = font.table_data(OS2) {
t.push((OS2, Cow::Owned(os2_for(os2.as_bytes(), &map))));
}
sfnt(sub.table_directory.sfnt_version(), t)
}
/// The face's `OS/2` table with its first and last character fitted to the subset's.
fn os2_for(os2: &[u8], map: &[(u32, u16)]) -> Vec<u8> {
let mut out = os2.to_vec();
let bmp = |c: u32| u16::try_from(c).unwrap_or(u16::MAX).to_be_bytes();
if let (Some(first), Some(last), Some(slot)) = (map.first(), map.last(), out.get_mut(64..68)) {
slot[..2].copy_from_slice(&bmp(first.0));
slot[2..].copy_from_slice(&bmp(last.0));
}
out
}
/// Runs of consecutive characters mapped to consecutive glyphs: (first char, last char, first
/// glyph).
fn runs(map: &[(u32, u16)]) -> Vec<(u32, u32, u16)> {
let mut out: Vec<(u32, u32, u16)> = Vec::new();
for &(c, g) in map {
match out.last_mut() {
Some((start, end, g0)) if c == *end + 1 && u32::from(g) == u32::from(*g0) + (c - *start) => *end = c,
_ => out.push((c, c, g)),
}
}
out
}
/// A `cmap` table mapping `map`'s characters (sorted, unique) to their glyphs: a format 4
/// subtable for the Basic Multilingual Plane and, when characters lie beyond it (or it would be
/// too long), a format 12 one for all of them. `None` when there is nothing to map.
pub(crate) fn cmap(map: &[(u32, u16)]) -> Option<Vec<u8>> {
let all = runs(map);
let last = all.last()?.1;
let mut bmp: Vec<(u32, u32, u16)> = runs(&map.iter().copied().filter(|(c, _)| *c < 0xffff).collect::<Vec<_>>());
// The closing segment every format 4 subtable ends with.
bmp.push((0xffff, 0xffff, 0));
let f4_len = 16 + 8 * bmp.len();
let f4 = f4_len <= usize::from(u16::MAX);
let f12 = !f4 || last >= 0xffff;
let mut subtables: Vec<(u16, Vec<u8>)> = Vec::new();
if f4 {
let seg_x2 = u16::try_from(bmp.len() * 2).ok()?;
let sel = bmp.len().ilog2() as u16;
let range = 2u16.checked_pow(u32::from(sel))?.checked_mul(2)?;
let mut s = Vec::with_capacity(f4_len);
for v in [4, u16::try_from(f4_len).ok()?, 0, seg_x2, range, sel, seg_x2 - range] {
s.extend(v.to_be_bytes());
}
let u = |c: u32| u16::try_from(c).unwrap_or(u16::MAX);
s.extend(bmp.iter().flat_map(|(_, end, _)| u(*end).to_be_bytes()));
s.extend(0u16.to_be_bytes());
s.extend(bmp.iter().flat_map(|(start, _, _)| u(*start).to_be_bytes()));
// The closing segment maps to glyph 0: 0xffff + 1.
s.extend(bmp.iter().flat_map(|(start, end, g)| if *end == 0xffff { 1u16 } else { g.wrapping_sub(u(*start)) }.to_be_bytes()));
s.extend(bmp.iter().flat_map(|_| 0u16.to_be_bytes()));
subtables.push((1, s));
}
if f12 {
let n = u32::try_from(all.len()).ok()?;
let mut s = Vec::with_capacity(16 + 12 * all.len());
s.extend(12u16.to_be_bytes());
s.extend(0u16.to_be_bytes());
for v in [16 + 12 * n, 0, n] {
s.extend(v.to_be_bytes());
}
for (start, end, g) in &all {
for v in [*start, *end, u32::from(*g)] {
s.extend(v.to_be_bytes());
}
}
subtables.push((10, s));
}
// Header and encoding records (Windows platform: 1 = BMP, 10 = full repertoire).
let mut out = Vec::new();
out.extend(0u16.to_be_bytes());
out.extend(u16::try_from(subtables.len()).ok()?.to_be_bytes());
let mut offset = 4 + 8 * subtables.len();
for (encoding, s) in &subtables {
out.extend(3u16.to_be_bytes());
out.extend(encoding.to_be_bytes());
out.extend(u32::try_from(offset).ok()?.to_be_bytes());
offset += s.len();
}
for (_, s) in subtables {
out.extend(s);
}
Some(out)
}
/// The sum of `data` as big-endian 32-bit words (zero-padded), as table directories record it.
fn checksum(data: &[u8]) -> u32 {
let (words, rest) = data.as_chunks::<4>();
let mut last = [0u8; 4];
last[..rest.len()].copy_from_slice(rest);
words.iter().chain([&last]).fold(0u32, |s, w| s.wrapping_add(u32::from_be_bytes(*w)))
}
/// An OpenType file of `tables` (unique tags) with checksums and the `head` checksum adjustment.
fn sfnt(version: u32, mut tables: Vec<(Tag, Cow<'_, [u8]>)>) -> Option<Vec<u8>> {
tables.sort_by_key(|t| t.0);
tables.dedup_by_key(|t| t.0);
let n = u16::try_from(tables.len()).ok().filter(|n| *n > 0)?;
let sel = n.ilog2() as u16;
let range = 2u16.checked_pow(u32::from(sel))?.checked_mul(16)?;
let mut out = Vec::with_capacity(12 + 16 * tables.len() + tables.iter().map(|t| t.1.len() + 3).sum::<usize>());
out.extend(version.to_be_bytes());
for v in [n, range, sel, n.checked_mul(16)? - range] {
out.extend(v.to_be_bytes());
}
let mut offset = 12 + 16 * tables.len();
let mut head_at = None;
for (tag, data) in &mut tables {
if *tag == HEAD {
// The adjustment is summed as zero; it is set once the whole file is written.
data.to_mut().get_mut(8..12)?.fill(0);
head_at = Some(offset + 8);
}
out.extend(tag.to_be_bytes());
out.extend(checksum(data).to_be_bytes());
out.extend(u32::try_from(offset).ok()?.to_be_bytes());
out.extend(u32::try_from(data.len()).ok()?.to_be_bytes());
offset += data.len().next_multiple_of(4);
}
for (_, data) in &tables {
out.extend_from_slice(data);
out.resize(out.len().next_multiple_of(4), 0);
}
if let Some(at) = head_at {
let adjust = 0xb1b0_afba_u32.wrapping_sub(checksum(&out));
out.get_mut(at..at + 4)?.copy_from_slice(&adjust.to_be_bytes());
}
Some(out)
}

View File

@ -77,6 +77,10 @@ impl FontFace {
pub(crate) fn hb(&self) -> Option<harfrust::FontRef<'_>> {
harfrust::FontRef::from_index(self.data(), self.index).ok()
}
/// The face's index in its font file (collections hold several).
pub(crate) fn index(&self) -> u32 {
self.index
}
/// Unique id of this face within the process.
pub fn id(&self) -> u32 {
self.id
@ -243,8 +247,8 @@ fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
}
/// Weight implied by a style name.
fn style_weight(style: &str) -> f32 {
/// Weight implied by a style name (400 = regular).
pub fn style_weight(style: &str) -> f32 {
let s = norm(style);
const TABLE: &[(&str, f32)] = &[
("extralight", 200.0),
@ -441,6 +445,11 @@ impl FontDb {
self.find(FALLBACK_FAMILY, style).or_else(|| self.find(FALLBACK_FAMILY, "Regular")).or_else(|| self.read_faces().first().cloned())
}
/// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from).
pub fn face_by_id(&self, id: u32) -> Option<Arc<FontFace>> {
self.read_faces().iter().find(|f| f.id == id).cloned()
}
/// Is `family` available (loaded)?
pub fn has_family(&self, family: &str) -> bool {
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))

View File

@ -11,6 +11,7 @@
mod composer;
pub mod edit;
pub mod embed;
mod features;
mod fontdb;
pub mod hyphen;
@ -19,7 +20,7 @@ mod shape;
pub mod thread;
pub use features::OtFeatures;
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace};
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{layout, layout_with};
pub use vectorcraft_doc::TextObject;
@ -353,6 +354,8 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_embed;
#[cfg(test)]
mod tests_scripts;
#[cfg(test)]
mod tests_snap;

View File

@ -0,0 +1,93 @@
//! Embedded fonts: licence flags and subsets a viewer can use.
use skrifa::MetadataProvider;
use skrifa::instance::{LocationRef, Size};
use skrifa::raw::types::Tag;
use skrifa::raw::{FontData, FontRead, TableProvider};
use super::embed::Embedding;
use super::*;
#[test]
fn fs_type_flags_decide_the_embedding() {
assert_eq!(Embedding::from_fs_type(0), Embedding::Subset, "installable");
assert_eq!(Embedding::from_fs_type(0x0004), Embedding::Subset, "preview & print");
assert_eq!(Embedding::from_fs_type(0x0008), Embedding::Subset, "editable");
assert_eq!(Embedding::from_fs_type(0x0002), Embedding::Forbidden, "restricted");
assert_eq!(Embedding::from_fs_type(0x0006), Embedding::Subset, "the most permissive bit wins");
assert_eq!(Embedding::from_fs_type(0x0200), Embedding::Forbidden, "bitmaps only");
assert_eq!(Embedding::from_fs_type(0x0108), Embedding::Whole, "no subsetting");
}
#[test]
fn a_subset_holds_only_the_glyphs_used_and_maps_their_characters() {
let face = FontDb::global().face("Source Sans 3", "Regular").unwrap();
assert_eq!(face.embedding(), Embedding::Subset);
let e = face.embed(&['H', 'e', 'l', 'l', 'o', ' ']).unwrap();
assert!(e.subset && !e.cff);
assert!(e.data.len() * 20 < face.data().len(), "{} of {} bytes", e.data.len(), face.data().len());
let f = skrifa::FontRef::new(&e.data).unwrap();
// .notdef and the five characters' glyphs.
assert_eq!(f.maxp().unwrap().num_glyphs(), 6);
let map = f.charmap();
let metrics = f.glyph_metrics(Size::unscaled(), LocationRef::default());
for c in ['H', 'e', 'l', 'o', ' '] {
let g = map.map(c).unwrap_or_else(|| panic!("{c} mapped"));
assert_eq!(metrics.advance_width(g).unwrap() as f64, face.advance(face.glyph_for(c)), "{c}: same advance");
}
assert!(map.map('x').is_none(), "unused characters are left out");
let h = f.outline_glyphs().get(map.map('H').unwrap()).unwrap();
let mut pen = Commands::default();
h.draw(skrifa::outline::DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut pen).unwrap();
assert!(pen.0 > 4, "H keeps its outline");
// The tables a web viewer needs, the licence flags kept and the file checksum right.
for t in [b"cmap", b"OS/2", b"head", b"hhea", b"hmtx", b"maxp", b"name", b"post", b"glyf", b"loca"] {
assert!(f.table_data(Tag::new(t)).is_some(), "{}", String::from_utf8_lossy(t));
}
let os2 = f.os2().unwrap();
assert_eq!(os2.fs_type(), face.skrifa().unwrap().os2().unwrap().fs_type());
assert_eq!((os2.us_first_char_index(), os2.us_last_char_index()), (' ' as u16, 'o' as u16));
let (words, rest) = e.data.as_chunks::<4>();
assert!(rest.is_empty());
assert_eq!(words.iter().fold(0u32, |s, w| s.wrapping_add(u32::from_be_bytes(*w))), 0xb1b0_afba);
}
#[test]
fn cmaps_map_runs_and_characters_beyond_the_basic_plane() {
let face = FontDb::global().face("Inter", "Regular").unwrap();
let e = face.embed(&['A', 'B', 'z']).unwrap();
let f = skrifa::FontRef::new(&e.data).unwrap();
let cmap = f.cmap().unwrap();
let (a, b, z) = (cmap.map_codepoint('A').unwrap(), cmap.map_codepoint('B').unwrap(), cmap.map_codepoint('z').unwrap());
assert!(a != b && b != z && a.to_u32() != 0);
assert_eq!(cmap.map_codepoint('C'), None);
// No bundled face maps characters beyond the BMP: the table is checked on its own.
let raw = embed::cmap(&[(0x41, 1), (0x42, 2), (0x1f600, 3)]).unwrap();
let cmap = skrifa::raw::tables::cmap::Cmap::read(FontData::new(&raw)).unwrap();
assert_eq!(cmap.encoding_records().len(), 2, "format 4 and format 12");
for (c, g) in [('A', 1), ('B', 2), ('\u{1f600}', 3)] {
assert_eq!(cmap.map_codepoint(c).map(|g| g.to_u32()), Some(g), "{c}");
}
assert_eq!(cmap.map_codepoint('C'), None);
assert!(embed::cmap(&[]).is_none());
}
/// Counts outline commands.
#[derive(Default)]
struct Commands(usize);
impl skrifa::outline::OutlinePen for Commands {
fn move_to(&mut self, _: f32, _: f32) {
self.0 += 1;
}
fn line_to(&mut self, _: f32, _: f32) {
self.0 += 1;
}
fn quad_to(&mut self, _: f32, _: f32, _: f32, _: f32) {
self.0 += 1;
}
fn curve_to(&mut self, _: f32, _: f32, _: f32, _: f32, _: f32, _: f32) {
self.0 += 1;
}
fn close(&mut self) {}
}

View File

@ -1,6 +1,6 @@
//! 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
//! SVG Options (Export As SVG, Save As / Save a Copy to .svg): profile, styling, fonts, images,
//! object ids, decimals, encoding, minify, responsive, embedded fonts, 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;
@ -54,6 +54,8 @@ const STYLING: [(&str, &str); 4] = [
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")];
const PROFILES: [(&str, &str); 2] = [("svg11", "SVG 1.1"), ("tiny12", "SVG Tiny 1.2")];
const ENCODINGS: [(&str, &str); 3] = [("utf8", "Unicode (UTF-8)"), ("utf16", "Unicode (UTF-16)"), ("latin1", "ISO 8859-1")];
/// Dialog fields that are not SVG options.
const UI_KEYS: [&str; 5] = ["mode", "path", "showCode", "allArtboards", "range"];
@ -137,14 +139,16 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
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)]) {
/// One labelled choice row: the dropdown sets `d.fields[key]` to the chosen value. `forced`: the
/// value the export uses whatever is chosen (shown, the dropdown disabled).
fn choice<V: Into<Value> + Copy>(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, choices: &[(V, &str)], forced: Option<V>) {
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 current = forced.map(Into::into).or_else(|| 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)) {
let picked = ui.add_enabled_ui(forced.is_none(), |ui| widgets::dropdown(ui, key, shown, &labels, 250.0)).inner;
if let Some((v, _)) = picked.and_then(|i| choices.get(i)) {
d.fields.insert(key.into(), (*v).into());
}
ui.end_row();
@ -181,13 +185,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
/// image is another file.
pub(super) fn option_fields(ui: &mut egui::Ui, d: &mut Dialog, screens: bool) {
let t = Tokens::get(ui.ctx());
// SVG Tiny has presentation attributes alone and no web fonts.
let tiny = d.str("profile") == "tiny12";
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, "profile", "SVG Profile:", &PROFILES, None);
choice(ui, d, "styling", "Styling:", &STYLING, tiny.then_some("presentation"));
choice(ui, d, "outlineText", "Font:", &FONTS, None);
if !screens {
choice(ui, d, "images", "Images:", &IMAGES);
choice(ui, d, "images", "Images:", &IMAGES, None);
}
choice(ui, d, "objectIds", "Object IDs:", &OBJECT_IDS);
choice(ui, d, "objectIds", "Object IDs:", &OBJECT_IDS, None);
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, &[]) {
@ -195,6 +202,7 @@ pub(super) fn option_fields(ui: &mut egui::Ui, d: &mut Dialog, screens: bool) {
d.fields.insert("decimals".into(), json!(v.round().clamp(lo, hi) as u8));
}
ui.end_row();
choice(ui, d, "encoding", "Encoding:", &ENCODINGS, None);
});
ui.add_space(6.0);
ui.horizontal(|ui| {
@ -202,6 +210,7 @@ pub(super) fn option_fields(ui: &mut egui::Ui, d: &mut Dialog, screens: bool) {
ui.add_space(12.0);
check(ui, d, "responsive", "Responsive", true);
});
check(ui, d, "embedFonts", "Embed Fonts (Glyphs Used)", !d.bool("outlineText") && !tiny);
check(ui, d, "fewerTspans", "Fewer <tspan> Elements", !d.bool("outlineText"));
check(ui, d, "metadata", "Include Metadata", true);
}
@ -252,7 +261,13 @@ fn code_view(app: &VectorcraftApp, ui: &mut egui::Ui, d: &Dialog) {
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(),
// Shown as text whatever the file's encoding.
Ok(enc) => enc
.files
.into_iter()
.next()
.map(|(_, b)| vectorcraft_svg::text_of(&b).map_or_else(|_| String::from_utf8_lossy(&b).into_owned(), |t| t.into_owned()))
.unwrap_or_default(),
Err(e) => e.to_string(),
};
if text.len() > MAX {
@ -388,4 +403,34 @@ mod tests {
app.run("file.saveAs", json!({"path": "/tmp/doc.vectorcraft"})).unwrap();
assert!(vectorcraft_format::sniff(&written.borrow()[3].1));
}
#[test]
fn profile_encoding_and_embedded_fonts_reach_the_file() {
let (mut app, written) = app("/tmp/tiny.svg");
app.run("text.create", json!({"x": 10, "y": 60, "text": "Grüße €"})).unwrap();
app.run("file.export.svg", Value::Null).unwrap();
let d = app.ui.dialog.clone().unwrap();
assert_eq!((d.str("profile"), d.str("encoding"), d.bool("embedFonts")), ("svg11".into(), "utf8".into(), false), "the defaults");
let f = &mut app.ui.dialog.as_mut().unwrap().fields;
f.insert("profile".into(), json!("tiny12"));
f.insert("encoding".into(), json!("latin1"));
f.insert("showCode".into(), json!(true));
// Tiny shows Presentation Attributes (disabled) and greys out Embed Fonts; the code view
// reads the Latin-1 file.
frame(&mut app);
super::super::confirm(&mut app).unwrap();
let bytes = written.borrow()[0].1.clone();
assert!(bytes.starts_with(b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?>"));
let svg = vectorcraft_svg::text_of(&bytes).unwrap();
assert!(svg.contains("baseProfile=\"tiny\"") && !svg.contains("<style>") && svg.contains("&#x20AC;"), "{svg}");
// SVG 1.1 with embedded fonts: an @font-face rule for the type.
app.run("file.export.svg", Value::Null).unwrap();
let f = &mut app.ui.dialog.as_mut().unwrap().fields;
f.insert("profile".into(), json!("svg11"));
f.insert("embedFonts".into(), json!(true));
super::super::confirm(&mut app).unwrap();
// Two artboards: two files each time; the type is on the first.
assert_eq!(written.borrow().len(), 4);
assert!(String::from_utf8_lossy(&written.borrow()[2].1).contains("@font-face{"));
}
}

View File

@ -23,7 +23,7 @@
| `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 |
| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab |
| 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 |
| 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`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `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 |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
| `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `<stem>.html` (client-side `<map>`) or `<stem>.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) |

View File

@ -127,7 +127,13 @@ SVG Options: `export` to SVG takes them in `options`, flat or as `{"svg": {…}}
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,
lists every option with its default. `encoding` is `utf8`, `utf16` (big-endian after a byte order mark) or `latin1`
(ISO 8859-1, other characters as `&#x…;` references); `document.serialize` still answers `text`, plus `dataBase64`
(the file) when it isn't UTF-8, and such files open again. `profile: "tiny12"` writes a simplified SVG Tiny 1.2
(presentation attributes only; filters, masks, blend modes and embedded fonts left out, symbols as art, each with
a warning). `embedFonts: true` embeds the fonts type uses as `@font-face` rules subset to the characters used; a
font whose licence (OS/2 `fsType`) forbids subsetting is embedded whole, one that forbids embedding is left out
with a warning, and the type then names faces by numeric `font-weight` (600 for Semibold). `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`).
Symbols export as one `<symbol>` with a `<use>` per instance. An instance the def can't stand for is written as its