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:
Gert Pellin - GPEandClaude Opus 5.5 authored and GitHub committed 2026-10-11 20:11:22 -04:00
1 parent 89064fd82a
commit f7c6293d09
12 files changed
+1152 -2

No files matched your search

Generated
+1
View File
@@ -660,6 +660,7 @@ dependencies = [
"cadcraft-geom",
"cadcraft-render",
"miniz_oxide 0.8.9",
"quick-xml",
"serde",
"serde_json",
"thiserror 2.0.21",
+2
View File
@@ -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"] }
+1
View File
@@ -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())
+3
View File
@@ -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.
+30
View File
@@ -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);
}
+1
View File
@@ -20,3 +20,4 @@ thiserror = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
miniz_oxide = "0.8"
quick-xml = { workspace = true }
+5 -2
View File
@@ -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())),
+945
View File
@@ -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)))
}
+161
View File
@@ -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());
}
+1
View File
@@ -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 Динамічне введення
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
253
254
255
256
257
258
259
+1
View File
@@ -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 |
+1
View File
@@ -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