mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 09:39:52 +00:00
Each writer marks its notes as a loss (something of the document that the file leaves out or that reaches it changed) or as a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The PDF, SVG, EPS, legacy .ai, DXF and HP-GL writers return their notes on the options in a list of their own (`setting_notes`) beside `warnings`, and the engine's `Encoded` does the same. The replies of the save and export commands, and of `clipboard.exportPdf` and `clipboard.exportPng`, list the losses first, each kind in the order the writers gave them, across every file that Export for Screens writes. The status bar names the first note, so an export to lossy WebP of type in a font that isn't installed now names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in.
438 lines
15 KiB
Rust
438 lines
15 KiB
Rust
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an
|
||
//! HP-GL/2 plot file reader and writer.
|
||
//!
|
||
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
|
||
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
|
||
//! become polylines (straight segments) or cubic splines (curves), fills their outlines and 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.
|
||
//!
|
||
//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`].
|
||
//! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`].
|
||
//!
|
||
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
|
||
//! - `writer`: group codes, handles and the file's sections, tables and objects.
|
||
//! - `scene`: the document walk that turns objects into entities.
|
||
//! - `import`: the reader, entities to art, blocks to symbols.
|
||
//! - `hpgl`: the HP-GL reader, the pen's moves to paths, and the writer, paths to pen moves.
|
||
|
||
mod aci;
|
||
pub mod hpgl;
|
||
pub mod import;
|
||
mod scene;
|
||
mod writer;
|
||
|
||
use vectorcraft_doc::{Document, Unit};
|
||
use vectorcraft_geom::Rect;
|
||
|
||
pub use aci::{aci_rgb, nearest_aci};
|
||
pub use import::{DxfInfo, ImportOptions, Imported, import, info, is_dxf};
|
||
|
||
/// How deep symbols, blocks 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;
|
||
|
||
/// The `$INSUNITS` codes of the units documents use (feet and inches write feet).
|
||
const INSUNITS: [(u8, Unit); 7] =
|
||
[(1, Unit::Inches), (2, Unit::Feet), (2, Unit::FeetInches), (4, Unit::Millimeters), (5, Unit::Centimeters), (6, Unit::Meters), (10, Unit::Yards)];
|
||
|
||
/// The `$INSUNITS` code of `unit` (none for points, picas and pixels).
|
||
fn insunits_code(unit: Unit) -> Option<u8> {
|
||
INSUNITS.iter().find(|(_, u)| *u == unit).map(|(c, _)| *c)
|
||
}
|
||
|
||
/// 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, dashes offset into their pattern)
|
||
/// 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,
|
||
/// Placed images are written as IMAGE entities with their files (else left out, undecoded).
|
||
pub include_images: bool,
|
||
/// Filled areas get a solid HATCH after their outlines (else the outlines alone).
|
||
pub include_hatches: bool,
|
||
pub preserve: Preserve,
|
||
/// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`]
|
||
/// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have
|
||
/// no lineweights) 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(),
|
||
include_images: true,
|
||
include_hatches: true,
|
||
preserve: Preserve::default(),
|
||
alter_paths: false,
|
||
outline_text: false,
|
||
region: Rect::ZERO,
|
||
crop: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A named set of DXF options (the `dxfPreset` param): the options it sets, and the ones a set of
|
||
/// options matches.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub enum DxfPreset {
|
||
/// The options as chosen (the export's defaults for those left out).
|
||
#[default]
|
||
Custom,
|
||
/// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight
|
||
/// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize
|
||
/// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths),
|
||
/// and placed images and fill hatches left out (fills are their outlines), with PNG as the
|
||
/// format images take when they are put back in.
|
||
Cnc,
|
||
}
|
||
|
||
impl DxfPreset {
|
||
pub const ALL: [Self; 2] = [Self::Custom, Self::Cnc];
|
||
|
||
/// The `dxfPreset` param value.
|
||
pub fn id(self) -> &'static str {
|
||
match self {
|
||
Self::Custom => "custom",
|
||
Self::Cnc => "cnc",
|
||
}
|
||
}
|
||
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Custom => "Custom",
|
||
Self::Cnc => "CNC / CAM",
|
||
}
|
||
}
|
||
|
||
/// A preset by id (`custom`, `cnc`), any case.
|
||
pub fn from_id(s: &str) -> Option<Self> {
|
||
Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id()))
|
||
}
|
||
|
||
/// The options the preset sets (Custom: the defaults); the region and crop are the defaults.
|
||
pub fn options(self) -> DxfOptions {
|
||
match self {
|
||
Self::Custom => DxfOptions::default(),
|
||
Self::Cnc => DxfOptions {
|
||
version: DxfVersion::R2000,
|
||
unit: Unit::Millimeters,
|
||
scale: 1.0,
|
||
scale_lineweights: false,
|
||
colors: ColorDepth::Aci256,
|
||
raster: RasterFormat::Png,
|
||
include_images: false,
|
||
include_hatches: false,
|
||
preserve: Preserve::Editability,
|
||
alter_paths: false,
|
||
outline_text: true,
|
||
..DxfOptions::default()
|
||
},
|
||
}
|
||
}
|
||
|
||
/// The preset `o` matches: CNC / CAM when every option it sets is as it sets it, else Custom.
|
||
pub fn of(o: &DxfOptions) -> Self {
|
||
let c = Self::Cnc.options();
|
||
let same = o.version == c.version
|
||
&& o.unit == c.unit
|
||
&& o.scale == c.scale
|
||
&& o.scale_lineweights == c.scale_lineweights
|
||
&& o.colors == c.colors
|
||
&& o.raster == c.raster
|
||
&& o.include_images == c.include_images
|
||
&& o.include_hatches == c.include_hatches
|
||
&& o.preserve == c.preserve
|
||
&& o.alter_paths == c.alter_paths
|
||
&& o.outline_text == c.outline_text;
|
||
if same { Self::Cnc } else { Self::Custom }
|
||
}
|
||
}
|
||
|
||
/// 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>,
|
||
/// What was left out because an option turns it off (Include Placed Images or Include Fill
|
||
/// Hatches off).
|
||
pub setting_notes: 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;
|