diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index c393f98f3..9b64f97dd 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -21,6 +21,17 @@ use crate::{ use crate::css::{self, Props, blend_css, css_string, font_descriptor}; +mod nodes; +mod output; +mod paint; +mod text; + +use nodes::SymbolDef; +pub(crate) use nodes::{MASK_FLAGS, Reuse}; +pub(crate) use output::sanitize_id; +use output::{entity_value, latin1_refs, valid_xml_value, xml_attr_escape, xml_text}; +use text::FontUse; + pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>) -> Output { // Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result. let baked = vectorcraft_effects::bake_document(doc); @@ -182,118 +193,6 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: Output { svg: out, linked: w.linked, warnings: w.warnings, encoding } } -/// XML 1.0 literal attribute whitespace is normalized to spaces; references retain the value. -fn xml_attr_escape(s: &str) -> String { - xml_escape(s).replace('\t', " ").replace('\n', " ").replace('\r', " ") -} - -/// Escape type for XML content. Characters XML 1.0 does not allow are dropped, except the line -/// and page breaks (U+000B, U+000C) other apps paste, which become spaces. -fn xml_text(s: &str) -> String { - let s: String = s - .chars() - .filter_map(|c| match c { - '\u{b}' | '\u{c}' => Some(' '), - c if valid_xml_value(c.encode_utf8(&mut [0; 4])) => Some(c), - _ => None, - }) - .collect(); - xml_escape(&s) -} - -fn valid_xml_value(s: &str) -> bool { - s.chars().all(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}')) -} - -/// An XML name without a colon (an NCName): a letter or `_`, then [`xml_name_char`]s. -fn xml_ncname(s: &str) -> bool { - let mut chars = s.chars(); - chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(xml_name_char) -} - -/// Namespace-free XML 1.0 NameChar. The fixed `data-` prefix supplies NameStartChar. -fn xml_name_char(c: char) -> bool { - matches!(c, - 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '\u{b7}' | - '\u{c0}'..='\u{d6}' | '\u{d8}'..='\u{f6}' | '\u{f8}'..='\u{2ff}' | - '\u{300}'..='\u{37d}' | '\u{37f}'..='\u{1fff}' | '\u{200c}'..='\u{200d}' | - '\u{203f}'..='\u{2040}' | '\u{2070}'..='\u{218f}' | '\u{2c00}'..='\u{2fef}' | - '\u{3001}'..='\u{d7ff}' | '\u{f900}'..='\u{fdcf}' | '\u{fdf0}'..='\u{fffd}' | - '\u{10000}'..='\u{effff}' - ) -} - -/// Write every character of `s` beyond ISO 8859-1 as a character reference. Called only when -/// names fit Latin-1, so references occur in values, text or style sheets. -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; -} - -/// `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 declaration block as the literal value of an ``: quotes, `%` and markup -/// characters become references that still read as themselves inside the `style` attribute. -fn entity_value(decl: &str) -> String { - let mut o = String::with_capacity(decl.len()); - for c in decl.chars() { - match c { - '"' => o.push_str("""), - '%' => o.push_str("%"), - // Doubly escaped: the entity's replacement text is parsed again where it is used. - '&' => o.push_str("&#38;"), - '<' => o.push_str("&#60;"), - _ => o.push(c), - } - } - o -} - -/// Image `b` as SVG viewers show it: PNG, JPEG, GIF, WebP and SVG as they are, other formats (a -/// CMYK TIFF) as PNG. -fn web_image(b: &ImageBlob) -> std::borrow::Cow<'_, ImageBlob> { - use std::borrow::Cow; - if matches!(b.mime.as_str(), "image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp" | "image/svg+xml") { - return Cow::Borrowed(b); - } - let png = image::load_from_memory(&b.bytes).ok().and_then(|img| { - let mut out = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; - Some(out) - }); - png.map_or(Cow::Borrowed(b), |png| Cow::Owned(ImageBlob::png(png))) -} - -/// The usual file extension of an image MIME type. -fn image_ext(mime: &str) -> &str { - match mime { - "image/jpeg" => "jpg", - "image/svg+xml" => "svg", - m => m.strip_prefix("image/").filter(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or("bin"), - } -} - struct Writer<'a> { doc: &'a Document, opts: &'a ExportOptions, @@ -352,2297 +251,3 @@ struct Writer<'a> { /// ([`Writer::foreign_attrs`]): declared on the root. foreign_ns: BTreeMap, } - -/// The characters type uses from one face, under one `@font-face` description: the family the -/// type names and the weight, italic and width its style asks for. -struct FontUse { - family: String, - weight: u16, - italic: bool, - /// The `font-stretch` percentage ([`css::font_stretch`]), as bits; `None` for the normal width. - stretch: Option, - face: Arc, - chars: BTreeSet, -} - -/// A symbol's `` def and the instance transforms that can `` it. -struct SymbolDef { - /// The def's id, once written. - id: Option, - reuse: Reuse, -} - -/// Which instance transforms paint a symbol's art exactly as the def under that transform (the -/// canvas transforms the art's geometry alone: stroke weights, effects, patterns and unlinked -/// masks stay put). -#[derive(Clone, Copy)] -pub(crate) struct Reuse { - /// Translations (no pattern paints or unlinked masks). - moves: bool, - /// Rotations and reflections (nor effects or brushes). - turns: bool, - /// Any other transform (nor strokes or live objects). - scales: bool, -} - -/// How one stroke is written. -enum StrokePlan { - /// Its brush art (document space). - Brush(Vec), - Written(Written), -} - -/// How deep symbols may sit in one another's art (deeper instances are left out). -const MAX_SYMBOL_NEST: u32 = 8; - -impl Reuse { - const NONE: Self = Self { moves: false, turns: false, scales: false }; - - /// What a def of symbol art `art` stands for. - pub(crate) fn of(doc: &Document, art: &Node) -> Self { - let mut r = Self { moves: true, turns: true, scales: true }; - r.scan(doc, art, 0); - r - } - - /// Narrow down to what `n` (inside symbols nested `depth` deep) allows. - fn scan(&mut self, doc: &Document, n: &Node, depth: u32) { - let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); - let text_patterns = matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke))); - let unlinked_mask = n.extras().mask.as_ref().is_some_and(|m| !m.linked); - if depth > MAX_SYMBOL_NEST || text_patterns || unlinked_mask || vectorcraft_doc::pattern::uses_pattern(n, None) { - *self = Self::NONE; - return; - } - let effects = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible); - if effects(&n.appearance.effects) || n.appearance.items.iter().any(|i| effects(i.effects())) { - (self.turns, self.scales) = (false, false); - } - for i in &n.appearance.items { - if let AppearanceItem::Stroke(s) = i - && s.visible - && !s.paint.is_none() - && s.width > 0.0 - { - // Stroke weights don't scale; brushes can orient their art to the page. - self.scales = false; - self.turns &= s.brush.is_none(); - } - } - match &n.kind { - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => (self.turns, self.scales) = (false, false), - NodeKind::SymbolInstance { symbol, .. } => { - if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) { - self.scan(doc, &s.art, depth + 1); - } - } - _ => {} - } - if let Some(m) = &n.extras().mask { - self.scan(doc, &m.art, depth); - } - for c in n.children().into_iter().flatten() { - self.scan(doc, c, depth); - } - } - - /// Can the def stand for an instance with transform `xf`? - pub(crate) fn allows(self, xf: Affine) -> bool { - const EPS: f64 = 1e-9; - let [a, b, c, d, _, _] = xf.as_coeffs(); - if (a - 1.0).abs() < EPS && b.abs() < EPS && c.abs() < EPS && (d - 1.0).abs() < EPS { - return self.moves; - } - let rigid = (a * a + b * b - 1.0).abs() < EPS && (c * c + d * d - 1.0).abs() < EPS && (a * c + b * d).abs() < EPS; - if rigid { self.turns } else { self.scales } - } -} - -/// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas -/// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement -/// and the art of unlinked opacity masks. -fn pin_to_page(n: &mut Node, m: Affine) { - vectorcraft_doc::pattern::transform_pattern_paints(n, m); - if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { - let linked = mask.linked; - let art = std::sync::Arc::make_mut(&mut mask.art); - if !linked { - art.transform(m, false); - } - pin_to_page(art, m); - } - for c in n.children_mut().into_iter().flatten() { - pin_to_page(std::sync::Arc::make_mut(c), m); - } -} - -/// The Dublin Core terms File Info writes to ``: the format, title, author (creator), -/// description, each keyword (subject), copyright notice and URL (rights) and created date, the -/// empty ones left out. -fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { - let info = &doc.metadata; - let mut terms = vec![("format", "image/svg+xml".to_string())]; - let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default(); - let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; - let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())]; - let keywords = info.keywords.iter().map(|k| ("subject", k.as_str())); - terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_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 -/// canvas and PDF take it) rather than from linearised ones. -const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; -/// Attribute of an exported opacity mask listing its options that differ from the defaults -/// (clipping, not inverted): `noclip`, `invert`. -pub(crate) const MASK_FLAGS: &str = "data-vectorcraft-mask"; - -/// Turn an object name into a valid, readable XML id. -pub(crate) fn sanitize_id(name: &str) -> String { - let mut s: String = name.trim().chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect(); - if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') { - s.insert(0, '_'); - } - s -} - -impl Writer<'_> { - /// Writing the SVG Tiny 1.2 profile? - fn tiny(&self) -> bool { - self.opts.profile == Profile::Tiny12 - } - fn num(&self, v: f64) -> String { - fmt_num(v, self.opts.decimals) - } - fn indent(&self, depth: usize) -> String { - if self.opts.minify { String::new() } else { " ".repeat(depth) } - } - fn line(&mut self, s: &str) { - let ind = self.indent(self.depth); - self.body.push_str(&ind); - self.body.push_str(s); - if !self.opts.minify { - self.body.push('\n'); - } - } - fn def(&mut self, depth: usize, s: &str) { - let ind = self.indent(depth + 1); - self.defs.push_str(&ind); - self.defs.push_str(s); - if !self.opts.minify { - self.defs.push('\n'); - } - } - /// The `href` of image `im`: its file with [`ImageMode::Link`] (or when its pixels are - /// missing), else its bytes. `None` when it has neither. - fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option { - let (doc, link) = (self.doc, self.opts.images == ImageMode::Link); - match (im.link.as_ref().filter(|_| link), doc.image_blob(im)) { - (Some(l), _) => Some(l.path.clone()), - (None, Some(b)) if !b.bytes.is_empty() => Some(self.blob_href(&web_image(b))), - _ => im.link.as_ref().map(|l| l.path.clone()), - } - } - - /// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs - /// once; each piece then draws them with ``. - fn share_images(&mut self, art: &Node) { - // Each image's count and its first piece, which shows what they all show. - let mut seen: HashMap<(String, u32, u32), (usize, vectorcraft_doc::ImageObject)> = HashMap::new(); - art.walk(&mut |c| { - if let NodeKind::Image(im) = &c.kind { - seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, (**im).clone())).0 += 1; - } - }); - let mut repeated: Vec<_> = seen.into_iter().filter(|(k, (n, _))| *n > 1 && !self.shared_images.contains_key(k)).collect(); - repeated.sort_by(|a, b| a.0.cmp(&b.0)); - for ((key, width, height), (_, im)) in repeated { - let Some(href) = self.image_href(&im) else { continue }; - let id = self.fresh_id("image"); - self.def( - 0, - &format!( - "", - xml_escape(&href) - ), - ); - self.shared_images.insert((key, width, height), id); - } - } - - fn fresh_id(&mut self, prefix: &str) -> String { - let mut i = 1; - loop { - let id = format!("{}{prefix}-{i}", self.id_prefix); - if self.used_ids.insert(id.clone()) { - return id; - } - i += 1; - } - } - /// `base` (with the unique prefix) if free, else `base-2`, `base-3`… - fn unique_id(&mut self, base: &str) -> String { - let base = format!("{}{base}", self.id_prefix); - if self.used_ids.insert(base.clone()) { - return base; - } - let next = self.next_suffix.entry(base.clone()).or_insert(2); - loop { - let cand = format!("{base}-{next}"); - *next += 1; - if self.used_ids.insert(cand.clone()) { - return cand; - } - } - } - - /// Reserve ids for every named object up front so generated def ids never collide with them. - fn assign_name_ids(&mut self) { - if self.opts.object_ids == ObjectIds::Minimal { - return; - } - let mut named: Vec<(NodeId, String)> = Vec::new(); - self.doc.walk(|n| { - if let Some(name) = &n.name { - named.push((n.id, sanitize_id(name))); - } - }); - for (id, base) in named { - let cand = self.unique_id(&base); - self.names.insert(id, cand); - } - } - /// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to - /// differ from it (spaces, punctuation, duplicates, the unique prefix). - /// The id of `n`'s element (with its name when the id isn't it), then the object's own data - /// as `data-*` attributes. Sublayers also retain their import marker. - fn id_attr(&mut self, n: &Node) -> String { - if self.anonymous { - return String::new(); - } - let mut out = self.names.get(&n.id).map_or_else(String::new, |id| format!(" id=\"{}\"", xml_escape(id))); - if let (Some(id), Some(name)) = (self.names.get(&n.id), n.name.as_deref()) - && name != id - { - if valid_xml_value(name) { - out.push_str(&format!(" data-name=\"{}\"", xml_attr_escape(name))); - } else { - self.warn("Object names containing invalid XML characters were left out of SVG name attributes"); - } - } - // Preserve sublayer identity independently of object data attributes. - if !out.is_empty() && n.is_layer() && self.layer_nest > 1 { - out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER)); - } - let mut keys = HashSet::new(); - for (k, v) in n.extras().attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { - // Ours (`name`, `vc-…`) are written by the export itself. - if k == "name" || k.starts_with("vc-") { - continue; - } - if k.is_empty() || !k.chars().all(xml_name_char) || !valid_xml_value(v) { - self.warn("Data attributes with invalid XML names or characters were left out of SVG"); - continue; - } - // Native data is an ordered list. Keep the first valid value of each key. - if !keys.insert(k.as_str()) { - self.warn("Repeated data attribute keys were written only once in SVG"); - continue; - } - self.non_latin1_names |= k.chars().any(|c| u32::from(c) > 0xff); - out.push_str(&format!(" data-{k}=\"{}\"", xml_attr_escape(v))); - } - self.foreign_attrs(n, &mut out); - self.event_attrs(n, &mut out); - out - } - /// `n`'s attributes in other applications' namespaces (`plotter:pen="2"`), added to `out`. - /// Each namespace is declared once, on the root, under the prefix the file gave it unless the - /// export uses that prefix itself or for another namespace ([`Writer::foreign_prefix`]). - fn foreign_attrs(&mut self, n: &Node, out: &mut String) { - let attrs = n.extras().attrs.as_deref().map_or(&[][..], |a| a.foreign.as_slice()); - let mut seen = HashSet::new(); - for a in attrs { - if !xml_ncname(&a.prefix) || !xml_ncname(&a.name) || a.ns.is_empty() || !valid_xml_value(&a.ns) || !valid_xml_value(&a.value) { - self.warn("Attributes of other applications with invalid XML names or characters were left out of SVG"); - continue; - } - // Native data is a list: one attribute per namespace and name. - if !seen.insert((a.ns.as_str(), a.name.as_str())) { - self.warn("Repeated attributes of other applications were written only once in SVG"); - continue; - } - let prefix = self.foreign_prefix(&a.ns, &a.prefix); - self.non_latin1_names |= prefix.chars().chain(a.name.chars()).any(|c| u32::from(c) > 0xff); - out.push_str(&format!(" {prefix}:{}=\"{}\"", a.name, xml_attr_escape(&a.value))); - } - } - /// The prefix namespace `ns` is written under: `wanted` (the file's), else `wanted2`, - /// `wanted3`… when the export's own prefixes or another namespace already have it. XML keeps - /// prefixes starting with `xml` for itself: those become `ns`, `ns2`… - fn foreign_prefix(&mut self, ns: &str, wanted: &str) -> String { - if let Some(p) = self.foreign_ns.get(ns) { - return p.clone(); - } - const OURS: [&str; 5] = ["svg", "xlink", "rdf", "dc", "vectorcraft"]; - let base = if wanted.to_ascii_lowercase().starts_with("xml") { "ns" } else { wanted }; - let taken = |p: &str| OURS.contains(&p) || self.foreign_ns.values().any(|q| q == p); - let mut prefix = base.to_string(); - let mut k = 2; - while taken(&prefix) { - prefix = format!("{base}{k}"); - k += 1; - } - self.foreign_ns.insert(ns.to_string(), prefix.clone()); - prefix - } - /// Window › SVG Interactivity: `n`'s events as event attributes (`onclick="…"`), added to - /// `out`. The SVG Tiny 1.2 profile leaves them out with a warning. - fn event_attrs(&mut self, n: &Node, out: &mut String) { - let events = n.extras().attrs.as_deref().map_or(&[][..], |a| a.events.as_slice()); - if events.is_empty() { - return; - } - if self.tiny() { - self.warn("SVG Tiny 1.2 has no event attributes: SVG Interactivity events were left out"); - return; - } - let mut seen = HashSet::new(); - for (event, script) in events { - // Native data can name anything: only the panel's events are written, once each. - if !SVG_EVENTS.contains(&event.as_str()) || !valid_xml_value(script) { - self.warn("SVG Interactivity events with unknown names or invalid XML characters were left out of SVG"); - continue; - } - if !seen.insert(event.as_str()) { - self.warn("Repeated SVG Interactivity events were written only once in SVG"); - continue; - } - out.push_str(&format!(" {event}=\"{}\"", xml_attr_escape(script))); - } - } - fn matrix(&self, m: Affine) -> String { - let c = m.as_coeffs(); - format!("matrix({} {} {} {} {} {})", self.num(c[0]), self.num(c[1]), self.num(c[2]), self.num(c[3]), self.num(c[4]), self.num(c[5])) - } - - /// Style properties as attributes, an inline style, or a CSS class, per the styling option. - fn attrs(&mut self, props: &Props) -> String { - if props.is_empty() { - return String::new(); - } - // 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 (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-variation-settings" | "font-kerning") - }); - let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect(); - if !style.is_empty() { - if self.tiny() { - self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation, font features and font variation settings are left out"); - } else { - s.push_str(&format!(" style=\"{}\"", xml_escape(&css::declarations(style)))); - } - } - s - } - Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css::declarations(props))), - Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css::declarations(props))), - Styling::InternalCss => { - let i = self.class(css::declarations(props)); - format!(" class=\"{}cls-{i}\"", self.id_prefix) - } - } - } - - /// The 1-based number of a distinct declaration block (a CSS class or a style entity). - fn class(&mut self, decl: String) -> usize { - if let Some(&i) = self.class_ids.get(decl.as_str()) { - return i; - } - let i = self.classes.len() + 1; - self.class_ids.insert(decl.clone(), i); - self.classes.push(decl); - i - } - - /// ``: the Dublin Core terms of File Info ([`ExportOptions::metadata`]) and the - /// native document ([`ExportOptions::preserve_editing`]) with the [`body_hash`] of the markup - /// around this element. - fn metadata(&self, out: &mut String, native: Option<(&[u8], String)>) { - if !self.opts.metadata && native.is_none() { - return; - } - let nl = if self.opts.minify { "" } else { "\n" }; - let mut line = |depth: usize, s: &str| { - out.push_str(&self.indent(depth)); - out.push_str(s); - out.push_str(nl); - }; - line(1, ""); - if self.opts.metadata { - line(2, ""); - line(3, ""); - for (term, value) in dublin_core(self.doc) { - line(4, &format!("{}", xml_escape(&value))); - } - line(3, ""); - line(2, ""); - } - if let Some((bytes, hash)) = native { - line( - 2, - &format!( - "", - base64_encode(bytes) - ), - ); - } - line(1, ""); - } - - fn path_d(&self, p: &PathData, m: Affine) -> String { - let mut s = String::new(); - // Each point as "x y", appended in place (no string per number or per point). - let dec = self.opts.decimals; - let pt = |s: &mut String, p: Point| { - let p = m * p; - crate::push_num(s, p.x, dec); - s.push(' '); - crate::push_num(s, p.y, dec); - }; - for sp in &p.subpaths { - let n = sp.anchors.len(); - if n == 0 { - continue; - } - if !s.is_empty() { - s.push(' '); - } - s.push('M'); - pt(&mut s, sp.anchors[0].p); - for i in 0..sp.segment_count() { - let (a, b) = (&sp.anchors[i], &sp.anchors[(i + 1) % n]); - if sp.segment_is_line(i) { - if sp.closed && i == n - 1 { - break; - } - s.push_str(" L"); - pt(&mut s, b.p); - } else { - s.push_str(" C"); - pt(&mut s, a.h_out); - s.push(' '); - pt(&mut s, b.h_in); - s.push(' '); - pt(&mut s, b.p); - } - } - if sp.closed { - s.push_str(" Z"); - } - } - s - } - - /// A fill or stroke value. Gradients resolve unset geometry against `bounds` (the space - /// [`Self::xf`] maps into user space, as the renderer does). - fn paint(&mut self, p: &Paint, bounds: Option) -> String { - match p { - Paint::None => "none".into(), - Paint::Pattern { pattern, xf } => match self.pattern_def(pattern, *xf) { - Some(id) => format!("url(#{id})"), - None => "none".into(), - }, - Paint::Solid { color, .. } => color.to_hex(), - Paint::Gradient(g) => format!("url(#{})", self.gradient_def(g, bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)))), - } - } - - /// A `` def for pattern `name` placed by `xf`: one period (super-tile) of the tiling, - /// with every instance that reaches into it. - fn pattern_def(&mut self, name: &str, xf: Affine) -> Option { - let doc = self.doc; - let def = doc.pattern(name)?; - let m = self.xf * xf; - let key = format!("{name}|{:?}", m.as_coeffs()); - if let Some(id) = self.patterns.get(&key) { - return Some(id.clone()); - } - if self.pattern_nest > 4 { - return None; - } - let id = self.fresh_id("pattern"); - self.patterns.insert(key, id.clone()); - let (pw, ph) = def.period(); - let (saved_body, saved_xf, saved_depth) = (std::mem::take(&mut self.body), self.xf, self.depth); - self.depth = 3; - self.pattern_nest += 1; - for o in def.offsets_covering(Rect::new(0.0, 0.0, pw, ph)) { - self.xf = def.instance_xf(o); - for a in &def.art { - self.node(a); - } - } - self.pattern_nest -= 1; - let content = std::mem::replace(&mut self.body, saved_body); - self.xf = saved_xf; - self.depth = saved_depth; - let head = format!( - "", - self.num(pw), - self.num(ph), - self.matrix(m) - ); - self.def(1, &head); - self.defs.push_str(&content); - self.def(1, ""); - Some(id) - } - - /// The id of a ``/`` def for `g` resolved against `bounds`. - /// Identical gradients share one def; a gradient from a swatch takes the swatch's name as its - /// id. A midpoint other than halfway is written as an extra stop marked `data-vc-midpoint` - /// (what import turns back into a midpoint). - fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String { - if g.gradient.kind == GradientKind::Freeform { - // Object fills and strokes are images (see `raster_paint`); characters' paints aren't. - self.warn("freeform gradients on characters are written as linear gradients"); - } - let mut geom = g.resolve(bounds); - geom.transform(self.xf, g.gradient.kind); - let (s, e) = (geom.start, geom.end); - // An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space. - let focal = |w: &Self, f: Point| format!(" fx=\"{}\" fy=\"{}\"", w.num(f.x), w.num(f.y)); - let (tag, prefix, attrs) = match g.gradient.kind { - GradientKind::Radial => { - let r = self.num((e - s).hypot()); - let attrs = if (geom.aspect - 1.0).abs() < 1e-9 { - let f = geom.focal.map(|f| focal(self, f)).unwrap_or_default(); - format!(" cx=\"{}\" cy=\"{}\" r=\"{r}\"{f}", self.num(s.x), self.num(s.y)) - } else { - let v = e - s; - let m = Affine::translate(s.to_vec2()) * Affine::rotate(v.y.atan2(v.x)) * Affine::scale_non_uniform(1.0, geom.aspect); - let f = geom.focal.map(|f| focal(self, m.inverse() * f)).unwrap_or_default(); - format!(" cx=\"0\" cy=\"0\" r=\"{r}\"{f} gradientTransform=\"{}\"", self.matrix(m)) - }; - ("radialGradient", "radial-gradient", attrs) - } - GradientKind::Linear | GradientKind::Freeform => ( - "linearGradient", - "linear-gradient", - format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)), - ), - }; - let stops: Vec = g - .gradient - .expanded() - .map(|(offset, c, o, mid)| { - let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() }; - let mid = mid.map(|m| format!(" data-vc-midpoint=\"{}\"", fmt_num(m as f64, 4))).unwrap_or_default(); - format!("", fmt_num(offset as f64, 4), c.to_hex()) - }) - .collect(); - let key = format!("{tag}{attrs}{}", stops.concat()); - if let Some(id) = self.gradients.get(&key) { - return id.clone(); - } - let id = match &g.swatch { - Some(name) => self.unique_id(&sanitize_id(name)), - None => self.fresh_id(prefix), - }; - self.gradients.insert(key, id.clone()); - self.def(1, &format!("<{tag} id=\"{id}\"{attrs} gradientUnits=\"userSpaceOnUse\">")); - for stop in &stops { - self.def(2, stop); - } - self.def(1, &format!("")); - id - } - - fn fill_props(&mut self, f: &FillLayer, rule: FillRule, bounds: Option, p: &mut Props) { - let paint = self.paint(&f.paint, bounds); - p.push(("fill", paint)); - if f.opacity < 1.0 { - p.push(("fill-opacity", fmt_num(f.opacity as f64, 3))); - } - if rule == FillRule::EvenOdd { - p.push(("fill-rule", "evenodd".into())); - } - } - - fn stroke_props(&mut self, s: &StrokeLayer, width: f64, bounds: Option, p: &mut Props) { - let paint = self.paint(&s.paint, bounds); - p.push(("stroke", paint)); - if s.opacity < 1.0 { - p.push(("stroke-opacity", fmt_num(s.opacity as f64, 3))); - } - if (width - 1.0).abs() > 1e-9 { - p.push(("stroke-width", self.num(width))); - } - match s.cap { - LineCap::Butt => {} - LineCap::Round => p.push(("stroke-linecap", "round".into())), - LineCap::Square => p.push(("stroke-linecap", "square".into())), - } - match s.join { - LineJoin::Miter => { - if (s.miter_limit - 4.0).abs() > 1e-9 { - p.push(("stroke-miterlimit", self.num(s.miter_limit))); - } - } - LineJoin::Round => p.push(("stroke-linejoin", "round".into())), - LineJoin::Bevel => p.push(("stroke-linejoin", "bevel".into())), - } - if let Some(d) = &s.dash - && d.is_dashed() - { - p.push(("stroke-dasharray", d.pattern.iter().map(|v| self.num(*v)).collect::>().join(" "))); - if d.offset != 0.0 { - p.push(("stroke-dashoffset", self.num(d.offset))); - } - } - } - - /// How stroke `st` is written (`bp`: the shape in document space, built on first use from - /// `paths`): its brush art, or a plain stroke or filled outlines matching the canvas. - fn stroke_plan(&mut self, st: &StrokeLayer, bp: &mut Option, paths: &[&PathData]) -> StrokePlan { - if stroke::is_plain(st) { - return StrokePlan::Written(stroke::for_writer(&BezPath::new(), st)); - } - let bp = bp.get_or_insert_with(|| doc_path(paths)); - let doc = self.doc; - let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); - match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) { - Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(doc, b, bp, st)), - None => StrokePlan::Written(stroke::for_writer(bp, st)), - } - } - - /// The `clip-path` (inside) or `mask` (outside) attribute that keeps an aligned stroke on its - /// side of the shape `d`; `reach` (document space) is what the stroke covers. - fn side_attr(&mut self, side: Option, d: &str, rule: FillRule, reach: Rect) -> String { - match side { - None | Some(StrokeAlign::Center) => String::new(), - Some(StrokeAlign::Inside) => { - let cid = self.fresh_id("clip-path"); - let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; - self.def(1, &format!("")); - self.def(2, &format!("")); - 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); - let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); - let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; - self.def(1, &format!("")); - self.def(2, &format!("")); - self.def(2, &format!("")); - self.def(1, ""); - format!(" mask=\"url(#{mid})\"") - } - } - } - - /// Paint a shape (`d`) with a node's appearance stack. `paths` is the same shape in document - /// space, for strokes that aren't plain (it may be empty when all are). - fn shape(&mut self, n: &Node, d: &str, paths: &[&PathData], rule: FillRule, bounds: Option) { - let id = self.id_attr(n); - let items: Vec<&AppearanceItem> = n - .appearance - .items - .iter() - .filter(|i| match i { - AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), - AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, - }) - .collect(); - let mut bp = None; - let plans: Vec> = items - .iter() - .map(|i| match i { - AppearanceItem::Stroke(s) => Some(self.stroke_plan(s, &mut bp, paths)), - AppearanceItem::Fill(_) => None, - }) - .collect(); - let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count(); - let strokes = items.len() - fills; - // A stroke's paint box is the bounds grown by half its weight, as on the canvas. - let paint_bounds = |s: &StrokeLayer| bounds.map(|b| s.paint_bounds(b)); - let simple = fills <= 1 - && strokes <= 1 - && !items.iter().any(|i| has_raster(i.effects())) - && items.iter().all(|i| i.blend() == BlendMode::Normal) - && !items.iter().any(|i| pixel_gradient(i.paint(), matches!(i, AppearanceItem::Fill(_))).is_some()) - && plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None }))); - if simple { - let mut p = Props::new(); - match items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) { - Some(f) => self.fill_props(f, rule, bounds, &mut p), - None => { - p.push(("fill", "none".into())); - if rule == FillRule::EvenOdd { - p.push(("fill-rule", "evenodd".into())); - } - } - } - if let Some(s) = items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) { - self.stroke_props(s, s.width, paint_bounds(s), &mut p); - if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 { - p.push(("paint-order", "stroke".into())); - } - } - p.extend(css::transparency(n)); - let a = self.attrs(&p); - self.line(&format!("")); - return; - } - let a = self.attrs(&css::transparency(n)); - self.line(&format!("")); - self.depth += 1; - for (it, plan) in items.into_iter().zip(plans) { - // The item's own raster effects filter that item alone. - let filters = self.open_filters(n, it.effects()); - let mut p = Props::new(); - match (it, plan) { - (AppearanceItem::Fill(f), _) => { - let blend = (f.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(f.blend).to_string())); - if let (Some(g), Some(b)) = (pixel_gradient(&f.paint, true), bounds) { - p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3)))); - p.extend(blend); - self.raster_paint(g, b, &[d.to_string()], rule, &p, "", true); - } else { - self.fill_props(f, rule, bounds, &mut p); - p.extend(blend); - let a = self.attrs(&p); - self.line(&format!("")); - } - } - (AppearanceItem::Stroke(s), Some(StrokePlan::Brush(art))) => { - // The brush art takes the stroke's opacity and blend mode as a group. - if s.opacity < 1.0 { - p.push(("opacity", fmt_num(s.opacity as f64, 3))); - } - if s.blend != BlendMode::Normal { - p.push(("mix-blend-mode", blend_css(s.blend).into())); - } - let a = self.attrs(&p); - self.line(&format!("")); - self.depth += 1; - for piece in &art { - self.node(piece); - } - self.depth -= 1; - self.line(""); - } - (AppearanceItem::Stroke(s), Some(StrokePlan::Written(w))) => { - let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default())); - let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string())); - let opacity = (s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3))); - match (&w.shape, pixel_gradient(&s.paint, false).zip(paint_bounds(s))) { - (WrittenShape::Stroke { width }, Some((g, b))) => { - // The stroke's outline clips the gradient's image. - let bp = bp.get_or_insert_with(|| doc_path(paths)); - let outline = stroke::line_outline(bp, s, *width, OUTLINE_TOLERANCE); - let clip = self.path_d(&PathData::from_bezpath(&outline), self.xf); - p.extend(opacity); - p.extend(blend); - self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side, false); - } - (WrittenShape::Fill(outlines), Some((g, b))) if s.path_gradient().is_none() => { - let clip: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); - p.extend(opacity); - p.extend(blend); - self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side, false); - } - (WrittenShape::Stroke { width }, _) => { - p.push(("fill", "none".into())); - self.stroke_props(s, *width, paint_bounds(s), &mut p); - p.extend(blend); - let a = self.attrs(&p); - self.line(&format!("")); - } - (WrittenShape::Fill(outlines), _) if s.path_gradient().is_some() => { - // A gradient along or across the stroke: slices clipped to its outlines. - if let Some(ws) = bp.as_ref().and_then(|bp| stroke::written_slices(bp, rule, s, outlines)) { - p.extend(opacity); - p.extend(blend); - self.sliced(&ws, &p, &side); - } - } - (WrittenShape::Fill(outlines), _) => { - let paint = self.paint(&s.paint, paint_bounds(s)); - let ds: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); - let opacity = (s.opacity < 1.0).then(|| fmt_num(s.opacity as f64, 3)); - if let [one] = &ds[..] { - p.push(("fill", paint)); - p.extend(opacity.map(|o| ("fill-opacity", o))); - p.extend(blend); - let a = self.attrs(&p); - self.line(&format!("")); - } else if !ds.is_empty() { - // The line and its arrowheads overlap: one group takes the opacity. - p.extend(opacity.map(|o| ("opacity", o))); - p.extend(blend); - let a = self.attrs(&p); - self.line(&format!("")); - self.depth += 1; - let fill = self.attrs(&vec![("fill", paint)]); - for one in &ds { - self.line(&format!("")); - } - self.depth -= 1; - self.line(""); - } - } - } - } - (AppearanceItem::Stroke(_), None) => {} - } - self.close_filters(filters); - } - self.depth -= 1; - self.line(""); - } - - /// A stroke whose gradient runs along or across it ([`stroke::written_slices`]): a group (with - /// `props` and the `side` attribute) of its slices, clipped to its outlines. - fn sliced(&mut self, ws: &stroke::WrittenSlices, props: &Props, side: &str) { - self.warn("gradients along or across strokes are written as slices of linear gradients"); - let cid = self.fresh_id("clip-path"); - let clip = self.path_d(&PathData::from_bezpath(&ws.clip), self.xf); - self.def(1, &format!("")); - self.def(2, &format!("")); - self.def(1, ""); - let a = self.attrs(props); - self.line(&format!("")); - self.depth += 1; - self.line(&format!("")); - self.depth += 1; - for (shape, paint) in &ws.slices { - let fill = self.paint(paint, None); - let fill = self.attrs(&vec![("fill", fill)]); - let d = self.path_d(&PathData::from_bezpath(shape), self.xf); - self.line(&format!("")); - } - self.depth -= 1; - self.line(""); - self.depth -= 1; - self.line(""); - } - - fn warn(&mut self, w: &str) { - if !self.warnings.iter().any(|x| x == w) { - self.warnings.push(w.to_string()); - } - } - - /// 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.extras_mut().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); - let art = self.detached(2, |w| w.node(&m.art)); - self.knockout = knockout; - let inv = m.invert.then(|| { - let fid = self.fresh_id("invert"); - self.def(1, &format!("")); - self.def(2, ""); - self.def(1, ""); - fid - }); - // The mask's options, so import restores them (and its art without backdrop and filter). - let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect(); - let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) }; - self.def(1, &format!("")); - if let Some(fid) = &inv { - self.def(1, &format!("")); - } - if !m.clip || m.invert { - let c = if m.clip { "black" } else { "white" }; - self.def(1, &format!("")); - } - self.defs.push_str(&art); - if inv.is_some() { - self.def(1, ""); - } - self.def(1, ""); - self.line(&format!("")); - self.depth += 1; - let mut bare = n.clone(); - bare.extras_mut().mask = None; - self.node_body(&bare); - self.depth -= 1; - self.line(""); - } - - /// What `write` writes to the body, taken out of it (written at `depth`, for ``). - fn detached(&mut self, depth: usize, write: impl FnOnce(&mut Self)) -> String { - let (body, saved) = (std::mem::take(&mut self.body), self.depth); - self.depth = depth; - write(self); - self.depth = saved; - std::mem::replace(&mut self.body, body) - } - - /// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed. - fn group_props(&self, n: &Node) -> Props { - let mut p = css::transparency(n); - if !n.isolate && n.knocks_out(self.knockout) { - p.push(("isolation", "isolate".into())); - } - p - } - - /// The children of group `n`, as the elements of a knockout group when it is one. - fn group_children(&mut self, n: &Node, children: &[std::sync::Arc]) { - let knockout = n.knocks_out(self.knockout); - let enclosing = std::mem::replace(&mut self.knockout, knockout); - self.children(children); - self.knockout = enclosing; - } - - /// Children of the group being written. SVG has no knockout groups: in one, each element is - /// 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 && self.tiny() { - self.warn(TINY_MASKS); - } - if !self.knockout || self.tiny() { - for c in children { - self.node(c); - } - return; - } - let elements = Node::knockout_elements(children); - // Where every element over another covers it fully, drawing them in order knocks out as - // much: the masks would only add seams along the shapes' antialiased edges. - if elements.iter().skip(1).all(|c| paints_opaque(c)) { - for c in elements { - self.node(c); - } - return; - } - let Some((first, rest)) = elements.split_first() else { return }; - for c in rest.iter().rev() { - let mid = self.knockout_mask(c); - self.line(&format!("")); - self.depth += 1; - } - self.node(first); - for c in rest { - self.depth -= 1; - self.line(""); - self.node(c); - } - } - - /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object - /// opacity without its own mask, unless those define its shape). Returns the mask id. - fn knockout_mask(&mut self, c: &Node) -> String { - let filter = match &self.knockout_filter { - Some(f) => f.clone(), - None => { - let f = self.fresh_id("knockout-shape"); - self.def(1, &format!("")); - self.def(2, ""); - self.def(1, ""); - self.knockout_filter = Some(f.clone()); - f - } - }; - let shape = if c.knockout_shape { - c.clone() - } else { - let mut s = c.clone().with_mask(None); - s.opacity = 1.0; - s - }; - let anonymous = std::mem::replace(&mut self.anonymous, true); - let art = self.detached(3, |w| w.node(&shape)); - self.anonymous = anonymous; - let mid = self.fresh_id("knockout"); - self.def(1, &format!("")); - self.def(2, &format!("")); - self.def(2, &format!("")); - self.defs.push_str(&art); - self.def(2, ""); - self.def(1, ""); - mid - } - - /// An object, inside `` when it links to a URL (Attributes panel). - fn node(&mut self, n: &Node) { - let layer = usize::from(n.is_layer()); - self.layer_nest += layer; - match n.url().filter(|_| n.visible && !self.anonymous) { - Some(url) => { - self.line(&format!("", xml_escape(url))); - self.depth += 1; - self.node_body(n); - self.depth -= 1; - self.line(""); - } - None => self.node_body(n), - } - self.layer_nest -= layer; - } - - fn node_body(&mut self, n: &Node) { - // Hidden layers and objects are left out, unless the options keep them (hidden). Template - // layers never print. - if !(n.visible || self.opts.hidden_layers) { - return; - } - if has_raster(&n.appearance.effects) { - return self.filtered(n); - } - if let Some(m) = n.extras().mask.as_deref() - && !m.disabled - { - return self.masked(n, m); - } - match &n.kind { - NodeKind::Layer { template: true, .. } => {} - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { - let id = self.id_attr(n); - let a = self.attrs(&self.group_props(n)); - self.line(&format!("")); - self.depth += 1; - self.group_children(n, children); - self.depth -= 1; - self.line(""); - } - NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { - let Some((clip, rest)) = children.split_first() else { return }; - let cid = self.fresh_id("clip-path"); - self.def(1, &format!("")); - // The region every output clips to; with nothing to clip by, an empty clip path - // hides the clipped art. - if let Some((bp, rule)) = vectorcraft_effects::clip_outline(clip) { - let d = self.path_d(&PathData::from_bezpath(&bp), self.xf); - let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; - self.def(2, &format!("")); - } - self.def(1, ""); - let id = self.id_attr(n); - let a = self.attrs(&self.group_props(n)); - // The clipping path's fill paints behind the clipped art and its stroke over it, - // outside the clip (the group then wraps both). - let paint = clip.clip_paint(); - if paint.stroke.is_some() { - self.line(&format!("")); - self.depth += 1; - self.line(&format!("")); - } else { - self.line(&format!("")); - } - self.depth += 1; - if let Some(fill) = &paint.fill { - self.node(fill); - } - self.group_children(n, rest); - self.depth -= 1; - self.line(""); - if let Some(stroke) = &paint.stroke { - // One element keeps the clipping path's id. - let anonymous = self.anonymous; - self.anonymous |= paint.fill.is_some(); - self.node(stroke); - self.anonymous = anonymous; - self.depth -= 1; - self.line(""); - } - } - NodeKind::Path { guide: true, .. } => {} - NodeKind::Path { path, rule, .. } => { - if path.is_empty() { - return; - } - let d = self.path_d(path, self.xf); - self.shape(n, &d, &[path], *rule, n.geometric_bounds()); - } - NodeKind::Compound { children, rule } => { - let paths: Vec<&PathData> = children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).filter(|p| !p.is_empty()).collect(); - if paths.is_empty() { - return; - } - let d: Vec = paths.iter().map(|p| self.path_d(p, self.xf)).collect(); - self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds()); - } - NodeKind::Text(t) => self.text_node(n, t), - NodeKind::Image(im) => { - let id = self.id_attr(n); - let m = self.matrix(self.xf * im.xf); - let a = self.attrs(&css::transparency(n)); - if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) { - let shared = shared.clone(); - self.line(&format!("")); - return; - } - let Some(href) = self.image_href(im) else { return }; - self.line(&format!( - "", - im.width, - im.height, - xml_escape(&href) - )); - } - NodeKind::SymbolInstance { symbol, xf } => { - let doc = self.doc; - let Some(sym) = doc.symbols.iter().find(|s| s.name == *symbol) else { return }; - if self.symbol_nest > MAX_SYMBOL_NEST { - return; - } - let id = self.id_attr(n); - let a = self.attrs(&css::transparency(n)); - if let Some(def) = self.symbol_use(sym, n, *xf) { - let m = self.xf * *xf; - let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; - self.line(&format!("")); - return; - } - // An instance of its own: the (stained) art moved by the instance, its pattern - // paints and unlinked masks left in place as on the canvas. - let mut art = vectorcraft_brush::instance_art(&sym.art, n); - let moved = self.instance_xf * *xf; - if moved.determinant().abs() > 1e-12 { - pin_to_page(&mut art, moved.inverse()); - } - let saved = (self.xf, self.instance_xf); - self.xf = saved.0 * *xf; - self.instance_xf = moved; - self.line(&format!("")); - self.depth += 1; - // The symbol's art is the instance's own picture, outside any knockout around it. - let knockout = std::mem::take(&mut self.knockout); - self.symbol_nest += 1; - self.node(&art); - self.symbol_nest -= 1; - self.knockout = knockout; - self.depth -= 1; - self.line(""); - (self.xf, self.instance_xf) = saved; - } - // Live blends/envelopes/meshes export their evaluated (expanded) form. - NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Mesh(_) - | NodeKind::Repeat(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => { - let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); - self.share_images(&g); - self.node_body(&g); - } - } - } - - /// The `href` of image bytes `b`: a `data:` URI, or with [`ImageMode::Link`] a file written - /// next to the SVG, named after the bytes (image keys such as "raster-1" repeat across - /// documents). - fn blob_href(&mut self, b: &ImageBlob) -> String { - if self.opts.images != ImageMode::Link { - return format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)); - } - let name = format!("{}.{}", b.content_key(), image_ext(&b.mime)); - if self.linked_names.insert(name.clone()) { - self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() }); - } - name - } - - /// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is - /// drawn as pixels ([`GradientPaint::draws_as_pixels`]) when it paints a fill (`fill`) or a - /// stroke: an `` over its painted box, clipped to `clip` (outlines in user space filled - /// by `rule`), in a group with `props` and the `side` attribute. A freeform gradient's colour - /// field, which SVG can't express, is sampled as the canvas samples it (at twice the box's size - /// in points); a linear or radial gradient at the document's raster effects resolution (at most - /// [`MAX_GRADIENT_PX`] a side), with dither's noise on a fill. Nothing is written for an empty - /// box. - #[allow(clippy::too_many_arguments)] - fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str, fill: bool) { - use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale}; - let Some(b) = painted_box(bounds) else { return }; - let freeform = g.gradient.kind == GradientKind::Freeform; - let (rgba, cols, rows): (Vec, u16, u16) = if freeform { - let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height())); - let field = g.freeform_on(b).field(spread_scale(b)); - let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - (field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows) - } else { - let k = self.doc.raster_effects_ppi / 72.0; - let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16; - let (cols, rows) = (along(b.width()), along(b.height())); - (g.pixels(b, b, cols, rows, fill, &Color::to_rgb).flatten().collect(), cols, rows) - }; - let mut png = Vec::new(); - let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return }; - if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { - return; - } - self.warn(if freeform { - "freeform gradients are written as images clipped to their shapes" - } else { - "linear and radial gradients drawn as pixels are written as images clipped to their shapes" - }); - let href = self.blob_href(&ImageBlob::new("image/png", png)); - let cid = self.fresh_id("clip-path"); - let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; - self.def(1, &format!("")); - for d in clip { - self.def(2, &format!("")); - } - self.def(1, ""); - let a = self.attrs(props); - let nested = !side.is_empty(); - if nested { - self.line(&format!("")); - self.depth += 1; - self.line(&format!("")); - } else { - self.line(&format!("")); - } - self.depth += 1; - let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); - let tr = if self.xf == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(self.xf)) }; - self.line(&format!( - "", - xml_escape(&href) - )); - self.depth -= 1; - self.line(""); - if nested { - self.depth -= 1; - self.line(""); - } - } - - /// The id of the `` def instance `inst` (transform `xf`) can ``, written on first - /// use. `None` when the instance needs its own copy of the art: stained by its fill, or moved - /// by a transform under which the def would not look as the canvas paints it ([`Reuse`]). - fn symbol_use(&mut self, sym: &vectorcraft_doc::Symbol, inst: &Node, xf: Affine) -> Option { - // SVG Tiny has no ``. - if self.tiny() || vectorcraft_brush::stain(inst).is_some() { - return None; - } - if !self.symbols.contains_key(&sym.name) { - let reuse = Reuse::of(self.doc, &sym.art); - self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse }); - } - let def = self.symbols.get(&sym.name)?; - if !def.reuse.allows(xf) { - return None; - } - if let Some(id) = &def.id { - return Some(id.clone()); - } - // The art in symbol space, as its own picture: no knockout around it, and no object ids - // (every instance shows it). - let id = self.unique_id(&sanitize_id(&sym.name)); - let saved = (self.xf, self.instance_xf, std::mem::take(&mut self.knockout), std::mem::take(&mut self.names), self.anonymous); - (self.xf, self.instance_xf, self.anonymous) = (Affine::IDENTITY, Affine::IDENTITY, false); - self.symbol_nest += 1; - let art = self.detached(3, |w| w.node(&sym.art)); - self.symbol_nest -= 1; - (self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved; - // The symbol's name, when the id had to differ from it, comes back on import. - let name = if id == sym.name { - String::new() - } else if valid_xml_value(&sym.name) { - format!(" data-name=\"{}\"", xml_attr_escape(&sym.name)) - } else { - self.warn("Symbol names containing invalid XML characters were left out of SVG name attributes"); - String::new() - }; - self.def(1, &format!("")); - self.defs.push_str(&art); - self.def(1, ""); - if let Some(def) = self.symbols.get_mut(&sym.name) { - def.id = Some(id.clone()); - } - Some(id) - } - - /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` - /// per effect, the first effect innermost (the reference app's stacking order). A drop shadow - /// with Darkness darkens the screen colors, in a CMYK document too. Photoshop-style - /// filters have no SVG equivalent: they are left out with a warning (exports from the app turn - /// such objects into images first). - fn filtered(&mut self, n: &Node) { - let mut inner = n.clone(); - inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); - let opened = self.open_filters(n, &n.appearance.effects); - self.node_body(&inner); - self.close_filters(opened); - } - - /// Open one `` per visible raster effect of `effects` (an object's, or one fill or - /// stroke's) on `n`, outermost last effect; returns how many to close. - 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 bounds = n.visual_bounds_with(&self.doc.symbol_boxes()); - // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. - let grow = 18.0 / self.doc.raster_ppi(); - let reach: f64 = - fx.iter().map(|f| f.outset(bounds.unwrap_or_default()) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }).sum::() - + 2.0; - let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); - let mut opened = 0; - for f in fx.iter().rev() { - if matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)) { - self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out"); - continue; - } - let fid = self.fresh_id("filter"); - let region_attr = match region { - Some(r) => format!( - " filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", - self.num(r.x0), - self.num(r.y0), - self.num(r.width()), - self.num(r.height()) - ), - None => String::new(), - }; - let sd = |blur: f64| fmt_num((blur / 2.0).max(0.0), 3); - let flood = |c: &vectorcraft_color::Color, o: f32| { - format!("", c.to_hex(), fmt_num(o as f64, 3)) - }; - let body = match f { - // Darkness: the object blurred and moved, each color channel times 1 − darkness. - RasterFx::DropShadow { opacity, dx, dy, blur, darkness: Some(darkness), .. } => format!( - "", - sd(*blur), - self.num(*dx), - self.num(*dy), - fmt_num(*opacity as f64, 3), - keep = fmt_num(1.0 - *darkness as f64, 3) - ), - RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!( - "{}", - sd(*blur), - self.num(*dx), - self.num(*dy), - flood(color, *opacity) - ), - // The glow blurs a solid silhouette (alpha × 255 makes any alpha full) grown by a quarter raster pixel. - RasterFx::OuterGlow { opacity, blur, color, .. } => format!( - "{}", - fmt_num(grow, 3), - sd(*blur), - flood(color, *opacity) - ), - RasterFx::InnerGlow { opacity, blur, color, center, .. } => { - // Edge: the blurred inverse silhouette; Center: the blurred silhouette; both clipped to the shape. - let src = if *center { - "".replace("SD", &sd(*blur)) - } else { - format!( - "", - sd(*blur) - ) - }; - format!( - "{src}{}", - flood(color, *opacity) - ) - } - RasterFx::Feather { radius } => format!( - "", - sd(*radius) - ), - RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), - RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue, - }; - // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. - let mode = match f { - RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } - if *mode != BlendMode::Normal => - { - format!(" {}=\"{}\"", crate::import::BLEND, blend_css(*mode)) - } - _ => String::new(), - }; - self.def(1, &format!("{body}")); - self.line(&format!("")); - self.depth += 1; - opened += 1; - } - opened - } - - fn close_filters(&mut self, opened: usize) { - for _ in 0..opened { - self.depth -= 1; - self.line(""); - } - } - - /// The ``/`` properties of a character style. `space`: the text's layout bounds - /// and the map from text space to user space, which character gradients resolve against - /// (as on the canvas). - fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props { - let decimals = self.opts.decimals; - let len = |v: f64| fmt_num(v, decimals); - let mut p = css::font_props(st, &len); - // Character paints resolve in the text's space (`to_user` maps it into user space). - let (bounds, to_user) = space; - let saved = std::mem::replace(&mut self.xf, to_user); - if let Paint::Gradient(g) = &st.fill - && g.gradient.dither - && g.gradient.kind != GradientKind::Freeform - { - self.warn("dithered gradients on characters are written without dither"); - } - let fill = self.paint(&st.fill, Some(bounds)); - p.push(("fill", fill)); - // An inside or outside stroke is drawn on the glyph outlines instead - // ([`Self::aligned_char_strokes`]): SVG strokes text centred only. - if st.has_stroke() && !aligned(st) { - // Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans - // the text grown by half the weight, as on the canvas. - let layer = st.stroke_layer(); - self.stroke_props(&layer, layer.width, Some(layer.paint_bounds(bounds)), &mut p); - } - self.xf = saved; - p.extend(css::type_props(st, &len)); - p - } - - /// Type: its characters (live text, or glyph outlines when text is exported as outlines) and, - /// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those - /// below the Characters row under the characters, the others over them. - /// - /// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are written after the characters as - /// instances of their symbols (a `` of the symbol's def where it can be shared), all in - /// one group carrying the object's transparency. The live text skips their characters and - /// places the text after each one; type on a path with inline graphics is written as outlines - /// (a `` couldn't leave room for the art). - fn text_node(&mut self, n: &Node, t: &TextObject) { - let doc = self.doc; - let resolved = doc.inline_resolved(t); - let t = &*resolved; - let inline = t.runs.iter().any(|r| r.inline.is_some()); - // Ruby is written as outlines with its base text: a reader's can't place it. So are - // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869). - let unwritable = t.runs.iter().any(|r| r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal) - || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); - let outlined = - self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; - let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; - let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()); - // Live type with an inside or outside character stroke writes that stroke apart from the - // text, in the group. - let apart = !outlined && t.runs.iter().any(|r| aligned(&r.style)); - if !painted && !inline && !apart { - return chars(self, n); - } - let id = self.id_attr(n); - let a = self.attrs(&css::transparency(n)); - self.line(&format!("")); - self.depth += 1; - let db = vectorcraft_text::FontDb::global(); - let lay = vectorcraft_text::layout(db, t); - let mut all = lay.to_bezpath(); - for (_, bar) in vectorcraft_text::decorations(&lay, db, t) { - all.extend(bar.iter()); - } - let pd = PathData::from_bezpath(&all).transformed(t.xf); - let d = self.path_d(&pd, self.xf); - let tb = Some(t.xf.transform_rect_bbox(lay.bounds)); - let (below, above) = n.appearance.split_contents(); - let glyphs = |w: &mut Self, items: &[AppearanceItem]| { - if !items.is_empty() { - let ap = vectorcraft_doc::Appearance { items: items.to_vec(), ..Default::default() }; - w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, tb); - } - }; - glyphs(self, below); - // The group carries the id and the object's transparency. - let mut bare = n.clone(); - (bare.opacity, bare.blend, bare.isolate) = (1.0, BlendMode::Normal, false); - let anonymous = std::mem::replace(&mut self.anonymous, true); - chars(self, &bare); - for ig in &lay.inlines { - let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; - let inst = Node::new( - NodeId(u64::MAX), - NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf: t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol) }, - ); - self.node(&inst); - } - glyphs(self, above); - self.anonymous = anonymous; - self.depth -= 1; - self.line(""); - } - - /// Text as glyph outlines: one compound path per run, painted like the run (gradients span - /// the whole text), over its colour glyphs, as on the canvas. - fn text_outlines(&mut self, n: &Node, t: &TextObject) { - let db = vectorcraft_text::FontDb::global(); - let lay = vectorcraft_text::layout(db, t); - let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; - // Underline and strikethrough bars join their run's outlines; ruby glyphs are painted - // with the run of the text they annotate. - let bars = vectorcraft_text::decorations(&lay, db, t); - for (run, outline) in lay - .glyphs - .iter() - .map(|g| (g.run, &g.outline)) - .chain(lay.ruby.iter().map(|g| (g.run, &g.outline))) - .chain(bars.iter().map(|(r, b)| (*r, b))) - { - if let Some(bp) = runs.get_mut(run) { - bp.extend(outline.iter()); - } - } - let id = self.id_attr(n); - let a = self.attrs(&css::transparency(n)); - // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group - // names its text for screen readers and search. - let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { - format!(" role=\"img\" aria-label=\"{}\"", xml_escape(&t.plain_text())) - } else { - String::new() - }; - self.line(&format!("")); - self.depth += 1; - // The glyphs stay in text space; the text's transform joins the document's meanwhile. - let saved = self.xf; - self.xf = saved * t.xf; - for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { - // Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as - // their images, COLR and SVG glyphs as their art. - for g in lay.glyphs.iter().filter(|g| g.run == i) { - if let Some((image, to_text)) = g.placed_image() { - let m = self.matrix(self.xf * to_text); - let href = self.blob_href(&image.blob); - self.line(&format!( - "", - image.width, - image.height, - xml_escape(&href) - )); - } - if let Some(art) = g.placed_colour(Affine::IDENTITY) { - self.node(&art); - } - } - if bp.elements().is_empty() { - continue; - } - let pd = PathData::from_bezpath(bp); - // An unnamed id: the pieces carry no id attribute (the group has it). - let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); - let d = self.path_d(&pd, self.xf); - self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); - } - self.xf = saved; - self.depth -= 1; - self.line(""); - } - - /// Point and area type, every laid-out line at its own position: wraps, indents, tabs and - /// spacing come out as on the canvas. Centred and right-aligned lines are anchored - /// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font - /// differs; other lines are placed where each style run (and, justified, each word) starts. - fn text(&mut self, n: &Node, t: &TextObject) { - // Live SVG text is written in logical order from left-aligned pieces: bidirectional text - // (and right-to-left paragraphs) keep their look as outlines until it is written with - // `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose - // `direction="rtl"` would anchor their pieces at the right end. - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft)); - if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs { - self.warn("bidirectional text is outlined to preserve shaping and visual order"); - return self.text_outlines(n, t); - } - if let TextKind::OnPath { path, start, end } = &t.kind { - // A reader lays a ``'s glyphs straight: joined glyphs bent along the path - // keep their look as outlines. - if lay.glyphs.iter().any(|g| g.bend.is_some()) { - self.warn("joined type bent along a path is outlined to keep its look"); - return self.text_outlines(n, t); - } - // `` has a start offset only: an end bracket, Align to Path and Spacing - // keep their look as outlines. - if end.is_some() || t.path_align != vectorcraft_doc::PathAlign::Baseline || t.path_spacing != 0.0 { - self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look"); - return self.text_outlines(n, t); - } - // It cannot hang glyphs outside the start bracket either. - if hangs_on_path(t, &lay) { - self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); - return self.text_outlines(n, t); - } - // A `` holds the characters of the text, not the bullets or numbers drawn - // before them. - if !lay.markers.is_empty() { - self.warn("type on a path with bullets or numbers is outlined to keep its look"); - return self.text_outlines(n, t); - } - return self.text_on_path(n, t, path, *start); - } - self.note_fonts(t, &lay); - let lines = text_lines(t, &lay, self.opts.fewer_tspans); - // `drawn` holds the styles the layout draws the text in: Shrink Text to Fit scales its sizes, explicit leading and baseline shifts. - let fitted = (lay.fit_scale != 1.0).then(|| vectorcraft_text::scaled(t, lay.fit_scale)); - let drawn = fitted.as_ref().unwrap_or(t); - // Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its - // stop, and so does the text after an inline graphic: lines with tabs, and text with - // inline graphics, are never anchored. - let has_inline = t.runs.iter().any(|r| r.inline.is_some()); - let anchors: Vec> = lines - .iter() - .map(|l| { - match t.para_at(l.para).justify { - Justify::Center => Some(("middle", 0.5)), - Justify::Right => Some(("end", 1.0)), - _ => None, - } - .filter(|_| !has_inline && !l.segs.iter().any(|s| s.brk)) - }) - .collect(); - // Every line anchored alike (the text's only paragraph style, typically): the - // carries the anchor. Otherwise each anchored line is a carrying its own. - let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten(); - // The element's user space is text space. - let space = (lay.bounds, Affine::IDENTITY); - let mut base_props = self.char_props(&drawn.first_style(), space); - // A `` cannot undo the decoration of the `` around it: with bullets, which - // have none, each run's `` carries its own. - if !lay.markers.is_empty() { - base_props.retain(|(k, _)| *k != "text-decoration"); - } - let base = TextBase { props: base_props, space, anchored: uniform.is_some() }; - let mut props = base.props.clone(); - if let Some((a, _)) = uniform { - props.push(("text-anchor", a.into())); - } - props.extend(css::transparency(n)); - let id = self.id_attr(n); - let a = self.attrs(&props); - let m = self.xf * t.xf; - let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; - let starts: Vec> = lines.iter().zip(&anchors).map(|(l, a)| line_start(&l.segs, a.map(|a| a.1))).collect(); - // The `` carries the first line's position too, so readers that place text by its - // own x/y start where the first line does. - let at = match starts.iter().flatten().next() { - Some((x, y)) => format!(" x=\"{}\" y=\"{}\"", self.num(*x), self.num(*y)), - None => String::new(), - }; - let mut s = format!(""); - for ((line, start), anchor) in lines.iter().zip(starts).zip(&anchors) { - let Some(start) = start else { continue }; - match anchor.filter(|_| uniform.is_none()) { - // A line anchored on its own: one positioned with the anchor around it. - Some((a, _)) => { - s.push_str(&format!("", self.num(start.0), self.num(start.1))); - let inner = TextBase { anchored: true, ..base.clone() }; - self.text_line(&mut s, drawn, &line.segs, start, &inner, false); - s.push_str(""); - } - None => self.text_line(&mut s, drawn, &line.segs, start, &base, true), - } - } - s.push_str(""); - self.line(&s); - self.aligned_char_strokes(t); - } - - /// The inside and outside character strokes of live type `t`, over its text (which goes - /// without them, see [`Self::char_props`]): each such run's stroke on its glyph outlines, drawn - /// twice as wide and clipped or masked to its side as aligned path strokes are - /// ([`Self::side_attr`]). The text stays live; only the stroke follows the outlines. - fn aligned_char_strokes(&mut self, t: &TextObject) { - if !t.runs.iter().any(|r| aligned(&r.style)) { - return; - } - let db = vectorcraft_text::FontDb::global(); - let lay = vectorcraft_text::layout(db, t); - let bars = vectorcraft_text::decorations(&lay, db, t); - // The glyphs stay in text space; the text's transform joins the document's meanwhile. - let saved = self.xf; - self.xf = saved * t.xf; - for (i, run) in t.runs.iter().enumerate().filter(|(_, r)| aligned(&r.style)) { - let mut bp = BezPath::new(); - for g in lay.glyphs.iter().filter(|g| g.run == i) { - bp.extend(g.outline.iter()); - } - for (_, bar) in bars.iter().filter(|(r, _)| *r == i) { - bp.extend(bar.iter()); - } - if bp.elements().is_empty() { - continue; - } - let pd = PathData::from_bezpath(&bp); - let ap = vectorcraft_doc::Appearance { items: vec![AppearanceItem::Stroke(run.style.stroke_layer())], ..Default::default() }; - let d = self.path_d(&pd, self.xf); - self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); - } - self.xf = saved; - } - - /// One line of laid-out text starting at `start`. Default: a positioned `` per segment - /// (only the first of an anchored line). Fewer tspans: one positioned `` per line with - /// the style changes nested in it, positioned again only after tabs and inline graphics. - /// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted. - /// `positioned`: false when the caller already positioned the line (an outer ``). - fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) { - let fewer = self.opts.fewer_tspans && positioned; - if fewer { - s.push_str(&format!("", self.num(start.0), self.num(start.1))); - } - let mut place = !fewer && positioned; - let mut first = true; - // The baseline shift the current text position carries. - let mut shift = 0.0; - for seg in line { - let Some(run_style) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; - let marker_style; - let st = if seg.marker { - marker_style = vectorcraft_doc::text::ParaList::marker_style(run_style); - &marker_style - } else { - run_style - }; - let mut attrs = String::new(); - if place { - let (x, y) = if first { start } else { (seg.x, seg.y) }; - attrs.push_str(&format!(" x=\"{}\" y=\"{}\"", self.num(x), self.num(y))); - shift = 0.0; - } - first = false; - if st.baseline_shift != shift { - attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift))); - shift = st.baseline_shift; - } - // After a tab, an inline graphic or a justified word space, and before a character that a - // reader would draw elsewhere, the next segment is placed. - place = seg.brk || (!fewer && !base.anchored && positioned); - if (st.h_scale - st.v_scale).abs() > 1e-9 || seg.scale != 1.0 { - attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance * seg.scale))); - } - if st.rotation != 0.0 { - attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation))); - } - let mut diff = run_diff(&base.props, self.char_props(st, base.space)); - diff.extend(direction_props(st)); - attrs.push_str(&self.attrs(&diff)); - let text = xml_text(&seg.text); - if attrs.is_empty() && fewer { - s.push_str(&text); - } else { - s.push_str(&format!("{text}")); - } - } - if fewer { - s.push_str(""); - } - } - - /// 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 (i, g) in lay.glyphs.iter().enumerate() { - let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue }; - // A colour font's subset would hold no bitmaps or COLR layers: its emoji are left to the - // viewer. - if g.image.is_some() || g.colour.is_some() { - self.warn("colour emoji fonts are not embedded: viewers show emoji in their own fonts"); - 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 stretch = css::font_stretch(st).map(f64::to_bits); - let at = self - .fonts - .iter() - .position(|u| u.face.id() == f.id() && (u.weight, u.italic, u.stretch) == (weight, italic, stretch) && u.family == st.font_family); - let at = at.unwrap_or_else(|| { - let family = st.font_family.clone(); - self.fonts.push(FontUse { family, weight, italic, stretch, 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 list marker's characters, a hyphen where a line breaks, capitals for All - // Caps, the style's Digits. - let src = lay.marker_text(i).unwrap_or_else(|| 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}').map(|c| st.digits.map(c)) { - 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`); where two of them share characters as well, a warning says - /// that browsers draw those in one of the faces. - fn font_faces(&mut self) -> Vec { - let uses = std::mem::take(&mut self.fonts); - let mut rules = Vec::new(); - fn described(u: &FontUse) -> (&str, u16, bool, Option) { - (&u.family, u.weight, u.italic, u.stretch) - } - let mut clashing: Vec<&String> = vec![]; - for (i, u) in uses.iter().enumerate() { - let clash = uses - .get(..i) - .unwrap_or_default() - .iter() - .any(|o| described(o) == described(u) && o.face.id() != u.face.id() && !o.chars.is_disjoint(&u.chars)); - if clash && !clashing.contains(&&u.family) { - clashing.push(&u.family); - } - } - for family in clashing { - self.warn(&format!( - "type in {family} uses faces of one weight, style and width that share characters: browsers draw those characters in one of the embedded faces" - )); - } - 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| described(o) == described(u)).count() > 1; - let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() }; - let stretch = u.stretch.map(|w| format!(";font-stretch:{}%", fmt_num(f64::from_bits(w), 3))).unwrap_or_default(); - let (mime, format) = if font.cff { ("font/otf", "opentype") } else { ("font/ttf", "truetype") }; - rules.push(format!( - "@font-face{{font-family:{};font-weight:{};font-style:{}{stretch};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); - let pid = self.fresh_id("text-path"); - let d = self.path_d(path, self.xf * t.xf); - 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(); - match t.para_at(0).justify { - Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())), - Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())), - _ => {} - } - props.extend(css::transparency(n)); - let a = self.attrs(&props); - let offset = fmt_num(start * 100.0, 3); - let mut s = format!(""); - for run in &t.runs { - let mut diff = run_diff(&base, self.char_props(&run.style, space)); - diff.extend(direction_props(&run.style)); - let a = self.attrs(&diff); - s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); - } - s.push_str(""); - self.line(&s); - self.aligned_char_strokes(t); - } -} - -/// Does a character style stroke inside or outside its glyphs (not centred)? -fn aligned(st: &CharStyle) -> bool { - st.has_stroke() && st.stroke_align != StrokeAlign::Center -} - -/// Does type on a path `t`, laid out as `lay`, hang glyphs outside its brackets? It does when -/// Optical Margin Alignment or its first paragraph's Roman Hanging Punctuation is on and its -/// glyphs sit elsewhere with both off. -fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool { - if !t.optical_margin && !t.para_at(0).roman_hanging { - return false; - } - let mut flush = t.clone(); - flush.optical_margin = false; - let mut styles = flush.paragraph_styles(); - for p in &mut styles { - p.roman_hanging = false; - } - flush.set_paragraph_styles(styles); - let flush = vectorcraft_text::layout(FontDb::global(), &flush); - lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin) -} - -/// The `direction` and `unicode-bidi` of a `` of characters in style `st`: an embedding in -/// its Character Direction, and nothing without one. Each `` of characters with a direction -/// carries them, and the `` does not. Type with a run set Right to Left is written as -/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only. -fn direction_props(st: &CharStyle) -> Props { - let Some(d) = st.char_direction else { return Props::new() }; - let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" }; - vec![("direction", direction.into()), ("unicode-bidi", "embed".into())] -} - -/// What the `` of a run whose properties are `run` sets over its ``'s `base`: the -/// properties that differ, and the initial value of each one `base` sets and the run doesn't. -fn run_diff(base: &Props, run: Props) -> Props { - let reset: Props = - base.iter().filter(|(k, _)| !run.iter().any(|(r, _)| r == k)).filter_map(|(k, _)| initial_value(k).map(|v| (*k, v.to_string()))).collect(); - run.into_iter().filter(|kv| !base.contains(kv)).chain(reset).collect() -} - -/// The initial value of an inherited property a run may leave unset (decorations can't be undone -/// on a ``). -fn initial_value(k: &str) -> Option<&'static str> { - Some(match k { - "font-weight" | "font-style" | "font-stretch" | "font-feature-settings" | "font-variation-settings" => "normal", - "stroke" | "stroke-dasharray" => "none", - "stroke-width" | "stroke-opacity" => "1", - "stroke-linecap" => "butt", - "stroke-linejoin" => "miter", - "stroke-miterlimit" => "4", - "stroke-dashoffset" | "letter-spacing" => "0", - "font-kerning" => "auto", - _ => return None, - }) -} - -/// A shape's paths as one path (document space). -fn doc_path(paths: &[&PathData]) -> BezPath { - let mut bp = BezPath::new(); - for p in paths { - bp.extend(p.to_bezpath()); - } - bp -} - -/// How closely stroke outlines that clip an image follow the stroke (points). -const OUTLINE_TOLERANCE: f64 = 0.01; - -/// Most pixels along a side of a linear or radial gradient's image. -const MAX_GRADIENT_PX: f64 = 4096.0; - -/// The gradient a fill (`fill`) or stroke paint is, if it is drawn as pixels -/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one). -fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> { - match p { - Paint::Gradient(g) if g.draws_as_pixels(fill) => Some(g), - _ => None, - } -} - -/// Any visible raster effect (shadow, glow, blur, feather) in `effects`? -fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { - effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) -} - -/// What the lines of one `` share. -#[derive(Clone)] -struct TextBase { - /// The `` element's character properties, which each `` differs from. - props: Props, - /// The text's layout bounds and the map from text space to user space (character paints). - space: (Rect, Affine), - /// Lines anchored at their centre or right end (`text-anchor`), positioned once each. - anchored: bool, -} - -/// One laid-out line: its paragraph (index) and its segments. -struct Line { - para: usize, - segs: Vec, -} - -/// Characters of one style on one line, written as one ``. -struct Segment { - /// Index into `TextObject::runs`. - run: usize, - /// Where a reader starts drawing the segment (text space; `y` is the baseline). - x: f64, - y: f64, - text: String, - /// The width a reader gives the segment (tracking included). On a line that is justified or - /// set with Mojikumi (unless `fewer`), that is the width without justification and Mojikumi - /// ([`reader_advances`]), for horizontally scaled type also without its last character's - /// kerning against the character after it ([`end_segment`]); elsewhere, the laid-out width. - advance: f64, - /// The next segment needs its own position (after a tab, a word space of a justified line, or - /// the next segment starts with a character the layout draws elsewhere than a reader would). - brk: bool, - /// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to - /// (`textLength`); 1 for other segments. - scale: f64, - /// A list marker's characters and tab, written in - /// [`vectorcraft_doc::text::ParaList::marker_style`] of the run's style. - marker: bool, -} - -/// How far a reader may draw a character from where the layout draws it before the character is -/// given its own position (points). -const PLACE_TOLERANCE: f64 = 1e-6; - -/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and, -/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters -/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all -/// caps are upper case. Unless `fewer`, a line that is justified or composed with Mojikumi also -/// starts a new segment at each character the layout draws elsewhere than a reader continuing -/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between -/// the characters of a justified Japanese line, and the spacing Mojikumi changes. -fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { - let plain = t.plain_text(); - let mut lines = Vec::with_capacity(lay.lines.len()); - let (mut kerned, mut unkerned) = (None, None); - for line in &lay.lines { - let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); - let style = t.para_at(para); - let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); - // Each cluster's advance and drawing offset as a reader gives them, by its byte offset. - // Justification…'s Desired word and letter spacing and glyph scaling move characters from - // where a reader draws them, on every line. - let spaced = !fewer && !style.justification.desired_is_default(); - let reader = if split_words || spaced || (!fewer && !style.mojikumi.is_none()) { - Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true))) - } else { - None - }; - // Where a reader draws the next character of the last segment, and the byte offset and - // reader's advance of the last segment's last cluster. - let mut pen = 0.0; - let mut tail = None; - let mut segs: Vec = Vec::new(); - let mut cluster = None; - // A list marker's glyphs write its characters and then a tab (glyph `i` of the layout). - let source = |i: usize, g: &vectorcraft_text::PositionedGlyph| { - lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }) - }; - let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default(); - // Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line. - if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) { - while let Some((_, rest)) = - glyphs.split_last().filter(|(g, rest)| source(line.glyph_start + rest.len(), g).chars().all(|c| c.is_whitespace() && c != '\t')) - { - glyphs = rest; - } - } - let mut after_inline = false; - for (k, g) in glyphs.iter().enumerate() { - let Some(run) = t.runs.get(g.run) else { continue }; - // A list marker before an inline graphic is in the graphic's run, and is text. - if run.inline.is_some() && lay.marker_of(line.glyph_start + k).is_none() { - // An inline graphic (written as art): the text after it is placed anew. - if let Some(last) = segs.last_mut() { - last.brk = true; - } - after_inline = true; - cluster = None; - continue; - } - // The cluster as a reader sets it (a hyphen the layout adds is set as laid out). - let set = reader.and_then(|r| r.get(&g.byte)).filter(|_| g.len > 0); - if g.len > 0 && cluster == Some(g.byte) { - // Another glyph of the same cluster (a reader's advance covers the whole cluster). - if let Some(last) = segs.last_mut().filter(|_| set.is_none()) { - last.advance += g.advance; - pen += g.advance; - } - continue; - } - // A glyph that stands for no character (a list marker before the paragraph's first - // character) starts no cluster. - cluster = (g.len > 0).then_some(g.byte); - let (advance, x) = match set { - Some(&(advance, offset)) => { - // A glyph the layout draws before its pen (Mojikumi) starts that much earlier. - let x = g.xf.translation().x - offset; - (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) - } - None => (g.advance, g.origin.x), - }; - let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); - let piece = if run.style.all_caps { src.to_uppercase() } else { src }; - let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); - // A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width. - let scale = if reader.is_some() && (g.scale - 1.0).abs() > 1e-9 && g.scale.is_finite() { g.scale } else { 1.0 }; - let fresh = std::mem::take(&mut after_inline) || scale != 1.0 || segs.last().is_some_and(|last| last.scale != 1.0); - // The layout draws the character elsewhere than a reader continuing the line would. - let moved = reader.is_some() - && segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE); - pen = x + advance; - // A list marker is a segment of its own, and so is the text after it. In horizontally - // scaled type a tab is a segment apart from the characters before it, so a reader - // stretching those characters to their width (`textLength`) stretches them alone. - let marker = lay.marker_of(line.glyph_start + k).is_some(); - let tab_apart = piece == "\t" && (run.style.h_scale - run.style.v_scale).abs() > 1e-9; - match segs.last_mut() { - Some(last) if last.run == g.run && !last.brk && !fresh && !moved && last.marker == marker && !tab_apart => { - last.text.push_str(&piece); - last.advance += advance; - last.brk = brk; - } - last => { - if let Some(last) = last { - last.brk |= moved || last.marker != marker; - end_segment(last, tail, t, lay, &mut unkerned); - } - segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale, marker }); - } - } - tail = set.map(|_| (g.byte, advance)); - } - if let Some(last) = segs.last_mut() { - end_segment(last, tail, t, lay, &mut unkerned); - } - segs.retain(|s| !s.text.is_empty()); - if !segs.is_empty() { - lines.push(Line { para, segs }); - } - } - lines -} - -/// Ends segment `seg` of horizontally scaled type (or a glyph Glyph Scaling scales), which a reader stretches to its width -/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance -/// a reader gives it at the end of a ``, without the font's kerning against the character -/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them. -fn end_segment( - seg: &mut Segment, - tail: Option<(usize, f64)>, - t: &TextObject, - lay: &vectorcraft_text::TextLayout, - unkerned: &mut Option>, -) { - let Some((byte, advance)) = tail else { return }; - if seg.scale == 1.0 && t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) { - return; - } - if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) { - seg.advance += alone - advance; - } -} - -/// The advance a reader of live SVG text gives each character cluster of `t` and how far from -/// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out -/// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by -/// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual -/// kerning leaves it out). -fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap { - let mut set = TextObject { kind: TextKind::Point, ..t.clone() }; - for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) { - p.mojikumi = vectorcraft_doc::Mojikumi::None; - // A reader spaces words and letters as the font does. - p.justification = vectorcraft_doc::JustificationOptions::default(); - } - if !kerning { - for r in &mut set.runs { - r.style.kerning.get_or_insert(0.0); - } - } - let mut out: HashMap = HashMap::new(); - for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) { - let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale)); - *advance += g.advance * lay.fit_scale; - } - out -} - -/// Where a line of segments starts: where its first segment starts or, `anchor`ed (0.5: center, 1: -/// right end), that point of its width. `None` for an empty line. -fn line_start(line: &[Segment], anchor: Option) -> Option<(f64, f64)> { - let (first, last) = (line.first()?, line.last()?); - let x = match anchor { - Some(f) => first.x + f * (last.x + last.advance - first.x), - None => first.x, - }; - Some((x, first.y)) -} - -/// Does `n` paint opaquely wherever it paints (no transparency, blending, masks or effects in -/// it)? Anything this can't tell is taken as not. -fn paints_opaque(n: &Node) -> bool { - use vectorcraft_doc::{AppearanceItem, NodeKind}; - let paint = |p: &Paint| match p { - Paint::None | Paint::Solid { .. } => true, - Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0), - _ => false, - }; - let items = n.appearance.items.iter().all(|i| match i { - AppearanceItem::Fill(f) => !f.visible || (f.opacity >= 1.0 && f.blend == BlendMode::Normal && f.effects.is_empty() && paint(&f.paint)), - // Brush art draws as pieces with opacities of their own (bristles vary theirs). - AppearanceItem::Stroke(s) => { - !s.visible || (s.opacity >= 1.0 && s.blend == BlendMode::Normal && s.effects.is_empty() && s.brush.is_none() && paint(&s.paint)) - } - }); - let own = n.opacity >= 1.0 && n.blend == BlendMode::Normal && n.extras().mask.is_none() && n.appearance.effects.is_empty() && items; - own && match &n.kind { - NodeKind::Path { .. } => true, - NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(|c| paints_opaque(c)), - NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0), - _ => false, - } -} diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs new file mode 100644 index 000000000..69d700844 --- /dev/null +++ b/crates/svg/src/export/nodes.rs @@ -0,0 +1,705 @@ +use super::output::{sanitize_id, valid_xml_value, xml_attr_escape}; +use super::paint::{StrokePlan, has_raster, pixel_gradient}; +use super::text::OUTLINE_TOLERANCE; +use super::*; + +/// A symbol's `` def and the instance transforms that can `` it. +pub(super) struct SymbolDef { + /// The def's id, once written. + id: Option, + reuse: Reuse, +} + +/// Which instance transforms paint a symbol's art exactly as the def under that transform (the +/// canvas transforms the art's geometry alone: stroke weights, effects, patterns and unlinked +/// masks stay put). +#[derive(Clone, Copy)] +pub(crate) struct Reuse { + /// Translations (no pattern paints or unlinked masks). + moves: bool, + /// Rotations and reflections (nor effects or brushes). + turns: bool, + /// Any other transform (nor strokes or live objects). + scales: bool, +} + +/// How deep symbols may sit in one another's art (deeper instances are left out). +const MAX_SYMBOL_NEST: u32 = 8; + +impl Reuse { + const NONE: Self = Self { moves: false, turns: false, scales: false }; + + /// What a def of symbol art `art` stands for. + pub(crate) fn of(doc: &Document, art: &Node) -> Self { + let mut r = Self { moves: true, turns: true, scales: true }; + r.scan(doc, art, 0); + r + } + + /// Narrow down to what `n` (inside symbols nested `depth` deep) allows. + fn scan(&mut self, doc: &Document, n: &Node, depth: u32) { + let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + let text_patterns = matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke))); + let unlinked_mask = n.extras().mask.as_ref().is_some_and(|m| !m.linked); + if depth > MAX_SYMBOL_NEST || text_patterns || unlinked_mask || vectorcraft_doc::pattern::uses_pattern(n, None) { + *self = Self::NONE; + return; + } + let effects = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible); + if effects(&n.appearance.effects) || n.appearance.items.iter().any(|i| effects(i.effects())) { + (self.turns, self.scales) = (false, false); + } + for i in &n.appearance.items { + if let AppearanceItem::Stroke(s) = i + && s.visible + && !s.paint.is_none() + && s.width > 0.0 + { + // Stroke weights don't scale; brushes can orient their art to the page. + self.scales = false; + self.turns &= s.brush.is_none(); + } + } + match &n.kind { + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => (self.turns, self.scales) = (false, false), + NodeKind::SymbolInstance { symbol, .. } => { + if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) { + self.scan(doc, &s.art, depth + 1); + } + } + _ => {} + } + if let Some(m) = &n.extras().mask { + self.scan(doc, &m.art, depth); + } + for c in n.children().into_iter().flatten() { + self.scan(doc, c, depth); + } + } + + /// Can the def stand for an instance with transform `xf`? + pub(crate) fn allows(self, xf: Affine) -> bool { + const EPS: f64 = 1e-9; + let [a, b, c, d, _, _] = xf.as_coeffs(); + if (a - 1.0).abs() < EPS && b.abs() < EPS && c.abs() < EPS && (d - 1.0).abs() < EPS { + return self.moves; + } + let rigid = (a * a + b * b - 1.0).abs() < EPS && (c * c + d * d - 1.0).abs() < EPS && (a * c + b * d).abs() < EPS; + if rigid { self.turns } else { self.scales } + } +} + +/// Art about to be written moved by `m.inverse()` (a symbol instance's): map what the canvas +/// leaves on the page by `m` first, so it stays there. That is every pattern paint's placement +/// and the art of unlinked opacity masks. +fn pin_to_page(n: &mut Node, m: Affine) { + vectorcraft_doc::pattern::transform_pattern_paints(n, m); + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { + let linked = mask.linked; + let art = std::sync::Arc::make_mut(&mut mask.art); + if !linked { + art.transform(m, false); + } + pin_to_page(art, m); + } + for c in n.children_mut().into_iter().flatten() { + pin_to_page(std::sync::Arc::make_mut(c), m); + } +} + +/// 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 +/// canvas and PDF take it) rather than from linearised ones. +pub(super) const MASK: &str = "maskUnits=\"userSpaceOnUse\" color-interpolation=\"sRGB\""; + +/// Attribute of an exported opacity mask listing its options that differ from the defaults +/// (clipping, not inverted): `noclip`, `invert`. +pub(crate) const MASK_FLAGS: &str = "data-vectorcraft-mask"; + +impl Writer<'_> { + /// Paint a shape (`d`) with a node's appearance stack. `paths` is the same shape in document + /// space, for strokes that aren't plain (it may be empty when all are). + pub(super) fn shape(&mut self, n: &Node, d: &str, paths: &[&PathData], rule: FillRule, bounds: Option) { + let id = self.id_attr(n); + let items: Vec<&AppearanceItem> = n + .appearance + .items + .iter() + .filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, + }) + .collect(); + let mut bp = None; + let plans: Vec> = items + .iter() + .map(|i| match i { + AppearanceItem::Stroke(s) => Some(self.stroke_plan(s, &mut bp, paths)), + AppearanceItem::Fill(_) => None, + }) + .collect(); + let fills = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count(); + let strokes = items.len() - fills; + // A stroke's paint box is the bounds grown by half its weight, as on the canvas. + let paint_bounds = |s: &StrokeLayer| bounds.map(|b| s.paint_bounds(b)); + let simple = fills <= 1 + && strokes <= 1 + && !items.iter().any(|i| has_raster(i.effects())) + && items.iter().all(|i| i.blend() == BlendMode::Normal) + && !items.iter().any(|i| pixel_gradient(i.paint(), matches!(i, AppearanceItem::Fill(_))).is_some()) + && plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None }))); + if simple { + let mut p = Props::new(); + match items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f) } else { None }) { + Some(f) => self.fill_props(f, rule, bounds, &mut p), + None => { + p.push(("fill", "none".into())); + if rule == FillRule::EvenOdd { + p.push(("fill-rule", "evenodd".into())); + } + } + } + if let Some(s) = items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None }) { + self.stroke_props(s, s.width, paint_bounds(s), &mut p); + if matches!(items.first(), Some(AppearanceItem::Stroke(_))) && fills == 1 { + p.push(("paint-order", "stroke".into())); + } + } + p.extend(css::transparency(n)); + let a = self.attrs(&p); + self.line(&format!("")); + return; + } + let a = self.attrs(&css::transparency(n)); + self.line(&format!("")); + self.depth += 1; + for (it, plan) in items.into_iter().zip(plans) { + // The item's own raster effects filter that item alone. + let filters = self.open_filters(n, it.effects()); + let mut p = Props::new(); + match (it, plan) { + (AppearanceItem::Fill(f), _) => { + let blend = (f.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(f.blend).to_string())); + if let (Some(g), Some(b)) = (pixel_gradient(&f.paint, true), bounds) { + p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3)))); + p.extend(blend); + self.raster_paint(g, b, &[d.to_string()], rule, &p, "", true); + } else { + self.fill_props(f, rule, bounds, &mut p); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + } + } + (AppearanceItem::Stroke(s), Some(StrokePlan::Brush(art))) => { + // The brush art takes the stroke's opacity and blend mode as a group. + if s.opacity < 1.0 { + p.push(("opacity", fmt_num(s.opacity as f64, 3))); + } + if s.blend != BlendMode::Normal { + p.push(("mix-blend-mode", blend_css(s.blend).into())); + } + let a = self.attrs(&p); + self.line(&format!("")); + self.depth += 1; + for piece in &art { + self.node(piece); + } + self.depth -= 1; + self.line(""); + } + (AppearanceItem::Stroke(s), Some(StrokePlan::Written(w))) => { + let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default())); + let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string())); + let opacity = (s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3))); + match (&w.shape, pixel_gradient(&s.paint, false).zip(paint_bounds(s))) { + (WrittenShape::Stroke { width }, Some((g, b))) => { + // The stroke's outline clips the gradient's image. + let bp = bp.get_or_insert_with(|| doc_path(paths)); + let outline = stroke::line_outline(bp, s, *width, OUTLINE_TOLERANCE); + let clip = self.path_d(&PathData::from_bezpath(&outline), self.xf); + p.extend(opacity); + p.extend(blend); + self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side, false); + } + (WrittenShape::Fill(outlines), Some((g, b))) if s.path_gradient().is_none() => { + let clip: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); + p.extend(opacity); + p.extend(blend); + self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side, false); + } + (WrittenShape::Stroke { width }, _) => { + p.push(("fill", "none".into())); + self.stroke_props(s, *width, paint_bounds(s), &mut p); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + } + (WrittenShape::Fill(outlines), _) if s.path_gradient().is_some() => { + // A gradient along or across the stroke: slices clipped to its outlines. + if let Some(ws) = bp.as_ref().and_then(|bp| stroke::written_slices(bp, rule, s, outlines)) { + p.extend(opacity); + p.extend(blend); + self.sliced(&ws, &p, &side); + } + } + (WrittenShape::Fill(outlines), _) => { + let paint = self.paint(&s.paint, paint_bounds(s)); + let ds: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); + let opacity = (s.opacity < 1.0).then(|| fmt_num(s.opacity as f64, 3)); + if let [one] = &ds[..] { + p.push(("fill", paint)); + p.extend(opacity.map(|o| ("fill-opacity", o))); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + } else if !ds.is_empty() { + // The line and its arrowheads overlap: one group takes the opacity. + p.extend(opacity.map(|o| ("opacity", o))); + p.extend(blend); + let a = self.attrs(&p); + self.line(&format!("")); + self.depth += 1; + let fill = self.attrs(&vec![("fill", paint)]); + for one in &ds { + self.line(&format!("")); + } + self.depth -= 1; + self.line(""); + } + } + } + } + (AppearanceItem::Stroke(_), None) => {} + } + self.close_filters(filters); + } + self.depth -= 1; + self.line(""); + } + + /// A stroke whose gradient runs along or across it ([`stroke::written_slices`]): a group (with + /// `props` and the `side` attribute) of its slices, clipped to its outlines. + fn sliced(&mut self, ws: &stroke::WrittenSlices, props: &Props, side: &str) { + self.warn("gradients along or across strokes are written as slices of linear gradients"); + let cid = self.fresh_id("clip-path"); + let clip = self.path_d(&PathData::from_bezpath(&ws.clip), self.xf); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + let a = self.attrs(props); + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + self.depth += 1; + for (shape, paint) in &ws.slices { + let fill = self.paint(paint, None); + let fill = self.attrs(&vec![("fill", fill)]); + let d = self.path_d(&PathData::from_bezpath(shape), self.xf); + self.line(&format!("")); + } + self.depth -= 1; + self.line(""); + self.depth -= 1; + self.line(""); + } + + /// 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.extras_mut().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); + let art = self.detached(2, |w| w.node(&m.art)); + self.knockout = knockout; + let inv = m.invert.then(|| { + let fid = self.fresh_id("invert"); + self.def(1, &format!("")); + self.def(2, ""); + self.def(1, ""); + fid + }); + // The mask's options, so import restores them (and its art without backdrop and filter). + let flags: Vec<&str> = [(!m.clip).then_some("noclip"), m.invert.then_some("invert")].into_iter().flatten().collect(); + let data = if flags.is_empty() { String::new() } else { format!(" {MASK_FLAGS}=\"{}\"", flags.join(" ")) }; + self.def(1, &format!("")); + if let Some(fid) = &inv { + self.def(1, &format!("")); + } + if !m.clip || m.invert { + let c = if m.clip { "black" } else { "white" }; + self.def(1, &format!("")); + } + self.defs.push_str(&art); + if inv.is_some() { + self.def(1, ""); + } + self.def(1, ""); + self.line(&format!("")); + self.depth += 1; + let mut bare = n.clone(); + bare.extras_mut().mask = None; + self.node_body(&bare); + self.depth -= 1; + self.line(""); + } + + /// What `write` writes to the body, taken out of it (written at `depth`, for ``). + fn detached(&mut self, depth: usize, write: impl FnOnce(&mut Self)) -> String { + let (body, saved) = (std::mem::take(&mut self.body), self.depth); + self.depth = depth; + write(self); + self.depth = saved; + std::mem::replace(&mut self.body, body) + } + + /// Props of a group (or layer): a knockout group is isolated, a hidden one not displayed. + fn group_props(&self, n: &Node) -> Props { + let mut p = css::transparency(n); + if !n.isolate && n.knocks_out(self.knockout) { + p.push(("isolation", "isolate".into())); + } + p + } + + /// The children of group `n`, as the elements of a knockout group when it is one. + fn group_children(&mut self, n: &Node, children: &[std::sync::Arc]) { + let knockout = n.knocks_out(self.knockout); + let enclosing = std::mem::replace(&mut self.knockout, knockout); + self.children(children); + self.knockout = enclosing; + } + + /// Children of the group being written. SVG has no knockout groups: in one, each element is + /// 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. + pub(super) fn children(&mut self, children: &[std::sync::Arc]) { + if self.knockout && self.tiny() { + self.warn(TINY_MASKS); + } + if !self.knockout || self.tiny() { + for c in children { + self.node(c); + } + return; + } + let elements = Node::knockout_elements(children); + // Where every element over another covers it fully, drawing them in order knocks out as + // much: the masks would only add seams along the shapes' antialiased edges. + if elements.iter().skip(1).all(|c| paints_opaque(c)) { + for c in elements { + self.node(c); + } + return; + } + let Some((first, rest)) = elements.split_first() else { return }; + for c in rest.iter().rev() { + let mid = self.knockout_mask(c); + self.line(&format!("")); + self.depth += 1; + } + self.node(first); + for c in rest { + self.depth -= 1; + self.line(""); + self.node(c); + } + } + + /// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object + /// opacity without its own mask, unless those define its shape). Returns the mask id. + fn knockout_mask(&mut self, c: &Node) -> String { + let filter = match &self.knockout_filter { + Some(f) => f.clone(), + None => { + let f = self.fresh_id("knockout-shape"); + self.def(1, &format!("")); + self.def(2, ""); + self.def(1, ""); + self.knockout_filter = Some(f.clone()); + f + } + }; + let shape = if c.knockout_shape { + c.clone() + } else { + let mut s = c.clone().with_mask(None); + s.opacity = 1.0; + s + }; + let anonymous = std::mem::replace(&mut self.anonymous, true); + let art = self.detached(3, |w| w.node(&shape)); + self.anonymous = anonymous; + let mid = self.fresh_id("knockout"); + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(2, &format!("")); + self.defs.push_str(&art); + self.def(2, ""); + self.def(1, ""); + mid + } + + /// An object, inside `` when it links to a URL (Attributes panel). + pub(super) fn node(&mut self, n: &Node) { + let layer = usize::from(n.is_layer()); + self.layer_nest += layer; + match n.url().filter(|_| n.visible && !self.anonymous) { + Some(url) => { + self.line(&format!("", xml_escape(url))); + self.depth += 1; + self.node_body(n); + self.depth -= 1; + self.line(""); + } + None => self.node_body(n), + } + self.layer_nest -= layer; + } + + pub(super) fn node_body(&mut self, n: &Node) { + // Hidden layers and objects are left out, unless the options keep them (hidden). Template + // layers never print. + if !(n.visible || self.opts.hidden_layers) { + return; + } + if has_raster(&n.appearance.effects) { + return self.filtered(n); + } + if let Some(m) = n.extras().mask.as_deref() + && !m.disabled + { + return self.masked(n, m); + } + match &n.kind { + NodeKind::Layer { template: true, .. } => {} + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + let id = self.id_attr(n); + let a = self.attrs(&self.group_props(n)); + self.line(&format!("")); + self.depth += 1; + self.group_children(n, children); + self.depth -= 1; + self.line(""); + } + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + let Some((clip, rest)) = children.split_first() else { return }; + let cid = self.fresh_id("clip-path"); + self.def(1, &format!("")); + // The region every output clips to; with nothing to clip by, an empty clip path + // hides the clipped art. + if let Some((bp, rule)) = vectorcraft_effects::clip_outline(clip) { + let d = self.path_d(&PathData::from_bezpath(&bp), self.xf); + let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + self.def(2, &format!("")); + } + self.def(1, ""); + let id = self.id_attr(n); + let a = self.attrs(&self.group_props(n)); + // The clipping path's fill paints behind the clipped art and its stroke over it, + // outside the clip (the group then wraps both). + let paint = clip.clip_paint(); + if paint.stroke.is_some() { + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + } else { + self.line(&format!("")); + } + self.depth += 1; + if let Some(fill) = &paint.fill { + self.node(fill); + } + self.group_children(n, rest); + self.depth -= 1; + self.line(""); + if let Some(stroke) = &paint.stroke { + // One element keeps the clipping path's id. + let anonymous = self.anonymous; + self.anonymous |= paint.fill.is_some(); + self.node(stroke); + self.anonymous = anonymous; + self.depth -= 1; + self.line(""); + } + } + NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { path, rule, .. } => { + if path.is_empty() { + return; + } + let d = self.path_d(path, self.xf); + self.shape(n, &d, &[path], *rule, n.geometric_bounds()); + } + NodeKind::Compound { children, rule } => { + let paths: Vec<&PathData> = children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).filter(|p| !p.is_empty()).collect(); + if paths.is_empty() { + return; + } + let d: Vec = paths.iter().map(|p| self.path_d(p, self.xf)).collect(); + self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds()); + } + NodeKind::Text(t) => self.text_node(n, t), + NodeKind::Image(im) => { + let id = self.id_attr(n); + let m = self.matrix(self.xf * im.xf); + let a = self.attrs(&css::transparency(n)); + if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) { + let shared = shared.clone(); + self.line(&format!("")); + return; + } + let Some(href) = self.image_href(im) else { return }; + self.line(&format!( + "", + im.width, + im.height, + xml_escape(&href) + )); + } + NodeKind::SymbolInstance { symbol, xf } => { + let doc = self.doc; + let Some(sym) = doc.symbols.iter().find(|s| s.name == *symbol) else { return }; + if self.symbol_nest > MAX_SYMBOL_NEST { + return; + } + let id = self.id_attr(n); + let a = self.attrs(&css::transparency(n)); + if let Some(def) = self.symbol_use(sym, n, *xf) { + let m = self.xf * *xf; + let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; + self.line(&format!("")); + return; + } + // An instance of its own: the (stained) art moved by the instance, its pattern + // paints and unlinked masks left in place as on the canvas. + let mut art = vectorcraft_brush::instance_art(&sym.art, n); + let moved = self.instance_xf * *xf; + if moved.determinant().abs() > 1e-12 { + pin_to_page(&mut art, moved.inverse()); + } + let saved = (self.xf, self.instance_xf); + self.xf = saved.0 * *xf; + self.instance_xf = moved; + self.line(&format!("")); + self.depth += 1; + // The symbol's art is the instance's own picture, outside any knockout around it. + let knockout = std::mem::take(&mut self.knockout); + self.symbol_nest += 1; + self.node(&art); + self.symbol_nest -= 1; + self.knockout = knockout; + self.depth -= 1; + self.line(""); + (self.xf, self.instance_xf) = saved; + } + // Live blends/envelopes/meshes export their evaluated (expanded) form. + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + self.share_images(&g); + self.node_body(&g); + } + } + } + + /// The id of the `` def instance `inst` (transform `xf`) can ``, written on first + /// use. `None` when the instance needs its own copy of the art: stained by its fill, or moved + /// by a transform under which the def would not look as the canvas paints it ([`Reuse`]). + fn symbol_use(&mut self, sym: &vectorcraft_doc::Symbol, inst: &Node, xf: Affine) -> Option { + // SVG Tiny has no ``. + if self.tiny() || vectorcraft_brush::stain(inst).is_some() { + return None; + } + if !self.symbols.contains_key(&sym.name) { + let reuse = Reuse::of(self.doc, &sym.art); + self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse }); + } + let def = self.symbols.get(&sym.name)?; + if !def.reuse.allows(xf) { + return None; + } + if let Some(id) = &def.id { + return Some(id.clone()); + } + // The art in symbol space, as its own picture: no knockout around it, and no object ids + // (every instance shows it). + let id = self.unique_id(&sanitize_id(&sym.name)); + let saved = (self.xf, self.instance_xf, std::mem::take(&mut self.knockout), std::mem::take(&mut self.names), self.anonymous); + (self.xf, self.instance_xf, self.anonymous) = (Affine::IDENTITY, Affine::IDENTITY, false); + self.symbol_nest += 1; + let art = self.detached(3, |w| w.node(&sym.art)); + self.symbol_nest -= 1; + (self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved; + // The symbol's name, when the id had to differ from it, comes back on import. + let name = if id == sym.name { + String::new() + } else if valid_xml_value(&sym.name) { + format!(" data-name=\"{}\"", xml_attr_escape(&sym.name)) + } else { + self.warn("Symbol names containing invalid XML characters were left out of SVG name attributes"); + String::new() + }; + self.def(1, &format!("")); + self.defs.push_str(&art); + self.def(1, ""); + if let Some(def) = self.symbols.get_mut(&sym.name) { + def.id = Some(id.clone()); + } + Some(id) + } +} + +/// A shape's paths as one path (document space). +pub(super) fn doc_path(paths: &[&PathData]) -> BezPath { + let mut bp = BezPath::new(); + for p in paths { + bp.extend(p.to_bezpath()); + } + bp +} + +/// Does `n` paint opaquely wherever it paints (no transparency, blending, masks or effects in +/// it)? Anything this can't tell is taken as not. +fn paints_opaque(n: &Node) -> bool { + use vectorcraft_doc::{AppearanceItem, NodeKind}; + let paint = |p: &Paint| match p { + Paint::None | Paint::Solid { .. } => true, + Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0), + _ => false, + }; + let items = n.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => !f.visible || (f.opacity >= 1.0 && f.blend == BlendMode::Normal && f.effects.is_empty() && paint(&f.paint)), + // Brush art draws as pieces with opacities of their own (bristles vary theirs). + AppearanceItem::Stroke(s) => { + !s.visible || (s.opacity >= 1.0 && s.blend == BlendMode::Normal && s.effects.is_empty() && s.brush.is_none() && paint(&s.paint)) + } + }); + let own = n.opacity >= 1.0 && n.blend == BlendMode::Normal && n.extras().mask.is_none() && n.appearance.effects.is_empty() && items; + own && match &n.kind { + NodeKind::Path { .. } => true, + NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children.iter().all(|c| paints_opaque(c)), + NodeKind::Mesh(m) => m.points.iter().all(|p| p.opacity >= 1.0), + _ => false, + } +} diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs new file mode 100644 index 000000000..2197b2682 --- /dev/null +++ b/crates/svg/src/export/output.rs @@ -0,0 +1,422 @@ +use super::*; + +/// XML 1.0 literal attribute whitespace is normalized to spaces; references retain the value. +pub(super) fn xml_attr_escape(s: &str) -> String { + xml_escape(s).replace('\t', " ").replace('\n', " ").replace('\r', " ") +} + +/// Escape type for XML content. Characters XML 1.0 does not allow are dropped, except the line +/// and page breaks (U+000B, U+000C) other apps paste, which become spaces. +pub(super) fn xml_text(s: &str) -> String { + let s: String = s + .chars() + .filter_map(|c| match c { + '\u{b}' | '\u{c}' => Some(' '), + c if valid_xml_value(c.encode_utf8(&mut [0; 4])) => Some(c), + _ => None, + }) + .collect(); + xml_escape(&s) +} + +pub(super) fn valid_xml_value(s: &str) -> bool { + s.chars().all(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}')) +} + +/// An XML name without a colon (an NCName): a letter or `_`, then [`xml_name_char`]s. +fn xml_ncname(s: &str) -> bool { + let mut chars = s.chars(); + chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(xml_name_char) +} + +/// Namespace-free XML 1.0 NameChar. The fixed `data-` prefix supplies NameStartChar. +fn xml_name_char(c: char) -> bool { + matches!(c, + 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '\u{b7}' | + '\u{c0}'..='\u{d6}' | '\u{d8}'..='\u{f6}' | '\u{f8}'..='\u{2ff}' | + '\u{300}'..='\u{37d}' | '\u{37f}'..='\u{1fff}' | '\u{200c}'..='\u{200d}' | + '\u{203f}'..='\u{2040}' | '\u{2070}'..='\u{218f}' | '\u{2c00}'..='\u{2fef}' | + '\u{3001}'..='\u{d7ff}' | '\u{f900}'..='\u{fdcf}' | '\u{fdf0}'..='\u{fffd}' | + '\u{10000}'..='\u{effff}' + ) +} + +/// Write every character of `s` beyond ISO 8859-1 as a character reference. Called only when +/// names fit Latin-1, so references occur in values, text or style sheets. +pub(super) 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; +} + +/// A CSS declaration block as the literal value of an ``: quotes, `%` and markup +/// characters become references that still read as themselves inside the `style` attribute. +pub(super) fn entity_value(decl: &str) -> String { + let mut o = String::with_capacity(decl.len()); + for c in decl.chars() { + match c { + '"' => o.push_str("""), + '%' => o.push_str("%"), + // Doubly escaped: the entity's replacement text is parsed again where it is used. + '&' => o.push_str("&#38;"), + '<' => o.push_str("&#60;"), + _ => o.push(c), + } + } + o +} + +/// The Dublin Core terms File Info writes to ``: the format, title, author (creator), +/// description, each keyword (subject), copyright notice and URL (rights) and created date, the +/// empty ones left out. +fn dublin_core(doc: &Document) -> Vec<(&'static str, String)> { + let info = &doc.metadata; + let mut terms = vec![("format", "image/svg+xml".to_string())]; + let created = info.created.map(vectorcraft_doc::metadata::iso8601).unwrap_or_default(); + let fields = [("title", doc.title.as_str()), ("creator", info.author.as_str()), ("description", info.description.as_str())]; + let rights = [("rights", info.copyright_notice.as_str()), ("rights", info.copyright_url.as_str()), ("date", created.as_str())]; + let keywords = info.keywords.iter().map(|k| ("subject", k.as_str())); + terms.extend(fields.into_iter().chain(keywords).chain(rights).filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| (k, v.to_string()))); + terms +} + +/// Turn an object name into a valid, readable XML id. +pub(crate) fn sanitize_id(name: &str) -> String { + let mut s: String = name.trim().chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect(); + if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_') { + s.insert(0, '_'); + } + s +} + +impl Writer<'_> { + /// Writing the SVG Tiny 1.2 profile? + pub(super) fn tiny(&self) -> bool { + self.opts.profile == Profile::Tiny12 + } + + pub(super) fn num(&self, v: f64) -> String { + fmt_num(v, self.opts.decimals) + } + + pub(super) fn indent(&self, depth: usize) -> String { + if self.opts.minify { String::new() } else { " ".repeat(depth) } + } + + pub(super) fn line(&mut self, s: &str) { + let ind = self.indent(self.depth); + self.body.push_str(&ind); + self.body.push_str(s); + if !self.opts.minify { + self.body.push('\n'); + } + } + + pub(super) fn def(&mut self, depth: usize, s: &str) { + let ind = self.indent(depth + 1); + self.defs.push_str(&ind); + self.defs.push_str(s); + if !self.opts.minify { + self.defs.push('\n'); + } + } + + pub(super) fn fresh_id(&mut self, prefix: &str) -> String { + let mut i = 1; + loop { + let id = format!("{}{prefix}-{i}", self.id_prefix); + if self.used_ids.insert(id.clone()) { + return id; + } + i += 1; + } + } + + /// `base` (with the unique prefix) if free, else `base-2`, `base-3`… + pub(super) fn unique_id(&mut self, base: &str) -> String { + let base = format!("{}{base}", self.id_prefix); + if self.used_ids.insert(base.clone()) { + return base; + } + let next = self.next_suffix.entry(base.clone()).or_insert(2); + loop { + let cand = format!("{base}-{next}"); + *next += 1; + if self.used_ids.insert(cand.clone()) { + return cand; + } + } + } + + /// Reserve ids for every named object up front so generated def ids never collide with them. + pub(super) fn assign_name_ids(&mut self) { + if self.opts.object_ids == ObjectIds::Minimal { + return; + } + let mut named: Vec<(NodeId, String)> = Vec::new(); + self.doc.walk(|n| { + if let Some(name) = &n.name { + named.push((n.id, sanitize_id(name))); + } + }); + for (id, base) in named { + let cand = self.unique_id(&base); + self.names.insert(id, cand); + } + } + + /// ` id="…"` for a named object, plus ` data-name="…"` with the name itself when the id had to + /// differ from it (spaces, punctuation, duplicates, the unique prefix). + /// The id of `n`'s element (with its name when the id isn't it), then the object's own data + /// as `data-*` attributes. Sublayers also retain their import marker. + pub(super) fn id_attr(&mut self, n: &Node) -> String { + if self.anonymous { + return String::new(); + } + let mut out = self.names.get(&n.id).map_or_else(String::new, |id| format!(" id=\"{}\"", xml_escape(id))); + if let (Some(id), Some(name)) = (self.names.get(&n.id), n.name.as_deref()) + && name != id + { + if valid_xml_value(name) { + out.push_str(&format!(" data-name=\"{}\"", xml_attr_escape(name))); + } else { + self.warn("Object names containing invalid XML characters were left out of SVG name attributes"); + } + } + // Preserve sublayer identity independently of object data attributes. + if !out.is_empty() && n.is_layer() && self.layer_nest > 1 { + out.push_str(&format!(" {}=\"sublayer\"", crate::import::SUBLAYER)); + } + let mut keys = HashSet::new(); + for (k, v) in n.extras().attrs.as_deref().map_or(&[][..], |a| a.data.as_slice()) { + // Ours (`name`, `vc-…`) are written by the export itself. + if k == "name" || k.starts_with("vc-") { + continue; + } + if k.is_empty() || !k.chars().all(xml_name_char) || !valid_xml_value(v) { + self.warn("Data attributes with invalid XML names or characters were left out of SVG"); + continue; + } + // Native data is an ordered list. Keep the first valid value of each key. + if !keys.insert(k.as_str()) { + self.warn("Repeated data attribute keys were written only once in SVG"); + continue; + } + self.non_latin1_names |= k.chars().any(|c| u32::from(c) > 0xff); + out.push_str(&format!(" data-{k}=\"{}\"", xml_attr_escape(v))); + } + self.foreign_attrs(n, &mut out); + self.event_attrs(n, &mut out); + out + } + + /// `n`'s attributes in other applications' namespaces (`plotter:pen="2"`), added to `out`. + /// Each namespace is declared once, on the root, under the prefix the file gave it unless the + /// export uses that prefix itself or for another namespace ([`Writer::foreign_prefix`]). + fn foreign_attrs(&mut self, n: &Node, out: &mut String) { + let attrs = n.extras().attrs.as_deref().map_or(&[][..], |a| a.foreign.as_slice()); + let mut seen = HashSet::new(); + for a in attrs { + if !xml_ncname(&a.prefix) || !xml_ncname(&a.name) || a.ns.is_empty() || !valid_xml_value(&a.ns) || !valid_xml_value(&a.value) { + self.warn("Attributes of other applications with invalid XML names or characters were left out of SVG"); + continue; + } + // Native data is a list: one attribute per namespace and name. + if !seen.insert((a.ns.as_str(), a.name.as_str())) { + self.warn("Repeated attributes of other applications were written only once in SVG"); + continue; + } + let prefix = self.foreign_prefix(&a.ns, &a.prefix); + self.non_latin1_names |= prefix.chars().chain(a.name.chars()).any(|c| u32::from(c) > 0xff); + out.push_str(&format!(" {prefix}:{}=\"{}\"", a.name, xml_attr_escape(&a.value))); + } + } + + /// The prefix namespace `ns` is written under: `wanted` (the file's), else `wanted2`, + /// `wanted3`… when the export's own prefixes or another namespace already have it. XML keeps + /// prefixes starting with `xml` for itself: those become `ns`, `ns2`… + fn foreign_prefix(&mut self, ns: &str, wanted: &str) -> String { + if let Some(p) = self.foreign_ns.get(ns) { + return p.clone(); + } + const OURS: [&str; 5] = ["svg", "xlink", "rdf", "dc", "vectorcraft"]; + let base = if wanted.to_ascii_lowercase().starts_with("xml") { "ns" } else { wanted }; + let taken = |p: &str| OURS.contains(&p) || self.foreign_ns.values().any(|q| q == p); + let mut prefix = base.to_string(); + let mut k = 2; + while taken(&prefix) { + prefix = format!("{base}{k}"); + k += 1; + } + self.foreign_ns.insert(ns.to_string(), prefix.clone()); + prefix + } + + /// Window › SVG Interactivity: `n`'s events as event attributes (`onclick="…"`), added to + /// `out`. The SVG Tiny 1.2 profile leaves them out with a warning. + fn event_attrs(&mut self, n: &Node, out: &mut String) { + let events = n.extras().attrs.as_deref().map_or(&[][..], |a| a.events.as_slice()); + if events.is_empty() { + return; + } + if self.tiny() { + self.warn("SVG Tiny 1.2 has no event attributes: SVG Interactivity events were left out"); + return; + } + let mut seen = HashSet::new(); + for (event, script) in events { + // Native data can name anything: only the panel's events are written, once each. + if !SVG_EVENTS.contains(&event.as_str()) || !valid_xml_value(script) { + self.warn("SVG Interactivity events with unknown names or invalid XML characters were left out of SVG"); + continue; + } + if !seen.insert(event.as_str()) { + self.warn("Repeated SVG Interactivity events were written only once in SVG"); + continue; + } + out.push_str(&format!(" {event}=\"{}\"", xml_attr_escape(script))); + } + } + + pub(super) fn matrix(&self, m: Affine) -> String { + let c = m.as_coeffs(); + format!("matrix({} {} {} {} {} {})", self.num(c[0]), self.num(c[1]), self.num(c[2]), self.num(c[3]), self.num(c[4]), self.num(c[5])) + } + + /// Style properties as attributes, an inline style, or a CSS class, per the styling option. + pub(super) fn attrs(&mut self, props: &Props) -> String { + if props.is_empty() { + return String::new(); + } + // 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 (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-variation-settings" | "font-kerning") + }); + let mut s: String = attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", xml_escape(v))).collect(); + if !style.is_empty() { + if self.tiny() { + self.warn("SVG Tiny 1.2 has no style sheets: blend modes, isolation, font features and font variation settings are left out"); + } else { + s.push_str(&format!(" style=\"{}\"", xml_escape(&css::declarations(style)))); + } + } + s + } + Styling::InlineStyle => format!(" style=\"{}\"", xml_escape(&css::declarations(props))), + Styling::StyleEntities => format!(" style=\"&st{};\"", self.class(css::declarations(props))), + Styling::InternalCss => { + let i = self.class(css::declarations(props)); + format!(" class=\"{}cls-{i}\"", self.id_prefix) + } + } + } + + /// The 1-based number of a distinct declaration block (a CSS class or a style entity). + fn class(&mut self, decl: String) -> usize { + if let Some(&i) = self.class_ids.get(decl.as_str()) { + return i; + } + let i = self.classes.len() + 1; + self.class_ids.insert(decl.clone(), i); + self.classes.push(decl); + i + } + + /// ``: the Dublin Core terms of File Info ([`ExportOptions::metadata`]) and the + /// native document ([`ExportOptions::preserve_editing`]) with the [`body_hash`] of the markup + /// around this element. + pub(super) fn metadata(&self, out: &mut String, native: Option<(&[u8], String)>) { + if !self.opts.metadata && native.is_none() { + return; + } + let nl = if self.opts.minify { "" } else { "\n" }; + let mut line = |depth: usize, s: &str| { + out.push_str(&self.indent(depth)); + out.push_str(s); + out.push_str(nl); + }; + line(1, ""); + if self.opts.metadata { + line(2, ""); + line(3, ""); + for (term, value) in dublin_core(self.doc) { + line(4, &format!("{}", xml_escape(&value))); + } + line(3, ""); + line(2, ""); + } + if let Some((bytes, hash)) = native { + line( + 2, + &format!( + "", + base64_encode(bytes) + ), + ); + } + line(1, ""); + } + + pub(super) fn path_d(&self, p: &PathData, m: Affine) -> String { + let mut s = String::new(); + // Each point as "x y", appended in place (no string per number or per point). + let dec = self.opts.decimals; + let pt = |s: &mut String, p: Point| { + let p = m * p; + crate::push_num(s, p.x, dec); + s.push(' '); + crate::push_num(s, p.y, dec); + }; + for sp in &p.subpaths { + let n = sp.anchors.len(); + if n == 0 { + continue; + } + if !s.is_empty() { + s.push(' '); + } + s.push('M'); + pt(&mut s, sp.anchors[0].p); + for i in 0..sp.segment_count() { + let (a, b) = (&sp.anchors[i], &sp.anchors[(i + 1) % n]); + if sp.segment_is_line(i) { + if sp.closed && i == n - 1 { + break; + } + s.push_str(" L"); + pt(&mut s, b.p); + } else { + s.push_str(" C"); + pt(&mut s, a.h_out); + s.push(' '); + pt(&mut s, b.h_in); + s.push(' '); + pt(&mut s, b.p); + } + } + if sp.closed { + s.push_str(" Z"); + } + } + s + } + + pub(super) fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } +} diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs new file mode 100644 index 000000000..412098b72 --- /dev/null +++ b/crates/svg/src/export/paint.rs @@ -0,0 +1,503 @@ +use super::nodes::{MASK, doc_path}; +use super::output::sanitize_id; +use super::*; + +/// Image `b` as SVG viewers show it: PNG, JPEG, GIF, WebP and SVG as they are, other formats (a +/// CMYK TIFF) as PNG. +fn web_image(b: &ImageBlob) -> std::borrow::Cow<'_, ImageBlob> { + use std::borrow::Cow; + if matches!(b.mime.as_str(), "image/png" | "image/jpeg" | "image/jpg" | "image/gif" | "image/webp" | "image/svg+xml") { + return Cow::Borrowed(b); + } + let png = image::load_from_memory(&b.bytes).ok().and_then(|img| { + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) + }); + png.map_or(Cow::Borrowed(b), |png| Cow::Owned(ImageBlob::png(png))) +} + +/// The usual file extension of an image MIME type. +fn image_ext(mime: &str) -> &str { + match mime { + "image/jpeg" => "jpg", + "image/svg+xml" => "svg", + m => m.strip_prefix("image/").filter(|e| !e.is_empty() && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or("bin"), + } +} + +/// How one stroke is written. +pub(super) enum StrokePlan { + /// Its brush art (document space). + Brush(Vec), + Written(Written), +} + +impl Writer<'_> { + /// The `href` of image `im`: its file with [`ImageMode::Link`] (or when its pixels are + /// missing), else its bytes. `None` when it has neither. + pub(super) fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option { + let (doc, link) = (self.doc, self.opts.images == ImageMode::Link); + match (im.link.as_ref().filter(|_| link), doc.image_blob(im)) { + (Some(l), _) => Some(l.path.clone()), + (None, Some(b)) if !b.bytes.is_empty() => Some(self.blob_href(&web_image(b))), + _ => im.link.as_ref().map(|l| l.path.clone()), + } + } + + /// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs + /// once; each piece then draws them with ``. + pub(super) fn share_images(&mut self, art: &Node) { + // Each image's count and its first piece, which shows what they all show. + let mut seen: HashMap<(String, u32, u32), (usize, vectorcraft_doc::ImageObject)> = HashMap::new(); + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, (**im).clone())).0 += 1; + } + }); + let mut repeated: Vec<_> = seen.into_iter().filter(|(k, (n, _))| *n > 1 && !self.shared_images.contains_key(k)).collect(); + repeated.sort_by(|a, b| a.0.cmp(&b.0)); + for ((key, width, height), (_, im)) in repeated { + let Some(href) = self.image_href(&im) else { continue }; + let id = self.fresh_id("image"); + self.def( + 0, + &format!( + "", + xml_escape(&href) + ), + ); + self.shared_images.insert((key, width, height), id); + } + } + + /// A fill or stroke value. Gradients resolve unset geometry against `bounds` (the space + /// [`Self::xf`] maps into user space, as the renderer does). + pub(super) fn paint(&mut self, p: &Paint, bounds: Option) -> String { + match p { + Paint::None => "none".into(), + Paint::Pattern { pattern, xf } => match self.pattern_def(pattern, *xf) { + Some(id) => format!("url(#{id})"), + None => "none".into(), + }, + Paint::Solid { color, .. } => color.to_hex(), + Paint::Gradient(g) => format!("url(#{})", self.gradient_def(g, bounds.unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)))), + } + } + + /// A `` def for pattern `name` placed by `xf`: one period (super-tile) of the tiling, + /// with every instance that reaches into it. + fn pattern_def(&mut self, name: &str, xf: Affine) -> Option { + let doc = self.doc; + let def = doc.pattern(name)?; + let m = self.xf * xf; + let key = format!("{name}|{:?}", m.as_coeffs()); + if let Some(id) = self.patterns.get(&key) { + return Some(id.clone()); + } + if self.pattern_nest > 4 { + return None; + } + let id = self.fresh_id("pattern"); + self.patterns.insert(key, id.clone()); + let (pw, ph) = def.period(); + let (saved_body, saved_xf, saved_depth) = (std::mem::take(&mut self.body), self.xf, self.depth); + self.depth = 3; + self.pattern_nest += 1; + for o in def.offsets_covering(Rect::new(0.0, 0.0, pw, ph)) { + self.xf = def.instance_xf(o); + for a in &def.art { + self.node(a); + } + } + self.pattern_nest -= 1; + let content = std::mem::replace(&mut self.body, saved_body); + self.xf = saved_xf; + self.depth = saved_depth; + let head = format!( + "", + self.num(pw), + self.num(ph), + self.matrix(m) + ); + self.def(1, &head); + self.defs.push_str(&content); + self.def(1, ""); + Some(id) + } + + /// The id of a ``/`` def for `g` resolved against `bounds`. + /// Identical gradients share one def; a gradient from a swatch takes the swatch's name as its + /// id. A midpoint other than halfway is written as an extra stop marked `data-vc-midpoint` + /// (what import turns back into a midpoint). + fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String { + if g.gradient.kind == GradientKind::Freeform { + // Object fills and strokes are images (see `raster_paint`); characters' paints aren't. + self.warn("freeform gradients on characters are written as linear gradients"); + } + let mut geom = g.resolve(bounds); + geom.transform(self.xf, g.gradient.kind); + let (s, e) = (geom.start, geom.end); + // An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space. + let focal = |w: &Self, f: Point| format!(" fx=\"{}\" fy=\"{}\"", w.num(f.x), w.num(f.y)); + let (tag, prefix, attrs) = match g.gradient.kind { + GradientKind::Radial => { + let r = self.num((e - s).hypot()); + let attrs = if (geom.aspect - 1.0).abs() < 1e-9 { + let f = geom.focal.map(|f| focal(self, f)).unwrap_or_default(); + format!(" cx=\"{}\" cy=\"{}\" r=\"{r}\"{f}", self.num(s.x), self.num(s.y)) + } else { + let v = e - s; + let m = Affine::translate(s.to_vec2()) * Affine::rotate(v.y.atan2(v.x)) * Affine::scale_non_uniform(1.0, geom.aspect); + let f = geom.focal.map(|f| focal(self, m.inverse() * f)).unwrap_or_default(); + format!(" cx=\"0\" cy=\"0\" r=\"{r}\"{f} gradientTransform=\"{}\"", self.matrix(m)) + }; + ("radialGradient", "radial-gradient", attrs) + } + GradientKind::Linear | GradientKind::Freeform => ( + "linearGradient", + "linear-gradient", + format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)), + ), + }; + let stops: Vec = g + .gradient + .expanded() + .map(|(offset, c, o, mid)| { + let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() }; + let mid = mid.map(|m| format!(" data-vc-midpoint=\"{}\"", fmt_num(m as f64, 4))).unwrap_or_default(); + format!("", fmt_num(offset as f64, 4), c.to_hex()) + }) + .collect(); + let key = format!("{tag}{attrs}{}", stops.concat()); + if let Some(id) = self.gradients.get(&key) { + return id.clone(); + } + let id = match &g.swatch { + Some(name) => self.unique_id(&sanitize_id(name)), + None => self.fresh_id(prefix), + }; + self.gradients.insert(key, id.clone()); + self.def(1, &format!("<{tag} id=\"{id}\"{attrs} gradientUnits=\"userSpaceOnUse\">")); + for stop in &stops { + self.def(2, stop); + } + self.def(1, &format!("")); + id + } + + pub(super) fn fill_props(&mut self, f: &FillLayer, rule: FillRule, bounds: Option, p: &mut Props) { + let paint = self.paint(&f.paint, bounds); + p.push(("fill", paint)); + if f.opacity < 1.0 { + p.push(("fill-opacity", fmt_num(f.opacity as f64, 3))); + } + if rule == FillRule::EvenOdd { + p.push(("fill-rule", "evenodd".into())); + } + } + + pub(super) fn stroke_props(&mut self, s: &StrokeLayer, width: f64, bounds: Option, p: &mut Props) { + let paint = self.paint(&s.paint, bounds); + p.push(("stroke", paint)); + if s.opacity < 1.0 { + p.push(("stroke-opacity", fmt_num(s.opacity as f64, 3))); + } + if (width - 1.0).abs() > 1e-9 { + p.push(("stroke-width", self.num(width))); + } + match s.cap { + LineCap::Butt => {} + LineCap::Round => p.push(("stroke-linecap", "round".into())), + LineCap::Square => p.push(("stroke-linecap", "square".into())), + } + match s.join { + LineJoin::Miter => { + if (s.miter_limit - 4.0).abs() > 1e-9 { + p.push(("stroke-miterlimit", self.num(s.miter_limit))); + } + } + LineJoin::Round => p.push(("stroke-linejoin", "round".into())), + LineJoin::Bevel => p.push(("stroke-linejoin", "bevel".into())), + } + if let Some(d) = &s.dash + && d.is_dashed() + { + p.push(("stroke-dasharray", d.pattern.iter().map(|v| self.num(*v)).collect::>().join(" "))); + if d.offset != 0.0 { + p.push(("stroke-dashoffset", self.num(d.offset))); + } + } + } + + /// How stroke `st` is written (`bp`: the shape in document space, built on first use from + /// `paths`): its brush art, or a plain stroke or filled outlines matching the canvas. + pub(super) fn stroke_plan(&mut self, st: &StrokeLayer, bp: &mut Option, paths: &[&PathData]) -> StrokePlan { + if stroke::is_plain(st) { + return StrokePlan::Written(stroke::for_writer(&BezPath::new(), st)); + } + let bp = bp.get_or_insert_with(|| doc_path(paths)); + let doc = self.doc; + let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); + match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) { + Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(doc, b, bp, st)), + None => StrokePlan::Written(stroke::for_writer(bp, st)), + } + } + + /// The `clip-path` (inside) or `mask` (outside) attribute that keeps an aligned stroke on its + /// side of the shape `d`; `reach` (document space) is what the stroke covers. + pub(super) fn side_attr(&mut self, side: Option, d: &str, rule: FillRule, reach: Rect) -> String { + match side { + None | Some(StrokeAlign::Center) => String::new(), + Some(StrokeAlign::Inside) => { + let cid = self.fresh_id("clip-path"); + let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + self.def(1, &format!("")); + self.def(2, &format!("")); + 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); + let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); + let fr = if rule == FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; + self.def(1, &format!("")); + self.def(2, &format!("")); + self.def(2, &format!("")); + self.def(1, ""); + format!(" mask=\"url(#{mid})\"") + } + } + } + + /// The `href` of image bytes `b`: a `data:` URI, or with [`ImageMode::Link`] a file written + /// next to the SVG, named after the bytes (image keys such as "raster-1" repeat across + /// documents). + pub(super) fn blob_href(&mut self, b: &ImageBlob) -> String { + if self.opts.images != ImageMode::Link { + return format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)); + } + let name = format!("{}.{}", b.content_key(), image_ext(&b.mime)); + if self.linked_names.insert(name.clone()) { + self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() }); + } + name + } + + /// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is + /// drawn as pixels ([`GradientPaint::draws_as_pixels`]) when it paints a fill (`fill`) or a + /// stroke: an `` over its painted box, clipped to `clip` (outlines in user space filled + /// by `rule`), in a group with `props` and the `side` attribute. A freeform gradient's colour + /// field, which SVG can't express, is sampled as the canvas samples it (at twice the box's size + /// in points); a linear or radial gradient at the document's raster effects resolution (at most + /// [`MAX_GRADIENT_PX`] a side), with dither's noise on a fill. Nothing is written for an empty + /// box. + #[allow(clippy::too_many_arguments)] + pub(super) fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str, fill: bool) { + use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale}; + let Some(b) = painted_box(bounds) else { return }; + let freeform = g.gradient.kind == GradientKind::Freeform; + let (rgba, cols, rows): (Vec, u16, u16) = if freeform { + let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height())); + let field = g.freeform_on(b).field(spread_scale(b)); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + (field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows) + } else { + let k = self.doc.raster_effects_ppi / 72.0; + let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16; + let (cols, rows) = (along(b.width()), along(b.height())); + (g.pixels(b, b, cols, rows, fill, &Color::to_rgb).flatten().collect(), cols, rows) + }; + let mut png = Vec::new(); + let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return }; + if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { + return; + } + self.warn(if freeform { + "freeform gradients are written as images clipped to their shapes" + } else { + "linear and radial gradients drawn as pixels are written as images clipped to their shapes" + }); + let href = self.blob_href(&ImageBlob::new("image/png", png)); + let cid = self.fresh_id("clip-path"); + let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; + self.def(1, &format!("")); + for d in clip { + self.def(2, &format!("")); + } + self.def(1, ""); + let a = self.attrs(props); + let nested = !side.is_empty(); + if nested { + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + } else { + self.line(&format!("")); + } + self.depth += 1; + let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height())); + let tr = if self.xf == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(self.xf)) }; + self.line(&format!( + "", + xml_escape(&href) + )); + self.depth -= 1; + self.line(""); + if nested { + self.depth -= 1; + self.line(""); + } + } + + /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` + /// per effect, the first effect innermost (the reference app's stacking order). A drop shadow + /// with Darkness darkens the screen colors, in a CMYK document too. Photoshop-style + /// filters have no SVG equivalent: they are left out with a warning (exports from the app turn + /// such objects into images first). + pub(super) fn filtered(&mut self, n: &Node) { + let mut inner = n.clone(); + inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); + let opened = self.open_filters(n, &n.appearance.effects); + self.node_body(&inner); + self.close_filters(opened); + } + + /// Open one `` per visible raster effect of `effects` (an object's, or one fill or + /// stroke's) on `n`, outermost last effect; returns how many to close. + pub(super) 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 bounds = n.visual_bounds_with(&self.doc.symbol_boxes()); + // An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first. + let grow = 18.0 / self.doc.raster_ppi(); + let reach: f64 = + fx.iter().map(|f| f.outset(bounds.unwrap_or_default()) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }).sum::() + + 2.0; + let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); + let mut opened = 0; + for f in fx.iter().rev() { + if matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)) { + self.warn("SVG has no pixel effects such as Radial Blur, Smart Blur or Unsharp Mask: they are left out"); + continue; + } + let fid = self.fresh_id("filter"); + let region_attr = match region { + Some(r) => format!( + " filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", + self.num(r.x0), + self.num(r.y0), + self.num(r.width()), + self.num(r.height()) + ), + None => String::new(), + }; + let sd = |blur: f64| fmt_num((blur / 2.0).max(0.0), 3); + let flood = |c: &vectorcraft_color::Color, o: f32| { + format!("", c.to_hex(), fmt_num(o as f64, 3)) + }; + let body = match f { + // Darkness: the object blurred and moved, each color channel times 1 − darkness. + RasterFx::DropShadow { opacity, dx, dy, blur, darkness: Some(darkness), .. } => format!( + "", + sd(*blur), + self.num(*dx), + self.num(*dy), + fmt_num(*opacity as f64, 3), + keep = fmt_num(1.0 - *darkness as f64, 3) + ), + RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!( + "{}", + sd(*blur), + self.num(*dx), + self.num(*dy), + flood(color, *opacity) + ), + // The glow blurs a solid silhouette (alpha × 255 makes any alpha full) grown by a quarter raster pixel. + RasterFx::OuterGlow { opacity, blur, color, .. } => format!( + "{}", + fmt_num(grow, 3), + sd(*blur), + flood(color, *opacity) + ), + RasterFx::InnerGlow { opacity, blur, color, center, .. } => { + // Edge: the blurred inverse silhouette; Center: the blurred silhouette; both clipped to the shape. + let src = if *center { + "".replace("SD", &sd(*blur)) + } else { + format!( + "", + sd(*blur) + ) + }; + format!( + "{src}{}", + flood(color, *opacity) + ) + } + RasterFx::Feather { radius } => format!( + "", + sd(*radius) + ), + RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), + RasterFx::Pixel(_) | RasterFx::Rasterize(_) => continue, + }; + // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. + let mode = match f { + RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } + if *mode != BlendMode::Normal => + { + format!(" {}=\"{}\"", crate::import::BLEND, blend_css(*mode)) + } + _ => String::new(), + }; + self.def(1, &format!("{body}")); + self.line(&format!("")); + self.depth += 1; + opened += 1; + } + opened + } + + pub(super) fn close_filters(&mut self, opened: usize) { + for _ in 0..opened { + self.depth -= 1; + self.line(""); + } + } +} + +/// Most pixels along a side of a linear or radial gradient's image. +const MAX_GRADIENT_PX: f64 = 4096.0; + +/// The gradient a fill (`fill`) or stroke paint is, if it is drawn as pixels +/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one). +pub(super) fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> { + match p { + Paint::Gradient(g) if g.draws_as_pixels(fill) => Some(g), + _ => None, + } +} + +/// Any visible raster effect (shadow, glow, blur, feather) in `effects`? +pub(super) fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { + effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) +} diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs new file mode 100644 index 000000000..7b5e55d84 --- /dev/null +++ b/crates/svg/src/export/text.rs @@ -0,0 +1,806 @@ +use super::output::xml_text; +use super::*; + +/// `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(",") +} + +/// The characters type uses from one face, under one `@font-face` description: the family the +/// type names and the weight, italic and width its style asks for. +pub(super) struct FontUse { + family: String, + weight: u16, + italic: bool, + /// The `font-stretch` percentage ([`css::font_stretch`]), as bits; `None` for the normal width. + stretch: Option, + face: Arc, + chars: BTreeSet, +} + +impl Writer<'_> { + /// The ``/`` properties of a character style. `space`: the text's layout bounds + /// and the map from text space to user space, which character gradients resolve against + /// (as on the canvas). + fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props { + let decimals = self.opts.decimals; + let len = |v: f64| fmt_num(v, decimals); + let mut p = css::font_props(st, &len); + // Character paints resolve in the text's space (`to_user` maps it into user space). + let (bounds, to_user) = space; + let saved = std::mem::replace(&mut self.xf, to_user); + if let Paint::Gradient(g) = &st.fill + && g.gradient.dither + && g.gradient.kind != GradientKind::Freeform + { + self.warn("dithered gradients on characters are written without dither"); + } + let fill = self.paint(&st.fill, Some(bounds)); + p.push(("fill", fill)); + // An inside or outside stroke is drawn on the glyph outlines instead + // ([`Self::aligned_char_strokes`]): SVG strokes text centred only. + if st.has_stroke() && !aligned(st) { + // Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans + // the text grown by half the weight, as on the canvas. + let layer = st.stroke_layer(); + self.stroke_props(&layer, layer.width, Some(layer.paint_bounds(bounds)), &mut p); + } + self.xf = saved; + p.extend(css::type_props(st, &len)); + p + } + + /// Type: its characters (live text, or glyph outlines when text is exported as outlines) and, + /// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those + /// below the Characters row under the characters, the others over them. + /// + /// Inline graphics ([`vectorcraft_doc::TextRun::inline`]) are written after the characters as + /// instances of their symbols (a `` of the symbol's def where it can be shared), all in + /// one group carrying the object's transparency. The live text skips their characters and + /// places the text after each one; type on a path with inline graphics is written as outlines + /// (a `` couldn't leave room for the art). + pub(super) fn text_node(&mut self, n: &Node, t: &TextObject) { + let doc = self.doc; + let resolved = doc.inline_resolved(t); + let t = &*resolved; + let inline = t.runs.iter().any(|r| r.inline.is_some()); + // Ruby is written as outlines with its base text: a reader's can't place it. So are + // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869). + let unwritable = t.runs.iter().any(|r| r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal) + || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); + let outlined = + self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; + let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; + let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()); + // Live type with an inside or outside character stroke writes that stroke apart from the + // text, in the group. + let apart = !outlined && t.runs.iter().any(|r| aligned(&r.style)); + if !painted && !inline && !apart { + return chars(self, n); + } + let id = self.id_attr(n); + let a = self.attrs(&css::transparency(n)); + self.line(&format!("")); + self.depth += 1; + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let mut all = lay.to_bezpath(); + for (_, bar) in vectorcraft_text::decorations(&lay, db, t) { + all.extend(bar.iter()); + } + let pd = PathData::from_bezpath(&all).transformed(t.xf); + let d = self.path_d(&pd, self.xf); + let tb = Some(t.xf.transform_rect_bbox(lay.bounds)); + let (below, above) = n.appearance.split_contents(); + let glyphs = |w: &mut Self, items: &[AppearanceItem]| { + if !items.is_empty() { + let ap = vectorcraft_doc::Appearance { items: items.to_vec(), ..Default::default() }; + w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, tb); + } + }; + glyphs(self, below); + // The group carries the id and the object's transparency. + let mut bare = n.clone(); + (bare.opacity, bare.blend, bare.isolate) = (1.0, BlendMode::Normal, false); + let anonymous = std::mem::replace(&mut self.anonymous, true); + chars(self, &bare); + for ig in &lay.inlines { + let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; + let inst = Node::new( + NodeId(u64::MAX), + NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf: t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol) }, + ); + self.node(&inst); + } + glyphs(self, above); + self.anonymous = anonymous; + self.depth -= 1; + self.line(""); + } + + /// Text as glyph outlines: one compound path per run, painted like the run (gradients span + /// the whole text), over its colour glyphs, as on the canvas. + fn text_outlines(&mut self, n: &Node, t: &TextObject) { + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; + // Underline and strikethrough bars join their run's outlines; ruby glyphs are painted + // with the run of the text they annotate. + let bars = vectorcraft_text::decorations(&lay, db, t); + for (run, outline) in lay + .glyphs + .iter() + .map(|g| (g.run, &g.outline)) + .chain(lay.ruby.iter().map(|g| (g.run, &g.outline))) + .chain(bars.iter().map(|(r, b)| (*r, b))) + { + if let Some(bp) = runs.get_mut(run) { + bp.extend(outline.iter()); + } + } + let id = self.id_attr(n); + let a = self.attrs(&css::transparency(n)); + // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group + // names its text for screen readers and search. + let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { + format!(" role=\"img\" aria-label=\"{}\"", xml_escape(&t.plain_text())) + } else { + String::new() + }; + self.line(&format!("")); + self.depth += 1; + // The glyphs stay in text space; the text's transform joins the document's meanwhile. + let saved = self.xf; + self.xf = saved * t.xf; + for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { + // Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as + // their images, COLR and SVG glyphs as their art. + for g in lay.glyphs.iter().filter(|g| g.run == i) { + if let Some((image, to_text)) = g.placed_image() { + let m = self.matrix(self.xf * to_text); + let href = self.blob_href(&image.blob); + self.line(&format!( + "", + image.width, + image.height, + xml_escape(&href) + )); + } + if let Some(art) = g.placed_colour(Affine::IDENTITY) { + self.node(&art); + } + } + if bp.elements().is_empty() { + continue; + } + let pd = PathData::from_bezpath(bp); + // An unnamed id: the pieces carry no id attribute (the group has it). + let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); + let d = self.path_d(&pd, self.xf); + self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); + } + self.xf = saved; + self.depth -= 1; + self.line(""); + } + + /// Point and area type, every laid-out line at its own position: wraps, indents, tabs and + /// spacing come out as on the canvas. Centred and right-aligned lines are anchored + /// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font + /// differs; other lines are placed where each style run (and, justified, each word) starts. + fn text(&mut self, n: &Node, t: &TextObject) { + // Live SVG text is written in logical order from left-aligned pieces: bidirectional text + // (and right-to-left paragraphs) keep their look as outlines until it is written with + // `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose + // `direction="rtl"` would anchor their pieces at the right end. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft)); + if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs { + self.warn("bidirectional text is outlined to preserve shaping and visual order"); + return self.text_outlines(n, t); + } + if let TextKind::OnPath { path, start, end } = &t.kind { + // A reader lays a ``'s glyphs straight: joined glyphs bent along the path + // keep their look as outlines. + if lay.glyphs.iter().any(|g| g.bend.is_some()) { + self.warn("joined type bent along a path is outlined to keep its look"); + return self.text_outlines(n, t); + } + // `` has a start offset only: an end bracket, Align to Path and Spacing + // keep their look as outlines. + if end.is_some() || t.path_align != vectorcraft_doc::PathAlign::Baseline || t.path_spacing != 0.0 { + self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look"); + return self.text_outlines(n, t); + } + // It cannot hang glyphs outside the start bracket either. + if hangs_on_path(t, &lay) { + self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); + return self.text_outlines(n, t); + } + // A `` holds the characters of the text, not the bullets or numbers drawn + // before them. + if !lay.markers.is_empty() { + self.warn("type on a path with bullets or numbers is outlined to keep its look"); + return self.text_outlines(n, t); + } + return self.text_on_path(n, t, path, *start); + } + self.note_fonts(t, &lay); + let lines = text_lines(t, &lay, self.opts.fewer_tspans); + // `drawn` holds the styles the layout draws the text in: Shrink Text to Fit scales its sizes, explicit leading and baseline shifts. + let fitted = (lay.fit_scale != 1.0).then(|| vectorcraft_text::scaled(t, lay.fit_scale)); + let drawn = fitted.as_ref().unwrap_or(t); + // Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its + // stop, and so does the text after an inline graphic: lines with tabs, and text with + // inline graphics, are never anchored. + let has_inline = t.runs.iter().any(|r| r.inline.is_some()); + let anchors: Vec> = lines + .iter() + .map(|l| { + match t.para_at(l.para).justify { + Justify::Center => Some(("middle", 0.5)), + Justify::Right => Some(("end", 1.0)), + _ => None, + } + .filter(|_| !has_inline && !l.segs.iter().any(|s| s.brk)) + }) + .collect(); + // Every line anchored alike (the text's only paragraph style, typically): the + // carries the anchor. Otherwise each anchored line is a carrying its own. + let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten(); + // The element's user space is text space. + let space = (lay.bounds, Affine::IDENTITY); + let mut base_props = self.char_props(&drawn.first_style(), space); + // A `` cannot undo the decoration of the `` around it: with bullets, which + // have none, each run's `` carries its own. + if !lay.markers.is_empty() { + base_props.retain(|(k, _)| *k != "text-decoration"); + } + let base = TextBase { props: base_props, space, anchored: uniform.is_some() }; + let mut props = base.props.clone(); + if let Some((a, _)) = uniform { + props.push(("text-anchor", a.into())); + } + props.extend(css::transparency(n)); + let id = self.id_attr(n); + let a = self.attrs(&props); + let m = self.xf * t.xf; + let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; + let starts: Vec> = lines.iter().zip(&anchors).map(|(l, a)| line_start(&l.segs, a.map(|a| a.1))).collect(); + // The `` carries the first line's position too, so readers that place text by its + // own x/y start where the first line does. + let at = match starts.iter().flatten().next() { + Some((x, y)) => format!(" x=\"{}\" y=\"{}\"", self.num(*x), self.num(*y)), + None => String::new(), + }; + let mut s = format!(""); + for ((line, start), anchor) in lines.iter().zip(starts).zip(&anchors) { + let Some(start) = start else { continue }; + match anchor.filter(|_| uniform.is_none()) { + // A line anchored on its own: one positioned with the anchor around it. + Some((a, _)) => { + s.push_str(&format!("", self.num(start.0), self.num(start.1))); + let inner = TextBase { anchored: true, ..base.clone() }; + self.text_line(&mut s, drawn, &line.segs, start, &inner, false); + s.push_str(""); + } + None => self.text_line(&mut s, drawn, &line.segs, start, &base, true), + } + } + s.push_str(""); + self.line(&s); + self.aligned_char_strokes(t); + } + + /// The inside and outside character strokes of live type `t`, over its text (which goes + /// without them, see [`Self::char_props`]): each such run's stroke on its glyph outlines, drawn + /// twice as wide and clipped or masked to its side as aligned path strokes are + /// ([`Self::side_attr`]). The text stays live; only the stroke follows the outlines. + fn aligned_char_strokes(&mut self, t: &TextObject) { + if !t.runs.iter().any(|r| aligned(&r.style)) { + return; + } + let db = vectorcraft_text::FontDb::global(); + let lay = vectorcraft_text::layout(db, t); + let bars = vectorcraft_text::decorations(&lay, db, t); + // The glyphs stay in text space; the text's transform joins the document's meanwhile. + let saved = self.xf; + self.xf = saved * t.xf; + for (i, run) in t.runs.iter().enumerate().filter(|(_, r)| aligned(&r.style)) { + let mut bp = BezPath::new(); + for g in lay.glyphs.iter().filter(|g| g.run == i) { + bp.extend(g.outline.iter()); + } + for (_, bar) in bars.iter().filter(|(r, _)| *r == i) { + bp.extend(bar.iter()); + } + if bp.elements().is_empty() { + continue; + } + let pd = PathData::from_bezpath(&bp); + let ap = vectorcraft_doc::Appearance { items: vec![AppearanceItem::Stroke(run.style.stroke_layer())], ..Default::default() }; + let d = self.path_d(&pd, self.xf); + self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); + } + self.xf = saved; + } + + /// One line of laid-out text starting at `start`. Default: a positioned `` per segment + /// (only the first of an anchored line). Fewer tspans: one positioned `` per line with + /// the style changes nested in it, positioned again only after tabs and inline graphics. + /// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted. + /// `positioned`: false when the caller already positioned the line (an outer ``). + fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) { + let fewer = self.opts.fewer_tspans && positioned; + if fewer { + s.push_str(&format!("", self.num(start.0), self.num(start.1))); + } + let mut place = !fewer && positioned; + let mut first = true; + // The baseline shift the current text position carries. + let mut shift = 0.0; + for seg in line { + let Some(run_style) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; + let marker_style; + let st = if seg.marker { + marker_style = vectorcraft_doc::text::ParaList::marker_style(run_style); + &marker_style + } else { + run_style + }; + let mut attrs = String::new(); + if place { + let (x, y) = if first { start } else { (seg.x, seg.y) }; + attrs.push_str(&format!(" x=\"{}\" y=\"{}\"", self.num(x), self.num(y))); + shift = 0.0; + } + first = false; + if st.baseline_shift != shift { + attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift))); + shift = st.baseline_shift; + } + // After a tab, an inline graphic or a justified word space, and before a character that a + // reader would draw elsewhere, the next segment is placed. + place = seg.brk || (!fewer && !base.anchored && positioned); + if (st.h_scale - st.v_scale).abs() > 1e-9 || seg.scale != 1.0 { + attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance * seg.scale))); + } + if st.rotation != 0.0 { + attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation))); + } + let mut diff = run_diff(&base.props, self.char_props(st, base.space)); + diff.extend(direction_props(st)); + attrs.push_str(&self.attrs(&diff)); + let text = xml_text(&seg.text); + if attrs.is_empty() && fewer { + s.push_str(&text); + } else { + s.push_str(&format!("{text}")); + } + } + if fewer { + s.push_str(""); + } + } + + /// 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 (i, g) in lay.glyphs.iter().enumerate() { + let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue }; + // A colour font's subset would hold no bitmaps or COLR layers: its emoji are left to the + // viewer. + if g.image.is_some() || g.colour.is_some() { + self.warn("colour emoji fonts are not embedded: viewers show emoji in their own fonts"); + 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 stretch = css::font_stretch(st).map(f64::to_bits); + let at = self + .fonts + .iter() + .position(|u| u.face.id() == f.id() && (u.weight, u.italic, u.stretch) == (weight, italic, stretch) && u.family == st.font_family); + let at = at.unwrap_or_else(|| { + let family = st.font_family.clone(); + self.fonts.push(FontUse { family, weight, italic, stretch, 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 list marker's characters, a hyphen where a line breaks, capitals for All + // Caps, the style's Digits. + let src = lay.marker_text(i).unwrap_or_else(|| 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}').map(|c| st.digits.map(c)) { + 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`); where two of them share characters as well, a warning says + /// that browsers draw those in one of the faces. + pub(super) fn font_faces(&mut self) -> Vec { + let uses = std::mem::take(&mut self.fonts); + let mut rules = Vec::new(); + fn described(u: &FontUse) -> (&str, u16, bool, Option) { + (&u.family, u.weight, u.italic, u.stretch) + } + let mut clashing: Vec<&String> = vec![]; + for (i, u) in uses.iter().enumerate() { + let clash = uses + .get(..i) + .unwrap_or_default() + .iter() + .any(|o| described(o) == described(u) && o.face.id() != u.face.id() && !o.chars.is_disjoint(&u.chars)); + if clash && !clashing.contains(&&u.family) { + clashing.push(&u.family); + } + } + for family in clashing { + self.warn(&format!( + "type in {family} uses faces of one weight, style and width that share characters: browsers draw those characters in one of the embedded faces" + )); + } + 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| described(o) == described(u)).count() > 1; + let range = if shared { format!(";unicode-range:{}", unicode_range(&u.chars)) } else { String::new() }; + let stretch = u.stretch.map(|w| format!(";font-stretch:{}%", fmt_num(f64::from_bits(w), 3))).unwrap_or_default(); + let (mime, format) = if font.cff { ("font/otf", "opentype") } else { ("font/ttf", "truetype") }; + rules.push(format!( + "@font-face{{font-family:{};font-weight:{};font-style:{}{stretch};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); + let pid = self.fresh_id("text-path"); + let d = self.path_d(path, self.xf * t.xf); + 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(); + match t.para_at(0).justify { + Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())), + Justify::Right | Justify::JustifyRight => props.push(("text-anchor", "end".into())), + _ => {} + } + props.extend(css::transparency(n)); + let a = self.attrs(&props); + let offset = fmt_num(start * 100.0, 3); + let mut s = format!(""); + for run in &t.runs { + let mut diff = run_diff(&base, self.char_props(&run.style, space)); + diff.extend(direction_props(&run.style)); + let a = self.attrs(&diff); + s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); + } + s.push_str(""); + self.line(&s); + self.aligned_char_strokes(t); + } +} + +/// Does a character style stroke inside or outside its glyphs (not centred)? +fn aligned(st: &CharStyle) -> bool { + st.has_stroke() && st.stroke_align != StrokeAlign::Center +} + +/// Does type on a path `t`, laid out as `lay`, hang glyphs outside its brackets? It does when +/// Optical Margin Alignment or its first paragraph's Roman Hanging Punctuation is on and its +/// glyphs sit elsewhere with both off. +fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool { + if !t.optical_margin && !t.para_at(0).roman_hanging { + return false; + } + let mut flush = t.clone(); + flush.optical_margin = false; + let mut styles = flush.paragraph_styles(); + for p in &mut styles { + p.roman_hanging = false; + } + flush.set_paragraph_styles(styles); + let flush = vectorcraft_text::layout(FontDb::global(), &flush); + lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin) +} + +/// The `direction` and `unicode-bidi` of a `` of characters in style `st`: an embedding in +/// its Character Direction, and nothing without one. Each `` of characters with a direction +/// carries them, and the `` does not. Type with a run set Right to Left is written as +/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only. +fn direction_props(st: &CharStyle) -> Props { + let Some(d) = st.char_direction else { return Props::new() }; + let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" }; + vec![("direction", direction.into()), ("unicode-bidi", "embed".into())] +} + +/// What the `` of a run whose properties are `run` sets over its ``'s `base`: the +/// properties that differ, and the initial value of each one `base` sets and the run doesn't. +fn run_diff(base: &Props, run: Props) -> Props { + let reset: Props = + base.iter().filter(|(k, _)| !run.iter().any(|(r, _)| r == k)).filter_map(|(k, _)| initial_value(k).map(|v| (*k, v.to_string()))).collect(); + run.into_iter().filter(|kv| !base.contains(kv)).chain(reset).collect() +} + +/// The initial value of an inherited property a run may leave unset (decorations can't be undone +/// on a ``). +fn initial_value(k: &str) -> Option<&'static str> { + Some(match k { + "font-weight" | "font-style" | "font-stretch" | "font-feature-settings" | "font-variation-settings" => "normal", + "stroke" | "stroke-dasharray" => "none", + "stroke-width" | "stroke-opacity" => "1", + "stroke-linecap" => "butt", + "stroke-linejoin" => "miter", + "stroke-miterlimit" => "4", + "stroke-dashoffset" | "letter-spacing" => "0", + "font-kerning" => "auto", + _ => return None, + }) +} + +/// How closely stroke outlines that clip an image follow the stroke (points). +pub(super) const OUTLINE_TOLERANCE: f64 = 0.01; + +/// What the lines of one `` share. +#[derive(Clone)] +struct TextBase { + /// The `` element's character properties, which each `` differs from. + props: Props, + /// The text's layout bounds and the map from text space to user space (character paints). + space: (Rect, Affine), + /// Lines anchored at their centre or right end (`text-anchor`), positioned once each. + anchored: bool, +} + +/// One laid-out line: its paragraph (index) and its segments. +struct Line { + para: usize, + segs: Vec, +} + +/// Characters of one style on one line, written as one ``. +struct Segment { + /// Index into `TextObject::runs`. + run: usize, + /// Where a reader starts drawing the segment (text space; `y` is the baseline). + x: f64, + y: f64, + text: String, + /// The width a reader gives the segment (tracking included). On a line that is justified or + /// set with Mojikumi (unless `fewer`), that is the width without justification and Mojikumi + /// ([`reader_advances`]), for horizontally scaled type also without its last character's + /// kerning against the character after it ([`end_segment`]); elsewhere, the laid-out width. + advance: f64, + /// The next segment needs its own position (after a tab, a word space of a justified line, or + /// the next segment starts with a character the layout draws elsewhere than a reader would). + brk: bool, + /// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to + /// (`textLength`); 1 for other segments. + scale: f64, + /// A list marker's characters and tab, written in + /// [`vectorcraft_doc::text::ParaList::marker_style`] of the run's style. + marker: bool, +} + +/// How far a reader may draw a character from where the layout draws it before the character is +/// given its own position (points). +const PLACE_TOLERANCE: f64 = 1e-6; + +/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and, +/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters +/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all +/// caps are upper case. Unless `fewer`, a line that is justified or composed with Mojikumi also +/// starts a new segment at each character the layout draws elsewhere than a reader continuing +/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between +/// the characters of a justified Japanese line, and the spacing Mojikumi changes. +fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec { + let plain = t.plain_text(); + let mut lines = Vec::with_capacity(lay.lines.len()); + let (mut kerned, mut unkerned) = (None, None); + for line in &lay.lines { + let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count()); + let style = t.para_at(para); + let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); + // Each cluster's advance and drawing offset as a reader gives them, by its byte offset. + // Justification…'s Desired word and letter spacing and glyph scaling move characters from + // where a reader draws them, on every line. + let spaced = !fewer && !style.justification.desired_is_default(); + let reader = if split_words || spaced || (!fewer && !style.mojikumi.is_none()) { + Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true))) + } else { + None + }; + // Where a reader draws the next character of the last segment, and the byte offset and + // reader's advance of the last segment's last cluster. + let mut pen = 0.0; + let mut tail = None; + let mut segs: Vec = Vec::new(); + let mut cluster = None; + // A list marker's glyphs write its characters and then a tab (glyph `i` of the layout). + let source = |i: usize, g: &vectorcraft_text::PositionedGlyph| { + lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }) + }; + let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default(); + // Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line. + if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) { + while let Some((_, rest)) = + glyphs.split_last().filter(|(g, rest)| source(line.glyph_start + rest.len(), g).chars().all(|c| c.is_whitespace() && c != '\t')) + { + glyphs = rest; + } + } + let mut after_inline = false; + for (k, g) in glyphs.iter().enumerate() { + let Some(run) = t.runs.get(g.run) else { continue }; + // A list marker before an inline graphic is in the graphic's run, and is text. + if run.inline.is_some() && lay.marker_of(line.glyph_start + k).is_none() { + // An inline graphic (written as art): the text after it is placed anew. + if let Some(last) = segs.last_mut() { + last.brk = true; + } + after_inline = true; + cluster = None; + continue; + } + // The cluster as a reader sets it (a hyphen the layout adds is set as laid out). + let set = reader.and_then(|r| r.get(&g.byte)).filter(|_| g.len > 0); + if g.len > 0 && cluster == Some(g.byte) { + // Another glyph of the same cluster (a reader's advance covers the whole cluster). + if let Some(last) = segs.last_mut().filter(|_| set.is_none()) { + last.advance += g.advance; + pen += g.advance; + } + continue; + } + // A glyph that stands for no character (a list marker before the paragraph's first + // character) starts no cluster. + cluster = (g.len > 0).then_some(g.byte); + let (advance, x) = match set { + Some(&(advance, offset)) => { + // A glyph the layout draws before its pen (Mojikumi) starts that much earlier. + let x = g.xf.translation().x - offset; + (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) + } + None => (g.advance, g.origin.x), + }; + let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); + let piece = if run.style.all_caps { src.to_uppercase() } else { src }; + let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); + // A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width. + let scale = if reader.is_some() && (g.scale - 1.0).abs() > 1e-9 && g.scale.is_finite() { g.scale } else { 1.0 }; + let fresh = std::mem::take(&mut after_inline) || scale != 1.0 || segs.last().is_some_and(|last| last.scale != 1.0); + // The layout draws the character elsewhere than a reader continuing the line would. + let moved = reader.is_some() + && segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE); + pen = x + advance; + // A list marker is a segment of its own, and so is the text after it. In horizontally + // scaled type a tab is a segment apart from the characters before it, so a reader + // stretching those characters to their width (`textLength`) stretches them alone. + let marker = lay.marker_of(line.glyph_start + k).is_some(); + let tab_apart = piece == "\t" && (run.style.h_scale - run.style.v_scale).abs() > 1e-9; + match segs.last_mut() { + Some(last) if last.run == g.run && !last.brk && !fresh && !moved && last.marker == marker && !tab_apart => { + last.text.push_str(&piece); + last.advance += advance; + last.brk = brk; + } + last => { + if let Some(last) = last { + last.brk |= moved || last.marker != marker; + end_segment(last, tail, t, lay, &mut unkerned); + } + segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale, marker }); + } + } + tail = set.map(|_| (g.byte, advance)); + } + if let Some(last) = segs.last_mut() { + end_segment(last, tail, t, lay, &mut unkerned); + } + segs.retain(|s| !s.text.is_empty()); + if !segs.is_empty() { + lines.push(Line { para, segs }); + } + } + lines +} + +/// Ends segment `seg` of horizontally scaled type (or a glyph Glyph Scaling scales), which a reader stretches to its width +/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance +/// a reader gives it at the end of a ``, without the font's kerning against the character +/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them. +fn end_segment( + seg: &mut Segment, + tail: Option<(usize, f64)>, + t: &TextObject, + lay: &vectorcraft_text::TextLayout, + unkerned: &mut Option>, +) { + let Some((byte, advance)) = tail else { return }; + if seg.scale == 1.0 && t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) { + return; + } + if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) { + seg.advance += alone - advance; + } +} + +/// The advance a reader of live SVG text gives each character cluster of `t` and how far from +/// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out +/// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by +/// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual +/// kerning leaves it out). +fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap { + let mut set = TextObject { kind: TextKind::Point, ..t.clone() }; + for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) { + p.mojikumi = vectorcraft_doc::Mojikumi::None; + // A reader spaces words and letters as the font does. + p.justification = vectorcraft_doc::JustificationOptions::default(); + } + if !kerning { + for r in &mut set.runs { + r.style.kerning.get_or_insert(0.0); + } + } + let mut out: HashMap = HashMap::new(); + for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) { + let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale)); + *advance += g.advance * lay.fit_scale; + } + out +} + +/// Where a line of segments starts: where its first segment starts or, `anchor`ed (0.5: center, 1: +/// right end), that point of its width. `None` for an empty line. +fn line_start(line: &[Segment], anchor: Option) -> Option<(f64, f64)> { + let (first, last) = (line.first()?, line.last()?); + let x = match anchor { + Some(f) => first.x + f * (last.x + last.advance - first.x), + None => first.x, + }; + Some((x, first.y)) +}