mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
Merge main into fix/live-paint-expand-drawn-stroke
This commit is contained in:
Generated
+98
-28
@@ -978,11 +978,33 @@ dependencies = [
|
||||
"ruzstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-codepage"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
|
||||
|
||||
[[package]]
|
||||
name = "craft-dxf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
dependencies = [
|
||||
"craft-codepage",
|
||||
"encoding_rs",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-eps"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
@@ -1057,6 +1079,21 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-svg"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"flate2",
|
||||
"kurbo 0.13.1",
|
||||
"roxmltree 0.21.2",
|
||||
"thiserror 2.0.21",
|
||||
"usvg 0.48.1",
|
||||
"vectorcraft-geom 0.18.0",
|
||||
"vectorcraft-pathops 0.18.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1146,6 +1183,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xml"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.3"
|
||||
@@ -6386,13 +6432,14 @@ dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-dxf",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -6400,8 +6447,8 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
@@ -6418,7 +6465,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-mcp",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
@@ -6455,7 +6502,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
@@ -6470,8 +6517,8 @@ dependencies = [
|
||||
"svgtypes 0.16.1",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-three-d",
|
||||
@@ -6515,10 +6562,10 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-eps",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-js",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
@@ -6537,6 +6584,7 @@ dependencies = [
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-eps",
|
||||
"craft-pdf",
|
||||
"encoding_rs",
|
||||
"fax",
|
||||
@@ -6552,9 +6600,9 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-testkit",
|
||||
@@ -6573,10 +6621,20 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.18.0"
|
||||
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.24.1"
|
||||
@@ -6611,7 +6669,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
@@ -6628,10 +6686,21 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.18.0"
|
||||
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom 0.18.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.24.1"
|
||||
@@ -6640,7 +6709,7 @@ dependencies = [
|
||||
"linesweeper",
|
||||
"proptest",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
]
|
||||
|
||||
@@ -6673,7 +6742,7 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"weezl 0.2.1",
|
||||
@@ -6692,7 +6761,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"wasmi",
|
||||
"wat",
|
||||
"web-time",
|
||||
@@ -6717,7 +6786,7 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
@@ -6741,6 +6810,7 @@ name = "vectorcraft-svg"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"craft-svg",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6757,8 +6827,8 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
@@ -6776,7 +6846,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
]
|
||||
@@ -6796,7 +6866,7 @@ dependencies = [
|
||||
"unicode-script",
|
||||
"unicode-segmentation",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vello_cpu",
|
||||
"windows-registry",
|
||||
"write-fonts",
|
||||
@@ -6806,8 +6876,8 @@ dependencies = [
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6822,8 +6892,8 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
@@ -6836,7 +6906,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6865,7 +6935,7 @@ dependencies = [
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-eps",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
|
||||
@@ -25,6 +25,12 @@ craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", re
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
# Native Affinity documents (craft-libs#914).
|
||||
craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" }
|
||||
# SVG parsing, limits, SVGZ and writer (craft-libs#778).
|
||||
craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2", features = ["tree"] }
|
||||
# EPS container, previews, ASCII85 and DSC comments (craft-libs#778).
|
||||
craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" }
|
||||
# DXF tags, reader and writer (craft-libs#778, #926, #927).
|
||||
craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+1
-1
@@ -7238,7 +7238,7 @@ hatches filled, pattern hatches as their boundaries), text and multiline text po
|
||||
(one per look when their art takes colour "by block"), anonymous blocks such as dimensions groups. Indexed colours
|
||||
(7 is black on paper), true colour, lineweights (`scaleLineweights` scales them with the art), linetypes and
|
||||
transparency resolve through layers and blocks. What is left out (viewports, images, meshes, unknown entities) is
|
||||
listed in `warnings`. Binary DXF and DWG files answer an error saying to save them as ASCII DXF.
|
||||
listed in `warnings`. ASCII and binary DXF files are read; DWG files answer an error saying to save them as DXF.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.dxfInfo","params":{"path":"/tmp/plan.dxf"}}}
|
||||
|
||||
Reference in New Issue
Block a user