From a055ebcbc73cb7f78034ab876d983ca31346ce00 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 13:30:34 -0800 Subject: [PATCH 01/12] Fix #3818: the chosen brightness holds when the system theme differs The theme's colors went into egui's style for the current system theme only. When the system's light or dark setting differed from it or changed while the app ran, egui switched to its other style, which held egui's defaults: tool tips became a white box with gray text, or with the theme's light text. Applying a brightness now sets egui's theme to match, starting from the current style so settings such as the tool tip delay carry over. --- crates/ui-egui/src/theme.rs | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index e6c06e5dd..3b1a0524d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -587,6 +587,15 @@ pub fn apply(ctx: &egui::Context, b: Brightness) { pub fn apply_with(ctx: &egui::Context, b: Brightness, accent: Accent, scheme: Scheme) { let t = Tokens::for_scheme(scheme, b, accent); ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t)); + // egui keeps a style for each system theme and by default follows the system's setting. The + // chosen brightness sets egui's theme, so these colors stay in force when the system's setting + // differs or changes. A change of theme starts from the current style, which keeps settings + // such as the tool tip delay. + let theme = if t.dark { egui::Theme::Dark } else { egui::Theme::Light }; + if ctx.theme() != theme { + ctx.set_style_of(theme, ctx.global_style()); + } + ctx.set_theme(theme); let mut v = if t.dark { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.panel; v.window_fill = t.panel; @@ -669,4 +678,55 @@ mod tests { assert_eq!(Scheme::parse(Scheme::Classic.id()), Some(Scheme::Classic)); } } + + /// #3818: the brightness chosen in Preferences stays in force when the system's light or dark + /// setting differs from it or changes while the app runs. Tool tips keep the theme's panel fill + /// and text color instead of egui's own white box and gray text. + #[test] + fn the_theme_holds_whatever_the_system_theme() { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 400.0)); + for b in Brightness::ALL { + for scheme in Scheme::ALL { + for (first, then) in [(egui::Theme::Dark, egui::Theme::Light), (egui::Theme::Light, egui::Theme::Dark)] { + let t = Tokens::for_scheme(scheme, b, Accent::default()); + let ctx = egui::Context::default(); + let mut time = 0.0; + let mut tip = None; + for i in 0..5 { + time += 0.5; + let system_theme = Some(if i == 0 { first } else { then }); + let events = if i == 1 { vec![egui::Event::PointerMoved(egui::pos2(30.0, 15.0))] } else { vec![] }; + let input = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, system_theme, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + if i == 0 { + apply_with(ui.ctx(), b, Accent::default(), scheme); + ui.ctx().global_style_mut(|s| s.interaction.tooltip_delay = 0.0); + } + ui.button("Hover me").on_hover_text("Tip text"); + }); + out.textures_delta.clear(); + tip = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(s) if s.galley.text() == "Tip text" => Some(s.fallback_color), + _ => None, + }); + } + let v = ctx.global_style().visuals.clone(); + assert_eq!((v.window_fill, v.override_text_color), (t.panel, Some(t.text)), "{scheme:?} {b:?}, system {first:?} then {then:?}"); + assert_eq!(tip, Some(t.text), "{scheme:?} {b:?}, system {first:?} then {then:?}: the tool tip's text"); + } + } + } + } + + /// Changing brightness keeps the interface settings made after the theme was applied, such as + /// General › Show Tool Tips' delay. + #[test] + fn changing_brightness_keeps_other_interface_settings() { + let ctx = egui::Context::default(); + apply(&ctx, Brightness::Dark); + ctx.global_style_mut(|s| s.interaction.tooltip_delay = f32::INFINITY); + apply(&ctx, Brightness::Light); + assert_eq!(ctx.global_style().interaction.tooltip_delay, f32::INFINITY); + assert_eq!(ctx.global_style().visuals.window_fill, Tokens::for_brightness(Brightness::Light).panel); + } } From b473716e3e3ac029d2e8368834ee45766ab1cd0e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 14:10:29 -0800 Subject: [PATCH 02/12] Fix #2089: Affinity strokes keep their Width tool profile An Affinity stroke shaped with the Width tool stores its pressure curve (LDeP: position along the path against width). The importer warned and drew the stroke at full width all along. - The pressure curve is read and sampled into the stroke's width profile (2 to 8 points per segment, 256 segments at most, widths up to the full weight). A curve at full width all along stays a plain stroke; one that can't be read is warned about. - Brush strokes keep their own warning. - The pinned Affinity 3 signatures for the five corpus files with such strokes are updated. --- crates/affinity/README.md | 3 +- crates/affinity/src/geometry.rs | 49 +++++++++ crates/affinity/src/paint.rs | 19 +++- .../tests/support/affinity3-signatures.txt | 10 +- crates/engine/src/cmd/fileio/affinity.rs | 2 +- .../engine/src/cmd/fileio/affinity/paint.rs | 3 +- .../engine/src/cmd/fileio/tests_affinity.rs | 102 ++++++++++++++++++ 7 files changed, 177 insertions(+), 11 deletions(-) diff --git a/crates/affinity/README.md b/crates/affinity/README.md index f0339d698..90c078023 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -23,6 +23,7 @@ Affinity files. | Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) | | Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) | | Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance | +| A stroke's pressure curve (`LDeP`: position along the path against width) | the stroke's width profile, the curve sampled at 2 to 8 points per segment (256 segments at most), widths up to the full weight | | Fill layers (`FRst`) | filled rectangles | | Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) | | Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) | @@ -33,7 +34,7 @@ Affinity files. | Opacity, visibility, lock, names, the blend modes VectorCraft has | the same | Everything else is reported in the import warnings, one line per kind with a count, never -dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes, +dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read, transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial), special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner types other than round or straight, stars with rounded points, several fills or strokes on one object (the diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 8e84e4807..83ea85428 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -169,6 +169,55 @@ fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option Option> { + let s = r.s; + let data = s.obj(crvd, b"Data")?; + let records = s.positional(data).find_map(|v| if let Value::Array(a) = v { Some(a) } else { None })?; + // Every segment takes at least an anchor record. + if records.len() > MAX_PRESSURE_SEGMENTS * 3 + 1 { + return None; + } + let curve = subpath(&anchors(records)?, false, None)?; + if curve.segments.len() > MAX_PRESSURE_SEGMENTS { + return None; + } + let steps = u32::try_from((PRESSURE_POINTS / curve.segments.len().max(1)).clamp(2, 8)).unwrap_or(2); + let mut samples = vec![(curve.start.x, curve.start.y)]; + let mut from = curve.start; + for [c1, c2, to] in &curve.segments { + for i in 1..=steps { + let t = f64::from(i) / f64::from(steps); + let u = 1.0 - t; + let at = |a: f64, b: f64, c: f64, d: f64| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d; + samples.push((at(from.x, c1.x, c2.x, to.x), at(from.y, c1.y, c2.y, to.y))); + } + from = *to; + } + let mut last = 0.0f64; + Some( + samples + .into_iter() + .map(|(x, y)| { + last = x.clamp(last, 1.0); + (last, y.clamp(0.0, 1.0)) + }) + .collect(), + ) +} + struct Anchor { p: Point, inh: Option, diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 1dfdb6575..144357ab7 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -121,6 +121,9 @@ pub struct Stroke { pub dash: Option<(Vec, f64)>, /// Painted below the fill instead of above it. pub behind: bool, + /// The pressure curve: (position 0..1 along the path, width as a fraction of `width`) in + /// order along the path, `None` for a stroke of the same width all along. + pub profile: Option>, } /// Gradient stops per gradient. @@ -350,10 +353,20 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh } _ => None, }; - if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() { - r.warn("brush and pressure strokes (imported as plain strokes)"); + if s.obj(lsty, b"Brus").is_some() { + r.warn("brush strokes (imported as plain strokes)"); } - Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind }) + // The pressure curve sets the width along the path; one at full width all along is a plain + // stroke. + let profile = match s.obj(ldsc, b"LDeP").map(|p| crate::geometry::pressure(r, p)) { + Some(Some(points)) => (!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-3)).then_some(points), + Some(None) => { + r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)"); + None + } + None => None, + }; + Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind, profile }) } #[cfg(test)] diff --git a/crates/affinity/tests/support/affinity3-signatures.txt b/crates/affinity/tests/support/affinity3-signatures.txt index 779a58552..462d32172 100644 --- a/crates/affinity/tests/support/affinity3-signatures.txt +++ b/crates/affinity/tests/support/affinity3-signatures.txt @@ -1,7 +1,7 @@ -affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"] -affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"] -affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"] -affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"] +affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=ce96643c;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)"] +affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=08a70ca5;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)"] +affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=557d4a95;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)"] +affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=bc0c838d;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush strokes (imported as plain strokes)"] patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[] @@ -30,4 +30,4 @@ patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1, patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"] patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"] -patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[] +patchy-vector.af dpi=72;spreads=1;model_crc32=3b16c7ed;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[] diff --git a/crates/engine/src/cmd/fileio/affinity.rs b/crates/engine/src/cmd/fileio/affinity.rs index 671bc04b6..c8ef3235e 100644 --- a/crates/engine/src/cmd/fileio/affinity.rs +++ b/crates/engine/src/cmd/fileio/affinity.rs @@ -12,7 +12,7 @@ use serde_json::json; use vectorcraft_affinity::model::{self as af, Align as AfAlign, Blend, Kind, Pixels}; use vectorcraft_affinity::paint::{self as ap, Cap, GradientKind as AfKind, Join}; use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; -use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer}; +use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; use vectorcraft_doc::geom::{FillRule, PathData, shapes}; use vectorcraft_doc::text::{CharStyle, FirstBaseline, Justify, TextKind, TextObject, TextRun, VerticalAlign}; use vectorcraft_doc::{Artboard, Document, ImageObject, LayerColor, Node, NodeKind, OpacityMask}; diff --git a/crates/engine/src/cmd/fileio/affinity/paint.rs b/crates/engine/src/cmd/fileio/affinity/paint.rs index ce798dcfb..ed2b44ad3 100644 --- a/crates/engine/src/cmd/fileio/affinity/paint.rs +++ b/crates/engine/src/cmd/fileio/affinity/paint.rs @@ -4,7 +4,7 @@ use super::text::Placement; use super::{ AfKind, Affine, Appearance, AppearanceItem, Arc, BezPath, Builder, Cap, Color, Dash, Effect, FillLayer, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, ImageObject, Join, LineCap, LineJoin, NodeKind, Paint, PathData, Pixels, Point, StrokeAlign, StrokeLayer, - TextObject, Vec2, af, ap, json, + TextObject, Vec2, WidthProfile, af, ap, json, }; impl Builder { @@ -232,6 +232,7 @@ impl Builder { offset: phase * self.k, align_corners: false, }); + l.profile = s.profile.as_ref().map(|p| WidthProfile { points: p.iter().map(|&(t, w)| (t, w, w)).collect() }); Some(l) } diff --git a/crates/engine/src/cmd/fileio/tests_affinity.rs b/crates/engine/src/cmd/fileio/tests_affinity.rs index ae8f6cc45..a288c5ec1 100644 --- a/crates/engine/src/cmd/fileio/tests_affinity.rs +++ b/crates/engine/src/cmd/fileio/tests_affinity.rs @@ -300,6 +300,108 @@ mod native { } } + /// A version-12 document holding an open curve with an 8 px stroke whose pressure curve (`LDeP`) + /// is made of `records`. + fn pressure_document(records: Vec>) -> Vec { + let line = F::Def( + 20, + vec![tag(b"PCrv"), tag(b"VNod"), tag(b"Node")], + vec![ + ( + tag(b"Crvs"), + F::Obj( + tag(b"PCvD"), + vec![( + tag(b"Data"), + F::Pos(vec![F::U8(0), F::U32(1), F::Bool(false), F::Records(18, vec![node(0.0, 0.0, 1, 0), node(100.0, 0.0, 1, 0)])]), + )], + ), + ), + (tag(b"LIFl"), F::Shared(vec![solid(24, rgba(0.0, 0.0, 0.0, 1.0))])), + ( + tag(b"LILn"), + F::Shared(vec![F::Def( + 27, + vec![tag(b"LDsc")], + vec![ + (tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(8.0))])), + (tag(b"LDSc"), F::Bool(false)), + ( + tag(b"LDeP"), + F::Obj(tag(b"CrvD"), vec![(tag(b"Data"), F::Pos(vec![F::U32(0), F::Bool(false), F::Records(18, records)]))]), + ), + ], + )]), + ), + ], + ); + let spread = + F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"Chld"), F::Shared(vec![line]))]); + let doc = synth::stream(&[ + (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(72.0))])), + (tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])), + ]); + synth::container(&[("doc.dat", &doc, Method::Zstd)], Some(&png())) + } + + /// The width profile of the only stroked path in `d`. + fn stroke_of(d: &vectorcraft_doc::Document) -> vectorcraft_doc::appearance::StrokeLayer { + let mut found = None; + d.walk(|n| { + if let Some(AppearanceItem::Stroke(k)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + found = Some(k.clone()); + } + }); + found.unwrap_or_else(|| panic!("no stroke in {:?}", d.layers)) + } + + #[test] + fn a_pressure_curve_becomes_the_strokes_width_profile() { + // Thin at the start, full width from half way: anchor (0, 0), handles (0.25, 0.5) and + // (0.5, 1), anchor (0.5, 1), then straight on to anchor (1, 1). + let curve = vec![node(0.0, 0.0, 4, 0), node(0.25, 0.5, 0, 1), node(0.5, 1.0, 0, 2), node(0.5, 1.0, 4, 0), node(1.0, 1.0, 4, 0)]; + let r = load("pressure.af", &pressure_document(curve)).unwrap(); + assert!(!r.preview_only); + assert!(r.warnings.iter().all(|w| !w.contains("pressure")), "{:?}", r.warnings); + let k = stroke_of(&r.doc); + assert_eq!(k.width, 8.0); + let profile = k.profile.unwrap_or_else(|| panic!("the pressure curve is read: {:?}", r.warnings)); + assert_eq!(profile.points.first(), Some(&(0.0, 0.0, 0.0))); + assert_eq!(profile.points.last(), Some(&(1.0, 1.0, 1.0))); + assert!(profile.points.windows(2).all(|w| w[0].0 <= w[1].0), "positions go forward: {:?}", profile.points); + let (left, right) = profile.at(0.25); + assert!(left > 0.25 && left < 1.0 && left == right, "{left} {right}"); + assert_eq!(profile.at(0.75), (1.0, 1.0)); + + // Widths past the full weight are held at it. + let huge = vec![node(0.0, 0.0, 4, 0), node(0.5, 1e300, 4, 0), node(1.0, 0.0, 4, 0)]; + let profile = stroke_of(&load("huge.af", &pressure_document(huge)).unwrap().doc).profile.unwrap(); + assert!(profile.points.iter().all(|p| (0.0..=1.0).contains(&p.1)), "{:?}", profile.points); + assert_eq!(profile.at(0.5), (1.0, 1.0)); + + // Each segment is sampled inside too: anchors at full width whose handles dip to nothing + // still narrow the stroke, with 256 segments. + let mut dips = vec![node(0.0, 1.0, 4, 0)]; + for i in 1..=256 { + let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0); + dips.extend([node(a + (b - a) / 3.0, 0.0, 0, 1), node(b - (b - a) / 3.0, 0.0, 0, 2), node(b, 1.0, 4, 0)]); + } + let profile = stroke_of(&load("dips.af", &pressure_document(dips)).unwrap().doc).profile.unwrap(); + assert!(profile.points.iter().any(|p| p.1 < 0.5), "{:?}", profile.points); + + // A curve of more than 256 segments is left out, with a warning. + let long: Vec> = (0..=300).map(|i| node(f64::from(i) / 300.0, f64::from(i % 2), 4, 0)).collect(); + let r = load("long.af", &pressure_document(long)).unwrap(); + assert!(stroke_of(&r.doc).profile.is_none()); + assert!(r.warnings.iter().any(|w| w.contains("pressure curve can't be read")), "{:?}", r.warnings); + + // A pressure curve at full width all along leaves the stroke plain. + let flat = vec![node(0.0, 1.0, 4, 0), node(1.0, 1.0, 4, 0)]; + let r = load("flat.af", &pressure_document(flat)).unwrap(); + assert!(stroke_of(&r.doc).profile.is_none()); + assert!(r.warnings.iter().all(|w| !w.contains("pressure")), "{:?}", r.warnings); + } + #[test] fn native_documents_save_reopen_export_place_and_template() { let bytes = document(Method::Zstd); From d5004b7dae1e0849023f8c41cbc0b7d809bcdd0f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 14:42:42 -0800 Subject: [PATCH 03/12] Fix #3437: Spiral segments follow the spiral The Spiral tool places its anchors on a logarithmic spiral, with the radius shrinking by the decay each quarter turn, but gave every handle the direction of a circle's tangent and a length from an averaged radius. The curve bulged off the spiral between anchors and turned at each anchor, so the spiral looked squared: up to 2.8% off its radius at the default 80% decay and 23% at 30%. The handles now follow the spiral's own tangent, with lengths that differ at each segment's outer and inner end. The curve stays within 0.03% of the spiral at 80% decay and 0.8% at 20%. Anchor positions and counts are unchanged. --- crates/geom/src/shapes.rs | 48 +++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index cba634aaa..557ae3294 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -210,21 +210,27 @@ pub fn arc(a: Point, b: Point, slope: f64, closed: bool, _base_along_x: bool) -> PathData::single(SubPath::new(anchors, closed)) } -/// Archimedean-style spiral approximating Illustrator's Spiral tool (decay-based, `segments` quarter turns). +/// Logarithmic spiral approximating the reference app's Spiral tool (decay-based, `segments` quarter turns). pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clockwise: bool) -> PathData { let decay = (decay_percent / 100.0).clamp(0.05, 0.9999); let dir = if clockwise { 1.0 } else { -1.0 }; + // The radius shrinks by `decay` each quarter turn: r(φ) = radius · e^(kφ) after turning φ. + let k = decay.ln() / (PI / 2.0); + // Handle lengths per unit of the derivative below, at a segment's outer and inner end: √r + // changes evenly with the curve's parameter along each quarter turn. Both are KAPPA when the + // radius does not shrink, and the curve stays within 0.8% of the spiral's radius at 20% decay. + let outer = 2.0 * KAPPA * (decay.sqrt() - 1.0) / decay.ln(); + let inner = outer / decay.sqrt(); let mut anchors = Vec::new(); let mut r = radius; for i in 0..=segments { let a = dir * (i as f64) * PI / 2.0; let p = Point::new(center.x + r * a.cos(), center.y + r * a.sin()); - // Tangent direction for a quarter arc of radius r. - let t = Vec2::new(-a.sin(), a.cos()) * dir; - let r_prev = r / decay; - let r_next = r * decay; - let h_in = p - t * (KAPPA * (r + r_prev) / 2.0); - let h_out = p + t * (KAPPA * (r + r_next) / 2.0); + // The spiral's derivative by the angle turned: the radius changes along the radial + // direction while the point turns along the circle's tangent. + let d = Vec2::new(a.cos(), a.sin()) * (r * k) + Vec2::new(-a.sin(), a.cos()) * (r * dir); + let h_in = p - d * inner; + let h_out = p + d * outer; anchors.push(Anchor { p, h_in: if i == 0 { p } else { h_in }, h_out: if i == segments { p } else { h_out }, kind: AnchorKind::Smooth }); r *= decay; } @@ -460,6 +466,34 @@ mod tests { assert!(!p.subpaths[0].closed); } + /// #3437: every segment stays on the spiral through its anchors, whose radius shrinks by + /// `decay` each quarter turn, for strong and weak decay and both directions. + #[test] + fn spiral_segments_follow_the_spiral() { + let center = Point::new(40.0, -25.0); + for decay in [20.0, 30.0, 50.0, 80.0, 95.0] { + for clockwise in [true, false] { + let p = spiral(center, 100.0, decay, 8, clockwise); + let anchors = &p.subpaths[0].anchors; + let k = (decay / 100.0_f64).ln() / (PI / 2.0); + let mut worst = 0.0_f64; + for pair in anchors.windows(2) { + let c = kurbo::CubicBez::new(pair[0].p, pair[0].h_out, pair[1].h_in, pair[1].p); + let r0 = (pair[0].p - center).hypot(); + let a0 = (pair[0].p - center).atan2(); + for step in 1..20 { + let q = kurbo::ParamCurve::eval(&c, f64::from(step) / 20.0) - center; + // The angle turned from the segment's start, toward the outer end. + let turned = (q.atan2() - a0 + 3.0 * PI).rem_euclid(TAU) - PI; + let want = r0 * (-k * turned.abs()).exp(); + worst = worst.max((q.hypot() - want).abs() / want); + } + } + assert!(worst < 0.008, "decay {decay} clockwise {clockwise}: radius off by {:.2}%", worst * 100.0); + } + } + } + #[test] fn grids() { let r = Rect::new(0.0, 0.0, 10.0, 10.0); From 0067aee519d9b447b99f112a9c3f6330b34e42ca Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 14:45:35 -0800 Subject: [PATCH 04/12] Tests: the tool tip's box keeps the theme's fill and contrast The test for #3818 checked the tool tip's text color and the style's window fill. It now also checks the fill of the box drawn behind the tool tip's text, and that the text and the box have a contrast ratio of at least 4.5, for every brightness and scheme. --- crates/ui-egui/src/theme.rs | 39 +++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3b1a0524d..0ca446b4d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -705,19 +705,50 @@ mod tests { ui.button("Hover me").on_hover_text("Tip text"); }); out.textures_delta.clear(); - tip = out.shapes.iter().find_map(|c| match &c.shape { - egui::Shape::Text(s) if s.galley.text() == "Tip text" => Some(s.fallback_color), + // The tool tip's text color, and the fill of the box drawn behind it. + let mut shapes: Vec<&egui::Shape> = Vec::new(); + let mut open: Vec<&egui::Shape> = out.shapes.iter().rev().map(|c| &c.shape).collect(); + while let Some(shape) = open.pop() { + match shape { + egui::Shape::Vec(inner) => open.extend(inner.iter().rev()), + shape => shapes.push(shape), + } + } + tip = shapes.iter().enumerate().find_map(|(n, shape)| match shape { + egui::Shape::Text(s) if s.galley.text() == "Tip text" => { + let fill = shapes[..n].iter().rev().find_map(|behind| match behind { + egui::Shape::Rect(r) if r.rect.contains(s.pos) && r.fill.a() > 0 => Some(r.fill), + _ => None, + }); + Some((s.fallback_color, fill)) + } _ => None, }); } let v = ctx.global_style().visuals.clone(); - assert_eq!((v.window_fill, v.override_text_color), (t.panel, Some(t.text)), "{scheme:?} {b:?}, system {first:?} then {then:?}"); - assert_eq!(tip, Some(t.text), "{scheme:?} {b:?}, system {first:?} then {then:?}: the tool tip's text"); + let what = format!("{scheme:?} {b:?}, system {first:?} then {then:?}"); + assert_eq!((v.window_fill, v.override_text_color), (t.panel, Some(t.text)), "{what}"); + assert_eq!(tip, Some((t.text, Some(t.panel))), "{what}: the tool tip's text and box"); + assert!(contrast(t.text, t.panel) >= 4.5, "{what}: the tool tip's text against its box, {}", contrast(t.text, t.panel)); } } } } + /// The contrast ratio of two opaque colors, from 1 (the same) to 21 (black on white), as WCAG 2 + /// defines it. + fn contrast(a: egui::Color32, b: egui::Color32) -> f32 { + let luminance = |c: egui::Color32| { + let channel = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * channel(c.r()) + 0.7152 * channel(c.g()) + 0.0722 * channel(c.b()) + }; + let (a, b) = (luminance(a), luminance(b)); + (a.max(b) + 0.05) / (a.min(b) + 0.05) + } + /// Changing brightness keeps the interface settings made after the theme was applied, such as /// General › Show Tool Tips' delay. #[test] From e441d3ba1879e277621b9a5bf31851a2700b1030 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 15:28:59 -0800 Subject: [PATCH 05/12] Affinity: pressure curves keep dips between samples and stay within a document-wide limit - Each segment of a pressure curve is also sampled where its width stops rising or falling, so a dip between the regular samples of a long curve is kept. The segment's values are scaled into -1..1 before that is solved, so huge values in a file can't overflow it. - A curve counts as full width only within 1e-9 of it, so a curve just under full width keeps its profile. - The pressure points of all strokes in one read are limited to about a million, as for characters and table cells. Curves at full width count too, and once the limit is reached no more curves are read; the strokes past it are imported as plain strokes with a warning. --- crates/affinity/README.md | 2 +- crates/affinity/src/geometry.rs | 59 +++++++- crates/affinity/src/model/read.rs | 13 +- crates/affinity/src/paint.rs | 22 ++- .../tests/support/affinity3-signatures.txt | 6 +- .../engine/src/cmd/fileio/tests_affinity.rs | 128 +++++++++++++----- 6 files changed, 182 insertions(+), 48 deletions(-) diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 90c078023..fd2d6dc71 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -34,7 +34,7 @@ Affinity files. | Opacity, visibility, lock, names, the blend modes VectorCraft has | the same | Everything else is reported in the import warnings, one line per kind with a count, never -dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read, +dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read or that go past the million points one document may hold, transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial), special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner types other than round or straight, stars with rounded points, several fills or strokes on one object (the diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 83ea85428..945c3040f 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -172,16 +172,19 @@ fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option Option> { let s = r.s; let data = s.obj(crvd, b"Data")?; @@ -198,8 +201,11 @@ pub(crate) fn pressure(r: &Reader, crvd: ObjId) -> Option> { let mut samples = vec![(curve.start.x, curve.start.y)]; let mut from = curve.start; for [c1, c2, to] in &curve.segments { - for i in 1..=steps { - let t = f64::from(i) / f64::from(steps); + let mut ts: Vec = (1..=steps).map(|i| f64::from(i) / f64::from(steps)).collect(); + ts.extend(turning_points(from.y, c1.y, c2.y, to.y).into_iter().flatten()); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + for t in ts { let u = 1.0 - t; let at = |a: f64, b: f64, c: f64, d: f64| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d; samples.push((at(from.x, c1.x, c2.x, to.x), at(from.y, c1.y, c2.y, to.y))); @@ -218,6 +224,29 @@ pub(crate) fn pressure(r: &Reader, crvd: ObjId) -> Option> { ) } +/// The parameters strictly between 0 and 1 where the cubic Bézier with control values `a`, `b`, +/// `c` and `d` stops rising or falling (where its derivative is 0). The values are scaled into +/// -1..1 first, so huge ones can't overflow the discriminant. +fn turning_points(a: f64, b: f64, c: f64, d: f64) -> [Option; 2] { + let scale = a.abs().max(b.abs()).max(c.abs()).max(d.abs()); + if !(scale.is_finite() && scale > 0.0) { + return [None, None]; + } + let (a, b, c, d) = (a / scale, b / scale, c / scale, d / scale); + let (d0, d1, d2) = (b - a, c - b, d - c); + let (qa, qb, qc) = (d0 - 2.0 * d1 + d2, 2.0 * (d1 - d0), d0); + let inside = |t: f64| (t.is_finite() && t > 0.0 && t < 1.0).then_some(t); + if qa.abs() < 1e-12 { + return [if qb.abs() < 1e-12 { None } else { inside(-qc / qb) }, None]; + } + let disc = qb * qb - 4.0 * qa * qc; + if disc < 0.0 { + return [None, None]; + } + let root = disc.sqrt(); + [inside((-qb - root) / (2.0 * qa)), inside((-qb + root) / (2.0 * qa))] +} + struct Anchor { p: Point, inh: Option, @@ -309,3 +338,19 @@ fn corner_radii(fields: Vec<&Value>) -> Option> { .collect(), ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn turning_points_of_huge_control_values_are_found() { + let small = turning_points(1.0, -3.0, 3.0, 1.0); + let huge = turning_points(1e200, -3e200, 3e200, 1e200); + assert!(small.iter().all(Option::is_some), "{small:?}"); + for (s, h) in small.iter().zip(&huge) { + let (Some(s), Some(h)) = (s, h) else { panic!("{huge:?}") }; + assert!((s - h).abs() < 1e-12, "{small:?} {huge:?}"); + } + } +} diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs index f94de75b9..00910baab 100644 --- a/crates/affinity/src/model/read.rs +++ b/crates/affinity/src/model/read.rs @@ -26,6 +26,9 @@ const MAX_DOCUMENT_CHARS: usize = 16 << 20; /// Table cells of all tables together (each becomes a text object with its edge lines). const MAX_DOCUMENT_CELLS: usize = 1 << 18; +/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers). +const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20; + /// What a whole read may still use. Shared objects can be linked from many layers, and embedded /// documents are read with what their embedding document left, so linking or embedding the /// same content many times can't multiply the work. @@ -39,10 +42,18 @@ pub(crate) struct Budget { pub(crate) chars: usize, /// Table cells ([`MAX_DOCUMENT_CELLS`] in all). pub(crate) cells: usize, + /// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all). + pub(crate) pressure: usize, } impl Budget { - pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS }; + pub(crate) const FULL: Budget = Budget { + nodes: MAX_NODES, + pixels: MAX_LAYER_PIXELS, + chars: MAX_DOCUMENT_CHARS, + cells: MAX_DOCUMENT_CELLS, + pressure: MAX_DOCUMENT_PRESSURE_POINTS, + }; } /// Read a document: archive, `doc.dat` and the model. diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 144357ab7..cfc95b8dd 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -357,9 +357,25 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh r.warn("brush strokes (imported as plain strokes)"); } // The pressure curve sets the width along the path; one at full width all along is a plain - // stroke. - let profile = match s.obj(ldsc, b"LDeP").map(|p| crate::geometry::pressure(r, p)) { - Some(Some(points)) => (!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-3)).then_some(points), + // stroke. Every curve read, full width or not, counts against the document's points, and once + // they are used up the rest are not read. + const BEYOND: &str = "pressure strokes beyond the pressure points one document may hold (imported as plain strokes)"; + let ldep = s.obj(ldsc, b"LDeP"); + if ldep.is_some() && r.budget.pressure == 0 { + r.warn(BEYOND); + } + let profile = match ldep.filter(|_| r.budget.pressure > 0).map(|p| crate::geometry::pressure(r, p)) { + Some(Some(points)) => match r.budget.pressure.checked_sub(points.len()) { + Some(left) => { + r.budget.pressure = left; + (!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-9)).then_some(points) + } + None => { + r.budget.pressure = 0; + r.warn(BEYOND); + None + } + }, Some(None) => { r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)"); None diff --git a/crates/affinity/tests/support/affinity3-signatures.txt b/crates/affinity/tests/support/affinity3-signatures.txt index 462d32172..13e8f0c53 100644 --- a/crates/affinity/tests/support/affinity3-signatures.txt +++ b/crates/affinity/tests/support/affinity3-signatures.txt @@ -1,6 +1,6 @@ -affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=ce96643c;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)"] -affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=08a70ca5;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)"] -affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=557d4a95;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)"] +affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=32b7cf3f;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)"] +affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=c10daaea;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)"] +affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=3bc13374;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)"] affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=bc0c838d;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush strokes (imported as plain strokes)"] patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] diff --git a/crates/engine/src/cmd/fileio/tests_affinity.rs b/crates/engine/src/cmd/fileio/tests_affinity.rs index a288c5ec1..a57da79fc 100644 --- a/crates/engine/src/cmd/fileio/tests_affinity.rs +++ b/crates/engine/src/cmd/fileio/tests_affinity.rs @@ -303,40 +303,52 @@ mod native { /// A version-12 document holding an open curve with an 8 px stroke whose pressure curve (`LDeP`) /// is made of `records`. fn pressure_document(records: Vec>) -> Vec { - let line = F::Def( - 20, - vec![tag(b"PCrv"), tag(b"VNod"), tag(b"Node")], - vec![ - ( - tag(b"Crvs"), - F::Obj( - tag(b"PCvD"), - vec![( - tag(b"Data"), - F::Pos(vec![F::U8(0), F::U32(1), F::Bool(false), F::Records(18, vec![node(0.0, 0.0, 1, 0), node(100.0, 0.0, 1, 0)])]), - )], - ), - ), - (tag(b"LIFl"), F::Shared(vec![solid(24, rgba(0.0, 0.0, 0.0, 1.0))])), - ( - tag(b"LILn"), - F::Shared(vec![F::Def( - 27, - vec![tag(b"LDsc")], - vec![ - (tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(8.0))])), - (tag(b"LDSc"), F::Bool(false)), - ( - tag(b"LDeP"), - F::Obj(tag(b"CrvD"), vec![(tag(b"Data"), F::Pos(vec![F::U32(0), F::Bool(false), F::Records(18, records)]))]), + pressure_documents(records, 1) + } + + /// [`pressure_document`] with `copies` curves, all linking the first one's fill and stroke. + fn pressure_documents(records: Vec>, copies: u32) -> Vec { + let mut style = Some(( + solid(24, rgba(0.0, 0.0, 0.0, 1.0)), + F::Def( + 27, + vec![tag(b"LDsc")], + vec![ + (tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(8.0))])), + (tag(b"LDSc"), F::Bool(false)), + (tag(b"LDeP"), F::Obj(tag(b"CrvD"), vec![(tag(b"Data"), F::Pos(vec![F::U32(0), F::Bool(false), F::Records(18, records)]))])), + ], + ), + )); + let lines = (0..copies) + .map(|i| { + let (fill, line) = style.take().unwrap_or((F::Ref(24), F::Ref(27))); + F::Def( + if i == 0 { 20 } else { 1000 + i }, + vec![tag(b"PCrv"), tag(b"VNod"), tag(b"Node")], + vec![ + ( + tag(b"Crvs"), + F::Obj( + tag(b"PCvD"), + vec![( + tag(b"Data"), + F::Pos(vec![ + F::U8(0), + F::U32(1), + F::Bool(false), + F::Records(18, vec![node(0.0, 0.0, 1, 0), node(100.0, 0.0, 1, 0)]), + ]), + )], ), - ], - )]), - ), - ], - ); - let spread = - F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"Chld"), F::Shared(vec![line]))]); + ), + (tag(b"LIFl"), F::Shared(vec![fill])), + (tag(b"LILn"), F::Shared(vec![line])), + ], + ) + }) + .collect(); + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"Chld"), F::Shared(lines))]); let doc = synth::stream(&[ (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(72.0))])), (tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])), @@ -402,6 +414,56 @@ mod native { assert!(r.warnings.iter().all(|w| !w.contains("pressure")), "{:?}", r.warnings); } + #[test] + fn a_dip_between_the_samples_of_a_long_pressure_curve_is_kept() { + // 256 segments at full width, except one whose handles sit at widths 0 and 2: its width is + // 1 at its middle and end but 0.71875 a quarter of the way along. + let mut curve = vec![node(0.0, 1.0, 4, 0)]; + for i in 1..=256 { + let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0); + let (h1, h2) = if i == 100 { (0.0, 2.0) } else { (1.0, 1.0) }; + curve.extend([node(a + (b - a) / 3.0, h1, 0, 1), node(b - (b - a) / 3.0, h2, 0, 2), node(b, 1.0, 4, 0)]); + } + let profile = stroke_of(&load("dip.af", &pressure_document(curve)).unwrap().doc).profile.unwrap_or_else(|| panic!("the dip is kept")); + let narrowest = profile.points.iter().map(|p| p.1).fold(1.0, f64::min); + assert!((narrowest - 0.71).abs() < 0.02, "{narrowest}"); + } + + #[test] + fn a_pressure_curve_just_under_full_width_is_kept() { + let curve = vec![node(0.0, 0.9995, 4, 0), node(1.0, 0.9995, 4, 0)]; + let profile = stroke_of(&load("near.af", &pressure_document(curve)).unwrap().doc).profile.unwrap_or_else(|| panic!("the curve is kept")); + assert_eq!(profile.at(0.5), (0.9995, 0.9995)); + } + + #[test] + fn pressure_curves_stay_within_the_points_of_one_document() { + // One 256-segment curve linked from 2100 strokes: more than a million points in all. + let mut curve = vec![node(0.0, 1.0, 4, 0)]; + for i in 1..=256 { + let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0); + curve.extend([node(a + (b - a) / 3.0, 0.0, 0, 1), node(b - (b - a) / 3.0, 0.0, 0, 2), node(b, 1.0, 4, 0)]); + } + let r = load("many.af", &pressure_documents(curve, 2100)).unwrap(); + let mut profiles = Vec::new(); + r.doc.walk(|n| { + if let Some(AppearanceItem::Stroke(k)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + profiles.push(k.profile.as_ref().map_or(0, |p| p.points.len())); + } + }); + assert_eq!(profiles.len(), 2100); + assert!(profiles.iter().sum::() <= 1 << 20, "{}", profiles.iter().sum::()); + assert!(profiles.iter().filter(|n| **n > 0).count() > 1000); + assert!(profiles.contains(&0)); + assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings); + + // Curves at full width count too, so linking one from many strokes can't repeat its work + // without bound. + let flat: Vec> = (0..=768).map(|i| node(f64::from(i) / 768.0, 1.0, if i % 3 == 0 { 4 } else { 0 }, (i % 3) as u8)).collect(); + let r = load("flat-many.af", &pressure_documents(flat, 2100)).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings); + } + #[test] fn native_documents_save_reopen_export_place_and_template() { let bytes = document(Method::Zstd); From 2adf5a9a61fa877fb5f452620e691532dd5bb2d5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 16:24:33 -0800 Subject: [PATCH 06/12] Symbols: Replace Symbol clears the old symbol's dynamic overrides An override on a dynamic symbol instance names an object of its symbol's art by path. Replace Symbol kept every override whose path the new symbol's art also had, so a change made to one object of the old symbol was applied to an unrelated object of the new one. An instance given a different symbol now drops its overrides; an instance replaced with its own symbol keeps them. Undo brings them back. --- crates/engine/src/cmd/brushsym.rs | 2 +- crates/engine/src/cmd/brushsym/symbols.rs | 11 ++++++++--- crates/engine/src/tests/dynamic_symbols.rs | 22 ++++++++++++++++++++++ docs/mcp.md | 3 ++- docs/target-app-parity.md | 2 +- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index a1f03b6a3..7386f7718 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -247,7 +247,7 @@ pub fn specs() -> Vec { "Replace Symbol", [], None, - "{name, ids?|id?} swap the symbol of the selected instances (they keep their appearance changes to the objects at paths a dynamic new symbol's art has) → {ids}", + "{name, ids?|id?} swap the symbol of the selected instances (an instance given a different symbol drops its appearance changes; one given its own symbol keeps them) → {ids}", has_selection, symbol_replace ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 0b69b7f8a..9bfbb902a 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -590,14 +590,19 @@ pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result { s.edit("Replace Symbol", |d, _| { let new = natural_size(d, &name); for id in &ids { - let old = match d.node(*id).map(|n| &n.kind) { - Some(NodeKind::SymbolInstance { symbol, .. }) => natural_size(d, symbol), + let (old, other) = match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::SymbolInstance { symbol, .. }) => (natural_size(d, symbol), *symbol != name), _ => continue, }; - if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) { + let Some(n) = d.node_mut(*id) else { continue }; + if let NodeKind::SymbolInstance { symbol, xf } = &mut n.kind { *symbol = name.clone(); *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); } + // An override's path names an object of the old symbol's art. + if other && !n.extras().symbol_overrides.is_empty() { + n.extras_mut().symbol_overrides.clear(); + } } fit_overrides(d, &ids); Ok(()) diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index aff7690c1..1c46d5302 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -111,6 +111,28 @@ fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() { assert_eq!(overrides(&s, inst), 0); } +#[test] +fn replace_symbol_drops_the_changes_made_for_the_old_symbol() { + let mut s = session(); + // "Other": two blue squares in the same places, the instance taken off the canvas. + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + let first = id(&s.execute("symbol.new", &json!({"name": "Other", "ids": [a.0, b.0], "type": "dynamic"})).unwrap()); + s.execute("edit.clear", &json!({"ids": [first.0]})).unwrap(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap(); + // The same symbol: nothing changes. + s.execute("symbol.replace", &json!({"name": "Pair", "ids": [inst.0]})).unwrap(); + assert_eq!(overrides(&s, inst), 1); + assert_eq!(pixel(&s, 80, 30), RED); + s.execute("symbol.replace", &json!({"name": "Other", "ids": [inst.0]})).unwrap(); + assert_eq!(overrides(&s, inst), 0, "the change named an object of Pair's art"); + assert_eq!(pixel(&s, 80, 30), BLUE, "Other's art draws as the symbol has it"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Pair")); + assert_eq!(overrides(&s, inst), 1); + assert_eq!(pixel(&s, 80, 30), RED); +} + #[test] fn reset_and_static_drop_the_changes_and_undo_brings_them_back() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index f1e55b9fd..c059e6762 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1586,7 +1586,8 @@ already walks Image Trace. Short agent entry points: a path that names no object. `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties panel's Reset). Redefining the symbol (`symbol.update`) reshapes every instance and keeps each change whose path the new art still has; `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` edits the symbol's own - art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them. + art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them, and + so does `symbol.replace` with a different symbol (a replace with the instance's own symbol keeps them). ```json {"name":"run_command","arguments":{"command":"symbol.instance.setAppearance","params":{"id":42,"path":[1],"fill":"#d0021b","opacity":80}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 40e1d1267..edd3ca93d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1454,7 +1454,7 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov ## Dynamic symbols (2026-11-01) -Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: Direct Selection of an object inside a dynamic instance on the canvas (with the Fill/Stroke controls then setting its change), and reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. +Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them, and Replace Symbol drops them when it gives the instance a different symbol. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: Direct Selection of an object inside a dynamic instance on the canvas (with the Fill/Stroke controls then setting its change), and reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. ## Document Tools add-on: Export All Open Documents (2026-11-01) From 589c56da2280d9ff9512b038ef0e5f3600567184 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 16:19:16 -0800 Subject: [PATCH 07/12] PDF export: a white stop linked to a process swatch keeps a spot gradient on its ink A gradient of one spot ink's tints and paper white is written as a Separation shading in that ink, with each white stop at 0% of it. A white stop linked to a swatch that is not a spot color (a global White swatch, for example) did not count as paper white. In an RGB document the spot ink was then dropped: a Classic gradient was written in the stops' own colors, and a Perceptual one was exported as an image in process colors. Such a stop now counts as paper white in the Separation and DeviceN paths and in the checks that pick them, as an unlinked white stop does. A stop linked to a white spot color is still a stop of that ink. --- crates/engine/src/tests/linked_stops.rs | 29 +++++++++++++++++++++++++ crates/pdf/src/export/paint.rs | 27 +++++++++++++---------- docs/target-app-parity.md | 2 ++ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 666d317fe..61bc3a4d2 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -140,6 +140,35 @@ fn spot_gradients_export_as_separation_shadings() { assert!(warnings.iter().any(|w| w.contains("spot")), "{warnings:?}"); } +#[test] +fn white_stops_linked_to_process_swatches_are_paper_in_spot_gradients() { + let (mut s, _) = setup(); + run(&mut s, "swatch.new", json!({"name": "Paper", "color": "#ffffff", "global": true})); + let ink_to_paper = json!({"stops": [{"offset": 0, "swatch": "Ink", "tint": 80}, {"offset": 1, "swatch": "Paper"}]}); + // As Ink → unlinked white: a Separation shading from 80 % to 0 %. + run(&mut s, "paint.editGradient", ink_to_paper.clone()); + let (pdf, warnings) = pdf_of(&s); + assert!(pdf.contains("/ShadingType 2") && pdf.contains("/Separation/Ink/DeviceCMYK"), "a Separation shading"); + assert!(!pdf.contains("/DeviceN") && pdf.contains("/C0[0.8]/C1[0]"), "the shading's tints"); + assert!(!warnings.iter().any(|w| w.contains("spot")), "{warnings:?}"); + // A Perceptual one too. + run(&mut s, "paint.editGradient", with(ink_to_paper, json!({"method": "perceptual"}))); + let (pdf, _) = pdf_of(&s); + assert!(!pdf.contains("/Subtype/Image") && !pdf.contains("/DeviceN") && pdf.contains("/Separation/Ink/DeviceCMYK"), "Perceptual"); + // With process yellow, the linked white stop adds no ink: Yellow and Ink only. + let yellow = json!({"c": 0, "m": 0, "y": 0.6, "k": 0}); + let three = + json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 0.5, "swatch": "Paper"}, {"offset": 1, "color": yellow}], "method": "classic"}); + run(&mut s, "paint.editGradient", three); + let (pdf, _) = pdf_of(&s); + assert!(pdf.contains("/DeviceN[/Yellow/Ink]/DeviceCMYK") && pdf.contains("/C0[0 1]/C1[0 0]"), "Yellow and Ink"); + // A white spot ink is an ink, not paper: a DeviceN shading of both spot inks. + run(&mut s, "swatch.new", json!({"name": "Opaque", "color": "#ffffff", "spot": true})); + run(&mut s, "paint.editGradient", json!({"stops": [{"offset": 0, "swatch": "Ink"}, {"offset": 1, "swatch": "Opaque"}]})); + let (pdf, _) = pdf_of(&s); + assert!(pdf.contains("/DeviceN[/Ink/Opaque]/DeviceCMYK"), "a white spot ink keeps its plate"); +} + #[test] fn spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() { let (mut s, _) = setup(); diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 665efe272..b68066ef6 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -69,13 +69,12 @@ impl Exporter<'_> { /// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink. fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?; - let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3]; let tints: Option> = g .stops .iter() .map(|s| match s.swatch.as_deref() { Some(n) if n == ink => Some(s.tint), - None if paper(&s.color) => Some(0.0), + _ if self.paper_stop(s) => Some(0.0), _ => None, }) .collect(); @@ -106,7 +105,6 @@ impl Exporter<'_> { /// the gradient is then written in the colors of its stops. fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process}; - let paper = |c: &Color| c.to_rgba8(1.0)[..3] == [255; 3]; let doc = self.doc; fn spot_of<'s>(doc: &vectorcraft_doc::Document, s: &'s vectorcraft_color::GradientStop) -> Option<&'s str> { s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)) @@ -128,7 +126,7 @@ impl Exporter<'_> { let i = spots.iter().position(|(m, _)| m == n)?; v[4 + i] = s.tint; } - None if s.swatch.is_none() && paper(&s.color) => {} + None if self.paper_stop(s) => {} None => match self.page_color(&s.color) { Color::Cmyk { c, m, y, k } => v[..4].copy_from_slice(&[c, m, y, k]), Color::Gray { k } => v[3] = k, @@ -348,25 +346,30 @@ impl Exporter<'_> { Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into()) } - /// The spot swatch whose tints are every stop of `g` that isn't paper white, unlinked; `None` - /// when no stop is linked to a spot swatch or a stop has another color. + /// Is stop `s` paper white: white, unlinked or linked to a swatch that isn't a spot color (a + /// white spot color is an ink of its own)? It adds no ink to a Separation or DeviceN shading. + fn paper_stop(&self, s: &vectorcraft_color::GradientStop) -> bool { + !s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)) && s.color.to_rgba8(1.0)[..3] == [255; 3] + } + + /// The spot swatch whose tints are every stop of `g` that isn't paper white + /// ([`Self::paper_stop`]); `None` when no stop is linked to a spot swatch or a stop has + /// another color. fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> { let spot = |s: &'g vectorcraft_color::GradientStop| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)); let ink = g.stops.iter().find_map(spot)?; - let paper = |s: &vectorcraft_color::GradientStop| s.swatch.is_none() && s.color.to_rgba8(1.0)[..3] == [255; 3]; - g.stops.iter().all(|s| spot(s) == Some(ink) || paper(s)).then_some(ink) + g.stops.iter().all(|s| spot(s) == Some(ink) || self.paper_stop(s)).then_some(ink) } /// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): is a stop - /// linked to a spot swatch, and is every other stop paper white, unlinked, or written in CMYK - /// or gray? + /// linked to a spot swatch, and is every other stop paper white ([`Self::paper_stop`]) or + /// written in CMYK or gray? fn device_n_inks_only(&self, g: &vectorcraft_color::Gradient) -> bool { let doc = self.doc; let cmyk_doc = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; let spot = |s: &vectorcraft_color::GradientStop| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot)); let inks = |s: &vectorcraft_color::GradientStop| { - (s.swatch.is_none() && s.color.to_rgba8(1.0)[..3] == [255; 3]) - || matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. }) + self.paper_stop(s) || matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. }) }; g.stops.iter().any(spot) && g.stops.iter().all(|s| spot(s) || inks(s)) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 40e1d1267..3fc42861d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1280,6 +1280,8 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html): "Gradient colors can be assigned as CMYK process colors, RGB colors, or a Spot color."; [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html): "a separate plate is created for each spot color"). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there (VectorCraft's own rule). Dithered fills, freeform gradients and Perceptual gradients that mix a spot color with RGB or Lab stops are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve` and `ink_mixes_follow_the_perceptual_curve`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`. +In these Separation and DeviceN shadings, a white stop linked to a swatch that is not a spot color (a global White swatch, for example) is paper white, 0% of the ink, as an unlinked white stop is, so a gradient of one spot ink and such a stop stays a Separation shading; a stop linked to a white spot color stays a stop of that ink (VectorCraft's own rule). Test: `engine::tests::linked_stops::white_stops_linked_to_process_swatches_are_paper_in_spot_gradients`. + ## Artwork Cleanup add-on (2026-10-30) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Artwork Cleanup and its From 79846064718ee0bac6e9581010c6ce45e1bc5d46 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 16:26:47 -0800 Subject: [PATCH 08/12] EPS: spot inks named All or None print as spot inks A Separation space whose colorant is named All paints every plate, and one named None paints nothing (ISO 32000-1:2008, 8.6.6.4). EPS export wrote a spot swatch's name as its colorant, so a spot color named All printed on every plate and one named None did not print. Such an ink is now written as All Spot or None Spot, numbered All Spot 2 and on when a spot swatch already has that name, in its Separation space, DeviceN spaces and the %%DocumentCustomColors and %%CMYKCustomColor comments. At PostScript Level 3, a gradient that mixes one of these inks with another spot ink or with process or gray stops is now a DeviceN shading instead of process colors. Registration still writes All. --- crates/eps/src/scene.rs | 62 ++++++++++++++++++++++++++++----------- crates/eps/src/tests.rs | 46 +++++++++++++++++++++++++++-- docs/target-app-parity.md | 2 +- 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index b5437b670..e07e318b1 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -142,8 +142,9 @@ pub(crate) struct Scene<'a> { cms: Arc, body: String, setup: String, - /// Spot colour spaces defined in the setup (`VCsp`): swatch name and CMYK equivalent. - spots: Vec<(String, [f32; 4])>, + /// Spot colour spaces defined in the setup (`VCsp`): swatch name, colorant name + /// ([`Scene::colorant_name`]) and CMYK equivalent. + spots: Vec<(String, String, [f32; 4])>, /// DeviceN color spaces defined in the setup (`VCdn`), as their definitions. device_n: Vec, warnings: Vec, @@ -191,7 +192,12 @@ impl<'a> Scene<'a> { } fn page(self) -> Page { - let custom = self.spots.iter().filter(|(n, _)| n != vectorcraft_color::swatch::REGISTRATION).cloned().collect(); + let custom = self + .spots + .iter() + .filter(|(n, ..)| n != vectorcraft_color::swatch::REGISTRATION) + .map(|(_, colorant, cmyk)| (colorant.clone(), *cmyk)) + .collect(); Page { setup: self.setup, body: self.body, custom, warnings: self.warnings } } @@ -235,7 +241,7 @@ impl<'a> Scene<'a> { /// The index of the Separation colour space of spot swatch (or Registration) `name`, defined /// in the setup the first time; `None` when `name` is neither. fn spot_space(&mut self, name: &str) -> Option { - if let Some(i) = self.spots.iter().position(|(n, _)| n == name) { + if let Some(i) = self.spots.iter().position(|(n, ..)| n == name) { return Some(i); } let registration = name == vectorcraft_color::swatch::REGISTRATION; @@ -249,17 +255,37 @@ impl<'a> Scene<'a> { }; let i = self.spots.len(); let _ = write!(self.setup, "/VCsp{i} [/Separation "); - if registration { + let colorant = if registration { self.setup.push_str("/All /DeviceCMYK {dup dup dup}"); + "All".to_string() } else { + let colorant = self.colorant_name(name); let [c, m, y, k] = cmyk.map(|v| ps::num(f64::from(v))); - let _ = write!(self.setup, "{} cvn /DeviceCMYK {{dup {c} mul exch dup {m} mul exch dup {y} mul exch {k} mul}}", ps::string(name)); - } + let _ = write!(self.setup, "{} cvn /DeviceCMYK {{dup {c} mul exch dup {m} mul exch dup {y} mul exch {k} mul}}", ps::string(&colorant)); + colorant + }; self.setup.push_str("] def\n"); - self.spots.push((name.to_string(), cmyk)); + self.spots.push((name.to_string(), colorant, cmyk)); Some(i) } + /// The colorant name spot swatch `name` is written under: its own name, except that `All` + /// (every plate in PostScript) and `None` (no plate) become `All Spot` and `None Spot`, + /// numbered `All Spot 2`, `All Spot 3`, … past the names of the document's spot swatches. + fn colorant_name(&self, name: &str) -> String { + if name != "All" && name != "None" { + return name.to_string(); + } + let taken = |c: &str| self.doc.swatches_iter().any(|s| s.spot && s.name == c); + let mut colorant = format!("{name} Spot"); + let mut n = 2; + while taken(&colorant) { + colorant = format!("{name} Spot {n}"); + n += 1; + } + colorant + } + fn set_ink(&mut self, ink: Ink) { match ink { Ink::Process(space, v) => { @@ -819,26 +845,28 @@ impl<'a> Scene<'a> { } /// A DeviceN color space of process inks `process` (indices into CMYK) and spot inks `names`, - /// defined in the setup the first time, each spot ink with the CMYK equivalent its Separation - /// space has. `None` when a spot ink has no Separation space, two colorants share a name, a - /// spot ink has the name of a process ink, `All` or `None`, or there are more than - /// [`MAX_DEVICE_N`] colorants. + /// defined in the setup the first time, each spot ink under the colorant name and with the + /// CMYK equivalent its Separation space has. `None` when a spot ink has no Separation space, + /// two colorants share a name, a spot ink's colorant has the name of a process ink, `All` or + /// `None`, or there are more than [`MAX_DEVICE_N`] colorants. fn device_n_space(&mut self, process: &[usize], names: &[String]) -> Option { const PROCESS: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"]; if process.len() + names.len() > MAX_DEVICE_N { return None; } - let mut colorants: Vec<&str> = process.iter().map(|&i| PROCESS[i]).collect(); + let mut colorants: Vec = process.iter().map(|&i| PROCESS[i].to_string()).collect(); let mut weights: Vec<[f32; 4]> = process.iter().map(|&i| [0, 1, 2, 3].map(|j| if i == j { 1.0 } else { 0.0 })).collect(); - for (i, name) in names.iter().enumerate() { - if PROCESS.contains(&name.as_str()) || name == "All" || name == "None" || names[..i].contains(name) { + let spots: Vec = names.iter().map(|n| self.colorant_name(n)).collect(); + for (i, name) in spots.iter().enumerate() { + if PROCESS.contains(&name.as_str()) || name == "All" || name == "None" || spots[..i].contains(name) { return None; } } for name in names { - colorants.push(name); let i = self.spot_space(name)?; - weights.push(self.spots.get(i)?.1); + let (_, colorant, cmyk) = self.spots.get(i)?; + colorants.push(colorant.clone()); + weights.push(*cmyk); } let mut def = String::from("[/DeviceN ["); for (i, c) in colorants.iter().enumerate() { diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 5b89922d5..2067a7d9c 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -381,8 +381,50 @@ fn a_gradient_mixing_a_spot_ink_with_rgb_is_written_without_the_ink() { } #[test] -fn spot_inks_named_like_process_inks_all_or_none_are_not_device_n_colorants() { - for name in ["Cyan", "Black", "All", "None"] { +fn spot_inks_named_all_or_none_are_written_under_names_of_their_own() { + let rect = |d: &mut Document, fill: &str, stroke: &str| { + let solid = |name: &str| Paint::Solid { color: GOLD, swatch: Some(name.into()), tint: 1.0 }; + path(d, shapes::rectangle(Rect::new(50.0, 60.0, 150.0, 100.0)), solid(fill), solid(stroke), 2.0) + }; + for (name, colorant) in [("All", "All Spot"), ("None", "None Spot")] { + // A fill of the spot ink and a Registration stroke. + let mut d = doc_with(|_| vec![]); + d.swatches.push(Swatch { name: name.into(), paint: Paint::solid(GOLD), global: true, spot: true }); + let n = rect(&mut d, name, vectorcraft_color::swatch::REGISTRATION); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let ps = text(&export(&d, &opts(&d), None, None).unwrap()); + assert_eq!(dsc(&ps, "DocumentCustomColors"), Some(format!("({colorant})")), "{ps}"); + assert_eq!(dsc(&ps, "CMYKCustomColor"), Some(format!("0 0.2 0.8 0.1 ({colorant})")), "{ps}"); + assert!(ps.contains(&format!("[/Separation ({colorant}) cvn /DeviceCMYK {{dup 0 mul")), "{name}: {ps}"); + assert!(ps.contains("[/Separation /All /DeviceCMYK {dup dup dup}]"), "Registration prints on every plate: {ps}"); + assert!(!ps.contains(&format!("({name}) cvn")), "{name}: {ps}"); + checked_page(&ps); + + // In a gradient with another spot ink, the DeviceN space has the same colorant. + let mut d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, name, LEAF, 1.0)]); + d.swatches.push(Swatch { name: name.into(), paint: Paint::solid(LEAF), global: true, spot: true }); + let out = export(&d, &opts(&d), None, None).unwrap(); + let ps = text(&out); + assert!(ps.contains(&format!("/VCdn0 [/DeviceN [(Gold Ink) cvn ({colorant}) cvn ] /DeviceCMYK")), "{name}: {ps}"); + assert!(ps.contains(&format!("%%DocumentCustomColors: (Gold Ink)\n%%+ ({colorant})\n")), "{name}: {ps}"); + assert!(!out.warnings.iter().any(|w| w.contains("spot")), "{:?}", out.warnings); + } + + // A spot swatch already named All Spot keeps its name, and All is numbered past it. + let mut d = doc_with(|_| vec![]); + for name in ["All", "All Spot"] { + d.swatches.push(Swatch { name: name.into(), paint: Paint::solid(GOLD), global: true, spot: true }); + } + let n = rect(&mut d, "All", "All Spot"); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let ps = text(&export(&d, &opts(&d), None, None).unwrap()); + assert!(ps.contains("[/Separation (All Spot 2) cvn ") && ps.contains("[/Separation (All Spot) cvn "), "{ps}"); + assert!(ps.contains("%%DocumentCustomColors: (All Spot 2)\n%%+ (All Spot)\n"), "{ps}"); +} + +#[test] +fn spot_inks_named_like_process_inks_are_not_device_n_colorants() { + for name in ["Cyan", "Black"] { // Gold Ink to the spot ink, with no process ink in the stops. let mut d = spot_gradient_doc(vec![spot_stop(0.0, "Gold Ink", GOLD, 1.0), spot_stop(1.0, name, LEAF, 1.0)]); d.swatches.retain(|s| s.name != name); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 40e1d1267..63a9afd17 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1082,7 +1082,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Open and Place dialogs list PostScript `.ps` files and `.epsf` files. A `.ps` file opens and places through the EPS reader (its first page), also when its header is only `%!`, as the `ps` format, which exports don't write (Print to PostScript does); an `.epsf` file opens and places as EPS. Copied in a file manager, these files paste as EPS, and `clipboard.importEps` also reads PostScript whose header is only `%!` (`crates/engine/src/cmd/fileio/tests_epsimport.rs`). `document.pdfInfo` reads an EPS, a `.ps` file or a `.ai` in PostScript form as one page the size of its bounding box (a US Letter page without one), and its `thumbnail: 1` is a PNG of what that page prints (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). - EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards; a document with no visible art gets only the artboard files (`crates/engine/src/cmd/fileio/tests_eps.rs`). - EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow, Black, All or None, are also written in process colors with a warning (`crates/eps/src/tests.rs`). + EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow or Black, are also written in process colors with a warning (`crates/eps/src/tests.rs`). A spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4), is written as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients and DSC comments; Registration still prints on every plate (`tests::spot_inks_named_all_or_none_are_written_under_names_of_their_own`). - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`). - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. The last valid `resc` or `resd` resolution value wins on each axis; an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). From 880a07805365add3b91d36db532b19f7127bcef3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 16:27:59 -0800 Subject: [PATCH 09/12] Legacy .ai: type with rotated characters is written as outlines that keep the rotation A version 7 text object has no arbitrary-angle character rotation: its character attributes include baseline shift (Ts), scaling (Tz) and tracking (Tt), Tv only turns Roman characters 90 degrees in vertical text, and the positioning operators such as Tm are ignored on reading. Type with Character Rotation was written as editable text with the rotation left out, so rotated characters opened upright. Such type is now written as its glyphs' outlines, the way type 8-bit text can't hold already is, with the warning "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text". The outlines keep each character's rotation, baseline shift and scale. Type without rotated characters, and type turned a whole number of times, is still written as text. Docs: docs/file-format-parity.md, docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/legacy_ai.rs | 2 +- crates/engine/src/cmd/fileio/native.rs | 2 +- crates/eps/src/legacy_ai.rs | 10 ++-- crates/eps/src/legacy_ai/encoding.rs | 20 +++++-- crates/eps/src/legacy_ai/tests.rs | 66 +++++++++++++++++++++++ crates/eps/src/legacy_ai/writer.rs | 17 ++++-- docs/file-format-parity.md | 4 +- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 9 files changed, 111 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 7cbbda2fe..1d9d23590 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -28,7 +28,7 @@ //! layer's hidden objects go to a hidden layer of their own just above it, with `omit` they are //! left out, with `visible` they show where they are. Type is written as text the format's reader //! lays out again, in the fonts it names; `outlineText` writes its outlines instead (as type that -//! 8-bit text can't hold always is). +//! 8-bit text can't hold and type with rotated characters always are). use std::sync::Arc; diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index d7dbbe5a6..948556c7a 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -121,7 +121,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "outlineText", ty: "boolean", default: "false", - description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is always outlines", + description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines", }, ]; diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index fc7d6e9d9..2e8f5e03e 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -50,7 +50,8 @@ //! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden //! layer they stay where they are). Type that 8-bit text can't hold (characters outside //! Windows-1252, vertical type, ruby, inline graphics, gradient or pattern fills) is written as its -//! outlines, and all type with [`LegacyAiOptions::outline_text`]. Layer, spot color and gradient +//! outlines, and so is type with rotated characters (a text object has no character rotation), and +//! all type with [`LegacyAiOptions::outline_text`]. Layer, spot color and gradient //! names and the title are written in Windows-1252 too, and a character outside it is written as //! `?`. Two spot colors or two gradients whose names become the same that way are numbered (`? 2`). //! @@ -76,9 +77,9 @@ mod encoding; mod prolog; mod writer; -#[cfg(test)] -use encoding::writable; use encoding::{gradient_def, latin_string}; +#[cfg(test)] +use encoding::{rotated, writable}; pub(crate) use prolog::PROCSET; use prolog::{PROLOG, PS_HEADER, fonts}; use writer::Writer; @@ -94,7 +95,8 @@ const HEX_LINE: usize = 60; pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)"; pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines"; -pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs, bulleted and numbered lists) are left out"; +pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)"; +pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs, bulleted and numbered lists) are left out"; pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)"; pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)"; diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index afa6a9b35..b8055c32f 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -80,10 +80,25 @@ pub(super) fn latin_string(name: &str) -> String { ps::bytes(&bytes) } -/// Can type `t` be written as text: horizontal, without ruby (which a text object can't hold), its +/// Can type `t` be written as text: 8-bit text can hold it ([`holds_as_text`]) and no character +/// is rotated ([`rotated`])? +pub(super) fn writable(t: &TextObject) -> bool { + holds_as_text(t) && !rotated(t) +} + +/// Has type `t` visible characters with Character Rotation? A legacy `.ai` text object's character +/// attributes have none that turns a character (§15), so such type is written as its outlines. +pub(super) fn rotated(t: &TextObject) -> bool { + t.runs.iter().any(|r| { + let turn = r.style.rotation; + turn.is_finite() && turn.rem_euclid(360.0) != 0.0 && r.text.chars().any(|c| !c.is_whitespace()) + }) +} + +/// Can 8-bit text hold type `t`: horizontal, without ruby (which a text object can't hold), its /// runs plain characters (no inline graphics) that 8-bit text can hold, filled and stroked with /// colours? -pub(super) fn writable(t: &TextObject) -> bool { +pub(super) fn holds_as_text(t: &TextObject) -> bool { let solid = |p: &Paint| matches!(p, Paint::Solid { .. } | Paint::None); !t.vertical && t.ruby.is_empty() @@ -103,7 +118,6 @@ pub(super) fn lost_attributes(t: &TextObject) -> bool { || s.strikethrough || s.small_caps.is_some() || s.position != CharPosition::Normal - || s.rotation != 0.0 || !s.features.is_empty() || !s.font_axes.is_empty() }; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 1c750d983..2af528a98 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1150,6 +1150,72 @@ fn type_8_bit_text_cant_hold_and_type_asked_for_as_outlines_are_outlines() { assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings); } +/// A legacy `.ai` text object has no character rotation: point type and area type with a rotated +/// character between ordinary ones (turned either way, shifted off the baseline and scaled) are +/// written as their outlines, with the warning, and read back as paths where the laid-out glyphs +/// are, the rotated character turned. A full turn writes text. +#[test] +fn type_with_rotated_characters_is_outlines_that_keep_the_rotation() { + use vectorcraft_doc::{CharStyle, TextKind, TextObject, TextRun}; + let typed = |turn: f64, area: bool| { + let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "HE", style(30.0, red())); + let turned = CharStyle { rotation: turn, baseline_shift: 4.0, h_scale: 120.0, v_scale: 80.0, ..style(30.0, red()) }; + t.runs.push(TextRun::new("L", turned)); + t.runs.push(TextRun::new("TH", style(30.0, red()))); + if area { + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)) }; + t.xf = Affine::translate((20.0, 40.0)); + } + t + }; + // Each run's laid-out glyphs' box, in the document. + let boxes = |t: &TextObject| -> Vec { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + (0..t.runs.len()) + .map(|i| { + let mut bp = kurbo::BezPath::new(); + for g in lay.glyphs.iter().filter(|g| g.run == i) { + bp.extend(g.outline.iter()); + } + bp.apply_affine(t.xf); + bp.bounding_box() + }) + .collect() + }; + let near = |a: kurbo::Rect, b: kurbo::Rect| (a.x0 - b.x0).abs().max((a.y0 - b.y0).abs()).max((a.x1 - b.x1).abs()).max((a.y1 - b.y1).abs()); + for (turn, area) in [(30.0, false), (-45.0, true)] { + let t = typed(turn, area); + assert!(rotated(&t)); + let want = boxes(&t); + let unturned = boxes(&typed(0.0, area)); + assert!(near(want[1], unturned[1]) > 1.0, "the turn moves the glyph: {:?} {:?}", want[1], unturned[1]); + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == ROTATED_TYPE), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == TYPE || w == TYPE_ATTRIBUTES), "{:?}", out.warnings); + assert!(!String::from_utf8_lossy(&out.bytes).contains(" To\n"), "not a text object"); + let back = crate::import(&out.bytes).unwrap().document; + assert!(texts(&back).is_empty(), "no type reads back"); + let parts = children(&children(&back.layers[0])[0]); + assert_eq!(parts.len(), 3, "one compound path for each run"); + for (i, part) in parts.iter().enumerate() { + let b = part.visual_bounds().unwrap(); + assert!(near(b, want[i]) < 0.05, "run {i} at {turn}°: {b:?}, laid out at {:?}", want[i]); + } + } + // A full turn draws the character upright: text. + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(typed(360.0, false)))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = write(&doc); + assert!(String::from_utf8_lossy(&out.bytes).contains(" To\n"), "a text object"); + assert!(!out.warnings.iter().any(|w| w == ROTATED_TYPE), "{:?}", out.warnings); +} + /// Ruby (furigana), which a text object can't hold, makes type 8-bit text could hold outlines, /// with the reading's glyphs among them, where the canvas draws them (the file prints as the /// document draws). diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 9879a51ab..981ab610a 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -1,12 +1,13 @@ use super::encoding::{ - Ink, average, direct_fill, direct_stroke, finite, font_of, invertible, latin_string, lost_attributes, segment_point, unit, writable, + Ink, average, direct_fill, direct_stroke, finite, font_of, holds_as_text, invertible, latin_string, lost_attributes, rotated, segment_point, + unit, writable, }; use super::{ Affine, AnchorKind, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, LineCap, LineJoin, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, - Paint, ParaDirection, ParaStyle, PathData, Point, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, - TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, latin, ps, stroke, + Paint, ParaDirection, ParaStyle, PathData, Point, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, + TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, latin, ps, stroke, }; pub(super) struct Writer<'a> { @@ -719,7 +720,8 @@ impl<'a> Writer<'a> { // ---------- type ---------- - /// Type: a text object when the format can hold it as text, else its outlines. A run whose + /// Type: a text object when the format can hold it as text, else its outlines (type with + /// rotated characters among them, which keeps their rotation). A run whose /// font size measures a height other than the em box is written at the em size it is drawn /// at ([`vectorcraft_text::FontDb::font_heights_resolved`]). fn text(&mut self, t: &TextObject) { @@ -732,7 +734,12 @@ impl<'a> Writer<'a> { if writable(t) { return self.text_object(t); } - self.warn(TYPE); + if !holds_as_text(t) { + self.warn(TYPE); + } + if rotated(t) { + self.warn(ROTATED_TYPE); + } } self.outlines(t); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c868252dd..11440516d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -65,7 +65,9 @@ private editing data. VectorCraft: `transparentImages: "flatten"|"omit"`). Type is written as editable text objects (point type, area type and type on a path, with their fonts' PostScript names, sizes, colours, alignment, indents, leading, tracking and scaling), which the reading app sets in those fonts; characters outside Windows-1252, vertical type, ruby, inline - graphics and gradient or pattern fills make that type outlines, and Text: Outlines (`outlineText: true`) writes + graphics and gradient or pattern fills make that type outlines, and so do rotated characters (Character Rotation, + which a version 7 text object can't hold: the outlines keep the rotation, and the type is no longer editable text + in the file), and Text: Outlines (`outlineText: true`) writes all type as outlines. The format can hide only layers: hidden objects go to a hidden layer of their own above their layer, are omitted, or are made visible (Hidden Objects, `hiddenObjects: "layer"|"omit"|"visible"`). Sublayers become groups; appearances, effects and brushes are expanded; object names are left out; layer diff --git a/docs/mcp.md b/docs/mcp.md index f1e55b9fd..57f990dfe 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -843,7 +843,10 @@ PostScript form, which keeps layers, groups, compound paths and clipping groups of `range` (default all; `{name}-{artboard}.ai` when several, listed in the result's `files`), with `transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity or a blending mode, `hiddenObjects: "layer"` (default: a hidden layer of each layer's hidden objects), `"omit"` or `"visible"`, and -`outlineText: true` to write type as outlines instead of text objects. An image that can't be decoded is left out of +`outlineText: true` to write type as outlines instead of text objects. Type with rotated characters (Character +Rotation) is always written as outlines, which keep the rotation, with the warning "type with rotated characters is +written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate +characters)". An image that can't be decoded is left out of the file and does not show in art that is flattened or whose raster effects are expanded; `warnings` carries "an image couldn't be decoded and is missing from the picture" once for all such images. From the CLI: `vectorcraft-cli convert in.svg out.ai --ai-version 7 [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] [--outline-text] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 40e1d1267..44d7b56c0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1064,6 +1064,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`). In SVG export without Fewer `` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned ``, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`). SVG export (live text), legacy `.ai` saves (text objects), DXF export (`TEXT` entities) and the CSS Properties panel write area type that Shrink Text to Fit scales down at the size it draws at, and scale its tracking, kerning, leading and baseline shifts the same way where the format holds them. + Legacy `.ai` saves (version 7) write type with rotated characters (Character Rotation, Touch Type turns) as its outlines, with a warning that it is no longer editable text: a version 7 text object has no character rotation ([Adobe Illustrator File Format Specification 7.0](https://www.nationalarchives.gov.uk/PRONOM/fmt/423), §15), and the outlines keep each character's rotation, baseline shift and scale (`legacy_ai::tests::type_with_rotated_characters_is_outlines_that_keep_the_rotation`). A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304). In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is; when all the type is made from the text document, the type's share has a warning of its own beside the art's (`crates/eps/src/import/tests_native.rs`). PDF text opens with every glyph where the file draws it (#722): the font's kerning or none, whichever the file followed, word and character spacing, `TJ` adjustments and horizontal scaling kept per character as tracking, text rise as baseline shift within its line, vertical glyphs narrower than an em centred on their column (`/W2`, `/W`, `/DW`), justified text boxes with word spacing back as justified area type (`tests_text_placement`, fixtures written from ISO 32000-1 §9). From 1186d1f7127e5dfbf4184df86ff89b08f6a66d1e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 18:09:53 -0800 Subject: [PATCH 10/12] Affinity: pressure curves that can't be read count against the document's limit A curve rejected for having more than 256 segments now counts the most points a curve may have against the document's pressure points, so linking it from many strokes reads it a bounded number of times. Once the points are used up, later strokes import as plain strokes with a warning. --- crates/affinity/src/geometry.rs | 4 ++++ crates/affinity/src/paint.rs | 6 ++++-- crates/engine/src/cmd/fileio/tests_affinity.rs | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 945c3040f..bb1c45746 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -178,6 +178,10 @@ const MAX_PRESSURE_SEGMENTS: usize = 256; /// points; the document's limit counts all of them. const PRESSURE_POINTS: usize = 512; +/// Most points one pressure curve can have (see [`PRESSURE_POINTS`]). A curve that can't be read +/// counts this many against the document's limit. +pub(crate) const MAX_PRESSURE_CURVE_POINTS: usize = 1 + 4 * MAX_PRESSURE_SEGMENTS; + /// A stroke's pressure curve (`LDsc.LDeP`, a `CrvD` whose positional `Data` holds an array of /// 18-byte records as in [`curves`]): x is the position along the path (0 to 1) and y the width as /// a fraction of the stroke's weight. Each segment is sampled at 2 to 8 points, at most diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index cfc95b8dd..12102581e 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -357,8 +357,9 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh r.warn("brush strokes (imported as plain strokes)"); } // The pressure curve sets the width along the path; one at full width all along is a plain - // stroke. Every curve read, full width or not, counts against the document's points, and once - // they are used up the rest are not read. + // stroke. Every curve read counts against the document's points: one at full width counts its + // points, and one that can't be read counts the most a curve may have. Once the points are + // used up the rest are not read. const BEYOND: &str = "pressure strokes beyond the pressure points one document may hold (imported as plain strokes)"; let ldep = s.obj(ldsc, b"LDeP"); if ldep.is_some() && r.budget.pressure == 0 { @@ -377,6 +378,7 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh } }, Some(None) => { + r.budget.pressure = r.budget.pressure.saturating_sub(crate::geometry::MAX_PRESSURE_CURVE_POINTS); r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)"); None } diff --git a/crates/engine/src/cmd/fileio/tests_affinity.rs b/crates/engine/src/cmd/fileio/tests_affinity.rs index a57da79fc..70b9b992a 100644 --- a/crates/engine/src/cmd/fileio/tests_affinity.rs +++ b/crates/engine/src/cmd/fileio/tests_affinity.rs @@ -462,6 +462,12 @@ mod native { let flat: Vec> = (0..=768).map(|i| node(f64::from(i) / 768.0, 1.0, if i % 3 == 0 { 4 } else { 0 }, (i % 3) as u8)).collect(); let r = load("flat-many.af", &pressure_documents(flat, 2100)).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings); + + // So do curves that can't be read: 768 segments, more than a curve may have. + let long: Vec> = (0..=768).map(|i| node(f64::from(i) / 768.0, f64::from(i % 2), 4, 0)).collect(); + let r = load("long-many.af", &pressure_documents(long, 2100)).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("pressure curve can't be read")), "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings); } #[test] From bce3de36ca6b5b98d156004a7417f54e083c9e46 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 1 Nov 2026 23:27:52 -0500 Subject: [PATCH 11/12] Legacy .ai: docs say when the rotated-type warning shows, and record the change Co-Authored-By: Claude Opus 5.5 --- docs/mcp.md | 2 +- docs/target-app-parity.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 57f990dfe..4a47d0dbc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -844,7 +844,7 @@ of `range` (default all; `{name}-{artboard}.ai` when several, listed in the resu `transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity or a blending mode, `hiddenObjects: "layer"` (default: a hidden layer of each layer's hidden objects), `"omit"` or `"visible"`, and `outlineText: true` to write type as outlines instead of text objects. Type with rotated characters (Character -Rotation) is always written as outlines, which keep the rotation, with the warning "type with rotated characters is +Rotation) is always written as outlines, which keep the rotation; without `outlineText: true` this gives the warning "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)". An image that can't be decoded is left out of the file and does not show in art that is flattened or whose raster effects are expanded; `warnings` carries "an image diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 44d7b56c0..31e65d51d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -19,6 +19,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Legacy `.ai` saves (version 7) write type with rotated characters as outlines that keep each character's rotation, with a warning unless `outlineText` is on (#5186); scores unchanged. | | 2026-10-31 | minor | Object › Path › Smooth smooths a path along its length before refitting it, so Auto-Smooth takes a shaky line to one smooth curve instead of leaving its jags, and with anchor points direct-selected it smooths only that section; scores unchanged. | | 2026-10-31 | minor | New Document: More Settings has the Save Preset button beside the name too, saving its settings (artboard layout included) as a preset (#4893); scores unchanged. | | 2026-10-31 | minor | Object › Rasterize in a CMYK document with its CMYK colour model makes a CMYK image of the art's ink amounts (as CMYK exports draw them), so the Properties panel says CMYK and CMYK exports keep its inks; a transparent background becomes the image's opacity mask (#4882); scores unchanged. | @@ -1064,7 +1065,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`). In SVG export without Fewer `` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned ``, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`). SVG export (live text), legacy `.ai` saves (text objects), DXF export (`TEXT` entities) and the CSS Properties panel write area type that Shrink Text to Fit scales down at the size it draws at, and scale its tracking, kerning, leading and baseline shifts the same way where the format holds them. - Legacy `.ai` saves (version 7) write type with rotated characters (Character Rotation, Touch Type turns) as its outlines, with a warning that it is no longer editable text: a version 7 text object has no character rotation ([Adobe Illustrator File Format Specification 7.0](https://www.nationalarchives.gov.uk/PRONOM/fmt/423), §15), and the outlines keep each character's rotation, baseline shift and scale (`legacy_ai::tests::type_with_rotated_characters_is_outlines_that_keep_the_rotation`). + Legacy `.ai` saves (version 7) write type with rotated characters (Character Rotation, Touch Type turns) as its outlines, with a warning that it is no longer editable text (unless `outlineText` already asks for every type object as outlines): a version 7 text object has no character rotation ([Adobe Illustrator File Format Specification 7.0](https://www.nationalarchives.gov.uk/PRONOM/fmt/423), §15), and the outlines keep each character's rotation, baseline shift and scale (`legacy_ai::tests::type_with_rotated_characters_is_outlines_that_keep_the_rotation`). A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304). In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is; when all the type is made from the text document, the type's share has a warning of its own beside the art's (`crates/eps/src/import/tests_native.rs`). PDF text opens with every glyph where the file draws it (#722): the font's kerning or none, whichever the file followed, word and character spacing, `TJ` adjustments and horizontal scaling kept per character as tracking, text rise as baseline shift within its line, vertical glyphs narrower than an em centred on their column (`/W2`, `/W`, `/DW`), justified text boxes with word spacing back as justified area type (`tests_text_placement`, fixtures written from ISO 32000-1 §9). From 890c27ccb212fc020f7f94da8533d4aadb773571 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sun, 1 Nov 2026 20:30:54 -0800 Subject: [PATCH 12/12] Native format: take the .vectorcraft envelope from craft-vectorcraft-format (#5146) * Native format: the .vectorcraft envelope comes from craft-vectorcraft-format vectorcraft-format reads and writes the file's envelope through the craft-vectorcraft-format crate in craft-libs: Head and Image, the version and size constants, the format and version check, Tail for the data after the JSON, split_tail and entry, and base64. The constants, is_compressed, json_part, base64_encode and base64_decode are re-exported from vectorcraft_format, so callers do not change. load_file reads the members it uses with Image entries and skips the preview and PDF data. The document, its migrations, the version 1 writer, gzip writing, sniff and the limits on decompression stay here. A preview or pdf member of the wrong shape now reads as missing on its own, so pdf_content still returns a file's PDF when its preview is damaged. A new test saves a document in every version, compressed and pretty, with a preview, profiles and a PDF, reads it back, saves it again and compares the bytes; it also reads each uncompressed file as the crate's File. Another reads the PDF of a file whose preview is damaged. * Native format: sniff from craft-vectorcraft-format, pinned at #408's head - craft-vectorcraft-format moves to craft-libs 0fd7e75fe6, the head of storytold/craft-libs#408. - `sniff` is the crate's: a file is native when its top-level `format` member, within the first 256 bytes after decompression, is `vectorcraft` or `drawcraft`, read with JSON escapes decoded. The word elsewhere in the head no longer counts. All 192 corpus files and their resaved copies get the same answer as before. - Envelope errors other than the format and the version keep the crate's message instead of reading as a compressed file that unpacks to too much. - A test checks that a repeated `generator`, `preview` or `pdf` opens and any other repeated member is refused, by `load_file` and by the crate's `Contents` alike. * Native format: read through the crate's Contents and Entries Pins craft-vectorcraft-format at 420a27f18f, the current head of craft-libs#408, and replaces VectorCraft's own copies with the crate's: - load_file reads the JSON through Contents::read, which also refuses another format or a newer version. Opened is removed. Messages are those of main: serde names the struct File, as main's reader did. - In-memory data after the JSON (uncompressed files, and compressed files unpacked whole) is read through the crate's Entries. entries_within keeps its streaming reader for compressed files, whose data is unpacked entry by entry and is never one slice in memory. - The streaming reader finds the marker through the crate's marker_at. - MAX_BLOB is the crate's MAX_ENTRY (1 GiB). save_to still writes the envelope member by member: it streams the document into the output and looks for the images it uses as it writes, and File would need the images before the document. A new test checks that its output, compact, pretty and compressed, for versions 1 to 4, equals serde_json::to_vec or to_vec_pretty of the crate's File holding the same members followed by the crate's Tail, so a change to either side fails. * Native format: pin craft-vectorcraft-format at craft-libs main craft-libs#408 merged as 14d5dbb50c; the crate is unchanged from 420a27f18f. A test checks that the crate's sniff takes every layout VectorCraft has written: the version 1 and 2 files from before the rename, compact and pretty, and each version save_with writes. --- Cargo.lock | 12 + Cargo.toml | 2 + crates/format/Cargo.toml | 3 +- crates/format/src/lib.rs | 410 +++++++++++++--------------- crates/format/src/unpack.rs | 50 ++-- crates/format/tests/it/native_v4.rs | 79 ++++++ 6 files changed, 311 insertions(+), 245 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 692dd5a91..01b2e0e81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,6 +1081,17 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-vectorcraft-format" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=14d5dbb50c317b8747a9cf9eb35ab8a22515f137#14d5dbb50c317b8747a9cf9eb35ab8a22515f137" +dependencies = [ + "flate2", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-window" version = "0.1.0" @@ -6281,6 +6292,7 @@ dependencies = [ name = "vectorcraft-format" version = "0.20.0" dependencies = [ + "craft-vectorcraft-format", "flate2", "proptest", "rustix 1.1.6", diff --git a/Cargo.toml b/Cargo.toml index 3088b469b..1876edbf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392c # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "14d5dbb50c317b8747a9cf9eb35ab8a22515f137" } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 55fbe7056..fc6a060a5 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -10,7 +10,8 @@ workspace = true [dependencies] vectorcraft-doc = { workspace = true } -serde = { workspace = true } +craft-vectorcraft-format = { workspace = true } +serde ={ workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } flate2 = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index ad9df39ff..6eb2e2bd3 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -37,33 +37,23 @@ mod legacy; mod stream; mod unpack; -use std::borrow::Cow; use std::collections::{BTreeMap, HashSet}; -use std::io::{Read as _, Write}; +use std::io::Write; use std::sync::Arc; -use serde::{Deserialize, Serialize}; +use craft_vectorcraft_format::{Contents, Head, Image, ReadError, Tail}; +use serde::Serialize; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with}; +pub use craft_vectorcraft_format::base64::{decode as base64_decode, encode as base64_encode}; +pub use craft_vectorcraft_format::{ + BLOB_MAGIC, COMPRESSED_SINCE, EXTENSION, ICC_MIME, INLINE_MAX, LEGACY_EXTENSION, MIN_VERSION, PREVIEW_MAX, VERSION, is_compressed, json_part, + sniff, +}; use stream::{Gz, Indent, KeyScan, Out}; -/// v4: large data after the JSON (v1 to v3 files still load). -pub const VERSION: u32 = 4; -/// Data this large or larger goes after the JSON (v4), not into it as base64. -pub const INLINE_MAX: usize = 8 << 10; -/// What separates the JSON from the data after it (v4). -pub const BLOB_MAGIC: &[u8] = b"\n\0VCBLOBS\0"; -/// The oldest version [`save_with`] writes. -pub const MIN_VERSION: u32 = 1; -/// The first version whose readers open compressed files. -pub const COMPRESSED_SINCE: u32 = 3; -/// Most pixels on the longer side of an embedded preview. -pub const PREVIEW_MAX: u32 = 256; -pub const EXTENSION: &str = "vectorcraft"; -/// Extension and format name from before the project was renamed (DrawCraft): still opened. -pub const LEGACY_EXTENSION: &str = "drawcraft"; /// Most bytes the JSON of a compressed file may unpack to (a guard against decompression bombs, /// as are [`MAX_BLOB`] and [`MAX_BLOBS`]). A compressed save refuses a document past any of them, /// so every file VectorCraft writes reads back. On wasm32 the cap is a quarter of the native @@ -72,7 +62,7 @@ pub const LEGACY_EXTENSION: &str = "drawcraft"; pub const MAX_JSON: u64 = if cfg!(target_arch = "wasm32") { 256 << 20 } else { 1 << 30 }; /// Most bytes one entry of the data after the JSON (an image, a placed document, the PDF, a /// profile) may have in a compressed file. -pub const MAX_BLOB: u64 = 1 << 30; +pub const MAX_BLOB: u64 = craft_vectorcraft_format::MAX_ENTRY; /// Most bytes the entries after the JSON may have together in a compressed file: 1 GiB in the web /// build, whose 4 GiB address space also holds the JSON, the document and the app, so 4 GiB of /// entries failed to allocate rather than being refused. @@ -111,53 +101,17 @@ pub enum FormatError { Encode(String), } -#[derive(Serialize, Deserialize)] -struct Image { - mime: String, - /// The bytes as base64, unless they come after the JSON (`blob`). - #[serde(default, skip_serializing_if = "String::is_empty")] - data: String, - /// Where the bytes are in the data after the JSON (v4): `[offset, length]`. - #[serde(default, skip_serializing_if = "Option::is_none")] - blob: Option<[u64; 2]>, - /// `data` is a linked image's preview ([`ImageBlob::proxy`]), not the file's bytes. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - proxy: bool, -} - -impl Image { - /// Its bytes when they are in the JSON (base64 `data`); `None` when they come after it - /// ([`Image::blob`]) or don't decode. - fn inline(&self) -> Option> { - match self.blob { - Some(_) => None, - None => base64_decode(&self.data), +impl From for FormatError { + fn from(e: craft_vectorcraft_format::Error) -> Self { + match e { + craft_vectorcraft_format::Error::Format(format) => FormatError::NotVectorcraft(format!("format is `{format}`")), + craft_vectorcraft_format::Error::TooNew(version) => FormatError::TooNew(version), + // The crate's other errors (`TooBig`: entries past the data after the JSON) keep their message. + e => FormatError::NotVectorcraft(e.to_string()), } } } -/// Entries written for one file: large bytes go to the data after the JSON when the version keeps -/// them there (in the order they are added, each at the offset it is given). -struct Blobs<'a> { - /// The bytes, and whether they are compressed already ([`is_packed`]). - tail: Vec<(&'a [u8], bool)>, - at: u64, - outside: bool, -} - -impl<'a> Blobs<'a> { - fn image(&mut self, mime: &str, bytes: &'a [u8], proxy: bool) -> Image { - if self.outside && bytes.len() >= INLINE_MAX { - let len = bytes.len() as u64; - let at = self.at; - self.at += len; - self.tail.push((bytes, is_packed(mime))); - return Image { mime: mime.into(), data: String::new(), blob: Some([at, len]), proxy }; - } - Image { mime: mime.into(), data: base64_encode(bytes), blob: None, proxy } - } -} - /// Is data of this MIME type compressed already (no use compressing it again)? fn is_packed(mime: &str) -> bool { matches!(mime, "image/jpeg" | "image/png" | "image/gif" | "image/webp") @@ -168,52 +122,6 @@ fn preview_mime(bytes: &[u8]) -> &'static str { if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME } } -/// The JSON of a native file without the data after it (version 4 keeps images there): a -/// complete JSON value whose `images` give each image's MIME type and where its data was, without -/// the data. -pub fn json_part(file: &[u8]) -> &[u8] { - split_tail(file).0 -} - -/// The JSON of a file and the data after it (v4), split at [`BLOB_MAGIC`] after the first JSON -/// value. -fn split_tail(text: &[u8]) -> (&[u8], &[u8]) { - // JSON holds no raw NUL, so the marker (which has one) starts at the first NUL, if the file - // has one. The writer puts it right after the JSON's last byte; found there, it is where the - // first JSON value ends, without parsing the JSON twice. Anything else takes the parse. - let Some(nul) = text.iter().position(|&b| b == 0) else { - // No NUL, so no marker: the parse below would find none either. - return (text, &[]); - }; - if let Some(at) = nul.checked_sub(1) - && text.get(at..).is_some_and(|r| r.starts_with(BLOB_MAGIC)) - && at.checked_sub(1).and_then(|k| text.get(k)).is_some_and(|b| matches!(b, b'}' | b']' | b'"')) - { - return (text.get(..at).unwrap_or(text), text.get(at + BLOB_MAGIC.len()..).unwrap_or_default()); - } - let mut values = serde_json::Deserializer::from_slice(text).into_iter::(); - if values.next().is_some_and(|v| v.is_ok()) { - let end = values.byte_offset(); - if let Some(rest) = text.get(end..) - && rest.starts_with(BLOB_MAGIC) - { - return (text.get(..end).unwrap_or(text), rest.get(BLOB_MAGIC.len()..).unwrap_or_default()); - } - } - (text, &[]) -} - -#[derive(Deserialize)] -struct File { - format: String, - version: u32, - document: Document, - #[serde(default)] - images: BTreeMap, - #[serde(default)] - profiles: BTreeMap, -} - /// How [`save_with`] writes a document. #[derive(Clone, Debug)] pub struct SaveOptions { @@ -239,9 +147,6 @@ impl Default for SaveOptions { } } -/// The MIME type of embedded ICC profiles. -pub const ICC_MIME: &str = "application/vnd.iccprofile"; - impl SaveOptions { /// Can a file be written this way (a version [`save_with`] writes, compressed only for readers /// that open compressed files)? @@ -369,7 +274,7 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For let pretty = o.pretty && !o.compress; let Body { d, v1, blobs, linked } = body(doc, o)?; let entries = Entries { blobs: &blobs, linked: &linked, include_linked: o.include_linked }; - let mut out_blobs = Blobs { tail: vec![], at: 0, outside: o.version >= 4 }; + let mut out_blobs = Tail::new(o.version); // Images are only referenced by image objects' `key` (wherever the objects are: layers, // symbols, patterns, masks, or foreign data). The data after the JSON has the images first, // then the profiles and the PDF, which the file lists before the document: with those, the @@ -382,7 +287,6 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For } let profiles: BTreeMap<&str, Image> = o.profiles.iter().map(|(name, icc)| (name.as_str(), out_blobs.image(ICC_MIME, icc, false))).collect(); let pdf = o.pdf.as_deref().map(|p| out_blobs.image("application/pdf", p, false)); - let blob = |mime: &str, bytes: &[u8]| Image { mime: mime.into(), data: base64_encode(bytes), blob: None, proxy: false }; let mut out = if o.compress { Out::Gz(Box::new(Gz::new(w).map_err(encode_err)?)) } else { Out::Plain(w) }; let mut env = Envelope { pretty, first: true }; // Older apps only know the name from before the rename. @@ -390,7 +294,7 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For env.field(&mut out, "version", &o.version)?; env.field(&mut out, "generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?; if let Some(png) = &o.preview { - env.field(&mut out, "preview", &blob("image/png", png))?; + env.field(&mut out, "preview", &Image::inline("image/png", png))?; } if !profiles.is_empty() { env.field(&mut out, "profiles", &profiles)?; @@ -412,22 +316,22 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For env.finish(&mut out)?; if o.compress { let json = out.written().unwrap_or(0); - let tail: Vec = out_blobs.tail.iter().map(|(b, _)| b.len() as u64).collect(); + let tail: Vec = out_blobs.parts().iter().map(|p| p.bytes.len() as u64).collect(); if json > MAX_JSON { return Err(FormatError::TooBigToCompress(format!("{} MB of document, at most {} MB", json >> 20, MAX_JSON >> 20))); } if let Some(big) = tail.iter().find(|&&n| n > MAX_BLOB) { return Err(FormatError::TooBigToCompress(format!("an image of {} MB, at most {} MB", big >> 20, MAX_BLOB >> 20))); } - if out_blobs.at > MAX_BLOBS { - return Err(FormatError::TooBigToCompress(format!("{} MB of images, at most {} MB", out_blobs.at >> 20, MAX_BLOBS >> 20))); + if out_blobs.len() > MAX_BLOBS { + return Err(FormatError::TooBigToCompress(format!("{} MB of images, at most {} MB", out_blobs.len() >> 20, MAX_BLOBS >> 20))); } } - if !out_blobs.tail.is_empty() { + if !out_blobs.is_empty() { out.write_all(BLOB_MAGIC).map_err(encode_err)?; - for &(bytes, packed) in &out_blobs.tail { - out.packed(packed).map_err(encode_err)?; - out.write_all(bytes).map_err(encode_err)?; + for part in out_blobs.parts() { + out.packed(is_packed(&part.mime)).map_err(encode_err)?; + out.write_all(part.bytes).map_err(encode_err)?; } } out.finish().map_err(encode_err) @@ -443,7 +347,7 @@ struct Entries<'a> { impl<'a> Entries<'a> { /// The entries of the images named in `used`, in the order of their names. - fn images(&self, used: &HashSet, out: &mut Blobs<'a>) -> BTreeMap<&'a str, Image> { + fn images(&self, used: &HashSet, out: &mut Tail<'a>) -> BTreeMap<&'a str, Image> { self.blobs .iter() .filter(|(k, _)| used.contains(k.as_str())) @@ -508,11 +412,6 @@ impl Envelope { } } -/// Is this gzip data (a compressed file)? -pub fn is_compressed(bytes: &[u8]) -> bool { - bytes.starts_with(&[0x1f, 0x8b]) -} - pub fn load(bytes: &[u8]) -> Result { load_info(bytes).map(|(doc, _)| doc) } @@ -563,22 +462,22 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result let file = unpack::Unpacked::new(bytes)?; // The JSON reader stops at 128 levels (a document's objects use three each), the paths read // at `MAX_READ_ANCHORS` anchors together, the document at `MAX_READ_NODES` objects, and the - // fields read before an object's type at `MAX_BUFFERED_VALUES` values. + // fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the + // PDF are skipped without being kept. let read = || { vectorcraft_doc::node::with_node_budget(max_nodes, || { - vectorcraft_doc::node::with_buffered_value_budget(max_values, || serde_json::from_slice::(file.json())) + vectorcraft_doc::node::with_buffered_value_budget(max_values, || Contents::::read(file.json())) }) }; - let f: File = vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, read).map_err(|e| { - let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() }; - FormatError::NotVectorcraft(why) + let f = vectorcraft_doc::geom::path::with_anchor_budget(vectorcraft_doc::geom::path::MAX_READ_ANCHORS, read).map_err(|e| match e { + ReadError::Json(e) => { + let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() }; + FormatError::NotVectorcraft(why) + } + ReadError::Envelope(e) => e.into(), + e => FormatError::NotVectorcraft(e.to_string()), })?; - if f.format != EXTENSION && f.format != LEGACY_EXTENSION { - return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format))); - } - if f.version > VERSION { - return Err(FormatError::TooNew(f.version)); - } + let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }; // The bytes after the JSON, read in one pass: the images' first, then the profiles'. let entries: Vec<[u64; 2]> = f.images.values().chain(f.profiles.values()).filter_map(|i| i.blob).collect(); let tail = file.entries(&entries)?; @@ -589,7 +488,7 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result let mut tail = tail.into_iter(); let mut bytes_of = |img: &Image| match img.blob { Some(_) => tail.next().flatten(), - None => img.inline().map(Arc::new), + None => base64_decode(&img.data).map(Arc::new), }; let mut doc = f.document; for (k, img) in f.images { @@ -652,14 +551,7 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result } }) .collect::>()?; - Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) -} - -/// The members of a native file besides its document (which is skipped, not decoded). -#[derive(Deserialize)] -struct Head { - preview: Option, - pdf: Option, + Ok(NativeFile { doc, depth_notes, info, profiles }) } /// The bytes of the member of the file's head `pick` picks (only that entry of the data after @@ -670,7 +562,7 @@ fn head_member(bytes: &[u8], pick: impl FnOnce(Head) -> Option) -> Option let img = pick(head)?; match img.blob { Some(at) => file.entries(&[at]).ok()?.pop().flatten().map(Arc::unwrap_or_clone), - None => img.inline(), + None => base64_decode(&img.data), } } @@ -685,71 +577,6 @@ pub fn pdf_content(bytes: &[u8]) -> Option> { head_member(bytes, |h| h.pdf) } -/// Does this look like a `.vectorcraft` file (compressed or not)? -pub fn sniff(bytes: &[u8]) -> bool { - const HEAD: usize = 256; - let head: Cow<'_, [u8]> = if is_compressed(bytes) { - let mut head = Vec::with_capacity(HEAD); - // A damaged stream may still unpack its start: judge what came out. - let _ = flate2::read::GzDecoder::new(bytes).take(HEAD as u64).read_to_end(&mut head); - Cow::Owned(head) - } else { - Cow::Borrowed(bytes.get(..HEAD).unwrap_or(bytes)) - }; - // The cut may split a character. - let s = String::from_utf8_lossy(&head); - s.trim_start().starts_with('{') && (s.contains("\"vectorcraft\"") || s.contains("\"drawcraft\"")) -} - -const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -pub fn base64_encode(data: &[u8]) -> String { - let mut out = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - out.push(B64[(n >> 18) as usize & 63] as char); - out.push(B64[(n >> 12) as usize & 63] as char); - out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); - out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' }); - } - out -} - -pub fn base64_decode(s: &str) -> Option> { - let val = |c: u8| B64.iter().position(|&b| b == c).map(|p| p as u32); - let clean: Vec = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); - if !clean.len().is_multiple_of(4) { - return None; - } - let mut out = Vec::with_capacity(clean.len() / 4 * 3); - let groups = clean.len() / 4; - for (i, c) in clean.chunks(4).enumerate() { - let mut n = 0u32; - let mut pad = 0; - for &b in c { - n <<= 6; - if b == b'=' { - pad += 1; - } else if pad > 0 { - return None; - } else { - n |= val(b)?; - } - } - if pad > 2 || (pad > 0 && i + 1 != groups) { - return None; - } - out.push((n >> 16) as u8); - if pad < 2 { - out.push((n >> 8) as u8); - } - if pad < 1 { - out.push(n as u8); - } - } - Some(out) -} - #[cfg(test)] mod tests { use super::*; @@ -778,6 +605,163 @@ mod tests { assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false }); assert!(load_info(v1.as_bytes()).unwrap().1.is_old()); } + + /// A file is native when its top-level `format` member names VectorCraft, read as JSON reads + /// it; the word elsewhere in the head does not count. + #[test] + fn sniff_reads_the_format_member() { + // `vectorcraft` with its second `c` written as a JSON escape. + let escaped = [br#"{"format":"vector\"#.as_slice(), br#"u0063raft","version":4,"document":{}}"#].concat(); + assert!(sniff(&escaped)); + assert!(!sniff(br#"{"format":"other","generator":"vectorcraft"}"#)); + assert!(!sniff(br#"{"names":["vectorcraft"],"format":"svg"}"#)); + } + + /// Every layout VectorCraft has written puts `format` first: the version 1 and 2 files from + /// before the rename (a struct of `format`, `version`, `generator`, `document` and `images`, + /// compact or pretty), and each version `save_with` writes, compact, pretty and compressed. + #[test] + fn sniff_takes_every_layout_written() { + let before_rename = [ + r#"{"format":"drawcraft","version":1,"generator":"DrawCraft 0.1.0","document":{},"images":{}}"#, + "{\n \"format\": \"drawcraft\",\n \"version\": 2,\n \"generator\": \"DrawCraft 0.1.0\",\n \"document\": {},\n \"images\": {}\n}", + ]; + for text in before_rename { + assert!(sniff(text.as_bytes()), "{text}"); + } + let d = Document::new(10.0, 10.0); + for version in MIN_VERSION..=VERSION { + for (pretty, compress) in [(false, false), (true, false), (false, true)] { + let o = SaveOptions { pretty, compress, version, ..SaveOptions::default() }; + if o.check().is_err() { + continue; + } + let bytes = save_with(&d, &o).unwrap(); + assert!(sniff(&bytes), "version {version}, pretty {pretty}, compress {compress}"); + } + } + } + + /// An envelope error other than the format and version keeps the crate's message. + #[test] + fn envelope_errors_keep_their_message() { + let e = FormatError::from(craft_vectorcraft_format::Error::TooBig); + assert!(matches!(e, FormatError::NotVectorcraft(_)), "{e}"); + assert!(e.to_string().contains(&craft_vectorcraft_format::Error::TooBig.to_string()), "{e}"); + let e = FormatError::from(craft_vectorcraft_format::Error::Format("x".into())); + assert_eq!(e.to_string(), "not a VectorCraft file: format is `x`"); + assert!(matches!(FormatError::from(craft_vectorcraft_format::Error::TooNew(9)), FormatError::TooNew(9))); + } + + /// `save_to` writes the file member by member and streams the document in. For every version + /// it writes, compact, pretty and compressed, its bytes are those of the crate's `File` + /// holding the same members, serialized with `serde_json::to_vec` or `to_vec_pretty` and + /// followed by the crate's `Tail`. + #[test] + fn save_to_writes_the_bytes_of_the_crates_file() { + use std::io::Read; + + /// The document as `save_to` writes it: its version 1 form, or the document. + struct Written<'a>(&'a Body); + impl Serialize for Written<'_> { + fn serialize(&self, s: S) -> Result { + match &self.0.v1 { + Some(v) => v.serialize(s), + None => self.0.d.serialize(s), + } + } + } + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + // An image large enough to go after the JSON from version 4, a small one, and one that + // nothing uses. + let big: Vec = (0..INLINE_MAX as u32 + 5).map(|i| (i % 251) as u8).collect(); + for (key, bytes) in [("big", big), ("small", vec![1, 2, 3, 250]), ("unused", vec![9])] { + d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); + } + for (i, key) in ["big", "small"].into_iter().enumerate() { + let img = d.alloc_id(); + let im = + ImageObject { key: key.into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(l), i + 1, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap(); + } + let png = vec![0x89, b'P', b'N', b'G']; + let icc = vec![7u8; INLINE_MAX]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + pdf.resize(INLINE_MAX + 1, b' '); + let full = SaveOptions { + preview: Some(png.clone()), + profiles: [("Gray".to_string(), icc.clone())].into(), + pdf: Some(pdf.clone()), + ..SaveOptions::default() + }; + let mut checked = 0; + for version in MIN_VERSION..=VERSION { + for members in [&full, &SaveOptions::default()] { + for pretty in [false, true] { + let o = SaveOptions { version, pretty, ..members.clone() }; + let b = body(&d, &o).unwrap(); + let mut file = craft_vectorcraft_format::File::new(Written(&b)); + file.format = if version < 3 { LEGACY_EXTENSION } else { EXTENSION }.into(); + file.version = version; + file.generator = Some(format!("VectorCraft {}", env!("CARGO_PKG_VERSION"))); + file.preview = o.preview.as_deref().map(|p| Image::inline("image/png", p)); + // The images come first in the data after the JSON, then the profiles and the PDF. + let mut tail = file.tail(); + for key in ["big", "small"] { + file.images.insert(key.into(), tail.image("image/png", b.blobs[key].bytes.as_slice(), false)); + } + for (name, icc) in &o.profiles { + file.profiles.insert(name.clone(), tail.image(ICC_MIME, icc, false)); + } + file.pdf = o.pdf.as_deref().map(|p| tail.image("application/pdf", p, false)); + let mut expected = if pretty { serde_json::to_vec_pretty(&file) } else { serde_json::to_vec(&file) }.unwrap(); + tail.write_to(&mut expected).unwrap(); + assert_eq!(tail.is_empty(), version < 4, "version {version}"); + assert!(save_with(&d, &o).unwrap() == expected, "version {version}, pretty {pretty}, {} profiles", o.profiles.len()); + checked += 1; + if version >= COMPRESSED_SINCE && !pretty { + let gz = save_with(&d, &SaveOptions { compress: true, ..o.clone() }).unwrap(); + let mut unpacked = Vec::new(); + flate2::read::GzDecoder::new(gz.as_slice()).read_to_end(&mut unpacked).unwrap(); + assert!(unpacked == expected, "version {version}, compressed, {} profiles", o.profiles.len()); + checked += 1; + } + } + } + } + assert_eq!(checked, 20); + } + + /// Files are read through the crate's `Contents` with the messages VectorCraft gave before, + /// and a top-level array holds the format, version, document, images and profiles in that + /// order. + #[test] + fn files_read_through_the_crates_contents_as_before() { + let message = |json: &str| load(json.as_bytes()).err().map(|e| e.to_string()); + assert_eq!( + message(r#""x""#).as_deref(), + Some(r#"not a VectorCraft file: invalid type: string "x", expected struct File at line 1 column 3"#) + ); + assert!( + message(r#"{"format":"vectorcraft","version":4}"#).is_some_and(|m| m.starts_with("not a VectorCraft file: missing field `document`")) + ); + let v: serde_json::Value = serde_json::from_slice(&save(&Document::new(10.0, 10.0), false)).unwrap(); + let doc = v["document"].to_string(); + let file = |format: &str, version: u32| format!(r#"{{"format":"{format}","version":{version},"document":{doc}}}"#); + assert_eq!(message(&file("svg", 4)).as_deref(), Some("not a VectorCraft file: format is `svg`")); + assert_eq!(message(&file("vectorcraft", 9)).as_deref(), Some("file version 9 is newer than this VectorCraft supports (4)")); + // A document that does not read is reported first, as before. + assert!( + message(r#"{"format":"vectorcraft","version":9,"document":7}"#).is_some_and(|m| m.starts_with("not a VectorCraft file: invalid type")) + ); + for json in [format!(r#"["vectorcraft",3,{doc},{{}},{{}}]"#), format!(r#"["vectorcraft",3,{doc}]"#)] { + assert_eq!(load(json.as_bytes()).map(|d| d.artboards.len()).ok(), Some(1), "{json}"); + } + assert!(load(format!(r#"["vectorcraft",3,{{}},{doc},{{}}]"#).as_bytes()).is_err()); + } use vectorcraft_doc::{Appearance, ImageObject, Node, NodeKind}; use vectorcraft_geom::{Affine, Rect, shapes}; diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index d0833ad0f..1573fa0bc 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -7,9 +7,10 @@ use std::collections::HashMap; use std::io::{self, Read}; use std::sync::Arc; +use craft_vectorcraft_format::{Entries, marker_at, split_tail}; use flate2::read::GzDecoder; -use crate::{BLOB_MAGIC, FormatError, MAX_BLOB, MAX_BLOBS, MAX_JSON, MAX_UNPACKED, is_compressed, split_tail}; +use crate::{BLOB_MAGIC, FormatError, MAX_BLOB, MAX_BLOBS, MAX_JSON, MAX_UNPACKED, is_compressed}; /// Bytes unpacked at a time while looking for the end of the JSON. const CHUNK: u64 = 64 << 10; @@ -87,19 +88,16 @@ impl<'a> Unpacked<'a> { return Err(FormatError::TooBig); } } - let Some(z) = nul else { + if nul.is_none() { // No NUL, so no marker: all of it is JSON. if buf.len() as u64 > max_json { return Err(FormatError::TooBig); } return Ok(Self { json: Json::Owned(buf), tail: Tail::None }); - }; - // JSON holds no raw NUL, so the marker (which has one) starts right before the first NUL; - // the writer puts it right after the JSON's last byte. - if let Some(end) = z.checked_sub(1) - && buf.get(end..).is_some_and(|r| r.starts_with(BLOB_MAGIC)) - && end.checked_sub(1).and_then(|k| buf.get(k)).is_some_and(|b| matches!(b, b'}' | b']' | b'"')) - { + } + // The marker where the writer puts it, right after the JSON's last byte: what is unpacked + // so far reaches past it. + if let Some(end) = marker_at(&buf) { if end as u64 > max_json { return Err(FormatError::TooBig); } @@ -143,24 +141,17 @@ impl<'a> Unpacked<'a> { /// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them. fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result, FormatError> { let mut out: Vec = vec![None; wanted.len()]; - // The entries read so far, by `[offset, length]`, and the bytes copied for them. - let mut done: HashMap<[u64; 2], Entry> = HashMap::new(); - let mut copied = 0u64; - let mut in_memory = |tail: &[u8], out: &mut Vec| { + // Data in memory is read through the crate's `Entries`: entries naming the same bytes + // share them, and the bytes copied stay within the data. + let in_memory = |tail: &[u8], out: &mut Vec| { + let mut entries = Entries::new(tail); for (slot, &w) in out.iter_mut().zip(wanted) { - if let Some(e) = done.get(&w) { - *slot = e.clone(); - continue; - } - let bytes = range(w[0], w[1]).and_then(|(a, b)| tail.get(a..b)); - copied = copied.saturating_add(bytes.map_or(0, |b| b.len() as u64)); - if copied > tail.len() as u64 { - return Err(FormatError::TooBig); - } - *slot = bytes.map(|b| Arc::new(b.to_vec())); - done.insert(w, slot.clone()); + *slot = entries.blob(w).map_err(|e| match e { + craft_vectorcraft_format::Error::TooBig => FormatError::TooBig, + e => e.into(), + })?; } - Ok(()) + Ok::<(), FormatError>(()) }; match self.tail { Tail::Slice(tail) => { @@ -179,6 +170,9 @@ impl<'a> Unpacked<'a> { Tail::Stream { file, at, read, rest } => { within(wanted, MAX_BLOB, MAX_BLOBS)?; drop(self.json); + // The entries read so far, by `[offset, length]`, and the bytes copied for them. + let mut done: HashMap<[u64; 2], Entry> = HashMap::new(); + let mut copied = 0u64; let mut order: Vec = (0..wanted.len()).collect(); order.sort_by_key(|&i| wanted.get(i).map_or(0, |w| w[0])); let mut s = Stream { r: Box::new(io::Cursor::new(read).chain(rest)), pos: 0, file, at, left: work }; @@ -220,12 +214,6 @@ fn within(wanted: &[[u64; 2]], each: u64, all: u64) -> Result<(), FormatError> { if total > all { Err(FormatError::TooBig) } else { Ok(()) } } -/// `offset..offset + len` as indices, when they fit. -fn range(offset: u64, len: u64) -> Option<(usize, usize)> { - let a = usize::try_from(offset).ok()?; - Some((a, a.checked_add(usize::try_from(len).ok()?)?)) -} - /// The data after a compressed file's JSON, being unpacked. struct Stream<'a> { r: Box, diff --git a/crates/format/tests/it/native_v4.rs b/crates/format/tests/it/native_v4.rs index c24231651..0a41b2d9e 100644 --- a/crates/format/tests/it/native_v4.rs +++ b/crates/format/tests/it/native_v4.rs @@ -1,6 +1,7 @@ //! `.vectorcraft` v4: large data after the JSON (images, the PDF, profiles), small files still plain //! JSON, older versions inline, damaged offsets refused. +use std::collections::BTreeMap; use std::sync::Arc; use serde_json::Value; @@ -87,3 +88,81 @@ fn damaged_offsets_are_refused_not_a_crash() { assert!(load_file(json_only).is_err()); let _ = Arc::new(()); } + +/// A `preview` of the wrong shape reads as missing without hiding the file's PDF. +#[test] +fn a_damaged_preview_leaves_the_pdf_readable() { + let pdf = b"%PDF-1.7 small".to_vec(); + let o = SaveOptions { version: 3, preview: Some(vec![5; 30]), pdf: Some(pdf.clone()), ..SaveOptions::default() }; + let mut v: Value = serde_json::from_slice(&save_with(&doc_with_image("small", 100), &o).unwrap()).unwrap(); + v["preview"] = Value::from(5); + let damaged = serde_json::to_vec(&v).unwrap(); + assert_eq!(vectorcraft_format::preview(&damaged), None); + assert_eq!(pdf_content(&damaged), Some(pdf)); + assert!(load_file(&damaged).is_ok()); +} + +/// A repeated `generator`, `preview` or `pdf` opens, of any shape; any other repeated member is +/// refused. The crate's `Contents` and `File` accept and refuse the same files. +#[test] +fn repeated_members_open_only_when_skipped() { + let o = SaveOptions { version: 3, ..SaveOptions::default() }; + let text = String::from_utf8(save_with(&doc_with_image("small", 100), &o).unwrap()).unwrap(); + let document = serde_json::from_str::(&text).unwrap()["document"].to_string(); + let with = |members: &str| text.replacen('{', &format!("{{{members},"), 1); + let contents_reads = |s: &str| { + let contents = craft_vectorcraft_format::Contents::::read(s.as_bytes()).is_ok(); + assert_eq!(craft_vectorcraft_format::File::::read(s.as_bytes()).is_ok(), contents, "{s}"); + contents + }; + let skipped = with(r#""preview":5,"preview":{"mime":"image/png","data":"AA=="},"generator":[],"generator":"x","pdf":{},"pdf":null"#); + let back = load_file(skipped.as_bytes()).unwrap(); + assert_eq!(back.doc.images["small"].bytes.len(), 100); + assert!(contents_reads(&skipped)); + let document = format!(r#""document":{document}"#); + for member in [document.as_str(), r#""format":"vectorcraft""#, r#""version":3"#, r#""images":{}"#, r#""profiles":{}"#] { + let repeated = with(&format!("{member},{member}")); + let err = load_file(repeated.as_bytes()).err().unwrap_or_else(|| panic!("{member}: a repeated member opened")); + assert!(err.to_string().contains("duplicate field"), "{member}: {err}"); + assert!(!contents_reads(&repeated), "{member}: the crate's Contents read a repeated member"); + } +} + +/// A file read back and saved again with the same options gives the same bytes, in every version, +/// compressed or not, with a preview, profiles and a PDF inline and after the JSON. +#[test] +fn a_file_read_back_saves_to_the_same_bytes() { + let mut d = doc_with_image("big", INLINE_MAX + 10); + d.images.insert("small".into(), ImageBlob::new("image/png", vec![1, 2, 3])); + let l = d.layers[0].id; + let id = d.alloc_id(); + let im = ImageObject { key: "small".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(l), 1, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + let profiles: BTreeMap> = [("Small".to_string(), vec![7; 40]), ("Large".to_string(), vec![9; INLINE_MAX])].into_iter().collect(); + for version in 1..=4 { + for compress in [false, true].into_iter().filter(|&c| !c || version >= 3) { + for pretty in [false, true] { + let o = SaveOptions { + version, + compress, + pretty, + preview: Some(vec![5; 300]), + profiles: profiles.clone(), + pdf: Some(vec![b'%'; INLINE_MAX * 2]), + ..SaveOptions::default() + }; + let first = save_with(&d, &o).unwrap(); + if !compress { + let json = vectorcraft_format::json_part(&first); + let env = craft_vectorcraft_format::File::::read(json).unwrap(); + assert_eq!((env.version, env.images.len(), env.profiles.len()), (version, 2, 2)); + assert!(env.preview.is_some() && env.pdf.is_some()); + } + let back = load_file(&first).unwrap(); + assert_eq!(back.profiles, profiles, "v{version} compress {compress} pretty {pretty}"); + let again = save_with(&back.doc, &SaveOptions { profiles: back.profiles, ..o }).unwrap(); + assert!(first == again, "v{version} compress {compress} pretty {pretty}: the second save differs"); + } + } + } +}