mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
SVG export: shadows in a masked group show in readers that bound it by its shapes
Some SVG readers draw a masked group only inside the bounding box of its shapes. A drop shadow, glow, blur or feather inside the group paints past those shapes, so these readers left it out, while others drew it. A masked group now starts with a rectangle over the mask's region, unpainted (fill="none") and hidden (visibility="hidden"), when what it holds has a visible raster effect: on a member at any depth, on a fill or stroke, in a symbol's art, or in the art a blend or other live object is written as. The rectangle widens the group's bounding box without drawing anything, and import leaves it out. The masked groups of a knockout group get the same rectangle when an element inside them has such an effect. Other masked art, brushed strokes included, is written as before, and so is an object whose own effects are written outside its mask. Tests: a_masked_group_spans_its_region_when_its_art_paints_past_its_shapes and a_knockout_group_spans_its_masks_when_an_element_paints_past_its_shapes in crates/svg/tests/it/blend_export.rs.
This commit is contained in:
@@ -419,11 +419,66 @@ impl Writer<'_> {
|
||||
self.def(1, "</mask>");
|
||||
self.line(&format!("<g mask=\"url(#{mid})\">"));
|
||||
self.depth += 1;
|
||||
self.spanning_rect(&bare, ®ion);
|
||||
self.node_body(&bare);
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
}
|
||||
|
||||
/// Some readers draw a masked group only inside the bounding box of its shapes, which leaves
|
||||
/// out a drop shadow, blur, glow or feather that paints past them. When `n`, written inside a
|
||||
/// `<g mask>`, holds one ([`Self::holds_raster`]), a hidden, unpainted rectangle over the
|
||||
/// mask's `region` makes that box cover what it paints.
|
||||
fn spanning_rect(&mut self, n: &Node, region: &str) {
|
||||
if self.holds_raster(n) {
|
||||
self.line(&format!("<rect {region} fill=\"none\" visibility=\"hidden\"/>"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Do `n`, its fills and strokes, its members at any depth, its symbols' art or the art its
|
||||
/// live objects are written as have a visible raster effect (shadow, glow, blur, feather)?
|
||||
/// Objects that aren't written (hidden ones, unless the options keep them) are skipped. The
|
||||
/// walk keeps its own stack, and looks at each symbol's art once.
|
||||
fn holds_raster(&self, n: &Node) -> bool {
|
||||
let mut stack: Vec<std::sync::Arc<Node>> = vec![];
|
||||
let mut symbols = std::collections::HashSet::new();
|
||||
// Whether `n` itself has one; else its members, symbol art or expanded art go on the stack.
|
||||
let mut visit = |n: &Node, stack: &mut Vec<std::sync::Arc<Node>>| {
|
||||
if !(n.visible || self.opts.hidden_layers) {
|
||||
return false;
|
||||
}
|
||||
if has_raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_raster(i.effects())) {
|
||||
return true;
|
||||
}
|
||||
// A live object is written as its expanded art ([`Self::node_body`]), which holds no
|
||||
// live objects.
|
||||
if vectorcraft_doc::live::is_live(n) {
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
if !vectorcraft_doc::live::is_live(&g) {
|
||||
stack.push(std::sync::Arc::new(g));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
|
||||
&& symbols.insert(symbol.clone())
|
||||
&& let Some(s) = self.doc.symbols.iter().find(|s| s.name == *symbol)
|
||||
{
|
||||
stack.push(s.art.clone());
|
||||
}
|
||||
stack.extend(n.children().into_iter().flatten().cloned());
|
||||
false
|
||||
};
|
||||
if visit(n, &mut stack) {
|
||||
return true;
|
||||
}
|
||||
while let Some(c) = stack.pop() {
|
||||
if visit(&c, &mut stack) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// What `write` writes to the body, taken out of it (written at `depth`, for `<defs>`).
|
||||
fn detached(&mut self, depth: usize, write: impl FnOnce(&mut Self)) -> String {
|
||||
let (body, saved) = (std::mem::take(&mut self.body), self.depth);
|
||||
@@ -618,11 +673,21 @@ impl Writer<'_> {
|
||||
// Each mask covers what the group's elements paint.
|
||||
let all: Vec<&Node> = elements.iter().map(|c| &***c).collect();
|
||||
let region = self.region_attrs(&all);
|
||||
for (c, (before, _)) in rest.iter().zip(&written).rev() {
|
||||
// The mask group of element i + 1 holds elements 0…i: it starts with a spanning rectangle
|
||||
// when one of those paints past its shapes (`Self::spanning_rect`), as `past[i]` records.
|
||||
let mut past = Vec::with_capacity(elements.len());
|
||||
for c in &elements {
|
||||
let any = past.last().copied().unwrap_or(false) || self.holds_raster(c);
|
||||
past.push(any);
|
||||
}
|
||||
for (i, (c, (before, _))) in rest.iter().zip(&written).enumerate().rev() {
|
||||
self.symbol_nodes = *before;
|
||||
let mid = self.knockout_mask(c, ®ion);
|
||||
self.line(&format!("<g mask=\"url(#{mid})\">"));
|
||||
self.depth += 1;
|
||||
if past.get(i).copied().unwrap_or(false) {
|
||||
self.line(&format!("<rect {region} fill=\"none\" visibility=\"hidden\"/>"));
|
||||
}
|
||||
}
|
||||
self.symbol_nodes = after;
|
||||
self.body.push_str(&first);
|
||||
|
||||
@@ -202,6 +202,174 @@ fn a_mask_region_keeps_the_shadows_of_the_masked_art() {
|
||||
}
|
||||
}
|
||||
|
||||
fn drop_shadow() -> vectorcraft_doc::Effect {
|
||||
vectorcraft_doc::Effect {
|
||||
id: "stylize.dropShadow".into(),
|
||||
params: serde_json::json!({"x": 15, "y": 15, "blur": 0, "opacity": 100}),
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every `<g mask>` in `svg` starts with a hidden, unpainted rectangle over its mask's region,
|
||||
/// and whether any does.
|
||||
fn spanning_rects(svg: &str) -> (bool, bool) {
|
||||
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
|
||||
let spans: Vec<bool> = xml
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("g") && n.has_attribute("mask"))
|
||||
.map(|group| {
|
||||
let id = group.attribute("mask").unwrap().trim_start_matches("url(#").trim_end_matches(')');
|
||||
let mask = xml.descendants().find(|n| n.has_tag_name("mask") && n.attribute("id") == Some(id)).unwrap();
|
||||
let first = group.children().find(|n| n.is_element()).unwrap();
|
||||
first.has_tag_name("rect")
|
||||
&& first.attribute("fill") == Some("none")
|
||||
&& first.attribute("visibility") == Some("hidden")
|
||||
&& ["x", "y", "width", "height"].iter().all(|a| first.attribute(*a).is_some() && first.attribute(*a) == mask.attribute(*a))
|
||||
})
|
||||
.collect();
|
||||
assert!(!spans.is_empty(), "no masked group in {svg}");
|
||||
(spans.iter().all(|s| *s), spans.iter().any(|s| *s))
|
||||
}
|
||||
|
||||
/// The number of paths `svg` imports as.
|
||||
fn imported_paths(svg: &str) -> usize {
|
||||
let (back, warnings) = vectorcraft_svg::import_with_report(svg).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let mut paths = 0;
|
||||
back.walk(|n| paths += usize::from(n.path_data().is_some()));
|
||||
paths
|
||||
}
|
||||
|
||||
/// A masked group whose content holds a raster effect (a drop shadow, blur, glow or feather, which
|
||||
/// paint past the shapes) starts with a hidden, unpainted rectangle over the mask's region, so that
|
||||
/// a reader that draws a masked group only inside the box of its shapes still draws it. Art without
|
||||
/// one gets none, brushed strokes (written as shapes) included, nor does an object whose own shadow
|
||||
/// is drawn outside the mask. Import leaves the rectangle out.
|
||||
#[test]
|
||||
fn a_masked_group_spans_its_region_when_its_art_paints_past_its_shapes() {
|
||||
// How the red square sits under the mask.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Case {
|
||||
Alone,
|
||||
InGroup,
|
||||
InNestedGroup,
|
||||
FillEffect,
|
||||
HiddenInGroup,
|
||||
Brushed,
|
||||
InSymbol,
|
||||
InBlend,
|
||||
}
|
||||
use Case::*;
|
||||
for (case, shadowed, want) in [
|
||||
(InGroup, true, true),
|
||||
(InGroup, false, false),
|
||||
(Alone, true, false),
|
||||
(Alone, false, false),
|
||||
(InNestedGroup, true, true),
|
||||
(FillEffect, true, true),
|
||||
(FillEffect, false, false),
|
||||
(HiddenInGroup, true, false),
|
||||
(Brushed, false, false),
|
||||
(Brushed, true, true),
|
||||
(InSymbol, true, true),
|
||||
(InSymbol, false, false),
|
||||
(InBlend, true, true),
|
||||
(InBlend, false, false),
|
||||
] {
|
||||
let mut d = Document::new(100.0, 60.0);
|
||||
let mut red = rect(&mut d, Rect::new(10.0, 10.0, 30.0, 30.0), [255, 0, 0]);
|
||||
if matches!(case, Brushed) {
|
||||
let mut stroke = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::rgb8(0, 0, 200)), 8.0);
|
||||
stroke.brush = Some("Bristle Round".into());
|
||||
red.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(stroke));
|
||||
}
|
||||
if shadowed {
|
||||
match case {
|
||||
FillEffect => red.appearance.items[0].effects_mut().push(drop_shadow()),
|
||||
_ => red.appearance.effects.push(drop_shadow()),
|
||||
}
|
||||
}
|
||||
let group = |d: &mut Document, n: Node| Node::group(d.alloc_id(), vec![n.into()]);
|
||||
let mut masked = match case {
|
||||
Alone | FillEffect => red,
|
||||
InGroup | Brushed => group(&mut d, red),
|
||||
InNestedGroup => {
|
||||
let inner = group(&mut d, red);
|
||||
group(&mut d, inner)
|
||||
}
|
||||
HiddenInGroup => {
|
||||
red.visible = false;
|
||||
group(&mut d, red)
|
||||
}
|
||||
InSymbol => {
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Square".into(), art: std::sync::Arc::new(red) });
|
||||
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Square".into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
group(&mut d, instance)
|
||||
}
|
||||
InBlend => {
|
||||
let mut blue = rect(&mut d, Rect::new(60.0, 10.0, 80.0, 30.0), [0, 0, 255]);
|
||||
blue.appearance.effects = red.appearance.effects.clone();
|
||||
let blend = Node::new(d.alloc_id(), NodeKind::Blend { children: vec![red.into(), blue.into()], spec: Default::default() });
|
||||
group(&mut d, blend)
|
||||
}
|
||||
};
|
||||
let art = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 30.0), [255, 255, 255]);
|
||||
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
add(&mut d, masked);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert_eq!(spanning_rects(&svg).0, want, "{case:?}, shadowed {shadowed}:\n{svg}");
|
||||
if matches!(case, Alone | InGroup | InNestedGroup | FillEffect) {
|
||||
// The red square alone: no rectangle of the region's size comes back.
|
||||
assert_eq!(imported_paths(&svg), 1, "{case:?}, shadowed {shadowed}:\n{svg}");
|
||||
}
|
||||
}
|
||||
// Type can't be measured, so its mask's region covers any artwork: a group of type gets the
|
||||
// rectangle only when it holds a raster effect, on the type or on a path beside it.
|
||||
for (shadowed, path_shadowed) in [(false, false), (true, false), (false, true)] {
|
||||
let mut d = Document::new(80.0, 40.0);
|
||||
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
|
||||
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 30.0), "Wide", style))));
|
||||
if shadowed {
|
||||
t.appearance.effects.push(drop_shadow());
|
||||
}
|
||||
let mut red = rect(&mut d, Rect::new(50.0, 5.0, 60.0, 15.0), [255, 0, 0]);
|
||||
if path_shadowed {
|
||||
red.appearance.effects.push(drop_shadow());
|
||||
}
|
||||
let mut group = Node::group(d.alloc_id(), vec![t.into(), red.into()]);
|
||||
let art = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [255, 255, 255]);
|
||||
group.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
add(&mut d, group);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
let want = shadowed || path_shadowed;
|
||||
assert_eq!(svg.contains("visibility=\"hidden\""), want, "type {shadowed}, path {path_shadowed}:\n{svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// In a knockout group, each element is drawn inside the masks of the elements above it. When an
|
||||
/// element inside a mask's group has a drop shadow, that group starts with the same hidden,
|
||||
/// unpainted rectangle over the region, and import leaves it out.
|
||||
#[test]
|
||||
fn a_knockout_group_spans_its_masks_when_an_element_paints_past_its_shapes() {
|
||||
for shadowed in [false, true] {
|
||||
let mut d = Document::new(60.0, 60.0);
|
||||
let mut below = rect(&mut d, Rect::new(5.0, 5.0, 25.0, 25.0), [200, 0, 0]);
|
||||
if shadowed {
|
||||
below.appearance.effects.push(drop_shadow());
|
||||
}
|
||||
let mut middle = rect(&mut d, Rect::new(15.0, 15.0, 35.0, 35.0), [0, 200, 0]);
|
||||
middle.opacity = 0.5;
|
||||
let mut top = rect(&mut d, Rect::new(20.0, 5.0, 30.0, 40.0), [0, 0, 200]);
|
||||
top.opacity = 0.5;
|
||||
let mut group = Node::group(d.alloc_id(), vec![below.into(), middle.into(), top.into()]);
|
||||
group.knockout = vectorcraft_doc::Knockout::On;
|
||||
add(&mut d, group);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert_eq!(spanning_rects(&svg), (shadowed, shadowed), "shadowed {shadowed}:\n{svg}");
|
||||
assert_eq!(imported_paths(&svg), 3, "shadowed {shadowed}:\n{svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type is measured by its layout or its path, and a reader sets live type in its own font, so a
|
||||
/// mask or knockout over type keeps the region that covers any artwork.
|
||||
#[test]
|
||||
|
||||
@@ -284,7 +284,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
|
||||
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
|
||||
| DCS 1.0 / 2.0 | place linked | place linked only (one image of the plates; spot plates as spot swatches; the composite's clipping path; each plate on its own ink in Separations Preview and print separations) | no | `crates/eps/src/dcs/tests.rs`, `crates/engine/src/tests/dcs.rs` |
|
||||
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) |
|
||||
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); a masked group, or a knockout group's masked group, whose content has a shadow, glow, blur or feather starts with a hidden, unpainted rectangle over the mask's region, so readers that draw a masked group only inside the box of its shapes draw those effects too (`crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) |
|
||||
| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open |
|
||||
| HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` |
|
||||
| DWG | open, export | no | no | no open spec; DXF instead |
|
||||
|
||||
Reference in New Issue
Block a user