mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
Merge main into fix/ai-calligraphic-samples
This commit is contained in:
Generated
+98
-28
@@ -978,11 +978,33 @@ dependencies = [
|
||||
"ruzstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-codepage"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
|
||||
|
||||
[[package]]
|
||||
name = "craft-dxf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
dependencies = [
|
||||
"craft-codepage",
|
||||
"encoding_rs",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-eps"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
@@ -1057,6 +1079,21 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-svg"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"flate2",
|
||||
"kurbo 0.13.1",
|
||||
"roxmltree 0.21.2",
|
||||
"thiserror 2.0.21",
|
||||
"usvg 0.48.1",
|
||||
"vectorcraft-geom 0.18.0",
|
||||
"vectorcraft-pathops 0.18.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1146,6 +1183,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xml"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2"
|
||||
dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.3"
|
||||
@@ -6386,13 +6432,14 @@ dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-dxf",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -6400,8 +6447,8 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
@@ -6418,7 +6465,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-mcp",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
@@ -6455,7 +6502,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
@@ -6470,8 +6517,8 @@ dependencies = [
|
||||
"svgtypes 0.16.1",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-three-d",
|
||||
@@ -6515,10 +6562,10 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-eps",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-js",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
@@ -6537,6 +6584,7 @@ dependencies = [
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-eps",
|
||||
"craft-pdf",
|
||||
"encoding_rs",
|
||||
"fax",
|
||||
@@ -6552,9 +6600,9 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-testkit",
|
||||
@@ -6573,10 +6621,20 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.18.0"
|
||||
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.24.1"
|
||||
@@ -6611,7 +6669,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
@@ -6628,10 +6686,21 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.18.0"
|
||||
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom 0.18.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.24.1"
|
||||
@@ -6640,7 +6709,7 @@ dependencies = [
|
||||
"linesweeper",
|
||||
"proptest",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
]
|
||||
|
||||
@@ -6673,7 +6742,7 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"weezl 0.2.1",
|
||||
@@ -6692,7 +6761,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"wasmi",
|
||||
"wat",
|
||||
"web-time",
|
||||
@@ -6717,7 +6786,7 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
@@ -6741,6 +6810,7 @@ name = "vectorcraft-svg"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"craft-svg",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6757,8 +6827,8 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
@@ -6776,7 +6846,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
]
|
||||
@@ -6796,7 +6866,7 @@ dependencies = [
|
||||
"unicode-script",
|
||||
"unicode-segmentation",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vello_cpu",
|
||||
"windows-registry",
|
||||
"write-fonts",
|
||||
@@ -6806,8 +6876,8 @@ dependencies = [
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6822,8 +6892,8 @@ dependencies = [
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pathops 0.24.1",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
@@ -6836,7 +6906,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6865,7 +6935,7 @@ dependencies = [
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-eps",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-geom 0.24.1",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
|
||||
@@ -25,6 +25,12 @@ craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", re
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
# Native Affinity documents (craft-libs#914).
|
||||
craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" }
|
||||
# SVG parsing, limits, SVGZ and writer (craft-libs#778).
|
||||
craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2", features = ["tree"] }
|
||||
# EPS container, previews, ASCII85 and DSC comments (craft-libs#778).
|
||||
craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" }
|
||||
# DXF tags, reader and writer (craft-libs#778, #926, #927).
|
||||
craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-dxf = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
|
||||
+4
-67
@@ -1,50 +1,11 @@
|
||||
//! The 256-colour index palette CAD apps share, generated rather than tabulated: indices 1–9 are
|
||||
//! the standard colours, 10–249 run through 24 hues at five brightnesses each in a full and a
|
||||
//! half-saturated tone, and 250–255 are greys.
|
||||
//! The colour index palette is `craft_dxf::model::aci`; here are the indices each colour depth
|
||||
//! of the writer may use.
|
||||
|
||||
use crate::ColorDepth;
|
||||
|
||||
/// The nine standard colours (index 7 shows white on a dark background and black on paper).
|
||||
const STANDARD: [[u8; 3]; 9] =
|
||||
[[255, 0, 0], [255, 255, 0], [0, 255, 0], [0, 255, 255], [0, 0, 255], [255, 0, 255], [255, 255, 255], [128, 128, 128], [192, 192, 192]];
|
||||
|
||||
/// The brightness of the five shades of each hue.
|
||||
const SHADES: [f64; 5] = [1.0, 0.65, 0.5, 0.3, 0.15];
|
||||
|
||||
/// The RGB of colour index `i` (1–255; anything else is index 7's white).
|
||||
pub fn aci_rgb(i: u8) -> [u8; 3] {
|
||||
match i {
|
||||
1..=9 => STANDARD[usize::from(i - 1)],
|
||||
10..=249 => {
|
||||
let hue = f64::from(i / 10 - 1) * 15.0;
|
||||
let k = i % 10;
|
||||
let v = SHADES[usize::from(k / 2)];
|
||||
let pastel = k % 2 == 1;
|
||||
hue_rgb(hue).map(|f| {
|
||||
let f = if pastel { f + (1.0 - f) / 2.0 } else { f };
|
||||
(255.0 * v * f).floor() as u8
|
||||
})
|
||||
}
|
||||
250..=255 => {
|
||||
let g = (51.0 + 204.0 * f64::from(i - 250) / 5.0).floor() as u8;
|
||||
[g; 3]
|
||||
}
|
||||
_ => STANDARD[6],
|
||||
}
|
||||
}
|
||||
|
||||
/// The fully saturated, full-brightness colour of `hue` degrees as 0–1 channels.
|
||||
fn hue_rgb(hue: f64) -> [f64; 3] {
|
||||
let h = hue.rem_euclid(360.0) / 60.0;
|
||||
let x = 1.0 - (h % 2.0 - 1.0).abs();
|
||||
match h as u8 {
|
||||
0 => [1.0, x, 0.0],
|
||||
1 => [x, 1.0, 0.0],
|
||||
2 => [0.0, 1.0, x],
|
||||
3 => [0.0, x, 1.0],
|
||||
4 => [x, 0.0, 1.0],
|
||||
_ => [1.0, 0.0, x],
|
||||
}
|
||||
craft_dxf::model::aci::rgb(i)
|
||||
}
|
||||
|
||||
/// The indices a colour depth may use (true colour: all of them, for the fallback index).
|
||||
@@ -59,37 +20,13 @@ fn candidates(depth: ColorDepth) -> Box<dyn Iterator<Item = u8>> {
|
||||
/// The index of `depth` nearest to `rgb` (squared RGB distance; ties go to the lower index).
|
||||
/// Index 7 stands for both white and black, as CAD apps draw it in the background's contrast.
|
||||
pub fn nearest_aci(rgb: [u8; 3], depth: ColorDepth) -> u8 {
|
||||
let dist = |c: [u8; 3]| rgb.iter().zip(c).map(|(a, b)| (i32::from(*a) - i32::from(b)).pow(2)).sum::<i32>();
|
||||
candidates(depth).min_by_key(|i| if *i == 7 { dist([255; 3]).min(dist([0; 3])) } else { dist(aci_rgb(*i)) }).unwrap_or(7)
|
||||
craft_dxf::model::aci::nearest(rgb, candidates(depth))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_palette_matches_its_known_entries() {
|
||||
let known: [(u8, [u8; 3]); 14] = [
|
||||
(1, [255, 0, 0]),
|
||||
(10, [255, 0, 0]),
|
||||
(11, [255, 127, 127]),
|
||||
(12, [165, 0, 0]),
|
||||
(13, [165, 82, 82]),
|
||||
(17, [76, 38, 38]),
|
||||
(19, [38, 19, 19]),
|
||||
(20, [255, 63, 0]),
|
||||
(21, [255, 159, 127]),
|
||||
(30, [255, 127, 0]),
|
||||
(140, [0, 191, 255]),
|
||||
(250, [51, 51, 51]),
|
||||
(252, [132, 132, 132]),
|
||||
(255, [255, 255, 255]),
|
||||
];
|
||||
for (i, rgb) in known {
|
||||
assert_eq!(aci_rgb(i), rgb, "index {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_index_by_depth() {
|
||||
assert_eq!(nearest_aci([255, 0, 0], ColorDepth::Aci8), 1);
|
||||
|
||||
+20
-19
@@ -1,4 +1,4 @@
|
||||
//! DXF import: an ASCII DXF drawing of any version as a document.
|
||||
//! DXF import: a DXF drawing (ASCII or binary) of any version as a document.
|
||||
//!
|
||||
//! The drawing's model space (or a paper layout) comes in at a ratio (1 unit of the art = N
|
||||
//! drawing units; by default the drawing's own unit at 1:1) or fitted to an artboard, centred
|
||||
@@ -10,7 +10,8 @@
|
||||
//! lineweights, linetypes and transparency come through layers and blocks as CAD apps resolve
|
||||
//! them. What can't come in is listed in the warnings.
|
||||
//!
|
||||
//! - `reader`: the file's text, group codes, sections, tables, blocks and layouts.
|
||||
//! - `reader`: what the import uses of the drawing `craft_dxf::model` reads: header, tables,
|
||||
//! blocks and layouts.
|
||||
//! - `entity`: entities to items (geometry and properties).
|
||||
//! - `curve`: bulges, arcs, ellipses and NURBS as Bézier paths.
|
||||
//! - `text`: text values to plain text.
|
||||
@@ -31,9 +32,6 @@ use crate::{DxfVersion, INSUNITS, SCALE_RANGE};
|
||||
/// Letter, the box Fit to Artboard fills unless told otherwise (points).
|
||||
pub const FIT_TO: (f64, f64) = (612.0, 792.0);
|
||||
|
||||
/// Why a binary DXF can't be opened.
|
||||
pub const BINARY: &str = "this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that";
|
||||
|
||||
/// How a drawing comes in.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ImportOptions {
|
||||
@@ -104,15 +102,20 @@ pub fn is_dxf(bytes: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The pairs of a DXF file and where it is damaged (binary and DWG files are refused).
|
||||
fn text(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, String> {
|
||||
if bytes.starts_with(reader::BINARY_SENTINEL) {
|
||||
return Err(BINARY.into());
|
||||
}
|
||||
/// The tags of a DXF file and where it is damaged (DWG files are refused).
|
||||
fn tags(bytes: &[u8]) -> Result<(Vec<craft_dxf::Tag>, Option<String>), String> {
|
||||
if bytes.starts_with(b"AC10") || bytes.starts_with(b"AC1.") {
|
||||
return Err("this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that".into());
|
||||
return Err("this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that".into());
|
||||
}
|
||||
Ok(reader::decode(bytes))
|
||||
let (tags, damaged) = craft_dxf::parse_recovering(bytes).map_err(|e| match e {
|
||||
craft_dxf::DxfError::TooLarge { .. } => "the drawing is too large to open".to_string(),
|
||||
_ if bytes.starts_with(reader::BINARY_SENTINEL) => "this binary DXF file is damaged or cut short".to_string(),
|
||||
_ => "not a DXF drawing".to_string(),
|
||||
})?;
|
||||
if tags.is_empty() {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
Ok((tags, damaged.map(|line| format!("the file is damaged at line {line}: what follows is left out"))))
|
||||
}
|
||||
|
||||
/// The unit of `$INSUNITS` code `code` and the scale that shows the drawing 1:1 in it.
|
||||
@@ -161,9 +164,8 @@ fn units_name(code: i64) -> &'static str {
|
||||
|
||||
/// Read what `bytes` holds: its version, units, layouts and layers.
|
||||
pub fn info(bytes: &[u8]) -> Result<DxfInfo, String> {
|
||||
let text = text(bytes)?;
|
||||
let (pairs, _) = reader::pairs(&text)?;
|
||||
let d = reader::Drawing::read(&pairs);
|
||||
let (tags, _) = tags(bytes)?;
|
||||
let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags));
|
||||
if d.sections == 0 {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
@@ -180,7 +182,7 @@ pub fn info(bytes: &[u8]) -> Result<DxfInfo, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Import an ASCII DXF drawing with `o`.
|
||||
/// Import a DXF drawing with `o`.
|
||||
pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
|
||||
let (w, h) = o.fit_to;
|
||||
if !(w > 0.0 && h > 0.0 && w.max(h) <= build::MAX_EXTENT) {
|
||||
@@ -191,9 +193,8 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
|
||||
{
|
||||
return Err(format!("scale must be a number from {} to {}, not {s}", SCALE_RANGE.start(), SCALE_RANGE.end()));
|
||||
}
|
||||
let text = text(bytes)?;
|
||||
let (pairs, damaged) = reader::pairs(&text)?;
|
||||
let d = reader::Drawing::read(&pairs);
|
||||
let (tags, damaged) = tags(bytes)?;
|
||||
let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags));
|
||||
if d.sections == 0 {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
|
||||
+180
-302
@@ -7,9 +7,10 @@ use kurbo::{BezPath, PathEl};
|
||||
use vectorcraft_doc::Justify;
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use craft_dxf::model::{self, EntityRecord, Groups, HatchEdge, HatchPath};
|
||||
|
||||
use super::curve::{self, Vertex};
|
||||
use super::reader::{Groups, Pair, Record, groups, number};
|
||||
use crate::aci::aci_rgb;
|
||||
use super::reader::{aci_paper, point};
|
||||
|
||||
/// How far below the baseline the lowest descender reaches, as a fraction of the cap height.
|
||||
const DESCENT: f64 = 0.3;
|
||||
@@ -25,21 +26,6 @@ const MAX_COORD: f64 = 1e12;
|
||||
/// factor 1, the 1 to 10,000% horizontal scale the Character panel allows.
|
||||
const SPAN_SCALE: (f64, f64) = (0.01, 100.0);
|
||||
|
||||
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
|
||||
pub(crate) fn aci_paper(i: u64) -> [u8; 3] {
|
||||
match u8::try_from(i) {
|
||||
Ok(7) | Err(_) => [0; 3],
|
||||
Ok(i) => aci_rgb(i),
|
||||
}
|
||||
}
|
||||
|
||||
/// The true colour (group 420, `0xRRGGBB`) of an entity or layer.
|
||||
pub(crate) fn true_color(g: &[Pair<'_>]) -> Option<[u8; 3]> {
|
||||
let v = g.int(420)?;
|
||||
let [_, r, gr, b] = u32::try_from(v & 0xFF_FFFF).ok()?.to_be_bytes();
|
||||
Some([r, gr, b])
|
||||
}
|
||||
|
||||
/// An entity's colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Col {
|
||||
@@ -79,30 +65,30 @@ pub(crate) struct Props {
|
||||
}
|
||||
|
||||
impl Props {
|
||||
fn read(g: &[Pair<'_>]) -> Self {
|
||||
let color = match (true_color(g), g.int(62)) {
|
||||
(Some(rgb), _) => Col::Rgb(rgb),
|
||||
(None, Some(0)) => Col::ByBlock,
|
||||
(None, None | Some(256..)) => Col::ByLayer,
|
||||
(None, Some(i)) => Col::Rgb(aci_paper(i.unsigned_abs())),
|
||||
fn read(c: &model::Common) -> Self {
|
||||
let color = match c.color {
|
||||
model::Color::Rgb(rgb) => Col::Rgb(rgb),
|
||||
model::Color::ByBlock => Col::ByBlock,
|
||||
model::Color::ByLayer => Col::ByLayer,
|
||||
model::Color::Index(i) => Col::Rgb(aci_paper(i)),
|
||||
};
|
||||
let lineweight = match g.int(370) {
|
||||
Some(-2) => Lw::ByBlock,
|
||||
Some(-3) => Lw::Default,
|
||||
Some(v @ 0..=211) => Lw::Hundredths(v as i32),
|
||||
let lineweight = match c.lineweight {
|
||||
model::Lineweight::ByBlock => Lw::ByBlock,
|
||||
model::Lineweight::Default => Lw::Default,
|
||||
model::Lineweight::Hundredths(v @ 0..=211) => Lw::Hundredths(i32::from(v)),
|
||||
_ => Lw::ByLayer,
|
||||
};
|
||||
let alpha = match g.int(440) {
|
||||
Some(v) if v & 0x0200_0000 != 0 => Alpha::Opacity((v & 0xFF) as f32 / 255.0),
|
||||
Some(v) if v & 0x0100_0000 != 0 => Alpha::ByBlock,
|
||||
_ => Alpha::ByLayer,
|
||||
let alpha = match c.transparency {
|
||||
model::Transparency::Alpha(a) => Alpha::Opacity(f32::from(a) / 255.0),
|
||||
model::Transparency::ByBlock => Alpha::ByBlock,
|
||||
model::Transparency::ByLayer => Alpha::ByLayer,
|
||||
};
|
||||
Self {
|
||||
layer: g.name(8).unwrap_or("0").to_string(),
|
||||
layer: c.layer.clone(),
|
||||
color,
|
||||
lineweight,
|
||||
linetype: g.name(6).unwrap_or("BYLAYER").to_uppercase(),
|
||||
ltscale: g.num(48).filter(|v| *v > 0.0).unwrap_or(1.0),
|
||||
linetype: c.linetype.as_deref().unwrap_or("BYLAYER").to_uppercase(),
|
||||
ltscale: c.ltscale,
|
||||
alpha,
|
||||
}
|
||||
}
|
||||
@@ -209,90 +195,87 @@ impl Converter {
|
||||
}
|
||||
|
||||
/// The items of `records` (an entity section or a block), in drawing order.
|
||||
pub fn items(&mut self, records: &[Record<'_>]) -> Vec<Item> {
|
||||
pub fn items(&mut self, records: &[EntityRecord<'_>]) -> Vec<Item> {
|
||||
let mut out = Vec::new();
|
||||
for group in groups(records) {
|
||||
let Some(head) = group.first() else { continue };
|
||||
for e in records {
|
||||
let m = model::Entity::read(e);
|
||||
// Invisible entities.
|
||||
if head.g.int(60) == Some(1) {
|
||||
if !m.common.visible {
|
||||
continue;
|
||||
}
|
||||
match self.geom(head, group) {
|
||||
match self.geom(e, &m) {
|
||||
Some(geom) if !in_range(&geom) => self.warn(FAR_AWAY),
|
||||
Some(geom) => out.push(Item { props: Props::read(head.g), geom }),
|
||||
None if is_known(head.kind) => {}
|
||||
None => self.skip(head.kind),
|
||||
Some(geom) => out.push(Item { props: Props::read(&m.common), geom }),
|
||||
None if is_known(e.kind) => {}
|
||||
None => self.skip(e.kind),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn geom(&mut self, head: &Record<'_>, group: &[Record<'_>]) -> Option<Geom> {
|
||||
let g = head.g;
|
||||
fn geom(&mut self, e: &EntityRecord<'_>, m: &model::Entity) -> Option<Geom> {
|
||||
let g = e.groups();
|
||||
let path = |bp: BezPath| Some(Geom::Path { bp, fill: false, width: 0.0 });
|
||||
match head.kind {
|
||||
"LINE" => {
|
||||
let ocs = ocs(m.common.extrusion);
|
||||
match &m.kind {
|
||||
model::EntityKind::Line { start, end } => {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(g.point(10));
|
||||
bp.line_to(g.point(11));
|
||||
bp.move_to(point(*start));
|
||||
bp.line_to(point(*end));
|
||||
path(bp)
|
||||
}
|
||||
"CIRCLE" => path(transformed(curve::arc(g.point(10), g.num(40)?, 0.0, std::f64::consts::TAU)?, ocs(g))),
|
||||
"ARC" => {
|
||||
let (start, end) = (g.num_or(50, 0.0), g.num_or(51, 360.0));
|
||||
let sweep = ccw_sweep(start, end);
|
||||
path(transformed(curve::arc(g.point(10), g.num(40)?, start.to_radians(), sweep.to_radians())?, ocs(g)))
|
||||
model::EntityKind::Circle { center, radius } => path(transformed(curve::arc(point(*center), *radius, 0.0, std::f64::consts::TAU)?, ocs)),
|
||||
model::EntityKind::Arc { center, radius, start_angle, end_angle } => {
|
||||
let sweep = ccw_sweep(*start_angle, *end_angle);
|
||||
path(transformed(curve::arc(point(*center), *radius, start_angle.to_radians(), sweep.to_radians())?, ocs))
|
||||
}
|
||||
"ELLIPSE" => path(ellipse(g)?),
|
||||
"LWPOLYLINE" => lwpolyline(g),
|
||||
"POLYLINE" => self.polyline(g, group.get(1..).unwrap_or(&[])),
|
||||
"SPLINE" => path(spline(g)?),
|
||||
"SOLID" | "TRACE" => {
|
||||
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
|
||||
model::EntityKind::Ellipse { center, major, ratio, start, end } => {
|
||||
path(ellipse(m.common.extrusion, *center, *major, *ratio, *start, *end)?)
|
||||
}
|
||||
model::EntityKind::LwPolyline(p) => lwpolyline(p, ocs),
|
||||
model::EntityKind::Polyline(p) => self.polyline(p, ocs),
|
||||
model::EntityKind::Spline(s) => path(spline(s)?),
|
||||
model::EntityKind::Solid { corners } | model::EntityKind::Trace { corners } => {
|
||||
let [a, b, c, d] = corners.map(point);
|
||||
// The corners zigzag: the outline runs 1, 2, 4, 3.
|
||||
Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs(g)), fill: true, width: 0.0 })
|
||||
Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs), fill: true, width: 0.0 })
|
||||
}
|
||||
"3DFACE" => {
|
||||
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
|
||||
path(polygon(&[a, b, c, d]))
|
||||
}
|
||||
"HATCH" => self.hatch(g),
|
||||
"TEXT" | "ATTRIB" => text(g, head.kind == "ATTRIB").map(Geom::Text),
|
||||
"MTEXT" => mtext(g).map(Geom::Text),
|
||||
"INSERT" => self.insert(g, group.get(1..).unwrap_or(&[])),
|
||||
model::EntityKind::Face3d { corners, .. } => path(polygon(&corners.map(point))),
|
||||
model::EntityKind::Hatch(h) => self.hatch(h, ocs),
|
||||
model::EntityKind::Text(t) => text(t, &g, false, ocs).map(Geom::Text),
|
||||
model::EntityKind::MText(t) => mtext(t, e.tags).map(Geom::Text),
|
||||
model::EntityKind::Insert(ins) => self.insert(ins, &e.followers, ocs),
|
||||
// A dimension draws the anonymous block of its lines, arrows and text.
|
||||
"DIMENSION" => Some(Geom::Insert(InsertItem { block: g.name(2)?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })),
|
||||
model::EntityKind::Dimension(d) => {
|
||||
Some(Geom::Insert(InsertItem { block: d.block.as_ref()?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn polyline(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
|
||||
let flags = g.int_or(70, 0);
|
||||
if flags & (16 | 64) != 0 {
|
||||
fn polyline(&mut self, p: &model::Polyline, ocs: Affine) -> Option<Geom> {
|
||||
if p.is_mesh() || p.is_polyface() {
|
||||
self.warn(MESHES);
|
||||
return None;
|
||||
}
|
||||
// Spline frame control points and polyface faces aren't on the line.
|
||||
let verts: Vec<Vertex> = rest
|
||||
.iter()
|
||||
.filter(|r| r.kind == "VERTEX" && r.g.int_or(70, 0) & (16 | 128) == 0)
|
||||
.map(|r| Vertex { p: r.g.point(10), bulge: r.g.num_or(42, 0.0) })
|
||||
.collect();
|
||||
let bp = curve::polyline(&verts, flags & 1 != 0)?;
|
||||
let verts: Vec<Vertex> =
|
||||
p.vertices.iter().filter(|v| v.flags & (16 | 128) == 0).map(|v| Vertex { p: point(v.location), bulge: v.bulge }).collect();
|
||||
let bp = curve::polyline(&verts, p.closed())?;
|
||||
// 3D polylines are in world coordinates; 2D ones in their plane's.
|
||||
let bp = if flags & 8 != 0 { bp } else { transformed(bp, ocs(g)) };
|
||||
Some(Geom::Path { bp, fill: false, width: g.num_or(40, 0.0).max(0.0) })
|
||||
let bp = if p.is_3d() { bp } else { transformed(bp, ocs) };
|
||||
Some(Geom::Path { bp, fill: false, width: p.start_width.max(0.0) })
|
||||
}
|
||||
|
||||
fn insert(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
|
||||
let block = g.name(2)?.to_uppercase();
|
||||
let (sx, sy) = (g.num_or(41, 1.0), g.num_or(42, 1.0));
|
||||
fn insert(&mut self, ins: &model::Insert, followers: &[model::Record<'_>], ocs: Affine) -> Option<Geom> {
|
||||
let block = Some(ins.block.trim()).filter(|b| !b.is_empty())?.to_uppercase();
|
||||
let [sx, sy, _] = ins.scale;
|
||||
if sx.abs() < 1e-12 || sy.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let place = ocs(g) * Affine::translate(g.point(10).to_vec2()) * Affine::rotate(g.num_or(50, 0.0).to_radians());
|
||||
let (cols, rows) = (g.int_or(70, 1).max(1), g.int_or(71, 1).max(1));
|
||||
let (cs, rs) = (g.num_or(44, 0.0), g.num_or(45, 0.0));
|
||||
let place = ocs * Affine::translate(point(ins.insert).to_vec2()) * Affine::rotate(ins.rotation.to_radians());
|
||||
let (cols, rows) = (ins.columns, ins.rows);
|
||||
let (cs, rs) = (ins.column_spacing, ins.row_spacing);
|
||||
let (cols, rows) = if cols.saturating_mul(rows) > MAX_CELLS {
|
||||
self.warn(BIG_ARRAY);
|
||||
(cols.min(MAX_CELLS), (MAX_CELLS / cols.min(MAX_CELLS)).max(1).min(rows))
|
||||
@@ -312,27 +295,41 @@ impl Converter {
|
||||
.flat_map(|r| (0..cols).map(move |c| (r, c)))
|
||||
.map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy))
|
||||
.collect();
|
||||
let attribs = self.items(rest);
|
||||
let attribs = self.attribs(followers);
|
||||
Some(Geom::Insert(InsertItem { block, cells, attribs }))
|
||||
}
|
||||
|
||||
fn hatch(&mut self, g: &[Pair<'_>]) -> Option<Geom> {
|
||||
let start = g.iter().position(|p| p.code == 91)?;
|
||||
let count = g.get(start).and_then(|p| number(p.value)).filter(|n| *n >= 0.0)? as usize;
|
||||
let mut c = Cursor { g, i: start + 1 };
|
||||
/// An insert's attributes as text.
|
||||
fn attribs(&mut self, followers: &[model::Record<'_>]) -> Vec<Item> {
|
||||
let mut out = Vec::new();
|
||||
for r in followers {
|
||||
let c = model::Common::read(r.tags);
|
||||
if !c.visible {
|
||||
continue;
|
||||
}
|
||||
let a = model::Attrib::read("ATTRIB", r.tags);
|
||||
match text(&a.text, &r.groups(), true, ocs(c.extrusion)) {
|
||||
Some(t) if !in_range(&Geom::Text(t.clone())) => self.warn(FAR_AWAY),
|
||||
Some(t) => out.push(Item { props: Props::read(&c), geom: Geom::Text(t) }),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn hatch(&mut self, h: &model::Hatch, ocs: Affine) -> Option<Geom> {
|
||||
let mut bp = BezPath::new();
|
||||
for _ in 0..count {
|
||||
let Some(path) = boundary(&mut c) else { break };
|
||||
for l in &h.loops {
|
||||
let Some(path) = boundary(&l.path) else { break };
|
||||
bp.extend(path);
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bp = transformed(bp, ocs(g));
|
||||
let solid = g.int_or(70, 0) == 1;
|
||||
if g.int_or(450, 0) == 1 {
|
||||
let bp = transformed(bp, ocs);
|
||||
if h.gradient.is_some() {
|
||||
self.warn(GRADIENT_HATCH);
|
||||
} else if !solid {
|
||||
} else if !h.solid {
|
||||
self.warn(PATTERN_HATCH);
|
||||
return Some(Geom::Path { bp, fill: false, width: 0.0 });
|
||||
}
|
||||
@@ -401,217 +398,104 @@ fn polygon(pts: &[Point]) -> BezPath {
|
||||
bp
|
||||
}
|
||||
|
||||
/// The extrusion direction (210, 220, 230) of a planar entity, normalised.
|
||||
fn extrusion(g: &[Pair<'_>]) -> [f64; 3] {
|
||||
let n = [g.num_or(210, 0.0), g.num_or(220, 0.0), g.num_or(230, 1.0)];
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
if len > 1e-12 && len.is_finite() { n.map(|v| v / len) } else { [0.0, 0.0, 1.0] }
|
||||
}
|
||||
|
||||
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
fn unit(v: [f64; 3]) -> [f64; 3] {
|
||||
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if len > 1e-12 { v.map(|x| x / len) } else { v }
|
||||
}
|
||||
|
||||
/// A planar entity's coordinate system seen from above: its x and y axes in world space by the
|
||||
/// arbitrary axis algorithm (identity for drawings in the world's plane; extruded along −z it
|
||||
/// mirrors x).
|
||||
fn ocs(g: &[Pair<'_>]) -> Affine {
|
||||
let n = extrusion(g);
|
||||
if n[2] > 1.0 - 1e-12 {
|
||||
fn ocs(extrusion: model::Point) -> Affine {
|
||||
let [ax, ay, _] = model::ocs_axes(extrusion);
|
||||
if ax == [1.0, 0.0, 0.0] && ay == [0.0, 1.0, 0.0] {
|
||||
return Affine::IDENTITY;
|
||||
}
|
||||
let ax = unit(if n[0].abs() < 1.0 / 64.0 && n[1].abs() < 1.0 / 64.0 { cross([0.0, 1.0, 0.0], n) } else { cross([0.0, 0.0, 1.0], n) });
|
||||
let ay = unit(cross(n, ax));
|
||||
Affine::new([ax[0], ax[1], ay[0], ay[1], 0.0, 0.0])
|
||||
}
|
||||
|
||||
fn ellipse(g: &[Pair<'_>]) -> Option<BezPath> {
|
||||
let centre = g.point(10);
|
||||
let major = g.point(11).to_vec2();
|
||||
let ratio = g.num_or(40, 1.0);
|
||||
fn ellipse(extrusion: model::Point, centre: model::Point, major: model::Point, ratio: f64, start: f64, end: f64) -> Option<BezPath> {
|
||||
let centre = point(centre);
|
||||
let major = point(major).to_vec2();
|
||||
if ratio <= 0.0 || major.hypot() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
// The minor axis is the major one turned a quarter about the extrusion direction.
|
||||
let n = extrusion(g);
|
||||
let n = model::ocs_axes(extrusion)[2];
|
||||
let m = cross(n, [major.x, major.y, 0.0]);
|
||||
let minor = Vec2::new(m[0], m[1]) * ratio;
|
||||
let (start, end) = (g.num_or(41, 0.0), g.num_or(42, std::f64::consts::TAU));
|
||||
let sweep = ccw_sweep(start.to_degrees(), end.to_degrees()).to_radians();
|
||||
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start, sweep)
|
||||
}
|
||||
|
||||
fn lwpolyline(g: &[Pair<'_>]) -> Option<Geom> {
|
||||
let mut verts: Vec<Vertex> = Vec::new();
|
||||
let (mut widths, mut uniform) = (None, true);
|
||||
for p in g {
|
||||
match p.code {
|
||||
10 => verts.push(Vertex { p: Point::new(number(p.value)?, 0.0), bulge: 0.0 }),
|
||||
20 => verts.last_mut()?.p.y = number(p.value)?,
|
||||
42 => verts.last_mut()?.bulge = number(p.value).unwrap_or(0.0),
|
||||
40 | 41 => {
|
||||
let w = number(p.value).unwrap_or(0.0);
|
||||
uniform &= widths.is_none_or(|x: f64| (x - w).abs() < 1e-12);
|
||||
widths = Some(w);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let bp = transformed(curve::polyline(&verts, g.int_or(70, 0) & 1 != 0)?, ocs(g));
|
||||
let width = g.num(43).filter(|w| *w > 0.0).or(widths.filter(|_| uniform)).unwrap_or(0.0).max(0.0);
|
||||
fn lwpolyline(p: &model::LwPolyline, ocs: Affine) -> Option<Geom> {
|
||||
let verts: Vec<Vertex> = p.vertices.iter().map(|v| Vertex { p: Point::new(v.x, v.y), bulge: v.bulge }).collect();
|
||||
let bp = transformed(curve::polyline(&verts, p.closed)?, ocs);
|
||||
// One width for every vertex is the line's width.
|
||||
let widths: Vec<f64> = p.vertices.iter().flat_map(|v| [v.start_width, v.end_width]).collect();
|
||||
let uniform = widths.first().filter(|w| widths.iter().all(|x| (x - *w).abs() < 1e-12)).copied();
|
||||
let width = Some(p.const_width).filter(|w| *w > 0.0).or(uniform).unwrap_or(0.0).max(0.0);
|
||||
Some(Geom::Path { bp, fill: false, width })
|
||||
}
|
||||
|
||||
/// The points at `code` (x) and `code + 10` (y), in order.
|
||||
fn points(g: &[Pair<'_>], code: i32) -> Option<Vec<Point>> {
|
||||
let mut out: Vec<Point> = Vec::new();
|
||||
for p in g {
|
||||
if p.code == code {
|
||||
out.push(Point::new(number(p.value)?, 0.0));
|
||||
} else if p.code == code + 10 {
|
||||
out.last_mut()?.y = number(p.value)?;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn spline(g: &[Pair<'_>]) -> Option<BezPath> {
|
||||
let flags = g.int_or(70, 0);
|
||||
let ctrl = points(g, 10)?;
|
||||
let degree = usize::try_from(g.int_or(71, 3)).ok()?;
|
||||
let knots: Vec<f64> = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::<Option<_>>()?;
|
||||
let weights: Vec<f64> = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect();
|
||||
let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice());
|
||||
curve::nurbs(degree, &knots, &ctrl, weights)
|
||||
fn spline(s: &model::Spline) -> Option<BezPath> {
|
||||
let ctrl: Vec<Point> = s.control.iter().map(|p| point(*p)).collect();
|
||||
let degree = usize::try_from(s.degree).ok()?;
|
||||
let weights = (s.flags & 4 != 0 && s.weights.len() == ctrl.len()).then_some(s.weights.as_slice());
|
||||
curve::nurbs(degree, &s.knots, &ctrl, weights)
|
||||
.map(|mut path| {
|
||||
if flags & 1 != 0 {
|
||||
if s.flags & 1 != 0 {
|
||||
path.close_path();
|
||||
}
|
||||
path
|
||||
})
|
||||
.or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
|
||||
}
|
||||
|
||||
/// Reads a hatch's boundary groups in order.
|
||||
struct Cursor<'g, 'a> {
|
||||
g: &'g [Pair<'a>],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl Cursor<'_, '_> {
|
||||
/// The next group's value when it has `code` (consumed).
|
||||
fn opt(&mut self, code: i32) -> Option<&str> {
|
||||
let p = self.g.get(self.i).filter(|p| p.code == code)?;
|
||||
self.i += 1;
|
||||
Some(p.value)
|
||||
}
|
||||
fn num(&mut self, code: i32) -> Option<f64> {
|
||||
self.opt(code).and_then(number)
|
||||
}
|
||||
fn count(&mut self, code: i32) -> Option<usize> {
|
||||
self.num(code).filter(|n| *n >= 0.0 && *n <= 1e7).map(|n| n as usize)
|
||||
}
|
||||
fn point(&mut self, code: i32) -> Option<Point> {
|
||||
Some(Point::new(self.num(code)?, self.num(code + 10)?))
|
||||
}
|
||||
.or_else(|| curve::through(&s.fit.iter().map(|p| point(*p)).collect::<Vec<_>>(), s.flags & 1 != 0))
|
||||
}
|
||||
|
||||
/// One hatch boundary loop, closed.
|
||||
fn boundary(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
|
||||
let flags = c.num(92)? as i64;
|
||||
let mut bp = BezPath::new();
|
||||
if flags & 2 != 0 {
|
||||
let bulges = c.num(72).unwrap_or(0.0) != 0.0;
|
||||
let _closed = c.opt(73);
|
||||
let n = c.count(93)?;
|
||||
let mut verts = Vec::with_capacity(n.min(4096));
|
||||
for _ in 0..n {
|
||||
let p = c.point(10)?;
|
||||
let bulge = if bulges { c.num(42).unwrap_or(0.0) } else { 0.0 };
|
||||
verts.push(Vertex { p, bulge });
|
||||
fn boundary(path: &HatchPath) -> Option<BezPath> {
|
||||
let xy = |p: &[f64; 2]| Point::new(p[0], p[1]);
|
||||
match path {
|
||||
HatchPath::Polyline { vertices, .. } => {
|
||||
let verts: Vec<Vertex> = vertices.iter().map(|[x, y, b]| Vertex { p: Point::new(*x, *y), bulge: *b }).collect();
|
||||
curve::polyline(&verts, true)
|
||||
}
|
||||
bp = curve::polyline(&verts, true)?;
|
||||
} else {
|
||||
let n = c.count(93)?;
|
||||
for _ in 0..n {
|
||||
let edge = edge(c)?;
|
||||
append(&mut bp, &edge);
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
bp.close_path();
|
||||
}
|
||||
// The boundary objects it was made from.
|
||||
if let Some(n) = c.count(97) {
|
||||
for _ in 0..n {
|
||||
if c.opt(330).is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// One edge of a hatch boundary.
|
||||
fn edge(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
|
||||
match c.num(72)? as i64 {
|
||||
1 => {
|
||||
HatchPath::Edges(edges) => {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(c.point(10)?);
|
||||
bp.line_to(c.point(11)?);
|
||||
for e in edges {
|
||||
let seg = match e {
|
||||
HatchEdge::Line { start, end } => {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(xy(start));
|
||||
bp.line_to(xy(end));
|
||||
Some(bp)
|
||||
}
|
||||
HatchEdge::Arc { center, radius, start_angle, end_angle, ccw } => {
|
||||
let sweep = ccw_sweep(*start_angle, *end_angle);
|
||||
// Clockwise arcs have their angles measured clockwise.
|
||||
let (start, sweep) = if *ccw { (*start_angle, sweep) } else { (-start_angle, -sweep) };
|
||||
curve::arc(xy(center), *radius, start.to_radians(), sweep.to_radians())
|
||||
}
|
||||
HatchEdge::Ellipse { center, major, ratio, start_angle, end_angle, ccw } => {
|
||||
let (centre, major) = (xy(center), xy(major).to_vec2());
|
||||
let minor = Vec2::new(-major.y, major.x) * *ratio * if *ccw { 1.0 } else { -1.0 };
|
||||
let sweep = ccw_sweep(*start_angle, *end_angle).to_radians();
|
||||
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start_angle.to_radians(), sweep)
|
||||
}
|
||||
HatchEdge::Spline { degree, rational, knots, control, weights, fit, .. } => {
|
||||
let ctrl: Vec<Point> = control.iter().map(xy).collect();
|
||||
let degree = usize::try_from(*degree).ok()?;
|
||||
let weights = (*rational && weights.len() == ctrl.len()).then_some(weights.as_slice());
|
||||
curve::nurbs(degree, knots, &ctrl, weights).or_else(|| curve::through(&fit.iter().map(xy).collect::<Vec<_>>(), false))
|
||||
}
|
||||
};
|
||||
append(&mut bp, &seg?);
|
||||
}
|
||||
if bp.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
bp.close_path();
|
||||
Some(bp)
|
||||
}
|
||||
2 => {
|
||||
let (centre, r, start, end) = (c.point(10)?, c.num(40)?, c.num(50)?, c.num(51)?);
|
||||
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
|
||||
let sweep = ccw_sweep(start, end);
|
||||
// Clockwise arcs have their angles measured clockwise.
|
||||
let (start, sweep) = if ccw { (start, sweep) } else { (-start, -sweep) };
|
||||
curve::arc(centre, r, start.to_radians(), sweep.to_radians())
|
||||
}
|
||||
3 => {
|
||||
let (centre, major, ratio, start, end) = (c.point(10)?, c.point(11)?.to_vec2(), c.num(40)?, c.num(50)?, c.num(51)?);
|
||||
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
|
||||
let minor = Vec2::new(-major.y, major.x) * ratio * if ccw { 1.0 } else { -1.0 };
|
||||
let sweep = ccw_sweep(start, end).to_radians();
|
||||
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start.to_radians(), sweep)
|
||||
}
|
||||
4 => {
|
||||
let degree = c.count(94)?;
|
||||
let rational = c.num(73).unwrap_or(0.0) != 0.0;
|
||||
let _periodic = c.opt(74);
|
||||
let (nk, nc) = (c.count(95)?, c.count(96)?);
|
||||
let knots: Vec<f64> = (0..nk).map(|_| c.num(40)).collect::<Option<_>>()?;
|
||||
let mut ctrl = Vec::with_capacity(nc.min(4096));
|
||||
let mut weights = Vec::with_capacity(nc.min(4096));
|
||||
for _ in 0..nc {
|
||||
ctrl.push(c.point(10)?);
|
||||
weights.push(c.num(42).unwrap_or(1.0));
|
||||
}
|
||||
// Fit data (DXF 2010 and later): a count, then that many points. Older files have none,
|
||||
// and the 97 that may follow is the boundary's count of source objects (then their
|
||||
// handles), so it is only the fit count when it is 0 or points follow.
|
||||
let fit_count = match (c.g.get(c.i), c.g.get(c.i + 1)) {
|
||||
(Some(count), next) if count.code == 97 => number(count.value) == Some(0.0) || next.is_some_and(|p| p.code == 11),
|
||||
_ => false,
|
||||
};
|
||||
let fit: Vec<Point> = match fit_count.then(|| c.count(97)).flatten() {
|
||||
Some(n) => (0..n).map(|_| c.point(11)).collect::<Option<_>>()?,
|
||||
None => vec![],
|
||||
};
|
||||
// Start and end tangents.
|
||||
for code in [12, 13] {
|
||||
c.point(code);
|
||||
}
|
||||
curve::nurbs(degree, &knots, &ctrl, rational.then_some(weights.as_slice())).or_else(|| curve::through(&fit, false))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,18 +527,21 @@ fn baseline(v: i64, height: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
|
||||
if attrib && g.int_or(70, 0) & 1 != 0 {
|
||||
/// TEXT, or an ATTRIB's text. The string is read from group 1 as written, so that the caret and
|
||||
/// percent codes are turned into text once, by `super::text`.
|
||||
fn text(t: &model::Text, g: &Groups<'_>, attrib: bool, ocs: Affine) -> Option<TextItem> {
|
||||
if attrib && g.i_or(70, 0) & 1 != 0 {
|
||||
return None;
|
||||
}
|
||||
let s = super::text::plain(g.raw(1)?);
|
||||
let s = super::text::plain(&g.s(1)?);
|
||||
if s.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let height = g.num(40).filter(|h| *h > 0.0)?;
|
||||
let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0));
|
||||
let (p1, p2) = (g.point(10), g.point_opt(11));
|
||||
let mut angle = g.num_or(50, 0.0).to_radians();
|
||||
let height = Some(t.height).filter(|h| *h > 0.0)?;
|
||||
// An ATTRIB's 73 is its field length: its vertical alignment is 74.
|
||||
let (h, v) = (t.halign, if attrib { g.i_or(74, 0) } else { t.valign });
|
||||
let (p1, p2) = (point(t.insert), t.align.map(point));
|
||||
let mut angle = t.rotation.to_radians();
|
||||
let mut span = None;
|
||||
// Aligned and fit text run from the first point to the second.
|
||||
let anchor = match (h, p2) {
|
||||
@@ -674,33 +561,36 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
|
||||
_ => Justify::Left,
|
||||
};
|
||||
let v = if h == 4 { 2 } else { v };
|
||||
let flags = g.int_or(71, 0);
|
||||
let flags = t.generation;
|
||||
let mirror = Affine::scale_non_uniform(if flags & 2 != 0 { -1.0 } else { 1.0 }, if flags & 4 != 0 { -1.0 } else { 1.0 });
|
||||
let xf = ocs(g) * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height)));
|
||||
let xf = ocs * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height)));
|
||||
Some(TextItem {
|
||||
text: s,
|
||||
height,
|
||||
xf,
|
||||
width: g.num(41).filter(|w| *w > 0.0).unwrap_or(1.0),
|
||||
width: Some(t.width_factor).filter(|w| *w > 0.0).unwrap_or(1.0),
|
||||
justify,
|
||||
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
|
||||
style: t.style.to_uppercase(),
|
||||
family: None,
|
||||
leading: None,
|
||||
span,
|
||||
})
|
||||
}
|
||||
|
||||
fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
|
||||
// The text comes in 250-character chunks (3) before the last (1).
|
||||
let raw: String = g.iter().filter(|p| p.code == 3).map(|p| p.value).chain(g.raw(1)).collect();
|
||||
/// MTEXT. The contents are read from groups 3 and 1 as written, as for [`text`].
|
||||
fn mtext(t: &model::MText, tags: &[craft_dxf::Tag]) -> Option<TextItem> {
|
||||
// The text comes in 250-character chunks (3) before the last (1), up to an embedded object.
|
||||
let (own, _) = model::text::split_embedded(tags);
|
||||
let g = Groups(own);
|
||||
let raw: String = own.iter().filter(|t| t.code == 3).map(craft_dxf::Tag::str).chain(g.s(1)).collect();
|
||||
let (s, family) = super::text::mtext(&raw);
|
||||
if s.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let height = g.num(40).filter(|h| *h > 0.0)?;
|
||||
let leading = height * LINE_SPACING * g.num(44).filter(|f| *f > 0.0).unwrap_or(1.0);
|
||||
let height = Some(t.height).filter(|h| *h > 0.0)?;
|
||||
let leading = height * LINE_SPACING * Some(t.line_spacing).filter(|f| *f > 0.0).unwrap_or(1.0);
|
||||
let lines = s.lines().count().max(1) as f64;
|
||||
let attach = g.int_or(71, 1).clamp(1, 9) - 1;
|
||||
let attach = i64::from(t.attach) - 1;
|
||||
let justify = [Justify::Left, Justify::Center, Justify::Right].get((attach % 3) as usize).copied().unwrap_or_default();
|
||||
// The first baseline below the attachment point: under the top, around the middle, above
|
||||
// the bottom of the lines.
|
||||
@@ -710,18 +600,6 @@ fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
|
||||
1 => total / 2.0 - height,
|
||||
_ => (lines - 1.0) * leading,
|
||||
};
|
||||
let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12);
|
||||
let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2());
|
||||
let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first));
|
||||
Some(TextItem {
|
||||
text: s,
|
||||
height,
|
||||
xf,
|
||||
width: 1.0,
|
||||
justify,
|
||||
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
|
||||
family,
|
||||
leading: Some(leading),
|
||||
span: None,
|
||||
})
|
||||
let xf = Affine::translate(point(t.insert).to_vec2()) * Affine::rotate(t.angle()) * Affine::translate((0.0, first));
|
||||
Some(TextItem { text: s, height, xf, width: 1.0, justify, style: t.style.to_uppercase(), family, leading: Some(leading), span: None })
|
||||
}
|
||||
|
||||
+68
-331
@@ -1,156 +1,14 @@
|
||||
//! Reading an ASCII DXF file: its text, the group-code pairs, and what the import uses of its
|
||||
//! sections (header variables; the layer, linetype, text style and block record tables; the
|
||||
//! blocks; the entities; the layouts).
|
||||
//! What the import uses of a drawing, from `craft_dxf::model`: header variables; the layer,
|
||||
//! linetype and text style tables; the blocks; the entities; the layouts.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use craft_dxf::model::{self, EntityRecord};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
/// The most group-code pairs a drawing may hold (about 300 MB of DXF).
|
||||
const MAX_PAIRS: usize = 20_000_000;
|
||||
|
||||
/// The first bytes of a binary DXF file (its format's signature).
|
||||
pub const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\x00";
|
||||
|
||||
/// One group: its code and its value as written.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Pair<'a> {
|
||||
pub code: i32,
|
||||
pub value: &'a str,
|
||||
}
|
||||
|
||||
/// Values of a run of pairs by group code.
|
||||
pub(crate) trait Groups<'a> {
|
||||
/// The value of the first `code`, as written.
|
||||
fn raw(&self, code: i32) -> Option<&'a str>;
|
||||
/// The first `code` as a name (trimmed, never empty).
|
||||
fn name(&self, code: i32) -> Option<&'a str> {
|
||||
self.raw(code).map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
/// The first `code` as a finite number.
|
||||
fn num(&self, code: i32) -> Option<f64> {
|
||||
self.raw(code).and_then(number)
|
||||
}
|
||||
fn num_or(&self, code: i32, default: f64) -> f64 {
|
||||
self.num(code).unwrap_or(default)
|
||||
}
|
||||
/// The first `code` as a whole number.
|
||||
fn int(&self, code: i32) -> Option<i64> {
|
||||
self.raw(code).and_then(integer)
|
||||
}
|
||||
fn int_or(&self, code: i32, default: i64) -> i64 {
|
||||
self.int(code).unwrap_or(default)
|
||||
}
|
||||
/// The point at `code` (x) and `code + 10` (y); 0 for what is missing.
|
||||
fn point(&self, code: i32) -> Point {
|
||||
Point::new(self.num_or(code, 0.0), self.num_or(code + 10, 0.0))
|
||||
}
|
||||
/// [`Groups::point`] when its x is given.
|
||||
fn point_opt(&self, code: i32) -> Option<Point> {
|
||||
self.num(code).map(|x| Point::new(x, self.num_or(code + 10, 0.0)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Groups<'a> for [Pair<'a>] {
|
||||
fn raw(&self, code: i32) -> Option<&'a str> {
|
||||
self.iter().find(|p| p.code == code).map(|p| p.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// A finite number (`1e999`, `NaN` and junk are none).
|
||||
pub(crate) fn number(s: &str) -> Option<f64> {
|
||||
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// A whole number; some writers put integers as `1.0`.
|
||||
pub(crate) fn integer(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15 && v.fract() == 0.0).map(|v| v as i64))
|
||||
}
|
||||
|
||||
/// One object: its type (the value of its `0` group) and the groups up to the next object.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Record<'a> {
|
||||
pub kind: &'a str,
|
||||
pub g: &'a [Pair<'a>],
|
||||
}
|
||||
|
||||
/// The text of a DXF file: UTF-8 (DXF 2007 and later), else the Windows code page older versions
|
||||
/// write (a byte order mark is dropped).
|
||||
pub(crate) fn decode(bytes: &[u8]) -> Cow<'_, str> {
|
||||
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => Cow::Borrowed(s),
|
||||
Err(_) => Cow::Owned(bytes.iter().map(|b| windows_1252(*b)).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte of the Windows Western code page.
|
||||
fn windows_1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
|
||||
'˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
|
||||
];
|
||||
match b {
|
||||
0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
|
||||
_ => char::from(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// The group-code pairs of `text`, up to `0 EOF`. A line that isn't a group code ends the
|
||||
/// reading: what came before is kept and the second value says where the file is damaged.
|
||||
pub(crate) fn pairs(text: &str) -> Result<(Vec<Pair<'_>>, Option<String>), String> {
|
||||
let mut out = Vec::new();
|
||||
let mut lines = text.lines().enumerate();
|
||||
let mut damaged = None;
|
||||
while let Some((n, code)) = lines.next() {
|
||||
let c = code.trim();
|
||||
if c.is_empty() && out.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(code) = c.parse::<i32>() else {
|
||||
damaged = Some(format!("the file is damaged at line {}: what follows is left out", n + 1));
|
||||
break;
|
||||
};
|
||||
let value = lines.next().map_or("", |(_, v)| v);
|
||||
if code == 0 && value.trim() == "EOF" {
|
||||
break;
|
||||
}
|
||||
if out.len() >= MAX_PAIRS {
|
||||
return Err("the drawing is too large to open".into());
|
||||
}
|
||||
out.push(Pair { code, value });
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err("not a DXF drawing".into());
|
||||
}
|
||||
Ok((out, damaged))
|
||||
}
|
||||
|
||||
/// The objects in `pairs`: each from a `0` group up to the next.
|
||||
fn records<'a>(pairs: &'a [Pair<'a>]) -> Vec<Record<'a>> {
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
for (i, p) in pairs.iter().enumerate() {
|
||||
if p.code == 0 {
|
||||
if let Some(s) = start {
|
||||
out.extend(record(pairs, s, i));
|
||||
}
|
||||
start = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(s) = start {
|
||||
out.extend(record(pairs, s, pairs.len()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn record<'a>(pairs: &'a [Pair<'a>], start: usize, end: usize) -> Option<Record<'a>> {
|
||||
let kind = pairs.get(start)?.value.trim();
|
||||
Some(Record { kind, g: pairs.get(start + 1..end)? })
|
||||
}
|
||||
|
||||
/// The header variables the import reads.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Header {
|
||||
@@ -186,7 +44,7 @@ pub(crate) struct LayerDef {
|
||||
pub(crate) struct BlockDef<'a> {
|
||||
pub name: String,
|
||||
pub base: Point,
|
||||
pub records: Vec<Record<'a>>,
|
||||
pub records: Vec<EntityRecord<'a>>,
|
||||
/// A reference to another drawing (its art isn't in this file).
|
||||
pub xref: bool,
|
||||
}
|
||||
@@ -218,97 +76,51 @@ pub(crate) struct Drawing<'a> {
|
||||
/// Blocks by upper-case name.
|
||||
pub blocks: HashMap<String, BlockDef<'a>>,
|
||||
/// The ENTITIES section.
|
||||
pub entities: Vec<Record<'a>>,
|
||||
pub entities: Vec<EntityRecord<'a>>,
|
||||
/// Model space first, then the paper layouts in tab order.
|
||||
pub layouts: Vec<LayoutDef>,
|
||||
/// How many sections the file has (none: not a drawing).
|
||||
pub sections: usize,
|
||||
}
|
||||
|
||||
impl<'a> Drawing<'a> {
|
||||
/// Read the sections of `pairs`.
|
||||
pub fn read(pairs: &'a [Pair<'a>]) -> Self {
|
||||
let mut d = Drawing { header: Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }, ..Drawing::default() };
|
||||
let all = records(pairs);
|
||||
let mut section: Option<&str> = None;
|
||||
let (mut tables, mut blocks, mut objects) = (vec![], vec![], vec![]);
|
||||
for r in all {
|
||||
match (r.kind, section) {
|
||||
("SECTION", _) => {
|
||||
d.sections += 1;
|
||||
section = r.g.name(2);
|
||||
if section == Some("HEADER") {
|
||||
d.header = header(r.g);
|
||||
}
|
||||
}
|
||||
("ENDSEC", _) => section = None,
|
||||
(_, Some("TABLES")) => tables.push(r),
|
||||
(_, Some("BLOCKS")) => blocks.push(r),
|
||||
(_, Some("ENTITIES")) => d.entities.push(r),
|
||||
(_, Some("OBJECTS")) => objects.push(r),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut block_records = HashMap::new();
|
||||
let mut table = "";
|
||||
for r in &tables {
|
||||
match r.kind {
|
||||
"TABLE" => table = r.g.name(2).unwrap_or(""),
|
||||
"LAYER" if table == "LAYER" => {
|
||||
if let Some(l) = layer(r.g) {
|
||||
d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len());
|
||||
d.layers.push(l);
|
||||
}
|
||||
}
|
||||
"LTYPE" if table == "LTYPE" => {
|
||||
if let Some(name) = r.g.name(2) {
|
||||
let pattern: Vec<f64> = r.g.iter().filter(|p| p.code == 49).filter_map(|p| number(p.value)).collect();
|
||||
d.linetypes.insert(name.to_uppercase(), pattern);
|
||||
}
|
||||
}
|
||||
"STYLE" if table == "STYLE" => {
|
||||
if let Some(name) = r.g.name(2) {
|
||||
d.styles.insert(name.to_uppercase(), font_family(r.g));
|
||||
}
|
||||
}
|
||||
"BLOCK_RECORD" if table == "BLOCK_RECORD" => {
|
||||
if let (Some(h), Some(name)) = (r.g.name(5), r.g.name(2)) {
|
||||
block_records.insert(h.to_uppercase(), name.to_uppercase());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
d.read_blocks(&blocks, &mut block_records);
|
||||
d.layouts = layouts(&objects, &block_records, &d.entities);
|
||||
d
|
||||
}
|
||||
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
|
||||
pub(crate) fn aci_paper(i: u8) -> [u8; 3] {
|
||||
if i == 7 { [0; 3] } else { model::aci::rgb(i) }
|
||||
}
|
||||
|
||||
fn read_blocks(&mut self, records: &[Record<'a>], block_records: &mut HashMap<String, String>) {
|
||||
let mut current: Option<BlockDef<'a>> = None;
|
||||
for r in records {
|
||||
match r.kind {
|
||||
"BLOCK" => {
|
||||
let name = r.g.name(2).or_else(|| r.g.name(3)).unwrap_or("").to_string();
|
||||
// R13+ blocks name their block record: layouts find their block through it.
|
||||
if let Some(owner) = r.g.name(330) {
|
||||
block_records.entry(owner.to_uppercase()).or_insert_with(|| name.to_uppercase());
|
||||
}
|
||||
let xref = r.g.int_or(70, 0) & 4 != 0;
|
||||
current = Some(BlockDef { name, base: r.g.point(10), records: vec![], xref });
|
||||
}
|
||||
"ENDBLK" => {
|
||||
if let Some(b) = current.take().filter(|b| !b.name.is_empty()) {
|
||||
self.blocks.insert(b.name.to_uppercase(), b);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(b) = &mut current {
|
||||
b.records.push(*r);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'a> Drawing<'a> {
|
||||
/// What the import uses of `doc`.
|
||||
pub fn read(doc: model::Document<'a>) -> Self {
|
||||
let h = &doc.header;
|
||||
let header = Header {
|
||||
acadver: h.acadver().unwrap_or_default(),
|
||||
insunits: h.insunits(),
|
||||
measurement: h.measurement(),
|
||||
ltscale: h.ltscale(),
|
||||
lwdefault: i32::from(h.lwdefault()),
|
||||
};
|
||||
let mut d = Drawing { header, sections: doc.sections, ..Drawing::default() };
|
||||
for l in doc.layers() {
|
||||
d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len());
|
||||
d.layers.push(layer(l));
|
||||
}
|
||||
for lt in doc.linetypes() {
|
||||
d.linetypes.insert(lt.name.to_uppercase(), lt.pattern.iter().map(|e| e.length).collect());
|
||||
}
|
||||
for st in doc.text_styles().into_iter().filter(|s| !s.name.is_empty()) {
|
||||
d.styles.insert(st.name.to_uppercase(), st.family().unwrap_or_default());
|
||||
}
|
||||
let block_names = doc.block_names();
|
||||
d.layouts = layouts(&doc, &block_names);
|
||||
for b in doc.blocks {
|
||||
if b.name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let def = BlockDef { name: b.name.to_string(), base: point(b.base()), xref: b.flags() & 4 != 0, records: b.entities };
|
||||
d.blocks.insert(b.name.to_uppercase(), def);
|
||||
}
|
||||
d.entities = doc.entities;
|
||||
d
|
||||
}
|
||||
|
||||
/// The layout named `name` (any case; `model` is model space).
|
||||
@@ -317,7 +129,7 @@ impl<'a> Drawing<'a> {
|
||||
}
|
||||
|
||||
/// The entity records of `layout`.
|
||||
pub fn layout_records(&self, layout: &LayoutDef) -> Vec<Record<'a>> {
|
||||
pub fn layout_records(&self, layout: &LayoutDef) -> Vec<EntityRecord<'a>> {
|
||||
let block = |name: &str| self.blocks.get(name).map(|b| b.records.clone()).unwrap_or_default();
|
||||
// The active paper layout keeps its art in the ENTITIES section, flagged paper space.
|
||||
let (block_name, paper) = match &layout.block {
|
||||
@@ -325,7 +137,7 @@ impl<'a> Drawing<'a> {
|
||||
Some(b) if b == PAPER_BLOCK => (PAPER_BLOCK, true),
|
||||
Some(b) => return block(b),
|
||||
};
|
||||
let mut out: Vec<Record<'a>> = groups(&self.entities).filter(|g| in_paper_space(g) == paper).flatten().copied().collect();
|
||||
let mut out: Vec<EntityRecord<'a>> = self.entities.iter().filter(|e| e.paper_space() == paper).cloned().collect();
|
||||
out.extend(block(block_name));
|
||||
out
|
||||
}
|
||||
@@ -336,111 +148,47 @@ impl<'a> Drawing<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is an entity (its first record) in paper space?
|
||||
fn in_paper_space(group: &[Record<'_>]) -> bool {
|
||||
group.first().is_some_and(|r| r.g.int(67) == Some(1))
|
||||
pub(crate) fn point(p: model::Point) -> Point {
|
||||
Point::new(p[0], p[1])
|
||||
}
|
||||
|
||||
/// The entities of `records`, each with the records that belong to it (a polyline's vertices,
|
||||
/// an insert's attributes, and the SEQEND closing them).
|
||||
pub(crate) fn groups<'r, 'a>(records: &'r [Record<'a>]) -> impl Iterator<Item = &'r [Record<'a>]> {
|
||||
let mut i = 0;
|
||||
std::iter::from_fn(move || {
|
||||
let start = i;
|
||||
let head = records.get(start)?;
|
||||
i += 1;
|
||||
if matches!(head.kind, "POLYLINE" | "INSERT") {
|
||||
while let Some(r) = records.get(i) {
|
||||
match r.kind {
|
||||
"VERTEX" | "ATTRIB" => i += 1,
|
||||
"SEQEND" => {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
records.get(start..i)
|
||||
})
|
||||
}
|
||||
|
||||
fn header(g: &[Pair<'_>]) -> Header {
|
||||
let mut h = Header { ltscale: 1.0, lwdefault: 25, ..Header::default() };
|
||||
let mut var = "";
|
||||
for p in g {
|
||||
if p.code == 9 {
|
||||
var = p.value.trim();
|
||||
continue;
|
||||
}
|
||||
match (var, p.code) {
|
||||
("$ACADVER", 1) => h.acadver = p.value.trim().to_string(),
|
||||
("$INSUNITS", 70) => h.insunits = integer(p.value).unwrap_or(0),
|
||||
("$MEASUREMENT", 70) => h.measurement = integer(p.value),
|
||||
("$LTSCALE", 40) => h.ltscale = number(p.value).filter(|v| *v > 0.0).unwrap_or(1.0),
|
||||
("$LWDEFAULT", 370) => h.lwdefault = integer(p.value).and_then(|v| i32::try_from(v).ok()).filter(|v| *v >= 0).unwrap_or(25),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn layer(g: &[Pair<'_>]) -> Option<LayerDef> {
|
||||
let name = g.name(2)?.to_string();
|
||||
let aci = g.int_or(62, 7);
|
||||
let flags = g.int_or(70, 0);
|
||||
let rgb = super::entity::true_color(g).unwrap_or_else(|| super::entity::aci_paper(aci.unsigned_abs()));
|
||||
Some(LayerDef {
|
||||
fn layer(l: model::Layer) -> LayerDef {
|
||||
let rgb = match l.color {
|
||||
model::Color::Rgb(rgb) => rgb,
|
||||
model::Color::Index(i) => aci_paper(i),
|
||||
model::Color::ByLayer | model::Color::ByBlock => aci_paper(7),
|
||||
};
|
||||
LayerDef {
|
||||
// CAD apps never plot this layer of dimension points.
|
||||
plot: g.int_or(290, 1) != 0 && !name.eq_ignore_ascii_case("Defpoints"),
|
||||
name,
|
||||
plot: l.plot && !l.name.eq_ignore_ascii_case("Defpoints"),
|
||||
rgb,
|
||||
hidden: aci < 0 || flags & 1 != 0,
|
||||
locked: flags & 4 != 0,
|
||||
linetype: g.name(6).unwrap_or("CONTINUOUS").to_uppercase(),
|
||||
lineweight: g.int(370).and_then(|v| i32::try_from(v).ok()).unwrap_or(-3),
|
||||
})
|
||||
}
|
||||
|
||||
/// A text style's font family: the one 2000-and-later files name, else the font file's name
|
||||
/// (TrueType); CAD fonts (`.shx`) have none.
|
||||
fn font_family(g: &[Pair<'_>]) -> String {
|
||||
if let Some(i) = g.iter().position(|p| p.code == 1001 && p.value.trim() == "ACAD")
|
||||
&& let Some(f) = g.get(i + 1..).and_then(|rest| rest.name(1000))
|
||||
{
|
||||
return f.to_string();
|
||||
}
|
||||
let file = g.name(3).unwrap_or("");
|
||||
let path = std::path::Path::new(file);
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"ttf" | "otf" | "ttc" => path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
hidden: !l.on || l.frozen,
|
||||
locked: l.locked,
|
||||
linetype: l.linetype.to_uppercase(),
|
||||
lineweight: i32::try_from(l.lineweight.to_dxf()).unwrap_or(-3),
|
||||
name: l.name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout
|
||||
/// objects but with paper-space art has one paper layout.
|
||||
/// The most bytes of layout and block names the layouts keep together. Each layout keeps its own
|
||||
/// copy of its block's name, so many layouts naming one long block name multiplied it; real
|
||||
/// drawings have a few dozen layouts with short names.
|
||||
const MAX_LAYOUT_NAME_BYTES: usize = 4 << 20;
|
||||
|
||||
fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, entities: &[Record<'_>]) -> Vec<LayoutDef> {
|
||||
/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout
|
||||
/// objects but with paper-space art has one paper layout.
|
||||
fn layouts(doc: &model::Document<'_>, block_names: &HashMap<u64, &str>) -> Vec<LayoutDef> {
|
||||
let mut paper: Vec<(i64, LayoutDef)> = vec![];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut name_bytes = 0usize;
|
||||
for r in objects.iter().filter(|r| r.kind == "LAYOUT") {
|
||||
// The layout's own groups follow its subclass marker.
|
||||
let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]);
|
||||
let Some(name) = own.name(1) else { continue };
|
||||
let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase()));
|
||||
if name.eq_ignore_ascii_case(MODEL) || block.map(String::as_str) == Some(MODEL_BLOCK) {
|
||||
for l in doc.layouts() {
|
||||
let block = l.block_record.and_then(|h| block_names.get(&h)).map(|n| n.to_uppercase());
|
||||
if l.name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) {
|
||||
continue;
|
||||
}
|
||||
let Some(block) = block else { continue };
|
||||
let key = name.to_ascii_lowercase();
|
||||
let cost = name.len() + block.len();
|
||||
let key = l.name.to_ascii_lowercase();
|
||||
let cost = l.name.len() + block.len();
|
||||
// A layout past the allowance is skipped alone: a later short one is still offered, and a
|
||||
// skipped one reserves neither its name nor its bytes.
|
||||
if seen.contains(&key) || name_bytes.saturating_add(cost) > MAX_LAYOUT_NAME_BYTES {
|
||||
@@ -448,22 +196,11 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, enti
|
||||
}
|
||||
seen.insert(key);
|
||||
name_bytes += cost;
|
||||
paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block.clone()) }));
|
||||
paper.push((l.tab_order, LayoutDef { name: l.name, block: Some(block) }));
|
||||
}
|
||||
paper.sort_by_key(|(order, _)| *order);
|
||||
if paper.is_empty() && groups(entities).any(in_paper_space) {
|
||||
if paper.is_empty() && doc.entities.iter().any(EntityRecord::paper_space) {
|
||||
paper.push((1, LayoutDef { name: "Layout1".into(), block: Some(PAPER_BLOCK.into()) }));
|
||||
}
|
||||
std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::integer;
|
||||
|
||||
#[test]
|
||||
fn integer_rejects_fractional_float_values() {
|
||||
assert_eq!(integer("1.0"), Some(1));
|
||||
assert_eq!(integer("1.9"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,7 +997,12 @@ fn binary_dwg_damaged_and_foreign_files() {
|
||||
binary.extend([0u8; 40]);
|
||||
assert!(is_dxf(&binary));
|
||||
let e = import(&binary, &ImportOptions::default()).unwrap_err();
|
||||
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
|
||||
assert!(e.contains("binary DXF") && e.contains("damaged"), "{e}");
|
||||
// A binary DXF opens as its ASCII form does.
|
||||
let ascii = entities(LINE);
|
||||
let tags = craft_dxf::parse(&ascii).unwrap();
|
||||
let r = open(&craft_dxf::write_binary(&tags, true), &points());
|
||||
assert_eq!(leaves(&r.document).len(), 1);
|
||||
let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err();
|
||||
assert!(e.contains("DWG"), "{e}");
|
||||
for junk in [&b""[..], b"hello", b"<svg/>", b"999\nnote\n"] {
|
||||
@@ -1166,9 +1171,8 @@ fn many_array_inserts_of_a_missing_block_stay_bounded() {
|
||||
let one = [(0, "INSERT"), (8, "0"), (2, "NOPE"), (10, "0"), (20, "0"), (70, "100"), (71, "100"), (44, "1"), (45, "1")];
|
||||
let e: Vec<(i32, &str)> = (0..3000).flat_map(|_| one).collect();
|
||||
let bytes = entities(&e);
|
||||
let text = reader::decode(&bytes);
|
||||
let (pairs, _) = reader::pairs(&text).unwrap();
|
||||
let d = reader::Drawing::read(&pairs);
|
||||
let tags = craft_dxf::parse(&bytes).unwrap();
|
||||
let d = reader::Drawing::read(craft_dxf::model::Document::read(&tags));
|
||||
let model = d.layout("Model").or(d.layouts.first()).unwrap();
|
||||
let mut conv = entity::Converter::default();
|
||||
let items = conv.items(&d.layout_records(model));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an
|
||||
//! HP-GL/2 plot file reader and writer.
|
||||
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and a reader on
|
||||
//! `craft_dxf::model`, and an HP-GL/2 plot file reader and writer.
|
||||
//!
|
||||
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
|
||||
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
|
||||
@@ -11,7 +11,7 @@
|
||||
//! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns,
|
||||
//! clipping) is approximated or left out, and each such loss comes back as a warning.
|
||||
//!
|
||||
//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`].
|
||||
//! [`import()`] reads a DXF drawing (ASCII or binary, any version) into a document: see [`import`].
|
||||
//! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`].
|
||||
//!
|
||||
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
|
||||
|
||||
@@ -713,6 +713,71 @@ fn corner_commands_refuse_bad_params() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_commands_refuse_an_unknown_item_key() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let i = id.0;
|
||||
let set = json!({"items": [{"id": i, "anchor": [0]}], "radius": 5});
|
||||
let e = s.execute("addon.pathtools.cornerSet", &set).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}");
|
||||
let set_late = json!({"items": [{"id": i, "anchors": [0]}, {"id": i, "radius": 3}], "radius": 5});
|
||||
let e = s.execute("addon.pathtools.cornerSet", &set_late).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `items[1].radius`; the command takes: items[1].id, items[1].anchors"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerSet", &[set, set_late]);
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"radius": 10})).unwrap();
|
||||
let remove = json!({"items": [{"id": i, "anchor": [0]}]});
|
||||
let e = s.execute("addon.pathtools.cornerRemove", &remove).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter `items[0].anchor`; did you mean `items[0].anchors`?"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerRemove", &[remove]);
|
||||
assert_eq!(drawn(&s, id, 0), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_items_take_their_documented_keys() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let i = id.0;
|
||||
let r = s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i, "anchors": [0]}], "radius": 5})).unwrap();
|
||||
assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), 5.0, 0.0));
|
||||
// A member without `anchors` takes the request's.
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"items": [{"id": i}], "anchors": [2], "radius": 6})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 2), 6.0);
|
||||
let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i, "anchors": [0]}]})).unwrap();
|
||||
assert_eq!((r["corners"].clone(), drawn(&s, id, 0), drawn(&s, id, 2)), (json!(1), 0.0, 6.0));
|
||||
// A null `ids` beside `items` counts as not given.
|
||||
s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": i}], "ids": null})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 2), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_commands_refuse_items_with_ids() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false);
|
||||
let both = json!({"items": [{"id": id.0, "anchors": [0]}], "ids": [other.0], "radius": 5});
|
||||
let e = s.execute("addon.pathtools.cornerSet", &both).unwrap_err().to_string();
|
||||
assert!(e.contains("give `items` or `ids`, not both"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerSet", &[both]);
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "radius": 5})).unwrap();
|
||||
let both = json!({"items": [{"id": id.0}], "ids": [id.0]});
|
||||
let e = s.execute("addon.pathtools.cornerRemove", &both).unwrap_err().to_string();
|
||||
assert!(e.contains("give `items` or `ids`, not both"), "{e}");
|
||||
refuses(&mut s, "addon.pathtools.cornerRemove", &[both]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_corner_steps_with_older_item_keys_still_play() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false);
|
||||
let saved = json!({"items": [{"id": id.0, "anchors": [0], "old": 1}], "ids": [other.0], "radius": 5});
|
||||
s.replay("addon.pathtools.cornerSet", &saved).unwrap();
|
||||
assert_eq!((drawn(&s, id, 0), drawn(&s, id, 1)), (5.0, 0.0));
|
||||
s.replay("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "old": 1}]})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 0), 0.0);
|
||||
}
|
||||
|
||||
/// A closed five-pointed star about (200, 150): its points at the even indices 0, 2… (the odd
|
||||
/// numbers counting from 1), its inner corners at the odd ones.
|
||||
fn star_path(s: &mut Session) -> NodeId {
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"DXF Info",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name?, dataBase64} what a DXF drawing holds → {version (2018, R12…), units (its drawing unit: Millimeters, Inches, Unitless…), layouts: [\"Model\", paper layouts…], layers: [name…], unit, scale (the default ratio: 1 unit = scale drawing units, the drawing at 1:1)}; a binary DXF or a DWG answers an error saying to save it as ASCII DXF",
|
||||
"{path} or {name?, dataBase64} what a DXF drawing holds → {version (2018, R12…), units (its drawing unit: Millimeters, Inches, Unitless…), layouts: [\"Model\", paper layouts…], layers: [name…], unit, scale (the default ratio: 1 unit = scale drawing units, the drawing at 1:1)}; a DWG answers an error saying to save it as DXF",
|
||||
always,
|
||||
dxf_info
|
||||
)]
|
||||
|
||||
@@ -163,12 +163,12 @@ fn dxf_info_lists_layouts_layers_and_units() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_dxf_says_to_save_it_as_ascii() {
|
||||
fn damaged_binary_dxf_says_so() {
|
||||
let mut s = Session::new();
|
||||
let mut bytes = vectorcraft_cad::import::BINARY_SENTINEL.to_vec();
|
||||
bytes.extend([0u8; 64]);
|
||||
let e = open(&mut s, &bytes, json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
|
||||
assert!(e.contains("binary DXF") && e.contains("damaged"), "{e}");
|
||||
let e = s.execute("document.dxfInfo", &json!({"dataBase64": b64(&bytes)})).unwrap_err().to_string();
|
||||
assert!(e.contains("binary DXF"), "{e}");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! save or Export for Screens: those are checked against the options of the formats the command
|
||||
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]); and in the
|
||||
//! objects of an array whose keys `MEMBER_KEYS` lists, such as the `items` of
|
||||
//! `object.setLiveShape`.
|
||||
//! `object.setLiveShape` and `addon.pathtools.cornerSet`.
|
||||
//!
|
||||
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
|
||||
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
|
||||
@@ -256,6 +256,22 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters a command takes instead of one another: (command, key, other key). Giving both,
|
||||
/// neither null, is a parameter error ([`check_exclusive_keys`]).
|
||||
const EXCLUSIVE_KEYS: &[(&str, &str, &str)] = &[("addon.pathtools.cornerSet", "items", "ids"), ("addon.pathtools.cornerRemove", "items", "ids")];
|
||||
|
||||
/// `params` of command `id` give at most one of each pair in [`EXCLUSIVE_KEYS`], or the parameter
|
||||
/// error naming the pair.
|
||||
fn check_exclusive_keys(id: &str, params: &Value) -> Result<()> {
|
||||
let given = |key: &str| params.get(key).filter(|v| !v.is_null());
|
||||
for (_, key, other) in EXCLUSIVE_KEYS.iter().filter(|(c, ..)| *c == id) {
|
||||
if given(key).is_some() && given(other).is_some() {
|
||||
return Err(bad(id, format!("give `{key}` or `{other}`, not both")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of
|
||||
/// them, or all of them.
|
||||
pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
@@ -273,7 +289,11 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError
|
||||
|
||||
/// Commands whose params hold an array of objects with keys of their own: the command, the key of
|
||||
/// the array and the keys each of its objects takes.
|
||||
const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[("object.setLiveShape", "items", &["id", "corners"])];
|
||||
const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[
|
||||
("object.setLiveShape", "items", &["id", "corners"]),
|
||||
("addon.pathtools.cornerSet", "items", &["id", "anchors"]),
|
||||
("addon.pathtools.cornerRemove", "items", &["id", "anchors"]),
|
||||
];
|
||||
|
||||
/// Each object in the arrays [`MEMBER_KEYS`] names for command `id` has only the keys it lists, or
|
||||
/// the parameter error naming the first other one with its place (`items[0].corner`), and the
|
||||
@@ -848,9 +868,9 @@ fn check_export_options(id: &str, params: &Value) -> Result<()> {
|
||||
check_option_keys(id, "", given, &keys, why_not, &trees)
|
||||
}
|
||||
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included,
|
||||
/// [`check_effect_params`] for the effect `effect.apply` adds and [`check_export_options`] for an
|
||||
/// export. A command the engine doesn't have passes: running it reports that.
|
||||
/// [`check_keys`], [`check_exclusive_keys`] and [`check_member_keys`] for an engine command, the steps of a `command.batch` or an
|
||||
/// `action.batch` included, [`check_effect_params`] for the effect `effect.apply` adds and
|
||||
/// [`check_export_options`] for an export. A command the engine doesn't have passes: running it reports that.
|
||||
pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
check_params_in(id, params, false)
|
||||
}
|
||||
@@ -869,6 +889,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
check_keys(id, spec.params, params)?;
|
||||
check_exclusive_keys(id, params)?;
|
||||
check_member_keys(id, params)?;
|
||||
if in_batch {
|
||||
crate::schemas::check(spec, params, false)?;
|
||||
|
||||
@@ -34,8 +34,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The Offset a wrap object takes, in points.
|
||||
pub const TEXT_WRAP_OFFSET_RANGE: std::ops::RangeInclusive<f64> = -1000.0..=1000.0;
|
||||
|
||||
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
|
||||
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) }
|
||||
let (lo, hi) = (*TEXT_WRAP_OFFSET_RANGE.start(), *TEXT_WRAP_OFFSET_RANGE.end());
|
||||
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(lo, hi), invert: bool_or(p, "invert", base.invert) }
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -867,7 +867,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Data Sets…",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{path? | data? | dataBase64? | csv?} replace every dataset with the rows of a CSV, comma- or tab-separated, in UTF-16 with a byte-order mark, UTF-8 or else Windows-1252 (header: one variable name per column, whatever its case; one row per dataset, named Data Set 1, 2… in file order). The document's variables and their bindings stay. A header that names no variable defines one, unbound: `#Name` defines a visibility variable, `@Name` defines an image variable, and a header without a prefix defines a text variable. A prefixed header whose name is a variable of another kind, and a `%` header that isn't a variable's exact name (graph data files), are refused, and so are two headers that name one variable and a header that would define a variable while the variables are locked. An import that leaves the variables and data sets as they were adds no undo step → {imported, names, defined, deletedDataSets}",
|
||||
"{path? | data? | dataBase64? | csv?} replace every dataset with the rows of a CSV, comma- or tab-separated, in UTF-16 (with a byte-order mark, or without one when at least half of its two-byte units hold a NUL byte and only one byte order reads a line break; any other file with NUL bytes is refused), UTF-8 or else Windows-1252 (header: one variable name per column, whatever its case; one row per dataset, named Data Set 1, 2… in file order). The document's variables and their bindings stay. A header that names no variable defines one, unbound: `#Name` defines a visibility variable, `@Name` defines an image variable, and a header without a prefix defines a text variable. A prefixed header whose name is a variable of another kind, and a `%` header that isn't a variable's exact name (graph data files), are refused, and so are two headers that name one variable and a header that would define a variable while the variables are locked. An import that leaves the variables and data sets as they were adds no undo step → {imported, names, defined, deletedDataSets}",
|
||||
has_doc,
|
||||
dataset_import
|
||||
),
|
||||
|
||||
@@ -32,20 +32,17 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<(Vec<u8>, bool)> {
|
||||
}
|
||||
|
||||
/// The text of a datasets CSV given as a file or as `dataBase64`: UTF-16 after a UTF-16
|
||||
/// byte-order mark, else UTF-8, with or without a byte-order mark, else Windows-1252. A file
|
||||
/// that starts with a byte-order mark must be valid in its encoding, and a file without one must
|
||||
/// not hold NUL bytes (UTF-16 without a byte-order mark).
|
||||
/// byte-order mark, else UTF-16 without one when the file holds NUL bytes (see
|
||||
/// [`utf16_without_mark`]), else UTF-8, with or without a byte-order mark, else Windows-1252. A
|
||||
/// file that starts with a byte-order mark must be valid in its encoding, and a file with NUL
|
||||
/// bytes and no byte-order mark must read as UTF-16 by [`utf16_without_mark`]'s rule.
|
||||
fn datasets_csv_text(bytes: &[u8], cmd: &str) -> Result<String> {
|
||||
let utf16 = |rest: &[u8], le: bool| {
|
||||
let (units, odd) = rest.as_chunks::<2>();
|
||||
let units = units.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
|
||||
let text = char::decode_utf16(units).collect::<std::result::Result<String, _>>();
|
||||
text.ok().filter(|_| odd.is_empty()).ok_or_else(|| bad(cmd, "CSV is not UTF-16"))
|
||||
};
|
||||
match bytes {
|
||||
[0xFF, 0xFE, rest @ ..] => return utf16(rest, true),
|
||||
[0xFE, 0xFF, rest @ ..] => return utf16(rest, false),
|
||||
_ if bytes.contains(&0) => return Err(bad(cmd, "CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark")),
|
||||
[0xFF, 0xFE, rest @ ..] => return utf16(rest, true).ok_or_else(|| bad(cmd, "CSV is not UTF-16")),
|
||||
[0xFE, 0xFF, rest @ ..] => return utf16(rest, false).ok_or_else(|| bad(cmd, "CSV is not UTF-16")),
|
||||
_ if bytes.contains(&0) => {
|
||||
return utf16_without_mark(bytes).ok_or_else(|| bad(cmd, "CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match std::str::from_utf8(bytes) {
|
||||
@@ -55,6 +52,34 @@ fn datasets_csv_text(bytes: &[u8], cmd: &str) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` as UTF-16, little-endian when `le` is set, or `None` when they aren't valid UTF-16.
|
||||
fn utf16(bytes: &[u8], le: bool) -> Option<String> {
|
||||
let (units, odd) = bytes.as_chunks::<2>();
|
||||
let units = units.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
|
||||
char::decode_utf16(units).collect::<std::result::Result<String, _>>().ok().filter(|_| odd.is_empty())
|
||||
}
|
||||
|
||||
/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16, or `None`. At least half of
|
||||
/// the two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with
|
||||
/// fewer, such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is
|
||||
/// the one that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or
|
||||
/// U+0D00, so a file that reads one in both orders is refused. A file that reads none in either
|
||||
/// order has no data rows, and is read little-endian so that the import reports that. The text
|
||||
/// must be valid UTF-16 in that order and hold no NUL character.
|
||||
fn utf16_without_mark(bytes: &[u8]) -> Option<String> {
|
||||
let (units, odd) = bytes.as_chunks::<2>();
|
||||
if !odd.is_empty() || units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() {
|
||||
return None;
|
||||
}
|
||||
let is_line_break = |u: u16| matches!(u, 0x0A | 0x0D);
|
||||
let lines_le = units.iter().any(|&c| is_line_break(u16::from_le_bytes(c)));
|
||||
let lines_be = units.iter().any(|&c| is_line_break(u16::from_be_bytes(c)));
|
||||
if lines_le && lines_be {
|
||||
return None;
|
||||
}
|
||||
utf16(bytes, !lines_be).filter(|text| !text.contains('\0'))
|
||||
}
|
||||
|
||||
/// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted.
|
||||
type CsvRow = (usize, Vec<String>, Vec<bool>);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ pub use cmd::params::{check_keys, check_params};
|
||||
pub use cmd::pathops::auto_simplify_level;
|
||||
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
|
||||
pub use cmd::select::{path_fingerprint, storable_selection, stored_path_params, stored_path_step};
|
||||
pub use cmd::textwrap::TEXT_WRAP_OFFSET_RANGE;
|
||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use cmd::{EyedropperOptions, PathfinderOptions};
|
||||
pub use journal::Journal;
|
||||
|
||||
@@ -1748,6 +1748,203 @@ fn shape_builder_arrow_keys_pass_swatches_of_the_same_color() {
|
||||
assert_eq!(s.paint.fill, after, "the swatch after Twin B");
|
||||
}
|
||||
|
||||
/// The colors of the cursor swatch preview's chips, left to right, and which one is ringed.
|
||||
fn swatch_chips(s: &mut Session, v: ViewInfo) -> Vec<([u8; 4], bool)> {
|
||||
let mut chips: Vec<(f64, [u8; 4], bool)> = s
|
||||
.overlays(v)
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
vectorcraft_tools::Overlay::Swatch { p, color, selected } => Some((p.x, color, selected)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
chips.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
chips.into_iter().map(|(_, c, sel)| (c, sel)).collect()
|
||||
}
|
||||
|
||||
/// A solid paint's color as the chips draw it.
|
||||
fn chip_color(p: &Paint) -> [u8; 4] {
|
||||
match p {
|
||||
Paint::Solid { color, .. } => color.to_rgba8(1.0),
|
||||
_ => panic!("not a solid color: {p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Live Paint Bucket: Left and Right step the active paint through the document's color swatches
|
||||
/// (the selected art keeps its paint), the Cursor Swatch Preview (on at first) shows the active
|
||||
/// paint ringed between the swatches before and after it, and with it off the arrows still step.
|
||||
#[test]
|
||||
fn live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches() {
|
||||
let mut s = session();
|
||||
let (a, _) = two(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
assert_eq!(s.tool_options_of("livePaintBucket")["cursorSwatchPreview"], json!(true));
|
||||
assert!(s.tool_claims_key(ToolKey::Right, v) && s.tool_claims_key(ToolKey::Left, v));
|
||||
assert!(!s.tool_claims_key(ToolKey::Up, v), "Up and Down keep their own use");
|
||||
let list: Vec<(String, Paint)> =
|
||||
vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| (sw.name.clone(), sw.paint.clone())).collect();
|
||||
assert!(list.len() >= 4);
|
||||
let n = list.len();
|
||||
let stroke = s.paint.stroke.clone();
|
||||
s.execute("paint.setFill", &json!({"swatch": list[0].0, "ids": []})).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[2].1);
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[1].1);
|
||||
assert_eq!(s.paint.stroke, stroke, "the stroke stays");
|
||||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its fill");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap();
|
||||
assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&list[0].1), false), (chip_color(&list[1].1), true), (chip_color(&list[2].1), false)]);
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0).with_mods(alt), v).unwrap();
|
||||
assert!(swatch_chips(&mut s, v).is_empty(), "Alt samples with the eyedropper and shows no chips");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap();
|
||||
// Past the first swatch, Left wraps around to the last.
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[n - 1].1);
|
||||
assert_eq!(swatch_chips(&mut s, v).iter().map(|c| c.0).collect::<Vec<_>>(), [&list[n - 2].1, &list[n - 1].1, &list[0].1].map(chip_color));
|
||||
// With the Stroke proxy in front, the arrows step the stroke and the chips show it.
|
||||
s.execute("paint.toggleActive", &json!({"fill": false})).unwrap();
|
||||
let fill = s.paint.fill.clone();
|
||||
s.execute("paint.setStroke", &json!({"swatch": list[1].0, "ids": []})).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!((&s.paint.fill, &s.paint.stroke), (&fill, &list[2].1));
|
||||
assert!(!s.fill_active, "the Stroke proxy stays in front");
|
||||
assert_eq!(swatch_chips(&mut s, v)[1], (chip_color(&list[2].1), true));
|
||||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its paint");
|
||||
// Cursor Swatch Preview off hides the chips; the arrows still step.
|
||||
s.set_tool_option_cmd(&json!({"tool": "livePaintBucket", "values": {"cursorSwatchPreview": false}})).unwrap();
|
||||
assert!(swatch_chips(&mut s, v).is_empty());
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.stroke, list[3].1);
|
||||
}
|
||||
|
||||
/// The Live Paint Bucket and the Shape Builder take Left and Right for swatches only without
|
||||
/// modifiers: Shift, Alt and Cmd/Ctrl arrows are left to their shortcuts and change no paint.
|
||||
#[test]
|
||||
fn swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts() {
|
||||
let mut s = session();
|
||||
two(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
let first = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc)[0].name.clone();
|
||||
s.execute("paint.setFill", &json!({"swatch": first, "ids": []})).unwrap();
|
||||
for tool in ["livePaintBucket", "shapeBuilder"] {
|
||||
s.select_tool(tool, v).unwrap();
|
||||
if tool == "shapeBuilder" {
|
||||
s.set_tool_option_cmd(&json!({"tool": tool, "values": {"pickColorFrom": "swatches"}})).unwrap();
|
||||
}
|
||||
let fill = s.paint.fill.clone();
|
||||
assert!(s.tool_claims_key_with(ToolKey::Right, Mods::default(), v), "{tool}");
|
||||
for m in [Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }, Mods { cmd: true, ..Default::default() }] {
|
||||
for key in [ToolKey::Left, ToolKey::Right] {
|
||||
assert!(!s.tool_claims_key_with(key, m, v), "{tool} {key:?} {m:?}");
|
||||
s.tool_key(key, m, v).unwrap();
|
||||
assert_eq!(s.paint.fill, fill, "{tool} {key:?} {m:?}");
|
||||
}
|
||||
}
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_ne!(s.paint.fill, fill, "{tool}: a plain Right steps");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two swatches of the same color: the Bucket's arrows step through both, and the fill and the
|
||||
/// stroke each step on from the swatch picked for them.
|
||||
#[test]
|
||||
fn live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke() {
|
||||
let mut s = session();
|
||||
two(&mut s);
|
||||
for name in ["Twin A", "Twin B"] {
|
||||
s.execute("swatch.new", &json!({"name": name, "color": "#123456"})).unwrap();
|
||||
}
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
let names: Vec<String> = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| sw.name.clone()).collect();
|
||||
let a = names.iter().position(|n| n == "Twin A").unwrap();
|
||||
assert_eq!(names[a + 1], "Twin B");
|
||||
let after = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc)[(a + 2) % names.len()].paint.clone();
|
||||
// The fill goes to Twin B.
|
||||
s.execute("paint.setFill", &json!({"swatch": "Twin A", "ids": []})).unwrap();
|
||||
let twin = s.paint.fill.clone();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, twin, "Twin B");
|
||||
// The stroke starts at Twin A: Right goes to Twin B, then past it.
|
||||
s.execute("paint.setStroke", &json!({"swatch": "Twin A", "ids": []})).unwrap();
|
||||
assert!(!s.fill_active);
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.stroke, twin, "Twin B for the stroke");
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.stroke, after, "the swatch after Twin B");
|
||||
// Back on the fill, which is still Twin B: Right goes past it.
|
||||
s.execute("paint.toggleActive", &json!({"fill": true})).unwrap();
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, after, "the fill steps on from Twin B");
|
||||
}
|
||||
|
||||
/// A color that is no swatch shows alone and Right picks the first swatch, Left the last; a paint
|
||||
/// that is not a solid color shows no chip; one swatch steps to itself; with no color swatch the
|
||||
/// tool leaves the arrows alone; during a fill drag the arrows change nothing.
|
||||
#[test]
|
||||
fn live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags() {
|
||||
let mut s = session();
|
||||
live(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 300.0, 300.0), v).unwrap();
|
||||
let list: Vec<Paint> = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| sw.paint.clone()).collect();
|
||||
let odd = Paint::solid(Color::rgb8(0x12, 0x34, 0x57));
|
||||
assert!(!list.contains(&odd));
|
||||
s.paint.fill = odd.clone();
|
||||
assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&odd), true)]);
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[0]);
|
||||
s.paint.fill = odd.clone();
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[list.len() - 1]);
|
||||
s.paint.fill = Paint::None;
|
||||
assert!(swatch_chips(&mut s, v).is_empty());
|
||||
|
||||
// During a fill drag the arrows are not taken and change nothing.
|
||||
s.paint.fill = list[0].clone();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap();
|
||||
assert!(s.tool_busy());
|
||||
assert!(!s.tool_claims_key(ToolKey::Right, v));
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, list[0]);
|
||||
assert!(swatch_chips(&mut s, v).is_empty(), "no chips during a drag");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 25.0, 50.0), v).unwrap();
|
||||
|
||||
// One color swatch: both arrows pick it, and it stands on both sides of itself.
|
||||
let one = list[0].clone();
|
||||
s.edit("t", |d, _| {
|
||||
d.swatch_groups.clear();
|
||||
d.swatches.retain(|sw| sw.paint == one);
|
||||
d.swatches.truncate(1);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).len(), 1);
|
||||
s.paint.fill = odd;
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, one);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 301.0, 300.0), v).unwrap();
|
||||
assert_eq!(swatch_chips(&mut s, v), vec![(chip_color(&one), false), (chip_color(&one), true), (chip_color(&one), false)]);
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, one);
|
||||
|
||||
// No color swatch: the arrows are left to the canvas and the paint stays.
|
||||
s.edit("t", |d, _| {
|
||||
d.swatches.clear();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!s.tool_claims_key(ToolKey::Right, v) && !s.tool_claims_key(ToolKey::Left, v));
|
||||
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
|
||||
assert_eq!(s.paint.fill, one);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_tool_alt_deletes_line_pieces() {
|
||||
let mut s = session();
|
||||
@@ -3284,9 +3481,10 @@ fn live_paint_tool_options_refuse_invalid_values_and_change_nothing() {
|
||||
let colors = [json!(3), json!("nope"), json!("#12345"), json!("#00ff0080"), json!("00ff0080"), json!("#0f08"), json!(null)];
|
||||
let widths = [json!("wide"), json!(null), json!(true)];
|
||||
let switches = [json!("true"), json!(null), json!(1)];
|
||||
for (tool, keys) in
|
||||
[("livePaintBucket", &["paintFills", "paintStrokes", "highlight"][..]), ("livePaintSelection", &["selectFills", "selectStrokes"][..])]
|
||||
{
|
||||
for (tool, keys) in [
|
||||
("livePaintBucket", &["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight"][..]),
|
||||
("livePaintSelection", &["selectFills", "selectStrokes"][..]),
|
||||
] {
|
||||
let mut bad: Vec<(&str, Value)> = keys.iter().flat_map(|k| switches.iter().map(move |v| (*k, v.clone()))).collect();
|
||||
bad.extend(colors.iter().map(|v| ("color", v.clone())));
|
||||
bad.extend(widths.iter().map(|v| ("width", v.clone())));
|
||||
|
||||
@@ -2137,8 +2137,9 @@ fn datasets_csv_reads_windows_1252_files() {
|
||||
}
|
||||
|
||||
/// A CSV that starts with a UTF-16 byte-order mark is read as UTF-16, and
|
||||
/// one that isn't valid UTF-16 is refused. A file with NUL bytes and no byte-order mark is refused.
|
||||
/// A text value holding NUL imports back from the `csv` that `dataset.export` returns.
|
||||
/// one that isn't valid UTF-16 is refused. A file with NUL bytes and no byte-order mark that isn't
|
||||
/// UTF-16 is refused. A text value holding NUL imports back from the `csv` that `dataset.export`
|
||||
/// returns.
|
||||
#[test]
|
||||
fn datasets_csv_reads_utf16_files() {
|
||||
let mut s = session();
|
||||
@@ -2160,7 +2161,7 @@ fn datasets_csv_reads_utf16_files() {
|
||||
assert!(e.contains("CSV is not UTF-16"), "{e}");
|
||||
let e = import(&mut s, &[b"\xff\xfe".as_slice(), "City\nAB,".as_bytes(), b"\x00\xd8"].concat()).unwrap_err().to_string();
|
||||
assert!(e.contains("CSV is not UTF-16"), "{e}");
|
||||
let e = import(&mut s, &utf16(b"", "City\nx\n", true)).unwrap_err().to_string();
|
||||
let e = import(&mut s, b"City\nx\0\n").unwrap_err().to_string();
|
||||
assert!(e.contains("CSV holds NUL bytes"), "{e}");
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("José"), "a refused file changes nothing");
|
||||
s.execute("dataset.set", &json!({"name": "Data Set 1", "values": {"City": "a\u{0}b"}})).unwrap();
|
||||
@@ -2170,6 +2171,63 @@ fn datasets_csv_reads_utf16_files() {
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("a\u{0}b"));
|
||||
}
|
||||
|
||||
/// A CSV in UTF-16 without a byte-order mark, with a NUL byte in at least half of its units, is
|
||||
/// read in the byte order that reads a line break: in either order, with characters whose bytes
|
||||
/// read as a line break, a quote or NUL, and when its bytes are also valid UTF-8. Files with fewer
|
||||
/// NUL bytes, that read a line break in both orders, that aren't valid UTF-16 in it, that hold a
|
||||
/// NUL character or that have an odd length are refused.
|
||||
#[test]
|
||||
fn datasets_csv_reads_utf16_files_without_a_byte_order_mark() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "City", "kind": "text"})).unwrap();
|
||||
let import = |s: &mut Session, bytes: &[u8]| s.execute("dataset.import", &json!({"dataBase64": vectorcraft_format::base64_encode(bytes)}));
|
||||
let value = |s: &Session, name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("City")).cloned();
|
||||
let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into()));
|
||||
let utf16 = |text: &str, le: bool| -> Vec<u8> { text.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }).collect() };
|
||||
import(&mut s, &utf16("City\r\nParis\r\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("Paris"));
|
||||
import(&mut s, &utf16("City\nZoë 😀\n", false)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("Zoë 😀"));
|
||||
// U+4E00's low byte is NUL, U+010A holds a `\n` byte and U+2200 a `"` byte; all three files
|
||||
// are valid UTF-8 too.
|
||||
import(&mut s, &utf16("City\n一\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一"));
|
||||
import(&mut s, &utf16("City\nĊ\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("Ċ"));
|
||||
import(&mut s, &utf16("City\n∀∀∀\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("∀∀∀"));
|
||||
// A file with no line break has no data rows in either byte order.
|
||||
for bytes in [utf16("City", true), utf16("City", false), b"\0aa\0".to_vec()] {
|
||||
let e = import(&mut s, &bytes).unwrap_err().to_string();
|
||||
assert!(e.contains("CSV has a header but no data rows"), "{e}");
|
||||
}
|
||||
import(&mut s, &utf16("City\tName\n一二三丁目\tKai\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一二三丁目"));
|
||||
// Most units hold their NUL byte where a big-endian file would (U+4E00); the line breaks
|
||||
// give the byte order.
|
||||
import(&mut s, &utf16("City\n一一一一一一一一一一二\n", true)).unwrap();
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一一一一一一一一一一二"));
|
||||
// Windows-1252 and UTF-8 text with stray NUL bytes (the first with `\n\0` units), UTF-16 with
|
||||
// fewer than half of its units holding a NUL byte, and, last, two files that read a line break
|
||||
// in both byte orders (U+0D00 reads as `\r` and U+0A00 as `\n` big-endian).
|
||||
let refused: [&[u8]; 9] = [
|
||||
b"Caf\xe9\n\0Lyon\n\0",
|
||||
b"Cit\0\n",
|
||||
b"City\nx\0\n",
|
||||
&utf16("City\n丁丁丁丁丁丁丁丁丁丁\n", true),
|
||||
&utf16("City\na\u{0}\n", true),
|
||||
&[utf16("City\n", true), vec![0x00, 0xD8], utf16("\n", true)].concat(),
|
||||
&[utf16("City\n", false), vec![0xDC, 0x00], utf16("\n", false)].concat(),
|
||||
&utf16("City\n\u{0D00}\u{0D00}\u{0D00}\n", true),
|
||||
&utf16("City\u{0A00}\nParis\n", true),
|
||||
];
|
||||
for bytes in refused {
|
||||
let e = import(&mut s, bytes).unwrap_err().to_string();
|
||||
assert!(e.contains("CSV holds NUL bytes"), "{bytes:?}: {e}");
|
||||
}
|
||||
assert_eq!(value(&s, "Data Set 1"), text_value("一一一一一一一一一一二"), "a refused file changes nothing");
|
||||
}
|
||||
|
||||
/// A data set of [`library`]: its name, and each variable's name with its paragraphs.
|
||||
type LibrarySet<'a> = (&'a str, &'a [(&'a str, &'a [&'a str])]);
|
||||
|
||||
|
||||
@@ -1164,6 +1164,12 @@ impl Session {
|
||||
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
|
||||
}
|
||||
|
||||
/// Does the active tool take `key`, pressed with `mods` held, ahead of the shortcuts bound to it
|
||||
/// (see `Tool::claims_key_with`)?
|
||||
pub fn tool_claims_key_with(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> bool {
|
||||
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key_with(cx, key, mods))
|
||||
}
|
||||
|
||||
/// Has the active tool started something that Escape ends while no button is pressed (see
|
||||
/// `Tool::ends_on_escape`)?
|
||||
pub fn tool_ends_on_escape(&mut self, view: ViewInfo) -> bool {
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "VectorCraft Encapsulated PostScript: a hand-written EPS writer (P
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-eps = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
|
||||
@@ -7582,9 +7582,10 @@ fn a_linked_object_past_the_object_limit_warns_that_its_link_is_left_out() {
|
||||
}
|
||||
|
||||
/// A link to a graphic style with Twist is kept where the reader reads Twist: on a curve, a curved
|
||||
/// compound path or a group with nothing stroked up to a quarter turn, and on a path with straight
|
||||
/// segments at any angle. A stroked curve, a group with a stroked member or a guide, or a curve,
|
||||
/// compound path or group twisted further, is written without the link and warns.
|
||||
/// compound path or a group with nothing stroked up to a quarter turn, and on a path or compound path
|
||||
/// with straight segments up to half a turn. A stroked curve, a group with a stroked member or a
|
||||
/// guide, a straight compound path with a guide member that reaches past its other members, or an
|
||||
/// object twisted further, is written without the link and warns.
|
||||
#[test]
|
||||
fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
// A document with the object `make` builds from its appearance, `ap` with Twist at `angle`,
|
||||
@@ -7619,6 +7620,17 @@ fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
n.appearance = ap.into();
|
||||
n
|
||||
};
|
||||
// A compound path of a square and a guide, a rectangle with corners `a` and `b`.
|
||||
let guided = |a: (f64, f64), b: (f64, f64)| {
|
||||
move |doc: &mut Document, ap: Appearance| {
|
||||
let outer = Node::path(doc.alloc_id(), square(), Appearance::default());
|
||||
let path = shapes::rectangle(Rect::new(a.0, a.1, b.0, b.1));
|
||||
let guide = Node::new(doc.alloc_id(), NodeKind::Path { path, rule: FillRule::NonZero, live: None, clipping: false, guide: true });
|
||||
let mut n = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(guide)], rule: FillRule::NonZero });
|
||||
n.appearance = ap.into();
|
||||
n
|
||||
}
|
||||
};
|
||||
// A group of a filled square and `second`.
|
||||
let group = |second: fn(&mut Document) -> Node| {
|
||||
move |doc: &mut Document, ap: Appearance| {
|
||||
@@ -7649,6 +7661,8 @@ fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
("a square twisted three quarters of a turn back", twisted(-270.0, filled(), path(square())), false),
|
||||
("a straight compound path twisted half a turn", twisted(180.0, filled(), frame), true),
|
||||
("a straight compound path twisted a full turn", twisted(360.0, filled(), frame), false),
|
||||
("a straight compound path with a guide inside it", twisted(45.0, filled(), guided((30.0, 20.0), (70.0, 50.0))), true),
|
||||
("a straight compound path with a guide reaching past it", twisted(45.0, filled(), guided((30.0, 20.0), (120.0, 50.0))), false),
|
||||
("a curve twisted past a quarter turn", twisted(120.0, filled(), path(oval())), false),
|
||||
("a stroked curve", twisted(45.0, Appearance::basic(black_fill(), red.clone(), 2.0), path(oval())), false),
|
||||
("a curved compound path", twisted(-60.0, filled(), ring), true),
|
||||
|
||||
@@ -1103,6 +1103,15 @@ fn no_guides(n: &Node, depth: usize) -> bool {
|
||||
depth <= MAX_DEPTH && !n.is_guide() && n.children().into_iter().flatten().all(|c| no_guides(c, depth + 1))
|
||||
}
|
||||
|
||||
/// Is `n` a compound path with a guide member whose bounds reach past the bounds of its members that
|
||||
/// aren't guides, or with guide members only? Any other object counts as no.
|
||||
fn guides_reach_past(n: &Node) -> bool {
|
||||
let NodeKind::Compound { children, .. } = &n.kind else { return false };
|
||||
let bounds =
|
||||
|guides: bool| children.iter().filter(|c| c.is_guide() == guides).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds()));
|
||||
bounds(true).is_some_and(|g| bounds(false).is_none_or(|o| o.union(g) != o))
|
||||
}
|
||||
|
||||
/// The largest Twist angle, in degrees either way, read as a live effect on an object that isn't a
|
||||
/// path or compound path with straight segments: VectorCraft's own limit. An object with a larger
|
||||
/// Twist keeps the file's drawn look.
|
||||
@@ -1116,7 +1125,8 @@ const MAX_STRAIGHT_TWIST: f64 = 180.0;
|
||||
/// Is `object` one that style `st`, whose effect moves the outline, is read on? It and everything
|
||||
/// in it [`moves_alike`], and the style paints no gradient. Twist is read only when nothing is
|
||||
/// stroked and its angle is a number: up to [`MAX_STRAIGHT_TWIST`] degrees either way on a path or
|
||||
/// compound path with straight segments, or up to [`MAX_TWIST`] degrees either way on another object
|
||||
/// compound path with straight segments (only at 0° when its guide members reach past its other
|
||||
/// members, see [`guides_reach_past`]), or up to [`MAX_TWIST`] degrees either way on another object
|
||||
/// with no guide in it. Transform that scales, turns or reflects is read only when nothing is stroked
|
||||
/// (its reference point lies on the bounds without strokes).
|
||||
fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
@@ -1129,7 +1139,12 @@ fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
&& st.effects.iter().all(|e| match e.id.as_str() {
|
||||
"distort.twist" => {
|
||||
let within = |limit: f64| e.params["angle"].as_f64().is_some_and(|a| a.abs() <= limit);
|
||||
!stroked && if straight(object) { within(MAX_STRAIGHT_TWIST) } else { no_guides(object, 0) && within(MAX_TWIST) }
|
||||
!stroked
|
||||
&& if straight(object) {
|
||||
within(MAX_STRAIGHT_TWIST) && (e.params["angle"].as_f64() == Some(0.0) || !guides_reach_past(object))
|
||||
} else {
|
||||
no_guides(object, 0) && within(MAX_TWIST)
|
||||
}
|
||||
}
|
||||
"distort.transform" => {
|
||||
let p = &e.params;
|
||||
|
||||
@@ -7296,7 +7296,11 @@ fn twist_is_read_on_curves_compound_paths_and_groups_with_nothing_stroked_up_to_
|
||||
("a twisted group", read(&twist(90.0, None), &distort_group()), 2),
|
||||
("a group with a hidden member", read(&twist(45.0, None), &hidden_member), 2),
|
||||
("a curve twisted a quarter turn back", read(&twist(-90.0, BLUE_FILL), &curved), 0),
|
||||
("a straight compound path with a guide, as before", read(&twist(45.0, BLUE_FILL), &with_guide(&rect_path(20.0, 20.0, 60.0, 50.0))), 2),
|
||||
(
|
||||
"a straight compound path with a guide inside it, as before",
|
||||
read(&twist(45.0, BLUE_FILL), &with_guide(&rect_path(0.0, 0.0, 60.0, 50.0))),
|
||||
2,
|
||||
),
|
||||
] {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
@@ -7363,6 +7367,48 @@ fn twist_is_read_on_straight_paths_and_compound_paths_up_to_half_a_turn() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twist_on_a_straight_compound_path_keeps_the_drawn_look_where_a_guide_reaches_past_its_other_members() {
|
||||
// GUARDS: a straight compound path whose guide members reach past its other members was read
|
||||
// with a live Twist measured from bounds that take in those guides, which draws differently from
|
||||
// the art saved with it.
|
||||
// The object, `hidden` painted with its style, under its drawn look.
|
||||
let object = |hidden: &str| {
|
||||
format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_"))
|
||||
};
|
||||
// A compound path of a square and the guide from `from` to `to`.
|
||||
let with_guide = |from: (f64, f64), to: (f64, f64)| {
|
||||
object(&format!("*u\n{}f\n{} {} m\n{} {} L\n(N) *\n*U\n", rect_path(20.0, 20.0, 60.0, 50.0), from.0, from.1, to.0, to.1))
|
||||
};
|
||||
let twist = |angle: f64| distort_styles("Adobe Twirl", &format!("{angle} /Real (angle) ,\n"), BLUE_FILL);
|
||||
let read = |angle: f64, art_: &str| read_ok(&format!("{}{}", twist(angle), layer(art_)));
|
||||
for (what, r) in [
|
||||
("a guide inside the square", read(45.0, &with_guide((25.0, 25.0), (35.0, 35.0)))),
|
||||
("a guide on the square's corners", read(180.0, &with_guide((20.0, 20.0), (60.0, 50.0)))),
|
||||
("a guide inside the square, half a turn back", read(-180.0, &with_guide((30.0, 40.0), (50.0, 30.0)))),
|
||||
("a guide outside the square, not turned", read(0.0, &with_guide((5.0, 0.0), (15.0, 10.0)))),
|
||||
] {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{what}: {a:#?}");
|
||||
assert_eq!(a[0].children().map_or(0, |c| c.len()), 2, "{what}: the object, not its look: {a:#?}");
|
||||
let [e] = a[0].appearance.effects.as_slice() else { panic!("{what}: {:?}", a[0].appearance) };
|
||||
assert_eq!(e.id, "distort.twist", "{what}");
|
||||
}
|
||||
for (what, r) in [
|
||||
("a guide outside the square", read(45.0, &with_guide((5.0, 0.0), (15.0, 10.0)))),
|
||||
("a guide reaching past the square's right side", read(45.0, &with_guide((30.0, 30.0), (70.0, 40.0)))),
|
||||
("a guide reaching past the square's top, half a turn", read(180.0, &with_guide((30.0, 30.0), (40.0, 51.0)))),
|
||||
("a guide around the square, a quarter turn back", read(-90.0, &with_guide((0.0, 0.0), (80.0, 70.0)))),
|
||||
("a compound path of guides only", read(45.0, &object("*u\n25 25 m\n35 35 L\n(N) *\n40 25 m\n50 35 L\n(N) *\n*U\n"))),
|
||||
] {
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{what}: {a:#?}");
|
||||
assert!(a[0].appearance.effects.is_empty(), "{what}: the drawn look: {a:#?}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
|
||||
// GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none.
|
||||
|
||||
+5
-46
@@ -352,64 +352,23 @@ fn read_data(ps: &[u8], name: &str) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The magic number of an EPS file with a binary header (a preview).
|
||||
const DOS_MAGIC: [u8; 4] = [0xC5, 0xD0, 0xD3, 0xC6];
|
||||
/// The binary header's size.
|
||||
const DOS_HEADER: usize = 30;
|
||||
pub use craft_eps::{DOS_HEADER, DOS_MAGIC, Sections, is_eps};
|
||||
|
||||
/// `ps` behind a binary header, followed by the TIFF preview `tiff`.
|
||||
fn dos_eps(ps: &[u8], tiff: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let big = || "the file is too large for an EPS preview header (4 GB): choose no preview".to_string();
|
||||
let ps_len = u32::try_from(ps.len()).map_err(|_| big())?;
|
||||
let tiff_at = u32::try_from(DOS_HEADER + ps.len()).map_err(|_| big())?;
|
||||
let tiff_len = u32::try_from(tiff.len()).map_err(|_| big())?;
|
||||
tiff_at.checked_add(tiff_len).ok_or_else(big)?;
|
||||
let mut out = Vec::with_capacity(DOS_HEADER + ps.len() + tiff.len());
|
||||
out.extend(DOS_MAGIC);
|
||||
out.extend((DOS_HEADER as u32).to_le_bytes());
|
||||
out.extend(ps_len.to_le_bytes());
|
||||
// No metafile preview.
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out.extend(tiff_at.to_le_bytes());
|
||||
out.extend(tiff_len.to_le_bytes());
|
||||
// No checksum.
|
||||
out.extend(0xFFFFu16.to_le_bytes());
|
||||
out.extend(ps);
|
||||
out.extend(tiff);
|
||||
Ok(out)
|
||||
craft_eps::wrap(ps, Some(tiff), None).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The sections of an EPS file: its PostScript and its TIFF preview (binary header), or the whole
|
||||
/// file as PostScript. `None` when a binary header points outside the file.
|
||||
pub fn sections(bytes: &[u8]) -> Option<(&[u8], Option<&[u8]>)> {
|
||||
if !bytes.starts_with(&DOS_MAGIC) {
|
||||
return Some((bytes, None));
|
||||
}
|
||||
let word = |at: usize| bytes.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize);
|
||||
let part = |at: usize| {
|
||||
let (start, len) = (word(at)?, word(at + 4)?);
|
||||
bytes.get(start..start.checked_add(len)?)
|
||||
};
|
||||
let ps = part(4)?;
|
||||
let tiff = match word(24) {
|
||||
Some(0) | None => None,
|
||||
Some(_) => Some(part(20)?),
|
||||
};
|
||||
Some((ps, tiff))
|
||||
let s = craft_eps::sections(bytes).ok()?;
|
||||
Some((s.postscript, s.tiff))
|
||||
}
|
||||
|
||||
/// The Windows metafile preview behind an EPS file's binary header, if it has one.
|
||||
pub fn metafile_preview(bytes: &[u8]) -> Option<&[u8]> {
|
||||
if !bytes.starts_with(&DOS_MAGIC) {
|
||||
return None;
|
||||
}
|
||||
let word = |at: usize| bytes.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize);
|
||||
let (start, len) = (word(12)?, word(16)?);
|
||||
if start == 0 || len == 0 {
|
||||
return None;
|
||||
}
|
||||
bytes.get(start..start.checked_add(len)?)
|
||||
craft_eps::sections(bytes).ok()?.metafile
|
||||
}
|
||||
|
||||
/// The native document an EPS file written by [`export`] carries, if any.
|
||||
|
||||
+2
-92
@@ -21,9 +21,6 @@ VCdict begin
|
||||
end
|
||||
";
|
||||
|
||||
/// Longest line of encoded data (DSC readers take up to 255 characters).
|
||||
const LINE: usize = 76;
|
||||
|
||||
/// `v` as PostScript writes it: at most four decimals, no trailing zeros (0 when not finite).
|
||||
pub(crate) fn num(v: f64) -> String {
|
||||
let mut s = String::new();
|
||||
@@ -180,54 +177,7 @@ pub(crate) fn clip_op(rule: FillRule) -> &'static str {
|
||||
|
||||
/// `bytes` in ASCII85 with its `~>` end, in lines of about [`LINE`] characters, none starting
|
||||
/// with `%` (DSC readers would take it for a comment).
|
||||
pub(crate) fn ascii85(bytes: &[u8]) -> String {
|
||||
let mut raw = String::with_capacity(bytes.len() * 5 / 4 + 8);
|
||||
let (chunks, rest) = bytes.as_chunks::<4>();
|
||||
for c in chunks {
|
||||
let v = u32::from_be_bytes(*c);
|
||||
if v == 0 {
|
||||
raw.push('z');
|
||||
} else {
|
||||
push85(&mut raw, v, 5);
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
let mut c = [0u8; 4];
|
||||
for (d, s) in c.iter_mut().zip(rest) {
|
||||
*d = *s;
|
||||
}
|
||||
push85(&mut raw, u32::from_be_bytes(c), rest.len() + 1);
|
||||
}
|
||||
raw.push_str("~>");
|
||||
let mut out = String::with_capacity(raw.len() + raw.len() / LINE + 2);
|
||||
if raw.starts_with('%') {
|
||||
// Whitespace is ignored by readers, and keeps the first line from starting with `%`.
|
||||
out.push(' ');
|
||||
}
|
||||
let mut col = 0;
|
||||
for ch in raw.chars() {
|
||||
if col >= LINE && ch != '%' {
|
||||
out.push('\n');
|
||||
col = 0;
|
||||
}
|
||||
out.push(ch);
|
||||
col += 1;
|
||||
}
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
/// The first `n` base-85 digits of `v`.
|
||||
fn push85(s: &mut String, mut v: u32, n: usize) {
|
||||
let mut d = [0u8; 5];
|
||||
for slot in d.iter_mut().rev() {
|
||||
*slot = (v % 85) as u8 + b'!';
|
||||
v /= 85;
|
||||
}
|
||||
for b in d.iter().take(n) {
|
||||
s.push(char::from(*b));
|
||||
}
|
||||
}
|
||||
pub(crate) use craft_eps::ascii85::encode as ascii85;
|
||||
|
||||
/// Most bytes [`ascii85_decode`] makes: what one filter or data section may hold.
|
||||
pub(crate) const MAX_ASCII85: usize = 1 << 28;
|
||||
@@ -241,47 +191,7 @@ pub(crate) fn ascii85_decode(text: &str) -> Option<Vec<u8>> {
|
||||
/// [`ascii85_decode`] of at most `max` bytes: `z` is four bytes for one, so the length of the
|
||||
/// text doesn't bound what it becomes (#1715).
|
||||
pub(crate) fn ascii85_decode_within(text: &str, max: usize) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity((text.len() / 5 * 4).min(max));
|
||||
let mut group = [0u8; 5];
|
||||
let mut n = 0;
|
||||
let mut chars = text.bytes().filter(|b| !b.is_ascii_whitespace()).peekable();
|
||||
while let Some(b) = chars.next() {
|
||||
if out.len() > max {
|
||||
return None;
|
||||
}
|
||||
match b {
|
||||
b'~' => {
|
||||
if chars.next() != Some(b'>') {
|
||||
return None;
|
||||
}
|
||||
if n == 1 {
|
||||
return None;
|
||||
}
|
||||
if n > 1 {
|
||||
let padded: Vec<u8> = group.iter().take(n).copied().chain(std::iter::repeat(b'u')).take(5).collect();
|
||||
let v = value85(&padded)?;
|
||||
out.extend(v.to_be_bytes().iter().take(n - 1));
|
||||
}
|
||||
return (out.len() <= max).then_some(out);
|
||||
}
|
||||
b'z' if n == 0 => out.extend([0; 4]),
|
||||
b'!'..=b'u' => {
|
||||
*group.get_mut(n)? = b;
|
||||
n += 1;
|
||||
if n == 5 {
|
||||
out.extend(value85(&group)?.to_be_bytes());
|
||||
n = 0;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The value of five base-85 digits; `None` past `u32::MAX`.
|
||||
fn value85(digits: &[u8]) -> Option<u32> {
|
||||
digits.iter().try_fold(0u32, |acc, d| acc.checked_mul(85)?.checked_add(u32::from(d.checked_sub(b'!')?)))
|
||||
craft_eps::ascii85::decode(text.as_bytes(), max)
|
||||
}
|
||||
|
||||
/// Run-length encoding (PackBits: what PostScript's `RunLengthDecode` reads), shared with TIFF.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ use super::entry::{
|
||||
UNNAMED_CHARACTERS_NOTE, max_objects,
|
||||
};
|
||||
use super::reshaped;
|
||||
use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer};
|
||||
use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, same_stroke, stroke_layer};
|
||||
use super::{
|
||||
Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget,
|
||||
FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint,
|
||||
@@ -445,15 +445,23 @@ impl<'p> Builder<'p> {
|
||||
if let Some((t, opacity, facts)) = line.finish() {
|
||||
if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) {
|
||||
outlined = true;
|
||||
if self.mask.is_some() {
|
||||
self.flush_glyphs();
|
||||
self.flush_masked();
|
||||
}
|
||||
for g in glyphs {
|
||||
let same = self.glyphs.as_ref().is_some_and(|r| {
|
||||
r.paint == g.paint
|
||||
&& r.opacity == g.opacity
|
||||
&& r.overprint == g.overprint
|
||||
&& r.stroke.is_some() == g.stroke.is_some()
|
||||
&& r.stroke.as_ref().map(|s| s.line_width) == g.stroke.as_ref().map(|s| s.line_width)
|
||||
&& (r.scale - g.scale).abs() < 1e-9
|
||||
});
|
||||
// Glyphs the file fills one by one composite where they overlap: only opaque
|
||||
// ones, drawn alike down to the stroke, and not under a soft mask (which
|
||||
// would be applied once to all of them), share a path.
|
||||
let same = g.opacity >= 0.999
|
||||
&& self.mask.is_none()
|
||||
&& self.glyphs.as_ref().is_some_and(|r| {
|
||||
r.paint == g.paint
|
||||
&& r.opacity == g.opacity
|
||||
&& r.overprint == g.overprint
|
||||
&& same_stroke(r.stroke.as_ref(), g.stroke.as_ref())
|
||||
&& (r.scale - g.scale).abs() < 1e-9
|
||||
});
|
||||
match &mut self.glyphs {
|
||||
Some(r) if same => r.path.extend(g.path.iter()),
|
||||
_ => {
|
||||
@@ -461,6 +469,12 @@ impl<'p> Builder<'p> {
|
||||
self.glyphs = Some(g);
|
||||
}
|
||||
}
|
||||
// Under a soft mask each glyph is masked on its own: the art queued for the
|
||||
// mask is drained at each glyph, or one group of them all takes it once.
|
||||
if self.mask.is_some() {
|
||||
self.flush_glyphs();
|
||||
self.flush_masked();
|
||||
}
|
||||
}
|
||||
self.warn(RESHAPED_TEXT_NOTE);
|
||||
} else {
|
||||
|
||||
@@ -235,6 +235,23 @@ pub(super) fn line_width(p: &StrokeProps, scale: f64) -> f64 {
|
||||
if p.line_width == 0.0 { HAIRLINE } else { p.line_width as f64 * scale }
|
||||
}
|
||||
|
||||
/// Do strokes `a` and `b` draw alike: both unstroked, or the same width, caps, joins, miter limit
|
||||
/// and dashes ([`stroke_layer`] takes all of them)?
|
||||
pub(super) fn same_stroke(a: Option<&StrokeProps>, b: Option<&StrokeProps>) -> bool {
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
a.line_width == b.line_width
|
||||
&& a.line_cap == b.line_cap
|
||||
&& a.line_join == b.line_join
|
||||
&& a.miter_limit == b.miter_limit
|
||||
&& a.dash_array == b.dash_array
|
||||
&& a.dash_offset == b.dash_offset
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> StrokeLayer {
|
||||
let mut st = StrokeLayer::new(paint, line_width(p, scale));
|
||||
st.opacity = opacity;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! ([`vectorcraft_text::test_fonts::matra_font`]): Ka draws Source Sans 3's `a` and the vowel sign
|
||||
//! I, set before its consonant, draws its `i`.
|
||||
|
||||
use kurbo::{Rect, Shape};
|
||||
use vectorcraft_doc::{Document, NodeKind};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
|
||||
use vectorcraft_text::FontDb;
|
||||
@@ -29,6 +30,14 @@ fn glyph(c: char) -> (u32, f64) {
|
||||
/// and a ToUnicode map naming each glyph's character. `sign_width`: the advance the file gives
|
||||
/// the vowel sign, in thousandths of an em (`None`: the font's).
|
||||
fn page(drawn: &[char], sign_width: Option<f64>) -> Vec<u8> {
|
||||
page_with(|id| format!("[{}] TJ", drawn.iter().map(|&c| id(c)).collect::<String>()), "", &[], sign_width)
|
||||
}
|
||||
|
||||
/// [`page`] showing glyphs with the text operators `ops` makes (after the position of the first
|
||||
/// glyph) from a function giving a character's glyph id in hex, and `extra` more resources: a
|
||||
/// graphics state, to set between glyphs or before them, with `objects` it refers to (the first
|
||||
/// is object `first_extra(1) + 4`).
|
||||
fn page_with(ops: impl Fn(&dyn Fn(char) -> String) -> String, extra: &str, objects: &[String], sign_width: Option<f64>) -> Vec<u8> {
|
||||
FontDb::global().add_font(matra_font().expect("the test font builds"));
|
||||
let program = matra_font().unwrap();
|
||||
let hex: String = program.iter().map(|b| format!("{b:02X}")).collect::<String>() + ">";
|
||||
@@ -55,14 +64,13 @@ fn page(drawn: &[char], sign_width: Option<f64>) -> Vec<u8> {
|
||||
),
|
||||
),
|
||||
];
|
||||
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||||
let refs: Vec<&str> = objs.iter().chain(objects).map(String::as_str).collect();
|
||||
let resources = format!(
|
||||
"/Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /MatratestSans-Regular /Encoding /Identity-H /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >> >>",
|
||||
"/Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /MatratestSans-Regular /Encoding /Identity-H /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >> >> {extra}",
|
||||
n + 2,
|
||||
n + 3
|
||||
);
|
||||
let shown: String = drawn.iter().map(|&c| format!("{:04X}", glyph(c).0)).collect();
|
||||
let content = format!("BT /F1 40 Tf 20 100 Td <{shown}> Tj ET");
|
||||
let content = format!("BT /F1 40 Tf 20 100 Td {} ET", ops(&|c| format!("<{:04X}>", glyph(c).0)));
|
||||
pdf_with(&[PdfPage { resources, ..PdfPage::new(300.0, 200.0, &content) }], &refs, None)
|
||||
}
|
||||
|
||||
@@ -117,3 +125,96 @@ fn a_mark_without_an_advance_stays_with_its_line() {
|
||||
assert_eq!(texts(&report.document), [""; 0], "the mark is no type of its own");
|
||||
assert_eq!(outlines(&report.document), 1, "both glyphs, as one path");
|
||||
}
|
||||
|
||||
/// The file's glyphs are outlines of one path when drawn alike, and a path each when a stroke
|
||||
/// property other than the width (here the dash) differs between them: the first glyph's would
|
||||
/// otherwise be drawn on all.
|
||||
#[test]
|
||||
fn outlined_glyphs_stroked_differently_stay_apart() {
|
||||
// Stroked (`1 Tr`) at the same width, one glyph solid and the next dashed.
|
||||
let alike = page_with(|id| format!("1 Tr 2 w {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), "", &[], None);
|
||||
let apart = page_with(|id| format!("1 Tr 2 w {} Tj [3 2] 0 d {} Tj [] 0 d {} Tj", id(KA), id(SIGN_I), id(KA)), "", &[], None);
|
||||
assert_eq!(outlines(&open(&alike).document), 1, "drawn alike: one path");
|
||||
assert_eq!(outlines(&open(&apart).document), 3, "solid, dashed, solid: a path each");
|
||||
}
|
||||
|
||||
/// Translucent glyphs the file fills one by one composite where they overlap, which one fill of
|
||||
/// their joined outlines does not do: each keeps a path of its own.
|
||||
#[test]
|
||||
fn outlined_translucent_glyphs_stay_apart() {
|
||||
let bytes = page_with(|id| format!("/G gs {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), "/ExtGState << /G << /ca 0.5 >> >>", &[], None);
|
||||
let report = open(&bytes);
|
||||
assert_eq!(texts(&report.document), [""; 0]);
|
||||
assert_eq!(outlines(&report.document), 3, "a path each");
|
||||
}
|
||||
|
||||
/// Glyphs the file draws under a soft mask are not joined into one path: the mask would be applied
|
||||
/// once to the whole, where the file applies it to each glyph.
|
||||
#[test]
|
||||
fn outlined_glyphs_under_a_soft_mask_stay_apart() {
|
||||
// A luminosity mask (object `first_extra(1) + 4`) of mid gray over the page.
|
||||
let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency /CS /DeviceGray >>", "0.5 g 0 0 300 200 re f");
|
||||
let mask = format!("/ExtGState << /M << /SMask << /S /Luminosity /G {} 0 R >> >> >>", first_extra(1) + 4);
|
||||
let bytes = page_with(|id| format!("/M gs {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), &mask, &[group], None);
|
||||
let report = open(&bytes);
|
||||
assert_eq!(texts(&report.document), [""; 0]);
|
||||
assert_eq!(outlines(&report.document), 3, "a path each");
|
||||
}
|
||||
|
||||
/// What the glyph outlines of `d` draw: how many path elements, and the box round them all.
|
||||
fn outline_geometry(d: &Document) -> (usize, Rect) {
|
||||
let (mut elements, mut bounds) = (0, None::<Rect>);
|
||||
d.walk(|n| {
|
||||
if is_glyph_outlines(n)
|
||||
&& let Some(p) = n.path_data()
|
||||
{
|
||||
let path = p.to_bezpath();
|
||||
elements += path.elements().len();
|
||||
let b = path.bounding_box();
|
||||
bounds = Some(bounds.map_or(b, |u| u.union(b)));
|
||||
}
|
||||
});
|
||||
(elements, bounds.unwrap_or(Rect::ZERO))
|
||||
}
|
||||
|
||||
/// A line kept as outlines draws the glyphs where they are drawn when the file is opened as
|
||||
/// outlines: none dropped or moved.
|
||||
#[test]
|
||||
fn kept_glyphs_are_where_the_file_draws_them() {
|
||||
for (drawn, sign_width) in [(vec![KA, SIGN_I, KA], None), (vec![KA, SIGN_I], Some(0.0))] {
|
||||
let bytes = page(&drawn, sign_width);
|
||||
let kept = open(&bytes);
|
||||
let all = import_with_report(&bytes, &ImportOptions { text_as: TextAs::Outlines, ..Default::default() }).unwrap();
|
||||
assert_eq!(texts(&kept.document), [""; 0], "the line is kept as outlines");
|
||||
let (kept, all) = (outline_geometry(&kept.document), outline_geometry(&all.document));
|
||||
assert!(all.0 > 0, "the glyphs have outlines");
|
||||
assert_eq!(kept.0, all.0, "every outline element is kept");
|
||||
for (a, b) in [(kept.1.x0, all.1.x0), (kept.1.y0, all.1.y0), (kept.1.x1, all.1.x1), (kept.1.y1, all.1.y1)] {
|
||||
assert!((a - b).abs() < 1e-6, "{:?} vs {:?}", kept.1, all.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many glyph outlines carry a soft mask of their own.
|
||||
fn masked_outlines(d: &Document) -> usize {
|
||||
let mut n = 0;
|
||||
d.walk(|node| n += usize::from(is_glyph_outlines(node) && node.extras().mask.is_some()));
|
||||
n
|
||||
}
|
||||
|
||||
/// A mask that isn't constant (dark on the left, light on the right) is a mask of its own on each
|
||||
/// glyph, not one on a group of them: glyphs that overlap composite one by one, as the file draws
|
||||
/// them.
|
||||
#[test]
|
||||
fn outlined_glyphs_under_a_varying_soft_mask_are_masked_one_by_one() {
|
||||
let group = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency /CS /DeviceGray >>",
|
||||
"0.2 g 0 0 150 200 re f 0.9 g 150 0 150 200 re f",
|
||||
);
|
||||
let mask = format!("/ExtGState << /M << /SMask << /S /Luminosity /G {} 0 R >> >> >>", first_extra(1) + 4);
|
||||
let bytes = page_with(|id| format!("/M gs {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), &mask, &[group], None);
|
||||
let report = open(&bytes);
|
||||
assert_eq!(texts(&report.document), [""; 0]);
|
||||
assert_eq!(outlines(&report.document), 3, "a path each");
|
||||
assert_eq!(masked_outlines(&report.document), 3, "each path has its own mask");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "VectorCraft SVG import and export"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-svg = { workspace = true }
|
||||
# Which paths stored in documents may be opened.
|
||||
craft-docpath = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
|
||||
@@ -2,90 +2,10 @@
|
||||
//! replaces every `&name;` with the entity's text as it reads, with no total limit: a short file
|
||||
//! that repeats a large entity in an attribute allocates far more than it holds.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The most text the references to entities may expand to in all (bytes).
|
||||
const MAX_EXPANDED: usize = 16 << 20;
|
||||
|
||||
/// How deep entities that refer to entities are followed (the reader's own limit is lower).
|
||||
const MAX_NESTING: u32 = 16;
|
||||
|
||||
/// The longest entity name a reference is looked for with ([`references`]).
|
||||
const MAX_NAME: usize = 256;
|
||||
|
||||
/// The internal general entities declared in `svg`: name → every value declared for it. Text that
|
||||
/// only looks like a declaration (in a comment, or in another entity's value) is kept too: the
|
||||
/// largest value counts, so it can only make the estimate larger.
|
||||
fn declared(svg: &str) -> HashMap<&str, Vec<&str>> {
|
||||
let mut map: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
for (at, _) in svg.match_indices("<!ENTITY") {
|
||||
let rest = svg.get(at + "<!ENTITY".len()..).unwrap_or("").trim_start();
|
||||
// A parameter entity (`%`) isn't referenced as `&name;`.
|
||||
if rest.starts_with('%') {
|
||||
continue;
|
||||
}
|
||||
let Some(end) = rest.find(char::is_whitespace) else { continue };
|
||||
let (name, rest) = rest.split_at(end);
|
||||
let rest = rest.trim_start();
|
||||
let Some(quote) = rest.chars().next().filter(|q| matches!(q, '"' | '\'')) else { continue };
|
||||
let Some(value) = rest.get(1..).and_then(|r| r.split_once(quote)).map(|(v, _)| v) else { continue };
|
||||
map.entry(name).or_default().push(value);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// The names of the `&name;` references in `text`.
|
||||
fn references(text: &str) -> impl Iterator<Item = &str> {
|
||||
text.match_indices('&').filter_map(|(at, _)| {
|
||||
let rest = text.get(at + 1..)?;
|
||||
// Look no further than the longest name: a file of `&`s without `;` would be read quadratically.
|
||||
let end = rest.bytes().take(MAX_NAME + 1).position(|b| b == b';')?;
|
||||
let name = rest.get(..end)?;
|
||||
(!name.is_empty() && name.len() <= MAX_NAME && !name.starts_with('#') && !name.contains(char::is_whitespace)).then_some(name)
|
||||
})
|
||||
}
|
||||
|
||||
/// What entity `name` expands to (bytes), references in its value followed.
|
||||
fn expanded<'a>(name: &'a str, entities: &HashMap<&'a str, Vec<&'a str>>, memo: &mut HashMap<&'a str, usize>, depth: u32) -> usize {
|
||||
if let Some(n) = memo.get(name) {
|
||||
return *n;
|
||||
}
|
||||
let Some(values) = entities.get(name) else { return 0 };
|
||||
if depth >= MAX_NESTING {
|
||||
return usize::MAX;
|
||||
}
|
||||
let mut most = 0usize;
|
||||
for value in values {
|
||||
let mut total = value.len();
|
||||
for inner in references(value) {
|
||||
total = total.saturating_add(expanded(inner, entities, memo, depth + 1));
|
||||
}
|
||||
most = most.max(total);
|
||||
}
|
||||
memo.insert(name, most);
|
||||
most
|
||||
}
|
||||
|
||||
/// Whether the references to the entities `svg` declares expand to more than [`MAX_EXPANDED`]
|
||||
/// Whether the references to the entities `svg` declares expand to more than the limit
|
||||
/// bytes in all. False for a file that declares none.
|
||||
pub(crate) fn expansion_too_large(svg: &str) -> bool {
|
||||
let entities = declared(svg);
|
||||
if entities.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// A reference to a longer name isn't looked for: refuse such a declaration instead.
|
||||
if entities.keys().any(|name| name.len() > MAX_NAME) {
|
||||
return true;
|
||||
}
|
||||
let mut memo = HashMap::new();
|
||||
let mut total = 0usize;
|
||||
for name in references(svg) {
|
||||
total = total.saturating_add(expanded(name, &entities, &mut memo, 0));
|
||||
if total > MAX_EXPANDED {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
craft_svg::guard::entities_expand_past(svg, craft_svg::guard::MAX_ENTITY_EXPANSION)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -118,7 +38,7 @@ mod tests {
|
||||
assert!(expansion_too_large(&commented));
|
||||
let in_value = format!("<!DOCTYPE svg [<!ENTITY c \"<!ENTITY a 'x'>\"><!ENTITY a \"{big}\">]><svg id=\"{refs}\"/>");
|
||||
assert!(expansion_too_large(&in_value));
|
||||
let long = "n".repeat(MAX_NAME + 1);
|
||||
let long = "n".repeat(257);
|
||||
let long_name = format!("<!DOCTYPE svg [<!ENTITY {long} \"x\">]><svg id=\"&{long};\"/>");
|
||||
assert!(expansion_too_large(&long_name));
|
||||
}
|
||||
|
||||
+19
-64
@@ -458,21 +458,17 @@ pub(crate) fn body_hash(parts: &[&str]) -> String {
|
||||
|
||||
/// Does `bytes` start like gzip data (an SVGZ file)?
|
||||
pub fn is_svgz(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&[0x1f, 0x8b])
|
||||
craft_svg::sniff::is_svgz(bytes)
|
||||
}
|
||||
|
||||
/// Compress SVG text as SVGZ (gzip).
|
||||
pub fn compress(svg: &str) -> Vec<u8> {
|
||||
compress_bytes(svg.as_bytes())
|
||||
craft_svg::svgz::compress(svg.as_bytes())
|
||||
}
|
||||
|
||||
/// Compress an SVG file (in any encoding, see [`Output::bytes`]) as SVGZ (gzip).
|
||||
pub fn compress_bytes(svg: &[u8]) -> Vec<u8> {
|
||||
use std::io::Write as _;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::with_capacity(svg.len() / 4), flate2::Compression::default());
|
||||
// Writing to a Vec cannot fail.
|
||||
let _ = gz.write_all(svg);
|
||||
gz.finish().unwrap_or_default()
|
||||
craft_svg::svgz::compress(svg)
|
||||
}
|
||||
|
||||
/// Most bytes an SVGZ file may unpack to (a guard against decompression bombs).
|
||||
@@ -487,29 +483,26 @@ pub fn text_of(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, SvgError> {
|
||||
/// [`text_of`], SVGZ bytes unpacking within what `unpacked`, the bytes SVGZ data unpacked to so
|
||||
/// far, leaves of [`MAX_SVGZ`]. What they unpack to is added to it, whether or not it decodes.
|
||||
pub(crate) fn text_within<'a>(bytes: &'a [u8], unpacked: &std::sync::atomic::AtomicU64) -> Result<std::borrow::Cow<'a, str>, SvgError> {
|
||||
use std::io::Read as _;
|
||||
use std::sync::atomic::Ordering;
|
||||
if !is_svgz(bytes) {
|
||||
return match decode(bytes)? {
|
||||
Decoded::Utf8(skip) => std::str::from_utf8(bytes.get(skip..).unwrap_or_default()).map(Into::into).map_err(|_| not_utf8()),
|
||||
Decoded::Text(t) => Ok(t.into()),
|
||||
};
|
||||
return craft_svg::svgz::decode(bytes).map_err(|_| not_utf8());
|
||||
}
|
||||
let most = MAX_SVGZ.saturating_sub(unpacked.load(Ordering::Relaxed));
|
||||
let mut raw = Vec::new();
|
||||
let read = flate2::read::GzDecoder::new(bytes).take(most.saturating_add(1)).read_to_end(&mut raw);
|
||||
// Read before a failure is kept: it was unpacked too.
|
||||
unpacked.fetch_add(raw.len() as u64, Ordering::Relaxed);
|
||||
read.map_err(|e| SvgError::Parse(format!("not a valid SVGZ file: {e}")))?;
|
||||
if raw.len() as u64 > most {
|
||||
return Err(SvgError::TooBig(format!("the SVGZ file unpacks to more than {} MB", most >> 20)));
|
||||
}
|
||||
match decode(&raw)? {
|
||||
Decoded::Utf8(skip) => {
|
||||
raw.drain(..skip);
|
||||
String::from_utf8(raw).map(Into::into).map_err(|_| not_utf8())
|
||||
let most = usize::try_from(MAX_SVGZ.saturating_sub(unpacked.load(Ordering::Relaxed))).unwrap_or(usize::MAX);
|
||||
match craft_svg::svgz::inflate(bytes, most) {
|
||||
Ok(raw) => {
|
||||
unpacked.fetch_add(raw.len() as u64, Ordering::Relaxed);
|
||||
craft_svg::svgz::decode(&raw)
|
||||
.map(|t| std::borrow::Cow::Owned(t.into_owned()))
|
||||
.map_err(|_| not_utf8())
|
||||
}
|
||||
Decoded::Text(t) => Ok(t.into()),
|
||||
Err(craft_svg::svgz::Error::TooLarge(_)) => {
|
||||
Err(SvgError::TooBig(format!("the SVGZ file unpacks to more than {} MB", most >> 20)))
|
||||
}
|
||||
Err(craft_svg::svgz::Error::Corrupt(e)) => {
|
||||
Err(SvgError::Parse(format!("not a valid SVGZ file: {e}")))
|
||||
}
|
||||
Err(craft_svg::svgz::Error::Encoding) => Err(not_utf8()),
|
||||
Err(e) => Err(SvgError::Parse(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,44 +510,6 @@ fn not_utf8() -> SvgError {
|
||||
SvgError::Parse("SVG is not UTF-8, UTF-16 or ISO 8859-1".into())
|
||||
}
|
||||
|
||||
/// How SVG bytes are read.
|
||||
enum Decoded {
|
||||
/// UTF-8 after this many bytes (a byte order mark).
|
||||
Utf8(usize),
|
||||
/// Text decoded from another encoding.
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// The encoding of SVG bytes: a byte order mark (UTF-8 or UTF-16), else the XML declaration's
|
||||
/// (ISO 8859-1 by its names; anything else is read as UTF-8).
|
||||
fn decode(bytes: &[u8]) -> Result<Decoded, SvgError> {
|
||||
let utf16 = |rest: &[u8], unit: fn([u8; 2]) -> u16| {
|
||||
let (units, odd) = rest.as_chunks::<2>();
|
||||
if !odd.is_empty() {
|
||||
return Err(not_utf8());
|
||||
}
|
||||
char::decode_utf16(units.iter().map(|u| unit(*u))).collect::<Result<String, _>>().map(Decoded::Text).map_err(|_| not_utf8())
|
||||
};
|
||||
match bytes {
|
||||
[0xef, 0xbb, 0xbf, ..] => Ok(Decoded::Utf8(3)),
|
||||
[0xfe, 0xff, rest @ ..] => utf16(rest, u16::from_be_bytes),
|
||||
[0xff, 0xfe, rest @ ..] => utf16(rest, u16::from_le_bytes),
|
||||
_ if declared_latin1(bytes) => Ok(Decoded::Text(bytes.iter().map(|b| char::from(*b)).collect())),
|
||||
_ => Ok(Decoded::Utf8(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the XML declaration at the start of `bytes` name ISO 8859-1?
|
||||
fn declared_latin1(bytes: &[u8]) -> bool {
|
||||
let head = bytes.get(..bytes.len().min(200)).unwrap_or_default();
|
||||
let Some(decl) = head.strip_prefix(b"<?xml").and_then(|d| d.split(|b| *b == b'>').next()) else { return false };
|
||||
let decl = String::from_utf8_lossy(decl).to_ascii_lowercase();
|
||||
let Some(at) = decl.find("encoding") else { return false };
|
||||
let value = decl.get(at + "encoding".len()..).unwrap_or("").trim_start().trim_start_matches('=').trim_start();
|
||||
let name = value.trim_start_matches(['"', '\'']).split(['"', '\'', ' ', '?']).next().unwrap_or("");
|
||||
matches!(name, "iso-8859-1" | "iso8859-1" | "iso_8859-1" | "latin1" | "latin-1" | "l1")
|
||||
}
|
||||
|
||||
/// Import an SVG document.
|
||||
pub fn import(svg: &str) -> Result<Document, SvgError> {
|
||||
import_with_report(svg).map(|(d, _)| d)
|
||||
|
||||
+43
-19
@@ -143,6 +143,38 @@ fn step_swatch(list: &[&Swatch], fill: &Paint, last: Option<usize>, forward: boo
|
||||
}
|
||||
}
|
||||
|
||||
/// Is no Shift, Alt, Cmd or Ctrl held? The arrows step swatches only then.
|
||||
fn plain(mods: Mods) -> bool {
|
||||
!(mods.shift || mods.alt || mods.cmd || mods.ctrl)
|
||||
}
|
||||
|
||||
/// The color swatch the Right (`forward`) or Left arrow key picks for the current `paint`, with its
|
||||
/// index in [`color_swatches`] (see [`step_swatch`]); None when the document has no color swatch.
|
||||
fn pick_swatch(doc: &Document, paint: &Paint, last: Option<usize>, forward: bool) -> Option<(usize, String)> {
|
||||
let list = color_swatches(doc);
|
||||
step_swatch(&list, paint, last, forward).and_then(|i| Some((i, list.get(i)?.name.clone())))
|
||||
}
|
||||
|
||||
/// The cursor swatch preview for the current `paint` with the pointer at `at`: the swatch before
|
||||
/// it, `paint` itself (ringed, while it is a solid color) and the swatch after it, side by side above
|
||||
/// the pointer. With `matched_only`, the swatches beside it show only while `paint` is one of the
|
||||
/// color swatches; otherwise the last and the first swatch stand beside a color that is none of them.
|
||||
fn swatch_chips(cx: &ToolContext, at: Point, paint: &Paint, last: Option<usize>, matched_only: bool) -> Vec<Overlay> {
|
||||
let list = color_swatches(cx.doc);
|
||||
let rgba = |p: &Paint| match p {
|
||||
Paint::Solid { color, .. } => Some(color.to_rgba8(1.0)),
|
||||
_ => None,
|
||||
};
|
||||
let beside = !matched_only || swatch_index(&list, paint, last).is_some();
|
||||
let chip = |forward| beside.then(|| step_swatch(&list, paint, last, forward)).flatten().and_then(|i| rgba(&list.get(i)?.paint));
|
||||
let (before, after) = (chip(false), chip(true));
|
||||
let y = at.y - cx.tol(22.0);
|
||||
[(before, -16.0, false), (rgba(paint), 0.0, true), (after, 16.0, false)]
|
||||
.into_iter()
|
||||
.filter_map(|(color, dx, selected)| Some(Overlay::Swatch { p: Point::new(at.x + cx.tol(dx), y), color: color?, selected }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Shape Builder tool: drag across regions to merge them. Holding Alt (Option) switches to erase
|
||||
/// mode while it is held: the pointer shows a minus, what a click or drag would delete highlights in
|
||||
/// red, and the gesture deletes the regions it touches, the pieces of open paths and the edges
|
||||
@@ -425,20 +457,7 @@ impl ShapeBuilderTool {
|
||||
/// The cursor swatch preview: the swatch before the current fill, the current fill (ringed)
|
||||
/// and the swatch after it, side by side above the pointer.
|
||||
fn swatch_chips(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(at) = self.at else { return vec![] };
|
||||
let list = color_swatches(cx.doc);
|
||||
let fill = &cx.paint.fill;
|
||||
let rgba = |p: &Paint| match p {
|
||||
Paint::Solid { color, .. } => Some(color.to_rgba8(1.0)),
|
||||
_ => None,
|
||||
};
|
||||
let chip = |forward| step_swatch(&list, fill, self.picked, forward).and_then(|i| rgba(&list.get(i)?.paint));
|
||||
let (before, after) = (chip(false), chip(true));
|
||||
let y = at.y - cx.tol(22.0);
|
||||
[(before, -16.0, false), (rgba(fill), 0.0, true), (after, 16.0, false)]
|
||||
.into_iter()
|
||||
.filter_map(|(color, dx, selected)| Some(Overlay::Swatch { p: Point::new(at.x + cx.tol(dx), y), color: color?, selected }))
|
||||
.collect()
|
||||
self.at.map(|at| swatch_chips(cx, at, &cx.paint.fill, self.picked, false)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,9 +731,15 @@ impl Tool for ShapeBuilderTool {
|
||||
(self.opts.swatches && !self.dragging && matches!(key, ToolKey::Left | ToolKey::Right)) || (self.dragging && key == ToolKey::Escape)
|
||||
}
|
||||
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
/// The arrows pick swatches only without modifiers; Shift, Alt and Cmd/Ctrl arrows keep their
|
||||
/// shortcuts.
|
||||
fn claims_key_with(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> bool {
|
||||
self.claims_key(cx, key) && (key == ToolKey::Escape || plain(mods))
|
||||
}
|
||||
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
// While a merge is being computed, the arrows leave the current fill it takes as it was.
|
||||
if !self.claims_key(cx, key) || self.editing() {
|
||||
if !self.claims_key_with(cx, key, mods) || self.editing() {
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape {
|
||||
@@ -726,13 +751,12 @@ impl Tool for ShapeBuilderTool {
|
||||
(self.marquee, self.hover, self.far) = (None, None, false);
|
||||
return vec![];
|
||||
}
|
||||
let list = color_swatches(cx.doc);
|
||||
let Some((i, s)) = step_swatch(&list, &cx.paint.fill, self.picked, key == ToolKey::Right).and_then(|i| Some((i, list.get(i)?))) else {
|
||||
let Some((i, name)) = pick_swatch(cx.doc, &cx.paint.fill, self.picked, key == ToolKey::Right) else {
|
||||
return vec![];
|
||||
};
|
||||
self.picked = Some(i);
|
||||
// The current fill only (no `ids`): the selected art keeps its own.
|
||||
vec![Action::Exec("paint.setFill".into(), json!({ "swatch": s.name, "ids": [] }))]
|
||||
vec![Action::Exec("paint.setFill".into(), json!({ "swatch": name, "ids": [] }))]
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::lookup::{is_live_paint, live_paint_at, live_paint_at_where, visible_e
|
||||
use super::map::{HIGHLIGHT_RED, sorted_roots};
|
||||
use super::{
|
||||
Action, Arc, BezPath, CLICK_TOL_PX, Cursor, Document, EDGE_NAME, FACE_NAME, FillRule, Mods, Node, NodeId, Overlay, Point, PointerEvent,
|
||||
PointerKind, Rect, Tool, ToolContext, ToolKey, Value, json, json_ids, path_contains,
|
||||
PointerKind, Rect, Tool, ToolContext, ToolKey, Value, color_swatches, json, json_ids, path_contains, pick_swatch, plain, swatch_chips,
|
||||
};
|
||||
|
||||
// ---------- Live Paint Bucket ----------
|
||||
@@ -171,6 +171,14 @@ enum LpPaint {
|
||||
/// click paints nothing. With Highlight off, the face or edge under the pointer is not
|
||||
/// highlighted. The cursor is a filled bucket where a click fills a face, a brush where it paints
|
||||
/// an edge and an empty bucket where it paints nothing; with Alt it is the Eyedropper.
|
||||
///
|
||||
/// The Left and Right arrow keys without Shift, Alt or Cmd/Ctrl set the active paint (the fill,
|
||||
/// or the stroke when the Stroke proxy is in front) to the color swatch before or after it in the
|
||||
/// Swatches panel, wrapping around; from a color that is no swatch, Right picks the first swatch
|
||||
/// and Left the last. The selected art keeps its paint. With `cursorSwatchPreview` (on), the
|
||||
/// active paint shows as a chip above the pointer between the swatches before and after it,
|
||||
/// except during a fill drag or with Alt held; a solid color that is no swatch shows alone, and a
|
||||
/// paint that is not a solid color shows no chip.
|
||||
pub struct LivePaintBucketTool {
|
||||
pub(super) cache: RegionCache,
|
||||
pub(super) hover: LpHover,
|
||||
@@ -183,6 +191,11 @@ pub struct LivePaintBucketTool {
|
||||
highlight: bool,
|
||||
color: [u8; 3],
|
||||
width: f64,
|
||||
/// Cursor Swatch Preview.
|
||||
preview: bool,
|
||||
/// The index in [`color_swatches`] of the swatch the arrow keys picked last for the fill and for
|
||||
/// the stroke.
|
||||
picked: [Option<usize>; 2],
|
||||
}
|
||||
|
||||
/// Maximum points in one Live Paint fill path chunk.
|
||||
@@ -200,11 +213,24 @@ impl Default for LivePaintBucketTool {
|
||||
highlight: true,
|
||||
color: HIGHLIGHT_RED,
|
||||
width: LP_BUCKET_WIDTH,
|
||||
preview: true,
|
||||
picked: [None; 2],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LivePaintBucketTool {
|
||||
/// The active paint the arrow keys step and the preview shows: the fill, or the stroke when the
|
||||
/// Stroke proxy is in front.
|
||||
fn active_paint<'a>(cx: &'a ToolContext) -> &'a vectorcraft_color::Paint {
|
||||
if cx.fill_active { &cx.paint.fill } else { &cx.paint.stroke }
|
||||
}
|
||||
|
||||
/// The slot of [`Self::picked`] for the active paint.
|
||||
fn picked_slot(cx: &ToolContext) -> usize {
|
||||
usize::from(!cx.fill_active)
|
||||
}
|
||||
|
||||
fn begin_fill(&mut self, params: Value, p: Point) -> Vec<Action> {
|
||||
self.fill_drag = Some((params.clone(), vec![vec![p]]));
|
||||
vec![Action::Begin("Live Paint Bucket".into()), Action::Preview("livePaint.fill".into(), params)]
|
||||
@@ -377,6 +403,8 @@ impl Tool for LivePaintBucketTool {
|
||||
}
|
||||
PointerKind::Drag => self.drag_fill(p),
|
||||
PointerKind::Up => {
|
||||
// The swatch preview shows again at the release point.
|
||||
self.at = Some((p, ev.mods.shift, ev.mods.alt));
|
||||
let mut acts = self.drag_fill(p);
|
||||
if self.fill_drag.take().is_some() {
|
||||
acts.push(Action::Commit);
|
||||
@@ -387,24 +415,63 @@ impl Tool for LivePaintBucketTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.fill_drag.take().is_some() { vec![Action::Cancel] } else { vec![] }
|
||||
/// Escape during a fill drag cancels it. Left and Right pick the color swatch before or after
|
||||
/// the active paint, while no drag is on.
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape {
|
||||
return if self.fill_drag.take().is_some() { vec![Action::Cancel] } else { vec![] };
|
||||
}
|
||||
if !self.claims_key_with(cx, key, mods) {
|
||||
return vec![];
|
||||
}
|
||||
let slot = Self::picked_slot(cx);
|
||||
let Some((i, name)) = pick_swatch(cx.doc, Self::active_paint(cx), self.picked[slot], key == ToolKey::Right) else {
|
||||
return vec![];
|
||||
};
|
||||
self.picked[slot] = Some(i);
|
||||
// The active paint only (no `ids`): the selected art keeps its own.
|
||||
let cmd = if cx.fill_active { "paint.setFill" } else { "paint.setStroke" };
|
||||
vec![Action::Exec(cmd.into(), json!({ "swatch": name, "ids": [] }))]
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.fill_drag.is_some()
|
||||
}
|
||||
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
key == ToolKey::Escape && self.busy()
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
match key {
|
||||
ToolKey::Escape => self.busy(),
|
||||
ToolKey::Left | ToolKey::Right => !self.busy() && !color_swatches(cx.doc).is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The arrows pick swatches only without modifiers; Shift, Alt and Cmd/Ctrl arrows keep their
|
||||
/// shortcuts.
|
||||
fn claims_key_with(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> bool {
|
||||
self.claims_key(cx, key) && (key == ToolKey::Escape || plain(mods))
|
||||
}
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
if self.fill_drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32)
|
||||
/// The pointer hovers nothing for the tool: its highlight and its swatch preview go.
|
||||
fn hover_ended(&mut self, _cx: &ToolContext) {
|
||||
self.hover = LpHover::None;
|
||||
self.at = None;
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32);
|
||||
// No chips during a fill drag, or while Alt makes the tool sample with the eyedropper.
|
||||
if self.preview
|
||||
&& !self.busy()
|
||||
&& let Some((at, _, false)) = self.at
|
||||
{
|
||||
out.extend(swatch_chips(cx, at, Self::active_paint(cx), self.picked[Self::picked_slot(cx)], true));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
@@ -416,6 +483,7 @@ impl Tool for LivePaintBucketTool {
|
||||
json!({
|
||||
"paintFills": self.fills,
|
||||
"paintStrokes": self.strokes,
|
||||
"cursorSwatchPreview": self.preview,
|
||||
"highlight": self.highlight,
|
||||
"color": format!("#{r:02x}{g:02x}{b:02x}"),
|
||||
"width": self.width,
|
||||
@@ -426,6 +494,7 @@ impl Tool for LivePaintBucketTool {
|
||||
match key {
|
||||
"paintFills" => self.fills = v.as_bool().unwrap_or(self.fills),
|
||||
"paintStrokes" => self.strokes = v.as_bool().unwrap_or(self.strokes),
|
||||
"cursorSwatchPreview" => self.preview = v.as_bool().unwrap_or(self.preview),
|
||||
"highlight" => self.highlight = v.as_bool().unwrap_or(self.highlight),
|
||||
"color" => self.color = lp_color(v).unwrap_or(self.color),
|
||||
"width" => {
|
||||
@@ -612,7 +681,7 @@ pub(super) fn lp_rgb_color(v: &Value) -> bool {
|
||||
/// value it cannot read.
|
||||
pub fn check_live_paint_options(tool: &str, values: &serde_json::Map<String, Value>) -> Result<(), String> {
|
||||
let switches: &[&str] = match tool {
|
||||
"livePaintBucket" => &["paintFills", "paintStrokes", "highlight"],
|
||||
"livePaintBucket" => &["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight"],
|
||||
"livePaintSelection" => &["selectFills", "selectStrokes"],
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
@@ -1087,6 +1087,42 @@ fn bucket_click_fills_face_and_shift_click_strokes_edge() {
|
||||
assert_eq!(id, "livePaint.strokeEdge");
|
||||
}
|
||||
|
||||
/// After a fill drag, the cursor swatch preview shows at the release point, not where the pointer
|
||||
/// last moved before the press.
|
||||
#[test]
|
||||
fn bucket_swatch_preview_follows_the_release_point() {
|
||||
let (d, ..) = live_group_doc();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
let chip_x = |t: &LivePaintBucketTool| {
|
||||
t.overlays(&cx).iter().find_map(|o| if let Overlay::Swatch { p, selected: true, .. } = o { Some(p.x) } else { None })
|
||||
};
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 25.0, 25.0));
|
||||
assert_eq!(chip_x(&t), Some(25.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 25.0));
|
||||
assert_eq!(chip_x(&t), None, "no chips during the drag");
|
||||
t.pointer(&cx, &ev(PointerKind::Up, 75.0, 25.0));
|
||||
assert_eq!(chip_x(&t), Some(75.0));
|
||||
}
|
||||
|
||||
/// When the pointer leaves the canvas, the Live Paint Bucket's swatch preview and highlight go.
|
||||
#[test]
|
||||
fn bucket_hover_ended_drops_the_swatch_preview_and_highlight() {
|
||||
let (d, ..) = live_group_doc();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
|
||||
let ov = t.overlays(&cx);
|
||||
assert!(ov.iter().any(|o| matches!(o, Overlay::Swatch { .. })) && ov.iter().any(|o| matches!(o, Overlay::Path { .. })), "{ov:?}");
|
||||
t.hover_ended(&cx);
|
||||
assert!(t.overlays(&cx).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_options_choose_what_a_click_paints() {
|
||||
let (d, gid, ..) = live_group_doc();
|
||||
@@ -1113,7 +1149,12 @@ fn bucket_options_choose_what_a_click_paints() {
|
||||
};
|
||||
let (face, edge) = (Some(Rect::new(50.0, 0.0, 100.0, 50.0)), Some(Rect::new(50.0, 0.0, 50.0, 50.0)));
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
assert_eq!(t.options(), json!({"paintFills": true, "paintStrokes": false, "highlight": true, "color": "#ff2a2a", "width": 4.0}));
|
||||
assert_eq!(
|
||||
t.options(),
|
||||
json!({"paintFills": true, "paintStrokes": false, "cursorSwatchPreview": true, "highlight": true, "color": "#ff2a2a", "width": 4.0})
|
||||
);
|
||||
// The highlight alone, without the swatch chips.
|
||||
t.set_option("cursorSwatchPreview", &json!(false));
|
||||
// Paint Fills alone: 1 pt from the edge, a click fills the face and Shift-click strokes the edge.
|
||||
assert_eq!((click(&mut t, 51.0, Mods::default()), hovered(&mut t, 51.0, Mods::default())), (fill(51.0), face));
|
||||
assert_eq!((click(&mut t, 51.0, shift), hovered(&mut t, 51.0, shift)), (stroke(51.0), edge));
|
||||
@@ -1231,6 +1272,7 @@ fn bucket_fills_a_region_of_a_selection_that_mixes_a_group_and_paths() {
|
||||
s.set([g]);
|
||||
let live = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
t.set_option("cursorSwatchPreview", &json!(false));
|
||||
t.pointer(&live, &ev(PointerKind::Move, pos.x, pos.y));
|
||||
t.poll(&live, true);
|
||||
assert!(t.overlays(&live).is_empty());
|
||||
@@ -1245,6 +1287,7 @@ fn bucket_strokes_alone_paints_and_highlights_no_region_of_the_selection() {
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
t.set_option("cursorSwatchPreview", &json!(false));
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
|
||||
t.poll(&cx, true);
|
||||
assert!(!t.overlays(&cx).is_empty(), "Paint Fills highlights the region");
|
||||
@@ -1263,6 +1306,7 @@ fn bucket_options_set_the_highlight() {
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
t.set_option("cursorSwatchPreview", &json!(false));
|
||||
let highlight = |t: &mut LivePaintBucketTool| {
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0));
|
||||
match t.overlays(&cx).as_slice() {
|
||||
@@ -1299,6 +1343,7 @@ fn bucket_highlights_an_edge_thinner_than_a_face() {
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = LivePaintBucketTool::default();
|
||||
t.set_option("cursorSwatchPreview", &json!(false));
|
||||
let width = |t: &mut LivePaintBucketTool, x: f64, shift: bool| {
|
||||
t.pointer(&cx, &ev(PointerKind::Move, x, 25.0).with_mods(Mods { shift, ..Default::default() }));
|
||||
match t.overlays(&cx).as_slice() {
|
||||
@@ -1517,7 +1562,7 @@ fn bucket_hover_builds_a_large_map_on_another_thread() {
|
||||
poll_until_ready(&mut t, &cx);
|
||||
assert!(matches!(t.hover, LpHover::Region(_)), "{:?}", t.hover);
|
||||
assert_eq!((t.cache.builds, t.cache.background), (1, 1));
|
||||
assert_eq!(t.overlays(&cx).len(), 1);
|
||||
assert_eq!(t.overlays(&cx).iter().filter(|o| matches!(o, Overlay::Path { .. })).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1883,6 +1928,7 @@ fn live_paint_edges_outside_the_faces_come_from_the_nearest_visible_group() {
|
||||
};
|
||||
assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]);
|
||||
let mut bucket = LivePaintBucketTool::default();
|
||||
bucket.set_option("cursorSwatchPreview", &json!(false));
|
||||
bucket.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(shift));
|
||||
assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2));
|
||||
let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, y], "tolerance": 4.0 })));
|
||||
@@ -1926,6 +1972,7 @@ fn live_paint_tools_skip_hidden_edges() {
|
||||
};
|
||||
assert_eq!(lp_click(&mut sel, &cx, ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}");
|
||||
let mut bucket = LivePaintBucketTool::default();
|
||||
bucket.set_option("cursorSwatchPreview", &json!(false));
|
||||
bucket.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(shift));
|
||||
assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2), "x {x}");
|
||||
let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, 50.0], "tolerance": 4.0 })));
|
||||
@@ -1953,6 +2000,7 @@ fn live_paint_tools_skip_hidden_faces() {
|
||||
};
|
||||
// Where the faces overlap, both tools take the back group's face under the hidden one.
|
||||
let mut bucket = LivePaintBucketTool::default();
|
||||
bucket.set_option("cursorSwatchPreview", &json!(false));
|
||||
bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0));
|
||||
assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
let fill = Action::Preview("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] }));
|
||||
|
||||
@@ -182,6 +182,11 @@ pub trait Tool: Send {
|
||||
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
|
||||
false
|
||||
}
|
||||
/// [`Self::claims_key`] for a press with `mods` held. By default the modifiers do not matter;
|
||||
/// a tool that takes a key only without modifiers leaves the modified presses to the shortcuts.
|
||||
fn claims_key_with(&self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> bool {
|
||||
self.claims_key(cx, key)
|
||||
}
|
||||
/// Has the tool started something that Escape ends while no button is pressed (a path being
|
||||
/// drawn, the Blend tool's first pick)? That Escape ends nothing else, such as isolation mode or
|
||||
/// a rotated view.
|
||||
|
||||
@@ -46,7 +46,7 @@ const ROWS: &[Row] = &[
|
||||
// Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`).
|
||||
(&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]),
|
||||
// Live Paint Bucket Options (`builder::LivePaintBucketTool`).
|
||||
(&["livePaintBucket"], None, &["paintFills", "paintStrokes", "highlight", "color", "width"]),
|
||||
(&["livePaintBucket"], None, &["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight", "color", "width"]),
|
||||
(&["polygon"], None, &["sides"]),
|
||||
(&["star"], None, &["points"]),
|
||||
(&["freeTransform"], None, &["constrain"]),
|
||||
|
||||
@@ -680,9 +680,17 @@ fn bucket_modifier_only_hover_updates_the_visible_cursor() {
|
||||
frame(app, &ctx, events);
|
||||
app.canvas.hover_cursor.map(|(_, _, cursor)| cursor)
|
||||
};
|
||||
// The cursor swatch preview's chips go and come back with Alt too.
|
||||
let chips = |app: &mut VectorcraftApp| {
|
||||
let view = app.view_info();
|
||||
app.session.overlays(view).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Swatch { .. })).count()
|
||||
};
|
||||
assert_eq!(shown(&mut app, vec![Event::PointerMoved(at)]), Some(Cursor::LivePaintBucket));
|
||||
assert!(chips(&mut app) > 0);
|
||||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT)]), Some(Cursor::Eyedropper));
|
||||
assert_eq!(chips(&mut app), 0);
|
||||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]), Some(Cursor::LivePaintBucket));
|
||||
assert!(chips(&mut app) > 0);
|
||||
}
|
||||
|
||||
/// The Image Trace panel's eye-button hold ends when the pointer is released, even when the
|
||||
|
||||
@@ -783,6 +783,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
return window(app, ctx);
|
||||
}
|
||||
let mut ok = false;
|
||||
let mut ok_clicked = false;
|
||||
let mut ok_enabled = true;
|
||||
let mut cancel = false;
|
||||
let mut reset = false;
|
||||
@@ -843,7 +844,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
|
||||
&& widgets::primary_button_enabled(ui, label, ok_enabled).clicked()
|
||||
{
|
||||
ok = true;
|
||||
ok_clicked = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// While Alt (Option) is held, Cancel reads Reset on a dialog that has one.
|
||||
@@ -878,6 +879,20 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
});
|
||||
let rejected = ctx.data_mut(|d| d.remove_temp::<bool>(widgets::dialog_reject_commit_flag())).unwrap_or(false);
|
||||
// A field that refused its text when the press on OK took its focus keeps the dialog open, as
|
||||
// Enter does. Only a pointer click has such a press; the keyboard and screen readers press OK
|
||||
// in one frame.
|
||||
let press = egui::Id::unique("dialog-press-pass");
|
||||
let (pressed, released) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.any_released()));
|
||||
if pressed {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|m| m.insert_temp(press, pass));
|
||||
}
|
||||
let (pressed_at, refused_at) = ctx.data(|m| (m.get_temp::<u64>(press), m.get_temp::<u64>(widgets::dialog_reject_pass())));
|
||||
let refused_during_click = released && matches!((refused_at, pressed_at), (Some(r), Some(p)) if r >= p);
|
||||
if ok_clicked && !rejected && !refused_during_click {
|
||||
ok = true;
|
||||
}
|
||||
if spec.ok.is_some() && ok_enabled && !after_panel && !rejected && crate::shortcuts::pressed_anew(ctx, egui::Key::Enter) {
|
||||
ok = true;
|
||||
}
|
||||
|
||||
@@ -128,10 +128,20 @@ fn lengths(command: &str) -> &'static [&'static str] {
|
||||
"type.pathOptions" => &["spacing"],
|
||||
"object.pathfinder.trap" => &["thickness"],
|
||||
crate::addons::cutting::BRIDGES => &["width"],
|
||||
"object.textWrap.options" => &["offset"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The range (points) of a distance parameter of the commands this dialog edits, where the
|
||||
/// command has one: the field refuses a distance typed outside it.
|
||||
pub(super) fn length_range(command: &str, key: &str) -> Option<std::ops::RangeInclusive<f64>> {
|
||||
match (command, key) {
|
||||
("object.textWrap.options", "offset") => Some(vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The order the fields of the commands this dialog edits come in, where the order of their
|
||||
/// names isn't it (the rest follow, by name).
|
||||
fn order(command: &str) -> &'static [&'static str] {
|
||||
@@ -505,4 +515,168 @@ mod tests {
|
||||
assert_eq!(crate::panels::pattern_options::repeat_fields(&app).unwrap()["instances"], 4);
|
||||
assert!(!app.session.in_interaction());
|
||||
}
|
||||
|
||||
/// Text Wrap Options › Offset is a distance, and its range is the one `object.textWrap.options`
|
||||
/// keeps an Offset to.
|
||||
#[test]
|
||||
fn text_wrap_offset_is_a_distance_within_the_commands_range() {
|
||||
assert!(lengths("object.textWrap.options").contains(&"offset"));
|
||||
assert!(!lengths("object.textWrap.options").contains(&"invert"));
|
||||
let range = length_range("object.textWrap.options", "offset").expect("a range");
|
||||
assert_eq!(range, vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE);
|
||||
assert_eq!(length_range("object.textWrap.options", "invert"), None);
|
||||
assert_eq!(length_range("text.areaOptions", "inset"), None);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("object.textWrap.make", &json!({})).unwrap();
|
||||
for (sent, end) in [(5000.0, *range.end()), (-5000.0, *range.start())] {
|
||||
assert_eq!(s.execute("object.textWrap.options", &json!({ "offset": sent })).unwrap()["offset"], json!(end));
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the text of the dialog's Offset field with `text`, leaving the field focused.
|
||||
fn type_offset(h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str) {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let all = egui::Modifiers { ctrl: true, command: true, ..Default::default() };
|
||||
h.get_by_role_and_label(Role::TextInput, "Offset:").focus();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(all, egui::Key::A);
|
||||
h.get_by_role_and_label(Role::TextInput, "Offset:").type_text(text);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Replaces the text of the dialog's Offset field with `text` and presses Enter.
|
||||
fn enter_offset(h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str) {
|
||||
type_offset(h, text);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// Text Wrap Options › Offset reads a bare number in the document's units and a typed unit as
|
||||
/// typed, and OK sends points. A distance typed outside the command's range, or text that
|
||||
/// doesn't read, is refused: the dialog stays open and the Offset isn't changed.
|
||||
#[test]
|
||||
fn text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
app.run("document.setUnits", json!({"units": "millimeters"})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run("object.textWrap.make", json!({})).unwrap();
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
crate::dialogs::show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
let offset = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>| {
|
||||
h.state_mut().session.execute("object.textWrap.options", &json!({})).unwrap()["offset"].as_f64().unwrap()
|
||||
};
|
||||
// The ends as the field shows them in millimeters (352.7778 mm is 1000.00006 pt) read
|
||||
// back as the ends.
|
||||
let ends = [("-1000 pt", -1000.0), ("352.7778", 1000.0), ("-352.7778", -1000.0), ("1000 pt", 1000.0)];
|
||||
for (text, want) in [("10", 10.0 * 72.0 / 25.4), ("-12 pt", -12.0)].into_iter().chain(ends) {
|
||||
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
|
||||
h.run_steps(3);
|
||||
enter_offset(&mut h, text);
|
||||
assert!(h.state().ui.dialog.is_none(), "{text}: OK applied it");
|
||||
assert!((offset(&mut h) - want).abs() < 1e-9, "{text}: {} pt", offset(&mut h));
|
||||
}
|
||||
for text in ["1000.5 pt", "-2000", "abc"] {
|
||||
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
|
||||
h.run_steps(3);
|
||||
enter_offset(&mut h, text);
|
||||
let d = h.state().ui.dialog.as_ref().unwrap_or_else(|| panic!("{text}: the dialog stays open"));
|
||||
assert_eq!(d.fields.get("offset"), Some(&json!(1000.0)), "{text}: the field keeps its value");
|
||||
crate::dialogs::cancel(h.state_mut());
|
||||
assert_eq!(offset(&mut h), 1000.0, "{text}: the Offset is unchanged");
|
||||
}
|
||||
// A click on OK with a value outside the range still in the field is refused too.
|
||||
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
|
||||
h.run_steps(3);
|
||||
type_offset(&mut h, "2000 pt");
|
||||
click_ok(&mut h);
|
||||
assert!(h.state().ui.dialog.is_some(), "OK leaves the dialog open");
|
||||
assert_eq!(offset(&mut h), 1000.0, "the Offset is unchanged");
|
||||
// After it, a value inside the range committed with Tab and OK pressed from the keyboard
|
||||
// apply.
|
||||
type_offset(&mut h, "-7 pt");
|
||||
h.key_press(egui::Key::Tab);
|
||||
h.run_steps(2);
|
||||
{
|
||||
use egui_kittest::kittest::Queryable;
|
||||
h.get_by_label("OK").focus();
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::Space);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().ui.dialog.is_none(), "OK from the keyboard applied it");
|
||||
assert_eq!(offset(&mut h), -7.0);
|
||||
// One inside it applies.
|
||||
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
|
||||
h.run_steps(3);
|
||||
type_offset(&mut h, "-5 pt");
|
||||
click_ok(&mut h);
|
||||
assert!(h.state().ui.dialog.is_none(), "OK applied it");
|
||||
assert_eq!(offset(&mut h), -5.0);
|
||||
}
|
||||
|
||||
/// In a document in picas, Text Wrap Options › Offset reads a bare number as the other length
|
||||
/// fields in picas do: in points with Preferences › Units › Numbers Without Units Are Points on
|
||||
/// (the default), in picas with it off. A typed unit is read as typed either way.
|
||||
#[test]
|
||||
fn text_wrap_offset_in_picas_follows_numbers_without_units_are_points() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
app.run("document.setUnits", json!({"units": "picas"})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run("object.textWrap.make", json!({})).unwrap();
|
||||
let mut fonts = false;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
crate::dialogs::show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
let apply = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str| {
|
||||
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
|
||||
h.run_steps(3);
|
||||
enter_offset(h, text);
|
||||
assert!(h.state().ui.dialog.is_none(), "{text}: OK applied it");
|
||||
h.state_mut().session.execute("object.textWrap.options", &json!({})).unwrap()["offset"].as_f64().unwrap()
|
||||
};
|
||||
assert_eq!(apply(&mut h, "10"), 10.0, "on by default: a bare number is in points");
|
||||
assert_eq!(apply(&mut h, "2p6"), 30.0, "picas and points as typed");
|
||||
assert_eq!(apply(&mut h, "-1in"), -72.0, "a typed unit wins");
|
||||
crate::widgets::set_bare_numbers_are_points(&h.ctx, false);
|
||||
assert_eq!(apply(&mut h, "10"), 120.0, "off: a bare number is in picas");
|
||||
assert_eq!(apply(&mut h, "-12 pt"), -12.0, "a typed unit wins");
|
||||
}
|
||||
|
||||
/// Clicks the dialog's OK button with the pointer, the press and the release in frames of
|
||||
/// their own.
|
||||
fn click_ok(h: &mut egui_kittest::Harness<'_, VectorcraftApp>) {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let ok = h.get_by_label("OK").rect().center();
|
||||
let (button, modifiers) = (egui::PointerButton::Primary, egui::Modifiers::default());
|
||||
h.event(egui::Event::PointerMoved(ok));
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: ok, button, pressed: true, modifiers });
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: ok, button, pressed: false, modifiers });
|
||||
h.run_steps(3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,7 +453,14 @@ pub(super) fn param_fields(
|
||||
if is_length(&k) {
|
||||
// An effect's documented range keeps the field to the distance it applies.
|
||||
let range = vectorcraft_effects::length_range(&d.str("__effect"), &k);
|
||||
if spinners {
|
||||
// A command's own range refuses a distance typed outside it.
|
||||
if let Some(bounds) = super::command::length_range(&d.str("__command"), &k) {
|
||||
let value = d.fields.contains_key(&k).then(|| d.f64(&k, 0.0));
|
||||
if let Some(x) = crate::widgets::num_field_refusing(ui, ("dlg-len", k.as_str()), value, unit, 140.0, bounds) {
|
||||
d.fields.insert(k.clone(), json!(x));
|
||||
changed = true;
|
||||
}
|
||||
} else if spinners {
|
||||
let bounds = range.unwrap_or(if k == "blur" { 0.0..=f64::INFINITY } else { crate::widgets::ANY });
|
||||
let value = d.fields.contains_key(&k).then(|| d.f64(&k, 0.0));
|
||||
if let Some(x) = crate::widgets::spin_length_within(ui, ("dlg-len", k.as_str()), value, unit, 140.0, bounds) {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! `tool.setOption {tool: "livePaintBucket", values}`.
|
||||
//!
|
||||
//! Fields: `paintFills` and `paintStrokes` (the faces and the edges of Live Paint groups),
|
||||
//! `highlight` (whether the face or edge under the pointer is outlined), `color` (the highlight
|
||||
//! `cursorSwatchPreview` (whether the active paint and the color swatches beside it show above the
|
||||
//! pointer), `highlight` (whether the face or edge under the pointer is outlined), `color` (the highlight
|
||||
//! color: `#rrggbb`, or a layer color's name such as "Red") and `width` (the width of a face's
|
||||
//! highlight in screen pixels; an edge's highlight is half as wide).
|
||||
|
||||
@@ -25,7 +26,7 @@ pub const KIND: &str = "livePaintBucketOptions";
|
||||
const TOOL: &str = "livePaintBucket";
|
||||
|
||||
/// The options the dialog edits.
|
||||
const KEYS: [&str; 5] = ["paintFills", "paintStrokes", "highlight", "color", "width"];
|
||||
const KEYS: [&str; 6] = ["paintFills", "paintStrokes", "cursorSwatchPreview", "highlight", "color", "width"];
|
||||
|
||||
/// Width of the label column.
|
||||
const LABEL_W: f32 = 60.0;
|
||||
@@ -48,6 +49,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
form::check(ui, d, "paintFills", tl!("Paint Fills"));
|
||||
form::check(ui, d, "paintStrokes", tl!("Paint Strokes"));
|
||||
form::check(ui, d, "cursorSwatchPreview", tl!("Cursor Swatch Preview"));
|
||||
ui.add_space(6.0);
|
||||
form::check(ui, d, "highlight", tl!("Highlight"));
|
||||
let color = resolve(&d.str("color")).unwrap_or(Color::BLACK);
|
||||
@@ -120,17 +122,18 @@ mod tests {
|
||||
|
||||
crate::toolbar::open_options(&mut app, TOOL).unwrap();
|
||||
let text = painted(&mut app);
|
||||
for label in ["Live Paint Bucket Options", "Paint Fills", "Paint Strokes", "Highlight", "Color:", "Width:"] {
|
||||
for label in ["Live Paint Bucket Options", "Paint Fills", "Paint Strokes", "Cursor Swatch Preview", "Highlight", "Color:", "Width:"] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!(d.kind, KIND);
|
||||
assert_eq!(
|
||||
(d.bool("paintFills"), d.bool("paintStrokes"), d.bool("highlight"), d.str("color"), d.f64("width", 0.0)),
|
||||
(true, false, true, "#ff2a2a".to_string(), 4.0)
|
||||
(d.bool("paintFills"), d.bool("paintStrokes"), d.bool("cursorSwatchPreview"), d.bool("highlight"), d.str("color"), d.f64("width", 0.0)),
|
||||
(true, false, true, true, "#ff2a2a".to_string(), 4.0)
|
||||
);
|
||||
d.fields.insert("paintFills".into(), json!(false));
|
||||
d.fields.insert("paintStrokes".into(), json!(true));
|
||||
d.fields.insert("cursorSwatchPreview".into(), json!(false));
|
||||
d.fields.insert("color".into(), json!("Green"));
|
||||
d.fields.insert("width".into(), json!(8));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
@@ -141,10 +144,13 @@ mod tests {
|
||||
// They last while another tool is chosen, and the dialog opens on them again.
|
||||
app.select_tool("selection");
|
||||
let o = app.session.tool_options_of(TOOL);
|
||||
assert_eq!((o["paintFills"].as_bool(), o["paintStrokes"].as_bool(), o["color"].as_str()), (Some(false), Some(true), Some("#4fff4f")));
|
||||
assert_eq!(
|
||||
(o["paintFills"].as_bool(), o["paintStrokes"].as_bool(), o["cursorSwatchPreview"].as_bool(), o["color"].as_str()),
|
||||
(Some(false), Some(true), Some(false), Some("#4fff4f"))
|
||||
);
|
||||
app.run("tool.options", json!({"tool": TOOL})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!((d.bool("paintStrokes"), d.str("color")), (true, "#4fff4f".to_string()));
|
||||
assert_eq!((d.bool("paintStrokes"), d.bool("cursorSwatchPreview"), d.str("color")), (true, false, "#4fff4f".to_string()));
|
||||
// Highlight off: the tool outlines nothing under the pointer.
|
||||
d.fields.insert("highlight".into(), json!(false));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -625,8 +625,10 @@ fn dialog_or_palette_open(app: &VectorcraftApp) -> bool {
|
||||
|
||||
/// Route Alt+arrows to spacing controls when text objects are selected.
|
||||
fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
if !m.alt {
|
||||
let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)];
|
||||
// The modifiers held when each arrow went down, as `nudge_and_clear` reads them.
|
||||
let pressed = keys.map(|(key, tool_key)| (key, tool_key, ctx.input(|i| press_modifiers(i, key))));
|
||||
if !pressed.iter().any(|(.., m)| m.alt) {
|
||||
return false;
|
||||
}
|
||||
let orientation = app.session.active().and_then(|st| {
|
||||
@@ -644,9 +646,8 @@ fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
let Some(vertical) = orientation else {
|
||||
return false;
|
||||
};
|
||||
let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)];
|
||||
for (key, tool_key) in keys {
|
||||
if ctx.input_mut(|i| i.consume_key(m, key)) {
|
||||
for (key, tool_key, m) in pressed {
|
||||
if m.alt && ctx.input_mut(|i| i.consume_key(m, key)) {
|
||||
if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false, vertical) {
|
||||
let by = if m.command { by * 5.0 } else { by };
|
||||
let _ = app.run("type.step", json!({"attribute": attribute, "by": by}));
|
||||
@@ -685,13 +686,27 @@ fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: boo
|
||||
settings_on_a_mac(app, ctx, mac(ctx));
|
||||
}
|
||||
|
||||
/// The modifiers held when `key` last went down in this frame, which can differ from the frame's
|
||||
/// at its end; the frame's when the key did not go down.
|
||||
fn press_modifiers(i: &egui::InputState, key: Key) -> Modifiers {
|
||||
i.events
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|e| match e {
|
||||
egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key => Some(*modifiers),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(i.modifiers)
|
||||
}
|
||||
|
||||
/// Apply arrow-key movement and clear selected art when no text editor owns input, unless a Layers
|
||||
/// or Appearance row has Delete/Backspace (`rows_keys`).
|
||||
fn nudge_and_clear(app: &mut VectorcraftApp, ctx: &egui::Context, rows_keys: bool) {
|
||||
// Arrow nudges (Shift = ×10, Alt = copy).
|
||||
let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
|
||||
for (k, dx, dy) in arrows {
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
// The modifiers held when the key went down, as `claimed_tool_keys` reads them.
|
||||
let m = ctx.input(|i| press_modifiers(i, k));
|
||||
if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) {
|
||||
let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt}));
|
||||
}
|
||||
@@ -878,8 +893,13 @@ fn claimed_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewIn
|
||||
// The Smart Pen Tool's Tab while it draws a path (the key otherwise hides the panels).
|
||||
(Key::Tab, ToolKey::Tab),
|
||||
] {
|
||||
if !rows_take(k) && ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) {
|
||||
let r = app.session.tool_key(tk, crate::canvas::mods(m, false), view);
|
||||
let pm = ctx.input(|i| press_modifiers(i, k));
|
||||
if !rows_take(k)
|
||||
&& ctx.input(|i| i.key_pressed(k))
|
||||
&& app.session.tool_claims_key_with(tk, crate::canvas::mods(pm, false), view)
|
||||
&& ctx.input_mut(|i| i.consume_key(pm, k))
|
||||
{
|
||||
let r = app.session.tool_key(tk, crate::canvas::mods(pm, false), view);
|
||||
// Keyboard actions can change the Artboard tool's active board too. Keep the
|
||||
// navigator aligned with its valid survivor, as pointer dispatch does.
|
||||
if r.is_ok()
|
||||
|
||||
@@ -2220,6 +2220,15 @@ fn alt_arrows_step_selected_type_object() {
|
||||
assert_eq!(r["runs"][0]["style"]["tracking"], json!(20.0));
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)]);
|
||||
|
||||
// Alt released within the frame of the press: the press's Alt still steps the spacing.
|
||||
frame(
|
||||
&mut app,
|
||||
vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowRight, Modifiers::ALT), egui::Event::ModifiersChanged(Modifiers::NONE)],
|
||||
);
|
||||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||||
assert_eq!(r["runs"][0]["style"]["tracking"], json!(40.0));
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(id)], "the text is not copied");
|
||||
|
||||
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::ALT), press(Key::ArrowDown, Modifiers::ALT)]);
|
||||
let r = app.session.execute("text.getRange", &json!({"id": id})).unwrap();
|
||||
assert_eq!(r["runs"][0]["style"]["leading"], json!(26.0));
|
||||
@@ -2245,6 +2254,65 @@ fn alt_arrows_step_selected_type_object() {
|
||||
assert_ne!(selected[0], vectorcraft_doc::NodeId(rect), "a non-type object is still copied by Alt+arrow");
|
||||
}
|
||||
|
||||
/// With the Live Paint Bucket, a plain Right picks the next color swatch, while Alt+Right keeps
|
||||
/// copying and nudging the selection, and a Shift+Right released within the same frame is still a
|
||||
/// Shift+Right that picks no swatch.
|
||||
#[test]
|
||||
fn live_paint_bucket_takes_only_plain_arrows_for_swatches() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [rect]})).unwrap();
|
||||
let first = vectorcraft_tools::builder::color_swatches(&app.session.active().unwrap().doc)[0].name.clone();
|
||||
app.session.execute("paint.setFill", &json!({"swatch": first, "ids": []})).unwrap();
|
||||
app.select_tool("livePaintBucket");
|
||||
let fill = app.session.paint.fill.clone();
|
||||
|
||||
frame(&mut app, press(Key::ArrowRight, Modifiers::ALT));
|
||||
assert_eq!(app.session.paint.fill, fill, "Alt+Right picks no swatch");
|
||||
let selected = &app.session.active().unwrap().selection.objects;
|
||||
assert_ne!(selected.as_slice(), [vectorcraft_doc::NodeId(rect)], "Alt+Right copies and nudges the selection");
|
||||
|
||||
let shift_then_released = vec![
|
||||
egui::Event::ModifiersChanged(Modifiers::SHIFT),
|
||||
egui::Event::Key { key: Key::ArrowRight, physical_key: Some(Key::ArrowRight), pressed: true, repeat: false, modifiers: Modifiers::SHIFT },
|
||||
egui::Event::ModifiersChanged(Modifiers::NONE),
|
||||
];
|
||||
frame(&mut app, shift_then_released);
|
||||
assert_eq!(app.session.paint.fill, fill, "Shift+Right picks no swatch");
|
||||
|
||||
frame(&mut app, press(Key::ArrowRight, Modifiers::NONE));
|
||||
assert_ne!(app.session.paint.fill, fill, "a plain Right picks the next swatch");
|
||||
}
|
||||
|
||||
/// An arrow nudge takes Shift and Alt from the key press, also when they are released within the
|
||||
/// same frame: Shift+Right moves 10 points, and Alt+Right copies.
|
||||
#[test]
|
||||
fn arrow_nudge_takes_the_modifiers_of_the_key_press() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let rect = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
let rect = vectorcraft_doc::NodeId(rect);
|
||||
app.select_tool("selection");
|
||||
app.session.execute("select.set", &json!({"ids": [rect.0]})).unwrap();
|
||||
let released = |modifiers| {
|
||||
vec![
|
||||
egui::Event::ModifiersChanged(modifiers),
|
||||
egui::Event::Key { key: Key::ArrowRight, physical_key: Some(Key::ArrowRight), pressed: true, repeat: false, modifiers },
|
||||
egui::Event::ModifiersChanged(Modifiers::NONE),
|
||||
]
|
||||
};
|
||||
let left = |app: &VectorcraftApp, id| app.session.active().unwrap().doc.bounds_of(&[id], false).unwrap().x0;
|
||||
|
||||
frame(&mut app, released(Modifiers::SHIFT));
|
||||
assert_eq!(left(&app, rect), 10.0, "Shift+Right moves 10 points");
|
||||
|
||||
frame(&mut app, released(Modifiers::ALT));
|
||||
let selected = app.session.active().unwrap().selection.objects.clone();
|
||||
assert_ne!(selected.as_slice(), [rect], "Alt+Right copies");
|
||||
assert_eq!(left(&app, rect), 10.0, "the original stays");
|
||||
}
|
||||
|
||||
/// #793: with a layout without Latin letters the one-key shortcuts go by the key's position:
|
||||
/// the P key typing Persian `ح` chooses the Pen, the V key typing Russian `м` the Selection
|
||||
/// tool. A Latin layout keeps the letter typed: AZERTY's A (the Q key's place) is A.
|
||||
|
||||
@@ -56,18 +56,18 @@ pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_labe
|
||||
pub(crate) use labels::{label_field, take_previous_label, text_width};
|
||||
pub(crate) use numeric::{ANY, round_to, set_bare_numbers_are_points, typed_unit};
|
||||
pub use numeric::{
|
||||
SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_within, num_field_within_empty, opt_field,
|
||||
opt_field_named, plain_field_live, range_field, range_field_live, range_field_named, range_field_stepped, size_field, size_field_named, slider,
|
||||
spin_field, spin_field_auto, spin_field_auto_named, spin_field_named, spin_field_stops_named, spin_kerning, spin_length_within, spin_plain,
|
||||
spin_plain_named, spin_range,
|
||||
SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_refusing, num_field_within,
|
||||
num_field_within_empty, opt_field, opt_field_named, plain_field_live, range_field, range_field_live, range_field_named, range_field_stepped,
|
||||
size_field, size_field_named, slider, spin_field, spin_field_auto, spin_field_auto_named, spin_field_named, spin_field_stops_named, spin_kerning,
|
||||
spin_length_within, spin_plain, spin_plain_named, spin_range,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use numeric::{decimal_point, field_number, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth};
|
||||
pub(crate) use panel_chrome::{PANEL_FOOTER, panel_footer, panel_footer_bottom, panel_footer_space, panel_list_height};
|
||||
pub use panel_chrome::{bottom_bar, layout_button, list_box, orientation_button, tab_bar};
|
||||
pub(crate) use text::{
|
||||
arm_field_focus, dialog_focus_flag, dialog_reject_commit_flag, disarm_field_focus, enter_modifiers, focus_field, own_field_shift_enter,
|
||||
reject_dialog_commit, set_dialog_open, take_dialog_focus,
|
||||
arm_field_focus, dialog_focus_flag, dialog_reject_commit_flag, dialog_reject_pass, disarm_field_focus, enter_modifiers, focus_field,
|
||||
own_field_shift_enter, reject_dialog_commit, set_dialog_open, take_dialog_focus,
|
||||
};
|
||||
pub use text::{
|
||||
exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets,
|
||||
|
||||
@@ -67,6 +67,21 @@ pub fn num_field_within(
|
||||
unit_field(ui, id, label, None, value, unit, width, false, bounds, &[], false, "")
|
||||
}
|
||||
|
||||
/// [`num_field_within`] that refuses a value typed outside `bounds` (points), as it refuses text
|
||||
/// that doesn't read: the field flashes, keeps its value and holds the dialog open. ↑/↓, the wheel
|
||||
/// and a scrub still stop at the ends.
|
||||
pub fn num_field_refusing(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash,
|
||||
value: Option<f64>,
|
||||
unit: Unit,
|
||||
width: f32,
|
||||
bounds: RangeInclusive<f64>,
|
||||
) -> Option<f64> {
|
||||
let label = take_previous_label(ui);
|
||||
unit_field_from(ui, id, label, None, value, unit, width, false, bounds, &[], false, None, "", true)
|
||||
}
|
||||
|
||||
/// [`num_field_within`] whose empty value reads `empty` (Transform Each's Absolute scales read
|
||||
/// "Mixed" when the selected objects differ): typing replaces it, as it replaces the value shown.
|
||||
pub fn num_field_within_empty(
|
||||
@@ -122,11 +137,12 @@ fn unit_field(
|
||||
live: bool,
|
||||
empty: &str,
|
||||
) -> Option<f64> {
|
||||
unit_field_from(ui, id, label, name, value, unit, width, share_of_value, bounds, stops, live, None, empty)
|
||||
unit_field_from(ui, id, label, name, value, unit, width, share_of_value, bounds, stops, live, None, empty, false)
|
||||
}
|
||||
|
||||
/// [`unit_field`] whose blank field (`value` is `None`) steps from `blank_from` (points) with
|
||||
/// ↑/↓ and the wheel instead of doing nothing.
|
||||
/// ↑/↓ and the wheel instead of doing nothing. With `refuse_outside`, a value typed outside
|
||||
/// `bounds` is refused as text that doesn't read is, rather than kept at the nearer end.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn unit_field_from(
|
||||
ui: &mut Ui,
|
||||
@@ -142,6 +158,7 @@ fn unit_field_from(
|
||||
live: bool,
|
||||
blank_from: Option<f64>,
|
||||
empty: &str,
|
||||
refuse_outside: bool,
|
||||
) -> Option<f64> {
|
||||
let id = id_with(ui.scope_id(), id);
|
||||
let shown = value.map(|v| unit.format(v)).unwrap_or_else(|| empty.to_string());
|
||||
@@ -163,7 +180,11 @@ fn unit_field_from(
|
||||
let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds_unit);
|
||||
let commit = commit && buf != shown;
|
||||
if commit {
|
||||
match read(&buf).or_else(|| if share_of_value { share_of(&buf, value?) } else { None }) {
|
||||
let typed_value = read(&buf).or_else(|| if share_of_value { share_of(&buf, value?) } else { None });
|
||||
// A refused value is one outside `bounds` that the field doesn't show as an end: the end
|
||||
// shown in the field's unit ("352.7778 mm" for 1000 pt) reads back as that end.
|
||||
let typed_value = typed_value.filter(|&v| !refuse_outside || bounds.contains(&v) || unit.format(within(v, &bounds)) == unit.format(v));
|
||||
match typed_value {
|
||||
Some(v) => {
|
||||
let clamped = within(v, &bounds);
|
||||
let formatted = unit.format(clamped);
|
||||
@@ -1017,7 +1038,7 @@ pub fn spin_field_stops_named(
|
||||
let stops = if value.is_none() { &BLANK_WEIGHT_STOPS[..] } else { presets };
|
||||
let fmt = |v: f64| unit.format(v);
|
||||
spin_generic(ui, label, value, Some(blank_from), None, None, None, width, step, min, presets, stops, &fmt, &mut |ui, fw, label| {
|
||||
unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, stops, false, Some(blank_from), "")
|
||||
unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, stops, false, Some(blank_from), "", false)
|
||||
})
|
||||
.and_then(SpinPick::value)
|
||||
}
|
||||
|
||||
@@ -94,9 +94,20 @@ pub(crate) fn dialog_reject_commit_flag() -> egui::Id {
|
||||
egui::Id::unique("dialog-reject-commit")
|
||||
}
|
||||
|
||||
/// Suppress confirming an open dialog on Enter during this frame when an invalid value was committed.
|
||||
/// The pass (`cumulative_pass_nr`) in which a field last rejected an invalid commit: a click on a
|
||||
/// dialog's OK whose press came at or before it doesn't confirm the dialog.
|
||||
pub(crate) fn dialog_reject_pass() -> egui::Id {
|
||||
egui::Id::unique("dialog-reject-pass")
|
||||
}
|
||||
|
||||
/// Suppress confirming an open dialog on Enter during this frame, or by the click on OK that took
|
||||
/// the focus from the field, when an invalid value was committed.
|
||||
pub(crate) fn reject_dialog_commit(ctx: &egui::Context) {
|
||||
ctx.data_mut(|d| d.insert_temp(dialog_reject_commit_flag(), true));
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(dialog_reject_commit_flag(), true);
|
||||
d.insert_temp(dialog_reject_pass(), pass);
|
||||
});
|
||||
}
|
||||
|
||||
/// The app sets this at the start of each frame: while a dialog is open, Shift+Enter in a field
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -27,7 +27,7 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker
|
||||
| Language | Code | UI strings translated | Dialogs / tooltips / messages | Script support | Native review | Status | To `full` (h) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| English | `en` | source, 100% | all | Latin | — | full | — |
|
||||
| Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) |
|
||||
| Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all (3,439 `@msg` and 48 `@msgplural` rows, counted 2026-11-13: the untranslated report lists 40 missing messages for it) | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) |
|
||||
| Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) |
|
||||
| Hindi | `hi` | 8,782 rows (2026-11-13): 3,014 `@msg`, 47 `@msgplural`, 41 `@plural`; no gaps in the untranslated report | menus, dialogs, tooltips and scanned messages; contributor-approved loanword terminology; agent wording pass | UI and document text shape with HarfRust; installed desktop Devanagari fallback, or a `Deva` craft-fonts face; web font still needed | contributor terminology review; 2026-11-13 wording pass on messages and longer labels, with a glossary-term test; wider proofread welcome | complete catalog, machine-assisted draft | native review, layout and web font |
|
||||
| Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 |
|
||||
@@ -54,9 +54,9 @@ plus review. Native-speaker review is human work and isn't in the hours.
|
||||
| Dutch | `nl` | 7,299 rows, 1,609 of them `@msg` and 47 `@msgplural` (counted 2026-11-13 on `main` at `9a1a158a3`): in `COMPLETE_MESSAGES`, and the untranslated report lists the same missing messages for it as for German; one/other plurals | full |
|
||||
| Danish | `da` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one/other plurals | full |
|
||||
| Russian | `ru` | 4,009 / 4,020 | full |
|
||||
| Ukrainian | `uk` | 4,009 / 4,020 (one/few/many plurals) | full |
|
||||
| Ukrainian | `uk` | 8,802 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13); one/few/many plurals | full |
|
||||
| Hungarian | `hu` | 5,253 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | full |
|
||||
| Polish | `pl` | 5,130 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/few/many plurals | full; native review pending |
|
||||
| Polish | `pl` | 9,044 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 46 missing messages for it, and 47 menu strings, 26 `tl!` literals, 11 undo labels and 45 swatch names; one/few/many plurals | full; native review pending |
|
||||
| Finnish | `fi` | 5,318 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals, 797 `@msg` rows | full; AI-assisted, native proofreading pending |
|
||||
| Czech | `cs` | 5,240 catalog entries, including 795 `@msg` rows (counted 2026-10-21); menus, literals and messages complete when counted | catalog complete; render-site gaps reduced; native review pending |
|
||||
| Bosnian | `bs` | 5,841 rows (counted 2026-10-30): menus, `tl!` literals and messages complete when counted; one/few/other plurals | full; AI-assisted, native review pending |
|
||||
|
||||
+23
-8
@@ -3170,8 +3170,13 @@ already walks Image Trace. Short agent entry points:
|
||||
reply's `defined` lists the variables the import defined. While the variables are
|
||||
locked, an import whose header would define a variable fails with "variables are locked".
|
||||
A `dataset.import` file that isn't UTF-8 is read as Windows-1252, unless it starts with the UTF-8 byte-order mark.
|
||||
A file that starts with a UTF-16 byte-order mark is read as UTF-16, and a file without one that holds NUL bytes is
|
||||
refused. `csv` and `data` are read as the text they hold.
|
||||
A file that starts with a UTF-16 byte-order mark is read as UTF-16. A file without one that holds NUL bytes is read
|
||||
as UTF-16 when at least half of its two-byte units hold a NUL byte, as characters from U+0001 to U+00FF do, in the
|
||||
byte order that reads a line break (`\n` or `\r`). It is refused when fewer of its units hold a NUL byte (UTF-8 or
|
||||
Windows-1252 text with stray NUL bytes, or UTF-16 text made mostly of other characters, which needs a byte-order
|
||||
mark), when both byte orders read a line break (the text holds U+0A00 or U+0D00), when it has an odd length, and
|
||||
when it isn't valid UTF-16 in that order or holds a NUL character. A file with no line break has no data rows and is
|
||||
refused for that. `csv` and `data` are read as the text they hold.
|
||||
- Variable libraries: `variable.loadLibrary {path|xml|dataBase64}` (the Variables panel menu's Load Variable
|
||||
Library…) replaces the document's variables and data sets with a variable library's in one undo step. A load, like a
|
||||
`dataset.import`, adds no undo step when it leaves the variables and data sets as they were. The library
|
||||
@@ -3516,7 +3521,8 @@ already walks Image Trace. Short agent entry points:
|
||||
at the pointer selects under these rules: the arrow with a filled square for a face, with a short line for an edge,
|
||||
and with an x for nothing. It keeps the look it had at the press until the release.
|
||||
The Live Paint Bucket's options (`tool.setOption {tool: "livePaintBucket", values}`; double-clicking the tool opens
|
||||
them, dialog `livePaintBucketOptions`) are `paintFills` (on), `paintStrokes` (off), `highlight` (on), `color` (the
|
||||
them, dialog `livePaintBucketOptions`) are `paintFills` (on), `paintStrokes` (off), `cursorSwatchPreview` (on),
|
||||
`highlight` (on), `color` (the
|
||||
highlight color, `#rrggbb` or a layer color's name; `#ff2a2a`) and `width` (the face highlight's width, 1 to 20 screen
|
||||
pixels; 4; an edge's highlight is half as wide). The dialog's OK gives an error for a `color` with an alpha part
|
||||
(`#rrggbbaa` or `#rgba`) and changes nothing. `tool.setOption` gives an error that names the option and sets none of
|
||||
@@ -3529,6 +3535,14 @@ already walks Image Trace. Short agent entry points:
|
||||
`livePaint.strokeEdge` when an edge is within 4 screen pixels of the pointer and `livePaint.fill` otherwise, and
|
||||
Shift-click runs `livePaint.fill`. With both off, a click runs nothing. With `highlight` off, the face or edge under
|
||||
the pointer is not outlined.
|
||||
With the Live Paint Bucket selected and no drag in progress, Left and Right without Shift, Alt or Cmd/Ctrl (which
|
||||
keep their shortcuts) run `paint.setFill {swatch, ids: []}` (or
|
||||
`paint.setStroke` when the Stroke proxy is in front) with the color swatch before or after the active paint in the
|
||||
Swatches panel, wrapping around at the ends; from a color that is no swatch, Right picks the first swatch and Left
|
||||
the last, and with no color swatch the tool does not take the keys. The selected art keeps its paint. With
|
||||
`cursorSwatchPreview` on, the active paint shows as a ringed chip above the pointer between the swatches before and
|
||||
after it, except during a fill drag or with Alt held; a solid color that is no swatch shows alone, and a paint that is not a solid color shows no chip. Left and
|
||||
Right step the swatches with `cursorSwatchPreview` off too.
|
||||
`livePaint.merge` keeps the bottom Live Paint group's id, name, lock, visibility, transparency, appearance, opacity
|
||||
mask and graphic style link. With a Live Paint group among the objects, `livePaint.make` adds the others to it as
|
||||
`livePaint.merge` does, and so does `livePaint.fill {ids}` when `point` is outside that group's faces.
|
||||
@@ -7218,7 +7232,7 @@ hatches filled, pattern hatches as their boundaries), text and multiline text po
|
||||
(one per look when their art takes colour "by block"), anonymous blocks such as dimensions groups. Indexed colours
|
||||
(7 is black on paper), true colour, lineweights (`scaleLineweights` scales them with the art), linetypes and
|
||||
transparency resolve through layers and blocks. What is left out (viewports, images, meshes, unknown entities) is
|
||||
listed in `warnings`. Binary DXF and DWG files answer an error saying to save them as ASCII DXF.
|
||||
listed in `warnings`. ASCII and binary DXF files are read; DWG files answer an error saying to save them as DXF.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.dxfInfo","params":{"path":"/tmp/plan.dxf"}}}
|
||||
@@ -9897,8 +9911,8 @@ The Shape Builder tool's options are its Shape Builder Tool Options (dialog `sha
|
||||
`openFilledAsClosed` (on: a filled open path bounds a region as if an invisible edge closed it; off: it cuts regions
|
||||
as an unfilled path does), `pickColorFrom` (`artwork`, the default: a merged shape takes the appearance, opacity and
|
||||
blending mode of the front-most selected object under the drag's first point, else of the one under its last point,
|
||||
else of the front-most selected object; `swatches`: the current fill, and the Left and Right arrow keys step the
|
||||
current fill through the document's color swatches), `cursorSwatchPreview` (off; with `swatches`, chips above the
|
||||
else of the front-most selected object; `swatches`: the current fill, and the Left and Right arrow keys without
|
||||
Shift, Alt or Cmd/Ctrl step the current fill through the document's color swatches), `cursorSwatchPreview` (off; with `swatches`, chips above the
|
||||
pointer show the current fill and the swatches before and after it), `selection` (`freeform`, the default, or
|
||||
`straightLine`: a drag touches what the line from its first point to the pointer crosses), `highlightFill` (on by
|
||||
default; when it is off, merge mode shades no regions), `gapDetection` (off) with `gapLength` (pt, 0 to 72; 3), `clickStrokeSplitsPath` (In Merge Mode,
|
||||
@@ -10798,7 +10812,8 @@ Commands:
|
||||
- `addon.pathtools.cornerSet {ids?, anchors?, items?, radius, kind?, every?, minAngle?, maxAngle?}` cuts live corners, the ones Direct Selection's
|
||||
corner widgets and the Corners dialog edit, as one undo step. `ids` (default: the selection) are the paths and
|
||||
`anchors` the anchor indices of each path's outline with no corners cut; without `anchors`, a path's direct-selected
|
||||
anchors, or all of them. `items: [{id, anchors?}…]` gives each path its own instead. Only anchors where two sides meet
|
||||
anchors, or all of them. `items: [{id, anchors?}…]` gives each path its own instead; a member key other than `id`
|
||||
and `anchors`, or `items` given with `ids`, is an error. Only anchors where two sides meet
|
||||
at an angle take a corner: smooth anchors and open ends are skipped, and it is an error if none is left. `radius` is
|
||||
0 to 1,000,000 pt, drawn no larger than fits; `kind` is `round`, `invertedRound` or `chamfer` (default: each corner's
|
||||
own). `every` (`all`, `odd` or `even`; default `all`) keeps, of those anchors, only the odd- or even-numbered ones,
|
||||
@@ -10808,7 +10823,7 @@ Commands:
|
||||
be more than `maxAngle`, and it is an error, saying so, if no chosen corner is in the range. Object › Plug-ins › Path Precision Tools › Set Corners… (also in the Anchor Inspector's
|
||||
Corners section) sets these with Preview. → `{ids, corners}`.
|
||||
- `addon.pathtools.cornerRemove {ids?, anchors?, items?}` makes those corners sharp again, as one undo step; a plain path
|
||||
with none left is plain again. → `{ids, corners}`.
|
||||
with none left is plain again. Its `items` take the same keys as `cornerSet`'s. → `{ids, corners}`.
|
||||
- `addon.pathtools.slideAnchor {id, subpath?, anchor, t}` moves an anchor along its path, the segments beside it refitted
|
||||
so the outline stays (the anchor count and index stay). `t`, -0.98 to 0.98, says where: positive, a curve parameter on
|
||||
the segment after the anchor; negative, back from the end of the one before. An anchor at a sharp corner loses it, as
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
>
|
||||
> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307).
|
||||
|
||||
> **PDF import status (2026-11-14, #6957):** Outlined Indic glyphs retain their full stroke properties; translucent glyphs stay separate, and a soft mask attaches to each glyph separately. Scores and estimates unchanged.
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
M4: Explicitly requested editing data keeps a single artboard larger than its 14,400 pt capped PDF page (#6865). Scores and estimates unchanged.
|
||||
@@ -56,6 +58,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i
|
||||
| 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. |
|
||||
| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. |
|
||||
| 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/swatches/tests.rs`). Scores and estimates unchanged. |
|
||||
| 2026-11-13 | minor | M4: PDF import (#5235, #6906): Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines (opaque, alike-stroked ones share a path except under a soft mask, where each glyph keeps its own path and mask; translucent ones keep their own). Scores and estimates unchanged. Evidence: `crates/pdf/src/tests/reshaped.rs`. |
|
||||
| 2026-11-10 | minor | M13 / G6: MCP catalogue (#6460): headless lists UI commands, eight prompts, 33 tools, effect vs command ids; scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
>
|
||||
> **Flyout update:** 2026-11-06 — resizable icon-strip flyouts and bottom-aligned panel footers (#5713); scores and estimates unchanged.
|
||||
|
||||
> **PDF import status (2026-11-14, #6957):** Outlined Indic glyphs retain their full stroke properties; translucent glyphs stay separate, and a soft mask attaches to each glyph separately. Scores and estimates unchanged.
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
EPS embedded fonts, 2026-11-09 (#6023–#6067): the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. Evidence: `crates/eps/src/import/tests_composite.rs`, `tests_type42.rs`, `tests_cid.rs`, `tests_type1.rs`; details in [file-format-parity.md](file-format-parity.md).
|
||||
@@ -60,6 +62,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. |
|
||||
| 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/swatches/tests.rs`). Scores and estimates unchanged. |
|
||||
| 2026-11-13 | minor | Swatches panel: its menu's Show Recent Colors hides and shows the panel's Recent Colors row, checked by default and kept between sessions apart from the Color panel's (#6617); scores unchanged. |
|
||||
| 2026-11-13 | minor | M4: PDF import (#5235, #6906): Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines (opaque, alike-stroked ones share a path except under a soft mask, where each glyph keeps its own path and mask; translucent ones keep their own). Scores and estimates unchanged. Evidence: `crates/pdf/src/tests/reshaped.rs`. |
|
||||
| 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged |
|
||||
| 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
|
||||
@@ -568,6 +571,7 @@ since 2026-10-01.
|
||||
- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, suggesting the original name and format, preserving options for the unchanged format and opening the chosen format’s Save options on a change. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; focused UI regressions passed; engine regression execution and native panel acceptance pending. Scores and hours unchanged.
|
||||
|
||||
- **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h.
|
||||
- **PDF Indic type (#5235, #6906):** `crates/pdf/src/tests/reshaped.rs` covers a vowel sign the file draws before its consonant (the line keeps the file's glyphs as outlines, with a note), text in reading order (stays type), a mark with no advance (stays on its line, not vertical type of its own), outlined glyphs stroked with different dashes and translucent outlined glyphs (each keeps its own path). Synthetic font only; one real Gujarati file checked by eye on page 1. Indic type in an uninstalled font keeps the file's glyphs as outlines; reordering signs so more lines stay editable is not done. Scores and hours stay at 75% / 10-20 h.
|
||||
|
||||
|
||||
- **Selection — disconnected path deletion (#3380, deletion only):** Clear/Delete and Cut turn surviving disconnected pieces of an ordinary contour into independent selected sibling objects. The first keeps its ID; all keep appearance, baked transforms and stacking order. `deletion_split_preserves_gradient_placement` compares rendered survivors with the original paint space and checks linear/radial fill/stroke vectors, already placed gradients and atomic Undo/Redo; connected remainders keep automatic fitting. Existing multi-subpath paths and explicit compound members retain their semantics. Original synthetic regressions in `crates/engine/tests/it/delete_path_pieces.rs` cover styled/transformed rectangles, cubic handles, compound preservation and atomic Undo/Redo; `crates/ui-egui/src/tests/removeanchors.rs` covers real-frame Delete/Backspace and a second Delete removing all survivors. Segment endpoint highlighting remains open. Scores and hours unchanged.
|
||||
@@ -788,6 +792,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone except by visibility values; every command, like the rest, is drivable over MCP and the CLI.
|
||||
- Export for Screens numbers distinct row files whose names collide, compared without case, as `-2`, `-3`… instead of leaving them out; identical rows still write one file. It puts row folders that differ only in case into one folder (`document.exportForScreens`).
|
||||
- Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones. Every header cell is a variable, and the rows replace the document's data sets as Data Set 1, Data Set 2 and so on. An import error about a row's values gives the file line the row starts on.
|
||||
- Variables: Import Data Sets… (`dataset.import`) reads a UTF-16 file without a byte-order mark, instead of refusing it, when at least half of its two-byte units hold a NUL byte; it uses the byte order that reads its line breaks.
|
||||
- Variables: Import Data Sets… (`dataset.import`) defines the variables a file's header names that the document lacks, unbound. `#Shown` defines a visibility variable, `@Photo` defines an image variable, and a header without a prefix defines a text variable. A header that is a variable's exact name keeps naming it, and any other `%` header (graph data files) is refused.
|
||||
- Variables: Load Variable Library… in the Variables panel menu (`variable.loadLibrary`) replaces the document's variables and data sets with a variable library's, read from its XML file, in one undo step: every data set is deleted, and every variable is deleted with its bindings and values, except a text variable the library also declares, which stays bound so the library's data sets change its objects (#4136); then the library's other text variables are defined, unbound, and its data sets take the text of each value's `p` elements, one paragraph each. Variables of other kinds are left out with a warning that names their kinds.
|
||||
- Variables: Save Variable Library… in the Variables panel menu (`variable.saveLibrary`) writes the document's text variables and data sets as a variable library's XML file, which Load Variable Library reads back with the same text variables, data set names and text values. Variables of other kinds are left out with a warning that names their kinds.
|
||||
@@ -886,6 +891,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`).
|
||||
- Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md).
|
||||
- The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`).
|
||||
- The Polish catalog (`crates/ui-egui/src/i18n/pl.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,439 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 46 Polish messages: 44 that open #7211, #7214, #7116, #7330 and #1931 translate, and 2 that no language has a row for. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English.
|
||||
- The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`).
|
||||
- The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`).
|
||||
- The status bar translates the errors a script's `File` and `Folder` objects, `vc.run`, `$.evalFile`, the `$` object's properties and functions, `alert`, `confirm` and `prompt`, `vc.document` and `vc.log` raise, the limits a run can pass, and the errors of Drop Script Engine, and the line for a run that fails (a syntax error, a thrown error, a limit, a stop or an internal error, with the script's name, the line and column, the included file it is in and the files left written), in every language that translates every message. The script's name, paths, numbers, command ids and the text a script throws itself are shown as they are. A failure with no line and column, such as an error in code `eval` compiled, is shown with one colon after the script's name (`an_error_without_a_place_names_the_script_and_kind`). A message template that names a value twice matches only when both places hold the same text, so the line for a failed run is not taken for the message inside it (`a_repeated_placeholder_matches_the_same_text`). The untranslated-strings report also lists the messages the script host's `fail`, `value` and `over` helpers take and the errors the script interpreter throws, and a constant's text comes from the file or crate that declares it, so the Scripts menu's headers are not listed as messages (`script_errors_are_translated`, `script_host_errors_are_translated`, `message_scan_reads_script_interpreter_errors`, `message_scan_reads_constants_of_the_file_and_its_crate`, `message_scan_skips_test_modules_with_any_test_cfg`).
|
||||
@@ -893,6 +899,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`.
|
||||
The status bar's messages for relinked, replaced, updated and embedded images, broken links, linked files that can't be read or changed on disk, images that can't be decoded, added and unavailable fonts, copied CSS, downloads and exports with their notes name one item in the singular and more in the plural, in English and in each language's plural forms (`counted_status_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`).
|
||||
The status bar's messages for documents opened or saved with notes and for discarded or restored recovered documents, and the Global Edit options' Results line, do the same (`open_save_and_recovery_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`); the warnings for linked images and placed documents output as their previews and the errors for a page past a placed file's last page or artboard name their counts in the singular or the plural in English.
|
||||
- The Simplified Chinese catalog (`crates/ui-egui/src/i18n/zh-hans.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,439 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 40 Simplified Chinese messages: the composite font, justification order and Blob Brush connect end errors that open #7211, #7116 and #7330 translate, and two messages that no catalog has a row for yet. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English.
|
||||
- Edit › Undo and Edit › Redo, in the in-window and native menus, name the step in the interface language as the History panel lists it (`panels::history::step_name`), so when the catalog has no plain row for a step named after an effect, such as Feather, the menu shows the translation of the effect's menu row without its ellipsis (`undo_and_redo_name_the_step_as_the_history_panel_does`).
|
||||
- The History panel translates the undo step names the engine, the tools and the shell write as literals: the untranslated report (`untranslated_report`) lists any a complete catalog lacks, and a step whose catalog row exists only with an ellipsis (such as an effect's menu name) shows that row without the ellipsis.
|
||||
- The German catalog (`crates/ui-egui/src/i18n/de.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,433 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 48 German messages; 38 of them are the composite font, select-a-path and justification order errors that open #7211, #7214 and #7116 translate. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English.
|
||||
@@ -1297,6 +1304,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests).
|
||||
In area type whose frame has several contours, a line runs across an edge two contours share only when their edges lie exactly on one line, so a gap between contours keeps lines from crossing it, even a gap one unit in the last place wide near the edge of the canvas; an edge divided by an anchor exactly on it still lets lines cross (`layout::region::separate_slanted_contours_keep_a_four_ulp_gap_near_canvas_limit`, `shared_slanted_edge_still_joins_near_canvas_limit`, `area_type_sets_no_line_across_a_four_ulp_gap_between_slanted_contours`).
|
||||
With Invert Wrap, the spans a line keeps inside a wrap shape of many parts (a comb of 500 teeth) are found by stepping through the line's spans and the shape's spans once for each piece of the band, so the work grows with the number of spans, not with its square (`crates/text/src/layout/region.rs` tests).
|
||||
Text Wrap Options › Offset is a distance field in the document's units: a bare number is in those units, a typed unit ("12 pt") is read as typed, and OK sends points. In a document in picas, a bare number follows Preferences › Units › Numbers Without Units Are Points as the other length fields do: it is in points while that preference is on (the default) and in picas while it is off (`dialogs::command::tests::text_wrap_offset_in_picas_follows_numbers_without_units_are_points`). An Offset typed outside -1000 to 1000 pt, VectorCraft's own range for the command, or text that doesn't read, is refused: the field flashes and the dialog stays open, whether Enter or a click on OK ends the entry; in every dialog drawn by the shared dialog window, a click on OK no longer confirms it when the distance field it takes the focus from refuses its text (`dialogs::command::tests::text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range`). Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/enter-values-in-panels-and-dialog-boxes.html).
|
||||
The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute.
|
||||
The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged.
|
||||
The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`.
|
||||
@@ -1437,6 +1445,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Object › Live Paint › Merge adds the other selected paths and Live Paint groups to the bottom selected Live Paint group ([Live Paint groups](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/live-paint-groups.html)), which keeps its id, the paint of its faces and edges, and its name, lock, visibility, transparency, appearance, opacity mask and graphic style link. Object › Live Paint › Make with a Live Paint group selected, and a Live Paint Bucket click on a selected path beside a selected Live Paint group, add the paths to that group the same way. Make, Merge and the Live Paint Bucket take paths, compound paths and groups of them; with type, an image or another object among the selection they report that it has to be converted to paths first and change nothing, as type and raster images can't go into a Live Paint group directly ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)); before, such an object went among the group's hidden sources and vanished from the canvas. Evidence: `tests_build::live_paint_refuses_type_and_images_and_changes_nothing`.
|
||||
With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`.
|
||||
Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`.
|
||||
Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`.
|
||||
Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision.
|
||||
Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off.
|
||||
View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)).
|
||||
@@ -1661,7 +1670,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`).
|
||||
In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an object with nothing stroked whose only effect is Twist: a path or compound path with straight segments at up to 180° either way, and another path, compound path or group with no guide in it at up to 90° either way (limits VectorCraft sets); one twisted further keeps the drawn art. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.rs`).
|
||||
In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an object with nothing stroked whose only effect is Twist: a path or compound path with straight segments at up to 180° either way, unless it is a compound path with a guide member that reaches past its other members and the angle is not 0°, and another path, compound path or group with no guide in it at up to 90° either way (limits VectorCraft sets); one twisted further keeps the drawn art. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.rs`).
|
||||
A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482).
|
||||
`document.open`, `file.place` and `document.pdfInfo` reject a `cropTo`, `crop`, `password`, `colorMode` or `textAs` that is not a string, such as `"textAs": true`, and `document.pdfInfo` rejects a `thumbnailSize` that is not a number; the error names the param, and nothing opens or is placed. A param left out or given as null keeps its default (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`).
|
||||
When the PDF content of a `.ai` has a page for each artboard, the layers are compared with every page on its artboard (until the pages compared take 8,388,608 pixels at the comparison's scale, about 67 letter-size pages, or number 128): art that only the layers or only a later page draws is handled as art that differs on the first page is, and a text object that a later page doesn't draw is left out unless its layer doesn't print (`crates/eps/src/import/tests_native.rs`).
|
||||
@@ -2083,6 +2092,9 @@ Corners section in the Anchor Inspector sets the radius and kind, counts the sel
|
||||
Set Corners… (in the submenu and the Corners section) sets the radius and kind with Preview, on all the chosen anchors
|
||||
or only the odd- or even-numbered ones (`every`, #5546), and only the corners whose angle is in a range
|
||||
(`minAngle`/`maxAngle`, #5545).
|
||||
`cornerSet` and `cornerRemove` refuse an `items` member key other than `id` and `anchors`, and `items` given with `ids`;
|
||||
saved steps still play (`corner_commands_refuse_an_unknown_item_key`, `corner_commands_refuse_items_with_ids`,
|
||||
`saved_corner_steps_with_older_item_keys_still_play`).
|
||||
Slide Anchor Along Path drags an anchor along its curve, the two segments beside it refitted by the keep-shape kernel
|
||||
(Shift: where the tangent is a multiple of 45°). Commands `addon.pathtools.cornerSet`, `cornerRemove` and `slideAnchor`,
|
||||
each one undo step; `vectorcraft_pathops::addons::pathtools::slide_anchor`. Tests: `addons::pathtools` in
|
||||
|
||||
Reference in New Issue
Block a user