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("