mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
feat: SVG import — read SVG and SVGZ into native entities (#636)
Squash of PR #636 by @switch87. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
89064fd82a
commit
f7c6293d09
12 files changed
+1152
-2
No files matched your search
Generated
+1
@@ -660,6 +660,7 @@ dependencies = [
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"miniz_oxide 0.8.9",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -41,6 +41,8 @@ harfrust = "0.12"
|
||||
unicode-bidi = "0.3.18"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
encoding_rs = "0.8.35"
|
||||
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
|
||||
quick-xml = "0.41"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
|
||||
@@ -119,6 +119,7 @@ fn services() -> Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
|
||||
.add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"])
|
||||
.add_filter(i18n::t("All files"), &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
|
||||
@@ -171,6 +171,9 @@ fn file_name(path: &str) -> String {
|
||||
pub(crate) fn open_bytes(s: &mut Session, bytes: &[u8], name: &str, path: Option<String>) -> Result<usize> {
|
||||
let hooks = io().ok_or_else(|| bad("open", "file formats are not available in this build"))?;
|
||||
let d = (hooks.read)(bytes, name).map_err(|e| bad("open", e))?;
|
||||
// A file that isn't a drawing (an imported SVG) gives the drawing no file name: QSAVE then
|
||||
// asks where to save instead of exporting over the original.
|
||||
let path = path.filter(|p| is_drawing_file(p));
|
||||
let i = s.open_drawing(d, &file_name(name), path);
|
||||
if let Some(st) = s.docs.get_mut(i) {
|
||||
// QSAVE keeps a binary DXF binary.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Opening a file that isn't a drawing (SVG, read into entities): the drawing gets no file name,
|
||||
//! so QSAVE asks for one instead of writing CADCraft's SVG export over the original.
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::cmd::file::{IoHooks, set_io};
|
||||
use cadcraft_engine::doc::Drawing;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn an_opened_svg_is_not_saved_over() {
|
||||
set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"exported".to_vec()), plot: None });
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-open-svg-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let svg = dir.join("Logo.svg");
|
||||
std::fs::write(&svg, b"<svg/>").unwrap();
|
||||
let mut s = Session::empty();
|
||||
s.execute("open", &json!({"path": svg.to_string_lossy()})).unwrap();
|
||||
let st = s.state().unwrap();
|
||||
assert_eq!(st.title, "Logo.svg");
|
||||
assert_eq!(st.path, None);
|
||||
assert!(s.execute("qsave", &json!({})).is_err(), "QSAVE asks for a file name");
|
||||
assert_eq!(std::fs::read(&svg).unwrap(), b"<svg/>", "the original is untouched");
|
||||
|
||||
// A drawing file keeps its path.
|
||||
let dxf = dir.join("Plan.dxf");
|
||||
std::fs::write(&dxf, b"0\nEOF\n").unwrap();
|
||||
s.execute("open", &json!({"path": dxf.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.state().unwrap().path.as_deref(), Some(dxf.to_string_lossy().as_ref()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -20,3 +20,4 @@ thiserror = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
miniz_oxide = "0.8"
|
||||
quick-xml = { workspace = true }
|
||||
@@ -1,4 +1,4 @@
|
||||
//! CADCraft file formats: DXF read/write, SVG, PNG and PDF export (plotting).
|
||||
//! CADCraft file formats: DXF/DWG read, SVG and SVGZ import, SVG/PNG/PDF export (plotting).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -15,6 +15,7 @@ mod dxf_xref;
|
||||
pub mod pdf;
|
||||
pub mod plotter;
|
||||
pub mod svg;
|
||||
mod svg_read;
|
||||
|
||||
/// Multilines and their styles (MLINE, MLINESTYLE).
|
||||
mod dxf_mline;
|
||||
@@ -54,13 +55,15 @@ fn ext(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read a drawing; the format comes from the content (DXF ASCII/binary) or the name.
|
||||
/// Read a drawing; the format comes from the content (DXF ASCII/binary, DWG) or the name
|
||||
/// (SVG and gzip-compressed SVGZ).
|
||||
pub fn read(bytes: &[u8], name: &str) -> Result<Drawing> {
|
||||
if cadcraft_dwg::is_dwg(bytes) {
|
||||
let dxf = cadcraft_dwg::dwg_to_dxf(bytes).map_err(IoError::Format)?;
|
||||
return dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")));
|
||||
}
|
||||
match ext(name).as_str() {
|
||||
"svg" | "svgz" => svg_read::read(bytes),
|
||||
"dxf" | "" => dxf_read::read(bytes),
|
||||
e if bytes.len() > 4 => dxf_read::read(bytes).map_err(|_| IoError::Unsupported(e.to_string())),
|
||||
e => Err(IoError::Unsupported(e.to_string())),
|
||||
|
||||
@@ -0,0 +1,945 @@
|
||||
//! SVG → Drawing: the static W3C SVG subset that drawing tools emit.
|
||||
//!
|
||||
//! Reads shapes (`rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`), `path`
|
||||
//! outlines (`M L H V C S Q T A Z`, absolute and relative), `g` grouping with `transform`
|
||||
//! attributes, `viewBox` and unit-bearing `width`/`height`, and Inkscape layers
|
||||
//! (`inkscape:groupmode="layer"`). Geometry becomes centre-line entities in millimetres with
|
||||
//! the page's top-left corner at the origin and Y pointing up — where a laser or pen job
|
||||
//! wants it.
|
||||
//!
|
||||
//! Not read (each is a follow-up): CSS class rules (`<style>`), `use` references, fills,
|
||||
//! gradients and hatch patterns, text, embedded raster images, rounded rectangle corners
|
||||
//! (`rx`/`ry`), and elliptical arcs under non-uniform transforms (those are flattened into
|
||||
//! line segments instead of losing the shape).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::*;
|
||||
use cadcraft_geom::{Mat3, PolyVertex, Spline, Vec2, Vec3};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
/// A decompressed SVGZ payload larger than this is refused (decompression bomb guard).
|
||||
const MAX_DECOMPRESSED: usize = 64 << 20;
|
||||
/// Shape elements per file (rect, circle, path, …; each counted once).
|
||||
const MAX_ELEMENTS: usize = 500_000;
|
||||
/// Path data segments per `d` attribute.
|
||||
const MAX_PATH_SEGMENTS: usize = 100_000;
|
||||
/// Vertices in one polyline, and control points in one spline.
|
||||
const MAX_CURVE_POINTS: usize = 100_001;
|
||||
/// Layers created from Inkscape layer groups.
|
||||
const MAX_LAYERS: usize = 256;
|
||||
/// Nested `<g>`/`<svg>` transforms.
|
||||
const MAX_TRANSFORM_DEPTH: usize = 64;
|
||||
/// Flattened chords per elliptical arc under a non-uniform transform.
|
||||
const MAX_ARC_CHORDS: usize = 10_000;
|
||||
|
||||
/// Elements whose whole subtree is skipped (paint servers, text, raster images, metadata).
|
||||
const SKIP: &[&[u8]] = &[
|
||||
b"defs",
|
||||
b"symbol",
|
||||
b"clipPath",
|
||||
b"mask",
|
||||
b"pattern",
|
||||
b"marker",
|
||||
b"linearGradient",
|
||||
b"radialGradient",
|
||||
b"filter",
|
||||
b"style",
|
||||
b"text",
|
||||
b"image",
|
||||
b"title",
|
||||
b"desc",
|
||||
b"metadata",
|
||||
];
|
||||
|
||||
/// Elements that hold children and push a transform stack frame.
|
||||
const CONTAINERS: &[&[u8]] = &[b"g", b"svg", b"a", b"switch"];
|
||||
|
||||
/// Read an SVG document, or a gzip-compressed one (`.svgz`, Inkscape's "Compressed SVG").
|
||||
pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
||||
let unpacked;
|
||||
let plain: &[u8] = if bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
unpacked = gunzip(bytes)?;
|
||||
&unpacked
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
let text = std::str::from_utf8(plain).map_err(|_| IoError::Format("SVG files must be UTF-8".into()))?;
|
||||
let mut parser = Parser::new();
|
||||
parser.parse(text)?;
|
||||
Ok(parser.drawing)
|
||||
}
|
||||
|
||||
/// Decompress a gzip member (RFC 1952) with a hard output cap.
|
||||
fn gunzip(bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let damaged = || IoError::Format("this SVGZ file's gzip stream is damaged".into());
|
||||
if bytes.get(2) != Some(&8) {
|
||||
return Err(damaged());
|
||||
}
|
||||
let flg = bytes.get(3).copied().unwrap_or(0);
|
||||
let mut pos = 10usize;
|
||||
if flg & 0x04 != 0 {
|
||||
// FEXTRA
|
||||
let extra = bytes.get(pos..pos + 2).ok_or_else(damaged)?;
|
||||
pos += 2 + usize::from(u16::from_le_bytes([extra[0], extra[1]]));
|
||||
}
|
||||
if flg & 0x08 != 0 {
|
||||
pos = skip_cstring(bytes, pos).ok_or_else(damaged)?; // FNAME
|
||||
}
|
||||
if flg & 0x10 != 0 {
|
||||
pos = skip_cstring(bytes, pos).ok_or_else(damaged)?; // FCOMMENT
|
||||
}
|
||||
if flg & 0x02 != 0 {
|
||||
pos = pos.saturating_add(2); // FHCRC
|
||||
}
|
||||
let end = bytes.len().checked_sub(8).filter(|e| *e >= pos).ok_or_else(damaged)?;
|
||||
miniz_oxide::inflate::decompress_to_vec_with_limit(&bytes[pos..end], MAX_DECOMPRESSED).map_err(|_| damaged())
|
||||
}
|
||||
|
||||
/// Skip a NUL-terminated field; `None` when the terminator is missing.
|
||||
fn skip_cstring(bytes: &[u8], from: usize) -> Option<usize> {
|
||||
let rest = bytes.get(from..)?;
|
||||
let nul = rest.iter().position(|b| *b == 0)?;
|
||||
Some(from + nul + 1)
|
||||
}
|
||||
|
||||
fn parse_err<E: std::fmt::Display>(e: E) -> IoError {
|
||||
IoError::Format(format!("SVG: {e}"))
|
||||
}
|
||||
|
||||
fn v3(p: Vec2) -> Vec3 {
|
||||
Vec3::new(p.x, p.y, 0.0)
|
||||
}
|
||||
|
||||
struct Parser {
|
||||
drawing: Drawing,
|
||||
/// Root viewBox/unit mapping composed with every open container transform.
|
||||
stack: Vec<Mat3>,
|
||||
/// Current Inkscape layer name.
|
||||
layer: String,
|
||||
layers: HashSet<String>,
|
||||
layer_color: u8,
|
||||
elements: usize,
|
||||
/// > 0 while inside a skipped subtree; counts the starts seen there.
|
||||
skip: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn new() -> Self {
|
||||
Parser {
|
||||
drawing: Drawing::new_metric(),
|
||||
stack: vec![Mat3::IDENTITY],
|
||||
layer: "0".into(),
|
||||
layers: HashSet::from(["0".to_string()]),
|
||||
layer_color: 1,
|
||||
elements: 0,
|
||||
skip: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(&mut self, text: &str) -> Result<()> {
|
||||
let mut reader = Reader::from_str(text);
|
||||
reader.config_mut().trim_text(true);
|
||||
loop {
|
||||
match reader.read_event().map_err(parse_err)? {
|
||||
Event::Start(e) => {
|
||||
let name = e.name();
|
||||
self.on_start(name.as_ref(), &e)?;
|
||||
}
|
||||
Event::Empty(e) => {
|
||||
let name = e.name();
|
||||
self.on_element(name.as_ref(), &e)?;
|
||||
}
|
||||
Event::End(e) => {
|
||||
let name = e.name();
|
||||
self.on_end(name.as_ref());
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A start tag: open a transform frame, descend into a skipped subtree, or treat a
|
||||
/// shape written as start+end the same as a self-closed one.
|
||||
fn on_start(&mut self, name: &[u8], e: &BytesStart) -> Result<()> {
|
||||
if self.skip > 0 {
|
||||
self.skip += 1;
|
||||
return Ok(());
|
||||
}
|
||||
if SKIP.contains(&name) {
|
||||
self.skip = 1;
|
||||
return Ok(());
|
||||
}
|
||||
if hidden(e) {
|
||||
self.skip = 1;
|
||||
return Ok(());
|
||||
}
|
||||
if CONTAINERS.contains(&name) { self.enter_group(name, e) } else { self.on_element(name, e) }
|
||||
}
|
||||
|
||||
fn on_element(&mut self, name: &[u8], e: &BytesStart) -> Result<()> {
|
||||
if self.skip > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if SKIP.contains(&name) || hidden(e) {
|
||||
return Ok(());
|
||||
}
|
||||
match name {
|
||||
b"rect" => self.rect(e),
|
||||
b"circle" => self.circle(e),
|
||||
b"ellipse" => self.ellipse(e),
|
||||
b"line" => self.line(e),
|
||||
b"polyline" => self.poly_points(e, false),
|
||||
b"polygon" => self.poly_points(e, true),
|
||||
b"path" => self.path(e),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_end(&mut self, name: &[u8]) {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
} else if CONTAINERS.contains(&name) && self.stack.len() > 1 {
|
||||
self.stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn enter_group(&mut self, name: &[u8], e: &BytesStart) -> Result<()> {
|
||||
if name == b"svg" && self.stack.len() == 1 {
|
||||
self.stack[0] = viewport(e)?;
|
||||
}
|
||||
if name == b"g" && attr(e, b"inkscape:groupmode").as_deref() == Some("layer") {
|
||||
let raw = attr(e, b"inkscape:label").or_else(|| attr(e, b"id"));
|
||||
if let Some(raw) = raw {
|
||||
self.set_layer(&raw);
|
||||
}
|
||||
}
|
||||
if self.stack.len() >= MAX_TRANSFORM_DEPTH {
|
||||
return Err(IoError::Format(format!("SVG: more than {MAX_TRANSFORM_DEPTH} nested groups")));
|
||||
}
|
||||
let current = *self.stack.last().unwrap_or(&Mat3::IDENTITY);
|
||||
let next = match attr(e, b"transform").as_deref().and_then(parse_transform) {
|
||||
Some(local) => current.then_before(local),
|
||||
None => current,
|
||||
};
|
||||
self.stack.push(next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_layer(&mut self, raw: &str) {
|
||||
let name = sanitize_layer(raw);
|
||||
if !self.layers.contains(&name) {
|
||||
if self.layers.len() >= MAX_LAYERS {
|
||||
return;
|
||||
}
|
||||
let color = Color::Index(self.layer_color);
|
||||
self.layer_color = if self.layer_color >= 7 { 1 } else { self.layer_color + 1 };
|
||||
self.drawing.layers.push(Layer { name: name.clone(), color, ..Layer::default() });
|
||||
self.layers.insert(name.clone());
|
||||
}
|
||||
self.layer = name;
|
||||
}
|
||||
|
||||
/// The composed mapping for the current nesting: SVG user units → drawing units (mm).
|
||||
fn cur(&self) -> Mat3 {
|
||||
*self.stack.last().unwrap_or(&Mat3::IDENTITY)
|
||||
}
|
||||
|
||||
fn map(&self, p: Vec2) -> Vec2 {
|
||||
self.cur().apply(p)
|
||||
}
|
||||
|
||||
fn map_vec(&self, v: Vec2) -> Vec2 {
|
||||
self.cur().apply_vec(v)
|
||||
}
|
||||
|
||||
fn add(&mut self, kind: EntityKind) -> Result<()> {
|
||||
self.elements += 1;
|
||||
if self.elements > MAX_ELEMENTS {
|
||||
return Err(IoError::Format(format!("SVG: more than {MAX_ELEMENTS} elements")));
|
||||
}
|
||||
let common = Common { layer: self.layer.clone(), ..Common::default() };
|
||||
self.drawing.add(&Space::Model, common, kind).map(|_| ()).map_err(parse_err)
|
||||
}
|
||||
|
||||
// --- shapes -------------------------------------------------------------
|
||||
|
||||
fn rect(&mut self, e: &BytesStart) -> Result<()> {
|
||||
let x = num(e, b"x", 0.0);
|
||||
let y = num(e, b"y", 0.0);
|
||||
let w = num(e, b"width", 0.0);
|
||||
let h = num(e, b"height", 0.0);
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let pts = [Vec2::new(x, y), Vec2::new(x + w, y), Vec2::new(x + w, y + h), Vec2::new(x, y + h)];
|
||||
self.add_polyline(&pts.map(|p| self.map(p)), true)
|
||||
}
|
||||
|
||||
fn circle(&mut self, e: &BytesStart) -> Result<()> {
|
||||
let r = num(e, b"r", 0.0);
|
||||
if r <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
self.add_rounded(Vec2::new(num(e, b"cx", 0.0), num(e, b"cy", 0.0)), r, r)
|
||||
}
|
||||
|
||||
fn ellipse(&mut self, e: &BytesStart) -> Result<()> {
|
||||
let rx = num(e, b"rx", 0.0);
|
||||
let ry = num(e, b"ry", 0.0);
|
||||
if rx <= 0.0 || ry <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
self.add_rounded(Vec2::new(num(e, b"cx", 0.0), num(e, b"cy", 0.0)), rx, ry)
|
||||
}
|
||||
|
||||
/// A circle or ellipse through a transform. A circle stays a `CIRCLE` under rotations,
|
||||
/// uniform scales and reflections; anything else becomes an `ELLIPSE` holding the axes
|
||||
/// of the transformed shape — a linear map sends the unit circle to an ellipse whose
|
||||
/// semi-axes are the square roots of the eigenvalues of `A·Aᵀ`.
|
||||
fn add_rounded(&mut self, center_user: Vec2, rx: f64, ry: f64) -> Result<()> {
|
||||
let center = self.map(center_user);
|
||||
let bx = self.map_vec(Vec2::new(rx, 0.0));
|
||||
let by = self.map_vec(Vec2::new(0.0, ry));
|
||||
let len = |v: Vec2| v.x.hypot(v.y);
|
||||
let (lbx, lby, dot) = (len(bx), len(by), bx.x * by.x + bx.y * by.y);
|
||||
if lbx.is_finite() && lby.is_finite() && (lbx - lby).abs() <= 1e-9 * (lbx + lby) && dot.abs() <= 1e-9 * lbx * lby {
|
||||
return self.add(EntityKind::Circle(Circle { center: v3(center), radius: lbx }));
|
||||
}
|
||||
let xx = bx.x * bx.x + bx.y * bx.y;
|
||||
let yy = by.x * by.x + by.y * by.y;
|
||||
let xy = bx.x * by.x + bx.y * by.y;
|
||||
let trace = xx + yy;
|
||||
let disc = (trace * trace - 4.0 * (xx * yy - xy * xy)).max(0.0).sqrt();
|
||||
let l_max = 0.5 * (trace + disc);
|
||||
let l_min = 0.5 * (trace - disc);
|
||||
let s_max = l_max.max(0.0).sqrt();
|
||||
if s_max <= f64::EPSILON {
|
||||
return Ok(());
|
||||
}
|
||||
let axis = if xy.abs() <= 1e-12 * trace.max(1.0) {
|
||||
if yy > xx { Vec2::new(0.0, 1.0) } else { Vec2::new(1.0, 0.0) }
|
||||
} else {
|
||||
let v = Vec2::new(xy, l_max - xx);
|
||||
let l = len(v);
|
||||
if l <= f64::EPSILON {
|
||||
return Ok(());
|
||||
}
|
||||
v / l
|
||||
};
|
||||
let ratio = (l_min.max(0.0).sqrt() / s_max).clamp(1e-9, 1.0);
|
||||
let major = center + axis * s_max;
|
||||
self.add(EntityKind::Ellipse(Ellipse { center: v3(center), major: v3(major), ratio, start: 0.0, end: std::f64::consts::TAU }))
|
||||
}
|
||||
|
||||
fn line(&mut self, e: &BytesStart) -> Result<()> {
|
||||
let a = Vec2::new(num(e, b"x1", 0.0), num(e, b"y1", 0.0));
|
||||
let b = Vec2::new(num(e, b"x2", 0.0), num(e, b"y2", 0.0));
|
||||
self.add(EntityKind::Line(Line { a: v3(self.map(a)), b: v3(self.map(b)) }))
|
||||
}
|
||||
|
||||
fn poly_points(&mut self, e: &BytesStart, closed: bool) -> Result<()> {
|
||||
let Some(raw) = attr(e, b"points") else {
|
||||
return Ok(());
|
||||
};
|
||||
let nums: Vec<f64> = Numbers::new(&raw).collect();
|
||||
let mut pts: Vec<Vec2> = nums.as_chunks::<2>().0.iter().map(|[x, y]| self.map(Vec2::new(*x, *y))).collect();
|
||||
if pts.len() > MAX_CURVE_POINTS {
|
||||
pts.truncate(MAX_CURVE_POINTS);
|
||||
}
|
||||
self.add_polyline(&pts, closed)
|
||||
}
|
||||
|
||||
fn add_polyline(&mut self, pts: &[Vec2], closed: bool) -> Result<()> {
|
||||
if pts.len() < 2 {
|
||||
return Ok(());
|
||||
}
|
||||
let vertices = pts.iter().map(|p| PolyVertex::new(*p)).collect();
|
||||
self.add(EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false }))
|
||||
}
|
||||
|
||||
// --- path data ----------------------------------------------------------
|
||||
|
||||
fn path(&mut self, e: &BytesStart) -> Result<()> {
|
||||
let Some(d) = attr(e, b"d") else {
|
||||
return Ok(());
|
||||
};
|
||||
for sp in parse_path(&d)? {
|
||||
self.emit_subpath(&sp)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit one subpath: runs of lines and arcs become an `LWPOLYLINE` (an arc becomes a
|
||||
/// bulge vertex, exactly like DXF's polyline bulge), runs of Bézier curves become a
|
||||
/// `SPLINE`. Under a non-uniform transform a circular arc is no longer circular, so it
|
||||
/// is flattened into line vertices instead.
|
||||
fn emit_subpath(&mut self, sp: &Subpath) -> Result<()> {
|
||||
let m = self.cur();
|
||||
let det = m.det();
|
||||
let ex = m.apply_vec(Vec2::new(1.0, 0.0));
|
||||
let ey = m.apply_vec(Vec2::new(0.0, 1.0));
|
||||
let mag = (ex.x * ex.x + ex.y * ex.y + ey.x * ey.x + ey.y * ey.y).max(1.0);
|
||||
let uniform =
|
||||
(ex.x * ex.x + ex.y * ex.y - (ey.x * ey.x + ey.y * ey.y)).abs() <= 1e-9 * mag && (ex.x * ey.x + ex.y * ey.y).abs() <= 1e-9 * mag;
|
||||
let scale = det.abs().sqrt();
|
||||
|
||||
let mut run: Vec<PolyVertex> = Vec::new();
|
||||
let mut ctrl: Vec<Vec2> = Vec::new();
|
||||
let mut cur_user = sp.start;
|
||||
let mut cur = m.apply(sp.start);
|
||||
|
||||
for seg in &sp.segs {
|
||||
match seg {
|
||||
Seg::Line(p) => {
|
||||
if run.is_empty() {
|
||||
run.push(PolyVertex::new(cur));
|
||||
}
|
||||
cur = m.apply(*p);
|
||||
run.push(PolyVertex::new(cur));
|
||||
cur_user = *p;
|
||||
}
|
||||
Seg::Cubic(c1, c2, p) => {
|
||||
self.flush_run(&mut run, false)?;
|
||||
if ctrl.is_empty() {
|
||||
ctrl.push(cur);
|
||||
}
|
||||
ctrl.push(m.apply(*c1));
|
||||
ctrl.push(m.apply(*c2));
|
||||
cur = m.apply(*p);
|
||||
ctrl.push(cur);
|
||||
cur_user = *p;
|
||||
if ctrl.len() > MAX_CURVE_POINTS {
|
||||
return Err(IoError::Format("SVG: one path has too many curve points".into()));
|
||||
}
|
||||
}
|
||||
Seg::Arc { rx, ry, rot, large, sweep, end } => {
|
||||
if uniform && scale > 0.0 {
|
||||
let r = rx * scale;
|
||||
let flipped = det < 0.0;
|
||||
if r > 0.0
|
||||
&& let Some((_, delta)) = arc_center_circular(cur, m.apply(*end), r, *large, *sweep != flipped)
|
||||
{
|
||||
if run.is_empty() {
|
||||
run.push(PolyVertex::new(cur));
|
||||
}
|
||||
if let Some(last) = run.last_mut() {
|
||||
last.bulge = (delta / 4.0).tan();
|
||||
}
|
||||
run.push(PolyVertex::new(m.apply(*end)));
|
||||
cur = m.apply(*end);
|
||||
cur_user = *end;
|
||||
}
|
||||
} else if *rx > 0.0 && *ry > 0.0 {
|
||||
self.flush_run(&mut run, false)?;
|
||||
self.flush_ctrl(&mut ctrl)?;
|
||||
for p in flatten_arc(&m, cur_user, *end, *rx, *ry, *rot, *large, *sweep)? {
|
||||
if run.is_empty() {
|
||||
run.push(PolyVertex::new(cur));
|
||||
}
|
||||
run.push(PolyVertex::new(p));
|
||||
}
|
||||
cur = m.apply(*end);
|
||||
cur_user = *end;
|
||||
}
|
||||
}
|
||||
}
|
||||
if run.len() > MAX_CURVE_POINTS {
|
||||
return Err(IoError::Format("SVG: one path has too many vertices".into()));
|
||||
}
|
||||
}
|
||||
self.flush_run(&mut run, sp.closed)?;
|
||||
self.flush_ctrl(&mut ctrl)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush_run(&mut self, run: &mut Vec<PolyVertex>, closed: bool) -> Result<()> {
|
||||
if run.len() >= 2 {
|
||||
let vertices = std::mem::take(run);
|
||||
self.add(EntityKind::LwPolyline(LwPolyline { vertices, closed, const_width: 0.0, elevation: 0.0, plinegen: false }))?;
|
||||
} else {
|
||||
run.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush_ctrl(&mut self, ctrl: &mut Vec<Vec2>) -> Result<()> {
|
||||
if ctrl.len() >= 4 {
|
||||
let spline = Spline::from_control(std::mem::take(ctrl), 3);
|
||||
self.add(EntityKind::Spline(spline))?;
|
||||
} else {
|
||||
ctrl.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The element is not rendered (`display="none"`, or a style that says so).
|
||||
fn hidden(e: &BytesStart) -> bool {
|
||||
if attr(e, b"display").as_deref() == Some("none") {
|
||||
return true;
|
||||
}
|
||||
attr(e, b"style").is_some_and(|s| s.replace(' ', "").contains("display:none"))
|
||||
}
|
||||
|
||||
fn attr(e: &BytesStart, name: &[u8]) -> Option<String> {
|
||||
for a in e.attributes().flatten() {
|
||||
if a.key.as_ref() == name {
|
||||
return a.normalized_value(XmlVersion::Explicit1_0).ok().map(|v| v.into_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn num(e: &BytesStart, name: &[u8], fallback: f64) -> f64 {
|
||||
attr(e, name).as_deref().and_then(parse_length_mm).unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// A bare number, or a number with an absolute CSS/SVG unit: millimetres here, everything
|
||||
/// else (px, the default; pt, pc, in, cm) through the CSS reference pixel (96 per inch).
|
||||
fn parse_length_mm(s: &str) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
let cut = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E')).unwrap_or(s.len());
|
||||
let (n, unit) = s.split_at(cut);
|
||||
let value: f64 = n.trim().parse().ok()?;
|
||||
if !value.is_finite() {
|
||||
return None;
|
||||
}
|
||||
match unit.trim() {
|
||||
"" | "px" => Some(value),
|
||||
"mm" => Some(value),
|
||||
"cm" => Some(value * 10.0),
|
||||
"in" => Some(value * 25.4),
|
||||
"pt" => Some(value * 25.4 / 72.0),
|
||||
"pc" => Some(value * 25.4 / 6.0),
|
||||
_ => Some(value * 25.4 / 96.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The root mapping: `viewBox` and unit-bearing `width`/`height` become millimetres per user
|
||||
/// unit, and the page's Y axis is flipped so the drawing keeps its appearance with Y up.
|
||||
fn viewport(e: &BytesStart) -> Result<Mat3> {
|
||||
let vb = attr(e, b"viewBox").and_then(|raw| {
|
||||
let nums: Vec<f64> = Numbers::new(&raw).collect();
|
||||
match nums.as_slice() {
|
||||
[x, y, w, h] if w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0 => Some((*x, *y, *w, *h)),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
let w_mm = attr(e, b"width").as_deref().and_then(parse_length_mm);
|
||||
let h_mm = attr(e, b"height").as_deref().and_then(parse_length_mm);
|
||||
let unit = match (vb, w_mm, h_mm) {
|
||||
(Some((_, _, vw, _)), Some(w), _) if w > 0.0 => w / vw,
|
||||
(Some((_, _, _, vh)), _, Some(h)) if h > 0.0 => h / vh,
|
||||
(Some(_), _, _) => 25.4 / 96.0,
|
||||
(None, _, _) => 25.4 / 96.0,
|
||||
};
|
||||
let (vx, vy, vh) = match vb {
|
||||
Some((x, y, _, h)) => (x, y, h),
|
||||
None => (0.0, 0.0, 0.0),
|
||||
};
|
||||
Ok(Mat3 { a: unit, b: 0.0, c: 0.0, d: -unit, e: -vx * unit, f: (vy + vh) * unit })
|
||||
}
|
||||
|
||||
/// `transform="…"`: a list of functions, applied left to right.
|
||||
fn parse_transform(s: &str) -> Option<Mat3> {
|
||||
let mut out: Option<Mat3> = None;
|
||||
let mut rest = s.trim();
|
||||
while !rest.is_empty() {
|
||||
let open = rest.find('(')?;
|
||||
let name = rest[..open].trim();
|
||||
let close = rest[open..].find(')')? + open;
|
||||
let args: Vec<f64> = Numbers::new(&rest[open + 1..close]).collect();
|
||||
let m = match (name, args.as_slice()) {
|
||||
("matrix", [a, b, c, d, e, f]) => Mat3 { a: *a, b: *b, c: *c, d: *d, e: *e, f: *f },
|
||||
("translate", [x]) => Mat3::translate(Vec2::new(*x, 0.0)),
|
||||
("translate", [x, y]) => Mat3::translate(Vec2::new(*x, *y)),
|
||||
("scale", [s]) => Mat3::scale(*s, *s),
|
||||
("scale", [sx, sy]) => Mat3::scale(*sx, *sy),
|
||||
("rotate", [a]) => Mat3::rotate(a.to_radians()),
|
||||
("rotate", [a, cx, cy]) => Mat3::rotate_about(Vec2::new(*cx, *cy), a.to_radians()),
|
||||
("skewX", [a]) => Mat3 { a: 1.0, b: 0.0, c: a.to_radians().tan(), d: 1.0, e: 0.0, f: 0.0 },
|
||||
("skewY", [a]) => Mat3 { a: 1.0, b: a.to_radians().tan(), c: 0.0, d: 1.0, e: 0.0, f: 0.0 },
|
||||
_ => return None,
|
||||
};
|
||||
out = Some(match out {
|
||||
Some(prev) => prev.then_before(m),
|
||||
None => m,
|
||||
});
|
||||
rest = rest[close + 1..].trim_start_matches(|c: char| c == ',' || c.is_whitespace());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sanitize_layer(raw: &str) -> String {
|
||||
let cleaned: String =
|
||||
raw.trim().chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '$') { c } else { '_' }).take(128).collect();
|
||||
let cleaned = cleaned.trim_matches('_').to_string();
|
||||
if cleaned.is_empty() { "SVG".into() } else { cleaned }
|
||||
}
|
||||
|
||||
/// A scanner over an SVG number list: separators are whitespace and commas, and a sign
|
||||
/// starts a new number, so `10-20` is two numbers and `1e-5 2` is two numbers.
|
||||
struct Numbers<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> Numbers<'a> {
|
||||
fn new(s: &'a str) -> Self {
|
||||
Numbers { s: s.as_bytes(), i: 0 }
|
||||
}
|
||||
|
||||
fn skip_seps(&mut self) {
|
||||
while matches!(self.s.get(self.i), Some(b' ' | b'\t' | b'\r' | b'\n' | b',')) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn next_num(&mut self) -> Option<f64> {
|
||||
self.skip_seps();
|
||||
let start = self.i;
|
||||
if matches!(self.s.get(self.i), Some(b'+' | b'-')) {
|
||||
self.i += 1;
|
||||
}
|
||||
let mut digits = 0;
|
||||
let mut dot = false;
|
||||
let mut exp = false;
|
||||
while let Some(c) = self.s.get(self.i) {
|
||||
match c {
|
||||
b'0'..=b'9' => {
|
||||
digits += 1;
|
||||
self.i += 1;
|
||||
}
|
||||
b'.' if !dot && !exp => {
|
||||
dot = true;
|
||||
self.i += 1;
|
||||
}
|
||||
b'e' | b'E' if !exp && digits > 0 => {
|
||||
exp = true;
|
||||
self.i += 1;
|
||||
if matches!(self.s.get(self.i), Some(b'+' | b'-')) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if digits == 0 {
|
||||
self.i = start;
|
||||
return None;
|
||||
}
|
||||
std::str::from_utf8(&self.s[start..self.i]).ok()?.parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Numbers<'_> {
|
||||
type Item = f64;
|
||||
fn next(&mut self) -> Option<f64> {
|
||||
self.next_num()
|
||||
}
|
||||
}
|
||||
|
||||
/// One `M …` run of a `d` attribute.
|
||||
struct Subpath {
|
||||
start: Vec2,
|
||||
segs: Vec<Seg>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
enum Seg {
|
||||
Line(Vec2),
|
||||
Cubic(Vec2, Vec2, Vec2),
|
||||
Arc { rx: f64, ry: f64, rot: f64, large: bool, sweep: bool, end: Vec2 },
|
||||
}
|
||||
|
||||
/// Tokens of a `d` attribute: command letters and numbers, in order.
|
||||
enum Tok {
|
||||
Cmd(u8),
|
||||
Num(f64),
|
||||
}
|
||||
|
||||
struct Tokenizer<'a> {
|
||||
s: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> Tokenizer<'a> {
|
||||
fn new(s: &'a str) -> Self {
|
||||
Tokenizer { s: s.as_bytes(), i: 0 }
|
||||
}
|
||||
|
||||
fn next_tok(&mut self) -> Option<Tok> {
|
||||
let mut nums = Numbers { s: self.s, i: self.i };
|
||||
nums.skip_seps();
|
||||
self.i = nums.i;
|
||||
let c = self.s.get(self.i).copied()?;
|
||||
if c.is_ascii_alphabetic() {
|
||||
self.i += 1;
|
||||
return Some(Tok::Cmd(c));
|
||||
}
|
||||
let tok = nums.next_num().map(Tok::Num);
|
||||
self.i = nums.i;
|
||||
tok
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `d` into subpaths, resolving relative commands and the S/T control reflections.
|
||||
fn parse_path(d: &str) -> Result<Vec<Subpath>> {
|
||||
let mut tk = Tokenizer::new(d);
|
||||
let mut lookahead: Option<Tok> = None;
|
||||
let mut cmd = 0u8;
|
||||
let mut cur = Vec2::ZERO;
|
||||
let mut start = Vec2::ZERO;
|
||||
let mut segs: Vec<Seg> = Vec::new();
|
||||
let mut out: Vec<Subpath> = Vec::new();
|
||||
let closed = false;
|
||||
let mut prev_cubic: Option<Vec2> = None;
|
||||
let mut prev_quad: Option<Vec2> = None;
|
||||
let mut segments = 0usize;
|
||||
|
||||
let next_num = |tk: &mut Tokenizer, look: &mut Option<Tok>| -> Result<f64> {
|
||||
match look.take() {
|
||||
Some(Tok::Num(v)) => Ok(v),
|
||||
Some(other @ Tok::Cmd(_)) => {
|
||||
*look = Some(other);
|
||||
Err(IoError::Format("SVG: path command is missing coordinates".into()))
|
||||
}
|
||||
None => match tk.next_tok() {
|
||||
Some(Tok::Num(v)) => Ok(v),
|
||||
Some(other @ Tok::Cmd(_)) => {
|
||||
*look = Some(other);
|
||||
Err(IoError::Format("SVG: path command is missing coordinates".into()))
|
||||
}
|
||||
None => Err(IoError::Format("SVG: path command is missing coordinates".into())),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
while let Some(tok) = lookahead.take().or_else(|| tk.next_tok()) {
|
||||
let rel = match tok {
|
||||
Tok::Cmd(c) => {
|
||||
if c.eq_ignore_ascii_case(&b'z') {
|
||||
if !segs.is_empty() {
|
||||
out.push(Subpath { start, segs, closed: true });
|
||||
segs = Vec::new();
|
||||
}
|
||||
cur = start;
|
||||
cmd = 0;
|
||||
continue;
|
||||
}
|
||||
cmd = c;
|
||||
c.is_ascii_lowercase()
|
||||
}
|
||||
tok @ Tok::Num(_) => {
|
||||
if cmd == 0 {
|
||||
return Err(IoError::Format("SVG: path data does not start with a command".into()));
|
||||
}
|
||||
lookahead = Some(tok);
|
||||
cmd.is_ascii_lowercase()
|
||||
}
|
||||
};
|
||||
let abs = cmd.to_ascii_uppercase();
|
||||
let count = match abs {
|
||||
b'M' | b'L' => 2,
|
||||
b'H' | b'V' | b'T' => 1,
|
||||
b'C' => 6,
|
||||
b'S' | b'Q' => 4,
|
||||
b'A' => 7,
|
||||
_ => return Err(IoError::Format(format!("SVG: unknown path command `{}`", cmd as char))),
|
||||
};
|
||||
let mut args = [0.0f64; 7];
|
||||
for slot in args.iter_mut().take(count) {
|
||||
*slot = next_num(&mut tk, &mut lookahead)?;
|
||||
}
|
||||
let point = |x: f64, y: f64| if rel { cur + Vec2::new(x, y) } else { Vec2::new(x, y) };
|
||||
match abs {
|
||||
b'M' => {
|
||||
if !segs.is_empty() {
|
||||
out.push(Subpath { start, segs, closed: false });
|
||||
segs = Vec::new();
|
||||
}
|
||||
cur = point(args[0], args[1]);
|
||||
start = cur;
|
||||
cmd = if rel { b'l' } else { b'L' };
|
||||
prev_cubic = None;
|
||||
prev_quad = None;
|
||||
}
|
||||
b'L' => {
|
||||
cur = point(args[0], args[1]);
|
||||
segs.push(Seg::Line(cur));
|
||||
prev_cubic = None;
|
||||
prev_quad = None;
|
||||
}
|
||||
b'H' => {
|
||||
cur = Vec2::new(if rel { cur.x + args[0] } else { args[0] }, cur.y);
|
||||
segs.push(Seg::Line(cur));
|
||||
prev_cubic = None;
|
||||
prev_quad = None;
|
||||
}
|
||||
b'V' => {
|
||||
cur = Vec2::new(cur.x, if rel { cur.y + args[0] } else { args[0] });
|
||||
segs.push(Seg::Line(cur));
|
||||
prev_cubic = None;
|
||||
prev_quad = None;
|
||||
}
|
||||
b'C' => {
|
||||
let (c1, c2, p) = (point(args[0], args[1]), point(args[2], args[3]), point(args[4], args[5]));
|
||||
segs.push(Seg::Cubic(c1, c2, p));
|
||||
prev_cubic = Some(c2);
|
||||
prev_quad = None;
|
||||
cur = p;
|
||||
}
|
||||
b'S' => {
|
||||
let c1 = prev_cubic.map_or(cur, |c2| cur * 2.0 - c2);
|
||||
let (c2, p) = (point(args[0], args[1]), point(args[2], args[3]));
|
||||
segs.push(Seg::Cubic(c1, c2, p));
|
||||
prev_cubic = Some(c2);
|
||||
prev_quad = None;
|
||||
cur = p;
|
||||
}
|
||||
b'Q' => {
|
||||
let (q, p) = (point(args[0], args[1]), point(args[2], args[3]));
|
||||
segs.push(Seg::Cubic(cur + (q - cur) * (2.0 / 3.0), p + (q - p) * (2.0 / 3.0), p));
|
||||
prev_quad = Some(q);
|
||||
prev_cubic = None;
|
||||
cur = p;
|
||||
}
|
||||
b'T' => {
|
||||
let reflect = prev_quad.map_or(cur, |q| cur * 2.0 - q);
|
||||
let p = point(args[0], args[1]);
|
||||
segs.push(Seg::Cubic(cur + (reflect - cur) * (2.0 / 3.0), p + (reflect - p) * (2.0 / 3.0), p));
|
||||
prev_quad = Some(reflect);
|
||||
prev_cubic = None;
|
||||
cur = p;
|
||||
}
|
||||
b'A' => {
|
||||
let end = point(args[5], args[6]);
|
||||
segs.push(Seg::Arc { rx: args[0].abs(), ry: args[1].abs(), rot: args[2], large: args[3] != 0.0, sweep: args[4] != 0.0, end });
|
||||
cur = end;
|
||||
prev_cubic = None;
|
||||
prev_quad = None;
|
||||
}
|
||||
_ => return Err(IoError::Format("SVG: unsupported path command".into())),
|
||||
}
|
||||
segments += 1;
|
||||
if segments > MAX_PATH_SEGMENTS {
|
||||
return Err(IoError::Format("SVG: path has too many segments".into()));
|
||||
}
|
||||
if segs.len() > MAX_CURVE_POINTS {
|
||||
return Err(IoError::Format("SVG: path has too many segments".into()));
|
||||
}
|
||||
}
|
||||
if !segs.is_empty() {
|
||||
out.push(Subpath { start, segs, closed });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The centre and signed sweep of a circular arc from `p0` to `p1` in a Y-up frame, per the
|
||||
/// SVG endpoint parameterisation (F.6.5). Radii that cannot span the chord are scaled up,
|
||||
/// as the specification requires.
|
||||
fn arc_center_circular(p0: Vec2, p1: Vec2, r: f64, large: bool, sweep: bool) -> Option<(Vec2, f64)> {
|
||||
if r <= 0.0 || !r.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let d = p1 - p0;
|
||||
let dist = d.x.hypot(d.y);
|
||||
let r = if dist > 2.0 * r { dist / 2.0 } else { r };
|
||||
if dist <= f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
let mid = (p0 + p1) * 0.5;
|
||||
let h = (r * r - (dist * dist) / 4.0).max(0.0).sqrt();
|
||||
let n = Vec2::new(-d.y / dist, d.x / dist);
|
||||
let sign = if large != sweep { 1.0 } else { -1.0 };
|
||||
let center = mid + n * (h * sign);
|
||||
let a0 = (p0.y - center.y).atan2(p0.x - center.x);
|
||||
let a1 = (p1.y - center.y).atan2(p1.x - center.x);
|
||||
let ccw = (a1 - a0).rem_euclid(std::f64::consts::TAU);
|
||||
Some((center, pick_sweep(ccw, large, sweep)))
|
||||
}
|
||||
|
||||
/// The signed sweep (CCW positive) for a chord sweep `ccw` in `[0, 2π)`: the centre is
|
||||
/// already chosen so that the magnitude matches `large`, so only the direction is left.
|
||||
fn pick_sweep(ccw: f64, large: bool, sweep: bool) -> f64 {
|
||||
let tau = std::f64::consts::TAU;
|
||||
let eps = f64::EPSILON;
|
||||
if sweep {
|
||||
if ccw <= eps { if large { tau } else { eps } } else { ccw }
|
||||
} else if ccw >= tau - eps {
|
||||
if large { -tau } else { -eps }
|
||||
} else {
|
||||
ccw - tau
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten a general elliptical arc (any `rx`/`ry`/rotation) from `p0` to `p1` in user
|
||||
/// space, then map every sample through `m`.
|
||||
fn flatten_arc(m: &Mat3, p0: Vec2, p1: Vec2, rx: f64, ry: f64, rot_deg: f64, large: bool, sweep: bool) -> Result<Vec<Vec2>> {
|
||||
let (c, theta, delta) = elliptical_params(p0, p1, rx, ry, rot_deg.to_radians(), large, sweep)
|
||||
.ok_or_else(|| IoError::Format("SVG: elliptical arc cannot be converted".into()))?;
|
||||
let axes = Mat3::rotate(rot_deg.to_radians());
|
||||
let rmax = rx.max(ry);
|
||||
let tol = 0.05_f64;
|
||||
let steps = if rmax > tol {
|
||||
let step = 2.0 * (1.0 - (tol / rmax).min(1.0)).acos().max(1e-6);
|
||||
((delta.abs() / step).ceil() as usize).clamp(8, MAX_ARC_CHORDS)
|
||||
} else {
|
||||
8
|
||||
};
|
||||
let mut out = Vec::with_capacity(steps + 1);
|
||||
for k in 0..=steps {
|
||||
let t = theta + delta * (k as f64) / (steps as f64);
|
||||
let local = Vec2::new(rx * t.cos(), ry * t.sin());
|
||||
out.push(m.apply(c + axes.apply_vec(local)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Endpoint → centre parameterisation for a rotated elliptical arc (SVG F.6.5), in the
|
||||
/// frame the points are given in. Returns the centre, the start parameter and the signed
|
||||
/// sweep.
|
||||
fn elliptical_params(p0: Vec2, p1: Vec2, rx: f64, ry: f64, phi: f64, large: bool, sweep: bool) -> Option<(Vec2, f64, f64)> {
|
||||
if rx <= 0.0 || ry <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (sphi, cphi) = phi.sin_cos();
|
||||
let dx = (p0.x - p1.x) / 2.0;
|
||||
let dy = (p0.y - p1.y) / 2.0;
|
||||
let x1 = cphi * dx + sphi * dy;
|
||||
let y1 = -sphi * dx + cphi * dy;
|
||||
let (rx, ry) = {
|
||||
let lambda = x1 * x1 / (rx * rx) + y1 * y1 / (ry * ry);
|
||||
if lambda > 1.0 {
|
||||
let s = lambda.sqrt();
|
||||
(rx * s, ry * s)
|
||||
} else {
|
||||
(rx, ry)
|
||||
}
|
||||
};
|
||||
let num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1;
|
||||
let den = rx * rx * y1 * y1 + ry * ry * x1 * x1;
|
||||
let coef = if sweep != large { 1.0 } else { -1.0 } * (num.max(0.0) / den.max(f64::MIN_POSITIVE)).sqrt();
|
||||
let cxp = coef * rx * y1 / ry;
|
||||
let cyp = -coef * ry * x1 / rx;
|
||||
let center = Vec2::new(cphi * cxp - sphi * cyp + (p0.x + p1.x) / 2.0, sphi * cxp + cphi * cyp + (p0.y + p1.y) / 2.0);
|
||||
let angle = |ux: f64, uy: f64, vx: f64, vy: f64| {
|
||||
let dot = ux * vx + uy * vy;
|
||||
let det = ux * vy - uy * vx;
|
||||
let denom = (ux.hypot(uy) * vx.hypot(vy)).max(f64::MIN_POSITIVE);
|
||||
(dot / denom).clamp(-1.0, 1.0).acos().copysign(det)
|
||||
};
|
||||
let (ux, uy) = ((x1 - cxp) / rx, (y1 - cyp) / ry);
|
||||
let (vx, vy) = ((-x1 - cxp) / rx, (-y1 - cyp) / ry);
|
||||
let theta = angle(1.0, 0.0, ux, uy);
|
||||
let ccw = angle(ux, uy, vx, vy).rem_euclid(std::f64::consts::TAU);
|
||||
Some((center, theta, pick_sweep(ccw, large, sweep)))
|
||||
}
|
||||
@@ -1824,3 +1824,164 @@ fn mirrored_mtext_extrusion_keeps_its_footprint() {
|
||||
let b = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds;
|
||||
assert!(b.min.x > -0.2 && b.max.x > 0.5 && b.min.y > -0.2 && b.max.y > 0.5, "box right of and above (0,0): {b:?}");
|
||||
}
|
||||
|
||||
// --- SVG import ---
|
||||
|
||||
/// One 20mm×10mm page whose user units are millimetres; the reader flips Y, so a point at
|
||||
/// SVG y lands at `10 - y` in the drawing.
|
||||
const SVG_PAGE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 20 10">"#;
|
||||
|
||||
fn kinds(d: &Drawing) -> Vec<&'static str> {
|
||||
d.model.iter().map(|e| e.kind.type_name()).collect()
|
||||
}
|
||||
|
||||
fn gzip_of(data: &[u8]) -> Vec<u8> {
|
||||
let mut crc: u32 = 0xffff_ffff;
|
||||
for b in data {
|
||||
crc ^= u32::from(*b);
|
||||
for _ in 0..8 {
|
||||
crc = (crc >> 1) ^ (0xedb8_8320u32 & 0u32.wrapping_sub(crc & 1));
|
||||
}
|
||||
}
|
||||
let mut out = vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3];
|
||||
out.extend_from_slice(&miniz_oxide::deflate::compress_to_vec(data, 6));
|
||||
out.extend_from_slice(&(!crc).to_le_bytes());
|
||||
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_shapes_become_entities_in_millimetres() {
|
||||
let svg = format!(
|
||||
"{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/><circle cx=\"10\" cy=\"5\" r=\"2\"/>\
|
||||
<ellipse cx=\"10\" cy=\"5\" rx=\"4\" ry=\"2\"/><line x1=\"0\" y1=\"0\" x2=\"20\" y2=\"10\"/>\
|
||||
<polyline points=\"0,0 5,0 5,5\"/><polygon points=\"0,0 2,0 1,1\"/></svg>"
|
||||
);
|
||||
let d = read(svg.as_bytes(), "shapes.svg").unwrap();
|
||||
assert_eq!(kinds(&d), ["Polyline", "Circle", "Ellipse", "Line", "Polyline", "Polyline"]);
|
||||
let r = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
|
||||
assert!(r.closed);
|
||||
let got: Vec<Vec2> = r.vertices.iter().map(|v| v.p).collect();
|
||||
let want = [Vec2::new(1.0, 9.0), Vec2::new(5.0, 9.0), Vec2::new(5.0, 6.0), Vec2::new(1.0, 6.0)];
|
||||
for (g, w) in got.iter().zip(want.iter()) {
|
||||
assert!((*g - *w).len() < 1e-9, "{g:?} vs {w:?}");
|
||||
}
|
||||
let c = first(&d, |k| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None });
|
||||
assert!((c.radius - 2.0).abs() < 1e-9 && (c.center.x - 10.0).abs() < 1e-9 && (c.center.y - 5.0).abs() < 1e-9, "{c:?}");
|
||||
let e = first(&d, |k| if let EntityKind::Ellipse(e) = k { Some(e.clone()) } else { None });
|
||||
let major = Vec2::new(e.major.x - e.center.x, e.major.y - e.center.y);
|
||||
assert!((major.len() - 4.0).abs() < 1e-9 && (e.ratio - 0.5).abs() < 1e-9, "{e:?}");
|
||||
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
|
||||
assert!((l.a.y - 10.0).abs() < 1e-9 && (l.b.y).abs() < 1e-9, "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_path_lines_curves_and_arcs() {
|
||||
let svg = format!("{SVG_PAGE}<path d=\"M1 1 L5 1 C6 1 7 2 8 3\"/><path d=\"M0 0 A5 5 0 0 1 10 0 Z\"/></svg>");
|
||||
let d = read(svg.as_bytes(), "paths.svg").unwrap();
|
||||
assert_eq!(kinds(&d), ["Polyline", "Spline", "Polyline"]);
|
||||
let line_run = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None });
|
||||
assert!(!line_run.closed && line_run.vertices.len() == 2, "{line_run:?}");
|
||||
let spline = first(&d, |k| if let EntityKind::Spline(s) = k { Some(s.clone()) } else { None });
|
||||
assert!(spline.is_valid(), "{spline:?}");
|
||||
let arc = d
|
||||
.model
|
||||
.iter()
|
||||
.find_map(|e| match &e.kind {
|
||||
EntityKind::LwPolyline(p) if p.vertices.first().is_some_and(|v| v.bulge != 0.0) => Some(p.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("an arc becomes a bulge vertex");
|
||||
assert!(arc.closed && arc.vertices.len() == 2, "{arc:?}");
|
||||
let bulge = arc.vertices[0].bulge;
|
||||
assert!((bulge.abs() - 1.0).abs() < 1e-6, "semicircle bulge is ±1, got {bulge}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_group_transforms_apply() {
|
||||
let svg = format!("{SVG_PAGE}<g transform=\"translate(5,2)\"><line x1=\"0\" y1=\"0\" x2=\"10\" y2=\"0\"/></g></svg>");
|
||||
let d = read(svg.as_bytes(), "t.svg").unwrap();
|
||||
let l = first(&d, |k| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None });
|
||||
assert!((l.a - Vec3::new(5.0, 8.0, 0.0)).len() < 1e-9, "{l:?}");
|
||||
assert!((l.b - Vec3::new(15.0, 8.0, 0.0)).len() < 1e-9, "{l:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_viewbox_scaling_and_mm_units() {
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 200 100"><circle cx="100" cy="50" r="50"/></svg>"#;
|
||||
let d = read(svg.as_bytes(), "vb.svg").unwrap();
|
||||
let c = first(&d, |k| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None });
|
||||
assert!((c.radius - 5.0).abs() < 1e-9, "{c:?}");
|
||||
assert!((c.center.x - 10.0).abs() < 1e-9 && (c.center.y - 5.0).abs() < 1e-9, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_inkscape_layers_are_kept() {
|
||||
let svg = format!(
|
||||
"{SVG_PAGE}<g inkscape:groupmode=\"layer\" inkscape:label=\"Cut\"><rect x=\"0\" y=\"0\" width=\"2\" height=\"2\"/></g>\
|
||||
<g inkscape:groupmode=\"layer\" inkscape:label=\"Engrave\"><circle cx=\"5\" cy=\"5\" r=\"1\"/></g></svg>",
|
||||
SVG_PAGE = SVG_PAGE.replace("<svg ", "<svg xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\" ")
|
||||
);
|
||||
let d = read(svg.as_bytes(), "layers.svg").unwrap();
|
||||
let layers: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert!(layers.contains(&"Cut") && layers.contains(&"Engrave"), "{layers:?}");
|
||||
let per_layer = |name: &str| d.model.iter().filter(|e| e.common.layer == name).count();
|
||||
assert_eq!(per_layer("Cut"), 1);
|
||||
assert_eq!(per_layer("Engrave"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_hidden_and_defs_subtrees_are_skipped() {
|
||||
let svg = format!(
|
||||
"{SVG_PAGE}<defs><rect x=\"0\" y=\"0\" width=\"9\" height=\"9\"/></defs><text x=\"0\" y=\"0\">hi</text>\
|
||||
<rect x=\"0\" y=\"0\" width=\"1\" height=\"1\" display=\"none\"/><rect x=\"0\" y=\"0\" width=\"1\" height=\"1\" style=\"fill:red;display:none\"/>\
|
||||
<rect x=\"1\" y=\"1\" width=\"2\" height=\"2\"/></svg>"
|
||||
);
|
||||
let d = read(svg.as_bytes(), "skip.svg").unwrap();
|
||||
assert_eq!(kinds(&d), ["Polyline"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svgz_gunzips_to_the_same_drawing() {
|
||||
let svg = format!("{SVG_PAGE}<rect x=\"1\" y=\"1\" width=\"4\" height=\"3\"/></svg>");
|
||||
let plain = read(svg.as_bytes(), "a.svg").unwrap();
|
||||
let packed = read(&gzip_of(svg.as_bytes()), "a.svgz").unwrap();
|
||||
assert_eq!(kinds(&plain), kinds(&packed));
|
||||
assert_eq!(plain.model.iter().map(|e| &e.kind).collect::<Vec<_>>(), packed.model.iter().map(|e| &e.kind).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_read_dispatches_by_extension() {
|
||||
let svg = format!("{SVG_PAGE}<circle cx=\"5\" cy=\"5\" r=\"1\"/></svg>");
|
||||
assert!(read(svg.as_bytes(), "a.svg").is_ok());
|
||||
assert!(read(&gzip_of(svg.as_bytes()), "a.svgz").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_hostile_input_errors_without_panicking() {
|
||||
let deep = format!("{SVG_PAGE}{}<line x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\"/>{}</svg>", "<g>".repeat(200), "</g>".repeat(200));
|
||||
for (bytes, name) in [
|
||||
(b"not xml at all".to_vec(), "a.svg"),
|
||||
(Vec::new(), "a.svg"),
|
||||
(b"<svg><rect x=\"1e309\" width=\"NaN\" height=\"inf\"/></svg>".to_vec(), "a.svg"),
|
||||
(deep.clone().into_bytes(), "a.svg"),
|
||||
(b"<svg><path d=\"M0 0 X5 5\"/></svg>".to_vec(), "a.svg"),
|
||||
(b"<svg><path d=\"M0 0 L\"/></svg>".to_vec(), "a.svg"),
|
||||
(b"<svg><path d=\"M0 0 A1 1 0 5 1 2 2\"/></svg>".to_vec(), "a.svg"),
|
||||
(b"<svg><polyline points=\"1 2 3\"/></svg>".to_vec(), "a.svg"),
|
||||
(b"<svg><g transform=\"bogus(1)\"><rect width=\"1\" height=\"1\"/></g></svg>".to_vec(), "a.svg"),
|
||||
(vec![0x1f, 0x8b, 8, 0, b'j', b'u', b'n', b'k', 0, 0, 0, 0, 0, 0, 0, 0], "a.svgz"),
|
||||
(vec![0xff, 0xfe, 0x00, 0x01, 0x02, 0x03], "a.svg"),
|
||||
] {
|
||||
let _ = read(&bytes, name);
|
||||
}
|
||||
let deep = read(deep.as_bytes(), "a.svg");
|
||||
assert!(deep.is_err(), "200 nested groups are refused, not walked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_no_geometry_yields_an_empty_drawing() {
|
||||
let svg = format!("{SVG_PAGE}</svg>");
|
||||
let d = read(svg.as_bytes(), "empty.svg").unwrap();
|
||||
assert!(kinds(&d).is_empty());
|
||||
}
|
||||
@@ -253,6 +253,7 @@
|
||||
Drawing Template (DWT) Шаблон креслення (DWT)
|
||||
Drawing Utilities Утиліти креслення
|
||||
Drawings (DWG, DXF) Креслення (DWG, DXF)
|
||||
SVG drawings (SVG, SVGZ) Креслення SVG (SVG, SVGZ)
|
||||
Dynamic Динамічно
|
||||
Dynamic Dimensions Динамічні розміри
|
||||
Dynamic Input Динамічне введення
|
||||
|
||||
|
@@ -113,6 +113,7 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr
|
||||
| Plot styles (`.ctb`, `.stb`), plotter configs (`.pc3`), `.pmp` | yes | plot style tables in our own JSON format ([plot-styles.md](plot-styles.md)) and kept in the drawing; binary `.ctb`/`.stb` not read (no public spec); no `.pc3`/`.pmp` | 6–10 |
|
||||
| Scripts (`.scr`) | SCRIPT | script text through the CLI, MCP and control channel; no SCRIPT command reading a `.scr` file | 1–2 |
|
||||
| Aliases (`.pgp`), CUI(x), workspaces | yes | no | 6–10 |
|
||||
| **SVG import** (Inkscape .svg/.svgz) | yes (via scripts) | partial (module compiled, 11 tests pass, dispatch works, open-dialoog filter updated) | 2–4 |
|
||||
| AutoLISP (`.lsp`, `.fas`, `.vlx`), `.dll`/`.arx` plug-ins | yes | no | see [gaps.md](gaps.md) (M12) |
|
||||
| Data extraction (`.csv`, `.xls`) and data links | yes | no | 6–10 |
|
||||
| eTransmit (`.zip` packages) | yes | no | 4–6 |
|
||||
|
||||
@@ -89,6 +89,7 @@ Detail: [file-format-parity.md](file-format-parity.md).
|
||||
| FF11 | DWF/DWFx, DGN, WMF, EPS, ACIS/STL and other exchange formats | Low-medium | 50–100 |
|
||||
| FF12 | DWG in the web build | Medium | 6–10 |
|
||||
| FF13 | R12 binary DXF, pre-R13 DWG | Low | 10–20 |
|
||||
| FF14 | **SVG import** (Inkscape .svg/.svgz) | Medium | 4–8 |
|
||||
|
||||
## Hardware
|
||||
|
||||
|
||||
Reference in new issue
Block a user