.ai import: a line width of 0 draws as a hairline

Squashed from #5174 onto main.
This commit is contained in:
dotCooCoo
2026-11-02 14:32:53 +07:00
committed by bflatastic
parent dc363ec356
commit 56f6128f94
10 changed files with 106 additions and 7 deletions
@@ -220,6 +220,24 @@ fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
assert_eq!(found, [[[255, 0, 0], [0, 255, 0], [0, 0, 255]]], "{r}");
}
/// A stroke of width 0 (`0 w`) in the editing data is the thinnest line the device draws: the
/// opened `.ai` shows a hairline where the same path without its stroke shows nothing.
#[test]
fn a_zero_width_stroke_in_the_editing_data_draws_a_hairline() {
let drawn = |art: &str| {
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", art));
let file = ai::ai(&compressed(data.as_bytes()), "");
let mut s = Session::new();
let r = open(&mut s, "zero.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
let v = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
let png = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
image::load_from_memory(&png).unwrap().to_rgba8().pixels().filter(|p| p.0[3] > 0).count()
};
assert!(drawn("0 1 1 0 1 0 0 XA\n0 w\n20 20 m\n180 80 L\nS\n") > 0, "the hairline draws");
assert_eq!(drawn("0 1 1 0 1 0 0 XA\n0 w\n20 20 m\n180 80 L\nN\n"), 0, "the unstroked path draws nothing");
}
/// A page of a square and a line of type, as a `.ai`'s PDF part draws them.
const SQUARE_AND_TYPE: &str = "0 g 10 10 40 40 re f BT /F1 12 Tf 100 50 Td (Hi) Tj ET";
+22
View File
@@ -3186,6 +3186,28 @@ proptest! {
prop_assert!(!opened);
}
/// A `.ai` whose strokes have any width (0, which draws a hairline, negative, huge, not a number) on a
/// path, on a gradient-filled path that `1 BB` strokes and on type in any rendering mode,
/// damaged or hostile.
#[test]
fn ai_line_widths_never_panic(
width in prop::sample::select(vec!["0", "0.0", "-0", "-1", "1e308", "1e-308", "nan", "inf", "2"]),
render in prop::sample::select(vec!["0", "1", "2", "5", "6", "7", "-1", "99"]),
cut in 0usize..2_000,
edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '1', '-', '.', ' ', '\n', '(', ')', 'w', 'B', 'S', 'T'])), 0..8),
) {
use vectorcraft_testkit::ai;
let gradient = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 0 0 2 1 6 50 0 Bs\n%_0 0 0 0 0 0 1 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let art = format!(
"0 1 1 0 1 0 0 XA\n{width} w\n10 10 m\n90 90 L\nS\n{}Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\nf\n1 BB\n\
0 To\n1 0 0 1 10 50 0 Tp\nTP\n{render} Tr\n/_Helvetica 9 Tf\n(hi) Tx\nTO\n",
ai::rect(20.0, 20.0, 80.0, 80.0)
);
let data = mutate_text(&ai::editing_data(100.0, 100.0, &format!("{gradient}{}", ai::layer("L", &art))), cut, &edits);
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat();
survive("hostile .ai line widths", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
}
/// A `.ai` whose variable library is damaged or hostile: the library is counted for the warning
/// whether the layers are read or not (an inserted `Z` can make an operator the reader doesn't
/// handle, and then the layers aren't read).
+1 -1
View File
@@ -328,7 +328,7 @@ impl Reader<'_> {
};
style.fill = if fill { gs.fill.clone() } else { Paint::None };
style.stroke = if stroke { gs.stroke.clone() } else { Paint::None };
style.stroke_width = if stroke { gs.width } else { 0.0 };
style.stroke_width = if stroke { gs.line_width() } else { 0.0 };
(style.stroke_cap, style.stroke_join, style.stroke_miter_limit, style.stroke_dash) = (gs.cap, gs.join, gs.miter, gs.dash.clone());
(style.overprint_fill, style.overprint_stroke) = (gs.overprint_fill, gs.overprint_stroke);
let mut text = String::new();
+1
View File
@@ -80,6 +80,7 @@ use vectorcraft_doc::{
use vectorcraft_geom::{AnchorKind, FillRule, PathData};
use super::super::ate::CANVAS_CENTRE;
use super::super::graphics::HAIRLINE;
use super::lex::{Lexer, Tok};
use super::obj::{self, Obj, V};
use super::paint::{self, GradientDef, Instance, Named};
+2 -2
View File
@@ -202,7 +202,7 @@ impl<'a> Reader<'a> {
}
// A clipping path keeps its stroke, which paints over the art it clips.
if stroke {
let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.width);
let mut st = StrokeLayer::new(stroke_paint.unwrap_or(gs.stroke.clone()), gs.line_width());
st.cap = gs.cap;
st.join = gs.join;
st.miter_limit = gs.miter;
@@ -334,7 +334,7 @@ impl<'a> Reader<'a> {
if !matches!(n.kind, NodeKind::Path { .. }) || n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) {
return;
}
let mut st = StrokeLayer::new(gs.stroke.clone(), gs.width);
let mut st = StrokeLayer::new(gs.stroke.clone(), gs.line_width());
st.cap = gs.cap;
st.join = gs.join;
st.miter_limit = gs.miter;
+2 -2
View File
@@ -1,6 +1,6 @@
use super::containers::close;
use super::document::slot_of;
use super::types::{BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH};
use super::types::{BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, line_width};
use super::{
Affine, Appearance, AppearanceItem, Arc, BlendMode, Dash, FillLayer, FillRule, Gradient, GradientDef, Graph, HashMap, HashSet, Instance,
Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, StrokeLayer, V, obj, paint,
@@ -753,7 +753,7 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
}
},
StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => {
let mut layer = StrokeLayer::new(paint.clone(), *width);
let mut layer = StrokeLayer::new(paint.clone(), line_width(*width));
(layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone());
AppearanceItem::Stroke(layer)
}
+13 -1
View File
@@ -1,4 +1,4 @@
use super::{Affine, BlendMode, Dash, FillRule, Knockout, LayerColor, LineCap, LineJoin, Node, Obj, Paint, Rect, V};
use super::{Affine, BlendMode, Dash, FillRule, HAIRLINE, Knockout, LayerColor, LineCap, LineJoin, Node, Obj, Paint, Rect, V};
/// Deepest nesting of layers, groups and dictionaries.
pub(super) const MAX_DEPTH: usize = 256;
@@ -96,6 +96,18 @@ impl Default for GState {
}
}
impl GState {
/// The width its stroke draws at (see [`line_width`]).
pub(crate) fn line_width(&self) -> f64 {
line_width(self.width)
}
}
/// The width a stroke of `w w` draws at: `0 w` is the thinnest line a device draws, a [`HAIRLINE`].
pub(super) fn line_width(w: f64) -> f64 {
if w > 0.0 { w } else { HAIRLINE }
}
/// The blend modes by their number in `Xy`.
pub(super) const BLENDS: [BlendMode; 16] = [
BlendMode::Normal,
+45
View File
@@ -350,6 +350,51 @@ fn a_segment_that_isnt_read_leaves_the_kinds_in_step() {
assert_eq!(anchor_kinds(&a[0]), [vec![Corner], vec![Corner, Smooth, Corner]]);
}
/// `0 w` is the thinnest line the device draws (PostScript's `setlinewidth`), so a stroke of width 0
/// comes in as a hairline: on a path, on a gradient-filled path that `1 BB` strokes, and on type.
#[test]
fn a_line_width_of_zero_is_a_hairline() {
let red = Some(Color::rgb(1.0, 0.0, 0.0));
let a = art(&read_ok(&layer("0 1 1 0 1 0 0 XA\n2 J\n0 w\n20 20 m\n180 80 L\nS\n")).doc);
assert_eq!(a.len(), 1);
let st = a[0].appearance.stroke().unwrap();
assert_eq!((st.width, st.paint.color(), st.cap), (0.25, red, LineCap::Square));
let art_ = format!(
"0 1 1 0 1 0 0 XA\n0 w\n{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n1 BB\n",
rect_path(50.0, 0.0, 150.0, 100.0)
);
let a = art(&read_ok(&format!("{G_DEFS}{}", layer(&art_))).doc);
assert!(matches!(fill(&a[0]), Paint::Gradient(_)), "{:?}", fill(&a[0]));
let st = a[0].appearance.stroke().unwrap();
assert_eq!((st.width, st.paint.color()), (0.25, red));
let text = "0 To\n1 0 0 1 10 50 0 Tp\nTP\n1 Tr\n0 1 1 0 1 0 0 XA\n0 w\n/_Helvetica 9 Tf\n(hi) Tx\nTO\n";
let a = art(&read_ok(&layer(text)).doc);
let Some(NodeKind::Text(t)) = a.first().map(|n| &n.kind) else { panic!("{a:#?}") };
let style = &t.runs[0].style;
assert_eq!((style.stroke_width, style.stroke.color(), style.fill.clone()), (0.25, red, Paint::None));
}
/// An art style's stroke of width 0 comes in as a hairline too.
#[test]
fn an_art_style_s_line_width_of_zero_is_a_hairline() {
let styles = "%AI9_BeginArtStyles\n/KnownStyle :\n(Thin) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Stroke Style Filter) 0 0 /Filter ,\n1 /Visible ,\n2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n\
/StrokeStyle : 0 R\n0 1 1 0 1 0 0 XA\n0 J 0 j 0 w 10 M []0 d\n0 XR\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n; /Dict ;\n /Part ,\n\
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n%AI9_EndArtStyles\n";
let line = "20 20 m\n180 80 L\n";
let art_ = format!("0 Ae\nu\n0 1 1 0 1 0 0 XA\n0 w\n{line}S\nU\n%_0 1 1 0 1 0 0 XA\n%_0 w\n%_{}S\n1 (Thin) XW\n", line.replace('\n', "\n%_"));
let r = read_ok(&format!("{styles}{}", layer(&art_)));
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}");
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
let st = a[0].appearance.stroke().unwrap();
assert_eq!((st.width, st.paint.color()), (0.25, Some(Color::rgb(1.0, 0.0, 0.0))));
}
/// `Xk` and `XK` are global process colours: a name, a tint and whether the colour is RGB, as
/// `Xx` has them (#1025). They come in as global swatches that aren't inks, linked at their tint.
#[test]
+1 -1
View File
@@ -31,7 +31,7 @@ const MAX_NODES: usize = 1 << 20;
/// are left out.
const LIMIT: f64 = 1e6;
/// Width of a zero-width line (the thinnest a device draws), points.
const HAIRLINE: f64 = 0.25;
pub(super) const HAIRLINE: f64 = 0.25;
/// Most inks a `DeviceN` space has (PostScript's own limit).
const MAX_INKS: usize = 32;
/// Most dashes `strokepath` makes of a path (more and it outlines the path undashed).
File diff suppressed because one or more lines are too long