Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_graphics/src/svg_doc.rs

36.4 KiB, 149 runs

created by r1870400018:37322, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! An SVG document, read into flat drawing operations.
2//!
3//! Where [`crate::svg`] reads only the `d` of one `<path>`, this reads the element tree a whole file
4//! carries: the `viewBox`, the nested `<g transform>` frames, the shapes (`<path>`, `<rect>`, `<circle>`,
5//! `<ellipse>`, `<line>`, `<polyline>`, `<polygon>`), and the `<use>` references that place an outline
6//! held in `<defs>`. Two kinds of file are the case in hand, and the reader spans both. One is what a
7//! typesetter emits -- Typst's cetz plots -- a regular subset of paths, `<use>` glyphs and
8//! `translate`/`matrix` transforms with colour as `#rrggbb`. The other is what an illustrator emits --
9//! Inkscape -- where presentation cascades down the group tree (`<g fill="#f00">` colours its children),
10//! the same properties may arrive as a `style="fill:#f00"` attribute instead, primitives stand in for
11//! paths, opacity rides its own attributes, and `fill-rule="evenodd"` asks a holed shape to fill with
12//! its overlaps read as holes.
13//!
14//! Live `<text>`/`<tspan>` runs an Inkscape file keeps are read, but not shaped: this reader has no font,
15//! so a run comes out as an [`SvgOp::Text`] carrying the string, the anchor point, the enclosing frame,
16//! the size and the face hints, for a caller that does have a font set to shape to glyph outlines. An
17//! embedded `<image>` (a base64 PNG or JPEG) is decoded here to straight RGBA and placed as an
18//! [`SvgOp::Image`]. What is still left at the door: `<clipPath>` and markers (arrowheads);
19//! `filter`/`pattern`; a `<text>` rotated or sheared by its frame keeps its position but is shaped upright;
20//! and an `<image>` under a rotation or shear is placed by its axis-aligned bounds.
21//! A gradient fill (`url(#id)`) resolves to the flat mean of its stops -- a true axial or radial shading
22//! would need a paint the op set does not model -- and a reference to nothing draws nothing rather than
23//! failing the read. A file reaching past all this is read as far as it fits and the rest is left.
24//!
25//! The one thing worth stating is what happens to a typesetter's text. It does not leave `<text>` in its
26//! SVG; it bakes each glyph to an outline, files the outline once as a `<symbol>`, and places it with a
27//! `<use>` whose enclosing group carries the position and the y-flip that turns a font's y-up outline the
28//! right way up. So no font is needed to read that text back: the outlines are already in the file, and a
29//! `<use>` is just a filled path fetched by id. An illustrator's `<text>`, by contrast, is still live
30//! text, so the reader hands it on unshaped for the caller with a font to bake -- see [`SvgOp::Text`].
31//!
32//! The geometry comes out in the `viewBox`'s own units, y down, which for a Typst file are points. A
33//! caller sizing the picture to a figure width scales every path by one factor; nothing here bakes that
34//! in, so the picture is read once and drawn at whatever size is wanted.
35//!
36//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
37//! Anthropic Claude
38
39use crate::colour::Rgba;
40use crate::path::{
41 Bounds,
42 Path,
43 PathBuilder,
44 Pt,
45 Seg,
46};
47use crate::stroke::{
48 Cap,
49 Dash,
50 Join,
51 Stroke,
52};
53use crate::transform::Transform;
54
55use oxedyne_fe2o3_core::prelude::*;
56use oxedyne_fe2o3_text::base64;
57use oxedyne_fe2o3_text::xml::{
58 Elem,
59 Node,
60 Xml,
61};
62
63use std::collections::HashMap;
64
65/// One drawing operation read from an SVG document: a filled or a stroked path, a live text run, or a
66/// decoded raster, in the document's own frame (y down, `viewBox` units). Path geometry is flattened of
67/// its element tree -- every transform baked in -- so a caller need only scale and place it. A text run,
68/// which needs a font this reader has none of, is instead handed on with its own frame (`local`), for the
69/// caller to shape; a raster carries its pixels and a placement rectangle already in the picture frame.
70#[derive(Clone, Debug)]
71pub enum SvgOp {
72 Fill { path: Path, colour: Rgba },
73 Stroke { path: Path, colour: Rgba, stroke: Stroke },
74 // A live `<text>`/`<tspan>` run left unshaped: `local` maps its own frame (y down) to the picture
75 // frame, `x`/`y` are the anchor and baseline in that frame, `size` the font-size in its units.
76 Text {
77 text: String,
78 local: Transform,
79 x: f32,
80 y: f32,
81 size: f32,
82 anchor: Anchor,
83 italic: bool,
84 bold: bool,
85 colour: Rgba,
86 },
87 // A decoded raster and the rectangle it fills, top-left (x, y), w wide and h tall, in the picture frame.
88 Image {
89 rgba: Vec<u8>, // straight RGBA, row-major, top row first
90 iw: usize, // image pixel width
91 ih: usize, // image pixel height
92 x: f32,
93 y: f32,
94 w: f32,
95 h: f32,
96 },
97}
98
99/// Where a `<text>` run's anchor point sits along the run: at its start, middle or end, per SVG's
100/// `text-anchor`. The caller applies it once the run's advance is known from shaping.
101#[derive(Clone, Copy, Debug)]
102pub enum Anchor {
103 Start,
104 Middle,
105 End,
106}
107
108/// A read SVG document: its drawing operations and the size of its `viewBox`, in the `viewBox`'s units.
109#[derive(Clone, Debug)]
110pub struct SvgPicture {
111 pub ops: Vec<SvgOp>,
112 pub width: f32, // viewBox width, in its own units (points, for a typesetter's output)
113 pub height: f32, // viewBox height
114}
115
116/// The store `<defs>` fills for the draw walk to draw from: outlines a `<use>` fetches by id, and the
117/// flat mean colour a gradient `url(#id)` fill resolves to.
118struct Defs {
119 outlines: HashMap<String, Path>, // id -> concatenated outline, for <use>
120 gradients: HashMap<String, Rgba>, // id -> flat mean of the gradient's stops
121}
122
123/// The presentation state inherited down the group tree: fill and stroke and their opacities, the pen's
124/// width and joins, the dash pattern, the fill rule, and the cumulative group opacity. Every field
125/// carries down to a child, which overrides only the ones its own attributes or `style` set. The initial
126/// state is SVG's own defaults: a black fill, no stroke, full opacity, non-zero winding.
127#[derive(Clone)]
128struct Paint {
129 fill: Option<Rgba>, // resolved fill colour, or None for `fill:none`
130 fill_opacity: f32,
131 even_odd: bool, // fill-rule="evenodd"
132 stroke: Option<Rgba>, // resolved stroke colour, or None for no stroke
133 stroke_opacity: f32,
134 width: f32, // stroke width, in the element's own frame
135 cap: Cap,
136 join: Join,
137 miter: f32,
138 dash: Option<Vec<f32>>,
139 dash_offset: f32,
140 opacity: f32, // cumulative group opacity, folded into every emitted alpha
141 font_size: f32, // inherited font-size, in the element's own frame; 0 until a font sets one
142 text_anchor: Anchor, // inherited text-anchor
143 italic: bool, // inherited font-style: italic
144 bold: bool, // inherited font-weight: bold
145}
146
147impl Default for Paint {
148 fn default() -> Self {
149 Self {
150 fill: Some(Rgba::BLACK),
151 fill_opacity: 1.0,
152 even_odd: false,
153 stroke: None,
154 stroke_opacity: 1.0,
155 width: 1.0,
156 cap: Cap::Butt,
157 join: Join::Miter,
158 miter: 4.0,
159 dash: None,
160 dash_offset: 0.0,
161 opacity: 1.0,
162 font_size: 0.0,
163 text_anchor: Anchor::Start,
164 italic: false,
165 bold: false,
166 }
167 }
168}
169
170/// Reads an SVG document into a flat [`SvgPicture`].
171///
172/// The tree is walked once in document order, a transform and a presentation state accumulated down each
173/// branch, and every shape and `<use>` turned into an [`SvgOp`] with its transform already applied.
174/// `<defs>` is read first, for the outlines a `<use>` will fetch and the mean colours a gradient fill
175/// will resolve to, then skipped in the draw walk.
176pub fn read_document(src: &str) -> Outcome<SvgPicture> {
177 let xml = res!(Xml::parse(src));
178 let root = res!(xml.root());
179 if root.name.local() != "svg" {
180 return Err(err!(
181 "An SVG document's root is <svg>, but this one's is <{}>.", root.name.local();
182 Invalid, Input));
183 }
184
185 // The viewBox sets the coordinate frame and the picture's size. A file with none falls back to its
186 // width/height, and failing that to a unit box, so a malformed header still reads rather than stops.
187 let (vx, vy, vw, vh) = res!(view_box(root));
188
189 let mut defs = Defs { outlines: HashMap::new(), gradients: HashMap::new() };
190 collect_defs(root, &mut defs);
191
192 // Everything is expressed relative to the viewBox origin, so the walk begins with a translation that
193 // carries that origin to (0, 0); a Typst file's origin is already there and the translation is nil.
194 let base = Transform::translate(-vx, -vy);
195 let mut ops: Vec<SvgOp> = Vec::new();
196 res!(walk(root, &base, &Paint::default(), &defs, &xml, &mut ops));
197
198 Ok(SvgPicture { ops, width: vw, height: vh })
199}
200
201/// The viewBox as `(min-x, min-y, width, height)`, or a fallback drawn from `width`/`height`.
202fn view_box(root: &Elem) -> Outcome<(f32, f32, f32, f32)> {
203 if let Some(vb) = root.attr("viewBox") {
204 let n = numbers(vb);
205 if n.len() == 4 {
206 return Ok((n[0], n[1], n[2], n[3]));
207 }
208 }
209 let w = root.attr("width").map(length_pt).unwrap_or(0.0);
210 let h = root.attr("height").map(length_pt).unwrap_or(0.0);
211 if w > 0.0 && h > 0.0 {
212 return Ok((0.0, 0.0, w, h));
213 }
214 Err(err!(
215 "An SVG document needs a viewBox or a width and height to set its size; this one has neither.";
216 Invalid, Input, Missing))
217}
218
219/// Gathers the `<defs>` store in one descent: every element with an `id` that yields an outline (for a
220/// `<use>` to fetch), and every gradient's flat mean colour (for a `url(#id)` fill to resolve to). The
221/// search descends the whole tree, since Inkscape files an id-bearing shape wherever it likes, not only
222/// under `<defs>`.
223fn collect_defs(elem: &Elem, out: &mut Defs) {
224 for child in elem.elems() {
225 let name = child.name.local();
226 match name {
227 "linearGradient" | "radialGradient" => {
228 if let Some(id) = child.attr("id") {
229 if let Some(c) = gradient_mean(child) {
230 out.gradients.insert(id.to_string(), c);
231 }
232 }
233 },
234 _ => {
235 // Any id-bearing element that carries an outline can be the target of a <use>. The
236 // outline is baked in the frame a <use> expects: the element's own transform applied,
237 // which a font glyph relies on to carry its em-square scale (`scale(0.015625)`).
238 if let Some(id) = child.attr("id") {
239 let mut pb = PathBuilder::new();
240 gather_paths(child, &Transform::IDENTITY, &mut pb);
241 if let Ok(path) = pb.finish() {
242 if !path.is_empty() {
243 out.outlines.insert(id.to_string(), path);
244 }
245 }
246 }
247 },
248 }
249 collect_defs(child, out);
250 }
251}
252
253/// The flat mean colour of a gradient's stops: each stop's `stop-color` weighted equally, its
254/// `stop-opacity` folded into the alpha. A gradient with no stops of its own yields nothing, and the
255/// caller leaves such a fill unpainted. This is the flat approximation a true axial or radial shading is
256/// reduced to, since the flat op set carries no paint that varies across a shape.
257fn gradient_mean(grad: &Elem) -> Option<Rgba> {
258 let mut r = 0.0f32;
259 let mut g = 0.0f32;
260 let mut b = 0.0f32;
261 let mut a = 0.0f32;
262 let mut n = 0.0f32;
263 for stop in grad.elems() {
264 if stop.name.local() != "stop" {
265 continue;
266 }
267 let style = stop.attr("style").unwrap_or("");
268 let col = prop(stop, style, "stop-color")
269 .and_then(|v| paint_colour(&v, None))
270 .unwrap_or(Rgba::BLACK);
271 let op = prop(stop, style, "stop-opacity")
272 .map(|v| number(&v).clamp(0.0, 1.0))
273 .unwrap_or(1.0);
274 r += col.r as f32;
275 g += col.g as f32;
276 b += col.b as f32;
277 a += (col.a as f32) * op;
278 n += 1.0;
279 }
280 if n < 1.0 {
281 return None;
282 }
283 Some(Rgba::new(
284 (r / n).round() as u8,
285 (g / n).round() as u8,
286 (b / n).round() as u8,
287 (a / n).round().clamp(0.0, 255.0) as u8,
288 ))
289}
290
291/// Appends every shape outline held under an element into one builder, descending through any groups and
292/// composing each element's own `transform` as it goes. Primitive shapes are turned to paths on the way,
293/// so a `<use>` of a group of circles fetches them all, and a glyph's em-square scale rides down with it.
294fn gather_paths(elem: &Elem, ctx: &Transform, pb: &mut PathBuilder) {
295 let local = child_transform(elem, ctx);
296 // The element's own shape, when it is one, in its baked frame, before its children.
297 if let Some(path) = shape_path(elem) {
298 if let Ok(placed) = path.transform(&local) {
299 append_path(pb, &placed);
300 }
301 }
302 for child in elem.elems() {
303 gather_paths(child, &local, pb);
304 }
305}
306
307/// Replays one path's segments into a builder, so several outlines become one.
308fn append_path(pb: &mut PathBuilder, path: &Path) {
309 for seg in path.segs() {
310 match *seg {
311 Seg::MoveTo(p) => pb.move_to(p),
312 Seg::LineTo(p) => pb.line_to(p),
313 Seg::QuadTo(c, p) => pb.quad_to(c, p),
314 Seg::CubicTo(c0, c1, p) => pb.cubic_to(c0, c1, p),
315 Seg::Close => pb.close(),
316 }
317 }
318}
319
320/// Walks the draw tree, emitting an [`SvgOp`] for every painted shape and `<use>`.
321///
322/// `ctx` maps this element's local frame to the picture frame; a group's `transform` composes onto it for
323/// its children. `paint` is the presentation state inherited to this point; each element overrides only
324/// what its own attributes or `style` set, and hands the rest down. `<defs>` and the gradient elements
325/// are the store, gathered already, so they draw nothing here.
326fn walk(
327 elem: &Elem,
328 ctx: &Transform,
329 paint: &Paint,
330 defs: &Defs,
331 xml: &Xml,
332 ops: &mut Vec<SvgOp>,
333)
334 -> Outcome<()>
335{
336 for child in elem.elems() {
337 let name = child.name.local();
338 match name {
339 "defs" | "symbol" | "linearGradient" | "radialGradient" | "clipPath"
340 | "marker" | "mask" | "pattern" | "filter" | "title" | "desc" | "metadata"
341 => {}, // the store and the unrendered furniture, not drawn in place
342 "g" | "a" | "svg" => {
343 let local = child_transform(child, ctx);
344 let sub = resolve_paint(paint, child, defs);
345 res!(walk(child, &local, &sub, defs, xml, ops));
346 },
347 "use" => {
348 let sub = resolve_paint(paint, child, defs);
349 res!(emit_use(child, ctx, &sub, defs, ops));
350 },
351 "text" => {
352 // A live text run, handed on unshaped; its `<tspan>` children are read here, not descended.
353 let sub = resolve_paint(paint, child, defs);
354 res!(emit_text(child, ctx, &sub, defs, xml, ops));
355 },
356 "image" => {
357 res!(emit_image(child, ctx, ops));
358 },
359 _ => {
360 // A shape is painted with its resolved state; an unmodelled container may still hold
361 // drawable children, so it is descended with its own state resolved.
362 let sub = resolve_paint(paint, child, defs);
363 if let Some(shape) = shape_path(child) {
364 let local = child_transform(child, ctx);
365 res!(emit_shape(shape, &local, &sub, ops));
366 } else {
367 let local = child_transform(child, ctx);
368 res!(walk(child, &local, &sub, defs, xml, ops));
369 }
370 },
371 }
372 }
373 Ok(())
374}
375
376/// Resolves an element's presentation state from the inherited one: its `style` properties (which win)
377/// and its presentation attributes (which fall back), each overriding only what it names.
378fn resolve_paint(base: &Paint, elem: &Elem, defs: &Defs) -> Paint {
379 let mut p = base.clone();
380 let style = elem.attr("style").unwrap_or("");
381
382 if let Some(v) = prop(elem, style, "fill") {
383 p.fill = resolve_fill(&v, defs);
384 }
385 if let Some(v) = prop(elem, style, "fill-opacity") {
386 p.fill_opacity = number(&v).clamp(0.0, 1.0);
387 }
388 if let Some(v) = prop(elem, style, "fill-rule") {
389 p.even_odd = v.trim() == "evenodd";
390 }
391 if let Some(v) = prop(elem, style, "stroke") {
392 p.stroke = resolve_fill(&v, defs);
393 }
394 if let Some(v) = prop(elem, style, "stroke-opacity") {
395 p.stroke_opacity = number(&v).clamp(0.0, 1.0);
396 }
397 if let Some(v) = prop(elem, style, "stroke-width") {
398 p.width = number(&v).max(0.0);
399 }
400 if let Some(v) = prop(elem, style, "stroke-linecap") {
401 p.cap = match v.trim() {
402 "round" => Cap::Round,
403 "square" => Cap::Square,
404 _ => Cap::Butt,
405 };
406 }
407 if let Some(v) = prop(elem, style, "stroke-linejoin") {
408 p.join = match v.trim() {
409 "round" => Join::Round,
410 "bevel" => Join::Bevel,
411 _ => Join::Miter,
412 };
413 }
414 if let Some(v) = prop(elem, style, "stroke-miterlimit") {
415 p.miter = number(&v).max(1.0);
416 }
417 if let Some(v) = prop(elem, style, "stroke-dasharray") {
418 let pattern = numbers(&v);
419 if pattern.is_empty() || pattern.iter().all(|&x| x <= 0.0) || v.trim() == "none" {
420 p.dash = None;
421 } else {
422 p.dash = Some(pattern);
423 }
424 }
425 if let Some(v) = prop(elem, style, "stroke-dashoffset") {
426 p.dash_offset = number(&v);
427 }
428 if let Some(v) = prop(elem, style, "opacity") {
429 // Group opacity is not a paint of its own; it scales everything the subtree draws.
430 p.opacity *= number(&v).clamp(0.0, 1.0);
431 }
432 // The font state cascades like the paint, so a `<g font-size=.. text-anchor=middle>` sets it for the
433 // `<text>` runs beneath, which is where an Inkscape file often keeps it rather than on the text itself.
434 if let Some(v) = prop(elem, style, "font-size") {
435 let n = length_num(&v);
436 if n > 0.0 {
437 p.font_size = n;
438 }
439 }
440 if let Some(v) = prop(elem, style, "font-style") {
441 let t = v.trim();
442 p.italic = t == "italic" || t == "oblique";
443 }
444 if let Some(v) = prop(elem, style, "font-weight") {
445 let t = v.trim();
446 p.bold = t == "bold" || t == "bolder"
447 || t.parse::<f32>().map(|w| w >= 600.0).unwrap_or(false);
448 }
449 if let Some(v) = prop(elem, style, "text-anchor") {
450 p.text_anchor = parse_anchor(&v);
451 }
452 p
453}
454
455/// SVG's `text-anchor` (or the `text-align` shorthand Inkscape sometimes writes) as an [`Anchor`].
456fn parse_anchor(v: &str) -> Anchor {
457 match v.trim() {
458 "middle" | "center" => Anchor::Middle,
459 "end" | "right" => Anchor::End,
460 _ => Anchor::Start,
461 }
462}
463
464/// A property's value, from the element's `style` first and its presentation attribute second, so the
465/// `style` wins the SVG cascade as it should. Returned owned, since a `style` value is a slice of a
466/// larger string this does not keep.
467fn prop(elem: &Elem, style: &str, name: &str) -> Option<String> {
468 if let Some(v) = style_prop(style, name) {
469 return Some(v);
470 }
471 elem.attr(name).map(|s| s.to_string())
472}
473
474/// One declaration's value from a `style` attribute -- `name:value;name:value` -- or `None`. The scan is
475/// literal: property names in these files carry no whitespace or comments to normalise.
476fn style_prop(style: &str, name: &str) -> Option<String> {
477 for decl in style.split(';') {
478 let mut it = decl.splitn(2, ':');
479 let key = it.next().unwrap_or("").trim();
480 if key == name {
481 if let Some(val) = it.next() {
482 return Some(val.trim().to_string());
483 }
484 }
485 }
486 None
487}
488
489/// Resolves a `fill`/`stroke` value to a colour, or `None` for `none`, an unknown paint, or a reference
490/// to nothing. A `url(#id)` fill resolves to the flat mean of the named gradient's stops.
491fn resolve_fill(v: &str, defs: &Defs) -> Option<Rgba> {
492 let s = v.trim();
493 if s.is_empty() || s == "none" || s == "context-fill" || s == "context-stroke" {
494 return None;
495 }
496 if let Some(rest) = s.strip_prefix("url(") {
497 // The id inside `url(#id)`, taking everything up to the closing parenthesis and dropping the hash.
498 let id = rest.split(')').next().unwrap_or("").trim().trim_start_matches('#');
499 return defs.gradients.get(id).copied();
500 }
501 paint_colour(s, Some(defs))
502}
503
504/// Places a `<use>`'s referenced outline: its target fetched by id, offset by the element's `x`/`y`, and
505/// filled with the resolved fill. A reference to no known outline draws nothing rather than failing the
506/// read.
507fn emit_use(
508 elem: &Elem,
509 ctx: &Transform,
510 paint: &Paint,
511 defs: &Defs,
512 ops: &mut Vec<SvgOp>,
513)
514 -> Outcome<()>
515{
516 let href = match elem.attr("xlink:href").or_else(|| elem.attr("href")) {
517 Some(h) => h.trim_start_matches('#'),
518 None => return Ok(()),
519 };
520 let outline = match defs.outlines.get(href) {
521 Some(p) => p.clone(),
522 None => return Ok(()),
523 };
524 let x = elem.attr("x").map(number).unwrap_or(0.0);
525 let y = elem.attr("y").map(number).unwrap_or(0.0);
526 let local = child_transform(elem, ctx);
527 let local = Transform::translate(x, y).then(&local);
528 emit_shape(outline, &local, paint, ops)
529}
530
531/// The font state inherited down a `<text>` and reset by each `<tspan>`: the size, the anchor, the face
532/// hints, and the colour the run is painted. The size begins at zero -- a run that never gets one cannot
533/// be shaped and is dropped -- and the colour at the inherited fill.
534#[derive(Clone)]
535struct TextState {
536 size: f32, // font-size, in the element's own units
537 anchor: Anchor,
538 italic: bool,
539 bold: bool,
540 colour: Rgba,
541}
542
543impl TextState {
544 /// The starting state a `<text>` inherits, all cascaded down the group tree to this point: the font
545 /// size, the anchor, the face hints, and the fill colour.
546 fn from_paint(paint: &Paint) -> Self {
547 Self {
548 size: paint.font_size,
549 anchor: paint.text_anchor,
550 italic: paint.italic,
551 bold: paint.bold,
552 colour: paint.fill.unwrap_or(Rgba::BLACK),
553 }
554 }
555}
556
557/// Resolves a `<text>` or `<tspan>`'s font state from the inherited one: its `style` properties (which
558/// win) and its presentation attributes (which fall back), each overriding only what it names. A
559/// `fill:none` on labelled outline text falls back to the stroke colour, so the glyphs still show.
560fn text_state(base: &TextState, elem: &Elem, defs: &Defs) -> TextState {
561 let mut s = base.clone();
562 let style = elem.attr("style").unwrap_or("");
563
564 if let Some(v) = prop(elem, style, "font-size") {
565 let n = length_num(&v);
566 if n > 0.0 {
567 s.size = n;
568 }
569 }
570 if let Some(v) = prop(elem, style, "font-style") {
571 let t = v.trim();
572 s.italic = t == "italic" || t == "oblique";
573 }
574 if let Some(v) = prop(elem, style, "font-weight") {
575 let t = v.trim();
576 s.bold = t == "bold" || t == "bolder"
577 || t.parse::<f32>().map(|w| w >= 600.0).unwrap_or(false);
578 }
579 // `text-anchor` is the SVG property; `text-align` is the shorthand Inkscape writes on a `<tspan>`.
580 if let Some(v) = prop(elem, style, "text-anchor").or_else(|| prop(elem, style, "text-align")) {
581 s.anchor = parse_anchor(&v);
582 }
583 // Fill wins the colour; a declared `none` falls back to the stroke so outline text still paints; a
584 // stroke alone, with no fill declared, likewise sets the colour.
585 if let Some(v) = prop(elem, style, "fill") {
586 match resolve_fill(&v, defs) {
587 Some(c) => s.colour = c,
588 None => {
589 if let Some(sc) = prop(elem, style, "stroke").and_then(|w| resolve_fill(&w, defs)) {
590 s.colour = sc;
591 }
592 },
593 }
594 } else if let Some(v) = prop(elem, style, "stroke") {
595 if let Some(c) = resolve_fill(&v, defs) {
596 s.colour = c;
597 }
598 }
599 s
600}
601
602/// Reads a live `<text>` into text runs: its direct text at its own anchor, and each `<tspan>` at the
603/// tspan's anchor (falling back to the text's) with the tspan's own font state. The reader shapes none of
604/// it -- it carries no font -- so each run is an [`SvgOp::Text`] the caller with a font set bakes.
605fn emit_text(
606 elem: &Elem,
607 ctx: &Transform,
608 paint: &Paint,
609 defs: &Defs,
610 xml: &Xml,
611 ops: &mut Vec<SvgOp>,
612)
613 -> Outcome<()>
614{
615 let local = child_transform(elem, ctx);
616 let base = text_state(&TextState::from_paint(paint), elem, defs);
617 let tx = first_number(elem.attr("x"));
618 let ty = first_number(elem.attr("y"));
619
620 for kid in &elem.kids {
621 match kid {
622 Node::Elem(e) if e.name.local() == "tspan" => {
623 let st = text_state(&base, e, defs);
624 let sx = e.attr("x").map(|v| first_number(Some(v))).unwrap_or(tx);
625 let sy = e.attr("y").map(|v| first_number(Some(v))).unwrap_or(ty);
626 push_text_run(ops, &xml.text_of(e), &local, sx, sy, &st);
627 },
628 Node::Text(span) => {
629 push_text_run(ops, &xml.text(span), &local, tx, ty, &base);
630 },
631 _ => {},
632 }
633 }
634 Ok(())
635}
636
637/// Queues one text run, dropping a run with no size to shape at or no visible characters. A tab or a
638/// newline `xml:space="preserve"` leaves in the content -- a wrapped label keeps a line break inside its
639/// run -- becomes a space, since the run sets on one line and the shaper would otherwise draw the control
640/// character as a missing-glyph box.
641fn push_text_run(
642 ops: &mut Vec<SvgOp>,
643 text: &str,
644 local: &Transform,
645 x: f32,
646 y: f32,
647 st: &TextState,
648) {
649 let cleaned: String = text
650 .chars()
651 .map(|c| if c == '\n' || c == '\r' || c == '\t' { ' ' } else { c })
652 .collect();
653 let cleaned = cleaned.trim();
654 if st.size <= 0.0 || cleaned.is_empty() {
655 return;
656 }
657 ops.push(SvgOp::Text {
658 text: cleaned.to_string(),
659 local: *local,
660 x,
661 y,
662 size: st.size,
663 anchor: st.anchor,
664 italic: st.italic,
665 bold: st.bold,
666 colour: st.colour,
667 });
668}
669
670/// Decodes an embedded `<image>` -- a `data:...;base64,` PNG or JPEG -- and places its rectangle in the
671/// picture frame. A file reference, an unreadable payload or an unknown raster draws nothing rather than
672/// failing the whole read. The placement is mapped through the element's frame by its corners, so a
673/// translate or a scale is exact; a rotation or a shear is approximated by the axis-aligned bounds.
674fn emit_image(elem: &Elem, ctx: &Transform, ops: &mut Vec<SvgOp>) -> Outcome<()> {
675 let href = match elem.attr("xlink:href").or_else(|| elem.attr("href")) {
676 Some(h) => h,
677 None => return Ok(()),
678 };
679 let payload = match href.find("base64,") {
680 Some(i) => &href[i + "base64,".len()..],
681 None => return Ok(()), // a file reference carries no bytes to decode here
682 };
683 // Inkscape wraps the payload across lines; the decoder refuses whitespace, so strip it first.
684 let clean: String = payload.chars().filter(|c| !c.is_ascii_whitespace()).collect();
685 let bytes = match base64::decode(&clean) {
686 Ok(b) => b,
687 Err(_) => return Ok(()),
688 };
689 let iw;
690 let ih;
691 let rgba;
692 if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
693 let pm = res!(crate::pixmap::Pixmap::from_png(&bytes));
694 iw = pm.width();
695 ih = pm.height();
696 rgba = pm.into_data();
697 } else if bytes.starts_with(&[0xFF, 0xD8]) {
698 let pm = res!(crate::pixmap::Pixmap::from_jpeg(&bytes));
699 iw = pm.width();
700 ih = pm.height();
701 rgba = pm.into_data();
702 } else {
703 return Ok(()); // neither PNG nor JPEG by its magic bytes
704 }
705
706 let x = first_number(elem.attr("x"));
707 let y = first_number(elem.attr("y"));
708 let w = first_number(elem.attr("width"));
709 let h = first_number(elem.attr("height"));
710 if w <= 0.0 || h <= 0.0 {
711 return Ok(());
712 }
713 let local = child_transform(elem, ctx);
714 let p0 = local.apply(Pt::new(x, y));
715 let p1 = local.apply(Pt::new(x + w, y + h));
716 ops.push(SvgOp::Image {
717 rgba,
718 iw,
719 ih,
720 x: p0.x.min(p1.x),
721 y: p0.y.min(p1.y),
722 w: (p1.x - p0.x).abs(),
723 h: (p1.y - p0.y).abs(),
724 });
725 Ok(())
726}
727
728/// Emits a shape's fill and stroke ops under a resolved presentation state. A path with a fill paints it
729/// under the stroke, the order SVG draws them; either may be absent. An even-odd fill has its geometry
730/// reordered to fill the same way under the non-zero rule the engine draws with. Opacity is folded into
731/// each emitted colour's alpha.
732fn emit_shape(
733 shape: Path,
734 local: &Transform,
735 paint: &Paint,
736 ops: &mut Vec<SvgOp>,
737)
738 -> Outcome<()>
739{
740 let placed = res!(shape.transform(local));
741
742 if let Some(colour) = paint.fill {
743 let colour = fade(colour, paint.fill_opacity * paint.opacity);
744 if colour.a > 0 {
745 let path = if paint.even_odd {
746 res!(placed.even_odd_as_non_zero())
747 } else {
748 placed.clone()
749 };
750 ops.push(SvgOp::Fill { path, colour });
751 }
752 }
753 if let Some(colour) = paint.stroke {
754 let colour = fade(colour, paint.stroke_opacity * paint.opacity);
755 if colour.a > 0 {
756 let stroke = res!(pen(paint, local));
757 ops.push(SvgOp::Stroke { path: placed, colour, stroke });
758 }
759 }
760 Ok(())
761}
762
763/// Builds the pen a shape's stroke wants, its width and dash scaled from the element's own frame into the
764/// picture frame by the placement transform's scale, so a stroke inside a shrunk group keeps its true
765/// thickness rather than the raw attribute's.
766fn pen(paint: &Paint, local: &Transform) -> Outcome<Stroke> {
767 let s = local.scale_factor().max(f32::MIN_POSITIVE);
768 let width = (paint.width * s).max(f32::MIN_POSITIVE);
769 let mut stroke = res!(Stroke::new(width));
770 stroke = stroke
771 .with_cap(paint.cap)
772 .with_join(paint.join)
773 .with_miter_limit(paint.miter.max(1.0));
774 if let Some(pattern) = &paint.dash {
775 let scaled: Vec<f32> = pattern.iter().map(|&x| x * s).collect();
776 if scaled.iter().any(|&x| x > 0.0) {
777 stroke = stroke.with_dash(Dash::new(scaled).with_offset(paint.dash_offset * s));
778 }
779 }
780 Ok(stroke)
781}
782
783/// Scales a colour's alpha by an opacity factor, for the fill-opacity, stroke-opacity and group opacity
784/// the flat op set folds into the one alpha it carries.
785fn fade(c: Rgba, factor: f32) -> Rgba {
786 let a = ((c.a as f32) * factor.clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8;
787 Rgba::new(c.r, c.g, c.b, a)
788}
789
790/// The path a shape element describes, in its own coordinates, or `None` when the element is not a shape
791/// this reader draws. The primitives are turned to the same paths the drawing crate builds them from.
792fn shape_path(elem: &Elem) -> Option<Path> {
793 match elem.name.local() {
794 "path" => {
795 let d = elem.attr("d")?;
796 crate::svg::path_data(d).ok()
797 },
798 "rect" => {
799 let x = elem.attr("x").map(number).unwrap_or(0.0);
800 let y = elem.attr("y").map(number).unwrap_or(0.0);
801 let w = elem.attr("width").map(number).unwrap_or(0.0);
802 let h = elem.attr("height").map(number).unwrap_or(0.0);
803 if w <= 0.0 || h <= 0.0 {
804 return None;
805 }
806 let b = Bounds::new(x, y, x + w, y + h);
807 // A rounded rect takes the radius given; either radius alone sets both, as SVG does.
808 let rx = elem.attr("rx").map(number);
809 let ry = elem.attr("ry").map(number);
810 let r = match (rx, ry) {
811 (Some(a), Some(b)) => a.max(b),
812 (Some(a), None) => a,
813 (None, Some(b)) => b,
814 (None, None) => 0.0,
815 };
816 if r > 0.0 {
817 Path::round_rect(b, r).ok()
818 } else {
819 Path::rect(b).ok()
820 }
821 },
822 "circle" => {
823 let cx = elem.attr("cx").map(number).unwrap_or(0.0);
824 let cy = elem.attr("cy").map(number).unwrap_or(0.0);
825 let r = elem.attr("r").map(number).unwrap_or(0.0);
826 if r <= 0.0 {
827 return None;
828 }
829 Path::circle(cx, cy, r).ok()
830 },
831 "ellipse" => {
832 let cx = elem.attr("cx").map(number).unwrap_or(0.0);
833 let cy = elem.attr("cy").map(number).unwrap_or(0.0);
834 let rx = elem.attr("rx").map(number).unwrap_or(0.0);
835 let ry = elem.attr("ry").map(number).unwrap_or(0.0);
836 if rx <= 0.0 || ry <= 0.0 {
837 return None;
838 }
839 Path::ellipse(cx, cy, rx, ry).ok()
840 },
841 "line" => {
842 let x1 = elem.attr("x1").map(number).unwrap_or(0.0);
843 let y1 = elem.attr("y1").map(number).unwrap_or(0.0);
844 let x2 = elem.attr("x2").map(number).unwrap_or(0.0);
845 let y2 = elem.attr("y2").map(number).unwrap_or(0.0);
846 let mut pb = PathBuilder::new();
847 pb.move_to(Pt::new(x1, y1));
848 pb.line_to(Pt::new(x2, y2));
849 pb.finish().ok()
850 },
851 "polyline" | "polygon" => {
852 let pts = numbers(elem.attr("points")?);
853 if pts.len() < 4 {
854 return None;
855 }
856 let mut pb = PathBuilder::new();
857 pb.move_to(Pt::new(pts[0], pts[1]));
858 let mut i = 2;
859 while i + 1 < pts.len() {
860 pb.line_to(Pt::new(pts[i], pts[i + 1]));
861 i += 2;
862 }
863 // A polygon closes back onto its first point; a polyline is left open.
864 if elem.name.local() == "polygon" {
865 pb.close();
866 }
867 pb.finish().ok()
868 },
869 _ => None,
870 }
871}
872
873/// The transform an element's own `transform` attribute composes onto the inherited frame.
874fn child_transform(elem: &Elem, ctx: &Transform) -> Transform {
875 match elem.attr("transform") {
876 Some(t) => parse_transform(t).then(ctx),
877 None => *ctx,
878 }
879}
880
881/// Parses an SVG `transform` list -- `translate`, `matrix`, `scale`, `rotate` -- into one affine map.
882///
883/// The list reads left to right and the leftmost function is the outermost, so a point is carried by the
884/// rightmost first. Folding each function on the left of the running result builds exactly that order.
885fn parse_transform(s: &str) -> Transform {
886 let mut acc = Transform::IDENTITY;
887 let bytes = s.as_bytes();
888 let mut i = 0;
889 while i < bytes.len() {
890 // The function name, up to its opening parenthesis.
891 let name_start = i;
892 while i < bytes.len() && bytes[i] != b'(' {
893 i += 1;
894 }
895 if i >= bytes.len() {
896 break;
897 }
898 let name = s[name_start..i].trim();
899 i += 1; // past '('
900 let args_start = i;
901 while i < bytes.len() && bytes[i] != b')' {
902 i += 1;
903 }
904 let args = numbers(&s[args_start..i.min(bytes.len())]);
905 if i < bytes.len() {
906 i += 1; // past ')'
907 }
908 let f = function(name, &args);
909 acc = f.then(&acc);
910 }
911 acc
912}
913
914/// One transform function as a matrix; an unrecognised or malformed one is the identity, so it is a
915/// no-op rather than a fault.
916fn function(name: &str, a: &[f32]) -> Transform {
917 match name {
918 "translate" => match a.len() {
919 1 => Transform::translate(a[0], 0.0),
920 n if n >= 2 => Transform::translate(a[0], a[1]),
921 _ => Transform::IDENTITY,
922 },
923 "scale" => match a.len() {
924 1 => Transform::scale(a[0], a[0]),
925 n if n >= 2 => Transform::scale(a[0], a[1]),
926 _ => Transform::IDENTITY,
927 },
928 "rotate" => match a.len() {
929 1 => Transform::rotate(a[0].to_radians()),
930 // A three-argument rotate turns about a centre: translate to it, rotate, translate back.
931 n if n >= 3 => Transform::translate(-a[1], -a[2])
932 .then(&Transform::rotate(a[0].to_radians()))
933 .then(&Transform::translate(a[1], a[2])),
934 _ => Transform::IDENTITY,
935 },
936 "matrix" if a.len() >= 6 => Transform {
937 a: a[0], b: a[1], c: a[2], d: a[3], e: a[4], f: a[5],
938 },
939 _ => Transform::IDENTITY,
940 }
941}
942
943/// One `fill`/`stroke`/`stop-color` colour: a `#rgb`/`#rrggbb`/`#rrggbbaa`, an `rgb(...)`, or a named
944/// colour. `none` and the unresolved are the caller's concern; this returns `None` for anything it cannot
945/// read as a colour. A `defs` is taken only so a caller may share this for stop colours, which never
946/// reference a gradient of their own.
947fn paint_colour(s: &str, _defs: Option<&Defs>) -> Option<Rgba> {
948 let s = s.trim();
949 if s.is_empty() || s == "none" {
950 return None;
951 }
952 if let Some(hex) = s.strip_prefix('#') {
953 return Rgba::from_hex(hex).ok();
954 }
955 if let Some(rest) = s.strip_prefix("rgb") {
956 let inner = rest.trim_start_matches('a').trim_start_matches('(').trim_end_matches(')');
957 let n = numbers(inner);
958 if n.len() >= 3 {
959 let a = if n.len() >= 4 {
960 // The fourth is a 0..1 alpha in rgba(); scaled to a byte.
961 (n[3].clamp(0.0, 1.0) * 255.0).round() as u8
962 } else {
963 255
964 };
965 return Some(Rgba::new(
966 n[0].clamp(0.0, 255.0) as u8,
967 n[1].clamp(0.0, 255.0) as u8,
968 n[2].clamp(0.0, 255.0) as u8,
969 a,
970 ));
971 }
972 }
973 named_colour(s)
974}
975
976/// The handful of named colours a plot or an illustration might carry, beyond the hex the files otherwise
977/// use.
978fn named_colour(name: &str) -> Option<Rgba> {
979 match name {
980 "black" => Some(Rgba::BLACK),
981 "white" => Some(Rgba::WHITE),
982 "red" => Some(Rgba::opaque(255, 0, 0)),
983 "green" => Some(Rgba::opaque(0, 128, 0)),
984 "lime" => Some(Rgba::opaque(0, 255, 0)),
985 "blue" => Some(Rgba::opaque(0, 0, 255)),
986 "navy" => Some(Rgba::opaque(0, 0, 128)),
987 "cyan" | "aqua" => Some(Rgba::opaque(0, 255, 255)),
988 "magenta" | "fuchsia" => Some(Rgba::opaque(255, 0, 255)),
989 "grey" | "gray" => Some(Rgba::opaque(128, 128, 128)),
990 "silver" => Some(Rgba::opaque(192, 192, 192)),
991 "maroon" => Some(Rgba::opaque(128, 0, 0)),
992 "yellow" => Some(Rgba::opaque(255, 255, 0)),
993 "orange" => Some(Rgba::opaque(255, 165, 0)),
994 "purple" => Some(Rgba::opaque(128, 0, 128)),
995 "teal" => Some(Rgba::opaque(0, 128, 128)),
996 "olive" => Some(Rgba::opaque(128, 128, 0)),
997 "transparent" | "none" => Some(Rgba::TRANSPARENT),
998 _ => None,
999 }
1000}
1001
1002/// A length attribute in points, dropping a `pt` unit suffix; other units are read as their number.
1003fn length_pt(s: &str) -> f32 {
1004 let t = s.trim().trim_end_matches("pt");
1005 number(t)
1006}
1007
1008/// A length as its bare number, dropping any trailing unit letters or a percent sign -- a `font-size`
1009/// arrives as `3.5278px`, whose unit `length_pt` would not shed. The value keeps the element's own units.
1010fn length_num(s: &str) -> f32 {
1011 numbers(s).first().copied().unwrap_or(0.0)
1012}
1013
1014/// The first number of an attribute -- an `x`/`y` may carry a whitespace-separated list -- or zero for
1015/// an absent or unparseable one.
1016fn first_number(v: Option<&str>) -> f32 {
1017 match v {
1018 Some(s) => numbers(s).first().copied().unwrap_or(0.0),
1019 None => 0.0,
1020 }
1021}
1022
1023/// One number, or zero when the text does not parse.
1024fn number(s: &str) -> f32 {
1025 s.trim().parse::<f32>().unwrap_or(0.0)
1026}
1027
1028/// Every number in a run of numbers separated by whitespace, commas or a leading minus, in order.
1029fn numbers(s: &str) -> Vec<f32> {
1030 let mut out: Vec<f32> = Vec::new();
1031 let bytes = s.as_bytes();
1032 let mut i = 0;
1033 while i < bytes.len() {
1034 let c = bytes[i];
1035 if c == b'-' || c == b'+' || c == b'.' || c.is_ascii_digit() {
1036 let start = i;
1037 // A sign only opens a number; a following sign closes the previous one.
1038 if c == b'-' || c == b'+' {
1039 i += 1;
1040 }
1041 let mut seen_dot = false;
1042 let mut seen_exp = false;
1043 while i < bytes.len() {
1044 let d = bytes[i];
1045 if d.is_ascii_digit() {
1046 i += 1;
1047 } else if d == b'.' && !seen_dot && !seen_exp {
1048 seen_dot = true;
1049 i += 1;
1050 } else if (d == b'e' || d == b'E') && !seen_exp {
1051 seen_exp = true;
1052 i += 1;
1053 if i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b'+') {
1054 i += 1;
1055 }
1056 } else {
1057 break;
1058 }
1059 }
1060 if let Ok(n) = s[start..i].parse::<f32>() {
1061 out.push(n);
1062 }
1063 } else {
1064 i += 1;
1065 }
1066 }
1067 out
1068}