Merge main into fix/art-brush-proportional-weight

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