Merge pull request #7398 from storytold/feat/craft-libs-migration

Migrate CAD (DXF), EPS/PostScript, and SVG to craft-libs (#778)
This commit is contained in:
Dylan M. Taylor
2026-11-13 22:14:10 -05:00
committed by GitHub
18 changed files with 423 additions and 1043 deletions
+1
View File
@@ -9,6 +9,7 @@ description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12
workspace = true
[dependencies]
craft-dxf = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
+4 -67
View File
@@ -1,50 +1,11 @@
//! The 256-colour index palette CAD apps share, generated rather than tabulated: indices 1–9 are
//! the standard colours, 10–249 run through 24 hues at five brightnesses each in a full and a
//! half-saturated tone, and 250–255 are greys.
//! The colour index palette is `craft_dxf::model::aci`; here are the indices each colour depth
//! of the writer may use.
use crate::ColorDepth;
/// The nine standard colours (index 7 shows white on a dark background and black on paper).
const STANDARD: [[u8; 3]; 9] =
[[255, 0, 0], [255, 255, 0], [0, 255, 0], [0, 255, 255], [0, 0, 255], [255, 0, 255], [255, 255, 255], [128, 128, 128], [192, 192, 192]];
/// The brightness of the five shades of each hue.
const SHADES: [f64; 5] = [1.0, 0.65, 0.5, 0.3, 0.15];
/// The RGB of colour index `i` (1–255; anything else is index 7's white).
pub fn aci_rgb(i: u8) -> [u8; 3] {
match i {
1..=9 => STANDARD[usize::from(i - 1)],
10..=249 => {
let hue = f64::from(i / 10 - 1) * 15.0;
let k = i % 10;
let v = SHADES[usize::from(k / 2)];
let pastel = k % 2 == 1;
hue_rgb(hue).map(|f| {
let f = if pastel { f + (1.0 - f) / 2.0 } else { f };
(255.0 * v * f).floor() as u8
})
}
250..=255 => {
let g = (51.0 + 204.0 * f64::from(i - 250) / 5.0).floor() as u8;
[g; 3]
}
_ => STANDARD[6],
}
}
/// The fully saturated, full-brightness colour of `hue` degrees as 0–1 channels.
fn hue_rgb(hue: f64) -> [f64; 3] {
let h = hue.rem_euclid(360.0) / 60.0;
let x = 1.0 - (h % 2.0 - 1.0).abs();
match h as u8 {
0 => [1.0, x, 0.0],
1 => [x, 1.0, 0.0],
2 => [0.0, 1.0, x],
3 => [0.0, x, 1.0],
4 => [x, 0.0, 1.0],
_ => [1.0, 0.0, x],
}
craft_dxf::model::aci::rgb(i)
}
/// The indices a colour depth may use (true colour: all of them, for the fallback index).
@@ -59,37 +20,13 @@ fn candidates(depth: ColorDepth) -> Box<dyn Iterator<Item = u8>> {
/// The index of `depth` nearest to `rgb` (squared RGB distance; ties go to the lower index).
/// Index 7 stands for both white and black, as CAD apps draw it in the background's contrast.
pub fn nearest_aci(rgb: [u8; 3], depth: ColorDepth) -> u8 {
let dist = |c: [u8; 3]| rgb.iter().zip(c).map(|(a, b)| (i32::from(*a) - i32::from(b)).pow(2)).sum::<i32>();
candidates(depth).min_by_key(|i| if *i == 7 { dist([255; 3]).min(dist([0; 3])) } else { dist(aci_rgb(*i)) }).unwrap_or(7)
craft_dxf::model::aci::nearest(rgb, candidates(depth))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_palette_matches_its_known_entries() {
let known: [(u8, [u8; 3]); 14] = [
(1, [255, 0, 0]),
(10, [255, 0, 0]),
(11, [255, 127, 127]),
(12, [165, 0, 0]),
(13, [165, 82, 82]),
(17, [76, 38, 38]),
(19, [38, 19, 19]),
(20, [255, 63, 0]),
(21, [255, 159, 127]),
(30, [255, 127, 0]),
(140, [0, 191, 255]),
(250, [51, 51, 51]),
(252, [132, 132, 132]),
(255, [255, 255, 255]),
];
for (i, rgb) in known {
assert_eq!(aci_rgb(i), rgb, "index {i}");
}
}
#[test]
fn nearest_index_by_depth() {
assert_eq!(nearest_aci([255, 0, 0], ColorDepth::Aci8), 1);
+20 -19
View File
@@ -1,4 +1,4 @@
//! DXF import: an ASCII DXF drawing of any version as a document.
//! DXF import: a DXF drawing (ASCII or binary) of any version as a document.
//!
//! The drawing's model space (or a paper layout) comes in at a ratio (1 unit of the art = N
//! drawing units; by default the drawing's own unit at 1:1) or fitted to an artboard, centred
@@ -10,7 +10,8 @@
//! lineweights, linetypes and transparency come through layers and blocks as CAD apps resolve
//! them. What can't come in is listed in the warnings.
//!
//! - `reader`: the file's text, group codes, sections, tables, blocks and layouts.
//! - `reader`: what the import uses of the drawing `craft_dxf::model` reads: header, tables,
//! blocks and layouts.
//! - `entity`: entities to items (geometry and properties).
//! - `curve`: bulges, arcs, ellipses and NURBS as Bézier paths.
//! - `text`: text values to plain text.
@@ -31,9 +32,6 @@ use crate::{DxfVersion, INSUNITS, SCALE_RANGE};
/// Letter, the box Fit to Artboard fills unless told otherwise (points).
pub const FIT_TO: (f64, f64) = (612.0, 792.0);
/// Why a binary DXF can't be opened.
pub const BINARY: &str = "this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that";
/// How a drawing comes in.
#[derive(Clone, Debug, PartialEq)]
pub struct ImportOptions {
@@ -104,15 +102,20 @@ pub fn is_dxf(bytes: &[u8]) -> bool {
false
}
/// The pairs of a DXF file and where it is damaged (binary and DWG files are refused).
fn text(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, String> {
if bytes.starts_with(reader::BINARY_SENTINEL) {
return Err(BINARY.into());
}
/// The tags of a DXF file and where it is damaged (DWG files are refused).
fn tags(bytes: &[u8]) -> Result<(Vec<craft_dxf::Tag>, Option<String>), String> {
if bytes.starts_with(b"AC10") || bytes.starts_with(b"AC1.") {
return Err("this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that".into());
return Err("this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that".into());
}
Ok(reader::decode(bytes))
let (tags, damaged) = craft_dxf::parse_recovering(bytes).map_err(|e| match e {
craft_dxf::DxfError::TooLarge { .. } => "the drawing is too large to open".to_string(),
_ if bytes.starts_with(reader::BINARY_SENTINEL) => "this binary DXF file is damaged or cut short".to_string(),
_ => "not a DXF drawing".to_string(),
})?;
if tags.is_empty() {
return Err("not a DXF drawing".into());
}
Ok((tags, damaged.map(|line| format!("the file is damaged at line {line}: what follows is left out"))))
}
/// The unit of `$INSUNITS` code `code` and the scale that shows the drawing 1:1 in it.
@@ -161,9 +164,8 @@ fn units_name(code: i64) -> &'static str {
/// Read what `bytes` holds: its version, units, layouts and layers.
pub fn info(bytes: &[u8]) -> Result<DxfInfo, String> {
let text = text(bytes)?;
let (pairs, _) = reader::pairs(&text)?;
let d = reader::Drawing::read(&pairs);
let (tags, _) = tags(bytes)?;
let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags));
if d.sections == 0 {
return Err("not a DXF drawing".into());
}
@@ -180,7 +182,7 @@ pub fn info(bytes: &[u8]) -> Result<DxfInfo, String> {
})
}
/// Import an ASCII DXF drawing with `o`.
/// Import a DXF drawing with `o`.
pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
let (w, h) = o.fit_to;
if !(w > 0.0 && h > 0.0 && w.max(h) <= build::MAX_EXTENT) {
@@ -191,9 +193,8 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
{
return Err(format!("scale must be a number from {} to {}, not {s}", SCALE_RANGE.start(), SCALE_RANGE.end()));
}
let text = text(bytes)?;
let (pairs, damaged) = reader::pairs(&text)?;
let d = reader::Drawing::read(&pairs);
let (tags, damaged) = tags(bytes)?;
let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags));
if d.sections == 0 {
return Err("not a DXF drawing".into());
}
+180 -302
View File
@@ -7,9 +7,10 @@ use kurbo::{BezPath, PathEl};
use vectorcraft_doc::Justify;
use vectorcraft_geom::{Affine, Point, Vec2};
use craft_dxf::model::{self, EntityRecord, Groups, HatchEdge, HatchPath};
use super::curve::{self, Vertex};
use super::reader::{Groups, Pair, Record, groups, number};
use crate::aci::aci_rgb;
use super::reader::{aci_paper, point};
/// How far below the baseline the lowest descender reaches, as a fraction of the cap height.
const DESCENT: f64 = 0.3;
@@ -25,21 +26,6 @@ const MAX_COORD: f64 = 1e12;
/// factor 1, the 1 to 10,000% horizontal scale the Character panel allows.
const SPAN_SCALE: (f64, f64) = (0.01, 100.0);
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
pub(crate) fn aci_paper(i: u64) -> [u8; 3] {
match u8::try_from(i) {
Ok(7) | Err(_) => [0; 3],
Ok(i) => aci_rgb(i),
}
}
/// The true colour (group 420, `0xRRGGBB`) of an entity or layer.
pub(crate) fn true_color(g: &[Pair<'_>]) -> Option<[u8; 3]> {
let v = g.int(420)?;
let [_, r, gr, b] = u32::try_from(v & 0xFF_FFFF).ok()?.to_be_bytes();
Some([r, gr, b])
}
/// An entity's colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Col {
@@ -79,30 +65,30 @@ pub(crate) struct Props {
}
impl Props {
fn read(g: &[Pair<'_>]) -> Self {
let color = match (true_color(g), g.int(62)) {
(Some(rgb), _) => Col::Rgb(rgb),
(None, Some(0)) => Col::ByBlock,
(None, None | Some(256..)) => Col::ByLayer,
(None, Some(i)) => Col::Rgb(aci_paper(i.unsigned_abs())),
fn read(c: &model::Common) -> Self {
let color = match c.color {
model::Color::Rgb(rgb) => Col::Rgb(rgb),
model::Color::ByBlock => Col::ByBlock,
model::Color::ByLayer => Col::ByLayer,
model::Color::Index(i) => Col::Rgb(aci_paper(i)),
};
let lineweight = match g.int(370) {
Some(-2) => Lw::ByBlock,
Some(-3) => Lw::Default,
Some(v @ 0..=211) => Lw::Hundredths(v as i32),
let lineweight = match c.lineweight {
model::Lineweight::ByBlock => Lw::ByBlock,
model::Lineweight::Default => Lw::Default,
model::Lineweight::Hundredths(v @ 0..=211) => Lw::Hundredths(i32::from(v)),
_ => Lw::ByLayer,
};
let alpha = match g.int(440) {
Some(v) if v & 0x0200_0000 != 0 => Alpha::Opacity((v & 0xFF) as f32 / 255.0),
Some(v) if v & 0x0100_0000 != 0 => Alpha::ByBlock,
_ => Alpha::ByLayer,
let alpha = match c.transparency {
model::Transparency::Alpha(a) => Alpha::Opacity(f32::from(a) / 255.0),
model::Transparency::ByBlock => Alpha::ByBlock,
model::Transparency::ByLayer => Alpha::ByLayer,
};
Self {
layer: g.name(8).unwrap_or("0").to_string(),
layer: c.layer.clone(),
color,
lineweight,
linetype: g.name(6).unwrap_or("BYLAYER").to_uppercase(),
ltscale: g.num(48).filter(|v| *v > 0.0).unwrap_or(1.0),
linetype: c.linetype.as_deref().unwrap_or("BYLAYER").to_uppercase(),
ltscale: c.ltscale,
alpha,
}
}
@@ -209,90 +195,87 @@ impl Converter {
}
/// The items of `records` (an entity section or a block), in drawing order.
pub fn items(&mut self, records: &[Record<'_>]) -> Vec<Item> {
pub fn items(&mut self, records: &[EntityRecord<'_>]) -> Vec<Item> {
let mut out = Vec::new();
for group in groups(records) {
let Some(head) = group.first() else { continue };
for e in records {
let m = model::Entity::read(e);
// Invisible entities.
if head.g.int(60) == Some(1) {
if !m.common.visible {
continue;
}
match self.geom(head, group) {
match self.geom(e, &m) {
Some(geom) if !in_range(&geom) => self.warn(FAR_AWAY),
Some(geom) => out.push(Item { props: Props::read(head.g), geom }),
None if is_known(head.kind) => {}
None => self.skip(head.kind),
Some(geom) => out.push(Item { props: Props::read(&m.common), geom }),
None if is_known(e.kind) => {}
None => self.skip(e.kind),
}
}
out
}
fn geom(&mut self, head: &Record<'_>, group: &[Record<'_>]) -> Option<Geom> {
let g = head.g;
fn geom(&mut self, e: &EntityRecord<'_>, m: &model::Entity) -> Option<Geom> {
let g = e.groups();
let path = |bp: BezPath| Some(Geom::Path { bp, fill: false, width: 0.0 });
match head.kind {
"LINE" => {
let ocs = ocs(m.common.extrusion);
match &m.kind {
model::EntityKind::Line { start, end } => {
let mut bp = BezPath::new();
bp.move_to(g.point(10));
bp.line_to(g.point(11));
bp.move_to(point(*start));
bp.line_to(point(*end));
path(bp)
}
"CIRCLE" => path(transformed(curve::arc(g.point(10), g.num(40)?, 0.0, std::f64::consts::TAU)?, ocs(g))),
"ARC" => {
let (start, end) = (g.num_or(50, 0.0), g.num_or(51, 360.0));
let sweep = ccw_sweep(start, end);
path(transformed(curve::arc(g.point(10), g.num(40)?, start.to_radians(), sweep.to_radians())?, ocs(g)))
model::EntityKind::Circle { center, radius } => path(transformed(curve::arc(point(*center), *radius, 0.0, std::f64::consts::TAU)?, ocs)),
model::EntityKind::Arc { center, radius, start_angle, end_angle } => {
let sweep = ccw_sweep(*start_angle, *end_angle);
path(transformed(curve::arc(point(*center), *radius, start_angle.to_radians(), sweep.to_radians())?, ocs))
}
"ELLIPSE" => path(ellipse(g)?),
"LWPOLYLINE" => lwpolyline(g),
"POLYLINE" => self.polyline(g, group.get(1..).unwrap_or(&[])),
"SPLINE" => path(spline(g)?),
"SOLID" | "TRACE" => {
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
model::EntityKind::Ellipse { center, major, ratio, start, end } => {
path(ellipse(m.common.extrusion, *center, *major, *ratio, *start, *end)?)
}
model::EntityKind::LwPolyline(p) => lwpolyline(p, ocs),
model::EntityKind::Polyline(p) => self.polyline(p, ocs),
model::EntityKind::Spline(s) => path(spline(s)?),
model::EntityKind::Solid { corners } | model::EntityKind::Trace { corners } => {
let [a, b, c, d] = corners.map(point);
// The corners zigzag: the outline runs 1, 2, 4, 3.
Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs(g)), fill: true, width: 0.0 })
Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs), fill: true, width: 0.0 })
}
"3DFACE" => {
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
path(polygon(&[a, b, c, d]))
}
"HATCH" => self.hatch(g),
"TEXT" | "ATTRIB" => text(g, head.kind == "ATTRIB").map(Geom::Text),
"MTEXT" => mtext(g).map(Geom::Text),
"INSERT" => self.insert(g, group.get(1..).unwrap_or(&[])),
model::EntityKind::Face3d { corners, .. } => path(polygon(&corners.map(point))),
model::EntityKind::Hatch(h) => self.hatch(h, ocs),
model::EntityKind::Text(t) => text(t, &g, false, ocs).map(Geom::Text),
model::EntityKind::MText(t) => mtext(t, e.tags).map(Geom::Text),
model::EntityKind::Insert(ins) => self.insert(ins, &e.followers, ocs),
// A dimension draws the anonymous block of its lines, arrows and text.
"DIMENSION" => Some(Geom::Insert(InsertItem { block: g.name(2)?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })),
model::EntityKind::Dimension(d) => {
Some(Geom::Insert(InsertItem { block: d.block.as_ref()?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] }))
}
_ => None,
}
}
fn polyline(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
let flags = g.int_or(70, 0);
if flags & (16 | 64) != 0 {
fn polyline(&mut self, p: &model::Polyline, ocs: Affine) -> Option<Geom> {
if p.is_mesh() || p.is_polyface() {
self.warn(MESHES);
return None;
}
// Spline frame control points and polyface faces aren't on the line.
let verts: Vec<Vertex> = rest
.iter()
.filter(|r| r.kind == "VERTEX" && r.g.int_or(70, 0) & (16 | 128) == 0)
.map(|r| Vertex { p: r.g.point(10), bulge: r.g.num_or(42, 0.0) })
.collect();
let bp = curve::polyline(&verts, flags & 1 != 0)?;
let verts: Vec<Vertex> =
p.vertices.iter().filter(|v| v.flags & (16 | 128) == 0).map(|v| Vertex { p: point(v.location), bulge: v.bulge }).collect();
let bp = curve::polyline(&verts, p.closed())?;
// 3D polylines are in world coordinates; 2D ones in their plane's.
let bp = if flags & 8 != 0 { bp } else { transformed(bp, ocs(g)) };
Some(Geom::Path { bp, fill: false, width: g.num_or(40, 0.0).max(0.0) })
let bp = if p.is_3d() { bp } else { transformed(bp, ocs) };
Some(Geom::Path { bp, fill: false, width: p.start_width.max(0.0) })
}
fn insert(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
let block = g.name(2)?.to_uppercase();
let (sx, sy) = (g.num_or(41, 1.0), g.num_or(42, 1.0));
fn insert(&mut self, ins: &model::Insert, followers: &[model::Record<'_>], ocs: Affine) -> Option<Geom> {
let block = Some(ins.block.trim()).filter(|b| !b.is_empty())?.to_uppercase();
let [sx, sy, _] = ins.scale;
if sx.abs() < 1e-12 || sy.abs() < 1e-12 {
return None;
}
let place = ocs(g) * Affine::translate(g.point(10).to_vec2()) * Affine::rotate(g.num_or(50, 0.0).to_radians());
let (cols, rows) = (g.int_or(70, 1).max(1), g.int_or(71, 1).max(1));
let (cs, rs) = (g.num_or(44, 0.0), g.num_or(45, 0.0));
let place = ocs * Affine::translate(point(ins.insert).to_vec2()) * Affine::rotate(ins.rotation.to_radians());
let (cols, rows) = (ins.columns, ins.rows);
let (cs, rs) = (ins.column_spacing, ins.row_spacing);
let (cols, rows) = if cols.saturating_mul(rows) > MAX_CELLS {
self.warn(BIG_ARRAY);
(cols.min(MAX_CELLS), (MAX_CELLS / cols.min(MAX_CELLS)).max(1).min(rows))
@@ -312,27 +295,41 @@ impl Converter {
.flat_map(|r| (0..cols).map(move |c| (r, c)))
.map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy))
.collect();
let attribs = self.items(rest);
let attribs = self.attribs(followers);
Some(Geom::Insert(InsertItem { block, cells, attribs }))
}
fn hatch(&mut self, g: &[Pair<'_>]) -> Option<Geom> {
let start = g.iter().position(|p| p.code == 91)?;
let count = g.get(start).and_then(|p| number(p.value)).filter(|n| *n >= 0.0)? as usize;
let mut c = Cursor { g, i: start + 1 };
/// An insert's attributes as text.
fn attribs(&mut self, followers: &[model::Record<'_>]) -> Vec<Item> {
let mut out = Vec::new();
for r in followers {
let c = model::Common::read(r.tags);
if !c.visible {
continue;
}
let a = model::Attrib::read("ATTRIB", r.tags);
match text(&a.text, &r.groups(), true, ocs(c.extrusion)) {
Some(t) if !in_range(&Geom::Text(t.clone())) => self.warn(FAR_AWAY),
Some(t) => out.push(Item { props: Props::read(&c), geom: Geom::Text(t) }),
None => {}
}
}
out
}
fn hatch(&mut self, h: &model::Hatch, ocs: Affine) -> Option<Geom> {
let mut bp = BezPath::new();
for _ in 0..count {
let Some(path) = boundary(&mut c) else { break };
for l in &h.loops {
let Some(path) = boundary(&l.path) else { break };
bp.extend(path);
}
if bp.elements().is_empty() {
return None;
}
let bp = transformed(bp, ocs(g));
let solid = g.int_or(70, 0) == 1;
if g.int_or(450, 0) == 1 {
let bp = transformed(bp, ocs);
if h.gradient.is_some() {
self.warn(GRADIENT_HATCH);
} else if !solid {
} else if !h.solid {
self.warn(PATTERN_HATCH);
return Some(Geom::Path { bp, fill: false, width: 0.0 });
}
@@ -401,217 +398,104 @@ fn polygon(pts: &[Point]) -> BezPath {
bp
}
/// The extrusion direction (210, 220, 230) of a planar entity, normalised.
fn extrusion(g: &[Pair<'_>]) -> [f64; 3] {
let n = [g.num_or(210, 0.0), g.num_or(220, 0.0), g.num_or(230, 1.0)];
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
if len > 1e-12 && len.is_finite() { n.map(|v| v / len) } else { [0.0, 0.0, 1.0] }
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn unit(v: [f64; 3]) -> [f64; 3] {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len > 1e-12 { v.map(|x| x / len) } else { v }
}
/// A planar entity's coordinate system seen from above: its x and y axes in world space by the
/// arbitrary axis algorithm (identity for drawings in the world's plane; extruded along −z it
/// mirrors x).
fn ocs(g: &[Pair<'_>]) -> Affine {
let n = extrusion(g);
if n[2] > 1.0 - 1e-12 {
fn ocs(extrusion: model::Point) -> Affine {
let [ax, ay, _] = model::ocs_axes(extrusion);
if ax == [1.0, 0.0, 0.0] && ay == [0.0, 1.0, 0.0] {
return Affine::IDENTITY;
}
let ax = unit(if n[0].abs() < 1.0 / 64.0 && n[1].abs() < 1.0 / 64.0 { cross([0.0, 1.0, 0.0], n) } else { cross([0.0, 0.0, 1.0], n) });
let ay = unit(cross(n, ax));
Affine::new([ax[0], ax[1], ay[0], ay[1], 0.0, 0.0])
}
fn ellipse(g: &[Pair<'_>]) -> Option<BezPath> {
let centre = g.point(10);
let major = g.point(11).to_vec2();
let ratio = g.num_or(40, 1.0);
fn ellipse(extrusion: model::Point, centre: model::Point, major: model::Point, ratio: f64, start: f64, end: f64) -> Option<BezPath> {
let centre = point(centre);
let major = point(major).to_vec2();
if ratio <= 0.0 || major.hypot() < 1e-12 {
return None;
}
// The minor axis is the major one turned a quarter about the extrusion direction.
let n = extrusion(g);
let n = model::ocs_axes(extrusion)[2];
let m = cross(n, [major.x, major.y, 0.0]);
let minor = Vec2::new(m[0], m[1]) * ratio;
let (start, end) = (g.num_or(41, 0.0), g.num_or(42, std::f64::consts::TAU));
let sweep = ccw_sweep(start.to_degrees(), end.to_degrees()).to_radians();
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start, sweep)
}
fn lwpolyline(g: &[Pair<'_>]) -> Option<Geom> {
let mut verts: Vec<Vertex> = Vec::new();
let (mut widths, mut uniform) = (None, true);
for p in g {
match p.code {
10 => verts.push(Vertex { p: Point::new(number(p.value)?, 0.0), bulge: 0.0 }),
20 => verts.last_mut()?.p.y = number(p.value)?,
42 => verts.last_mut()?.bulge = number(p.value).unwrap_or(0.0),
40 | 41 => {
let w = number(p.value).unwrap_or(0.0);
uniform &= widths.is_none_or(|x: f64| (x - w).abs() < 1e-12);
widths = Some(w);
}
_ => {}
}
}
let bp = transformed(curve::polyline(&verts, g.int_or(70, 0) & 1 != 0)?, ocs(g));
let width = g.num(43).filter(|w| *w > 0.0).or(widths.filter(|_| uniform)).unwrap_or(0.0).max(0.0);
fn lwpolyline(p: &model::LwPolyline, ocs: Affine) -> Option<Geom> {
let verts: Vec<Vertex> = p.vertices.iter().map(|v| Vertex { p: Point::new(v.x, v.y), bulge: v.bulge }).collect();
let bp = transformed(curve::polyline(&verts, p.closed)?, ocs);
// One width for every vertex is the line's width.
let widths: Vec<f64> = p.vertices.iter().flat_map(|v| [v.start_width, v.end_width]).collect();
let uniform = widths.first().filter(|w| widths.iter().all(|x| (x - *w).abs() < 1e-12)).copied();
let width = Some(p.const_width).filter(|w| *w > 0.0).or(uniform).unwrap_or(0.0).max(0.0);
Some(Geom::Path { bp, fill: false, width })
}
/// The points at `code` (x) and `code + 10` (y), in order.
fn points(g: &[Pair<'_>], code: i32) -> Option<Vec<Point>> {
let mut out: Vec<Point> = Vec::new();
for p in g {
if p.code == code {
out.push(Point::new(number(p.value)?, 0.0));
} else if p.code == code + 10 {
out.last_mut()?.y = number(p.value)?;
}
}
Some(out)
}
fn spline(g: &[Pair<'_>]) -> Option<BezPath> {
let flags = g.int_or(70, 0);
let ctrl = points(g, 10)?;
let degree = usize::try_from(g.int_or(71, 3)).ok()?;
let knots: Vec<f64> = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::<Option<_>>()?;
let weights: Vec<f64> = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect();
let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice());
curve::nurbs(degree, &knots, &ctrl, weights)
fn spline(s: &model::Spline) -> Option<BezPath> {
let ctrl: Vec<Point> = s.control.iter().map(|p| point(*p)).collect();
let degree = usize::try_from(s.degree).ok()?;
let weights = (s.flags & 4 != 0 && s.weights.len() == ctrl.len()).then_some(s.weights.as_slice());
curve::nurbs(degree, &s.knots, &ctrl, weights)
.map(|mut path| {
if flags & 1 != 0 {
if s.flags & 1 != 0 {
path.close_path();
}
path
})
.or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
}
/// Reads a hatch's boundary groups in order.
struct Cursor<'g, 'a> {
g: &'g [Pair<'a>],
i: usize,
}
impl Cursor<'_, '_> {
/// The next group's value when it has `code` (consumed).
fn opt(&mut self, code: i32) -> Option<&str> {
let p = self.g.get(self.i).filter(|p| p.code == code)?;
self.i += 1;
Some(p.value)
}
fn num(&mut self, code: i32) -> Option<f64> {
self.opt(code).and_then(number)
}
fn count(&mut self, code: i32) -> Option<usize> {
self.num(code).filter(|n| *n >= 0.0 && *n <= 1e7).map(|n| n as usize)
}
fn point(&mut self, code: i32) -> Option<Point> {
Some(Point::new(self.num(code)?, self.num(code + 10)?))
}
.or_else(|| curve::through(&s.fit.iter().map(|p| point(*p)).collect::<Vec<_>>(), s.flags & 1 != 0))
}
/// One hatch boundary loop, closed.
fn boundary(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
let flags = c.num(92)? as i64;
let mut bp = BezPath::new();
if flags & 2 != 0 {
let bulges = c.num(72).unwrap_or(0.0) != 0.0;
let _closed = c.opt(73);
let n = c.count(93)?;
let mut verts = Vec::with_capacity(n.min(4096));
for _ in 0..n {
let p = c.point(10)?;
let bulge = if bulges { c.num(42).unwrap_or(0.0) } else { 0.0 };
verts.push(Vertex { p, bulge });
fn boundary(path: &HatchPath) -> Option<BezPath> {
let xy = |p: &[f64; 2]| Point::new(p[0], p[1]);
match path {
HatchPath::Polyline { vertices, .. } => {
let verts: Vec<Vertex> = vertices.iter().map(|[x, y, b]| Vertex { p: Point::new(*x, *y), bulge: *b }).collect();
curve::polyline(&verts, true)
}
bp = curve::polyline(&verts, true)?;
} else {
let n = c.count(93)?;
for _ in 0..n {
let edge = edge(c)?;
append(&mut bp, &edge);
}
if bp.elements().is_empty() {
return None;
}
bp.close_path();
}
// The boundary objects it was made from.
if let Some(n) = c.count(97) {
for _ in 0..n {
if c.opt(330).is_none() {
break;
}
}
}
Some(bp)
}
/// One edge of a hatch boundary.
fn edge(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
match c.num(72)? as i64 {
1 => {
HatchPath::Edges(edges) => {
let mut bp = BezPath::new();
bp.move_to(c.point(10)?);
bp.line_to(c.point(11)?);
for e in edges {
let seg = match e {
HatchEdge::Line { start, end } => {
let mut bp = BezPath::new();
bp.move_to(xy(start));
bp.line_to(xy(end));
Some(bp)
}
HatchEdge::Arc { center, radius, start_angle, end_angle, ccw } => {
let sweep = ccw_sweep(*start_angle, *end_angle);
// Clockwise arcs have their angles measured clockwise.
let (start, sweep) = if *ccw { (*start_angle, sweep) } else { (-start_angle, -sweep) };
curve::arc(xy(center), *radius, start.to_radians(), sweep.to_radians())
}
HatchEdge::Ellipse { center, major, ratio, start_angle, end_angle, ccw } => {
let (centre, major) = (xy(center), xy(major).to_vec2());
let minor = Vec2::new(-major.y, major.x) * *ratio * if *ccw { 1.0 } else { -1.0 };
let sweep = ccw_sweep(*start_angle, *end_angle).to_radians();
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start_angle.to_radians(), sweep)
}
HatchEdge::Spline { degree, rational, knots, control, weights, fit, .. } => {
let ctrl: Vec<Point> = control.iter().map(xy).collect();
let degree = usize::try_from(*degree).ok()?;
let weights = (*rational && weights.len() == ctrl.len()).then_some(weights.as_slice());
curve::nurbs(degree, knots, &ctrl, weights).or_else(|| curve::through(&fit.iter().map(xy).collect::<Vec<_>>(), false))
}
};
append(&mut bp, &seg?);
}
if bp.elements().is_empty() {
return None;
}
bp.close_path();
Some(bp)
}
2 => {
let (centre, r, start, end) = (c.point(10)?, c.num(40)?, c.num(50)?, c.num(51)?);
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
let sweep = ccw_sweep(start, end);
// Clockwise arcs have their angles measured clockwise.
let (start, sweep) = if ccw { (start, sweep) } else { (-start, -sweep) };
curve::arc(centre, r, start.to_radians(), sweep.to_radians())
}
3 => {
let (centre, major, ratio, start, end) = (c.point(10)?, c.point(11)?.to_vec2(), c.num(40)?, c.num(50)?, c.num(51)?);
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
let minor = Vec2::new(-major.y, major.x) * ratio * if ccw { 1.0 } else { -1.0 };
let sweep = ccw_sweep(start, end).to_radians();
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start.to_radians(), sweep)
}
4 => {
let degree = c.count(94)?;
let rational = c.num(73).unwrap_or(0.0) != 0.0;
let _periodic = c.opt(74);
let (nk, nc) = (c.count(95)?, c.count(96)?);
let knots: Vec<f64> = (0..nk).map(|_| c.num(40)).collect::<Option<_>>()?;
let mut ctrl = Vec::with_capacity(nc.min(4096));
let mut weights = Vec::with_capacity(nc.min(4096));
for _ in 0..nc {
ctrl.push(c.point(10)?);
weights.push(c.num(42).unwrap_or(1.0));
}
// Fit data (DXF 2010 and later): a count, then that many points. Older files have none,
// and the 97 that may follow is the boundary's count of source objects (then their
// handles), so it is only the fit count when it is 0 or points follow.
let fit_count = match (c.g.get(c.i), c.g.get(c.i + 1)) {
(Some(count), next) if count.code == 97 => number(count.value) == Some(0.0) || next.is_some_and(|p| p.code == 11),
_ => false,
};
let fit: Vec<Point> = match fit_count.then(|| c.count(97)).flatten() {
Some(n) => (0..n).map(|_| c.point(11)).collect::<Option<_>>()?,
None => vec![],
};
// Start and end tangents.
for code in [12, 13] {
c.point(code);
}
curve::nurbs(degree, &knots, &ctrl, rational.then_some(weights.as_slice())).or_else(|| curve::through(&fit, false))
}
_ => None,
}
}
@@ -643,18 +527,21 @@ fn baseline(v: i64, height: f64) -> f64 {
}
}
fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
if attrib && g.int_or(70, 0) & 1 != 0 {
/// TEXT, or an ATTRIB's text. The string is read from group 1 as written, so that the caret and
/// percent codes are turned into text once, by `super::text`.
fn text(t: &model::Text, g: &Groups<'_>, attrib: bool, ocs: Affine) -> Option<TextItem> {
if attrib && g.i_or(70, 0) & 1 != 0 {
return None;
}
let s = super::text::plain(g.raw(1)?);
let s = super::text::plain(&g.s(1)?);
if s.trim().is_empty() {
return None;
}
let height = g.num(40).filter(|h| *h > 0.0)?;
let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0));
let (p1, p2) = (g.point(10), g.point_opt(11));
let mut angle = g.num_or(50, 0.0).to_radians();
let height = Some(t.height).filter(|h| *h > 0.0)?;
// An ATTRIB's 73 is its field length: its vertical alignment is 74.
let (h, v) = (t.halign, if attrib { g.i_or(74, 0) } else { t.valign });
let (p1, p2) = (point(t.insert), t.align.map(point));
let mut angle = t.rotation.to_radians();
let mut span = None;
// Aligned and fit text run from the first point to the second.
let anchor = match (h, p2) {
@@ -674,33 +561,36 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
_ => Justify::Left,
};
let v = if h == 4 { 2 } else { v };
let flags = g.int_or(71, 0);
let flags = t.generation;
let mirror = Affine::scale_non_uniform(if flags & 2 != 0 { -1.0 } else { 1.0 }, if flags & 4 != 0 { -1.0 } else { 1.0 });
let xf = ocs(g) * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height)));
let xf = ocs * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height)));
Some(TextItem {
text: s,
height,
xf,
width: g.num(41).filter(|w| *w > 0.0).unwrap_or(1.0),
width: Some(t.width_factor).filter(|w| *w > 0.0).unwrap_or(1.0),
justify,
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
style: t.style.to_uppercase(),
family: None,
leading: None,
span,
})
}
fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
// The text comes in 250-character chunks (3) before the last (1).
let raw: String = g.iter().filter(|p| p.code == 3).map(|p| p.value).chain(g.raw(1)).collect();
/// MTEXT. The contents are read from groups 3 and 1 as written, as for [`text`].
fn mtext(t: &model::MText, tags: &[craft_dxf::Tag]) -> Option<TextItem> {
// The text comes in 250-character chunks (3) before the last (1), up to an embedded object.
let (own, _) = model::text::split_embedded(tags);
let g = Groups(own);
let raw: String = own.iter().filter(|t| t.code == 3).map(craft_dxf::Tag::str).chain(g.s(1)).collect();
let (s, family) = super::text::mtext(&raw);
if s.trim().is_empty() {
return None;
}
let height = g.num(40).filter(|h| *h > 0.0)?;
let leading = height * LINE_SPACING * g.num(44).filter(|f| *f > 0.0).unwrap_or(1.0);
let height = Some(t.height).filter(|h| *h > 0.0)?;
let leading = height * LINE_SPACING * Some(t.line_spacing).filter(|f| *f > 0.0).unwrap_or(1.0);
let lines = s.lines().count().max(1) as f64;
let attach = g.int_or(71, 1).clamp(1, 9) - 1;
let attach = i64::from(t.attach) - 1;
let justify = [Justify::Left, Justify::Center, Justify::Right].get((attach % 3) as usize).copied().unwrap_or_default();
// The first baseline below the attachment point: under the top, around the middle, above
// the bottom of the lines.
@@ -710,18 +600,6 @@ fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
1 => total / 2.0 - height,
_ => (lines - 1.0) * leading,
};
let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12);
let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2());
let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first));
Some(TextItem {
text: s,
height,
xf,
width: 1.0,
justify,
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
family,
leading: Some(leading),
span: None,
})
let xf = Affine::translate(point(t.insert).to_vec2()) * Affine::rotate(t.angle()) * Affine::translate((0.0, first));
Some(TextItem { text: s, height, xf, width: 1.0, justify, style: t.style.to_uppercase(), family, leading: Some(leading), span: None })
}
+68 -331
View File
@@ -1,156 +1,14 @@
//! Reading an ASCII DXF file: its text, the group-code pairs, and what the import uses of its
//! sections (header variables; the layer, linetype, text style and block record tables; the
//! blocks; the entities; the layouts).
//! What the import uses of a drawing, from `craft_dxf::model`: header variables; the layer,
//! linetype and text style tables; the blocks; the entities; the layouts.
use std::borrow::Cow;
use std::collections::HashMap;
use craft_dxf::model::{self, EntityRecord};
use vectorcraft_geom::Point;
/// The most group-code pairs a drawing may hold (about 300 MB of DXF).
const MAX_PAIRS: usize = 20_000_000;
/// The first bytes of a binary DXF file (its format's signature).
pub const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\x00";
/// One group: its code and its value as written.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Pair<'a> {
pub code: i32,
pub value: &'a str,
}
/// Values of a run of pairs by group code.
pub(crate) trait Groups<'a> {
/// The value of the first `code`, as written.
fn raw(&self, code: i32) -> Option<&'a str>;
/// The first `code` as a name (trimmed, never empty).
fn name(&self, code: i32) -> Option<&'a str> {
self.raw(code).map(str::trim).filter(|s| !s.is_empty())
}
/// The first `code` as a finite number.
fn num(&self, code: i32) -> Option<f64> {
self.raw(code).and_then(number)
}
fn num_or(&self, code: i32, default: f64) -> f64 {
self.num(code).unwrap_or(default)
}
/// The first `code` as a whole number.
fn int(&self, code: i32) -> Option<i64> {
self.raw(code).and_then(integer)
}
fn int_or(&self, code: i32, default: i64) -> i64 {
self.int(code).unwrap_or(default)
}
/// The point at `code` (x) and `code + 10` (y); 0 for what is missing.
fn point(&self, code: i32) -> Point {
Point::new(self.num_or(code, 0.0), self.num_or(code + 10, 0.0))
}
/// [`Groups::point`] when its x is given.
fn point_opt(&self, code: i32) -> Option<Point> {
self.num(code).map(|x| Point::new(x, self.num_or(code + 10, 0.0)))
}
}
impl<'a> Groups<'a> for [Pair<'a>] {
fn raw(&self, code: i32) -> Option<&'a str> {
self.iter().find(|p| p.code == code).map(|p| p.value)
}
}
/// A finite number (`1e999`, `NaN` and junk are none).
pub(crate) fn number(s: &str) -> Option<f64> {
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
}
/// A whole number; some writers put integers as `1.0`.
pub(crate) fn integer(s: &str) -> Option<i64> {
let s = s.trim();
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15 && v.fract() == 0.0).map(|v| v as i64))
}
/// One object: its type (the value of its `0` group) and the groups up to the next object.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Record<'a> {
pub kind: &'a str,
pub g: &'a [Pair<'a>],
}
/// The text of a DXF file: UTF-8 (DXF 2007 and later), else the Windows code page older versions
/// write (a byte order mark is dropped).
pub(crate) fn decode(bytes: &[u8]) -> Cow<'_, str> {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
match std::str::from_utf8(bytes) {
Ok(s) => Cow::Borrowed(s),
Err(_) => Cow::Owned(bytes.iter().map(|b| windows_1252(*b)).collect()),
}
}
/// A byte of the Windows Western code page.
fn windows_1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
'˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
];
match b {
0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
_ => char::from(b),
}
}
/// The group-code pairs of `text`, up to `0 EOF`. A line that isn't a group code ends the
/// reading: what came before is kept and the second value says where the file is damaged.
pub(crate) fn pairs(text: &str) -> Result<(Vec<Pair<'_>>, Option<String>), String> {
let mut out = Vec::new();
let mut lines = text.lines().enumerate();
let mut damaged = None;
while let Some((n, code)) = lines.next() {
let c = code.trim();
if c.is_empty() && out.is_empty() {
continue;
}
let Ok(code) = c.parse::<i32>() else {
damaged = Some(format!("the file is damaged at line {}: what follows is left out", n + 1));
break;
};
let value = lines.next().map_or("", |(_, v)| v);
if code == 0 && value.trim() == "EOF" {
break;
}
if out.len() >= MAX_PAIRS {
return Err("the drawing is too large to open".into());
}
out.push(Pair { code, value });
}
if out.is_empty() {
return Err("not a DXF drawing".into());
}
Ok((out, damaged))
}
/// The objects in `pairs`: each from a `0` group up to the next.
fn records<'a>(pairs: &'a [Pair<'a>]) -> Vec<Record<'a>> {
let mut out = Vec::new();
let mut start: Option<usize> = None;
for (i, p) in pairs.iter().enumerate() {
if p.code == 0 {
if let Some(s) = start {
out.extend(record(pairs, s, i));
}
start = Some(i);
}
}
if let Some(s) = start {
out.extend(record(pairs, s, pairs.len()));
}
out
}
fn record<'a>(pairs: &'a [Pair<'a>], start: usize, end: usize) -> Option<Record<'a>> {
let kind = pairs.get(start)?.value.trim();
Some(Record { kind, g: pairs.get(start + 1..end)? })
}
/// The header variables the import reads.
#[derive(Clone, Debug, Default)]
pub(crate) struct Header {
@@ -186,7 +44,7 @@ pub(crate) struct LayerDef {
pub(crate) struct BlockDef<'a> {
pub name: String,
pub base: Point,
pub records: Vec<Record<'a>>,
pub records: Vec<EntityRecord<'a>>,
/// A reference to another drawing (its art isn't in this file).
pub xref: bool,
}
@@ -218,97 +76,51 @@ pub(crate) struct Drawing<'a> {
/// Blocks by upper-case name.
pub blocks: HashMap<String, BlockDef<'a>>,
/// The ENTITIES section.
pub entities: Vec<Record<'a>>,
pub entities: Vec<EntityRecord<'a>>,
/// Model space first, then the paper layouts in tab order.
pub layouts: Vec<LayoutDef>,
/// How many sections the file has (none: not a drawing).
pub sections: usize,
}
impl<'a> Drawing<'a> {
/// Read the sections of `pairs`.
pub fn read(pairs: &'a [Pair<'a>]) -> Self {
let mut d = Drawing { header: Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }, ..Drawing::default() };
let all = records(pairs);
let mut section: Option<&str> = None;
let (mut tables, mut blocks, mut objects) = (vec![], vec![], vec![]);
for r in all {
match (r.kind, section) {
("SECTION", _) => {
d.sections += 1;
section = r.g.name(2);
if section == Some("HEADER") {
d.header = header(r.g);
}
}
("ENDSEC", _) => section = None,
(_, Some("TABLES")) => tables.push(r),
(_, Some("BLOCKS")) => blocks.push(r),
(_, Some("ENTITIES")) => d.entities.push(r),
(_, Some("OBJECTS")) => objects.push(r),
_ => {}
}
}
let mut block_records = HashMap::new();
let mut table = "";
for r in &tables {
match r.kind {
"TABLE" => table = r.g.name(2).unwrap_or(""),
"LAYER" if table == "LAYER" => {
if let Some(l) = layer(r.g) {
d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len());
d.layers.push(l);
}
}
"LTYPE" if table == "LTYPE" => {
if let Some(name) = r.g.name(2) {
let pattern: Vec<f64> = r.g.iter().filter(|p| p.code == 49).filter_map(|p| number(p.value)).collect();
d.linetypes.insert(name.to_uppercase(), pattern);
}
}
"STYLE" if table == "STYLE" => {
if let Some(name) = r.g.name(2) {
d.styles.insert(name.to_uppercase(), font_family(r.g));
}
}
"BLOCK_RECORD" if table == "BLOCK_RECORD" => {
if let (Some(h), Some(name)) = (r.g.name(5), r.g.name(2)) {
block_records.insert(h.to_uppercase(), name.to_uppercase());
}
}
_ => {}
}
}
d.read_blocks(&blocks, &mut block_records);
d.layouts = layouts(&objects, &block_records, &d.entities);
d
}
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
pub(crate) fn aci_paper(i: u8) -> [u8; 3] {
if i == 7 { [0; 3] } else { model::aci::rgb(i) }
}
fn read_blocks(&mut self, records: &[Record<'a>], block_records: &mut HashMap<String, String>) {
let mut current: Option<BlockDef<'a>> = None;
for r in records {
match r.kind {
"BLOCK" => {
let name = r.g.name(2).or_else(|| r.g.name(3)).unwrap_or("").to_string();
// R13+ blocks name their block record: layouts find their block through it.
if let Some(owner) = r.g.name(330) {
block_records.entry(owner.to_uppercase()).or_insert_with(|| name.to_uppercase());
}
let xref = r.g.int_or(70, 0) & 4 != 0;
current = Some(BlockDef { name, base: r.g.point(10), records: vec![], xref });
}
"ENDBLK" => {
if let Some(b) = current.take().filter(|b| !b.name.is_empty()) {
self.blocks.insert(b.name.to_uppercase(), b);
}
}
_ => {
if let Some(b) = &mut current {
b.records.push(*r);
}
}
}
impl<'a> Drawing<'a> {
/// What the import uses of `doc`.
pub fn read(doc: model::Document<'a>) -> Self {
let h = &doc.header;
let header = Header {
acadver: h.acadver().unwrap_or_default(),
insunits: h.insunits(),
measurement: h.measurement(),
ltscale: h.ltscale(),
lwdefault: i32::from(h.lwdefault()),
};
let mut d = Drawing { header, sections: doc.sections, ..Drawing::default() };
for l in doc.layers() {
d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len());
d.layers.push(layer(l));
}
for lt in doc.linetypes() {
d.linetypes.insert(lt.name.to_uppercase(), lt.pattern.iter().map(|e| e.length).collect());
}
for st in doc.text_styles().into_iter().filter(|s| !s.name.is_empty()) {
d.styles.insert(st.name.to_uppercase(), st.family().unwrap_or_default());
}
let block_names = doc.block_names();
d.layouts = layouts(&doc, &block_names);
for b in doc.blocks {
if b.name.is_empty() {
continue;
}
let def = BlockDef { name: b.name.to_string(), base: point(b.base()), xref: b.flags() & 4 != 0, records: b.entities };
d.blocks.insert(b.name.to_uppercase(), def);
}
d.entities = doc.entities;
d
}
/// The layout named `name` (any case; `model` is model space).
@@ -317,7 +129,7 @@ impl<'a> Drawing<'a> {
}
/// The entity records of `layout`.
pub fn layout_records(&self, layout: &LayoutDef) -> Vec<Record<'a>> {
pub fn layout_records(&self, layout: &LayoutDef) -> Vec<EntityRecord<'a>> {
let block = |name: &str| self.blocks.get(name).map(|b| b.records.clone()).unwrap_or_default();
// The active paper layout keeps its art in the ENTITIES section, flagged paper space.
let (block_name, paper) = match &layout.block {
@@ -325,7 +137,7 @@ impl<'a> Drawing<'a> {
Some(b) if b == PAPER_BLOCK => (PAPER_BLOCK, true),
Some(b) => return block(b),
};
let mut out: Vec<Record<'a>> = groups(&self.entities).filter(|g| in_paper_space(g) == paper).flatten().copied().collect();
let mut out: Vec<EntityRecord<'a>> = self.entities.iter().filter(|e| e.paper_space() == paper).cloned().collect();
out.extend(block(block_name));
out
}
@@ -336,111 +148,47 @@ impl<'a> Drawing<'a> {
}
}
/// Is an entity (its first record) in paper space?
fn in_paper_space(group: &[Record<'_>]) -> bool {
group.first().is_some_and(|r| r.g.int(67) == Some(1))
pub(crate) fn point(p: model::Point) -> Point {
Point::new(p[0], p[1])
}
/// The entities of `records`, each with the records that belong to it (a polyline's vertices,
/// an insert's attributes, and the SEQEND closing them).
pub(crate) fn groups<'r, 'a>(records: &'r [Record<'a>]) -> impl Iterator<Item = &'r [Record<'a>]> {
let mut i = 0;
std::iter::from_fn(move || {
let start = i;
let head = records.get(start)?;
i += 1;
if matches!(head.kind, "POLYLINE" | "INSERT") {
while let Some(r) = records.get(i) {
match r.kind {
"VERTEX" | "ATTRIB" => i += 1,
"SEQEND" => {
i += 1;
break;
}
_ => break,
}
}
}
records.get(start..i)
})
}
fn header(g: &[Pair<'_>]) -> Header {
let mut h = Header { ltscale: 1.0, lwdefault: 25, ..Header::default() };
let mut var = "";
for p in g {
if p.code == 9 {
var = p.value.trim();
continue;
}
match (var, p.code) {
("$ACADVER", 1) => h.acadver = p.value.trim().to_string(),
("$INSUNITS", 70) => h.insunits = integer(p.value).unwrap_or(0),
("$MEASUREMENT", 70) => h.measurement = integer(p.value),
("$LTSCALE", 40) => h.ltscale = number(p.value).filter(|v| *v > 0.0).unwrap_or(1.0),
("$LWDEFAULT", 370) => h.lwdefault = integer(p.value).and_then(|v| i32::try_from(v).ok()).filter(|v| *v >= 0).unwrap_or(25),
_ => {}
}
}
h
}
fn layer(g: &[Pair<'_>]) -> Option<LayerDef> {
let name = g.name(2)?.to_string();
let aci = g.int_or(62, 7);
let flags = g.int_or(70, 0);
let rgb = super::entity::true_color(g).unwrap_or_else(|| super::entity::aci_paper(aci.unsigned_abs()));
Some(LayerDef {
fn layer(l: model::Layer) -> LayerDef {
let rgb = match l.color {
model::Color::Rgb(rgb) => rgb,
model::Color::Index(i) => aci_paper(i),
model::Color::ByLayer | model::Color::ByBlock => aci_paper(7),
};
LayerDef {
// CAD apps never plot this layer of dimension points.
plot: g.int_or(290, 1) != 0 && !name.eq_ignore_ascii_case("Defpoints"),
name,
plot: l.plot && !l.name.eq_ignore_ascii_case("Defpoints"),
rgb,
hidden: aci < 0 || flags & 1 != 0,
locked: flags & 4 != 0,
linetype: g.name(6).unwrap_or("CONTINUOUS").to_uppercase(),
lineweight: g.int(370).and_then(|v| i32::try_from(v).ok()).unwrap_or(-3),
})
}
/// A text style's font family: the one 2000-and-later files name, else the font file's name
/// (TrueType); CAD fonts (`.shx`) have none.
fn font_family(g: &[Pair<'_>]) -> String {
if let Some(i) = g.iter().position(|p| p.code == 1001 && p.value.trim() == "ACAD")
&& let Some(f) = g.get(i + 1..).and_then(|rest| rest.name(1000))
{
return f.to_string();
}
let file = g.name(3).unwrap_or("");
let path = std::path::Path::new(file);
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
match ext.as_str() {
"ttf" | "otf" | "ttc" => path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
_ => String::new(),
hidden: !l.on || l.frozen,
locked: l.locked,
linetype: l.linetype.to_uppercase(),
lineweight: i32::try_from(l.lineweight.to_dxf()).unwrap_or(-3),
name: l.name,
}
}
/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout
/// objects but with paper-space art has one paper layout.
/// The most bytes of layout and block names the layouts keep together. Each layout keeps its own
/// copy of its block's name, so many layouts naming one long block name multiplied it; real
/// drawings have a few dozen layouts with short names.
const MAX_LAYOUT_NAME_BYTES: usize = 4 << 20;
fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, entities: &[Record<'_>]) -> Vec<LayoutDef> {
/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout
/// objects but with paper-space art has one paper layout.
fn layouts(doc: &model::Document<'_>, block_names: &HashMap<u64, &str>) -> Vec<LayoutDef> {
let mut paper: Vec<(i64, LayoutDef)> = vec![];
let mut seen = std::collections::HashSet::new();
let mut name_bytes = 0usize;
for r in objects.iter().filter(|r| r.kind == "LAYOUT") {
// The layout's own groups follow its subclass marker.
let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]);
let Some(name) = own.name(1) else { continue };
let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase()));
if name.eq_ignore_ascii_case(MODEL) || block.map(String::as_str) == Some(MODEL_BLOCK) {
for l in doc.layouts() {
let block = l.block_record.and_then(|h| block_names.get(&h)).map(|n| n.to_uppercase());
if l.name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) {
continue;
}
let Some(block) = block else { continue };
let key = name.to_ascii_lowercase();
let cost = name.len() + block.len();
let key = l.name.to_ascii_lowercase();
let cost = l.name.len() + block.len();
// A layout past the allowance is skipped alone: a later short one is still offered, and a
// skipped one reserves neither its name nor its bytes.
if seen.contains(&key) || name_bytes.saturating_add(cost) > MAX_LAYOUT_NAME_BYTES {
@@ -448,22 +196,11 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, enti
}
seen.insert(key);
name_bytes += cost;
paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block.clone()) }));
paper.push((l.tab_order, LayoutDef { name: l.name, block: Some(block) }));
}
paper.sort_by_key(|(order, _)| *order);
if paper.is_empty() && groups(entities).any(in_paper_space) {
if paper.is_empty() && doc.entities.iter().any(EntityRecord::paper_space) {
paper.push((1, LayoutDef { name: "Layout1".into(), block: Some(PAPER_BLOCK.into()) }));
}
std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect()
}
#[cfg(test)]
mod tests {
use super::integer;
#[test]
fn integer_rejects_fractional_float_values() {
assert_eq!(integer("1.0"), Some(1));
assert_eq!(integer("1.9"), None);
}
}
+8 -4
View File
@@ -997,7 +997,12 @@ fn binary_dwg_damaged_and_foreign_files() {
binary.extend([0u8; 40]);
assert!(is_dxf(&binary));
let e = import(&binary, &ImportOptions::default()).unwrap_err();
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
assert!(e.contains("binary DXF") && e.contains("damaged"), "{e}");
// A binary DXF opens as its ASCII form does.
let ascii = entities(LINE);
let tags = craft_dxf::parse(&ascii).unwrap();
let r = open(&craft_dxf::write_binary(&tags, true), &points());
assert_eq!(leaves(&r.document).len(), 1);
let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err();
assert!(e.contains("DWG"), "{e}");
for junk in [&b""[..], b"hello", b"<svg/>", b"999\nnote\n"] {
@@ -1166,9 +1171,8 @@ fn many_array_inserts_of_a_missing_block_stay_bounded() {
let one = [(0, "INSERT"), (8, "0"), (2, "NOPE"), (10, "0"), (20, "0"), (70, "100"), (71, "100"), (44, "1"), (45, "1")];
let e: Vec<(i32, &str)> = (0..3000).flat_map(|_| one).collect();
let bytes = entities(&e);
let text = reader::decode(&bytes);
let (pairs, _) = reader::pairs(&text).unwrap();
let d = reader::Drawing::read(&pairs);
let tags = craft_dxf::parse(&bytes).unwrap();
let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags));
let model = d.layout("Model").or(d.layouts.first()).unwrap();
let mut conv = entity::Converter::default();
let items = conv.items(&d.layout_records(model));
+3 -3
View File
@@ -1,5 +1,5 @@
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an
//! HP-GL/2 plot file reader and writer.
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and a reader on
//! `craft_dxf::model`, and an HP-GL/2 plot file reader and writer.
//!
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
@@ -11,7 +11,7 @@
//! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns,
//! clipping) is approximated or left out, and each such loss comes back as a warning.
//!
//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`].
//! [`import()`] reads a DXF drawing (ASCII or binary, any version) into a document: see [`import`].
//! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`].
//!
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
+1 -1
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"DXF Info",
[],
None,
"{path} or {name?, dataBase64} what a DXF drawing holds → {version (2018, R12…), units (its drawing unit: Millimeters, Inches, Unitless…), layouts: [\"Model\", paper layouts…], layers: [name…], unit, scale (the default ratio: 1 unit = scale drawing units, the drawing at 1:1)}; a binary DXF or a DWG answers an error saying to save it as ASCII DXF",
"{path} or {name?, dataBase64} what a DXF drawing holds → {version (2018, R12…), units (its drawing unit: Millimeters, Inches, Unitless…), layouts: [\"Model\", paper layouts…], layers: [name…], unit, scale (the default ratio: 1 unit = scale drawing units, the drawing at 1:1)}; a DWG answers an error saying to save it as DXF",
always,
dxf_info
)]
@@ -163,12 +163,12 @@ fn dxf_info_lists_layouts_layers_and_units() {
}
#[test]
fn binary_dxf_says_to_save_it_as_ascii() {
fn damaged_binary_dxf_says_so() {
let mut s = Session::new();
let mut bytes = vectorcraft_cad::import::BINARY_SENTINEL.to_vec();
bytes.extend([0u8; 64]);
let e = open(&mut s, &bytes, json!({})).unwrap_err().to_string();
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
assert!(e.contains("binary DXF") && e.contains("damaged"), "{e}");
let e = s.execute("document.dxfInfo", &json!({"dataBase64": b64(&bytes)})).unwrap_err().to_string();
assert!(e.contains("binary DXF"), "{e}");
}
+1
View File
@@ -9,6 +9,7 @@ description = "VectorCraft Encapsulated PostScript: a hand-written EPS writer (P
workspace = true
[dependencies]
craft-eps = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
+5 -46
View File
@@ -352,64 +352,23 @@ fn read_data(ps: &[u8], name: &str) -> Option<Vec<u8>> {
None
}
/// The magic number of an EPS file with a binary header (a preview).
const DOS_MAGIC: [u8; 4] = [0xC5, 0xD0, 0xD3, 0xC6];
/// The binary header's size.
const DOS_HEADER: usize = 30;
pub use craft_eps::{DOS_HEADER, DOS_MAGIC, Sections, is_eps};
/// `ps` behind a binary header, followed by the TIFF preview `tiff`.
fn dos_eps(ps: &[u8], tiff: &[u8]) -> Result<Vec<u8>, String> {
let big = || "the file is too large for an EPS preview header (4 GB): choose no preview".to_string();
let ps_len = u32::try_from(ps.len()).map_err(|_| big())?;
let tiff_at = u32::try_from(DOS_HEADER + ps.len()).map_err(|_| big())?;
let tiff_len = u32::try_from(tiff.len()).map_err(|_| big())?;
tiff_at.checked_add(tiff_len).ok_or_else(big)?;
let mut out = Vec::with_capacity(DOS_HEADER + ps.len() + tiff.len());
out.extend(DOS_MAGIC);
out.extend((DOS_HEADER as u32).to_le_bytes());
out.extend(ps_len.to_le_bytes());
// No metafile preview.
out.extend(0u32.to_le_bytes());
out.extend(0u32.to_le_bytes());
out.extend(tiff_at.to_le_bytes());
out.extend(tiff_len.to_le_bytes());
// No checksum.
out.extend(0xFFFFu16.to_le_bytes());
out.extend(ps);
out.extend(tiff);
Ok(out)
craft_eps::wrap(ps, Some(tiff), None).map_err(|e| e.to_string())
}
/// The sections of an EPS file: its PostScript and its TIFF preview (binary header), or the whole
/// file as PostScript. `None` when a binary header points outside the file.
pub fn sections(bytes: &[u8]) -> Option<(&[u8], Option<&[u8]>)> {
if !bytes.starts_with(&DOS_MAGIC) {
return Some((bytes, None));
}
let word = |at: usize| bytes.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize);
let part = |at: usize| {
let (start, len) = (word(at)?, word(at + 4)?);
bytes.get(start..start.checked_add(len)?)
};
let ps = part(4)?;
let tiff = match word(24) {
Some(0) | None => None,
Some(_) => Some(part(20)?),
};
Some((ps, tiff))
let s = craft_eps::sections(bytes).ok()?;
Some((s.postscript, s.tiff))
}
/// The Windows metafile preview behind an EPS file's binary header, if it has one.
pub fn metafile_preview(bytes: &[u8]) -> Option<&[u8]> {
if !bytes.starts_with(&DOS_MAGIC) {
return None;
}
let word = |at: usize| bytes.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize);
let (start, len) = (word(12)?, word(16)?);
if start == 0 || len == 0 {
return None;
}
bytes.get(start..start.checked_add(len)?)
craft_eps::sections(bytes).ok()?.metafile
}
/// The native document an EPS file written by [`export`] carries, if any.
+2 -92
View File
@@ -21,9 +21,6 @@ VCdict begin
end
";
/// Longest line of encoded data (DSC readers take up to 255 characters).
const LINE: usize = 76;
/// `v` as PostScript writes it: at most four decimals, no trailing zeros (0 when not finite).
pub(crate) fn num(v: f64) -> String {
let mut s = String::new();
@@ -180,54 +177,7 @@ pub(crate) fn clip_op(rule: FillRule) -> &'static str {
/// `bytes` in ASCII85 with its `~>` end, in lines of about [`LINE`] characters, none starting
/// with `%` (DSC readers would take it for a comment).
pub(crate) fn ascii85(bytes: &[u8]) -> String {
let mut raw = String::with_capacity(bytes.len() * 5 / 4 + 8);
let (chunks, rest) = bytes.as_chunks::<4>();
for c in chunks {
let v = u32::from_be_bytes(*c);
if v == 0 {
raw.push('z');
} else {
push85(&mut raw, v, 5);
}
}
if !rest.is_empty() {
let mut c = [0u8; 4];
for (d, s) in c.iter_mut().zip(rest) {
*d = *s;
}
push85(&mut raw, u32::from_be_bytes(c), rest.len() + 1);
}
raw.push_str("~>");
let mut out = String::with_capacity(raw.len() + raw.len() / LINE + 2);
if raw.starts_with('%') {
// Whitespace is ignored by readers, and keeps the first line from starting with `%`.
out.push(' ');
}
let mut col = 0;
for ch in raw.chars() {
if col >= LINE && ch != '%' {
out.push('\n');
col = 0;
}
out.push(ch);
col += 1;
}
out.push('\n');
out
}
/// The first `n` base-85 digits of `v`.
fn push85(s: &mut String, mut v: u32, n: usize) {
let mut d = [0u8; 5];
for slot in d.iter_mut().rev() {
*slot = (v % 85) as u8 + b'!';
v /= 85;
}
for b in d.iter().take(n) {
s.push(char::from(*b));
}
}
pub(crate) use craft_eps::ascii85::encode as ascii85;
/// Most bytes [`ascii85_decode`] makes: what one filter or data section may hold.
pub(crate) const MAX_ASCII85: usize = 1 << 28;
@@ -241,47 +191,7 @@ pub(crate) fn ascii85_decode(text: &str) -> Option<Vec<u8>> {
/// [`ascii85_decode`] of at most `max` bytes: `z` is four bytes for one, so the length of the
/// text doesn't bound what it becomes (#1715).
pub(crate) fn ascii85_decode_within(text: &str, max: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity((text.len() / 5 * 4).min(max));
let mut group = [0u8; 5];
let mut n = 0;
let mut chars = text.bytes().filter(|b| !b.is_ascii_whitespace()).peekable();
while let Some(b) = chars.next() {
if out.len() > max {
return None;
}
match b {
b'~' => {
if chars.next() != Some(b'>') {
return None;
}
if n == 1 {
return None;
}
if n > 1 {
let padded: Vec<u8> = group.iter().take(n).copied().chain(std::iter::repeat(b'u')).take(5).collect();
let v = value85(&padded)?;
out.extend(v.to_be_bytes().iter().take(n - 1));
}
return (out.len() <= max).then_some(out);
}
b'z' if n == 0 => out.extend([0; 4]),
b'!'..=b'u' => {
*group.get_mut(n)? = b;
n += 1;
if n == 5 {
out.extend(value85(&group)?.to_be_bytes());
n = 0;
}
}
_ => return None,
}
}
None
}
/// The value of five base-85 digits; `None` past `u32::MAX`.
fn value85(digits: &[u8]) -> Option<u32> {
digits.iter().try_fold(0u32, |acc, d| acc.checked_mul(85)?.checked_add(u32::from(d.checked_sub(b'!')?)))
craft_eps::ascii85::decode(text.as_bytes(), max)
}
/// Run-length encoding (PackBits: what PostScript's `RunLengthDecode` reads), shared with TIFF.
+1
View File
@@ -9,6 +9,7 @@ description = "VectorCraft SVG import and export"
workspace = true
[dependencies]
craft-svg = { workspace = true }
# Which paths stored in documents may be opened.
craft-docpath = { workspace = true }
vectorcraft-geom = { workspace = true }
+3 -83
View File
@@ -2,90 +2,10 @@
//! replaces every `&name;` with the entity's text as it reads, with no total limit: a short file
//! that repeats a large entity in an attribute allocates far more than it holds.
use std::collections::HashMap;
/// The most text the references to entities may expand to in all (bytes).
const MAX_EXPANDED: usize = 16 << 20;
/// How deep entities that refer to entities are followed (the reader's own limit is lower).
const MAX_NESTING: u32 = 16;
/// The longest entity name a reference is looked for with ([`references`]).
const MAX_NAME: usize = 256;
/// The internal general entities declared in `svg`: name → every value declared for it. Text that
/// only looks like a declaration (in a comment, or in another entity's value) is kept too: the
/// largest value counts, so it can only make the estimate larger.
fn declared(svg: &str) -> HashMap<&str, Vec<&str>> {
let mut map: HashMap<&str, Vec<&str>> = HashMap::new();
for (at, _) in svg.match_indices("<!ENTITY") {
let rest = svg.get(at + "<!ENTITY".len()..).unwrap_or("").trim_start();
// A parameter entity (`%`) isn't referenced as `&name;`.
if rest.starts_with('%') {
continue;
}
let Some(end) = rest.find(char::is_whitespace) else { continue };
let (name, rest) = rest.split_at(end);
let rest = rest.trim_start();
let Some(quote) = rest.chars().next().filter(|q| matches!(q, '"' | '\'')) else { continue };
let Some(value) = rest.get(1..).and_then(|r| r.split_once(quote)).map(|(v, _)| v) else { continue };
map.entry(name).or_default().push(value);
}
map
}
/// The names of the `&name;` references in `text`.
fn references(text: &str) -> impl Iterator<Item = &str> {
text.match_indices('&').filter_map(|(at, _)| {
let rest = text.get(at + 1..)?;
// Look no further than the longest name: a file of `&`s without `;` would be read quadratically.
let end = rest.bytes().take(MAX_NAME + 1).position(|b| b == b';')?;
let name = rest.get(..end)?;
(!name.is_empty() && name.len() <= MAX_NAME && !name.starts_with('#') && !name.contains(char::is_whitespace)).then_some(name)
})
}
/// What entity `name` expands to (bytes), references in its value followed.
fn expanded<'a>(name: &'a str, entities: &HashMap<&'a str, Vec<&'a str>>, memo: &mut HashMap<&'a str, usize>, depth: u32) -> usize {
if let Some(n) = memo.get(name) {
return *n;
}
let Some(values) = entities.get(name) else { return 0 };
if depth >= MAX_NESTING {
return usize::MAX;
}
let mut most = 0usize;
for value in values {
let mut total = value.len();
for inner in references(value) {
total = total.saturating_add(expanded(inner, entities, memo, depth + 1));
}
most = most.max(total);
}
memo.insert(name, most);
most
}
/// Whether the references to the entities `svg` declares expand to more than [`MAX_EXPANDED`]
/// Whether the references to the entities `svg` declares expand to more than the limit
/// bytes in all. False for a file that declares none.
pub(crate) fn expansion_too_large(svg: &str) -> bool {
let entities = declared(svg);
if entities.is_empty() {
return false;
}
// A reference to a longer name isn't looked for: refuse such a declaration instead.
if entities.keys().any(|name| name.len() > MAX_NAME) {
return true;
}
let mut memo = HashMap::new();
let mut total = 0usize;
for name in references(svg) {
total = total.saturating_add(expanded(name, &entities, &mut memo, 0));
if total > MAX_EXPANDED {
return true;
}
}
false
craft_svg::guard::entities_expand_past(svg, craft_svg::guard::MAX_ENTITY_EXPANSION)
}
#[cfg(test)]
@@ -118,7 +38,7 @@ mod tests {
assert!(expansion_too_large(&commented));
let in_value = format!("<!DOCTYPE svg [<!ENTITY c \"<!ENTITY a 'x'>\"><!ENTITY a \"{big}\">]><svg id=\"{refs}\"/>");
assert!(expansion_too_large(&in_value));
let long = "n".repeat(MAX_NAME + 1);
let long = "n".repeat(257);
let long_name = format!("<!DOCTYPE svg [<!ENTITY {long} \"x\">]><svg id=\"&{long};\"/>");
assert!(expansion_too_large(&long_name));
}
+19 -64
View File
@@ -458,21 +458,17 @@ pub(crate) fn body_hash(parts: &[&str]) -> String {
/// Does `bytes` start like gzip data (an SVGZ file)?
pub fn is_svgz(bytes: &[u8]) -> bool {
bytes.starts_with(&[0x1f, 0x8b])
craft_svg::sniff::is_svgz(bytes)
}
/// Compress SVG text as SVGZ (gzip).
pub fn compress(svg: &str) -> Vec<u8> {
compress_bytes(svg.as_bytes())
craft_svg::svgz::compress(svg.as_bytes())
}
/// Compress an SVG file (in any encoding, see [`Output::bytes`]) as SVGZ (gzip).
pub fn compress_bytes(svg: &[u8]) -> Vec<u8> {
use std::io::Write as _;
let mut gz = flate2::write::GzEncoder::new(Vec::with_capacity(svg.len() / 4), flate2::Compression::default());
// Writing to a Vec cannot fail.
let _ = gz.write_all(svg);
gz.finish().unwrap_or_default()
craft_svg::svgz::compress(svg)
}
/// Most bytes an SVGZ file may unpack to (a guard against decompression bombs).
@@ -487,29 +483,26 @@ pub fn text_of(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, SvgError> {
/// [`text_of`], SVGZ bytes unpacking within what `unpacked`, the bytes SVGZ data unpacked to so
/// far, leaves of [`MAX_SVGZ`]. What they unpack to is added to it, whether or not it decodes.
pub(crate) fn text_within<'a>(bytes: &'a [u8], unpacked: &std::sync::atomic::AtomicU64) -> Result<std::borrow::Cow<'a, str>, SvgError> {
use std::io::Read as _;
use std::sync::atomic::Ordering;
if !is_svgz(bytes) {
return match decode(bytes)? {
Decoded::Utf8(skip) => std::str::from_utf8(bytes.get(skip..).unwrap_or_default()).map(Into::into).map_err(|_| not_utf8()),
Decoded::Text(t) => Ok(t.into()),
};
return craft_svg::svgz::decode(bytes).map_err(|_| not_utf8());
}
let most = MAX_SVGZ.saturating_sub(unpacked.load(Ordering::Relaxed));
let mut raw = Vec::new();
let read = flate2::read::GzDecoder::new(bytes).take(most.saturating_add(1)).read_to_end(&mut raw);
// Read before a failure is kept: it was unpacked too.
unpacked.fetch_add(raw.len() as u64, Ordering::Relaxed);
read.map_err(|e| SvgError::Parse(format!("not a valid SVGZ file: {e}")))?;
if raw.len() as u64 > most {
return Err(SvgError::TooBig(format!("the SVGZ file unpacks to more than {} MB", most >> 20)));
}
match decode(&raw)? {
Decoded::Utf8(skip) => {
raw.drain(..skip);
String::from_utf8(raw).map(Into::into).map_err(|_| not_utf8())
let most = usize::try_from(MAX_SVGZ.saturating_sub(unpacked.load(Ordering::Relaxed))).unwrap_or(usize::MAX);
match craft_svg::svgz::inflate(bytes, most) {
Ok(raw) => {
unpacked.fetch_add(raw.len() as u64, Ordering::Relaxed);
craft_svg::svgz::decode(&raw)
.map(|t| std::borrow::Cow::Owned(t.into_owned()))
.map_err(|_| not_utf8())
}
Decoded::Text(t) => Ok(t.into()),
Err(craft_svg::svgz::Error::TooLarge(_)) => {
Err(SvgError::TooBig(format!("the SVGZ file unpacks to more than {} MB", most >> 20)))
}
Err(craft_svg::svgz::Error::Corrupt(e)) => {
Err(SvgError::Parse(format!("not a valid SVGZ file: {e}")))
}
Err(craft_svg::svgz::Error::Encoding) => Err(not_utf8()),
Err(e) => Err(SvgError::Parse(e.to_string())),
}
}
@@ -517,44 +510,6 @@ fn not_utf8() -> SvgError {
SvgError::Parse("SVG is not UTF-8, UTF-16 or ISO 8859-1".into())
}
/// How SVG bytes are read.
enum Decoded {
/// UTF-8 after this many bytes (a byte order mark).
Utf8(usize),
/// Text decoded from another encoding.
Text(String),
}
/// The encoding of SVG bytes: a byte order mark (UTF-8 or UTF-16), else the XML declaration's
/// (ISO 8859-1 by its names; anything else is read as UTF-8).
fn decode(bytes: &[u8]) -> Result<Decoded, SvgError> {
let utf16 = |rest: &[u8], unit: fn([u8; 2]) -> u16| {
let (units, odd) = rest.as_chunks::<2>();
if !odd.is_empty() {
return Err(not_utf8());
}
char::decode_utf16(units.iter().map(|u| unit(*u))).collect::<Result<String, _>>().map(Decoded::Text).map_err(|_| not_utf8())
};
match bytes {
[0xef, 0xbb, 0xbf, ..] => Ok(Decoded::Utf8(3)),
[0xfe, 0xff, rest @ ..] => utf16(rest, u16::from_be_bytes),
[0xff, 0xfe, rest @ ..] => utf16(rest, u16::from_le_bytes),
_ if declared_latin1(bytes) => Ok(Decoded::Text(bytes.iter().map(|b| char::from(*b)).collect())),
_ => Ok(Decoded::Utf8(0)),
}
}
/// Does the XML declaration at the start of `bytes` name ISO 8859-1?
fn declared_latin1(bytes: &[u8]) -> bool {
let head = bytes.get(..bytes.len().min(200)).unwrap_or_default();
let Some(decl) = head.strip_prefix(b"<?xml").and_then(|d| d.split(|b| *b == b'>').next()) else { return false };
let decl = String::from_utf8_lossy(decl).to_ascii_lowercase();
let Some(at) = decl.find("encoding") else { return false };
let value = decl.get(at + "encoding".len()..).unwrap_or("").trim_start().trim_start_matches('=').trim_start();
let name = value.trim_start_matches(['"', '\'']).split(['"', '\'', ' ', '?']).next().unwrap_or("");
matches!(name, "iso-8859-1" | "iso8859-1" | "iso_8859-1" | "latin1" | "latin-1" | "l1")
}
/// Import an SVG document.
pub fn import(svg: &str) -> Result<Document, SvgError> {
import_with_report(svg).map(|(d, _)| d)