M4.77: DXF export (R12 to 2018) with DXF Options; DWG and PICT say what to use instead
New crates/cad (L3): a hand-written ASCII DXF writer. Layers become DXF
layers (hidden: off, locked, non-printing: not plotted), straight paths
LWPOLYLINE (POLYLINE before R14), curves cubic SPLINEs through every
anchor, fills solid HATCHes (outlines in R12), strokes lines with a
lineweight and a linetype per dash pattern, type TEXT or glyph outlines,
placed images IMAGE entities linked to PNG or JPEG files. Units and
scale ($INSUNITS), scaled lineweights, 8/16/256 indexed or true colour
(420), transparency (440), Preserve Appearance / Maximize Editability,
Alter Paths for Appearance, Outline Text.
document.export {format: dxf} and document.exportDxf; useArtboards
writes one drawing per artboard; selectedOnly (any export) writes the
selection in its layers. Export As lists DXF (then DXF Options,
ui.dxfOptionsDialog for agents, remembered between exports) and DWG
greyed out; document.formats lists DWG and PICT as unsupported with a
hint, and exporting or opening them answers with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
448437fb76
commit
a17cc428d1
|
|
@ -4089,6 +4089,22 @@ dependencies = [
|
|||
"vectorcraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.1.1"
|
||||
|
|
@ -4157,6 +4173,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-cad",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-format",
|
||||
|
|
@ -4362,6 +4379,7 @@ dependencies = [
|
|||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"vectorcraft-cad",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ vectorcraft-engine = { path = "crates/engine" }
|
|||
vectorcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
vectorcraft-mcp = { path = "crates/mcp" }
|
||||
vectorcraft-testkit = { path = "crates/testkit" }
|
||||
vectorcraft-cad = { path = "crates/cad" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "vectorcraft-cad"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-effects = { workspace = true }
|
||||
vectorcraft-brush = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
//! 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.
|
||||
|
||||
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],
|
||||
}
|
||||
}
|
||||
|
||||
/// The indices a colour depth may use (true colour: all of them, for the fallback index).
|
||||
fn candidates(depth: ColorDepth) -> Box<dyn Iterator<Item = u8>> {
|
||||
match depth {
|
||||
ColorDepth::Aci8 => Box::new(1..=8),
|
||||
ColorDepth::Aci16 => Box::new((1..=9).chain([30]).chain(250..=255)),
|
||||
ColorDepth::Aci256 | ColorDepth::True => Box::new(1..=255),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
#[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);
|
||||
assert_eq!(nearest_aci([0, 0, 0], ColorDepth::Aci256), 7, "black is index 7");
|
||||
assert_eq!(nearest_aci([255, 255, 255], ColorDepth::Aci16), 7);
|
||||
assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci256), 30);
|
||||
assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci16), 30);
|
||||
assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci8), 1, "8 colours: red is nearest");
|
||||
assert_eq!(nearest_aci([60, 60, 60], ColorDepth::Aci16), 250);
|
||||
assert_eq!(nearest_aci([100, 100, 100], ColorDepth::Aci8), 8);
|
||||
assert_eq!(nearest_aci([60, 60, 60], ColorDepth::Aci8), 7, "dark greys go to black");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
//! CAD interchange: a hand-written ASCII DXF writer, versions R12 to 2018.
|
||||
//!
|
||||
//! [`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
|
||||
//! become polylines (straight segments) or cubic splines (curves), fills solid hatches, type text
|
||||
//! entities or glyph outlines, and placed images image entities linked to PNG or JPEG files the
|
||||
//! caller writes next to the drawing. Coordinates are y-up, in drawing units: `scale` units per
|
||||
//! `unit` of the art, with the origin at the bottom-left corner of [`DxfOptions::region`].
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
|
||||
//! - `writer`: group codes, handles and the file's sections, tables and objects.
|
||||
//! - `scene`: the document walk that turns objects into entities.
|
||||
|
||||
mod aci;
|
||||
mod scene;
|
||||
mod writer;
|
||||
|
||||
use vectorcraft_doc::{Document, Unit};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use aci::{aci_rgb, nearest_aci};
|
||||
|
||||
/// The DXF version a file is written for (the `$ACADVER` it declares).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum DxfVersion {
|
||||
R12,
|
||||
R13,
|
||||
R14,
|
||||
R2000,
|
||||
R2004,
|
||||
R2007,
|
||||
R2010,
|
||||
R2013,
|
||||
#[default]
|
||||
R2018,
|
||||
}
|
||||
|
||||
impl DxfVersion {
|
||||
/// Newest first, as version menus list them.
|
||||
pub const ALL: [Self; 9] = [Self::R2018, Self::R2013, Self::R2010, Self::R2007, Self::R2004, Self::R2000, Self::R14, Self::R13, Self::R12];
|
||||
|
||||
/// The `version` param value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::R12 => "R12",
|
||||
Self::R13 => "R13",
|
||||
Self::R14 => "R14",
|
||||
Self::R2000 => "2000",
|
||||
Self::R2004 => "2004",
|
||||
Self::R2007 => "2007",
|
||||
Self::R2010 => "2010",
|
||||
Self::R2013 => "2013",
|
||||
Self::R2018 => "2018",
|
||||
}
|
||||
}
|
||||
|
||||
/// The releases that write this version (menus).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::R12 => "R12",
|
||||
Self::R13 => "R13",
|
||||
Self::R14 => "R14",
|
||||
Self::R2000 => "2000–2002",
|
||||
Self::R2004 => "2004–2006",
|
||||
Self::R2007 => "2007–2009",
|
||||
Self::R2010 => "2010–2012",
|
||||
Self::R2013 => "2013–2017",
|
||||
Self::R2018 => "2018",
|
||||
}
|
||||
}
|
||||
|
||||
/// The `$ACADVER` header value.
|
||||
pub fn acadver(self) -> &'static str {
|
||||
match self {
|
||||
Self::R12 => "AC1009",
|
||||
Self::R13 => "AC1012",
|
||||
Self::R14 => "AC1014",
|
||||
Self::R2000 => "AC1015",
|
||||
Self::R2004 => "AC1018",
|
||||
Self::R2007 => "AC1021",
|
||||
Self::R2010 => "AC1024",
|
||||
Self::R2013 => "AC1027",
|
||||
Self::R2018 => "AC1032",
|
||||
}
|
||||
}
|
||||
|
||||
/// A version by id (`2018`, `R2018`, `r12`) or `$ACADVER` (`AC1032`), any case.
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
let s = s.trim();
|
||||
Self::ALL.into_iter().find(|v| {
|
||||
let id = v.id();
|
||||
s.eq_ignore_ascii_case(id) || s.eq_ignore_ascii_case(v.acadver()) || s.strip_prefix(['R', 'r']).is_some_and(|y| y == id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Objects carry handles and subclass markers; splines, hatches and images exist.
|
||||
fn handles(self) -> bool {
|
||||
self >= Self::R13
|
||||
}
|
||||
/// Lightweight polylines.
|
||||
fn lwpolyline(self) -> bool {
|
||||
self >= Self::R14
|
||||
}
|
||||
/// Lineweights, layouts, plot styles and long symbol names.
|
||||
fn r2000(self) -> bool {
|
||||
self >= Self::R2000
|
||||
}
|
||||
/// True colour and transparency.
|
||||
fn true_color(self) -> bool {
|
||||
self >= Self::R2004
|
||||
}
|
||||
/// Text is UTF-8 (earlier versions escape what isn't ASCII as `\U+XXXX`).
|
||||
fn utf8(self) -> bool {
|
||||
self >= Self::R2007
|
||||
}
|
||||
}
|
||||
|
||||
/// How many colours entities may use: indexed colours of the 256-colour palette, or true colour
|
||||
/// (with the nearest index for apps that read only the index).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ColorDepth {
|
||||
/// Indices 1–8: the seven standard colours and dark grey.
|
||||
Aci8,
|
||||
/// Indices 1–9, orange (30) and the six greys (250–255).
|
||||
Aci16,
|
||||
/// Every index 1–255.
|
||||
Aci256,
|
||||
/// 24-bit colour (DXF 2004 and later).
|
||||
#[default]
|
||||
True,
|
||||
}
|
||||
|
||||
impl ColorDepth {
|
||||
pub const ALL: [Self; 4] = [Self::Aci8, Self::Aci16, Self::Aci256, Self::True];
|
||||
|
||||
/// The `colors` param value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Aci8 => "8",
|
||||
Self::Aci16 => "16",
|
||||
Self::Aci256 => "256",
|
||||
Self::True => "true",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Aci8 => "8 Colors",
|
||||
Self::Aci16 => "16 Colors",
|
||||
Self::Aci256 => "256 Colors",
|
||||
Self::True => "True Colors",
|
||||
}
|
||||
}
|
||||
|
||||
/// A depth by id (`8`, `16`, `256`, `true`; `truecolor` and `24` also read).
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
match s.as_str() {
|
||||
"truecolor" | "truecolors" | "true colors" | "24" => Some(Self::True),
|
||||
_ => Self::ALL.into_iter().find(|d| d.id() == s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file format placed images are written in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum RasterFormat {
|
||||
#[default]
|
||||
Png,
|
||||
/// No transparency: transparent pixels show white.
|
||||
Jpeg,
|
||||
}
|
||||
|
||||
impl RasterFormat {
|
||||
pub const ALL: [Self; 2] = [Self::Png, Self::Jpeg];
|
||||
|
||||
/// The `rasterFormat` param value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "png",
|
||||
Self::Jpeg => "jpeg",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "PNG",
|
||||
Self::Jpeg => "JPEG",
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension the image files get.
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "png",
|
||||
Self::Jpeg => "jpg",
|
||||
}
|
||||
}
|
||||
|
||||
/// A format by id (`png`, `jpeg`, `jpg`), any case.
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
let s = s.trim();
|
||||
if s.eq_ignore_ascii_case("jpg") {
|
||||
return Some(Self::Jpeg);
|
||||
}
|
||||
Self::ALL.into_iter().find(|f| s.eq_ignore_ascii_case(f.id()))
|
||||
}
|
||||
}
|
||||
|
||||
/// What the export favours when DXF can't hold an object's look as it is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Preserve {
|
||||
/// Type becomes glyph outlines; strokes a plain CAD line can't draw (aligned inside or
|
||||
/// outside, width profiles, arrowheads, fitted dashes) become their filled outlines; brush
|
||||
/// strokes become their brush art.
|
||||
#[default]
|
||||
Appearance,
|
||||
/// Type stays text; every stroke is a line with its weight and dashes.
|
||||
Editability,
|
||||
}
|
||||
|
||||
impl Preserve {
|
||||
pub const ALL: [Self; 2] = [Self::Appearance, Self::Editability];
|
||||
|
||||
/// The `preserve` param value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "appearance",
|
||||
Self::Editability => "editability",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "Preserve Appearance",
|
||||
Self::Editability => "Maximize Editability",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The DXF export settings.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DxfOptions {
|
||||
pub version: DxfVersion,
|
||||
/// The unit of the art that `scale` drawing units stand for.
|
||||
pub unit: Unit,
|
||||
/// Drawing units per `unit` (1 mm = `scale` units).
|
||||
pub scale: f64,
|
||||
/// Lineweights grow with `scale` (else they keep their printed width).
|
||||
pub scale_lineweights: bool,
|
||||
pub colors: ColorDepth,
|
||||
pub raster: RasterFormat,
|
||||
pub preserve: Preserve,
|
||||
/// Alter Paths for Appearance: every stroke is written as the filled area it paints.
|
||||
pub alter_paths: bool,
|
||||
/// Type is written as glyph outlines (always with [`Preserve::Appearance`]).
|
||||
pub outline_text: bool,
|
||||
/// The drawing's origin is this region's bottom-left corner (document space).
|
||||
pub region: Rect,
|
||||
/// Leave out objects entirely outside `region` (one drawing per artboard).
|
||||
pub crop: bool,
|
||||
}
|
||||
|
||||
impl Default for DxfOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: DxfVersion::default(),
|
||||
unit: Unit::Millimeters,
|
||||
scale: 1.0,
|
||||
scale_lineweights: false,
|
||||
colors: ColorDepth::default(),
|
||||
raster: RasterFormat::default(),
|
||||
preserve: Preserve::default(),
|
||||
alter_paths: false,
|
||||
outline_text: false,
|
||||
region: Rect::ZERO,
|
||||
crop: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An image file a drawing links to, to be written next to it under `name`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DxfImage {
|
||||
pub name: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A written drawing.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DxfOutput {
|
||||
pub bytes: Vec<u8>,
|
||||
/// The image files the drawing links to (by name, next to it).
|
||||
pub images: Vec<DxfImage>,
|
||||
/// What was approximated or left out.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The smallest and largest `scale` (drawing units per unit).
|
||||
pub const SCALE_RANGE: std::ops::RangeInclusive<f64> = 1e-6..=1e6;
|
||||
|
||||
/// Write `doc` as a DXF drawing with `opts`. Live geometry effects are applied first; template
|
||||
/// layers, guides and hidden objects are left out.
|
||||
pub fn export(doc: &Document, opts: &DxfOptions) -> Result<DxfOutput, String> {
|
||||
if !(opts.scale.is_finite() && SCALE_RANGE.contains(&opts.scale)) {
|
||||
return Err(format!("scale must be a number from {} to {}, not {}", SCALE_RANGE.start(), SCALE_RANGE.end(), opts.scale));
|
||||
}
|
||||
let r = opts.region;
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
||||
return Err("the export region is not finite".into());
|
||||
}
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
Ok(scene::Scene::new(doc, opts).run())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,854 @@
|
|||
//! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint
|
||||
//! order. Fills are solid hatches (outlines in R12), strokes polylines and cubic splines with a
|
||||
//! lineweight and dashes, or filled outlines where the look needs them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
|
||||
use kurbo::PathEl;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, TextKind, TextObject, Unit};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
|
||||
use crate::aci::nearest_aci;
|
||||
use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed};
|
||||
use crate::{ColorDepth, DxfImage, DxfOptions, DxfOutput, Preserve, RasterFormat};
|
||||
|
||||
/// Curves are flattened to within this distance (points) where DXF needs straight segments
|
||||
/// (hatch boundaries, R12 polylines).
|
||||
const FLATNESS: f64 = 0.05;
|
||||
/// How deep symbols and brush art may nest in one another (deeper art is left out).
|
||||
const MAX_NEST: u32 = 8;
|
||||
/// Cap height as a fraction of the type size: the height of CAD text is that of its capitals.
|
||||
const CAP_HEIGHT: f64 = 0.7;
|
||||
/// Longest text value older DXF versions read.
|
||||
const MAX_TEXT: usize = 250;
|
||||
/// The lineweights DXF knows (hundredths of a millimetre).
|
||||
const LINEWEIGHTS: [i32; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
|
||||
|
||||
const NO_TRUE_COLOR: &str = "true colour needs DXF 2004 or later: colours are written as the nearest of the 256 indexed colours";
|
||||
const NO_TRANSPARENCY: &str = "transparency needs DXF 2004 or later: transparent objects are written opaque";
|
||||
const NO_FILLS: &str = "DXF R12 has no fills: filled areas are written as their outlines";
|
||||
const NO_IMAGES: &str = "DXF R12 has no images: placed images are left out";
|
||||
const WIDE_LINES: &str =
|
||||
"lines wider than 2.11 mm (the widest lineweight) are written at 2.11 mm: Alter Paths for Appearance writes their true width";
|
||||
const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)";
|
||||
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
|
||||
const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)";
|
||||
|
||||
/// One segment of a subpath, cubics for every curve.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Seg {
|
||||
Line(Point),
|
||||
Cubic(Point, Point, Point),
|
||||
}
|
||||
|
||||
impl Seg {
|
||||
fn end(self) -> Point {
|
||||
match self {
|
||||
Seg::Line(p) | Seg::Cubic(_, _, p) => p,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A subpath: its start, its segments and whether it closes.
|
||||
struct Sub {
|
||||
start: Point,
|
||||
segs: Vec<Seg>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
/// The subpaths of `bp` (quadratics raised to cubics).
|
||||
fn subpaths(bp: &BezPath) -> Vec<Sub> {
|
||||
let mut out = vec![];
|
||||
let mut cur: Option<Sub> = None;
|
||||
let mut last = Point::ZERO;
|
||||
for el in bp.elements() {
|
||||
let seg = match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
out.extend(cur.replace(Sub { start: p, segs: vec![], closed: false }));
|
||||
last = p;
|
||||
continue;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if let Some(mut s) = cur.take() {
|
||||
s.closed = true;
|
||||
last = s.start;
|
||||
out.push(s);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
PathEl::LineTo(p) => Seg::Line(p),
|
||||
PathEl::QuadTo(q, p) => Seg::Cubic(last + (q - last) * (2.0 / 3.0), p + (q - p) * (2.0 / 3.0), p),
|
||||
PathEl::CurveTo(a, b, p) => Seg::Cubic(a, b, p),
|
||||
};
|
||||
last = seg.end();
|
||||
cur.get_or_insert_with(|| Sub { start: last, segs: vec![], closed: false }).segs.push(seg);
|
||||
}
|
||||
out.extend(cur);
|
||||
out
|
||||
}
|
||||
|
||||
/// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped.
|
||||
fn polygons(bp: &BezPath) -> Vec<Vec<Point>> {
|
||||
let mut out: Vec<Vec<Point>> = vec![];
|
||||
let mut cur: Vec<Point> = vec![];
|
||||
let push = |p: Point, cur: &mut Vec<Point>| {
|
||||
if cur.last().is_none_or(|q| q.distance(p) > 1e-9) {
|
||||
cur.push(p);
|
||||
}
|
||||
};
|
||||
kurbo::flatten(bp.iter(), FLATNESS, |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
push(p, &mut cur);
|
||||
}
|
||||
PathEl::LineTo(p) => push(p, &mut cur),
|
||||
PathEl::ClosePath => out.push(std::mem::take(&mut cur)),
|
||||
_ => {}
|
||||
});
|
||||
out.push(cur);
|
||||
for poly in &mut out {
|
||||
if poly.len() > 1 && poly.first().zip(poly.last()).is_some_and(|(a, b)| a.distance(*b) <= 1e-9) {
|
||||
poly.pop();
|
||||
}
|
||||
}
|
||||
out.retain(|p| p.len() >= 2);
|
||||
out
|
||||
}
|
||||
|
||||
/// A DXF symbol name (layer, text style) from `name`: from DXF 2000 up to 255 characters
|
||||
/// without `<>/\":;?*|=`` ` ``; before, up to 31 letters, digits, `$`, `-` and `_`, in capitals.
|
||||
fn symbol_name(name: &str, long: bool) -> String {
|
||||
let s: String = if long {
|
||||
let bad = |c: char| c.is_control() || "<>/\\\":;?*|=`".contains(c);
|
||||
name.trim().chars().map(|c| if bad(c) { '_' } else { c }).take(255).collect()
|
||||
} else {
|
||||
name.trim().chars().map(|c| if c.is_ascii_alphanumeric() || "$-_".contains(c) { c.to_ascii_uppercase() } else { '_' }).take(31).collect()
|
||||
};
|
||||
s.trim().to_string()
|
||||
}
|
||||
|
||||
/// `base` made unique among `taken` (any case) by `_2`, `_3`…, within `max` characters.
|
||||
fn unique(base: &str, taken: &[&str], max: usize) -> String {
|
||||
let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() };
|
||||
let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n));
|
||||
if free(&base) {
|
||||
return base;
|
||||
}
|
||||
(2..)
|
||||
.map(|i| {
|
||||
let suffix = format!("_{i}");
|
||||
let keep: String = base.chars().take(max.saturating_sub(suffix.len())).collect();
|
||||
format!("{keep}{suffix}")
|
||||
})
|
||||
.find(|n| free(n))
|
||||
.unwrap_or(base)
|
||||
}
|
||||
|
||||
fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
||||
}
|
||||
|
||||
/// The layer flags art inherits from the layers around it.
|
||||
#[derive(Clone, Copy)]
|
||||
struct LayerFlags {
|
||||
off: bool,
|
||||
locked: bool,
|
||||
plot: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct Scene<'a> {
|
||||
doc: &'a Document,
|
||||
o: &'a DxfOptions,
|
||||
/// Document space → drawing units (y up, origin at the region's bottom-left).
|
||||
to_dxf: Affine,
|
||||
/// Drawing units per point.
|
||||
k: f64,
|
||||
/// The entities written so far.
|
||||
e: Codes,
|
||||
next: u32,
|
||||
layers: Vec<Layer>,
|
||||
/// The DXF layer art goes on, and its flags.
|
||||
layer: String,
|
||||
flags: LayerFlags,
|
||||
linetypes: Vec<LineType>,
|
||||
/// (style name, font family).
|
||||
styles: Vec<(String, String)>,
|
||||
images: Vec<ImageDef>,
|
||||
files: Vec<DxfImage>,
|
||||
/// Image keys → index into `images` (`None`: left out).
|
||||
by_key: HashMap<String, Option<usize>>,
|
||||
warnings: Vec<String>,
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
extents: Option<[f64; 4]>,
|
||||
/// The opacity of the containers around the current object.
|
||||
opacity: f32,
|
||||
/// Symbols and brush art being written inside one another.
|
||||
nest: u32,
|
||||
}
|
||||
|
||||
impl<'a> Scene<'a> {
|
||||
pub fn new(doc: &'a Document, o: &'a DxfOptions) -> Self {
|
||||
let k = o.scale / o.unit.points();
|
||||
let r = o.region;
|
||||
Self {
|
||||
doc,
|
||||
o,
|
||||
to_dxf: Affine::new([k, 0.0, 0.0, -k, -r.x0 * k, r.y1 * k]),
|
||||
k,
|
||||
e: Codes::new(o.version),
|
||||
next: fixed::FIRST_FREE,
|
||||
layers: vec![],
|
||||
layer: "0".into(),
|
||||
flags: LayerFlags { off: false, locked: false, plot: true },
|
||||
linetypes: vec![],
|
||||
styles: vec![],
|
||||
images: vec![],
|
||||
files: vec![],
|
||||
by_key: HashMap::new(),
|
||||
warnings: vec![],
|
||||
brushes: None,
|
||||
extents: None,
|
||||
opacity: 1.0,
|
||||
nest: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(mut self) -> DxfOutput {
|
||||
if self.o.colors == ColorDepth::True && !self.o.version.true_color() {
|
||||
self.warn(NO_TRUE_COLOR);
|
||||
}
|
||||
let doc = self.doc;
|
||||
for l in &doc.layers {
|
||||
self.node(l, false);
|
||||
}
|
||||
let r = self.o.region;
|
||||
let limits = [0.0, 0.0, r.width() * self.k, r.height() * self.k];
|
||||
let one = (self.o.scale - 1.0).abs() < 1e-12;
|
||||
let insunits = match self.o.unit {
|
||||
Unit::Inches if one => 1,
|
||||
Unit::Feet | Unit::FeetInches if one => 2,
|
||||
Unit::Millimeters if one => 4,
|
||||
Unit::Centimeters if one => 5,
|
||||
Unit::Meters if one => 6,
|
||||
Unit::Yards if one => 10,
|
||||
_ => 0,
|
||||
};
|
||||
let drawing = Drawing {
|
||||
version: self.o.version,
|
||||
layers: self.layers,
|
||||
linetypes: self.linetypes,
|
||||
styles: self.styles,
|
||||
images: self.images,
|
||||
entities: self.e.into_string(),
|
||||
extents: self.extents.unwrap_or(limits),
|
||||
limits,
|
||||
insunits,
|
||||
metric: matches!(self.o.unit, Unit::Millimeters | Unit::Centimeters | Unit::Meters),
|
||||
next: self.next,
|
||||
};
|
||||
DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings }
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self) -> Handle {
|
||||
let h = Handle(self.next);
|
||||
self.next = self.next.saturating_add(1);
|
||||
h
|
||||
}
|
||||
|
||||
/// A document point in drawing units, counted in the extents.
|
||||
fn pt(&mut self, p: Point) -> Point {
|
||||
let q = self.to_dxf * p;
|
||||
let e = self.extents.get_or_insert([q.x, q.y, q.x, q.y]);
|
||||
*e = [e[0].min(q.x), e[1].min(q.y), e[2].max(q.x), e[3].max(q.y)];
|
||||
q
|
||||
}
|
||||
|
||||
fn node(&mut self, n: &Node, force: bool) {
|
||||
if let NodeKind::Layer { template: true, .. } = n.kind {
|
||||
return;
|
||||
}
|
||||
// Hidden layers are written, switched off.
|
||||
let layer = n.is_layer();
|
||||
if !force && !n.visible && !layer {
|
||||
return;
|
||||
}
|
||||
if self.o.crop && !layer {
|
||||
match n.visual_bounds() {
|
||||
Some(b) if !overlaps(b, self.o.region) => return,
|
||||
None if !n.is_container() => return,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.note_losses(n);
|
||||
let opacity = self.opacity;
|
||||
self.opacity *= n.opacity.clamp(0.0, 1.0);
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, printable, clip, .. } => {
|
||||
let outer = (self.layer.clone(), self.flags);
|
||||
self.flags =
|
||||
LayerFlags { off: self.flags.off || !n.visible, locked: self.flags.locked || n.locked, plot: self.flags.plot && *printable };
|
||||
self.layer = self.add_layer(n);
|
||||
self.children(children, *clip);
|
||||
(self.layer, self.flags) = outer;
|
||||
}
|
||||
NodeKind::Group { children, clip } => self.children(children, *clip),
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
if !*guide {
|
||||
self.shape(n, &path.to_bezpath(), *rule);
|
||||
}
|
||||
}
|
||||
NodeKind::Compound { children, rule } => {
|
||||
let mut bp = BezPath::new();
|
||||
for p in children.iter().filter_map(|c| c.path_data()) {
|
||||
bp.extend(p.to_bezpath());
|
||||
}
|
||||
self.shape(n, &bp, *rule);
|
||||
}
|
||||
NodeKind::Text(t) => self.text(n, t),
|
||||
NodeKind::Image(im) => self.image(im),
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
{
|
||||
let mut art = (*sym.art).clone();
|
||||
art.transform(*xf, false);
|
||||
self.nest += 1;
|
||||
self.node(&art, true);
|
||||
self.nest -= 1;
|
||||
}
|
||||
}
|
||||
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
let g = vectorcraft_doc::live::expand_deep(n, None);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(c, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.opacity = opacity;
|
||||
}
|
||||
|
||||
/// A container's children; a clipping container's first child is its clipping path, which
|
||||
/// paints its fills below the others and its strokes over them.
|
||||
fn children(&mut self, children: &[std::sync::Arc<Node>], clip: bool) {
|
||||
let Some((first, rest)) = children.split_first().filter(|_| clip) else {
|
||||
children.iter().for_each(|c| self.node(c, false));
|
||||
return;
|
||||
};
|
||||
self.warn(CLIPPING);
|
||||
let paint = first.clip_paint();
|
||||
if let Some(f) = &paint.fill {
|
||||
self.node(f, false);
|
||||
}
|
||||
rest.iter().for_each(|c| self.node(c, false));
|
||||
if let Some(s) = &paint.stroke {
|
||||
self.node(s, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Warn about what DXF can't hold in `n`'s look.
|
||||
fn note_losses(&mut self, n: &Node) {
|
||||
if n.blend != vectorcraft_color::BlendMode::Normal {
|
||||
self.warn("blending modes are left out");
|
||||
}
|
||||
if n.mask.as_ref().is_some_and(|m| !m.disabled) {
|
||||
self.warn("opacity masks are left out");
|
||||
}
|
||||
let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id));
|
||||
if raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| raster(i.effects())) {
|
||||
self.warn("raster effects (shadows, glows, blurs, feathering) are left out");
|
||||
}
|
||||
}
|
||||
|
||||
/// A DXF layer for document layer `n`, with a name no other layer has.
|
||||
fn add_layer(&mut self, n: &Node) -> String {
|
||||
let long = self.o.version.r2000();
|
||||
let base = symbol_name(n.name.as_deref().unwrap_or("Layer"), long);
|
||||
let mut taken: Vec<&str> = self.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
taken.extend(["0", "Defpoints"]);
|
||||
let name = unique(&base, &taken, if long { 255 } else { 31 });
|
||||
let aci = match n.kind {
|
||||
NodeKind::Layer { color, .. } => nearest_aci(color.rgb(), ColorDepth::Aci256),
|
||||
_ => 7,
|
||||
};
|
||||
let f = self.flags;
|
||||
self.layers.push(Layer { name: name.clone(), aci, off: f.off, locked: f.locked, plot: f.plot });
|
||||
name
|
||||
}
|
||||
|
||||
/// One colour for a paint: a solid colour, the average of a gradient's stops, or a pattern's
|
||||
/// first solid fill.
|
||||
fn paint_rgb(&mut self, p: &Paint) -> Option<[u8; 3]> {
|
||||
let rgb = |c: &vectorcraft_color::Color| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
};
|
||||
match p {
|
||||
Paint::None => None,
|
||||
Paint::Solid { color, .. } => Some(rgb(color)),
|
||||
Paint::Gradient(g) => {
|
||||
self.warn("gradients are written as one colour, the average of their stops");
|
||||
let stops = &g.gradient.stops;
|
||||
let n = stops.len().max(1) as f64;
|
||||
let sum = stops.iter().map(|s| rgb(&s.color)).fold([0.0; 3], |a, c| [0, 1, 2].map(|i| a[i] + f64::from(c[i])));
|
||||
Some(sum.map(|v| (v / n).round().clamp(0.0, 255.0) as u8))
|
||||
}
|
||||
Paint::Pattern { pattern, .. } => {
|
||||
self.warn("pattern fills are written as one colour");
|
||||
let first = self.doc.pattern(pattern).and_then(|def| def.art.iter().find_map(|a| first_solid(a)));
|
||||
Some(first.map_or([128; 3], |c| rgb(&c)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity style of colour `rgb` at `opacity` (times the containers'), with an optional
|
||||
/// stroke weight (points) and linetype.
|
||||
fn style(&mut self, rgb: [u8; 3], opacity: f32, weight: Option<f64>, linetype: Option<String>) -> EntityStyle {
|
||||
let v = self.o.version;
|
||||
let opacity = (self.opacity * opacity).clamp(0.0, 1.0);
|
||||
let alpha = if opacity >= 0.998 {
|
||||
None
|
||||
} else if v.true_color() {
|
||||
Some((opacity * 255.0).round() as u8)
|
||||
} else {
|
||||
self.warn(NO_TRANSPARENCY);
|
||||
None
|
||||
};
|
||||
EntityStyle {
|
||||
aci: nearest_aci(rgb, self.o.colors),
|
||||
rgb: (self.o.colors == ColorDepth::True).then(|| u32::from_be_bytes([0, rgb[0], rgb[1], rgb[2]])),
|
||||
lineweight: weight.filter(|_| v.r2000()).map(|w| self.lineweight(w)),
|
||||
linetype,
|
||||
alpha,
|
||||
}
|
||||
}
|
||||
|
||||
/// The DXF lineweight nearest to `width` points (scaled with the drawing when asked).
|
||||
fn lineweight(&mut self, width: f64) -> i32 {
|
||||
let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 };
|
||||
let hundredths = width * 2540.0 / 72.0 * scale;
|
||||
let max = LINEWEIGHTS[LINEWEIGHTS.len() - 1];
|
||||
if hundredths > f64::from(max) + 5.0 {
|
||||
self.warn(WIDE_LINES);
|
||||
}
|
||||
LINEWEIGHTS.into_iter().min_by(|a, b| (f64::from(*a) - hundredths).abs().total_cmp(&(f64::from(*b) - hundredths).abs())).unwrap_or(max)
|
||||
}
|
||||
|
||||
/// A linetype for dash pattern `d` (one per distinct pattern).
|
||||
fn linetype(&mut self, d: &Dash) -> String {
|
||||
let mut lengths: Vec<f64> = d.pattern.iter().map(|v| v * self.k).collect();
|
||||
if lengths.len() % 2 == 1 {
|
||||
lengths.extend(lengths.clone());
|
||||
}
|
||||
let pattern: Vec<f64> = lengths.iter().enumerate().map(|(i, v)| if i % 2 == 0 { *v } else { -v }).collect();
|
||||
let same = |l: &LineType| l.pattern.len() == pattern.len() && l.pattern.iter().zip(&pattern).all(|(a, b)| (a - b).abs() < 1e-9);
|
||||
if let Some(l) = self.linetypes.iter().find(|l| same(l)) {
|
||||
return l.name.clone();
|
||||
}
|
||||
let name = format!("DASHED{}", self.linetypes.len() + 1);
|
||||
self.linetypes.push(LineType { name: name.clone(), pattern });
|
||||
name
|
||||
}
|
||||
|
||||
/// A path's fills and strokes, in paint order.
|
||||
fn shape(&mut self, n: &Node, bp: &BezPath, rule: FillRule) {
|
||||
if bp.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
for item in &n.appearance.items {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) if f.visible => {
|
||||
if let Some(rgb) = self.paint_rgb(&f.paint) {
|
||||
let style = self.style(rgb, f.opacity, None, None);
|
||||
self.area(bp, rule, &style, false);
|
||||
}
|
||||
}
|
||||
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && st.width.is_finite() => self.stroke(bp, rule, st),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke(&mut self, bp: &BezPath, rule: FillRule, st: &StrokeLayer) {
|
||||
let appearance = self.o.preserve == Preserve::Appearance;
|
||||
if appearance
|
||||
&& st.brush.is_some()
|
||||
&& self.nest < MAX_NEST
|
||||
&& let Some(art) = self.brush_art(bp, st)
|
||||
{
|
||||
let opacity = self.opacity;
|
||||
self.opacity *= st.opacity.clamp(0.0, 1.0);
|
||||
self.nest += 1;
|
||||
art.iter().for_each(|piece| self.node(piece, true));
|
||||
self.nest -= 1;
|
||||
self.opacity = opacity;
|
||||
return;
|
||||
}
|
||||
let Some(rgb) = self.paint_rgb(&st.paint) else { return };
|
||||
let plain = stroke::is_plain(st);
|
||||
if self.o.alter_paths || (appearance && !plain) {
|
||||
let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st);
|
||||
let style = self.style(rgb, st.opacity, None, None);
|
||||
self.area(®ion.to_bezpath(), FillRule::NonZero, &style, true);
|
||||
return;
|
||||
}
|
||||
if !plain {
|
||||
self.warn(STROKE_EXTRAS);
|
||||
}
|
||||
let linetype = st.dash.as_ref().filter(|d| d.is_dashed()).map(|d| self.linetype(d));
|
||||
let style = self.style(rgb, st.opacity, Some(st.width), linetype);
|
||||
self.lines(bp, &style, st.width);
|
||||
}
|
||||
|
||||
fn brush_art(&mut self, bp: &BezPath, st: &StrokeLayer) -> Option<Vec<Node>> {
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?;
|
||||
Some(vectorcraft_brush::stroke_pieces(b, bp, st))
|
||||
}
|
||||
|
||||
/// A filled area as a solid hatch (R12: its outlines). `clean`: the path has no overlaps, so
|
||||
/// the hatch's even-odd rule reads it as it is.
|
||||
fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) {
|
||||
if !self.o.version.handles() {
|
||||
self.warn(NO_FILLS);
|
||||
self.lines(bp, style, 0.0);
|
||||
return;
|
||||
}
|
||||
let several = bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() > 1;
|
||||
let normalized = (rule == FillRule::NonZero && several && !clean)
|
||||
.then(|| vectorcraft_pathops::try_normalize(&PathData::from_bezpath(bp), rule).ok())
|
||||
.flatten()
|
||||
.map(|p| p.to_bezpath());
|
||||
let loops: Vec<Vec<Point>> = polygons(normalized.as_ref().unwrap_or(bp)).into_iter().filter(|p| p.len() >= 3).collect();
|
||||
if loops.is_empty() {
|
||||
return;
|
||||
}
|
||||
let h = self.handle();
|
||||
let layer = self.layer.clone();
|
||||
self.e.entity("HATCH", h, fixed::MODEL_RECORD, &layer, style, "AcDbHatch");
|
||||
self.e.xyz(10, 0.0, 0.0);
|
||||
self.e.normal();
|
||||
self.e.raw(2, "SOLID");
|
||||
self.e.int(70, 1);
|
||||
self.e.int(71, 0);
|
||||
self.e.int(91, loops.len() as i64);
|
||||
for l in &loops {
|
||||
// An external polyline boundary, closed, without bulges.
|
||||
self.e.int(92, 3);
|
||||
self.e.int(72, 0);
|
||||
self.e.int(73, 1);
|
||||
self.e.int(93, l.len() as i64);
|
||||
for p in l {
|
||||
let q = self.pt(*p);
|
||||
self.e.xy(10, q.x, q.y);
|
||||
}
|
||||
self.e.int(97, 0);
|
||||
}
|
||||
self.e.int(75, 0);
|
||||
self.e.int(76, 1);
|
||||
self.e.int(98, 0);
|
||||
}
|
||||
|
||||
/// Path `bp` as lines: polylines for straight subpaths (and for curves in R12, flattened),
|
||||
/// cubic splines for curved ones. `width`: the stroke weight, which DXF before 2000 gives
|
||||
/// polylines as a width.
|
||||
fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) {
|
||||
let v = self.o.version;
|
||||
let width = (!v.r2000() && width > 0.0).then_some(width * self.k);
|
||||
for sub in subpaths(bp) {
|
||||
if sub.segs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if sub.segs.iter().all(|s| matches!(s, Seg::Line(_))) {
|
||||
let mut pts: Vec<Point> = std::iter::once(sub.start).chain(sub.segs.iter().map(|s| s.end())).collect();
|
||||
if sub.closed && pts.len() > 2 && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
|
||||
pts.pop();
|
||||
}
|
||||
self.polyline(&pts, sub.closed, style, width);
|
||||
} else if v.handles() {
|
||||
self.spline(&sub, style);
|
||||
} else {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to(sub.start);
|
||||
for s in &sub.segs {
|
||||
match *s {
|
||||
Seg::Line(p) => path.line_to(p),
|
||||
Seg::Cubic(a, b, p) => path.curve_to(a, b, p),
|
||||
}
|
||||
}
|
||||
for pts in polygons(&path) {
|
||||
self.polyline(&pts, sub.closed, style, width);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A polyline through `pts` (document space): lightweight from R14, else a polyline with a
|
||||
/// vertex entity per point.
|
||||
fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option<f64>) {
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let pts: Vec<Point> = pts.iter().map(|p| self.pt(*p)).collect();
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
let flags = i64::from(closed);
|
||||
if self.o.version.lwpolyline() {
|
||||
self.e.entity("LWPOLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbPolyline");
|
||||
self.e.int(90, pts.len() as i64);
|
||||
self.e.int(70, flags);
|
||||
if let Some(w) = width {
|
||||
self.e.num(43, w);
|
||||
}
|
||||
for p in &pts {
|
||||
self.e.xy(10, p.x, p.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.e.entity("POLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDb2dPolyline");
|
||||
self.e.int(66, 1);
|
||||
self.e.xyz(10, 0.0, 0.0);
|
||||
self.e.int(70, flags);
|
||||
if let Some(w) = width {
|
||||
self.e.num(40, w);
|
||||
self.e.num(41, w);
|
||||
}
|
||||
for p in &pts {
|
||||
let vh = self.handle();
|
||||
self.e.entity("VERTEX", vh, h, &layer, style, "AcDbVertex");
|
||||
if self.o.version.handles() {
|
||||
self.e.raw(100, "AcDb2dVertex");
|
||||
}
|
||||
self.e.xyz(10, p.x, p.y);
|
||||
}
|
||||
let end = self.handle();
|
||||
self.e.entity("SEQEND", end, h, &layer, style, "");
|
||||
}
|
||||
|
||||
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
|
||||
/// passes through every anchor (straight segments become straight cubics).
|
||||
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
|
||||
let end = sub.segs.last().map_or(sub.start, |s| s.end());
|
||||
let closing = (sub.closed && end.distance(sub.start) > 1e-9).then_some(Seg::Line(sub.start));
|
||||
let mut ctrl = vec![sub.start];
|
||||
let mut last = sub.start;
|
||||
for seg in sub.segs.iter().copied().chain(closing) {
|
||||
match seg {
|
||||
Seg::Line(p) => ctrl.extend([last.lerp(p, 1.0 / 3.0), last.lerp(p, 2.0 / 3.0), p]),
|
||||
Seg::Cubic(a, b, p) => ctrl.extend([a, b, p]),
|
||||
}
|
||||
last = seg.end();
|
||||
}
|
||||
let n = (ctrl.len() - 1) / 3;
|
||||
let mut knots = vec![0.0; 4];
|
||||
for i in 1..n {
|
||||
knots.extend([i as f64; 3]);
|
||||
}
|
||||
knots.extend([n as f64; 4]);
|
||||
let ctrl: Vec<Point> = ctrl.iter().map(|p| self.pt(*p)).collect();
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
self.e.entity("SPLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbSpline");
|
||||
self.e.normal();
|
||||
// Planar.
|
||||
self.e.int(70, 8);
|
||||
self.e.int(71, 3);
|
||||
self.e.int(72, knots.len() as i64);
|
||||
self.e.int(73, ctrl.len() as i64);
|
||||
self.e.int(74, 0);
|
||||
for k in knots {
|
||||
self.e.num(40, k);
|
||||
}
|
||||
for p in ctrl {
|
||||
self.e.xyz(10, p.x, p.y);
|
||||
}
|
||||
}
|
||||
|
||||
/// Type: glyph outlines (Preserve Appearance, Outline Text, type on a path), else one text
|
||||
/// entity per run of each line.
|
||||
fn text(&mut self, n: &Node, t: &TextObject) {
|
||||
let outline = self.o.preserve == Preserve::Appearance || self.o.outline_text || matches!(t.kind, TextKind::OnPath { .. });
|
||||
if outline {
|
||||
if let Some(o) = vectorcraft_effects::outline_text(n) {
|
||||
self.node(&o, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !n.appearance.items.is_empty() {
|
||||
self.warn(TYPE_ITEMS);
|
||||
}
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let plain = t.plain_text();
|
||||
let xf = self.to_dxf * t.xf;
|
||||
let [a, b, c, d, _, _] = xf.as_coeffs();
|
||||
// Along and across the baseline, in drawing units per text-space unit.
|
||||
let along = a.hypot(b);
|
||||
if along <= 1e-12 {
|
||||
return;
|
||||
}
|
||||
let across = (a * d - b * c).abs() / along;
|
||||
let angle = b.atan2(a).to_degrees();
|
||||
for line in &layout.lines {
|
||||
let Some(glyphs) = layout.glyphs.get(line.glyph_start..line.glyph_end) else { continue };
|
||||
for run in glyphs.chunk_by(|x, y| x.run == y.run) {
|
||||
let Some(first) = run.first() else { continue };
|
||||
let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte);
|
||||
let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);
|
||||
let Some(s) = plain.get(lo..hi).map(str::trim_end).filter(|s| !s.trim().is_empty()) else { continue };
|
||||
let Some(cs) = t.runs.get(first.run).map(|r| &r.style) else { continue };
|
||||
let paint = if cs.fill.is_none() { &cs.stroke } else { &cs.fill };
|
||||
let Some(rgb) = self.paint_rgb(paint) else { continue };
|
||||
let style = self.style(rgb, 1.0, None, None);
|
||||
let text_style = self.text_style(&cs.font_family);
|
||||
let height = cs.size * cs.v_scale / 100.0 * CAP_HEIGHT * across;
|
||||
let width = if cs.v_scale > 0.0 { cs.h_scale / cs.v_scale } else { 1.0 };
|
||||
let origin = run.iter().map(|g| g.origin).min_by(|p, q| p.x.total_cmp(&q.x)).unwrap_or(first.origin);
|
||||
let p = self.pt(t.xf * origin);
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
self.e.entity("TEXT", h, fixed::MODEL_RECORD, &layer, &style, "AcDbText");
|
||||
self.e.xyz(10, p.x, p.y);
|
||||
self.e.num(40, height);
|
||||
let s: String = s.chars().take(MAX_TEXT).collect();
|
||||
self.e.str(1, &s);
|
||||
if angle.abs() > 1e-9 {
|
||||
self.e.num(50, angle);
|
||||
}
|
||||
if (width - 1.0).abs() > 1e-9 {
|
||||
self.e.num(41, width);
|
||||
}
|
||||
self.e.str(7, &text_style);
|
||||
if self.o.version.handles() {
|
||||
self.e.raw(100, "AcDbText");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text style for font `family` (one per family).
|
||||
fn text_style(&mut self, family: &str) -> String {
|
||||
if let Some((name, _)) = self.styles.iter().find(|(_, f)| f == family) {
|
||||
return name.clone();
|
||||
}
|
||||
let long = self.o.version.r2000();
|
||||
let mut taken: Vec<&str> = self.styles.iter().map(|(n, _)| n.as_str()).collect();
|
||||
taken.push(crate::writer::STANDARD_STYLE);
|
||||
let name = unique(&symbol_name(family, long), &taken, if long { 255 } else { 31 });
|
||||
self.styles.push((name.clone(), family.to_string()));
|
||||
name
|
||||
}
|
||||
|
||||
/// A placed image, linked to its file (written once per image).
|
||||
fn image(&mut self, im: &ImageObject) {
|
||||
if !self.o.version.handles() {
|
||||
self.warn(NO_IMAGES);
|
||||
return;
|
||||
}
|
||||
let Some(i) = self.image_def(&im.key) else { return };
|
||||
let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return };
|
||||
let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1)));
|
||||
let map = self.to_dxf * im.xf;
|
||||
let lin = |v: kurbo::Vec2| (map * v.to_point()) - (map * Point::ZERO);
|
||||
// Insertion at the bottom-left corner; u along one file pixel, v one pixel up.
|
||||
let u = lin(kurbo::Vec2::new(w / dw, 0.0));
|
||||
let v = lin(kurbo::Vec2::new(0.0, -h / dh));
|
||||
for corner in [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(0.0, h), Point::new(w, h)] {
|
||||
self.pt(im.xf * corner);
|
||||
}
|
||||
let at = map * Point::new(0.0, h);
|
||||
let handle = self.handle();
|
||||
let reactor = self.handle();
|
||||
let Some(def) = self.images.get_mut(i) else { return };
|
||||
def.reactors.push((reactor, handle));
|
||||
let def = def.handle;
|
||||
let layer = self.layer.clone();
|
||||
let style = EntityStyle { aci: 7, ..EntityStyle::default() };
|
||||
self.e.entity("IMAGE", handle, fixed::MODEL_RECORD, &layer, &style, "AcDbRasterImage");
|
||||
self.e.int(90, 0);
|
||||
self.e.xyz(10, at.x, at.y);
|
||||
self.e.xyz(11, u.x, u.y);
|
||||
self.e.xyz(12, v.x, v.y);
|
||||
self.e.xy(13, dw, dh);
|
||||
self.e.handle(340, def);
|
||||
// Shown, also when not aligned with the screen; PNG with its transparency.
|
||||
self.e.int(70, if self.o.raster == RasterFormat::Png { 11 } else { 3 });
|
||||
self.e.int(280, 0);
|
||||
self.e.int(281, 50);
|
||||
self.e.int(282, 50);
|
||||
self.e.int(283, 0);
|
||||
self.e.handle(360, reactor);
|
||||
self.e.int(71, 1);
|
||||
self.e.int(91, 2);
|
||||
self.e.xy(14, -0.5, -0.5);
|
||||
self.e.xy(14, dw - 0.5, dh - 0.5);
|
||||
if self.o.version >= crate::DxfVersion::R2010 {
|
||||
self.e.int(290, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The image file for image `key`, encoded once in the chosen format (`None`: left out).
|
||||
fn image_def(&mut self, key: &str) -> Option<usize> {
|
||||
if let Some(i) = self.by_key.get(key) {
|
||||
return *i;
|
||||
}
|
||||
let made = self.encode_image(key);
|
||||
let i = match made {
|
||||
Ok((file, size)) => {
|
||||
let handle = self.handle();
|
||||
self.images.push(ImageDef { name: file.name.clone(), size, handle, reactors: vec![] });
|
||||
self.files.push(file);
|
||||
Some(self.images.len() - 1)
|
||||
}
|
||||
Err(e) => {
|
||||
self.warn(&e);
|
||||
None
|
||||
}
|
||||
};
|
||||
self.by_key.insert(key.to_string(), i);
|
||||
i
|
||||
}
|
||||
|
||||
/// Image `key` as a file in the chosen format, and its size in pixels.
|
||||
fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> {
|
||||
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| format!("image '{key}' has no pixels and was left out"))?;
|
||||
let img = image::load_from_memory(&blob.bytes).map_err(|_| format!("image '{key}' could not be decoded and was left out"))?;
|
||||
let size = (img.width(), img.height());
|
||||
let mut bytes = vec![];
|
||||
let written = match self.o.raster {
|
||||
RasterFormat::Png => img.to_rgba8().write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png),
|
||||
RasterFormat::Jpeg => {
|
||||
// No alpha in JPEG: transparent pixels over white.
|
||||
let mut rgba = img.to_rgba8();
|
||||
for p in rgba.pixels_mut() {
|
||||
let a = u32::from(p[3]);
|
||||
for c in 0..3 {
|
||||
p[c] = ((u32::from(p[c]) * a + 255 * (255 - a)) / 255) as u8;
|
||||
}
|
||||
}
|
||||
let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
|
||||
rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90))
|
||||
}
|
||||
};
|
||||
written.map_err(|e| format!("image '{key}' could not be written: {e}"))?;
|
||||
Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size))
|
||||
}
|
||||
}
|
||||
|
||||
/// The first visible solid fill colour in `n` (pattern art).
|
||||
fn first_solid(n: &Node) -> Option<vectorcraft_color::Color> {
|
||||
let own = n.appearance.items.iter().find_map(|i| match i {
|
||||
AppearanceItem::Fill(f) if f.visible => f.paint.color(),
|
||||
_ => None,
|
||||
});
|
||||
own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c)))
|
||||
}
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
//! DXF output read back as group codes: versions, entities, colours, units, layers, type, images.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
type Pairs = Vec<(i32, String)>;
|
||||
|
||||
/// The `(code, value)` pairs of a DXF file.
|
||||
fn pairs(dxf: &[u8]) -> Pairs {
|
||||
let text = std::str::from_utf8(dxf).expect("UTF-8");
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len() % 2, 0, "group codes and values come in pairs");
|
||||
lines.chunks(2).map(|p| (p[0].trim().parse().expect("a group code"), p[1].to_string())).collect()
|
||||
}
|
||||
|
||||
/// The objects of section `name`: each the pairs from its `0` code on.
|
||||
fn section(p: &[(i32, String)], name: &str) -> Vec<Pairs> {
|
||||
let start = p.windows(2).position(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into()));
|
||||
let Some(start) = start else { return vec![] };
|
||||
let mut out: Vec<Pairs> = vec![];
|
||||
for pair in &p[start + 2..] {
|
||||
if *pair == (0, "ENDSEC".into()) {
|
||||
break;
|
||||
}
|
||||
if pair.0 == 0 {
|
||||
out.push(vec![]);
|
||||
}
|
||||
if let Some(o) = out.last_mut() {
|
||||
o.push(pair.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn kind(o: &[(i32, String)]) -> &str {
|
||||
o.first().map_or("", |p| p.1.as_str())
|
||||
}
|
||||
|
||||
fn get(o: &[(i32, String)], code: i32) -> Option<&str> {
|
||||
o.iter().find(|p| p.0 == code).map(|p| p.1.as_str())
|
||||
}
|
||||
|
||||
fn all(o: &[(i32, String)], code: i32) -> Vec<f64> {
|
||||
o.iter().filter(|p| p.0 == code).map(|p| p.1.trim().parse().expect("a number")).collect()
|
||||
}
|
||||
|
||||
fn header(p: &[(i32, String)], var: &str) -> Option<String> {
|
||||
let i = p.iter().position(|x| *x == (9, var.into()))?;
|
||||
p.get(i + 1).map(|x| x.1.clone())
|
||||
}
|
||||
|
||||
fn entities(out: &DxfOutput) -> Vec<Pairs> {
|
||||
section(&pairs(&out.bytes), "ENTITIES")
|
||||
}
|
||||
|
||||
fn kinds(out: &DxfOutput) -> Vec<String> {
|
||||
entities(out).iter().map(|e| kind(e).to_string()).collect()
|
||||
}
|
||||
|
||||
fn table(out: &DxfOutput, kind_: &str) -> Vec<Pairs> {
|
||||
section(&pairs(&out.bytes), "TABLES").into_iter().filter(|o| kind(o) == kind_).collect()
|
||||
}
|
||||
|
||||
/// A 200 × 100 pt document whose first layer holds what `nodes` makes.
|
||||
fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec<Node>) -> Document {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let nodes = nodes(&mut d);
|
||||
let layer = d.layers[0].id;
|
||||
for n in nodes {
|
||||
d.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn path(d: &mut Document, p: PathData, fill: Paint, stroke: Paint, width: f64) -> Node {
|
||||
Node::path(d.alloc_id(), p, Appearance::basic(fill, stroke, width))
|
||||
}
|
||||
|
||||
/// The options with the first artboard as the region, in points.
|
||||
fn opts(d: &Document) -> DxfOptions {
|
||||
DxfOptions { region: d.artboards[0].rect, unit: Unit::Points, ..DxfOptions::default() }
|
||||
}
|
||||
|
||||
/// A red-filled rectangle with a black 1 pt stroke, and an unfilled blue 2 pt ellipse.
|
||||
fn shapes_doc() -> Document {
|
||||
doc_with(|d| {
|
||||
vec![
|
||||
path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0),
|
||||
path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_version_declares_its_acadver_and_is_well_formed() {
|
||||
let d = shapes_doc();
|
||||
for v in DxfVersion::ALL {
|
||||
let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap();
|
||||
let p = pairs(&out.bytes);
|
||||
assert_eq!(header(&p, "$ACADVER").as_deref(), Some(v.acadver()), "{v:?}");
|
||||
assert_eq!(p.last(), Some(&(0, "EOF".to_string())), "{v:?}");
|
||||
let opened = p.iter().filter(|x| **x == (0, "SECTION".into())).count();
|
||||
assert_eq!(opened, p.iter().filter(|x| **x == (0, "ENDSEC".into())).count(), "{v:?}: balanced sections");
|
||||
assert!(!entities(&out).is_empty(), "{v:?}");
|
||||
if v >= DxfVersion::R13 {
|
||||
// Every handle is unique and below $HANDSEED.
|
||||
let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..];
|
||||
let handles: Vec<u32> = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect();
|
||||
let unique: std::collections::HashSet<_> = handles.iter().collect();
|
||||
assert_eq!(unique.len(), handles.len(), "{v:?}: unique handles");
|
||||
let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap();
|
||||
assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed");
|
||||
assert!(section(&p, "OBJECTS").iter().any(|o| kind(o) == "DICTIONARY"), "{v:?}: root dictionary");
|
||||
} else {
|
||||
assert!(header(&p, "$HANDSEED").is_none());
|
||||
}
|
||||
}
|
||||
assert_eq!(DxfVersion::from_id("r2013"), Some(DxfVersion::R2013));
|
||||
assert_eq!(DxfVersion::from_id("AC1009"), Some(DxfVersion::R12));
|
||||
assert_eq!(DxfVersion::from_id("2019"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
|
||||
let d = shapes_doc();
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(kinds(&out), ["HATCH", "LWPOLYLINE", "SPLINE"], "fill, then stroke, then the ellipse's stroke");
|
||||
let e = entities(&out);
|
||||
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
|
||||
let poly = &e[1];
|
||||
assert_eq!(get(poly, 70), Some("1"), "closed");
|
||||
let mut xs = all(poly, 10);
|
||||
xs.sort_by(f64::total_cmp);
|
||||
assert_eq!(xs, [10.0, 10.0, 110.0, 110.0]);
|
||||
let mut ys = all(poly, 20);
|
||||
ys.sort_by(f64::total_cmp);
|
||||
assert_eq!(ys, [40.0, 40.0, 90.0, 90.0]);
|
||||
let hatch = &e[0];
|
||||
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
|
||||
// The ellipse: four cubics through clamped knots.
|
||||
let spline = &e[2];
|
||||
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
|
||||
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
|
||||
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
|
||||
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
|
||||
assert_eq!(get(&entities(&r14)[1], 43), Some("1"));
|
||||
assert_eq!(get(&entities(&r14)[1], 370), None);
|
||||
// R13 has splines and hatches, and polylines of vertices.
|
||||
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&r13), ["HATCH", "POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND", "SPLINE"]);
|
||||
// R12: polylines only; the fill is its outline, the ellipse flattened.
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let k = kinds(&r12);
|
||||
assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}");
|
||||
assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3);
|
||||
assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened");
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_map_to_the_index_palette_or_true_colour() {
|
||||
let d = shapes_doc();
|
||||
let style = |o: &DxfOutput, i: usize| {
|
||||
let e = &entities(o)[i];
|
||||
(get(e, 62).map(str::to_string), get(e, 420).map(str::to_string))
|
||||
};
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(style(&out, 0), (Some("1".into()), Some("16711680".into())), "red: index 1, true colour 0xFF0000");
|
||||
assert_eq!(style(&out, 1), (Some("7".into()), Some("0".into())), "black is index 7");
|
||||
assert_eq!(style(&out, 2), (Some("5".into()), Some("255".into())));
|
||||
for depth in [ColorDepth::Aci8, ColorDepth::Aci16, ColorDepth::Aci256] {
|
||||
let o = export(&d, &DxfOptions { colors: depth, ..opts(&d) }).unwrap();
|
||||
assert_eq!(style(&o, 0), (Some("1".into()), None), "{depth:?}");
|
||||
}
|
||||
let orange = doc_with(|d| {
|
||||
vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::from_hex("#ff8000").unwrap()), Paint::None, 0.0)]
|
||||
});
|
||||
let aci = |depth| get(&entities(&export(&orange, &DxfOptions { colors: depth, ..opts(&orange) }).unwrap())[0], 62).unwrap().to_string();
|
||||
assert_eq!(
|
||||
(aci(ColorDepth::Aci256), aci(ColorDepth::Aci16), aci(ColorDepth::Aci8)),
|
||||
("30".into(), "30".into(), "2".into()),
|
||||
"8 colours: yellow is nearest"
|
||||
);
|
||||
// True colour needs 2004: earlier versions fall back to indices, and say so.
|
||||
let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap();
|
||||
assert_eq!(style(&old, 0), (Some("1".into()), None));
|
||||
assert!(old.warnings.iter().any(|w| w.contains("true colour")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_scale_coordinates_and_set_insunits() {
|
||||
let d = shapes_doc();
|
||||
let insunits = |o: &DxfOutput| header(&pairs(&o.bytes), "$INSUNITS");
|
||||
let max_x = |o: &DxfOutput| all(&entities(o)[1], 10).into_iter().fold(f64::MIN, f64::max);
|
||||
let mm = export(&d, &DxfOptions { unit: Unit::Millimeters, ..opts(&d) }).unwrap();
|
||||
assert_eq!(insunits(&mm).as_deref(), Some("4"));
|
||||
assert!((max_x(&mm) - 110.0 * 25.4 / 72.0).abs() < 1e-5);
|
||||
assert_eq!(header(&pairs(&mm.bytes), "$MEASUREMENT").as_deref(), Some("1"));
|
||||
let inches = export(&d, &DxfOptions { unit: Unit::Inches, ..opts(&d) }).unwrap();
|
||||
assert_eq!(insunits(&inches).as_deref(), Some("1"));
|
||||
// 1 in = 10 units: unitless, coordinates ten times larger.
|
||||
let scaled = export(&d, &DxfOptions { unit: Unit::Inches, scale: 10.0, ..opts(&d) }).unwrap();
|
||||
assert_eq!(insunits(&scaled).as_deref(), Some("0"));
|
||||
assert!((max_x(&scaled) - 110.0 / 72.0 * 10.0).abs() < 1e-5);
|
||||
// Lineweights: 1 pt is 0.35 mm, scaled with the drawing only when asked.
|
||||
assert_eq!(get(&entities(&scaled)[1], 370), Some("35"));
|
||||
let heavy = export(&d, &DxfOptions { unit: Unit::Inches, scale: 2.0, scale_lineweights: true, ..opts(&d) }).unwrap();
|
||||
assert_eq!(get(&entities(&heavy)[1], 370), Some("70"));
|
||||
assert!(export(&d, &DxfOptions { scale: 0.0, ..opts(&d) }).is_err());
|
||||
assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_become_dxf_layers() {
|
||||
let mut d = shapes_doc();
|
||||
let second = d.add_layer(Some("Dims: <top>"));
|
||||
let hidden = d.add_layer(Some("Notes"));
|
||||
let template = d.add_layer(Some("Tracing"));
|
||||
for l in [second, hidden, template] {
|
||||
let n = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
if let Some(n) = d.node_mut(hidden) {
|
||||
n.visible = false;
|
||||
}
|
||||
if let Some(NodeKind::Layer { template, .. }) = d.node_mut(template).map(|n| &mut n.kind) {
|
||||
*template = true;
|
||||
}
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
let layers: Vec<(String, String)> =
|
||||
table(&out, "LAYER").iter().map(|o| (get(o, 2).unwrap().to_string(), get(o, 62).unwrap().to_string())).collect();
|
||||
let names: Vec<&str> = layers.iter().map(|l| l.0.as_str()).collect();
|
||||
assert_eq!(names, ["0", "Layer 1", "Dims_ _top_", "Notes"], "the template layer is left out");
|
||||
assert!(layers[3].1.starts_with('-'), "a hidden layer is switched off");
|
||||
let on: Vec<String> = entities(&out).iter().map(|e| get(e, 8).unwrap().to_string()).collect();
|
||||
assert_eq!(on, ["Layer 1", "Layer 1", "Layer 1", "Dims_ _top_", "Notes"]);
|
||||
// Before 2000: capitals, letters, digits and $-_ only.
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
let names: Vec<String> = table(&r12, "LAYER").iter().map(|o| get(o, 2).unwrap().to_string()).collect();
|
||||
assert_eq!(names, ["0", "LAYER_1", "DIMS___TOP_", "NOTES"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_become_linetypes_and_opacity_transparency() {
|
||||
let d = doc_with(|d| {
|
||||
let mut n = path(d, shapes::line(Point::new(0.0, 50.0), Point::new(100.0, 50.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
if let Some(st) = n.appearance.stroke_mut() {
|
||||
st.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false });
|
||||
}
|
||||
n.opacity = 0.5;
|
||||
vec![n]
|
||||
});
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
let e = &entities(&out)[0];
|
||||
let alpha = (0x0200_0000 | 128).to_string();
|
||||
assert_eq!((get(e, 6), get(e, 440)), (Some("DASHED1"), Some(alpha.as_str())));
|
||||
let lt = table(&out, "LTYPE").into_iter().find(|o| get(o, 2) == Some("DASHED1")).unwrap();
|
||||
assert_eq!(all(<, 49), [6.0, -3.0]);
|
||||
let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap();
|
||||
assert!(get(&entities(&old)[0], 440).is_none());
|
||||
assert!(old.warnings.iter().any(|w| w.contains("transparency")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
let d = shapes_doc();
|
||||
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&out), ["HATCH", "HATCH", "HATCH"]);
|
||||
// The rectangle's stroke: an outer and an inner boundary.
|
||||
assert_eq!(get(&entities(&out)[1], 91), Some("2"));
|
||||
// Preserve Appearance outlines only the strokes a line can't draw.
|
||||
let mut d = shapes_doc();
|
||||
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
|
||||
st.align = vectorcraft_doc::StrokeAlign::Outside;
|
||||
}
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["HATCH", "HATCH", "SPLINE"]);
|
||||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&editable), ["HATCH", "LWPOLYLINE", "SPLINE"]);
|
||||
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
|
||||
}
|
||||
|
||||
fn text_doc(text: &str, size: f64) -> Document {
|
||||
doc_with(|d| {
|
||||
let style = CharStyle { size, fill: Paint::solid(Color::rgb(0.0, 0.5, 0.0)), ..CharStyle::default() };
|
||||
let t = TextObject::point(Point::new(10.0, 50.0), text, style);
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_is_text_or_outlines() {
|
||||
let d = text_doc("Plan A", 20.0);
|
||||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
let e = entities(&editable);
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!((kind(&e[0]), get(&e[0], 1)), ("TEXT", Some("Plan A")));
|
||||
assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "the cap height of 20 pt type");
|
||||
assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 50.0), "the baseline start, y up");
|
||||
let family = CharStyle::default().font_family;
|
||||
let style = table(&editable, "STYLE").into_iter().find(|o| get(o, 3) == Some(&*format!("{family}.ttf"))).unwrap();
|
||||
assert_eq!(get(&e[0], 7), get(&style, 2));
|
||||
// Outline Text, and Preserve Appearance: the glyphs as filled outlines.
|
||||
for o in [DxfOptions { preserve: Preserve::Editability, outline_text: true, ..opts(&d) }, opts(&d)] {
|
||||
let k = kinds(&export(&d, &o).unwrap());
|
||||
assert!(!k.is_empty() && k.iter().all(|k| k == "HATCH"), "{k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_outside_ascii_is_escaped_before_2007() {
|
||||
let d = text_doc("Größe", 12.0);
|
||||
let text = |v| {
|
||||
let out = export(&d, &DxfOptions { version: v, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
get(&entities(&out)[0], 1).unwrap().to_string()
|
||||
};
|
||||
assert_eq!(text(DxfVersion::R2004), "Gr\\U+00F6\\U+00DFe");
|
||||
assert_eq!(text(DxfVersion::R2018), "Größe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_link_png_or_jpeg_files() {
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 128])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut d = doc_with(|d| {
|
||||
let im = ImageObject { key: "raster-1".into(), width: 4, height: 2, xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), link: None };
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Image(im))]
|
||||
});
|
||||
d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None });
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(kind(&e[0]), "IMAGE");
|
||||
// The bottom-left corner (10, 30) is (10, 70) y up; a pixel is 5 units.
|
||||
assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 70.0));
|
||||
assert_eq!((all(&e[0], 11)[0], all(&e[0], 22)[0], all(&e[0], 13)[0], all(&e[0], 23)[0]), (5.0, 5.0, 4.0, 2.0));
|
||||
assert_eq!(out.images.len(), 1);
|
||||
assert!(out.images[0].name.ends_with(".png") && out.images[0].bytes.starts_with(b"\x89PNG"));
|
||||
let p = pairs(&out.bytes);
|
||||
let objects = section(&p, "OBJECTS");
|
||||
let def = objects.iter().find(|o| kind(o) == "IMAGEDEF").unwrap();
|
||||
assert_eq!(get(def, 1), Some(out.images[0].name.as_str()));
|
||||
assert_eq!(get(&e[0], 340), get(def, 5), "the image points at its definition");
|
||||
assert!(objects.iter().any(|o| kind(o) == "IMAGEDEF_REACTOR"));
|
||||
assert!(section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE")));
|
||||
let jpeg = export(&d, &DxfOptions { raster: RasterFormat::Jpeg, ..opts(&d) }).unwrap();
|
||||
assert!(jpeg.images[0].name.ends_with(".jpg") && jpeg.images[0].bytes.starts_with(&[0xFF, 0xD8]));
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
assert!(entities(&r12).is_empty() && r12.images.is_empty());
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("no images")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_leaves_out_art_outside_the_region() {
|
||||
let d = shapes_doc();
|
||||
// Only the ellipse (120..180) reaches into 115..200.
|
||||
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
|
||||
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
|
||||
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 3);
|
||||
}
|
||||
|
|
@ -0,0 +1,661 @@
|
|||
//! The ASCII DXF file: group codes, handles, and the sections, tables and objects around the
|
||||
//! entities. DXF R13 and later give every object a handle and subclass markers; R2000 and later
|
||||
//! add layouts and plot styles, which CAD apps expect to find.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::DxfVersion;
|
||||
|
||||
/// An object handle (written in hex).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Handle(pub u32);
|
||||
|
||||
/// The handles of the fixed objects every drawing has. Entities and table entries count up from
|
||||
/// [`FIRST_FREE`].
|
||||
pub(crate) mod fixed {
|
||||
use super::Handle;
|
||||
pub const LAYER_TABLE: Handle = Handle(0x1);
|
||||
pub const LTYPE_TABLE: Handle = Handle(0x2);
|
||||
pub const APPID_TABLE: Handle = Handle(0x3);
|
||||
pub const DIMSTYLE_TABLE: Handle = Handle(0x4);
|
||||
pub const STYLE_TABLE: Handle = Handle(0x5);
|
||||
pub const UCS_TABLE: Handle = Handle(0x6);
|
||||
pub const VIEW_TABLE: Handle = Handle(0x7);
|
||||
pub const VPORT_TABLE: Handle = Handle(0x8);
|
||||
pub const BLOCK_RECORD_TABLE: Handle = Handle(0x9);
|
||||
pub const ROOT: Handle = Handle(0xA);
|
||||
pub const GROUPS: Handle = Handle(0xB);
|
||||
pub const LAYOUTS: Handle = Handle(0xC);
|
||||
pub const MODEL_RECORD: Handle = Handle(0xD);
|
||||
pub const PAPER_RECORD: Handle = Handle(0xE);
|
||||
pub const MODEL_BLOCK: Handle = Handle(0xF);
|
||||
pub const MODEL_END: Handle = Handle(0x10);
|
||||
pub const PAPER_BLOCK: Handle = Handle(0x11);
|
||||
pub const PAPER_END: Handle = Handle(0x12);
|
||||
pub const MODEL_LAYOUT: Handle = Handle(0x13);
|
||||
pub const PAPER_LAYOUT: Handle = Handle(0x14);
|
||||
pub const ACTIVE_VPORT: Handle = Handle(0x15);
|
||||
pub const ACAD_APPID: Handle = Handle(0x16);
|
||||
pub const STANDARD_DIMSTYLE: Handle = Handle(0x17);
|
||||
pub const IMAGE_DICT: Handle = Handle(0x18);
|
||||
pub const IMAGE_VARS: Handle = Handle(0x19);
|
||||
pub const PLOT_STYLES: Handle = Handle(0x1A);
|
||||
pub const PLOT_NORMAL: Handle = Handle(0x1B);
|
||||
pub const FIRST_FREE: u32 = 0x30;
|
||||
}
|
||||
|
||||
/// A number as DXF writes it: up to 6 decimals, no trailing zeros, 0 for anything not finite.
|
||||
pub(crate) fn num(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
let s = format!("{v:.6}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" || s.is_empty() { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
/// Text as a DXF string value: one line, `^` escaped, and before DXF 2007 (which reads UTF-8)
|
||||
/// every character outside ASCII as `\U+XXXX`.
|
||||
pub(crate) fn text(s: &str, utf8: bool) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\r' | '\n' | '\t' => out.push(' '),
|
||||
c if c.is_control() => {}
|
||||
'^' => out.push_str("^ "),
|
||||
c if !utf8 && !c.is_ascii() => match u16::try_from(u32::from(c)) {
|
||||
Ok(u) => _ = write!(out, "\\U+{u:04X}"),
|
||||
Err(_) => out.push('?'),
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Group-code/value pairs in the order they are written.
|
||||
pub(crate) struct Codes {
|
||||
pub v: DxfVersion,
|
||||
out: String,
|
||||
}
|
||||
|
||||
impl Codes {
|
||||
pub fn new(v: DxfVersion) -> Self {
|
||||
Self { v, out: String::new() }
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.out
|
||||
}
|
||||
|
||||
pub fn append(&mut self, other: &str) {
|
||||
self.out.push_str(other);
|
||||
}
|
||||
|
||||
/// A value written as it is (names and keywords the writer itself chose).
|
||||
pub fn raw(&mut self, code: u16, value: &str) {
|
||||
_ = writeln!(self.out, "{code:>3}\n{value}");
|
||||
}
|
||||
|
||||
/// Text from the document, encoded for the version (see [`text`]).
|
||||
pub fn str(&mut self, code: u16, value: &str) {
|
||||
let t = text(value, self.v.utf8());
|
||||
self.raw(code, &t);
|
||||
}
|
||||
|
||||
pub fn int(&mut self, code: u16, value: i64) {
|
||||
_ = writeln!(self.out, "{code:>3}\n{value}");
|
||||
}
|
||||
|
||||
pub fn num(&mut self, code: u16, value: f64) {
|
||||
_ = writeln!(self.out, "{code:>3}\n{}", num(value));
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, code: u16, h: Handle) {
|
||||
_ = writeln!(self.out, "{code:>3}\n{:X}", h.0);
|
||||
}
|
||||
|
||||
/// A 2D point at `code` (x), `code + 10` (y).
|
||||
pub fn xy(&mut self, code: u16, x: f64, y: f64) {
|
||||
self.num(code, x);
|
||||
self.num(code + 10, y);
|
||||
}
|
||||
|
||||
/// A 3D point at `code`, `code + 10`, `code + 20` (z = 0).
|
||||
pub fn xyz(&mut self, code: u16, x: f64, y: f64) {
|
||||
self.xy(code, x, y);
|
||||
self.num(code + 20, 0.0);
|
||||
}
|
||||
|
||||
/// The extrusion direction of planar entities: the z axis.
|
||||
pub fn normal(&mut self) {
|
||||
self.num(210, 0.0);
|
||||
self.num(220, 0.0);
|
||||
self.num(230, 1.0);
|
||||
}
|
||||
|
||||
/// The start of an object: its type, and from R13 its handle, owner and first subclass.
|
||||
fn object(&mut self, kind: &str, h: Handle, owner: Handle, subclass: &str) {
|
||||
self.raw(0, kind);
|
||||
if self.v.handles() {
|
||||
self.handle(if kind == "DIMSTYLE" { 105 } else { 5 }, h);
|
||||
self.handle(330, owner);
|
||||
self.raw(100, subclass);
|
||||
}
|
||||
}
|
||||
|
||||
/// A table entry: the shared record subclass, then the table's own (from R13).
|
||||
fn record(&mut self, kind: &str, h: Handle, table: Handle, subclass: &str) {
|
||||
self.object(kind, h, table, "AcDbSymbolTableRecord");
|
||||
if self.v.handles() {
|
||||
self.raw(100, subclass);
|
||||
}
|
||||
}
|
||||
|
||||
fn section(&mut self, name: &str) {
|
||||
self.raw(0, "SECTION");
|
||||
self.raw(2, name);
|
||||
}
|
||||
|
||||
fn end_section(&mut self) {
|
||||
self.raw(0, "ENDSEC");
|
||||
}
|
||||
|
||||
fn table(&mut self, name: &str, h: Handle, count: usize) {
|
||||
self.raw(0, "TABLE");
|
||||
self.raw(2, name);
|
||||
if self.v.handles() {
|
||||
self.handle(5, h);
|
||||
self.raw(330, "0");
|
||||
self.raw(100, "AcDbSymbolTable");
|
||||
}
|
||||
self.int(70, count as i64);
|
||||
if name == "DIMSTYLE" && self.v.r2000() {
|
||||
self.raw(100, "AcDbDimStyleTable");
|
||||
}
|
||||
}
|
||||
|
||||
fn end_table(&mut self) {
|
||||
self.raw(0, "ENDTAB");
|
||||
}
|
||||
}
|
||||
|
||||
/// How an entity is drawn: its colour index, true colour, lineweight, linetype and transparency.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct EntityStyle {
|
||||
pub aci: u8,
|
||||
/// `0xRRGGBB` (DXF 2004 and later).
|
||||
pub rgb: Option<u32>,
|
||||
/// Hundredths of a millimetre (DXF 2000 and later).
|
||||
pub lineweight: Option<i32>,
|
||||
pub linetype: Option<String>,
|
||||
/// Opacity 0–255 (DXF 2004 and later; opaque is left out).
|
||||
pub alpha: Option<u8>,
|
||||
}
|
||||
|
||||
impl Codes {
|
||||
/// The start of an entity of `kind` owned by `owner` (model space, or a polyline for its
|
||||
/// vertices) on `layer` with its style, ending with `subclass` (none when empty).
|
||||
pub fn entity(&mut self, kind: &str, h: Handle, owner: Handle, layer: &str, style: &EntityStyle, subclass: &str) {
|
||||
self.object(kind, h, owner, "AcDbEntity");
|
||||
self.str(8, layer);
|
||||
if let Some(lt) = &style.linetype {
|
||||
self.str(6, lt);
|
||||
}
|
||||
self.int(62, i64::from(style.aci.max(1)));
|
||||
if self.v.r2000()
|
||||
&& let Some(lw) = style.lineweight
|
||||
{
|
||||
self.int(370, i64::from(lw));
|
||||
}
|
||||
if self.v.true_color() {
|
||||
if let Some(rgb) = style.rgb {
|
||||
self.int(420, i64::from(rgb));
|
||||
}
|
||||
if let Some(a) = style.alpha {
|
||||
self.int(440, 0x0200_0000 | i64::from(a));
|
||||
}
|
||||
}
|
||||
if self.v.handles() && !subclass.is_empty() {
|
||||
self.raw(100, subclass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A DXF layer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Layer {
|
||||
pub name: String,
|
||||
pub aci: u8,
|
||||
/// Switched off (a hidden layer).
|
||||
pub off: bool,
|
||||
pub locked: bool,
|
||||
/// Plotted (printable).
|
||||
pub plot: bool,
|
||||
}
|
||||
|
||||
/// A dash pattern: dash, gap, dash… lengths in drawing units.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct LineType {
|
||||
pub name: String,
|
||||
pub pattern: Vec<f64>,
|
||||
}
|
||||
|
||||
/// A placed image file and the image entities that show it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ImageDef {
|
||||
pub name: String,
|
||||
pub size: (u32, u32),
|
||||
pub handle: Handle,
|
||||
/// `(reactor, image entity)` per image entity.
|
||||
pub reactors: Vec<(Handle, Handle)>,
|
||||
}
|
||||
|
||||
/// Everything around the entities, collected while they were written.
|
||||
pub(crate) struct Drawing {
|
||||
pub version: DxfVersion,
|
||||
pub layers: Vec<Layer>,
|
||||
pub linetypes: Vec<LineType>,
|
||||
/// Text styles: (style name, font family).
|
||||
pub styles: Vec<(String, String)>,
|
||||
pub images: Vec<ImageDef>,
|
||||
/// The entities' group codes.
|
||||
pub entities: String,
|
||||
/// `[x0, y0, x1, y1]` of everything written (drawing units).
|
||||
pub extents: [f64; 4],
|
||||
/// The export region (drawing units).
|
||||
pub limits: [f64; 4],
|
||||
/// `$INSUNITS`.
|
||||
pub insunits: u8,
|
||||
pub metric: bool,
|
||||
/// The next free handle.
|
||||
pub next: u32,
|
||||
}
|
||||
|
||||
/// The text style every drawing has.
|
||||
pub(crate) const STANDARD_STYLE: &str = "Standard";
|
||||
|
||||
impl Drawing {
|
||||
/// The whole file.
|
||||
pub fn write(self) -> String {
|
||||
let v = self.version;
|
||||
let mut c = Codes::new(v);
|
||||
// Table entries get the handles after the entities'.
|
||||
let mut next = self.next;
|
||||
let mut take = |n: usize| {
|
||||
let start = next;
|
||||
next = next.saturating_add(u32::try_from(n).unwrap_or(u32::MAX));
|
||||
start
|
||||
};
|
||||
let ltype_base = take(3 + self.linetypes.len());
|
||||
let layer_base = take(1 + self.layers.len());
|
||||
let style_base = take(1 + self.styles.len());
|
||||
self.header(&mut c, next);
|
||||
if v.handles() {
|
||||
self.classes(&mut c);
|
||||
}
|
||||
c.section("TABLES");
|
||||
self.vport(&mut c);
|
||||
self.ltypes(&mut c, ltype_base);
|
||||
self.layer_table(&mut c, layer_base);
|
||||
self.style_table(&mut c, style_base);
|
||||
if v.handles() {
|
||||
for (name, h) in [("VIEW", fixed::VIEW_TABLE), ("UCS", fixed::UCS_TABLE)] {
|
||||
c.table(name, h, 0);
|
||||
c.end_table();
|
||||
}
|
||||
c.table("APPID", fixed::APPID_TABLE, 1);
|
||||
c.record("APPID", fixed::ACAD_APPID, fixed::APPID_TABLE, "AcDbRegAppTableRecord");
|
||||
c.raw(2, "ACAD");
|
||||
c.int(70, 0);
|
||||
c.end_table();
|
||||
c.table("DIMSTYLE", fixed::DIMSTYLE_TABLE, 1);
|
||||
c.record("DIMSTYLE", fixed::STANDARD_DIMSTYLE, fixed::DIMSTYLE_TABLE, "AcDbDimStyleTableRecord");
|
||||
c.raw(2, "Standard");
|
||||
c.int(70, 0);
|
||||
c.end_table();
|
||||
c.table("BLOCK_RECORD", fixed::BLOCK_RECORD_TABLE, 2);
|
||||
for (name, h, layout) in
|
||||
[("*Model_Space", fixed::MODEL_RECORD, fixed::MODEL_LAYOUT), ("*Paper_Space", fixed::PAPER_RECORD, fixed::PAPER_LAYOUT)]
|
||||
{
|
||||
c.record("BLOCK_RECORD", h, fixed::BLOCK_RECORD_TABLE, "AcDbBlockTableRecord");
|
||||
c.raw(2, name);
|
||||
if v.r2000() {
|
||||
c.handle(340, layout);
|
||||
c.int(70, 0);
|
||||
c.int(280, 1);
|
||||
c.int(281, 0);
|
||||
}
|
||||
}
|
||||
c.end_table();
|
||||
}
|
||||
c.end_section();
|
||||
if v.handles() {
|
||||
c.section("BLOCKS");
|
||||
for (name, record, begin, end) in [
|
||||
("*Model_Space", fixed::MODEL_RECORD, fixed::MODEL_BLOCK, fixed::MODEL_END),
|
||||
("*Paper_Space", fixed::PAPER_RECORD, fixed::PAPER_BLOCK, fixed::PAPER_END),
|
||||
] {
|
||||
c.object("BLOCK", begin, record, "AcDbEntity");
|
||||
c.raw(8, "0");
|
||||
c.raw(100, "AcDbBlockBegin");
|
||||
c.raw(2, name);
|
||||
c.int(70, 0);
|
||||
c.xyz(10, 0.0, 0.0);
|
||||
c.raw(3, name);
|
||||
c.raw(1, "");
|
||||
c.object("ENDBLK", end, record, "AcDbEntity");
|
||||
c.raw(8, "0");
|
||||
c.raw(100, "AcDbBlockEnd");
|
||||
}
|
||||
c.end_section();
|
||||
}
|
||||
c.section("ENTITIES");
|
||||
c.append(&self.entities);
|
||||
c.end_section();
|
||||
if v.handles() {
|
||||
self.objects(&mut c);
|
||||
}
|
||||
c.raw(0, "EOF");
|
||||
c.into_string()
|
||||
}
|
||||
|
||||
fn header(&self, c: &mut Codes, handseed: u32) {
|
||||
let v = self.version;
|
||||
let var = |c: &mut Codes, name: &str| c.raw(9, name);
|
||||
c.section("HEADER");
|
||||
var(c, "$ACADVER");
|
||||
c.raw(1, v.acadver());
|
||||
var(c, "$DWGCODEPAGE");
|
||||
c.raw(3, "ANSI_1252");
|
||||
var(c, "$INSBASE");
|
||||
c.xyz(10, 0.0, 0.0);
|
||||
let [x0, y0, x1, y1] = self.extents;
|
||||
var(c, "$EXTMIN");
|
||||
c.xyz(10, x0, y0);
|
||||
var(c, "$EXTMAX");
|
||||
c.xyz(10, x1, y1);
|
||||
let [lx0, ly0, lx1, ly1] = self.limits;
|
||||
var(c, "$LIMMIN");
|
||||
c.xy(10, lx0, ly0);
|
||||
var(c, "$LIMMAX");
|
||||
c.xy(10, lx1, ly1);
|
||||
if v.r2000() {
|
||||
var(c, "$LWDISPLAY");
|
||||
c.int(290, 1);
|
||||
var(c, "$INSUNITS");
|
||||
c.int(70, i64::from(self.insunits));
|
||||
var(c, "$MEASUREMENT");
|
||||
c.int(70, i64::from(self.metric));
|
||||
}
|
||||
if v.handles() {
|
||||
var(c, "$HANDSEED");
|
||||
c.handle(5, Handle(handseed));
|
||||
}
|
||||
c.end_section();
|
||||
}
|
||||
|
||||
fn classes(&self, c: &mut Codes) {
|
||||
let mut classes: Vec<(&str, &str, &str, i64, bool)> = vec![];
|
||||
if self.version.r2000() {
|
||||
classes.extend([
|
||||
("ACDBDICTIONARYWDFLT", "AcDbDictionaryWithDefault", "ObjectDBX Classes", 0, false),
|
||||
("ACDBPLACEHOLDER", "AcDbPlaceHolder", "ObjectDBX Classes", 0, false),
|
||||
("LAYOUT", "AcDbLayout", "ObjectDBX Classes", 0, false),
|
||||
]);
|
||||
}
|
||||
if !self.images.is_empty() {
|
||||
classes.extend([
|
||||
("RASTERVARIABLES", "AcDbRasterVariables", "ISM", 0, false),
|
||||
("IMAGE", "AcDbRasterImage", "ISM", 2175, true),
|
||||
("IMAGEDEF", "AcDbRasterImageDef", "ISM", 0, false),
|
||||
("IMAGEDEF_REACTOR", "AcDbRasterImageDefReactor", "ISM", 1, false),
|
||||
]);
|
||||
}
|
||||
c.section("CLASSES");
|
||||
for (name, cpp, app, flags, entity) in classes {
|
||||
c.raw(0, "CLASS");
|
||||
c.raw(1, name);
|
||||
c.raw(2, cpp);
|
||||
c.raw(3, app);
|
||||
c.int(90, flags);
|
||||
if self.version.true_color() {
|
||||
c.int(91, 0);
|
||||
}
|
||||
c.int(280, 0);
|
||||
c.int(281, i64::from(entity));
|
||||
}
|
||||
c.end_section();
|
||||
}
|
||||
|
||||
/// The active viewport, zoomed to the drawing's extents.
|
||||
fn vport(&self, c: &mut Codes) {
|
||||
let [x0, y0, x1, y1] = self.extents;
|
||||
c.table("VPORT", fixed::VPORT_TABLE, 1);
|
||||
c.record("VPORT", fixed::ACTIVE_VPORT, fixed::VPORT_TABLE, "AcDbViewportTableRecord");
|
||||
c.raw(2, if self.version.handles() { "*Active" } else { "*ACTIVE" });
|
||||
c.int(70, 0);
|
||||
c.xy(10, 0.0, 0.0);
|
||||
c.xy(11, 1.0, 1.0);
|
||||
c.xy(12, (x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
c.xy(13, 0.0, 0.0);
|
||||
c.xy(14, 1.0, 1.0);
|
||||
c.xy(15, 10.0, 10.0);
|
||||
c.xyz(16, 0.0, 0.0);
|
||||
c.num(36, 1.0);
|
||||
c.xyz(17, 0.0, 0.0);
|
||||
let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
|
||||
// The view's height, with a margin; its width follows from the aspect ratio.
|
||||
c.num(40, (h.max(w / 1.5) * 1.1).max(1.0));
|
||||
c.num(41, 1.5);
|
||||
c.num(42, 50.0);
|
||||
c.num(43, 0.0);
|
||||
c.num(44, 0.0);
|
||||
c.num(50, 0.0);
|
||||
c.num(51, 0.0);
|
||||
for (code, value) in [(71, 0), (72, 1000), (73, 1), (74, 3), (75, 0), (76, 0), (77, 0), (78, 0)] {
|
||||
c.int(code, value);
|
||||
}
|
||||
if self.version.r2000() {
|
||||
c.int(281, 0);
|
||||
c.int(65, 0);
|
||||
c.num(146, 0.0);
|
||||
}
|
||||
c.end_table();
|
||||
}
|
||||
|
||||
fn ltypes(&self, c: &mut Codes, base: u32) {
|
||||
c.table("LTYPE", fixed::LTYPE_TABLE, 3 + self.linetypes.len());
|
||||
let solid = [("ByBlock", ""), ("ByLayer", ""), ("Continuous", "Solid line")];
|
||||
let rows = solid.iter().map(|(n, d)| (*n, *d, &[][..])).chain(self.linetypes.iter().map(|l| (l.name.as_str(), "", l.pattern.as_slice())));
|
||||
for (i, (name, desc, pattern)) in rows.enumerate() {
|
||||
c.record("LTYPE", Handle(base.saturating_add(i as u32)), fixed::LTYPE_TABLE, "AcDbLinetypeTableRecord");
|
||||
c.str(2, name);
|
||||
c.int(70, 0);
|
||||
c.str(3, if pattern.is_empty() { desc } else { "Dashed" });
|
||||
c.int(72, 65);
|
||||
c.int(73, pattern.len() as i64);
|
||||
c.num(40, pattern.iter().map(|v| v.abs()).sum());
|
||||
for v in pattern {
|
||||
c.num(49, *v);
|
||||
if self.version.handles() {
|
||||
c.int(74, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
c.end_table();
|
||||
}
|
||||
|
||||
fn layer_table(&self, c: &mut Codes, base: u32) {
|
||||
let zero = Layer { name: "0".into(), aci: 7, off: false, locked: false, plot: true };
|
||||
c.table("LAYER", fixed::LAYER_TABLE, 1 + self.layers.len());
|
||||
for (i, l) in std::iter::once(&zero).chain(&self.layers).enumerate() {
|
||||
c.record("LAYER", Handle(base.saturating_add(i as u32)), fixed::LAYER_TABLE, "AcDbLayerTableRecord");
|
||||
c.str(2, &l.name);
|
||||
c.int(70, if l.locked { 4 } else { 0 });
|
||||
// A negative colour switches the layer off.
|
||||
let aci = i64::from(l.aci.max(1));
|
||||
c.int(62, if l.off { -aci } else { aci });
|
||||
c.raw(6, "Continuous");
|
||||
if self.version.r2000() {
|
||||
c.int(290, i64::from(l.plot));
|
||||
c.int(370, -3);
|
||||
c.handle(390, fixed::PLOT_NORMAL);
|
||||
}
|
||||
}
|
||||
c.end_table();
|
||||
}
|
||||
|
||||
fn style_table(&self, c: &mut Codes, base: u32) {
|
||||
c.table("STYLE", fixed::STYLE_TABLE, 1 + self.styles.len());
|
||||
let standard = (STANDARD_STYLE.to_string(), String::new());
|
||||
for (i, (name, family)) in std::iter::once(&standard).chain(&self.styles).enumerate() {
|
||||
c.record("STYLE", Handle(base.saturating_add(i as u32)), fixed::STYLE_TABLE, "AcDbTextStyleTableRecord");
|
||||
c.str(2, name);
|
||||
c.int(70, 0);
|
||||
c.num(40, 0.0);
|
||||
c.num(41, 1.0);
|
||||
c.num(50, 0.0);
|
||||
c.int(71, 0);
|
||||
c.num(42, 2.5);
|
||||
// The font file; from 2000 the family name too, which is how CAD apps find a TrueType font.
|
||||
if family.is_empty() {
|
||||
c.raw(3, "txt");
|
||||
} else {
|
||||
c.str(3, &format!("{family}.ttf"));
|
||||
}
|
||||
c.raw(4, "");
|
||||
if self.version.r2000() && !family.is_empty() {
|
||||
c.raw(1001, "ACAD");
|
||||
c.str(1000, family);
|
||||
c.int(1071, 0);
|
||||
}
|
||||
}
|
||||
c.end_table();
|
||||
}
|
||||
|
||||
fn objects(&self, c: &mut Codes) {
|
||||
let v = self.version;
|
||||
let images = !self.images.is_empty();
|
||||
c.section("OBJECTS");
|
||||
let dict = |c: &mut Codes, h: Handle, owner: Handle, entries: &[(&str, Handle)]| {
|
||||
c.object("DICTIONARY", h, owner, "AcDbDictionary");
|
||||
c.int(281, 1);
|
||||
for (name, e) in entries {
|
||||
c.str(3, name);
|
||||
c.handle(350, *e);
|
||||
}
|
||||
};
|
||||
let mut root = vec![("ACAD_GROUP", fixed::GROUPS)];
|
||||
if images {
|
||||
root.extend([("ACAD_IMAGE_DICT", fixed::IMAGE_DICT), ("ACAD_IMAGE_VARS", fixed::IMAGE_VARS)]);
|
||||
}
|
||||
if v.r2000() {
|
||||
root.extend([("ACAD_LAYOUT", fixed::LAYOUTS), ("ACAD_PLOTSTYLENAME", fixed::PLOT_STYLES)]);
|
||||
}
|
||||
dict(c, fixed::ROOT, Handle(0), &root);
|
||||
dict(c, fixed::GROUPS, fixed::ROOT, &[]);
|
||||
if v.r2000() {
|
||||
dict(c, fixed::LAYOUTS, fixed::ROOT, &[("Layout1", fixed::PAPER_LAYOUT), ("Model", fixed::MODEL_LAYOUT)]);
|
||||
c.object("ACDBDICTIONARYWDFLT", fixed::PLOT_STYLES, fixed::ROOT, "AcDbDictionary");
|
||||
c.int(281, 1);
|
||||
c.raw(3, "Normal");
|
||||
c.handle(350, fixed::PLOT_NORMAL);
|
||||
c.raw(100, "AcDbDictionaryWithDefault");
|
||||
c.handle(340, fixed::PLOT_NORMAL);
|
||||
c.object("ACDBPLACEHOLDER", fixed::PLOT_NORMAL, fixed::PLOT_STYLES, "AcDbPlaceHolder");
|
||||
self.layout(c, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD);
|
||||
self.layout(c, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD);
|
||||
}
|
||||
if images {
|
||||
c.object("RASTERVARIABLES", fixed::IMAGE_VARS, fixed::ROOT, "AcDbRasterVariables");
|
||||
c.int(90, 0);
|
||||
c.int(70, 0);
|
||||
c.int(71, 1);
|
||||
c.int(72, 0);
|
||||
let entries: Vec<(&str, Handle)> = self.images.iter().map(|i| (i.name.as_str(), i.handle)).collect();
|
||||
dict(c, fixed::IMAGE_DICT, fixed::ROOT, &entries);
|
||||
for img in &self.images {
|
||||
c.raw(0, "IMAGEDEF");
|
||||
c.handle(5, img.handle);
|
||||
c.raw(102, "{ACAD_REACTORS");
|
||||
c.handle(330, fixed::IMAGE_DICT);
|
||||
for (reactor, _) in &img.reactors {
|
||||
c.handle(330, *reactor);
|
||||
}
|
||||
c.raw(102, "}");
|
||||
c.handle(330, fixed::IMAGE_DICT);
|
||||
c.raw(100, "AcDbRasterImageDef");
|
||||
c.int(90, 0);
|
||||
c.str(1, &img.name);
|
||||
c.xy(10, f64::from(img.size.0), f64::from(img.size.1));
|
||||
c.xy(11, 1.0, 1.0);
|
||||
c.int(280, 1);
|
||||
c.int(281, 0);
|
||||
for (reactor, image) in &img.reactors {
|
||||
c.object("IMAGEDEF_REACTOR", *reactor, *image, "AcDbRasterImageDefReactor");
|
||||
c.int(90, 2);
|
||||
c.handle(330, *image);
|
||||
}
|
||||
}
|
||||
}
|
||||
c.end_section();
|
||||
}
|
||||
|
||||
/// A layout (DXF 2000 and later): its plot settings, then the layout itself.
|
||||
fn layout(&self, c: &mut Codes, h: Handle, name: &str, tab: i64, record: Handle) {
|
||||
let [x0, y0, x1, y1] = self.extents;
|
||||
c.object("LAYOUT", h, fixed::LAYOUTS, "AcDbPlotSettings");
|
||||
c.raw(1, "");
|
||||
c.raw(2, "none_device");
|
||||
c.raw(4, "");
|
||||
c.raw(6, "");
|
||||
for code in 40..=49 {
|
||||
c.num(code, 0.0);
|
||||
}
|
||||
c.num(140, 0.0);
|
||||
c.num(141, 0.0);
|
||||
c.num(142, 1.0);
|
||||
c.num(143, 1.0);
|
||||
c.int(70, 688);
|
||||
c.int(72, 1);
|
||||
c.int(73, 0);
|
||||
c.int(74, 5);
|
||||
c.raw(7, "");
|
||||
c.int(75, 16);
|
||||
c.num(147, 1.0);
|
||||
c.xy(148, 0.0, 0.0);
|
||||
c.raw(100, "AcDbLayout");
|
||||
c.raw(1, name);
|
||||
c.int(70, 1);
|
||||
c.int(71, tab);
|
||||
let [lx0, ly0, lx1, ly1] = self.limits;
|
||||
c.xy(10, lx0, ly0);
|
||||
c.xy(11, lx1, ly1);
|
||||
c.xyz(12, 0.0, 0.0);
|
||||
c.xyz(14, x0, y0);
|
||||
c.xyz(15, x1, y1);
|
||||
c.num(146, 0.0);
|
||||
c.xyz(13, 0.0, 0.0);
|
||||
c.xyz(16, 1.0, 0.0);
|
||||
c.xyz(17, 0.0, 1.0);
|
||||
c.int(76, 1);
|
||||
c.handle(330, record);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn numbers_and_text_are_written_plainly() {
|
||||
assert_eq!(num(1.5), "1.5");
|
||||
assert_eq!(num(-0.0000001), "0");
|
||||
assert_eq!(num(2.0), "2");
|
||||
assert_eq!(num(f64::NAN), "0");
|
||||
assert_eq!(text("a^b\nc", true), "a^ b c");
|
||||
assert_eq!(text("Größe 😀", false), "Gr\\U+00F6\\U+00DFe ?");
|
||||
assert_eq!(text("Größe", true), "Größe");
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ vectorcraft-trace = { workspace = true }
|
|||
vectorcraft-svg = { workspace = true }
|
||||
vectorcraft-format = { workspace = true }
|
||||
vectorcraft-pdf = { workspace = true }
|
||||
vectorcraft-cad = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
//! DXF export for every DXF path (`document.export {format: dxf}`, `document.exportDxf`, Export As
|
||||
//! → DXF Options): the options parsed from the params, one drawing per artboard with Use
|
||||
//! Artboards, and the formats that can't be written (DWG, PICT) with what to use instead.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_cad::{ColorDepth, DxfOptions, DxfVersion, Preserve, RasterFormat};
|
||||
use vectorcraft_doc::{Document, Unit};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::super::*;
|
||||
use super::{ArtboardPick, Encoded, FormatOption};
|
||||
|
||||
const C: &str = "document.export";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"document.exportDxf",
|
||||
"Export DXF",
|
||||
[],
|
||||
None,
|
||||
"{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (every stroke as its filled outline), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills solid hatches. Same as document.export {format: dxf}",
|
||||
has_doc,
|
||||
export_dxf
|
||||
)]
|
||||
}
|
||||
|
||||
/// The DXF options `document.formats` lists.
|
||||
pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
super::ARTBOARD,
|
||||
super::ARTBOARDS,
|
||||
super::RANGE,
|
||||
super::USE_ARTBOARDS,
|
||||
FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" },
|
||||
FormatOption {
|
||||
name: "unit",
|
||||
ty: "string",
|
||||
default: "\"mm\"",
|
||||
description: "the art unit that `scale` drawing units stand for (pt, in, mm, cm, m, ft…)",
|
||||
},
|
||||
FormatOption { name: "scale", ty: "number", default: "1", description: "drawing units per unit (1 mm = 1 unit)" },
|
||||
FormatOption { name: "scaleLineweights", ty: "boolean", default: "false", description: "lineweights grow with the scale" },
|
||||
FormatOption {
|
||||
name: "colors",
|
||||
ty: "string",
|
||||
default: "\"true\"",
|
||||
description: "8, 16 or 256 indexed colours, or true colour (DXF 2004 and later)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "rasterFormat",
|
||||
ty: "string",
|
||||
default: "\"png\"",
|
||||
description: "png or jpeg: the files placed images are written to, next to the drawing",
|
||||
},
|
||||
FormatOption {
|
||||
name: "preserve",
|
||||
ty: "string",
|
||||
default: "\"appearance\"",
|
||||
description: "appearance (type as outlines, strokes a CAD line can't draw as filled outlines) or editability (type as text, strokes as lines)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "alterPaths",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "Alter Paths for Appearance: every stroke as its filled outline",
|
||||
},
|
||||
FormatOption { name: "outlineText", ty: "boolean", default: "false", description: "type as glyph outlines (always with preserve: appearance)" },
|
||||
FormatOption { name: "selectedOnly", ty: "boolean", default: "false", description: "only the selected objects, in their layers" },
|
||||
];
|
||||
|
||||
/// A format that can be neither opened nor written, and what to use instead.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Unsupported {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
/// Lower-case extensions without the dot.
|
||||
pub extensions: &'static [&'static str],
|
||||
pub hint: &'static str,
|
||||
}
|
||||
|
||||
/// Formats people ask for that VectorCraft doesn't read or write. Append-only.
|
||||
pub const UNSUPPORTED: &[Unsupported] = &[
|
||||
Unsupported {
|
||||
id: "dwg",
|
||||
label: "DWG",
|
||||
extensions: &["dwg"],
|
||||
hint: "DWG is not available (no openly licensed writer exists): export DXF instead, which CAD apps open",
|
||||
},
|
||||
Unsupported {
|
||||
id: "pict",
|
||||
label: "PICT",
|
||||
extensions: &["pict", "pct", "pic"],
|
||||
hint: "PICT is a retired format and is not supported: convert the file to PDF or PNG first",
|
||||
},
|
||||
];
|
||||
|
||||
/// The unsupported format an id or extension (any case, leading dot allowed) names.
|
||||
pub fn unsupported(id_or_ext: &str) -> Option<&'static Unsupported> {
|
||||
let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase();
|
||||
UNSUPPORTED.iter().find(|u| u.id == k || u.extensions.contains(&k.as_str()))
|
||||
}
|
||||
|
||||
impl Unsupported {
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({ "id": self.id, "label": self.label, "extensions": self.extensions, "hint": self.hint })
|
||||
}
|
||||
}
|
||||
|
||||
/// The DXF params, as they come (each checked when read).
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct Params {
|
||||
#[serde(flatten)]
|
||||
boards: ArtboardPick,
|
||||
version: Option<Value>,
|
||||
unit: Option<String>,
|
||||
scale: Option<f64>,
|
||||
scale_lineweights: Option<bool>,
|
||||
colors: Option<Value>,
|
||||
raster_format: Option<String>,
|
||||
preserve: Option<String>,
|
||||
alter_paths: Option<bool>,
|
||||
outline_text: Option<bool>,
|
||||
}
|
||||
|
||||
/// A string or number param as text (`2018` and `"2018"` alike).
|
||||
fn text(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `field` read by `read`, or an error listing what it may be.
|
||||
fn choice<T>(field: &str, v: Option<String>, read: impl Fn(&str) -> Option<T>, default: T, allowed: &str) -> Result<T> {
|
||||
match v {
|
||||
None => Ok(default),
|
||||
Some(s) => read(&s).ok_or_else(|| bad(C, format!("DXF {field} `{s}`: {allowed}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The DXF options in `p` (the region and crop are set per drawing) and the artboard choice.
|
||||
fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> {
|
||||
let q: Params = if p.is_object() { Params::deserialize(p).map_err(|e| bad(C, format!("DXF options: {e}")))? } else { Params::default() };
|
||||
let d = DxfOptions::default();
|
||||
let scale = q.scale.unwrap_or(d.scale);
|
||||
if !(scale.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&scale)) {
|
||||
return Err(bad(C, format!("DXF scale must be a positive number up to {}, not {scale}", vectorcraft_cad::SCALE_RANGE.end())));
|
||||
}
|
||||
let opts = DxfOptions {
|
||||
version: choice(
|
||||
"version",
|
||||
q.version.as_ref().map(text),
|
||||
DxfVersion::from_id,
|
||||
d.version,
|
||||
"R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018",
|
||||
)?,
|
||||
unit: choice("unit", q.unit, Unit::named, d.unit, "a unit such as mm, cm, in or pt")?,
|
||||
scale,
|
||||
scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights),
|
||||
colors: choice("colors", q.colors.as_ref().map(text), ColorDepth::from_id, d.colors, "8, 16, 256 or true")?,
|
||||
raster: choice("rasterFormat", q.raster_format, RasterFormat::from_id, d.raster, "png or jpeg")?,
|
||||
preserve: choice("preserve", q.preserve, Preserve::from_id, d.preserve, "appearance or editability")?,
|
||||
alter_paths: q.alter_paths.unwrap_or(d.alter_paths),
|
||||
outline_text: q.outline_text.unwrap_or(d.outline_text),
|
||||
..d
|
||||
};
|
||||
Ok((opts, q.boards))
|
||||
}
|
||||
|
||||
/// Check the DXF options in `p` without exporting (dialogs keep themselves open on an error).
|
||||
pub fn check(p: &Value) -> std::result::Result<(), String> {
|
||||
parse(p).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Encode `doc` as DXF: one drawing whose origin is the chosen artboard's bottom-left corner
|
||||
/// (default the first), or with `use_artboards` one drawing per chosen artboard (default all)
|
||||
/// holding the art over it.
|
||||
pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) -> Result<Encoded> {
|
||||
let (opts, pick) = parse(p)?;
|
||||
let n = doc.artboards.len();
|
||||
let rect = |b: usize| doc.artboards.get(b).map(|a| a.rect).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)));
|
||||
let drawings: Vec<(Option<usize>, Rect, bool)> = match use_artboards {
|
||||
Some(true) => {
|
||||
let boards = pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
|
||||
boards.into_iter().map(|b| rect(b).map(|r| (Some(b), r, true))).collect::<Result<_>>()?
|
||||
}
|
||||
_ if n == 0 => vec![(None, doc.art_bounds().unwrap_or(Rect::ZERO), false)],
|
||||
_ => {
|
||||
let b = match pick.resolve(n).map_err(|e| bad(C, e))?.as_deref() {
|
||||
None => 0,
|
||||
Some([b]) => *b,
|
||||
Some(_) => return Err(bad(C, "a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard")),
|
||||
};
|
||||
vec![(Some(b), rect(b)?, false)]
|
||||
}
|
||||
};
|
||||
let mut enc = Encoded::default();
|
||||
for (board, region, crop) in drawings {
|
||||
let out = vectorcraft_cad::export(doc, &DxfOptions { region, crop, ..opts.clone() }).map_err(|e| bad(C, e))?;
|
||||
enc.files.push((board, out.bytes));
|
||||
for w in out.warnings {
|
||||
if !enc.warnings.contains(&w) {
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
for img in out.images {
|
||||
if !enc.linked.iter().any(|l| l.name == img.name) {
|
||||
enc.linked.push(vectorcraft_svg::LinkedImage { name: img.name, bytes: Arc::new(img.bytes) });
|
||||
}
|
||||
}
|
||||
}
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut q = if p.is_object() { p.clone() } else { json!({}) };
|
||||
q["format"] = json!("dxf");
|
||||
super::export::export(s, &q)
|
||||
}
|
||||
|
|
@ -224,9 +224,9 @@ pub(crate) fn anti_alias(id: &str) -> std::result::Result<AntiAlias, String> {
|
|||
AntiAlias::from_id(id).ok_or_else(|| format!("antiAlias `{id}`: none, art or type"))
|
||||
}
|
||||
|
||||
/// The `useArtboards` param of a PDF or raster export (SVG has its own): `true` writes every
|
||||
/// The `useArtboards` param of a PDF, DXF or raster export (SVG has its own): `true` writes every
|
||||
/// chosen artboard (default all), `false` the bounds of the visible art instead, absent one
|
||||
/// artboard (raster) or the chosen pages (PDF).
|
||||
/// artboard (raster, DXF) or the chosen pages (PDF).
|
||||
fn use_artboards(p: &Value) -> Result<Option<bool>> {
|
||||
match p.get("useArtboards") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
|
|
@ -293,7 +293,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
|||
let doc = &*doc.without_edit_modes();
|
||||
let n = doc.artboards.len();
|
||||
// SVG reads its own `useArtboards` (an SVG option).
|
||||
let use_artboards = if f.raster || f.id == "pdf" { use_artboards(p)? } else { None };
|
||||
let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf") { use_artboards(p)? } else { None };
|
||||
if use_artboards == Some(false) {
|
||||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
let mut q = super::export::without_artboards(p);
|
||||
|
|
@ -312,6 +312,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
|||
}
|
||||
"txt" => super::text::encode(doc, p)?,
|
||||
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
|
||||
"dxf" => return super::dxf::encode(doc, p, use_artboards),
|
||||
"pdf" => {
|
||||
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
|
||||
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ use std::sync::Arc;
|
|||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use crate::DocState;
|
||||
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
|
|
@ -48,6 +50,40 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
|
||||
}
|
||||
|
||||
/// The document an export of `st` writes: the whole document, or with `selectedOnly` just the
|
||||
/// selected objects, each in its layers and groups (a clipping group keeps its clipping path).
|
||||
pub fn export_source<'a>(st: &'a DocState, p: &Value) -> Result<Cow<'a, Document>> {
|
||||
if !bool_or(p, "selectedOnly", false) {
|
||||
return Ok(Cow::Borrowed(&*st.doc));
|
||||
}
|
||||
let keep: HashSet<NodeId> = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc)).into_iter().collect();
|
||||
if keep.is_empty() {
|
||||
return Err(bad("document.export", "select something to export (or turn off selectedOnly)"));
|
||||
}
|
||||
let mut d = Document::clone(&st.doc);
|
||||
d.layers = st.doc.layers.iter().filter_map(|l| selected_part(l, &keep)).map(Arc::new).collect();
|
||||
Ok(Cow::Owned(d))
|
||||
}
|
||||
|
||||
/// `n` with only the parts in `keep` (whole, with everything inside them), `None` when none is.
|
||||
fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
|
||||
if keep.contains(&n.id) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let children = n.children()?;
|
||||
let mut kept: Vec<Arc<Node>> = children.iter().filter_map(|c| selected_part(c, keep).map(Arc::new)).collect();
|
||||
if kept.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
|
||||
if let Some(first) = children.first().filter(|f| clip && kept.first().is_none_or(|k| k.id != f.id)) {
|
||||
kept.insert(0, first.clone());
|
||||
}
|
||||
let mut out = n.clone();
|
||||
*out.children_mut()? = kept;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `p` without its artboard choice, also inside its SVG options (for documents made of one
|
||||
/// synthetic artboard, and for callers that pick the artboard themselves).
|
||||
pub(super) fn without_artboards(p: &Value) -> Value {
|
||||
|
|
@ -62,13 +98,13 @@ pub(super) fn without_artboards(p: &Value) -> Value {
|
|||
q
|
||||
}
|
||||
|
||||
/// The document an export of `f` writes: the active one, or for text with `selectionOnly` the
|
||||
/// selected objects alone.
|
||||
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
|
||||
/// with `selectionOnly` the selected objects alone.
|
||||
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Cow<'a, Document>> {
|
||||
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
|
||||
return Ok(Cow::Owned(selection(s, cmd)?.0));
|
||||
}
|
||||
Ok(Cow::Borrowed(&s.doc()?.doc))
|
||||
export_source(s.doc()?, p)
|
||||
}
|
||||
|
||||
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
|
||||
|
|
@ -117,7 +153,7 @@ fn screen_format(row: &Value) -> Result<ScreenFormat> {
|
|||
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
|
||||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if matches!(format.id, "vectorcraft" | "template") {
|
||||
if matches!(format.id, "vectorcraft" | "template" | "dxf") {
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, gif, png8, svg, svgz or pdf"));
|
||||
}
|
||||
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one
|
||||
|
|
|
|||
|
|
@ -114,7 +114,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
if vectorcraft_pdf::is_postscript(bytes) {
|
||||
return Err(err(format!("can't open `{}`: {}", file_name(name), vectorcraft_pdf::PdfError::PostScript)));
|
||||
}
|
||||
let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?;
|
||||
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
|
||||
Some(u) => err(format!("can't open `{}`: {}", file_name(name), u.hint)),
|
||||
None => err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")),
|
||||
})?;
|
||||
let title = file_name(name);
|
||||
let mut converted = false;
|
||||
let (mut doc, warnings, restored) = match format.id {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@
|
|||
//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
|
||||
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
|
||||
//! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`.
|
||||
//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT).
|
||||
//!
|
||||
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
|
||||
//! agents query `document.formats`.
|
||||
|
||||
mod batch;
|
||||
pub mod dxf;
|
||||
mod encode;
|
||||
mod export;
|
||||
mod imagemap;
|
||||
|
|
@ -28,8 +30,10 @@ mod text;
|
|||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
|
||||
pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
pub use export::export_source;
|
||||
use load::err;
|
||||
pub(crate) use load::source;
|
||||
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
|
||||
|
|
@ -56,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Serialize Document",
|
||||
[],
|
||||
None,
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
has_doc,
|
||||
export::serialize
|
||||
),
|
||||
|
|
@ -65,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
|
|
@ -93,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"File Formats",
|
||||
[],
|
||||
None,
|
||||
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}}], readable: [id…], writable: [id…], openExtensions: [ext…]}",
|
||||
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}",
|
||||
always,
|
||||
formats
|
||||
),
|
||||
|
|
@ -391,6 +395,7 @@ pub const FORMATS: &[Format] = &[
|
|||
},
|
||||
Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS },
|
||||
Format { id: "txt", label: "Text", extensions: TEXT_EXTS, mime: "text/plain", read: false, write: true, raster: false, options: text::OPTIONS },
|
||||
Format { id: "dxf", label: "DXF", extensions: &["dxf"], mime: "image/vnd.dxf", read: false, write: true, raster: false, options: dxf::OPTIONS },
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
|
|
@ -447,9 +452,11 @@ pub fn format_for_name(name: &str) -> Option<&'static Format> {
|
|||
|
||||
/// The format to write: `format` (an id or extension), else the path's extension, else PNG.
|
||||
pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
|
||||
// Formats that can't be written say what to use instead.
|
||||
let unknown = |what: String, key: &str| unsupported(key).map_or(what, |u| u.hint.to_string());
|
||||
let f = match (format_param, path.map(extension).filter(|e| !e.is_empty())) {
|
||||
(Some(f), _) => format(f).ok_or_else(|| format!("unknown format `{f}` (see document.formats)"))?,
|
||||
(None, Some(e)) => format(&e).ok_or_else(|| format!("unknown extension `.{e}`: pass `format` (see document.formats)"))?,
|
||||
(Some(f), _) => format(f).ok_or_else(|| unknown(format!("unknown format `{f}` (see document.formats)"), f))?,
|
||||
(None, Some(e)) => format(&e).ok_or_else(|| unknown(format!("unknown extension `.{e}`: pass `format` (see document.formats)"), &e))?,
|
||||
(None, None) => format("png").ok_or("no PNG encoder")?,
|
||||
};
|
||||
if f.write { Ok(f) } else { Err(format!("{} files can be opened but not written (see document.formats)", f.label)) }
|
||||
|
|
@ -467,6 +474,7 @@ fn formats(_: &mut Session, _: &Value) -> Result<Value> {
|
|||
"readable": ids(|f| f.read),
|
||||
"writable": ids(|f| f.write),
|
||||
"openExtensions": OPEN_EXTS,
|
||||
"unsupported": UNSUPPORTED.iter().map(Unsupported::to_json).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -645,3 +653,6 @@ mod tests_palette;
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests_text;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_dxf;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() {
|
|||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt"]);
|
||||
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf"]);
|
||||
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
|
||||
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||||
assert_eq!(png["options"]["scale"]["default"], 1);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
//! DXF export through the commands: options, artboards, selection, images, and the formats that
|
||||
//! can't be written (DWG, PICT).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session(width: f64, height: f64, artboards: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
fn text(v: &Value) -> String {
|
||||
String::from_utf8(b64(v)).unwrap()
|
||||
}
|
||||
|
||||
/// The value after header variable `var`.
|
||||
fn header(dxf: &str, var: &str) -> Option<String> {
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
let i = lines.iter().position(|l| *l == var)?;
|
||||
lines.get(i + 2).map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// The entity types of a DXF, in order.
|
||||
fn entities(dxf: &str) -> Vec<String> {
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
let start = lines.windows(3).position(|w| w[1] == "ENTITIES" && w[0].trim() == "2").unwrap();
|
||||
let pairs: Vec<(&str, &str)> = lines[start + 2..].chunks(2).map(|p| (p[0].trim(), p[1])).collect();
|
||||
pairs.iter().take_while(|p| p.1 != "ENDSEC").filter(|p| p.0 == "0").map(|p| p.1.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Two artboards side by side, a rectangle on each.
|
||||
fn two_boards() -> Session {
|
||||
let mut s = session(100.0, 100.0, 2);
|
||||
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_dxf_of_the_chosen_version() {
|
||||
let mut s = two_boards();
|
||||
let r = s.execute("document.export", &json!({"format": "dxf"})).unwrap();
|
||||
assert_eq!(r["format"], "dxf");
|
||||
let dxf = text(&r);
|
||||
assert_eq!(header(&dxf, "$ACADVER").as_deref(), Some("AC1032"));
|
||||
assert_eq!(header(&dxf, "$INSUNITS").as_deref(), Some("4"), "millimetres by default");
|
||||
assert_eq!(entities(&dxf), ["HATCH", "HATCH"], "the whole document, origin at the first artboard");
|
||||
for (v, acadver) in [("R12", "AC1009"), ("R14", "AC1014"), ("2000", "AC1015"), ("2004", "AC1018"), ("2013", "AC1027")] {
|
||||
let r = s.execute("document.exportDxf", &json!({"version": v})).unwrap();
|
||||
assert_eq!(header(&text(&r), "$ACADVER").as_deref(), Some(acadver), "{v}");
|
||||
}
|
||||
// A number reads as a version too; serialize gives the same bytes.
|
||||
assert_eq!(header(&text(&s.execute("document.exportDxf", &json!({"version": 2007})).unwrap()), "$ACADVER").as_deref(), Some("AC1021"));
|
||||
let ser = s.execute("document.serialize", &json!({"format": "dxf", "unit": "in"})).unwrap();
|
||||
assert_eq!(header(&text(&ser), "$INSUNITS").as_deref(), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_artboards_writes_one_drawing_per_artboard() {
|
||||
let mut s = two_boards();
|
||||
let r = s.execute("document.export", &json!({"format": "dxf", "useArtboards": true})).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Untitled-1-Artboard-1.dxf", "Untitled-1-Artboard-2.dxf"]);
|
||||
for f in files {
|
||||
assert_eq!(entities(&text(f)), ["HATCH"], "each holds the art over its artboard");
|
||||
}
|
||||
let r = s.execute("document.export", &json!({"format": "dxf", "useArtboards": true, "range": "2"})).unwrap();
|
||||
assert!(r.get("files").is_none());
|
||||
assert_eq!(entities(&text(&r)), ["HATCH"]);
|
||||
// Without artboards: the origin at the art's bounds.
|
||||
let r = s.execute("document.export", &json!({"format": "dxf", "useArtboards": false, "unit": "pt"})).unwrap();
|
||||
let dxf = text(&r);
|
||||
assert_eq!(header(&dxf, "$LIMMIN").as_deref(), Some("0"));
|
||||
assert!(s.execute("document.export", &json!({"format": "dxf", "artboard": 5})).is_err());
|
||||
let e = s.execute("document.export", &json!({"format": "dxf", "range": "1-2"})).unwrap_err().to_string();
|
||||
assert!(e.contains("useArtboards"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_only_exports_the_selection_in_its_layers() {
|
||||
let mut s = two_boards();
|
||||
s.execute("layer.new", &json!({"name": "Top"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
|
||||
let line = s.execute("shape.line", &json!({"x1": 0, "y1": 90, "x2": 90, "y2": 90})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [line]})).unwrap();
|
||||
let dxf = text(&s.execute("document.export", &json!({"format": "dxf", "selectedOnly": true})).unwrap());
|
||||
assert_eq!(entities(&dxf), ["LWPOLYLINE"]);
|
||||
assert!(dxf.contains("\n 2\nTop\n") && !dxf.contains("\n 2\nLayer 1\n"), "only the selection's layer");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let e = s.execute("document.export", &json!({"format": "dxf", "selectedOnly": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("select something"), "{e}");
|
||||
// Other formats take it too.
|
||||
s.execute("select.set", &json!({"ids": [line]})).unwrap();
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg", "selectedOnly": true})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert_eq!((svg.matches("<path").count(), svg.matches("<rect").count()), (1, 0), "{svg}");
|
||||
assert!(svg.contains("id=\"Top\"") && !svg.contains("Layer"), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_images_come_back_as_linked_files() {
|
||||
let mut s = super::tests_svg::image_session();
|
||||
let r = s.execute("document.export", &json!({"format": "dxf", "rasterFormat": "jpeg"})).unwrap();
|
||||
assert_eq!(entities(&text(&r)), ["IMAGE"]);
|
||||
let linked = r["linked"].as_array().unwrap();
|
||||
assert_eq!(linked.len(), 1);
|
||||
assert!(linked[0]["name"].as_str().unwrap().ends_with(".jpg"));
|
||||
assert_eq!(&b64(&linked[0])[..2], [0xFF, 0xD8]);
|
||||
let dir = std::env::temp_dir().join(format!("vc-dxf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("plan.dxf").to_string_lossy().to_string();
|
||||
let r = s.execute("document.exportDxf", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["path"], path.as_str());
|
||||
let png = r["linked"][0].as_str().unwrap();
|
||||
assert!(png.ends_with(".png") && std::fs::read(png).unwrap().starts_with(b"\x89PNG"));
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains(&png.rsplit(['/', '\\']).next().unwrap().to_string()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_dxf_options_are_refused() {
|
||||
let mut s = two_boards();
|
||||
for p in [
|
||||
json!({"version": "2019"}),
|
||||
json!({"colors": 12}),
|
||||
json!({"scale": 0}),
|
||||
json!({"scale": -2}),
|
||||
json!({"unit": "furlong"}),
|
||||
json!({"rasterFormat": "gif"}),
|
||||
json!({"preserve": "both"}),
|
||||
json!({"alterPaths": "yes"}),
|
||||
] {
|
||||
let e = s.execute("document.exportDxf", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("DXF"), "{p}: {e}");
|
||||
}
|
||||
// Export for Screens writes images, SVG and PDF only.
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "dxf"}]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_list_dxf_and_what_can_not_be_written() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let dxf = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "dxf").unwrap().clone();
|
||||
assert_eq!((dxf["read"].as_bool(), dxf["write"].as_bool()), (Some(false), Some(true)));
|
||||
for o in [
|
||||
"version",
|
||||
"unit",
|
||||
"scale",
|
||||
"scaleLineweights",
|
||||
"colors",
|
||||
"rasterFormat",
|
||||
"preserve",
|
||||
"alterPaths",
|
||||
"outlineText",
|
||||
"selectedOnly",
|
||||
"useArtboards",
|
||||
] {
|
||||
assert!(dxf["options"].get(o).is_some(), "{o}");
|
||||
}
|
||||
assert!(r["writable"].as_array().unwrap().contains(&json!("dxf")));
|
||||
let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(unsupported, ["dwg", "pict"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dwg_and_pict_say_what_to_use_instead() {
|
||||
let mut s = two_boards();
|
||||
for p in [json!({"format": "dwg"}), json!({"path": "/tmp/plan.DWG"})] {
|
||||
let e = s.execute("document.export", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("DWG is not available") && e.contains("DXF"), "{p}: {e}");
|
||||
}
|
||||
let e = s.execute("document.export", &json!({"format": "pict"})).unwrap_err().to_string();
|
||||
assert!(e.contains("PICT is a retired format"), "{e}");
|
||||
let e = s
|
||||
.execute("document.open", &json!({"name": "old.pct", "dataBase64": vectorcraft_format::base64_encode(&[0u8; 600])}))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.contains("old.pct") && e.contains("PICT"), "{e}");
|
||||
let e = s
|
||||
.execute("document.open", &json!({"name": "site.dwg", "dataBase64": vectorcraft_format::base64_encode(b"AC1032\0\0")}))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.contains("DWG is not available"), "{e}");
|
||||
}
|
||||
|
|
@ -222,6 +222,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(place::specs());
|
||||
v.extend(links::specs());
|
||||
v.extend(pdfcmds::specs());
|
||||
v.extend(fileio::dxf::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ fn export_tool_lists_engine_formats_and_options() {
|
|||
let tools = tool_definitions();
|
||||
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
|
||||
let props = &export["inputSchema"]["properties"];
|
||||
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt"]));
|
||||
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf"]));
|
||||
for k in ["artboard", "range", "options"] {
|
||||
assert!(props.get(k).is_some(), "export takes {k}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ vectorcraft-engine = { workspace = true }
|
|||
vectorcraft-svg = { workspace = true }
|
||||
vectorcraft-effects = { workspace = true }
|
||||
vectorcraft-pdf = { workspace = true }
|
||||
vectorcraft-cad = { workspace = true }
|
||||
vectorcraft-format = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui_extras = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
//! DXF Options (Export As → DXF): version, scale (1 unit of the art = N drawing units) and
|
||||
//! lineweights, number of colours, the format of placed images, Preserve Appearance or Maximize
|
||||
//! Editability, and Export Selected Art Only, Alter Paths for Appearance and Outline Text.
|
||||
//!
|
||||
//! Fields: `document.export` params (`format: dxf`, `path?`, the artboard choice) and the DXF
|
||||
//! options of `document.exportDxf`. OK remembers the options for next time and writes the file
|
||||
//! (asking where when there is no path); bad options keep the dialog open.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_cad::{ColorDepth, DxfVersion, Preserve, RasterFormat};
|
||||
use vectorcraft_doc::Unit;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
|
||||
use super::DialogSpec;
|
||||
use super::document_setup::{LABEL, check, choice};
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, io, widgets};
|
||||
|
||||
pub(super) const KIND: &str = "dxfOptions";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "DXF Options".into(), body, confirm, ok: Some("Export"), min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM };
|
||||
|
||||
/// Fields that are not remembered between exports (they belong to one export).
|
||||
const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"];
|
||||
|
||||
/// The options a first DXF Options dialog starts from (the export's defaults).
|
||||
fn defaults() -> Map<String, Value> {
|
||||
let mut m = Map::new();
|
||||
for (k, v) in [
|
||||
("version", json!(DxfVersion::default().id())),
|
||||
("unit", json!(Unit::Millimeters.label())),
|
||||
("scale", json!(1)),
|
||||
("scaleLineweights", json!(false)),
|
||||
("colors", json!(ColorDepth::default().id())),
|
||||
("rasterFormat", json!(RasterFormat::default().id())),
|
||||
("preserve", json!(Preserve::default().id())),
|
||||
("alterPaths", json!(false)),
|
||||
("outlineText", json!(false)),
|
||||
("selectedOnly", json!(false)),
|
||||
] {
|
||||
m.insert(k.into(), v);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Open DXF Options for an export whose params (path, artboard choice, any DXF option) are
|
||||
/// `params`, over the options used last.
|
||||
pub fn open(app: &mut VectorcraftApp, params: &Value) {
|
||||
let mut fields = defaults();
|
||||
for given in [&app.ui.dxf_options, params] {
|
||||
if let Some(o) = given.as_object() {
|
||||
fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
}
|
||||
fields.insert("format".into(), json!("dxf"));
|
||||
app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields)));
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let versions: Vec<(&str, &str)> = DxfVersion::ALL.iter().map(|v| (v.id(), v.label())).collect();
|
||||
choice(ui, d, "version", "Version:", &versions);
|
||||
ui.add_space(4.0);
|
||||
scale_row(ui, d);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(LABEL + 8.0);
|
||||
check(ui, d, "scaleLineweights", "Scale Lineweights");
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let depths: Vec<(&str, &str)> = ColorDepth::ALL.iter().map(|c| (c.id(), c.label())).collect();
|
||||
choice(ui, d, "colors", "Number of Colors:", &depths);
|
||||
let version = DxfVersion::from_id(&d.str("version")).unwrap_or_default();
|
||||
if ColorDepth::from_id(&d.str("colors")) == Some(ColorDepth::True) && version < DxfVersion::R2004 {
|
||||
note(ui, "True colour needs version 2004 or later: the nearest of 256 colours is written.");
|
||||
}
|
||||
let rasters: Vec<(&str, &str)> = RasterFormat::ALL.iter().map(|r| (r.id(), r.label())).collect();
|
||||
choice(ui, d, "rasterFormat", "Raster File Format:", &rasters);
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Export Option");
|
||||
let preserve = Preserve::from_id(&d.str("preserve")).unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
for p in Preserve::ALL {
|
||||
if widgets::radio(ui, p.label(), p == preserve, true) {
|
||||
d.fields.insert("preserve".into(), json!(p.id()));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let selection = app.session.active().is_some_and(|st| !st.selection.is_empty());
|
||||
if widgets::check(ui, "Export Selected Art Only", d.bool("selectedOnly") && selection, selection) {
|
||||
d.fields.insert("selectedOnly".into(), json!(!d.bool("selectedOnly")));
|
||||
}
|
||||
check(ui, d, "alterPaths", "Alter Paths for Appearance");
|
||||
// Preserve Appearance always outlines type.
|
||||
let appearance = preserve == Preserve::Appearance;
|
||||
if widgets::check(ui, "Outline Text", appearance || d.bool("outlineText"), !appearance) {
|
||||
d.fields.insert("outlineText".into(), json!(!d.bool("outlineText")));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
super::form::caption(
|
||||
ui,
|
||||
match preserve {
|
||||
Preserve::Appearance => "Type becomes outlines, and strokes a CAD line can't draw become filled shapes.",
|
||||
Preserve::Editability => "Type stays text, and every stroke is a line with its lineweight and dashes.",
|
||||
},
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
/// Scale: 1 [unit] = [scale] Units.
|
||||
fn scale_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let units: Vec<&str> = Unit::ALL.iter().filter(|u| **u != Unit::FeetInches).map(|u| u.label()).collect();
|
||||
let unit = Unit::named(&d.str("unit")).unwrap_or(Unit::Millimeters);
|
||||
widgets::label_row(ui, "Scale:", LABEL, |ui| {
|
||||
ui.label(egui::RichText::new("1").color(t.text));
|
||||
if let Some(u) = widgets::dropdown(ui, "dxf-unit", unit.label(), &units, 120.0).and_then(|i| units.get(i)) {
|
||||
d.fields.insert("unit".into(), json!(u));
|
||||
}
|
||||
ui.label(egui::RichText::new("=").color(t.text));
|
||||
let scale = d.f64("scale", 1.0);
|
||||
if let Some(v) = widgets::plain_field(ui, "dxf-scale", scale, "", 3, 64.0) {
|
||||
let (lo, hi) = (*vectorcraft_cad::SCALE_RANGE.start(), *vectorcraft_cad::SCALE_RANGE.end());
|
||||
d.fields.insert("scale".into(), json!(v.clamp(lo, hi)));
|
||||
}
|
||||
ui.label(egui::RichText::new("Units").color(t.text));
|
||||
});
|
||||
}
|
||||
|
||||
/// A dim line under a field.
|
||||
fn note(ui: &mut egui::Ui, text: &str) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(LABEL + 8.0);
|
||||
super::form::caption(ui, text);
|
||||
});
|
||||
}
|
||||
|
||||
/// Check the options, remember them, and write the file(s).
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let params = super::form::params(d);
|
||||
fileio::dxf::check(¶ms)?;
|
||||
// Nothing selected keeps the dialog open too.
|
||||
if let Some(st) = app.session.active() {
|
||||
fileio::export_source(st, ¶ms).map_err(|e| e.to_string())?;
|
||||
}
|
||||
app.ui.dxf_options =
|
||||
Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect());
|
||||
app.ui.dialog = None;
|
||||
let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
io::export(app, Some("dxf"), path, ¶ms)
|
||||
}
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
//! File → Export → Export As…: the format and Use Artboards (All or a range: one file per
|
||||
//! artboard, or one page each in a PDF); without artboards the export covers the visible art. OK
|
||||
//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG
|
||||
//! Options, Text Export Options, and Save PDF for artboards as pages), else the file is written.
|
||||
//! Options, Text Export Options, DXF Options, and Save PDF for artboards as pages), else the file
|
||||
//! is written. DWG is listed, greyed out, with what to use instead.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format};
|
||||
|
||||
use super::{DialogSpec, form, png_options, save_pdf, svg_options, text_export};
|
||||
use super::{DialogSpec, dxf_options, form, png_options, save_pdf, svg_options, text_export};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, io, widgets};
|
||||
|
|
@ -18,13 +19,26 @@ pub(super) const SPEC: DialogSpec =
|
|||
|
||||
/// The formats Export As writes (the native format is Save's).
|
||||
static FORMATS: LazyLock<Vec<&'static Format>> = LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && f.id != "vectorcraft").collect());
|
||||
static LABELS: LazyLock<Vec<&'static str>> = LazyLock::new(|| FORMATS.iter().map(|f| f.label).collect());
|
||||
/// Formats listed after them that can't be written (`fileio::unsupported` ids), greyed out.
|
||||
const UNAVAILABLE: [(&str, &str); 1] = [("dwg", "DWG (use DXF)")];
|
||||
static LABELS: LazyLock<Vec<&'static str>> = LazyLock::new(|| FORMATS.iter().map(|f| f.label).chain(UNAVAILABLE.iter().map(|u| u.1)).collect());
|
||||
|
||||
/// What to use instead when the field names a format that can't be written (DWG).
|
||||
fn unavailable(d: &Dialog) -> Option<(&'static str, &'static str)> {
|
||||
let id = d.str("format");
|
||||
let (_, label) = UNAVAILABLE.iter().find(|u| u.0 == id)?;
|
||||
Some((label, fileio::unsupported(&id)?.hint))
|
||||
}
|
||||
|
||||
/// Open the dialog on `format` (an id or extension, default PNG); `p` may preset `useArtboards`
|
||||
/// and `range`.
|
||||
pub fn open(app: &mut VectorcraftApp, format: Option<&str>, p: &Value) {
|
||||
let n = app.session.active().map_or(0, |s| s.doc.artboards.len());
|
||||
let format = format.and_then(fileio::format).filter(|f| FORMATS.iter().any(|g| g.id == f.id)).map_or("png", |f| f.id);
|
||||
let missing = format.and_then(|f| UNAVAILABLE.iter().find(|u| u.0.eq_ignore_ascii_case(f.trim_start_matches('.'))));
|
||||
let format = match missing {
|
||||
Some((id, _)) => id,
|
||||
None => format.and_then(fileio::format).filter(|f| FORMATS.iter().any(|g| g.id == f.id)).map_or("png", |f| f.id),
|
||||
};
|
||||
let range = p.get("range").and_then(Value::as_str);
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
"exportAs",
|
||||
|
|
@ -47,13 +61,19 @@ fn chosen(d: &Dialog) -> Option<&'static Format> {
|
|||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(f) = chosen(d) else { return false };
|
||||
let missing = unavailable(d);
|
||||
egui::Grid::new("export-as").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Format:").color(t.text_dim));
|
||||
if let Some(i) = widgets::dropdown(ui, "export-as-format", f.label, &LABELS, 160.0) {
|
||||
d.fields.insert("format".into(), json!(FORMATS[i].id));
|
||||
let shown = missing.map_or(f.label, |(label, _)| label);
|
||||
if let Some(g) = widgets::dropdown_with(ui, "export-as-format", shown, &LABELS, 160.0, |i| i < FORMATS.len()).and_then(|i| FORMATS.get(i)) {
|
||||
d.fields.insert("format".into(), json!(g.id));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
if let Some((_, hint)) = missing {
|
||||
ui.add_space(6.0);
|
||||
form::caption(ui, hint);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
form::check(ui, d, "useArtboards", "Use Artboards");
|
||||
let use_artboards = d.bool("useArtboards");
|
||||
|
|
@ -83,6 +103,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
|
||||
/// Check the artboard range, pick the file, then show the format's options or write the file.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
if let Some((_, hint)) = unavailable(d) {
|
||||
return Err(hint.into());
|
||||
}
|
||||
let f = chosen(d).ok_or("no format to export to")?;
|
||||
let use_artboards = d.bool("useArtboards");
|
||||
let all = d.bool("all");
|
||||
|
|
@ -122,6 +145,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
|||
png_options::open(app, f, params);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if f.id == "dxf" {
|
||||
dxf_options::open(app, ¶ms);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
io::export(app, Some(f.id), Some(path), ¶ms)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ mod color_picker;
|
|||
mod command;
|
||||
pub mod confirm;
|
||||
mod document_setup;
|
||||
pub mod dxf_options;
|
||||
mod effect;
|
||||
pub mod expand;
|
||||
mod export_as;
|
||||
|
|
@ -207,6 +208,7 @@ registry! {
|
|||
SaveOptions: [save_options::KIND] => save_options::SPEC,
|
||||
TextExport: [text_export::KIND] => text_export::SPEC,
|
||||
OfficeExport: [office_export::KIND] => office_export::SPEC,
|
||||
DxfOptions: [dxf_options::KIND] => dxf_options::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
@ -314,3 +316,6 @@ mod tests_export;
|
|||
mod tests_import_pdf;
|
||||
#[cfg(test)]
|
||||
mod tests_raster_formats;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_dxf;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
//! Export As → DXF Options, and DWG listed as unavailable, drawn headlessly.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::tests_export::{app, frame, kind, names, set};
|
||||
use super::*;
|
||||
|
||||
fn text(bytes: &[u8]) -> String {
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_as_dxf_shows_dxf_options_then_writes_and_remembers_them() {
|
||||
let (mut app, written) = app(1);
|
||||
app.run("file.exportAs", json!({"format": "dxf"})).unwrap();
|
||||
assert_eq!(kind(&app), Some("exportAs"));
|
||||
frame(&mut app);
|
||||
set(&mut app, "useArtboards", json!(true));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(kind(&app), Some("dxfOptions"), "DXF Options follow");
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.str("path"), d.str("version"), d.str("preserve")), ("/out/Untitled-1.dxf".into(), "2018".into(), "appearance".into()));
|
||||
frame(&mut app);
|
||||
// Every control draws: R12 with true colour shows its note, Maximize Editability frees Outline Text.
|
||||
set(&mut app, "version", json!("R12"));
|
||||
set(&mut app, "preserve", json!("editability"));
|
||||
set(&mut app, "unit", json!("Inches"));
|
||||
frame(&mut app);
|
||||
set(&mut app, "colors", json!("256"));
|
||||
let r = confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(r["path"], "/out/Untitled-1.dxf");
|
||||
let dxf = text(&written.borrow()[0].1);
|
||||
assert!(dxf.contains("$ACADVER\n 1\nAC1009"), "{dxf}");
|
||||
// The next DXF Options start from these choices, but not from this export's path.
|
||||
crate::menus::invoke(&mut app, "ui.dxfOptionsDialog", json!({}));
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.str("version"), d.str("unit"), d.str("colors")), ("R12".into(), "Inches".into(), "256".into()));
|
||||
assert!(!d.fields.contains_key("path"));
|
||||
assert!(app.ui.dxf_options.get("useArtboards").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_dxf_options_keep_the_dialog_open() {
|
||||
let (mut app, written) = app(1);
|
||||
app.run("ui.dxfOptionsDialog", json!({"path": "/out/a.dxf"})).unwrap();
|
||||
set(&mut app, "scale", json!(-1));
|
||||
assert!(confirm(&mut app).is_err());
|
||||
assert_eq!(kind(&app), Some("dxfOptions"));
|
||||
set(&mut app, "scale", json!(10));
|
||||
// Export Selected Art Only with nothing selected.
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
set(&mut app, "selectedOnly", json!(true));
|
||||
frame(&mut app);
|
||||
let e = confirm(&mut app).unwrap_err();
|
||||
assert!(e.contains("select something"), "{e}");
|
||||
assert!(written.borrow().is_empty());
|
||||
set(&mut app, "selectedOnly", json!(false));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(names(&written), ["/out/a.dxf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dwg_is_listed_but_points_to_dxf() {
|
||||
let (mut app, written) = app(1);
|
||||
app.run("file.exportAs", json!({"format": "dwg"})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "dwg");
|
||||
frame(&mut app);
|
||||
let e = confirm(&mut app).unwrap_err();
|
||||
assert!(e.contains("DWG") && e.contains("DXF"), "{e}");
|
||||
assert_eq!(kind(&app), Some("exportAs"), "the dialog stays open to pick DXF");
|
||||
set(&mut app, "format", json!("dxf"));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(kind(&app), Some("dxfOptions"));
|
||||
assert!(written.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dxf_options_dialog_needs_a_document() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
assert!(app.run("ui.dxfOptionsDialog", json!({})).is_err());
|
||||
assert!(!crate::menus::enabled(&app, "ui.dxfOptionsDialog"));
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ pub(super) fn set(app: &mut VectorcraftApp, field: &str, value: serde_json::Valu
|
|||
app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(field.into(), value);
|
||||
}
|
||||
|
||||
fn names(w: &Written) -> Vec<String> {
|
||||
pub(super) fn names(w: &Written) -> Vec<String> {
|
||||
w.borrow().iter().map(|(p, _)| p.clone()).collect()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -317,12 +317,17 @@ pub fn recent_files(app: &VectorcraftApp) -> &[String] {
|
|||
}
|
||||
|
||||
/// Export the active document in `format` (default: the path's extension, else PNG) with the
|
||||
/// `document.export` options in `params` (artboard, range, useArtboards, ppi, SVG options…):
|
||||
/// every file it writes (one per artboard, linked images) goes next to `path`. The document keeps
|
||||
/// its path. → `{path, warnings, files?}` (`files`: every file when there are several).
|
||||
/// `document.export` options in `params` (artboard, range, useArtboards, selectedOnly, ppi, SVG
|
||||
/// options…): every file it writes (one per artboard, linked images) goes next to `path`. The
|
||||
/// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are
|
||||
/// several).
|
||||
pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<String>, params: &Value) -> Result<Value, String> {
|
||||
let f = fileio::writable_format(format, path.as_deref())?;
|
||||
let doc = app.session.active().ok_or("no document")?.doc.clone();
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let doc = match fileio::export_source(st, params).map_err(|e| e.to_string())? {
|
||||
std::borrow::Cow::Borrowed(_) => st.doc.clone(),
|
||||
std::borrow::Cow::Owned(d) => std::sync::Arc::new(d),
|
||||
};
|
||||
let path = target_path(app, path, f.extensions[0])?;
|
||||
// An SVG given a .svgz name is written compressed.
|
||||
let f = match fileio::format_for_name(&path) {
|
||||
|
|
|
|||
|
|
@ -425,6 +425,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
("file.openRecent29", "Open Recent File 29", "", "{}"),
|
||||
("file.openRecent30", "Open Recent File 30", "", "{}"),
|
||||
("file.reveal", "Show in Folder", "", "{} show the document's file in the system file manager (desktop) → {path}"),
|
||||
(
|
||||
"ui.dxfOptionsDialog",
|
||||
"DXF Options Dialog",
|
||||
"",
|
||||
"{path?, useArtboards?, range?, …document.exportDxf options} open DXF Options (dialog `dxfOptions`: fields = these options over the ones used last); OK checks them, remembers them and writes path (else asks). Export As… → DXF opens it too",
|
||||
),
|
||||
];
|
||||
|
||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||
|
|
@ -793,6 +799,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
// Save for Office Documents…: its dialog; with options, pick the file (or write `path`).
|
||||
"document.exportForOffice" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::office_export::open(app),
|
||||
"document.exportForOffice" => io::save_command_output(app, id, "png", p.clone()).map(|path| json!({ "path": path })),
|
||||
"ui.dxfOptionsDialog" if app.session.active().is_none() => Err("no document".into()),
|
||||
"ui.dxfOptionsDialog" => {
|
||||
crate::dialogs::dxf_options::open(app, p);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
|
|
@ -1019,6 +1030,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
|||
"ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"ui.spotColors" => app.session.active().is_some(),
|
||||
"ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"ui.dxfOptionsDialog" => app.session.active().is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -285,6 +285,9 @@ pub struct UiState {
|
|||
/// dialog is open.
|
||||
#[serde(skip)]
|
||||
pub dialog_file: Option<std::sync::Arc<crate::dialogs::import_pdf::DialogFile>>,
|
||||
/// The DXF Options chosen last (`document.exportDxf` options; null: never used).
|
||||
#[serde(default)]
|
||||
pub dxf_options: Value,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
|
|
@ -343,6 +346,7 @@ impl Default for UiState {
|
|||
svg_options: Value::Null,
|
||||
place_link: true,
|
||||
dialog_file: None,
|
||||
dxf_options: Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -295,3 +295,12 @@ system file manager (desktop only; answers `{path}`), and `file.newFromTemplate`
|
|||
in the Templates folder (preference `templatesFolder`). Older native files open as "<name> [Converted]" (preference
|
||||
`appendConverted`), so their Save asks for a new `.vectorcraft` name. Native files reopen at the view they were saved
|
||||
with.
|
||||
|
||||
DXF Options: Export As… with the DXF format (`file.exportAs {format: "dxf"}`, then the save dialog), or
|
||||
`ui.dxfOptionsDialog {path?, …document.exportDxf options}` for agents, opens the `dxfOptions` dialog. Its fields are
|
||||
`document.export` params: `version`, `unit` (a unit name such as `Millimeters`), `scale`, `scaleLineweights`, `colors`
|
||||
(`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, `editability`), `selectedOnly`,
|
||||
`alterPaths`, `outlineText`, plus `path` and the artboard choice. `ui.dialog.confirm` checks them (a bad value, or
|
||||
Export Selected Art Only with nothing selected, keeps the dialog open), remembers them for the next DXF Options and
|
||||
writes the file (asking for a path when there is none). Export As also lists DWG, greyed out: setting its `format` to
|
||||
`dwg` shows, and on confirm answers, the hint to export DXF instead.
|
||||
|
|
|
|||
27
docs/mcp.md
27
docs/mcp.md
|
|
@ -1029,3 +1029,30 @@ without its formatting, taking the style at the caret.
|
|||
{"name":"run_command","arguments":{"command":"clipboard.importImage","params":{"dataBase64":"iVBORw0KGgo…","center":[300,200]}}}
|
||||
{"name":"run_command","arguments":{"command":"edit.paste","params":{"center":[300,200]}}}
|
||||
```
|
||||
|
||||
## DXF export
|
||||
|
||||
`document.exportDxf` (also `document.export` / `export` with format `dxf`) writes a CAD drawing, ASCII DXF R12 to 2018
|
||||
(`version`: `R12`, `R13`, `R14`, `2000`, `2004`, `2007`, `2010`, `2013`, `2018`; default `2018`). Each layer becomes a
|
||||
DXF layer (hidden layers switched off, locked ones locked, non-printing ones not plotted; template layers left out),
|
||||
straight paths become polylines, curved ones cubic splines through every anchor, fills solid hatches (R12 has none:
|
||||
their outlines), strokes lines with their lineweight and a linetype per dash pattern, and placed images image entities
|
||||
linked to PNG or JPEG files (`rasterFormat`) written next to the drawing (`linked` in the result). Coordinates are y up
|
||||
from the bottom-left corner of the first artboard (or `artboard`), in drawing units: `scale` units per `unit` (default
|
||||
1 mm = 1 unit, which sets `$INSUNITS`); `scaleLineweights` scales the lineweights with them. `colors` is `8`, `16` or
|
||||
`256` indexed colours, or `true` (default; true colour with the nearest index, DXF 2004 and later). `preserve:
|
||||
"appearance"` (default) writes type as glyph outlines and the strokes a CAD line can't draw (inside or outside,
|
||||
width profiles, arrowheads) as filled outlines; `"editability"` keeps type as text and every stroke a line.
|
||||
`alterPaths` writes every stroke as its filled outline, `outlineText` outlines type, `selectedOnly` writes only the
|
||||
selected objects in their layers (every export takes it), and `useArtboards: true` writes one drawing per chosen
|
||||
artboard holding the art over it. What DXF can't hold (gradients and patterns as one colour, blending, opacity masks,
|
||||
raster effects, clipping) comes back in `warnings`.
|
||||
|
||||
DWG can't be written (no openly licensed writer exists): `document.formats` lists it under `unsupported` with that
|
||||
hint, and exporting to it answers with the hint to export DXF. PICT files are not supported either and say so when
|
||||
opened.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.exportDxf","params":{"path":"/tmp/plan.dxf","version":"2013","unit":"mm","scale":10,"preserve":"editability"}}}
|
||||
{"name":"export","arguments":{"path":"/tmp/plan.dxf","options":{"useArtboards":true,"colors":256}}}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ pub const TABLE: &[(&str, Class)] = &[
|
|||
("cli", Class::Exempt),
|
||||
("web", Class::Exempt),
|
||||
("xtask", Class::Exempt),
|
||||
// Crates added later (append-only).
|
||||
("cad", Class::Layer(3)),
|
||||
];
|
||||
|
||||
/// Explicit orderings *within* a layer (earlier may be used by later).
|
||||
|
|
|
|||
Loading…
Reference in New Issue