From fa3dcab4110f39ee91d3e471ec4e03aefb5a169e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 13:07:26 +0700 Subject: [PATCH] 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 --- Cargo.lock | 2 + crates/engine/src/cmd/fileio/export.rs | 11 +- crates/engine/src/cmd/fileio/mod.rs | 6 +- crates/engine/src/cmd/fileio/svg.rs | 23 +- crates/engine/src/cmd/fileio/tests_svgenc.rs | 51 ++++ crates/svg/Cargo.toml | 2 + crates/svg/src/export.rs | 231 ++++++++++++++-- crates/svg/src/lib.rs | 155 ++++++++++- crates/svg/tests/encoding_profile_fonts.rs | 273 +++++++++++++++++++ crates/text/Cargo.toml | 2 + crates/text/src/embed.rs | 238 ++++++++++++++++ crates/text/src/fontdb.rs | 13 +- crates/text/src/lib.rs | 5 +- crates/text/src/tests_embed.rs | 93 +++++++ crates/ui-egui/src/dialogs/svg_options.rs | 69 ++++- docs/control-protocol.md | 2 +- docs/mcp.md | 8 +- 17 files changed, 1138 insertions(+), 46 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_svgenc.rs create mode 100644 crates/svg/tests/encoding_profile_fonts.rs create mode 100644 crates/text/src/embed.rs create mode 100644 crates/text/src/tests_embed.rs diff --git a/Cargo.lock b/Cargo.lock index 82ab5494..d5112295 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 67a3fe58..2110d22e 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -21,9 +21,16 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { 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 })); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 8519de28..82383ca0 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "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 { "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 .html / .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 .html / .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; diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 0aea86da..4dc3e970 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -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 Result 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("{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!("{}{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 ``. let native = native.filter(|_| opts.preserve_editing).map(|bytes| (bytes, body_hash(&[&out, &w.body, "", 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(""); 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) -> 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::>().join(",") +} + +/// A CSS string. +fn css_string(s: &str) -> String { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) } /// A CSS declaration block as the literal value of an ``: 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, +} + +/// 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, + chars: BTreeSet, } /// A symbol's `` def and the instance transforms that can `` 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 ``: 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::>().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, ""); 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!("")); + self.def(2, &format!("")); + self.def(1, ""); + 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: `` around the unmasked object. The mask /// art goes in ``; 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]) { - 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 { - if vectorcraft_brush::stain(inst).is_some() { + // SVG Tiny has no ``. + 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::() + 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> = 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 { + let uses = std::mem::take(&mut self.fonts); + let mut rules = Vec::new(); + for u in &uses { + let chars: Vec = 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 `` 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!("")); // The 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(); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index f367555e..74f9c14f 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -4,7 +4,9 @@ //! [`ExportOptions`] (presentation attributes, inline styles, style entities or internal CSS //! classes; gradients in `` with `userSpaceOnUse`; clip groups as ``; images //! embedded as `data:` URIs or linked; text as ``/`` or outlines; symbols as one -//! `` each with a `` per instance; hidden layers left out, or kept not displayed). +//! `` each with a `` 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 ``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, /// Features the export approximates. pub warnings: Vec, + /// 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 { + 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 { + 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 { + 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 { + 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 { 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, 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, 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 { + 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::>().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"').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. diff --git a/crates/svg/tests/encoding_profile_fonts.rs b/crates/svg/tests/encoding_profile_fonts.rs new file mode 100644 index 00000000..4987dd60 --- /dev/null +++ b/crates/svg/tests/encoding_profile_fonts.rs @@ -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(""), "{svg}"); + assert!(svg.contains(LINE) && svg.contains("Grüße 中文"), "{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 = [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""), "{}", 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(" { + *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(""), "{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 [" Vec<(String, u16, Vec)> { + 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::>(), [("\"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::>().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 { + 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::>(); + 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""[..], &b" = head.iter().chain(&body).copied().collect(); + let _ = text_of(&bytes); + let _ = text_of(&compress_bytes(&bytes)); + } +} diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 9fa90d83..ca0e0424 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -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 } diff --git a/crates/text/src/embed.rs b/crates/text/src/embed.rs new file mode 100644 index 00000000..50288912 --- /dev/null +++ b/crates/text/src/embed.rs @@ -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, + /// 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 { + 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> { + 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 { + 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> { + 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::>()); + // 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)> = 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> { + 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::()); + 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) +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index da064a5a..51c63cde 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -77,6 +77,10 @@ impl FontFace { pub(crate) fn hb(&self) -> Option> { 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> { + 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)) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 2cfe13e3..54599838 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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; diff --git a/crates/text/src/tests_embed.rs b/crates/text/src/tests_embed.rs new file mode 100644 index 00000000..495d05ba --- /dev/null +++ b/crates/text/src/tests_embed.rs @@ -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) {} +} diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 720822dd..4a6e5d4b 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -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 { app.run(id, params) } -/// One labelled choice row: the dropdown sets `d.fields[key]` to the chosen value. -fn choice + 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 + Copy>(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, choices: &[(V, &str)], forced: Option) { 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 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"")); + let svg = vectorcraft_svg::text_of(&bytes).unwrap(); + assert!(svg.contains("baseProfile=\"tiny\"") && !svg.contains("