mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M4.97: SVG Options choose the encoding, SVG Tiny 1.2 and embedded font subsets
SVG export writes UTF-8, UTF-16 (big-endian with a byte order mark) or ISO 8859-1 (other characters as character references) and SVG/SVGZ open in any of them. The Tiny 1.2 profile (simplified) writes presentation attributes only and leaves out filters, masks, blend modes and symbols with warnings. embedFonts embeds the fonts type uses as @font-face subsets of the characters used, honouring the fonts' fsType (whole when subsetting is forbidden, left out when embedding is). New options: encoding, profile, embedFonts (SVG Options dialog, document.formats); document.serialize also returns dataBase64 for non-UTF-8 files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
eacc83e05b
commit
fa3dcab411
17 files changed
+1138
-46
No files matched your search
Generated
+2
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
+214
-17
@@ -1,6 +1,8 @@
|
||||
//! 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::{
|
||||
@@ -10,7 +12,12 @@ use vectorcraft_doc::{CharStyle, 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
|
||||
}
|
||||
@@ -747,6 +841,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);
|
||||
@@ -935,6 +1039,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 +1115,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);
|
||||
}
|
||||
@@ -1227,7 +1340,8 @@ impl Writer<'_> {
|
||||
/// 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 +1390,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 +1482,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 +1609,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 +1695,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 +1772,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();
|
||||
|
||||
+149
-6
@@ -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.
|
||||
//!
|
||||
@@ -122,6 +124,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 (`中`).
|
||||
#[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 +200,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 +224,9 @@ impl Default for ExportOptions {
|
||||
metadata: false,
|
||||
fewer_tspans: false,
|
||||
hidden_layers: false,
|
||||
encoding: Encoding::Utf8,
|
||||
profile: Profile::Svg11,
|
||||
embed_fonts: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +254,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 +393,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 +426,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.
|
||||
|
||||
@@ -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("— 5 €") && o.svg.contains("中文"), "{}", 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(""", "\"");
|
||||
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("truetype")"), "{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));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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("€"), "{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{"));
|
||||
}
|
||||
}
|
||||
@@ -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`) |
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user