oxedyne/fe2o3/fe2o3_graphics/src/svg.rs
32.6 KiB, 56 runs
created by r1870400018:14443, 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 | //! SVG path data: the `d` attribute, read into a [`Path`]. |
| 2 | //! |
| 3 | //! A vector mark -- an icon, a logo -- is drawn in a drawing program and leaves it as SVG, where all |
| 4 | //! of the geometry sits in the `d` attribute of a `<path>` element: a terse string of one-letter |
| 5 | //! commands and numbers. This module reads that string with [`path_data`] and writes it back with |
| 6 | //! [`write_path_data`], and nothing else. No XML, no styling, no document: the caller keeps whatever |
| 7 | //! it wants of the file and hands the geometry here. |
| 8 | //! |
| 9 | //! That boundary is the point. Path data is a small, closed, fully specified grammar, and it is the |
| 10 | //! part every drawing program agrees on. Everything above it -- elements, attributes, gradients, |
| 11 | //! filters, referenced content -- is a document format, and a caller that wants an icon does not want |
| 12 | //! a document. |
| 13 | //! |
| 14 | //! The one concession above bare geometry is [`presentation`], which renders the paint a |
| 15 | //! [`crate::stroke::Stroke`] and an [`Rgba`] already model -- fill, stroke, width, caps, joins, |
| 16 | //! dashes -- as the attribute string a `<path>` carries alongside its `d`. It writes the attributes |
| 17 | //! and no element around them, for the same reason the reader stops at the `d`: the element tree is |
| 18 | //! the caller's format. |
| 19 | //! |
| 20 | //! Every command in the grammar is read, including elliptical arcs. An arc has no [`crate::path::Seg`] |
| 21 | //! of its own, so it is converted to cubic béziers on the way in and no caller has to know it was |
| 22 | //! ever an arc. |
| 23 | //! |
| 24 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 25 | //! Anthropic Claude |
| 26 | |
| 27 | use crate::colour::Rgba; |
| 28 | use crate::path::{ |
| 29 | Path, |
| 30 | PathBuilder, |
| 31 | Pt, |
| 32 | Seg, |
| 33 | }; |
| 34 | use crate::stroke::{ |
| 35 | Cap, |
| 36 | Join, |
| 37 | Stroke, |
| 38 | }; |
| 39 | |
| 40 | use oxedyne_fe2o3_core::prelude::*; |
| 41 | |
| 42 | use std::f64::consts::PI; |
| 43 | |
| 44 | // The most cubic segments one elliptical arc becomes. An arc is cut at quadrant boundaries and its |
| 45 | // sweep cannot exceed a full turn, so four pieces always suffice. |
| 46 | const ARC_SEGS: usize = 4; |
| 47 | |
| 48 | /// The ellipse an arc travels on, and which of the four arcs between the endpoints to take. |
| 49 | /// |
| 50 | /// These are the five arguments the `A` command carries before its endpoint. They travel together |
| 51 | /// because they mean nothing apart: a radius without its flags does not pick out an arc. |
| 52 | #[derive(Clone, Copy)] |
| 53 | struct Arc { |
| 54 | rx: f32, // horizontal radius; the sign is ignored, and one too small to span is grown |
| 55 | ry: f32, // vertical radius |
| 56 | rot: f32, // the ellipse's x-axis rotation, in degrees |
| 57 | large: bool, // take the sweep greater than a half turn |
| 58 | sweep: bool, // take the sweep in the direction of increasing angle |
| 59 | } |
| 60 | |
| 61 | /// The last curve's trailing control point, which `S` and `T` reflect. |
| 62 | /// |
| 63 | /// The kind matters: `S` reflects only a cubic's control point and `T` only a quadratic's. After any |
| 64 | /// other command, or a curve of the other kind, the reflected point is the current point instead -- |
| 65 | /// so a bare [`Self::None`] is not enough and the kind must be carried. |
| 66 | #[derive(Clone, Copy)] |
| 67 | enum Last { |
| 68 | None, // not a curve, or a curve of the other kind |
| 69 | Cubic(Pt), // after C, c, S or s, carrying its second control point |
| 70 | Quad(Pt), // after Q, q, T or t, carrying its control point |
| 71 | } |
| 72 | |
| 73 | /// Reads SVG path data -- the `d` attribute of a `<path>` element -- into a [`Path`]. |
| 74 | pub fn path_data(d: &str) -> Outcome<Path> { |
| 75 | let mut sc = Scan::new(d); |
| 76 | let mut pb = PathBuilder::new(); |
| 77 | let mut cur = Pt::new(0.0, 0.0); // Where the pen is. |
| 78 | let mut start = Pt::new(0.0, 0.0); // Where this contour began, which `Z` returns to. |
| 79 | let mut prev = 0u8; // The last command, for the implicit-repeat rule. |
| 80 | let mut last = Last::None; |
| 81 | let mut open = false; // Whether a contour is under way. |
| 82 | loop { |
| 83 | if sc.done() { |
| 84 | break; |
| 85 | } |
| 86 | // A command letter may be left out to repeat the last one. A repeated `moveto` is a |
| 87 | // `lineto`, which is the grammar's one irregularity. |
| 88 | let cmd = match sc.cmd() { |
| 89 | Some(c) => c, |
| 90 | None => match prev { |
| 91 | 0 => return Err(err!( |
| 92 | "Path data must begin with a command letter, found '{}'.", |
| 93 | sc.rest(); Invalid, Input)), |
| 94 | b'M' => b'L', |
| 95 | b'm' => b'l', |
| 96 | c => c, |
| 97 | }, |
| 98 | }; |
| 99 | // The grammar opens with a moveto, and nothing else will do: until one has been read there is |
| 100 | // no pen for a drawing command to draw from. |
| 101 | if prev == 0 && !matches!(cmd, b'M' | b'm') { |
| 102 | return Err(err!( |
| 103 | "Path data must begin with a moveto, found '{}'.", cmd as char; Invalid, Input)); |
| 104 | } |
| 105 | prev = cmd; |
| 106 | // A `Z` leaves the pen on the contour's first point but closes the contour. The |
| 107 | // specification has the next subpath begin at that same point, so a drawing command |
| 108 | // following a close opens one there rather than drawing from nowhere. |
| 109 | if !open && !matches!(cmd, b'M' | b'm' | b'Z' | b'z') { |
| 110 | pb.move_to(cur); |
| 111 | open = true; |
| 112 | } |
| 113 | let rel = cmd.is_ascii_lowercase(); |
| 114 | match cmd { |
| 115 | b'M' | b'm' => { |
| 116 | let p = res!(sc.point(rel, cur)); |
| 117 | pb.move_to(p); |
| 118 | cur = p; |
| 119 | start = p; |
| 120 | last = Last::None; |
| 121 | open = true; |
| 122 | }, |
| 123 | b'L' | b'l' => { |
| 124 | let p = res!(sc.point(rel, cur)); |
| 125 | pb.line_to(p); |
| 126 | cur = p; |
| 127 | last = Last::None; |
| 128 | }, |
| 129 | b'H' | b'h' => { |
| 130 | let x = res!(sc.num()); |
| 131 | let p = Pt::new(if rel { cur.x + x } else { x }, cur.y); |
| 132 | pb.line_to(p); |
| 133 | cur = p; |
| 134 | last = Last::None; |
| 135 | }, |
| 136 | b'V' | b'v' => { |
| 137 | let y = res!(sc.num()); |
| 138 | let p = Pt::new(cur.x, if rel { cur.y + y } else { y }); |
| 139 | pb.line_to(p); |
| 140 | cur = p; |
| 141 | last = Last::None; |
| 142 | }, |
| 143 | b'C' | b'c' => { |
| 144 | let c0 = res!(sc.point(rel, cur)); |
| 145 | let c1 = res!(sc.point(rel, cur)); |
| 146 | let p = res!(sc.point(rel, cur)); |
| 147 | pb.cubic_to(c0, c1, p); |
| 148 | cur = p; |
| 149 | last = Last::Cubic(c1); |
| 150 | }, |
| 151 | b'S' | b's' => { |
| 152 | let c1 = res!(sc.point(rel, cur)); |
| 153 | let p = res!(sc.point(rel, cur)); |
| 154 | let c0 = match last { |
| 155 | Last::Cubic(q) => reflect(cur, q), |
| 156 | _ => cur, |
| 157 | }; |
| 158 | pb.cubic_to(c0, c1, p); |
| 159 | cur = p; |
| 160 | last = Last::Cubic(c1); |
| 161 | }, |
| 162 | b'Q' | b'q' => { |
| 163 | let c = res!(sc.point(rel, cur)); |
| 164 | let p = res!(sc.point(rel, cur)); |
| 165 | pb.quad_to(c, p); |
| 166 | cur = p; |
| 167 | last = Last::Quad(c); |
| 168 | }, |
| 169 | b'T' | b't' => { |
| 170 | let p = res!(sc.point(rel, cur)); |
| 171 | let c = match last { |
| 172 | Last::Quad(q) => reflect(cur, q), |
| 173 | _ => cur, |
| 174 | }; |
| 175 | pb.quad_to(c, p); |
| 176 | cur = p; |
| 177 | last = Last::Quad(c); |
| 178 | }, |
| 179 | b'A' | b'a' => { |
| 180 | let rx = res!(sc.num()); |
| 181 | let ry = res!(sc.num()); |
| 182 | let rot = res!(sc.num()); |
| 183 | let large = res!(sc.flag()); |
| 184 | let sweep = res!(sc.flag()); |
| 185 | let p = res!(sc.point(rel, cur)); |
| 186 | arc(&mut pb, cur, Arc { rx, ry, rot, large, sweep }, p); |
| 187 | cur = p; |
| 188 | last = Last::None; |
| 189 | }, |
| 190 | b'Z' | b'z' => { |
| 191 | pb.close(); |
| 192 | cur = start; |
| 193 | last = Last::None; |
| 194 | open = false; |
| 195 | }, |
| 196 | c => return Err(err!( |
| 197 | "'{}' is not an SVG path command.", c as char; Invalid, Input)), |
| 198 | } |
| 199 | } |
| 200 | pb.finish() |
| 201 | } |
| 202 | |
| 203 | /// Reflects `q` through `p`, which is what `S` and `T` do to the previous control point to keep a |
| 204 | /// curve smooth across the join. |
| 205 | fn reflect(p: Pt, q: Pt) -> Pt { |
| 206 | Pt::new(2.0 * p.x - q.x, 2.0 * p.y - q.y) |
| 207 | } |
| 208 | |
| 209 | /// Lays an elliptical arc onto `pb` as cubic béziers. |
| 210 | /// |
| 211 | /// SVG states an arc by where it ends and which of the four candidate arcs to take; a bézier needs |
| 212 | /// the centre and the angles spanned. The conversion between them is the one the SVG specification |
| 213 | /// sets out in its implementation notes (F.6.5 for the centre, F.6.6 for radii too small to reach), |
| 214 | /// after which each quadrant of the sweep takes one cubic. |
| 215 | /// |
| 216 | /// The arithmetic runs in `f64` though the path is `f32`: the centre falls out of a difference of |
| 217 | /// squares that cancels badly near the degenerate cases, and the wider type costs nothing here. |
| 218 | /// |
| 219 | /// The pen is assumed to be at `p0`. |
| 220 | fn arc(pb: &mut PathBuilder, p0: Pt, a: Arc, p1: Pt) { |
| 221 | // An arc whose ends coincide is dropped, and one with no radius is a straight line. Both are |
| 222 | // what the specification asks for, and both would otherwise divide by zero below. |
| 223 | if p0 == p1 { |
| 224 | return; |
| 225 | } |
| 226 | let (mut rx, mut ry) = ((a.rx as f64).abs(), (a.ry as f64).abs()); |
| 227 | if rx == 0.0 || ry == 0.0 { |
| 228 | pb.line_to(p1); |
| 229 | return; |
| 230 | } |
| 231 | let (large, sweep) = (a.large, a.sweep); |
| 232 | let (x0, y0) = (p0.x as f64, p0.y as f64); |
| 233 | let (x1, y1) = (p1.x as f64, p1.y as f64); |
| 234 | let (sin_phi, cos_phi) = (a.rot as f64).to_radians().sin_cos(); |
| 235 | |
| 236 | // The ends in the ellipse's own frame, with their midpoint at the origin. |
| 237 | let dx = (x0 - x1) / 2.0; |
| 238 | let dy = (y0 - y1) / 2.0; |
| 239 | let xp = cos_phi * dx + sin_phi * dy; |
| 240 | let yp = -sin_phi * dx + cos_phi * dy; |
| 241 | |
| 242 | // Radii too small to reach from one end to the other are grown until they just do (F.6.6). |
| 243 | let lam = (xp * xp) / (rx * rx) + (yp * yp) / (ry * ry); |
| 244 | if lam > 1.0 { |
| 245 | let s = lam.sqrt(); |
| 246 | rx *= s; |
| 247 | ry *= s; |
| 248 | } |
| 249 | |
| 250 | // The centre, in that frame and then back in the caller's (F.6.5). |
| 251 | let num = rx * rx * ry * ry - rx * rx * yp * yp - ry * ry * xp * xp; |
| 252 | let den = rx * rx * yp * yp + ry * ry * xp * xp; |
| 253 | // The max() holds the root real against rounding; lam has already made num non-negative. |
| 254 | let mut co = if den > 0.0 { (num / den).max(0.0).sqrt() } else { 0.0 }; |
| 255 | if large == sweep { |
| 256 | co = -co; |
| 257 | } |
| 258 | let cxp = co * (rx * yp) / ry; |
| 259 | let cyp = -co * (ry * xp) / rx; |
| 260 | let cx = cos_phi * cxp - sin_phi * cyp + (x0 + x1) / 2.0; |
| 261 | let cy = sin_phi * cxp + cos_phi * cyp + (y0 + y1) / 2.0; |
| 262 | |
| 263 | // Where the sweep starts and how far it goes. |
| 264 | let ux = (xp - cxp) / rx; |
| 265 | let uy = (yp - cyp) / ry; |
| 266 | let vx = (-xp - cxp) / rx; |
| 267 | let vy = (-yp - cyp) / ry; |
| 268 | let th0 = angle(1.0, 0.0, ux, uy); |
| 269 | let mut dth = angle(ux, uy, vx, vy); |
| 270 | if !sweep && dth > 0.0 { |
| 271 | dth -= 2.0 * PI; |
| 272 | } |
| 273 | if sweep && dth < 0.0 { |
| 274 | dth += 2.0 * PI; |
| 275 | } |
| 276 | |
| 277 | // One cubic per quadrant of the sweep. A bézier meets a circular arc closely only over a short |
| 278 | // span, so the cut is what keeps the approximation honest. |
| 279 | let n = ((dth.abs() / (PI / 2.0)).ceil() as usize).clamp(1, ARC_SEGS); |
| 280 | let step = dth / n as f64; |
| 281 | // How far along the tangent a control point sits, for a piece spanning this angle. At a quarter |
| 282 | // turn this is the familiar 0.5523. |
| 283 | let k = (4.0 / 3.0) * (step / 4.0).tan(); |
| 284 | // The point on the ellipse at an angle, and the derivative there. |
| 285 | let at = |t: f64| -> (f64, f64, f64, f64) { |
| 286 | let (s, c) = t.sin_cos(); |
| 287 | ( |
| 288 | cx + rx * cos_phi * c - ry * sin_phi * s, |
| 289 | cy + rx * sin_phi * c + ry * cos_phi * s, |
| 290 | -rx * cos_phi * s - ry * sin_phi * c, |
| 291 | -rx * sin_phi * s + ry * cos_phi * c, |
| 292 | ) |
| 293 | }; |
| 294 | for i in 0..n { |
| 295 | let t0 = th0 + step * i as f64; |
| 296 | let (px0, py0, dx0, dy0) = at(t0); |
| 297 | let (px1, py1, dx1, dy1) = at(t0 + step); |
| 298 | pb.cubic_to( |
| 299 | Pt::new((px0 + k * dx0) as f32, (py0 + k * dy0) as f32), |
| 300 | Pt::new((px1 - k * dx1) as f32, (py1 - k * dy1) as f32), |
| 301 | Pt::new(px1 as f32, py1 as f32), |
| 302 | ); |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | /// The signed angle from one vector to another, which is what the arc conversion measures its sweep |
| 307 | /// with. |
| 308 | fn angle(ux: f64, uy: f64, vx: f64, vy: f64) -> f64 { |
| 309 | let len = ((ux * ux + uy * uy) * (vx * vx + vy * vy)).sqrt(); |
| 310 | if len == 0.0 { |
| 311 | return 0.0; |
| 312 | } |
| 313 | // The clamp holds acos in range against rounding, which a dot product of unit vectors can leave |
| 314 | // a hair outside. |
| 315 | let a = ((ux * vx + uy * vy) / len).clamp(-1.0, 1.0).acos(); |
| 316 | if ux * vy - uy * vx < 0.0 { |
| 317 | -a |
| 318 | } else { |
| 319 | a |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | /// Writes a [`Path`] out as SVG path data -- the `d` attribute of a `<path>` element. |
| 324 | /// |
| 325 | /// The exact inverse of [`path_data`]: every segment becomes the one command that names it, and a |
| 326 | /// string this writes is one that reader reads back to the same geometry. Only absolute commands are |
| 327 | /// emitted -- `M`, `L`, `Q`, `C`, `Z` -- since those are the segments the path types hold, and the |
| 328 | /// relative and shorthand forms the reader also accepts are a convenience of hand-written data, not |
| 329 | /// a distinction the geometry keeps. |
| 330 | /// |
| 331 | /// The commands are separated by spaces, and the two coordinates of a point by a comma, which is the |
| 332 | /// form drawing programs write and the eye reads most easily. No document, element or attribute is |
| 333 | /// written -- only the path data -- for the reason [`path_data`] reads only the same: the structure |
| 334 | /// above a `<path>` is the caller's format, not this crate's. An empty path writes an empty string. |
| 335 | pub fn write_path_data(path: &Path) -> String { |
| 336 | let mut out = String::new(); |
| 337 | for seg in path.segs() { |
| 338 | if !out.is_empty() { |
| 339 | out.push(' '); |
| 340 | } |
| 341 | match *seg { |
| 342 | Seg::MoveTo(p) => { |
| 343 | out.push('M'); |
| 344 | point(&mut out, p); |
| 345 | }, |
| 346 | Seg::LineTo(p) => { |
| 347 | out.push('L'); |
| 348 | point(&mut out, p); |
| 349 | }, |
| 350 | Seg::QuadTo(c, p) => { |
| 351 | out.push('Q'); |
| 352 | point(&mut out, c); |
| 353 | out.push(' '); |
| 354 | point(&mut out, p); |
| 355 | }, |
| 356 | Seg::CubicTo(c0, c1, p) => { |
| 357 | out.push('C'); |
| 358 | point(&mut out, c0); |
| 359 | out.push(' '); |
| 360 | point(&mut out, c1); |
| 361 | out.push(' '); |
| 362 | point(&mut out, p); |
| 363 | }, |
| 364 | Seg::Close => out.push('Z'), |
| 365 | } |
| 366 | } |
| 367 | out |
| 368 | } |
| 369 | |
| 370 | /// Appends a point as `x,y`, each coordinate in its shortest exact form. |
| 371 | fn point(out: &mut String, p: Pt) { |
| 372 | out.push_str(&num(p.x)); |
| 373 | out.push(','); |
| 374 | out.push_str(&num(p.y)); |
| 375 | } |
| 376 | |
| 377 | /// One coordinate, in the shortest decimal that reads back to the same `f32`. |
| 378 | /// |
| 379 | /// Rust's own float formatting already gives the shortest round-tripping form -- `10` for `10.0`, |
| 380 | /// `0.15` for a fifth and a bit -- so a whole coordinate carries no trailing `.0` and the data stays |
| 381 | /// terse, exactly as a drawing program would write it. |
| 382 | fn num(v: f32) -> String { |
| 383 | fmt!("{}", v) |
| 384 | } |
| 385 | |
| 386 | /// Renders the SVG presentation attributes for a fill and a stroke, as one attribute string. |
| 387 | /// |
| 388 | /// This is the counterpart to [`write_path_data`] for everything that is not geometry: the colours, |
| 389 | /// the pen width, the caps and joins and dashes that [`crate::stroke::Stroke`] and [`Rgba`] already |
| 390 | /// model. It writes the attributes and their values -- `fill="#..."`, `stroke-width="2"`, and so on |
| 391 | /// -- and nothing around them, so a caller drops the string straight into the `<path>` element its |
| 392 | /// own format builds. |
| 393 | /// |
| 394 | /// The fill and the stroke are each optional, because a shape may be filled, stroked, or both: |
| 395 | /// * A fill of `Some(c)` writes `fill` and, where the colour is not opaque, `fill-opacity`. A fill |
| 396 | /// of `None` writes `fill="none"`, since SVG fills black by default and a caller that wants no |
| 397 | /// fill must say so. |
| 398 | /// * A stroke of `Some((c, pen))` writes the stroke colour, its opacity where it is not opaque, the |
| 399 | /// width, the cap, the join, the miter limit, and the dash pattern and offset where the pen |
| 400 | /// carries one. A stroke of `None` writes nothing, and the shape is filled only. |
| 401 | /// |
| 402 | /// The stroke colour travels with the pen because neither draws a stroke without the other: a width |
| 403 | /// with no colour paints nothing, and a colour with no width has nothing to paint. |
| 404 | pub fn presentation(fill: Option<Rgba>, stroke: Option<(Rgba, &Stroke)>) -> String { |
| 405 | let mut at: Vec<String> = Vec::new(); |
| 406 | match fill { |
| 407 | None => at.push(fmt!("fill=\"none\"")), |
| 408 | Some(c) => { |
| 409 | at.push(fmt!("fill=\"{}\"", rgb(c))); |
| 410 | if !c.is_opaque() { |
| 411 | at.push(fmt!("fill-opacity=\"{}\"", opacity(c))); |
| 412 | } |
| 413 | }, |
| 414 | } |
| 415 | if let Some((c, pen)) = stroke { |
| 416 | at.push(fmt!("stroke=\"{}\"", rgb(c))); |
| 417 | if !c.is_opaque() { |
| 418 | at.push(fmt!("stroke-opacity=\"{}\"", opacity(c))); |
| 419 | } |
| 420 | at.push(fmt!("stroke-width=\"{}\"", num(pen.width))); |
| 421 | at.push(fmt!("stroke-linecap=\"{}\"", cap(pen.cap))); |
| 422 | at.push(fmt!("stroke-linejoin=\"{}\"", join(pen.join))); |
| 423 | at.push(fmt!("stroke-miterlimit=\"{}\"", num(pen.miter_limit))); |
| 424 | if let Some(d) = &pen.dash { |
| 425 | let lens: Vec<String> = d.pattern.iter().map(|v| num(*v)).collect(); |
| 426 | at.push(fmt!("stroke-dasharray=\"{}\"", lens.join(","))); |
| 427 | if d.offset != 0.0 { |
| 428 | at.push(fmt!("stroke-dashoffset=\"{}\"", num(d.offset))); |
| 429 | } |
| 430 | } |
| 431 | } |
| 432 | at.join(" ") |
| 433 | } |
| 434 | |
| 435 | /// A colour's `#rrggbb`, the paint value an SVG attribute takes. The alpha, if any, is carried |
| 436 | /// separately by an opacity attribute, which is the form every SVG renderer understands. |
| 437 | fn rgb(c: Rgba) -> String { |
| 438 | fmt!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b) |
| 439 | } |
| 440 | |
| 441 | /// A colour's alpha as an opacity from 0 to 1, to three places, which resolves every one of the 256 |
| 442 | /// steps an eight-bit alpha can take. |
| 443 | fn opacity(c: Rgba) -> String { |
| 444 | fmt!("{:.3}", (c.a as f32) / 255.0) |
| 445 | } |
| 446 | |
| 447 | /// The SVG name of a line cap. |
| 448 | fn cap(c: Cap) -> &'static str { |
| 449 | match c { |
| 450 | Cap::Butt => "butt", |
| 451 | Cap::Round => "round", |
| 452 | Cap::Square => "square", |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | /// The SVG name of a line join. |
| 457 | fn join(j: Join) -> &'static str { |
| 458 | match j { |
| 459 | Join::Miter => "miter", |
| 460 | Join::Round => "round", |
| 461 | Join::Bevel => "bevel", |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | /// A cursor over path data. |
| 466 | struct Scan<'a> { |
| 467 | s: &'a [u8], // ASCII throughout, so a byte index is always a character boundary |
| 468 | i: usize, // how far in the cursor has reached |
| 469 | } |
| 470 | |
| 471 | impl<'a> Scan<'a> { |
| 472 | fn new(s: &'a str) -> Self { |
| 473 | Self { s: s.as_bytes(), i: 0 } |
| 474 | } |
| 475 | |
| 476 | /// Steps over whitespace and commas, which separate numbers and mean nothing else. |
| 477 | fn sep(&mut self) { |
| 478 | while self.i < self.s.len() { |
| 479 | match self.s[self.i] { |
| 480 | b' ' | b'\t' | b'\n' | b'\r' | b'\x0C' | b',' => self.i += 1, |
| 481 | _ => break, |
| 482 | } |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | /// Is the data spent? Any separators are stepped over first. |
| 487 | fn done(&mut self) -> bool { |
| 488 | self.sep(); |
| 489 | self.i >= self.s.len() |
| 490 | } |
| 491 | |
| 492 | /// What is left, for an error to quote. Truncated, since path data runs long. |
| 493 | fn rest(&self) -> String { |
| 494 | let end = (self.i + 16).min(self.s.len()); |
| 495 | String::from_utf8_lossy(&self.s[self.i..end]).into_owned() |
| 496 | } |
| 497 | |
| 498 | /// Takes the next byte if it is a command letter, and leaves the cursor alone if not. |
| 499 | fn cmd(&mut self) -> Option<u8> { |
| 500 | self.sep(); |
| 501 | if self.i < self.s.len() && self.s[self.i].is_ascii_alphabetic() { |
| 502 | self.i += 1; |
| 503 | Some(self.s[self.i - 1]) |
| 504 | } else { |
| 505 | None |
| 506 | } |
| 507 | } |
| 508 | |
| 509 | /// Reads one number. |
| 510 | /// |
| 511 | /// The grammar is looser than Rust's: a sign is optional, either side of the point may be empty, |
| 512 | /// and there is no separator requirement -- so `1.5.5` is two numbers and `-1-2` is two more. |
| 513 | /// The scanner therefore stops at the second point rather than trusting `parse` to complain. |
| 514 | fn num(&mut self) -> Outcome<f32> { |
| 515 | self.sep(); |
| 516 | let from = self.i; |
| 517 | if self.i < self.s.len() && (self.s[self.i] == b'+' || self.s[self.i] == b'-') { |
| 518 | self.i += 1; |
| 519 | } |
| 520 | let mut any = false; // A number needs at least one digit, on one side or the other. |
| 521 | while self.i < self.s.len() && self.s[self.i].is_ascii_digit() { |
| 522 | self.i += 1; |
| 523 | any = true; |
| 524 | } |
| 525 | if self.i < self.s.len() && self.s[self.i] == b'.' { |
| 526 | self.i += 1; |
| 527 | while self.i < self.s.len() && self.s[self.i].is_ascii_digit() { |
| 528 | self.i += 1; |
| 529 | any = true; |
| 530 | } |
| 531 | } |
| 532 | if !any { |
| 533 | return Err(err!( |
| 534 | "Expected a number at byte {} of the path data, found '{}'.", |
| 535 | from, self.rest(); Invalid, Input)); |
| 536 | } |
| 537 | // An exponent counts only if digits follow it. Otherwise the 'e' is not ours -- path data |
| 538 | // has no command by that name, but being strict here keeps the error at the right byte. |
| 539 | if self.i < self.s.len() && (self.s[self.i] == b'e' || self.s[self.i] == b'E') { |
| 540 | let mark = self.i; |
| 541 | self.i += 1; |
| 542 | if self.i < self.s.len() && (self.s[self.i] == b'+' || self.s[self.i] == b'-') { |
| 543 | self.i += 1; |
| 544 | } |
| 545 | if self.i < self.s.len() && self.s[self.i].is_ascii_digit() { |
| 546 | while self.i < self.s.len() && self.s[self.i].is_ascii_digit() { |
| 547 | self.i += 1; |
| 548 | } |
| 549 | } else { |
| 550 | self.i = mark; |
| 551 | } |
| 552 | } |
| 553 | let txt = res!(std::str::from_utf8(&self.s[from..self.i])); |
| 554 | match txt.parse::<f32>() { |
| 555 | Ok(v) => Ok(v), |
| 556 | Err(e) => Err(err!(e, |
| 557 | "'{}' at byte {} of the path data is not a number.", txt, from; |
| 558 | Invalid, Input)), |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | /// Reads an arc flag: a single `0` or `1`. |
| 563 | /// |
| 564 | /// A flag is one character and needs no separator, so `0 011` is two flags and the start of a |
| 565 | /// number. Reading it with [`Self::num`] would swallow the digits that follow it. |
| 566 | fn flag(&mut self) -> Outcome<bool> { |
| 567 | self.sep(); |
| 568 | if self.i >= self.s.len() { |
| 569 | return Err(err!("The path data ended where an arc flag was expected."; Invalid, Input)); |
| 570 | } |
| 571 | self.i += 1; |
| 572 | match self.s[self.i - 1] { |
| 573 | b'0' => Ok(false), |
| 574 | b'1' => Ok(true), |
| 575 | c => Err(err!( |
| 576 | "An arc flag is '0' or '1', found '{}' at byte {} of the path data.", |
| 577 | c as char, self.i - 1; Invalid, Input)), |
| 578 | } |
| 579 | } |
| 580 | |
| 581 | /// Reads a coordinate pair, offset from `from` when the command was relative. |
| 582 | fn point(&mut self, rel: bool, from: Pt) -> Outcome<Pt> { |
| 583 | let x = res!(self.num()); |
| 584 | let y = res!(self.num()); |
| 585 | Ok(if rel { |
| 586 | Pt::new(from.x + x, from.y + y) |
| 587 | } else { |
| 588 | Pt::new(x, y) |
| 589 | }) |
| 590 | } |
| 591 | } |
| 592 | |
| 593 | #[cfg(test)] |
| 594 | mod tests { |
| 595 | use super::*; |
| 596 | use crate::{ |
| 597 | path::{ |
| 598 | Seg, |
| 599 | TOLERANCE, |
| 600 | }, |
| 601 | transform::Transform, |
| 602 | }; |
| 603 | |
| 604 | #[test] |
| 605 | fn test_a_moveto_and_a_lineto_place_the_pen_00() -> Outcome<()> { |
| 606 | let p = res!(path_data("M 10 20 L 30 40")); |
| 607 | assert_eq!(p.segs(), &[Seg::MoveTo(Pt::new(10.0, 20.0)), Seg::LineTo(Pt::new(30.0, 40.0))]); |
| 608 | Ok(()) |
| 609 | } |
| 610 | |
| 611 | #[test] |
| 612 | fn test_a_lower_case_command_is_relative_to_the_pen_01() -> Outcome<()> { |
| 613 | let p = res!(path_data("M 10 10 l 5 5 l 5 5")); |
| 614 | assert_eq!(p.segs(), &[ |
| 615 | Seg::MoveTo(Pt::new(10.0, 10.0)), |
| 616 | Seg::LineTo(Pt::new(15.0, 15.0)), |
| 617 | Seg::LineTo(Pt::new(20.0, 20.0)), |
| 618 | ]); |
| 619 | Ok(()) |
| 620 | } |
| 621 | |
| 622 | #[test] |
| 623 | fn test_a_repeated_moveto_is_a_lineto_02() -> Outcome<()> { |
| 624 | // The grammar's one irregularity: extra pairs after a moveto are linetos, not movetos. Read |
| 625 | // as movetos they would be three contours of one point each, and nothing would be drawn. |
| 626 | let p = res!(path_data("M 0 0 1 1 2 2")); |
| 627 | assert_eq!(p.segs(), &[ |
| 628 | Seg::MoveTo(Pt::new(0.0, 0.0)), |
| 629 | Seg::LineTo(Pt::new(1.0, 1.0)), |
| 630 | Seg::LineTo(Pt::new(2.0, 2.0)), |
| 631 | ]); |
| 632 | Ok(()) |
| 633 | } |
| 634 | |
| 635 | #[test] |
| 636 | fn test_a_command_letter_may_be_left_out_to_repeat_it_03() -> Outcome<()> { |
| 637 | let p = res!(path_data("M 0 0 L 1 1 2 2 3 3")); |
| 638 | assert_eq!(p.segs().len(), 4); |
| 639 | assert_eq!(p.segs()[3], Seg::LineTo(Pt::new(3.0, 3.0))); |
| 640 | Ok(()) |
| 641 | } |
| 642 | |
| 643 | #[test] |
| 644 | fn test_two_numbers_may_share_a_point_04() -> Outcome<()> { |
| 645 | // `1.5.5` is 1.5 then 0.5: the grammar needs no separator between numbers, so a second point |
| 646 | // ends the first number. A scanner that read greedily to the next separator would see one |
| 647 | // malformed number and reject a legal file. |
| 648 | let p = res!(path_data("M1.5.5L.5 1")); |
| 649 | assert_eq!(p.segs()[0], Seg::MoveTo(Pt::new(1.5, 0.5))); |
| 650 | assert_eq!(p.segs()[1], Seg::LineTo(Pt::new(0.5, 1.0))); |
| 651 | Ok(()) |
| 652 | } |
| 653 | |
| 654 | #[test] |
| 655 | fn test_a_sign_separates_numbers_05() -> Outcome<()> { |
| 656 | // `-1-2` is two numbers, for the same reason. |
| 657 | let p = res!(path_data("M0 0L-1-2")); |
| 658 | assert_eq!(p.segs()[1], Seg::LineTo(Pt::new(-1.0, -2.0))); |
| 659 | Ok(()) |
| 660 | } |
| 661 | |
| 662 | #[test] |
| 663 | fn test_an_exponent_is_read_06() -> Outcome<()> { |
| 664 | let p = res!(path_data("M 0 0 L 1e2 1.5e-1")); |
| 665 | assert_eq!(p.segs()[1], Seg::LineTo(Pt::new(100.0, 0.15))); |
| 666 | Ok(()) |
| 667 | } |
| 668 | |
| 669 | #[test] |
| 670 | fn test_an_arc_flag_needs_no_separator_07() -> Outcome<()> { |
| 671 | // `0 011 1` is two flags then the endpoint. A flag read as a number would swallow `011` whole |
| 672 | // and the arc would land somewhere else entirely -- silently, with no error to notice. |
| 673 | let a = res!(path_data("M 0 0 a 1 1 0 011 1")); |
| 674 | let b = res!(path_data("M 0 0 a 1 1 0 0 1 1 1")); |
| 675 | assert_eq!(a.segs(), b.segs()); |
| 676 | Ok(()) |
| 677 | } |
| 678 | |
| 679 | #[test] |
| 680 | fn test_a_smooth_cubic_reflects_the_last_control_point_08() -> Outcome<()> { |
| 681 | // After C with its second control at (2,2) and the pen at (3,3), S's first control must be |
| 682 | // the reflection, (4,4). |
| 683 | let p = res!(path_data("M 0 0 C 1 1 2 2 3 3 S 5 5 6 6")); |
| 684 | match p.segs()[2] { |
| 685 | Seg::CubicTo(c0, _, _) => assert_eq!(c0, Pt::new(4.0, 4.0)), |
| 686 | s => return Err(err!("Expected a cubic, found {:?}.", s; Test, Invalid)), |
| 687 | } |
| 688 | Ok(()) |
| 689 | } |
| 690 | |
| 691 | #[test] |
| 692 | fn test_a_smooth_cubic_after_a_non_curve_uses_the_pen_09() -> Outcome<()> { |
| 693 | // There is nothing to reflect, so the first control coincides with the current point. A |
| 694 | // reader that reflected a stale control point would bend the curve the wrong way. |
| 695 | let p = res!(path_data("M 0 0 L 3 3 S 5 5 6 6")); |
| 696 | match p.segs()[2] { |
| 697 | Seg::CubicTo(c0, _, _) => assert_eq!(c0, Pt::new(3.0, 3.0)), |
| 698 | s => return Err(err!("Expected a cubic, found {:?}.", s; Test, Invalid)), |
| 699 | } |
| 700 | Ok(()) |
| 701 | } |
| 702 | |
| 703 | #[test] |
| 704 | fn test_a_smooth_cubic_does_not_reflect_a_quadratics_control_10() -> Outcome<()> { |
| 705 | // `S` reflects only a cubic's control point. After a `Q`, there is nothing of its kind to |
| 706 | // reflect, so the pen is used -- which is why the last control point carries its kind. |
| 707 | let p = res!(path_data("M 0 0 Q 1 1 3 3 S 5 5 6 6")); |
| 708 | match p.segs()[2] { |
| 709 | Seg::CubicTo(c0, _, _) => assert_eq!(c0, Pt::new(3.0, 3.0)), |
| 710 | s => return Err(err!("Expected a cubic, found {:?}.", s; Test, Invalid)), |
| 711 | } |
| 712 | Ok(()) |
| 713 | } |
| 714 | |
| 715 | #[test] |
| 716 | fn test_close_returns_the_pen_to_where_the_contour_began_11() -> Outcome<()> { |
| 717 | // The `l 1 0` after `Z` is relative to (2,2), where the contour started, not to (5,5) where |
| 718 | // the pen last drew. The close also ends the contour, so the next subpath opens at that same |
| 719 | // point -- which is what the implicit moveto records. |
| 720 | let p = res!(path_data("M 2 2 L 5 5 Z l 1 0")); |
| 721 | assert_eq!(p.segs(), &[ |
| 722 | Seg::MoveTo(Pt::new(2.0, 2.0)), |
| 723 | Seg::LineTo(Pt::new(5.0, 5.0)), |
| 724 | Seg::Close, |
| 725 | Seg::MoveTo(Pt::new(2.0, 2.0)), |
| 726 | Seg::LineTo(Pt::new(3.0, 2.0)), |
| 727 | ]); |
| 728 | Ok(()) |
| 729 | } |
| 730 | |
| 731 | #[test] |
| 732 | fn test_horizontal_and_vertical_hold_the_other_axis_12() -> Outcome<()> { |
| 733 | let p = res!(path_data("M 1 2 H 5 V 8 h -1 v -1")); |
| 734 | assert_eq!(p.segs()[1], Seg::LineTo(Pt::new(5.0, 2.0))); |
| 735 | assert_eq!(p.segs()[2], Seg::LineTo(Pt::new(5.0, 8.0))); |
| 736 | assert_eq!(p.segs()[3], Seg::LineTo(Pt::new(4.0, 8.0))); |
| 737 | assert_eq!(p.segs()[4], Seg::LineTo(Pt::new(4.0, 7.0))); |
| 738 | Ok(()) |
| 739 | } |
| 740 | |
| 741 | #[test] |
| 742 | fn test_an_arc_stays_on_its_radius_13() -> Outcome<()> { |
| 743 | // Two half-turn arcs make a circle of radius 100 about the origin. Every flattened point must |
| 744 | // sit on that radius: this is the whole arc conversion -- centre, angles and all -- checked |
| 745 | // against geometry rather than against itself. |
| 746 | let p = res!(path_data("M 100 0 A 100 100 0 0 1 -100 0 A 100 100 0 0 1 100 0 Z")); |
| 747 | let cs = p.flatten(&Transform::IDENTITY, TOLERANCE); |
| 748 | let mut n = 0; |
| 749 | for c in &cs { |
| 750 | for q in c { |
| 751 | let r = (q.x * q.x + q.y * q.y).sqrt(); |
| 752 | assert!((r - 100.0).abs() < 0.5, "point ({}, {}) sits at radius {}", q.x, q.y, r); |
| 753 | n += 1; |
| 754 | } |
| 755 | } |
| 756 | assert!(n > 16, "a circle of radius 100 flattened to only {} points", n); |
| 757 | Ok(()) |
| 758 | } |
| 759 | |
| 760 | #[test] |
| 761 | fn test_the_sweep_flag_picks_the_side_the_arc_bulges_14() -> Outcome<()> { |
| 762 | // The same ends and radii, opposite sweeps: one arc must bow above the chord and the other |
| 763 | // below. Getting this backwards mirrors every rounded shape in a drawing. |
| 764 | let up = res!(path_data("M 0 0 A 50 50 0 0 1 100 0")); |
| 765 | let dn = res!(path_data("M 0 0 A 50 50 0 0 0 100 0")); |
| 766 | let mid = |p: &Path| -> f32 { |
| 767 | let cs = p.flatten(&Transform::IDENTITY, TOLERANCE); |
| 768 | let mut y = 0.0; |
| 769 | for c in &cs { |
| 770 | for q in c { |
| 771 | if (q.x - 50.0).abs() < 2.0 { |
| 772 | y = q.y; |
| 773 | } |
| 774 | } |
| 775 | } |
| 776 | y |
| 777 | }; |
| 778 | assert!(mid(&up) < -40.0, "sweep 1 should bow to negative y, reached {}", mid(&up)); |
| 779 | assert!(mid(&dn) > 40.0, "sweep 0 should bow to positive y, reached {}", mid(&dn)); |
| 780 | Ok(()) |
| 781 | } |
| 782 | |
| 783 | #[test] |
| 784 | fn test_an_arc_with_no_radius_is_a_straight_line_15() -> Outcome<()> { |
| 785 | let p = res!(path_data("M 0 0 A 0 0 0 0 1 10 10")); |
| 786 | assert_eq!(p.segs()[1], Seg::LineTo(Pt::new(10.0, 10.0))); |
| 787 | Ok(()) |
| 788 | } |
| 789 | |
| 790 | #[test] |
| 791 | fn test_an_arc_that_ends_where_it_starts_is_dropped_16() -> Outcome<()> { |
| 792 | // The specification says so, and the conversion would divide by zero otherwise. |
| 793 | let p = res!(path_data("M 5 5 A 10 10 0 1 1 5 5")); |
| 794 | assert_eq!(p.segs(), &[Seg::MoveTo(Pt::new(5.0, 5.0))]); |
| 795 | Ok(()) |
| 796 | } |
| 797 | |
| 798 | #[test] |
| 799 | fn test_radii_too_small_to_reach_are_grown_17() -> Outcome<()> { |
| 800 | // The ends are 100 apart but the radii say 10. The specification grows them rather than |
| 801 | // failing, so the arc must still land on its endpoint. |
| 802 | let p = res!(path_data("M 0 0 A 10 10 0 0 1 100 0")); |
| 803 | let end = match p.segs().last() { |
| 804 | Some(Seg::CubicTo(_, _, e)) => *e, |
| 805 | s => return Err(err!("Expected a cubic last, found {:?}.", s; Test, Invalid)), |
| 806 | }; |
| 807 | assert!((end.x - 100.0).abs() < 0.01 && end.y.abs() < 0.01, |
| 808 | "the arc ended at ({}, {}) rather than (100, 0)", end.x, end.y); |
| 809 | Ok(()) |
| 810 | } |
| 811 | |
| 812 | #[test] |
| 813 | fn test_data_that_does_not_begin_with_a_command_is_refused_18() -> Outcome<()> { |
| 814 | assert!(path_data("10 20 L 30 40").is_err()); |
| 815 | Ok(()) |
| 816 | } |
| 817 | |
| 818 | #[test] |
| 819 | fn test_an_unknown_command_is_refused_19() -> Outcome<()> { |
| 820 | assert!(path_data("M 0 0 X 1 1").is_err()); |
| 821 | Ok(()) |
| 822 | } |
| 823 | |
| 824 | #[test] |
| 825 | fn test_a_command_missing_an_argument_is_refused_20() -> Outcome<()> { |
| 826 | assert!(path_data("M 0 0 L 5").is_err()); |
| 827 | Ok(()) |
| 828 | } |
| 829 | |
| 830 | #[test] |
| 831 | fn test_an_arc_flag_that_is_not_zero_or_one_is_refused_21() -> Outcome<()> { |
| 832 | assert!(path_data("M 0 0 a 1 1 0 2 1 1 1").is_err()); |
| 833 | Ok(()) |
| 834 | } |
| 835 | |
| 836 | #[test] |
| 837 | fn test_empty_data_is_an_empty_path_22() -> Outcome<()> { |
| 838 | let p = res!(path_data(" ")); |
| 839 | assert!(p.is_empty()); |
| 840 | Ok(()) |
| 841 | } |
| 842 | |
| 843 | #[test] |
| 844 | fn test_data_that_does_not_begin_with_a_moveto_is_refused_23() -> Outcome<()> { |
| 845 | // A drawing command has nowhere to draw from until a moveto has named a pen position. |
| 846 | // Quietly starting at the origin would put the shape somewhere the author never asked for. |
| 847 | assert!(path_data("L 30 40").is_err()); |
| 848 | assert!(path_data("C 1 1 2 2 3 3").is_err()); |
| 849 | Ok(()) |
| 850 | } |
| 851 | |
| 852 | #[test] |
| 853 | fn test_a_first_relative_moveto_is_absolute_24() -> Outcome<()> { |
| 854 | // It is measured from a pen at the origin, so it lands on its own coordinates. |
| 855 | let p = res!(path_data("m 10 20 l 1 1")); |
| 856 | assert_eq!(p.segs()[0], Seg::MoveTo(Pt::new(10.0, 20.0))); |
| 857 | Ok(()) |
| 858 | } |
| 859 | |
| 860 | #[test] |
| 861 | fn test_a_line_writes_the_expected_data_25() -> Outcome<()> { |
| 862 | // The hand-known case: a move to the origin and a line to (10, 0) is exactly "M0,0 L10,0". |
| 863 | // Whole coordinates carry no decimal point, and a comma joins each pair, a space each |
| 864 | // command. |
| 865 | let p = res!(path_data("M 0 0 L 10 0")); |
| 866 | assert_eq!(write_path_data(&p), "M0,0 L10,0"); |
| 867 | Ok(()) |
| 868 | } |
| 869 | |
| 870 | #[test] |
| 871 | fn test_every_command_writes_its_letter_26() -> Outcome<()> { |
| 872 | // One of each segment kind, so the writer's whole command vocabulary is pinned to a known |
| 873 | // string. |
| 874 | let mut pb = PathBuilder::new(); |
| 875 | pb.move_to(Pt::new(1.0, 2.0)); |
| 876 | pb.line_to(Pt::new(3.0, 4.0)); |
| 877 | pb.quad_to(Pt::new(5.0, 6.0), Pt::new(7.0, 8.0)); |
| 878 | pb.cubic_to(Pt::new(9.0, 10.0), Pt::new(11.0, 12.0), Pt::new(13.0, 14.0)); |
| 879 | pb.close(); |
| 880 | let p = res!(pb.finish()); |
| 881 | assert_eq!(write_path_data(&p), "M1,2 L3,4 Q5,6 7,8 C9,10 11,12 13,14 Z"); |
| 882 | Ok(()) |
| 883 | } |
| 884 | |
| 885 | #[test] |
| 886 | fn test_an_empty_path_writes_an_empty_string_27() -> Outcome<()> { |
| 887 | let p = res!(PathBuilder::new().finish()); |
| 888 | assert_eq!(write_path_data(&p), ""); |
| 889 | Ok(()) |
| 890 | } |
| 891 | |
| 892 | #[test] |
| 893 | fn test_the_writer_round_trips_through_the_reader_28() -> Outcome<()> { |
| 894 | // The writer is the reader's inverse: a path written to data and read back is the path it |
| 895 | // began as, segment for segment. The reader is the external oracle here -- the geometry is |
| 896 | // checked against the module that already reads what every drawing program writes, not |
| 897 | // against the writer restated. Fractional coordinates are used deliberately, so the test |
| 898 | // bites on the number formatting and not only on round integers. |
| 899 | let mut pb = PathBuilder::new(); |
| 900 | pb.move_to(Pt::new(1.5, -2.25)); |
| 901 | pb.line_to(Pt::new(10.0, 0.5)); |
| 902 | pb.quad_to(Pt::new(12.5, 3.75), Pt::new(20.0, -1.5)); |
| 903 | pb.cubic_to(Pt::new(21.0, 2.0), Pt::new(23.5, 4.5), Pt::new(30.0, 0.0)); |
| 904 | pb.close(); |
| 905 | let p = res!(pb.finish()); |
| 906 | let back = res!(path_data(&write_path_data(&p))); |
| 907 | assert_eq!(p.segs(), back.segs(), "the path did not survive the round trip"); |
| 908 | Ok(()) |
| 909 | } |
| 910 | |
| 911 | #[test] |
| 912 | fn test_a_curved_shape_round_trips_29() -> Outcome<()> { |
| 913 | // A whole built shape -- a rounded rectangle, all lines and cubics -- survives the round |
| 914 | // trip through data and back, so the writer holds up on geometry it did not itself hand-pick. |
| 915 | use crate::path::Bounds; |
| 916 | let p = res!(Path::round_rect(Bounds::new(2.0, 3.0, 40.0, 25.0), 6.0)); |
| 917 | let back = res!(path_data(&write_path_data(&p))); |
| 918 | assert_eq!(p.segs(), back.segs()); |
| 919 | Ok(()) |
| 920 | } |
| 921 | |
| 922 | #[test] |
| 923 | fn test_presentation_writes_fill_and_stroke_attributes_30() -> Outcome<()> { |
| 924 | use crate::stroke::{ |
| 925 | Cap, |
| 926 | Dash, |
| 927 | Join, |
| 928 | }; |
| 929 | let pen = res!(Stroke::new(2.0)) |
| 930 | .with_cap(Cap::Round) |
| 931 | .with_join(Join::Bevel) |
| 932 | .with_dash(Dash::new(vec![4.0, 2.0]).with_offset(1.0)); |
| 933 | let attrs = presentation(Some(res!(Rgba::from_hex("#ff8800"))), Some((Rgba::BLACK, &pen))); |
| 934 | assert!(attrs.contains("fill=\"#ff8800\""), "the fill colour, found: {}", attrs); |
| 935 | assert!(attrs.contains("stroke=\"#000000\""), "the stroke colour"); |
| 936 | assert!(attrs.contains("stroke-width=\"2\""), "the pen width"); |
| 937 | assert!(attrs.contains("stroke-linecap=\"round\""), "the cap"); |
| 938 | assert!(attrs.contains("stroke-linejoin=\"bevel\""), "the join"); |
| 939 | assert!(attrs.contains("stroke-dasharray=\"4,2\""), "the dash pattern"); |
| 940 | assert!(attrs.contains("stroke-dashoffset=\"1\""), "the dash offset"); |
| 941 | Ok(()) |
| 942 | } |
| 943 | |
| 944 | #[test] |
| 945 | fn test_presentation_says_none_for_no_fill_and_carries_alpha_31() -> Outcome<()> { |
| 946 | // No fill must be stated outright, since SVG fills black by default. A translucent stroke |
| 947 | // splits into a colour and a separate opacity, the form every renderer reads. |
| 948 | let pen = res!(Stroke::new(1.0)); |
| 949 | let attrs = presentation(None, Some((Rgba::new(0, 0, 0, 128), &pen))); |
| 950 | assert!(attrs.contains("fill=\"none\""), "no fill, found: {}", attrs); |
| 951 | assert!(attrs.contains("stroke=\"#000000\""), "the stroke colour without its alpha"); |
| 952 | assert!(attrs.contains("stroke-opacity=\"0.502\""), "the alpha as an opacity, found: {}", attrs); |
| 953 | Ok(()) |
| 954 | } |
| 955 | } |