diff --git a/ROADMAP.md b/ROADMAP.md index fbe233400..e83a01efe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -83,7 +83,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so | Strokes, brushes, width profiles | 88% | 8–15 | | Appearance, transparency, graphic styles, masks | 97% | 1–2 | | Live vector effects | 85% | 4–8 | -| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 85% | 1–2 | +| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 88% | — | | 3D and Materials | ~25% | 30–55 | | Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 | | Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 | @@ -131,8 +131,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap 2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h. 3. **Launch and stability on real machines** (G10): 20–35 h. 4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h. -5. **Raster effects** (G2) alongside: the Effect Gallery's filter thumbnails and in-dialog preview area (the gallery and every Photoshop-style filter are in), 1–2 h. -6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3). +5. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3). The Effect Gallery's filter thumbnails and in-dialog preview are in (G2). ## Progress log @@ -226,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged | | 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. | diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 573a9ee16..23aeb83bb 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -125,7 +125,7 @@ const BACKGROUND: FormatOption = FormatOption { name: "background", ty: "string", default: "\"transparent\"", - description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent)", + description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent); drawn under the finished art, so blend modes do not read it", }; const ANTI_ALIAS: FormatOption = FormatOption { diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index ba99c4beb..2a12eb9c5 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -5,7 +5,7 @@ //! cut into atomic regions: the faces of the planar arrangement of every fill, outlined stroke and //! clipping path in them ([`po::regions`]). The same paints cover each whole region, so its colour //! is exact: those paints composited as the renderer composites them ([`Composer`], blend modes by -//! [`composite`]), over white (or over nothing with Preserve Alpha); in CMYK documents their inks +//! [`composite`]), with white under the finished art (or nothing with Preserve Alpha); in CMYK documents their inks //! composite, plane by plane ([`cmyk_planes`]), and the colour is CMYK. Each region becomes a path //! filled with that flat colour. Where gradients, patterns, images, opacity masks or raster //! effects reach there is no single colour: those regions are rendered into one image per group, diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 2b0b1d072..c70f4dca6 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -1,10 +1,10 @@ use super::analysis::{Scratch, outlined_text, overlapping, reach}; -use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, WHITE}; +use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE}; use super::options::FlattenOptions; use super::{ Affine, Appearance, AppearanceItem, Arc, BlendMode, Color, ColorMode, Document, EngineError, FillLayer, FillRule, ImageBlob, ImageObject, Inks, - MAX_PIXELS, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, effects, isolated_doc, outline_strokes_under, planes_cmyk, po, separate, - shape_node, shapes, unique_key, + MAX_PIXELS, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, composite, effects, isolated_doc, outline_strokes_under, planes_cmyk, po, + separate, shape_node, shapes, unique_key, }; // ---------- plan ---------- @@ -235,18 +235,14 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool if alone[3] < 0.5 / 255.0 { return None; } - let start = if o.preserve_alpha { CLEAR } else { WHITE }; - let px = if o.preserve_alpha { - alone - } else { - c.top = None; - c.eval(elems, start) - }; + // The white goes under the finished art, as under the renderer's: blend modes read only the art. + let on_page = |p: Px| if o.preserve_alpha { p } else { composite(BlendMode::Normal, WHITE, p) }; + let px = on_page(alone); let rgb = [px[0], px[1], px[2]]; // The K plane composites the same way. let k = cmyk.then(|| { c.plane = 1; - c.eval(elems, start)[0] + on_page(c.eval(elems, CLEAR))[0] }); // A paint showing as it is keeps its colour (spot, process, swatch) and overprint. let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3); @@ -266,7 +262,7 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool } /// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out -/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and +/// again, or over nothing with Preserve Alpha. The image and /// its `clip` reach a pixel further. An opaque image in a CMYK document holds the art's ink /// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs. /// Fails when the image can't be encoded. diff --git a/crates/engine/src/tests/file.rs b/crates/engine/src/tests/file.rs index 1e3a4b923..6467fe091 100644 --- a/crates/engine/src/tests/file.rs +++ b/crates/engine/src/tests/file.rs @@ -447,6 +447,27 @@ fn export_selection_keeps_the_effects_in_the_crop() { } } +/// An Outer Glow at its default Screen mode on the bare page is in a PNG on white and in a JPEG, +/// and in a transparent PNG (#6125). +#[test] +fn a_screen_outer_glow_is_in_raster_exports() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap(); + let mut pixel = |p: serde_json::Value| { + let v = s.execute("document.export", &p).unwrap(); + let img = image::load_from_memory(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap().to_rgba8(); + // 2 pt past the square's stroke, on the right. + *img.get_pixel(122, 110) + }; + for p in [json!({"format": "png", "background": "white"}), json!({"format": "jpg"})] { + let px = pixel(p.clone()); + assert!(px[0] > 235 && px[1] > 235 && px[2] < 235, "{p}: yellow on white: {px:?}"); + } + let px = pixel(json!({"format": "png", "background": "transparent"})); + assert!(px[3] > 0 && px[0] > 235 && px[1] > 235 && px[2] < 40, "the glow is yellow in the transparent image: {px:?}"); +} + /// A group's own drop shadow is inside the crop of the group's selection export too. #[test] fn export_selection_keeps_a_groups_shadow_in_the_crop() { diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index 6a93b80a3..31ceb2434 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -251,11 +251,16 @@ fn every_builtin_style_paints_visibly_on_a_rectangle() { let (_, lib) = cmd::stylelib::library(&s, b.id).unwrap(); for g in &lib.styles { run(&mut s, "file.new", json!({"width": 100, "height": 100})); + // Art under the rectangle: a blend mode shows only against art, not the bare page. + let under = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + run(&mut s, "paint.setFill", json!({"color": "#336699", "ids": [under.0]})); + run(&mut s, "paint.setStroke", json!({"none": true, "ids": [under.0]})); let id = rect(&mut s, 25.0, 30.0, 50.0, 40.0); run(&mut s, "graphicStyle.addFromLibrary", json!({"library": b.id, "name": g.name, "apply": true})); assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name); let img = renderer.render_region(doc(&s), Rect::new(0.0, 0.0, 100.0, 100.0), 0.5, true); - let painted = img.pixels.as_chunks::<4>().0.iter().filter(|p| p[..3] != [255, 255, 255]).count(); + let bare = img.pixel(0, 0); + let painted = (0..img.height).flat_map(|y| (0..img.width).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y) != bare).count(); assert!(painted > 40, "{} / {} paints {painted} pixels", b.id, g.name); } } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 6423578af..ffc185a57 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -30,6 +30,7 @@ use crate::import_shading::{ use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; +mod actual_text; mod builder; mod cid; mod device; diff --git a/crates/pdf/src/import/actual_text.rs b/crates/pdf/src/import/actual_text.rs new file mode 100644 index 000000000..d6d18a756 --- /dev/null +++ b/crates/pdf/src/import/actual_text.rs @@ -0,0 +1,95 @@ +//! Bounded ownership of authored replacement text (ISO 32000-1, 14.9.4). + +use super::state::Builder; +use super::{Affine, Look, Placement, Shape, TextLine, Upright}; +use crate::import_text::TextContent; + +/// Only one replacement is retained: the outermost owner, even after it is consumed. +/// Depth is counted separately so ignored deep tags still unwind to the right owner. +#[derive(Default)] +pub(super) struct ActualText { + depth: usize, + owner: Option, + pending: Option, +} + +impl ActualText { + pub(super) fn begin(&mut self) { + self.depth = self.depth.saturating_add(1); + } + + pub(super) fn end(&mut self) { + if self.owner == Some(self.depth) { + self.owner = None; + self.pending = None; + } + self.depth = self.depth.saturating_sub(1); + } + + pub(super) fn active(&self) -> bool { + self.owner.is_some() + } + + pub(super) fn set(&mut self, bytes: &[u8]) -> bool { + if self.active() { + return true; + } + // Bound before decoding: at most 64 KiB encoded / 192 KiB UTF-8, and no + // allocation per nested tag. Oversized/deep replacements use ordinary mappings. + if self.depth == 0 || self.depth > 128 || bytes.len() > 64 * 1024 { + return false; + } + self.owner = Some(self.depth); + self.pending = Some(crate::import_scan::text_string(bytes)); + true + } +} + +impl Builder<'_> { + /// The authored path never enters ordinary visual glyph reordering. The font was matched + /// against the glyph's original Unicode, not against this replacement's characters. + pub(super) fn actual_glyph(&mut self, o: &craft_pdf::interpret::font::OutlineGlyph, m: Affine, at: Placement, look: Look, opacity: f32) -> bool { + let key = o.font_cache_key(); + self.flush_glyphs(); + // A fill and stroke callback for the same glyph consumes its text only once. + if let (Some(stroke), Some(line)) = (&look.stroke, &mut self.text) + && look.fill.is_none() + && line.stroke_actual(key, at, stroke.clone(), look.overprint_stroke) + { + return true; + } + let width = o.advance_width().filter(|w| w.is_finite()); + let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0); + let top = width.filter(|w| w.abs() <= 1.0).map(|_| { + let vx = self.fonts.get(&key).and_then(|f| f.vertical.as_ref()).map_or(500.0, |v| v.centre(o.glyph_id().to_u32())); + Upright { top: m * kurbo::Point::new(vx, 880.0) } + }); + // Keep pending until placement succeeds, including across a line flush. + let pending = self.actual.pending.take(); + let content = match pending.as_deref() { + Some(text) if !text.is_empty() => TextContent::Actual(text), + _ => TextContent::Pen, + }; + let place = |line: &mut TextLine| match top { + Some(top) => line.push_upright_content(&look, at, opacity, top, content), + None => line.push_content(&look, at, opacity, advance, content), + }; + if !self.text.as_mut().is_some_and(place) { + self.flush_text(); + let mut line = TextLine::new(at, opacity); + if !place(&mut line) { + self.actual.pending = pending; + return false; + } + self.text = Some(line); + } + let mut ink = o.outline(); + if let Some(line) = &mut self.text + && !ink.elements().is_empty() + { + ink.apply_affine(m); + line.ink.push(ink.bounding_box()); + } + true + } +} diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index b16b49b03..5fb504fd9 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -23,6 +23,7 @@ impl<'p> Builder<'p> { glyphs: None, text: None, text_as, + actual: Default::default(), images: HashMap::new(), image_keys: HashMap::new(), cmyk_keys: HashSet::new(), @@ -102,6 +103,7 @@ impl<'p> Builder<'p> { self.mask = None; self.masked.clear(); self.marked.clear(); + self.actual = Default::default(); (self.scan, self.tag_at, self.aligned, self.paint_at) = (scan, 0, true, 0); if self.scan.overprints && self.scan.zero_mode { self.warn(OVERPRINT_ZERO_MODE); @@ -749,6 +751,7 @@ impl<'p> Builder<'p> { if self.text_as != TextAs::Text || self.nested > 0 { return false; } + let authored = self.actual.active(); let key = o.font_cache_key(); let glyph = o.glyph_id().to_u32(); let (unicode, from_cid) = match o.as_unicode().filter(|u| !replacement(u)) { @@ -773,6 +776,7 @@ impl<'p> Builder<'p> { let text: String = match unicode { Some(BfString::Char(c)) => c.to_string(), Some(BfString::String(s)) => s, + None if authored => String::new(), None => { // Invisible text isn't kept as outlines: the caller says it was left out. if paint.is_some() && o.as_unicode().is_some_and(|u| replacement(&u)) { @@ -785,7 +789,7 @@ impl<'p> Builder<'p> { // Adobe's CID tables map some ideographs to their look-alike Kangxi radicals. let text: String = if from_cid { text.chars().map(unify_radical).collect() } else { text }; let Some(at) = Placement::of(m) else { return false }; - if text.is_empty() || text.chars().any(|c| c.is_control()) { + if !authored && (text.is_empty() || text.chars().any(|c| c.is_control())) { return false; } let width = o.advance_width().filter(|w| w.is_finite()); @@ -849,6 +853,9 @@ impl<'p> Builder<'p> { fill: if stroke.is_none() { paint } else { None }, stroke, }; + if authored { + return self.actual_glyph(o, m, at, look, opacity); + } let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0); // A glyph set vertically is drawn from its origin, which the file puts the position // vector's vx (from the font's `W2`, else half its width from `W` or `DW`) to the left of diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index 53da5a521..731a74d07 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -378,6 +378,7 @@ impl<'a> Device<'a> for Builder<'_> { if self.nested > 0 { return; } + self.actual.begin(); let at = self.tag_at; self.tag_at += 1; let group = match self.scan.tags.get(at) { @@ -416,5 +417,12 @@ impl<'a> Device<'a> for Builder<'_> { self.flush(); } self.marked.pop(); + self.actual.end(); + } + + fn actual_text(&mut self, text: &[u8]) { + if self.nested == 0 && self.text_as == crate::TextAs::Text && !self.actual.set(text) { + self.warn("ActualText beyond the import size or nesting limit was left to glyph mappings"); + } } } diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index 0eb100b36..93a84ae68 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -82,6 +82,7 @@ pub(super) struct Builder<'p> { pub(super) glyphs: Option, pub(super) text: Option, pub(super) text_as: TextAs, + pub(super) actual: super::actual_text::ActualText, pub(super) images: HashMap, pub(super) image_keys: HashMap, /// The images kept with their CMYK samples ([`crate::import_image`]). diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 8157759e7..a90d6a33b 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -146,6 +146,27 @@ fn common_gap(gaps: &[Gap]) -> f64 { if *common > 0.5 { 0.0 } else { common.max(0.0) } } +/// A mapped glyph, authored logical text, or a glyph whose replacement was already placed. +#[derive(Clone, Copy)] +pub(crate) enum TextContent<'a> { + Mapped(&'a str), + Actual(&'a str), + Pen, +} + +impl<'a> TextContent<'a> { + fn text(self) -> &'a str { + match self { + Self::Mapped(text) | Self::Actual(text) => text, + Self::Pen => "", + } + } + + fn authored(self) -> bool { + !matches!(self, Self::Mapped(_)) + } +} + /// A line of type being gathered: runs of glyphs on one baseline. pub(crate) struct TextLine { /// The first glyph's placement. @@ -173,6 +194,10 @@ pub(crate) struct TextLine { /// glyph has its glyph's [`Slot`]; a space read from a gap and the other characters of a /// glyph of several have none. slots: Vec>, + /// Per character: authored logical order must survive visual-to-logical reconstruction. + authored: Vec, + /// Fill callbacks in an authored span, for the reader's later stroke pass. + actual_fills: HashMap<(u128, u64, u64), Option>, /// The first glyph was set vertically ([`Upright`]). upright: Option, /// The glyphs make a column (vertical type), each below the last. @@ -232,6 +257,8 @@ impl TextLine { runs: vec![], text_bytes: 0, slots: vec![], + authored: vec![], + actual_fills: HashMap::new(), upright: None, column: false, ink: vec![], @@ -251,10 +278,24 @@ impl TextLine { /// line's single glyph a column): below the last one, in the same column. `false`: it starts /// another line. pub fn push_upright(&mut self, look: &Look, at: Placement, opacity: f32, upright: Upright, text: &str) -> bool { + self.push_upright_content(look, at, opacity, upright, TextContent::Mapped(text)) + } + + pub fn push_upright_content(&mut self, look: &Look, at: Placement, opacity: f32, upright: Upright, content: TextContent<'_>) -> bool { + let text = content.text(); + if !text.is_empty() && self.authored.is_empty() && self.upright.is_some() { + // Suppressed vertical glyphs likewise provide no text anchor. + self.at = at; + self.opacity = opacity; + self.runs.clear(); + self.column = false; + } let down = -self.at.up(); let size = self.at.size.max(at.size); let Some((last, _, run)) = self.runs.last_mut() else { self.runs.push((look.clone(), 0.0, text.to_string())); + self.record_actual_fill(look, at, content); + self.authored.extend(text.chars().map(|_| content.authored())); (self.upright, self.next, self.last) = (Some(upright), at.origin + down * at.size, at.origin); return true; }; @@ -276,14 +317,20 @@ impl TextLine { self.column = true; // Letter-spaced Japanese is common: a gap reads as a space only when a whole em is left // out (the tracking set in `finish` keeps narrower gaps). - if step > size * 0.9 && !run.ends_with(is_space) && !text.starts_with(is_space) { + if !matches!(content, TextContent::Pen) && step > size * 0.9 && !run.is_empty() && !run.ends_with(is_space) && !text.starts_with(is_space) { run.push(' '); + self.authored.push(false); } - if last.takes(look) { + if last.takes(look) + && (matches!(content, TextContent::Pen) + || (!matches!(content, TextContent::Actual(_)) && !self.authored.last().copied().unwrap_or(false))) + { run.push_str(text); } else { self.runs.push((look.clone(), 0.0, text.to_string())); } + self.record_actual_fill(look, at, content); + self.authored.extend(text.chars().map(|_| content.authored())); self.next = at.origin + down * at.size; self.last = at.origin; true @@ -292,10 +339,28 @@ impl TextLine { /// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if /// it continues this line; `false`: it starts another. pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool { + self.push_content(look, at, opacity, advance, TextContent::Mapped(text)) + } + + pub fn push_content(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, content: TextContent<'_>) -> bool { + let text = content.text(); + let pen = matches!(content, TextContent::Pen); // A vertical line (even a single upright glyph) takes no horizontal glyphs. if self.column || self.upright.is_some() { return false; } + if !pen && self.text_bytes == 0 && !self.baseline.is_empty() { + // A line containing only suppressed glyphs has no text anchor yet. Its first + // real character starts at its own origin, including after a line flush. + self.at = at; + self.opacity = opacity; + self.next = at.origin; + self.last = at.origin; + self.last_dir = at.dir; + self.last_size = at.size; + self.baseline.clear(); + self.runs.clear(); + } let bytes = self.text_bytes; let glyph = self.baseline.len(); let curved = self.curved(); @@ -334,12 +399,12 @@ impl TextLine { if !on_line && !on_curve { return false; } - if !run.ends_with(is_space) && !text.starts_with(is_space) { + if !pen && !run.is_empty() && !run.ends_with(is_space) && !text.starts_with(is_space) { self.gaps.push(Gap { at: bytes, width: gap, em: self.last_size.min(at.size), on_line }); } // A space the file draws between two glyphs spans the gaps on both sides of it. - let drawn = self.drawn_space.take(); - if on_line { + let drawn = if pen { None } else { self.drawn_space.take() }; + if on_line && !pen { let mut chars = text.chars(); if chars.next().is_some_and(is_space) && chars.next().is_none() && !run.ends_with(is_space) { self.drawn_space = Some((bytes, gap + advance)); @@ -347,7 +412,10 @@ impl TextLine { self.drawn_spaces.push((b, before + gap)); } } - if last.takes(look) && (*last_shift - shift).abs() < 1e-3 { + if last.takes(look) + && (*last_shift - shift).abs() < 1e-3 + && (pen || (!matches!(content, TextContent::Actual(_)) && !self.authored.last().copied().unwrap_or(false))) + { run.push_str(text); } else { self.runs.push((look.clone(), shift, text.to_string())); @@ -355,12 +423,18 @@ impl TextLine { } else { self.runs.push((look.clone(), 0.0, text.to_string())); } + self.record_actual_fill(look, at, content); self.text_bytes = self.text_bytes.saturating_add(text.len()); - let one = text.chars().count() == 1; - self.slots.push(Some(Slot { glyph, advance: one.then_some(advance) })); - self.slots.extend(text.chars().skip(1).map(|_| None)); + if !pen { + let one = text.chars().count() == 1 && !content.authored(); + self.slots.push(Some(Slot { glyph, advance: one.then_some(advance) })); + self.slots.extend(text.chars().skip(1).map(|_| None)); + self.authored.extend(text.chars().map(|_| content.authored())); + } + // Pen-only glyphs retain a baseline but no character slot: later text still points + // to its own glyph, even after several suppressed glyphs or a style change. self.next = at.origin + at.dir * advance; - if !text.trim().is_empty() { + if pen || !text.trim().is_empty() { self.visible = self.next; } self.last = at.origin; @@ -370,6 +444,25 @@ impl TextLine { true } + fn record_actual_fill(&mut self, look: &Look, at: Placement, content: TextContent<'_>) { + if content.authored() && look.fill.is_some() { + let run = matches!(content, TextContent::Actual(_)).then(|| self.runs.len().saturating_sub(1)); + self.actual_fills.insert((look.font, at.origin.x.to_bits(), at.origin.y.to_bits()), run); + } + } + + /// The reader fills a whole Tj/TJ before replaying its glyphs as strokes. A replay + /// must neither consume the replacement nor rewind the pen. Only the first glyph's + /// style belongs to the replacement; suppressed glyphs have no text to restyle. + pub fn stroke_actual(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool { + let key = (font, at.origin.x.to_bits(), at.origin.y.to_bits()); + let Some(run) = self.actual_fills.remove(&key) else { return false }; + if let Some((look, ..)) = run.and_then(|i| self.runs.get_mut(i)) { + (look.stroke, look.overprint_stroke) = (Some(stroke), overprint); + } + true + } + /// Has the baseline turned (more than about 3° from the first glyph to the last)? fn curved(&self) -> bool { self.last_dir.dot(self.at.dir) < 0.9986 @@ -416,16 +509,27 @@ impl TextLine { // Turned along a curve: type on a path through the glyphs. let path = if self.curved() { self.baseline_path() } else { None }; facts.wraps = path.is_none() && self.upright.is_none() && self.runs.last().is_some_and(|(.., t)| t.ends_with(' ')); - if let Some((.., last)) = self.runs.last_mut() { - last.truncate(last.trim_end().len()); + if !self.authored.last().copied().unwrap_or(false) + && let Some((.., last)) = self.runs.last_mut() + { + // Trim only ordinary trailing spaces, never authored replacement characters. + let trim = last + .chars() + .rev() + .zip(self.authored.iter().rev()) + .take_while(|(c, authored)| c.is_whitespace() && !**authored) + .map(|(c, _)| c.len_utf8()) + .sum::(); + last.truncate(last.len().saturating_sub(trim)); } self.runs.retain(|(.., t)| !t.is_empty()); - if self.runs.iter().all(|(.., t)| t.trim().is_empty()) { + if self.runs.iter().all(|(.., t)| t.is_empty()) || (!self.authored.contains(&true) && self.runs.iter().all(|(.., t)| t.trim().is_empty())) { return None; } // The spaces trimmed off the end have no place either. let chars: usize = self.runs.iter().map(|(.., t)| t.chars().count()).sum(); self.slots.truncate(chars); + self.authored.truncate(chars); // The baseline most of the characters sit on is the line's: the others are shifted off it // (a line starting with a raised footnote mark keeps the baseline of its text). let mut on: Vec<(f64, usize)> = vec![]; @@ -493,6 +597,7 @@ impl TextLine { let char_at = earlier + head.chars().count(); if char_at <= self.slots.len() { self.slots.insert(char_at, None); + self.authored.insert(char_at, false); } run.text.insert(at, ' '); spaces.push((gap.at, gap.width, gap.on_line)); @@ -502,7 +607,7 @@ impl TextLine { gaps.extend(self.drawn_spaces.iter().map(|&(b, width)| (b + spaces.iter().filter(|s| s.0 < b).count(), width))); gaps.sort_by_key(|&(b, _)| b); if self.upright.is_none() { - to_logical(&mut t); + to_logical(&mut t, &self.authored); } let angle = self.at.dir.atan2(); // The glyphs' tops lean along the baseline (+x), up being -y in the type's own space. @@ -1055,9 +1160,26 @@ fn is_style(s: &str) -> bool { /// Hebrew or Arabic comes from a PDF in visual order (each glyph where it is drawn): put `t`'s text /// back in logical order, with the paragraph direction that shows it as drawn. -fn to_logical(t: &mut TextObject) { - let Some((order, rtl)) = vectorcraft_text::logical_order(&t.plain_text()) else { return }; +fn to_logical(t: &mut TextObject, authored: &[bool]) { + let Some((mut order, rtl)) = vectorcraft_text::logical_order(&t.plain_text()) else { return }; let chars: Vec<(char, usize)> = t.runs.iter().enumerate().flat_map(|(i, r)| r.text.chars().map(move |c| (c, i))).collect(); + if authored.contains(&true) { + // Explicit spans are logical-order islands. Reconstruct each ordinary stretch on + // either side independently; never reverse authored Arabic or its joiners again. + order.clear(); + let mut start = 0; + while start < chars.len() { + let actual = authored.get(start).copied().unwrap_or(false); + let end = (start + 1..chars.len()).find(|&i| authored.get(i).copied().unwrap_or(false) != actual).unwrap_or(chars.len()); + let text: String = chars.get(start..end).unwrap_or_default().iter().map(|(c, _)| c).collect(); + if !actual && let Some((local, _)) = vectorcraft_text::logical_order(&text) { + order.extend(local.into_iter().map(|i| start + i)); + } else { + order.extend(start..end); + } + start = end; + } + } let mut runs: Vec<(usize, String)> = Vec::with_capacity(t.runs.len()); for &(c, i) in order.iter().filter_map(|&k| chars.get(k)) { match runs.last_mut() { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index eb9a4d284..a8a22b00f 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -8,6 +8,7 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes}; use crate::*; +mod actual_text; mod blend; mod charstroke; mod cmykblend; diff --git a/crates/pdf/src/tests/actual_text.rs b/crates/pdf/src/tests/actual_text.rs new file mode 100644 index 000000000..8e6bd4810 --- /dev/null +++ b/crates/pdf/src/tests/actual_text.rs @@ -0,0 +1,309 @@ +//! Original, synthetic PDF content; no external fonts needed for semantic coverage. + +use crate::{ImportOptions, ImportReport, TextAs, import_with_report}; +use vectorcraft_doc::{NodeKind, TextObject}; + +fn stream(entries: &str, content: &[u8]) -> Vec { + let mut bytes = format!("<< /Length {} {entries} >>\nstream\n", content.len()).into_bytes(); + bytes.extend_from_slice(content); + bytes.extend_from_slice(b"\nendstream"); + bytes +} + +fn document(objects: &[Vec]) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![0]; + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend(format!("{} 0 obj\n", i + 1).bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len()).bytes()); + for offset in offsets.iter().skip(1) { + bytes.extend(format!("{offset:010} 00000 n \n").bytes()); + } + bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len()).bytes()); + bytes +} + +fn pdf(pages: &[&str]) -> Vec { + let kids = (0..pages.len()).map(|i| format!("{} 0 R", 4 + i * 2)).collect::>().join(" "); + let mut objects = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + format!("<< /Type /Pages /Kids [{kids}] /Count {} >>", pages.len()).into_bytes(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + ]; + for (i, content) in pages.iter().enumerate() { + objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F 3 0 R >> /Properties << /Named << /ActualText (Named) >> >> >> /Contents {} 0 R >>", 5 + i * 2).into_bytes()); + objects.push(stream("", format!("BT /F 20 Tf 1 0 0 1 20 700 Tm {content} ET").as_bytes())); + } + document(&objects) +} + +fn open(content: &str) -> ImportReport { + import_with_report(&pdf(&[content]), &ImportOptions::default()).unwrap() +} + +// Geometry assertions need the PDF and layout to use the same widths. Unembedded +// Helvetica is not installed on every platform; missing-font layout fits the overall +// line length, so removing characters changes even an ordinary suffix's position. +fn open_positioned(content: &str) -> ImportReport { + let data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf"); + let face = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap(); + assert_eq!(face.file_data(), data); + let widths = (32u8..=126) + .map(|c| { + let glyph = face.glyph_for(char::from(c)); + assert_ne!(glyph, 0); + (face.advance(glyph) * 1000.0 / face.units_per_em()).to_string() + }) + .collect::>() + .join(" "); + let font = format!( + "<< /Type /Font /Subtype /TrueType /BaseFont /SourceSans3-Regular /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R /FirstChar 32 /LastChar 126 /Widths [{widths}] >>" + ); + let descriptor = b"<< /Type /FontDescriptor /FontName /SourceSans3-Regular /Flags 32 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1000 /Descent -300 /CapHeight 700 /StemV 80 /FontFile2 7 0 R >>".to_vec(); + let bytes = custom_pdf(content, font.as_bytes(), "", &[descriptor, stream("", data)]); + import_with_report(&bytes, &ImportOptions::default()).unwrap() +} + +fn texts(report: &ImportReport) -> Vec { + let mut result = vec![]; + report.document.walk(|node| { + if let NodeKind::Text(text) = &node.kind { + result.push((**text).clone()); + } + }); + result +} + +fn plain(report: &ImportReport) -> String { + texts(report).iter().map(TextObject::plain_text).collect() +} + +fn utf16(text: &str) -> String { + format!("", text.encode_utf16().map(|u| format!("{u:04X}")).collect::()) +} + +fn character_origin(report: &ImportReport, character: char) -> kurbo::Point { + for text in texts(report) { + let plain = text.plain_text(); + if let Some(byte) = plain.find(character) { + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text); + let glyph = layout.glyphs.iter().find(|g| g.byte == byte).unwrap(); + return text.xf * glyph.origin; + } + } + panic!("missing {character}"); +} + +#[test] +fn actual_text_replaces_once_and_keeps_suffix_position() { + let replaced = open_positioned("/Span << /ActualText (AB) >> BDC (xy) Tj EMC (C) Tj"); + let ordinary = open_positioned("(xyC) Tj"); + assert_eq!(plain(&replaced), "ABC"); + let delta = character_origin(&replaced, 'C') - character_origin(&ordinary, 'C'); + assert!(delta.hypot() < 0.05, "suffix moved: {delta:?}"); +} + +#[test] +fn actual_text_empty_and_nested_owners() { + for (content, expected) in [ + ("(P) Tj /Span << /ActualText () >> BDC (xy) Tj EMC (C) Tj", "PC"), + ("/Span << /ActualText (AB) >> BDC /Span << /ActualText (INNER) >> BDC (x) Tj EMC (y) Tj EMC (C) Tj", "ABC"), + ("/Span << /ActualText () >> BDC /Span << /ActualText (INNER) >> BDC (xy) Tj EMC EMC (C) Tj", "C"), + ("/Span BMC /Span << /ActualText (AB) >> BDC (xy) Tj EMC EMC (C) Tj", "ABC"), + ("/Span << /ActualText (A) >> BDC (x) Tj EMC /Span << /ActualText (B) >> BDC (y) Tj EMC (C) Tj", "ABC"), + ] { + assert_eq!(plain(&open(content)), expected, "{content}"); + } +} + +#[test] +fn actual_text_decodes_pdf_strings_and_preserves_authored_order() { + for (encoded, expected) in [ + (utf16("ل\u{200d}ا\u{200c}ب"), "ل\u{200d}ا\u{200c}ب"), + ("<8093A0>".into(), "•fi€"), + ("".into(), "Ab"), + ("( A )".into(), " A "), + ("( )".into(), " "), + (utf16("A\nB"), "A\nB"), + ] { + assert_eq!(plain(&open(&format!("/Span << /ActualText {encoded} >> BDC (xy) Tj EMC"))), expected); + } + assert_eq!(plain(&open("/Span /Named BDC (xy) Tj EMC")), "Named"); +} + +#[test] +fn actual_text_across_operations_styles_and_line_flushes() { + let report = open_positioned("/Span << /ActualText (AB) >> BDC (x) Tj [(y) -400 (z)] TJ 1 0 0 rg /F 24 Tf (w) Tj EMC (C) Tj"); + assert_eq!(plain(&report), "ABC"); + let report = open_positioned("/Span << /ActualText (AB) >> BDC (x) Tj 0 -40 Td (y) Tj EMC (C) Tj"); + assert_eq!(plain(&report), "ABC"); + let ordinary = open_positioned("(x) Tj 0 -40 Td (yC) Tj"); + assert!((character_origin(&report, 'C') - character_origin(&ordinary, 'C')).hypot() < 0.05); + // The replacement belongs to the first placed glyph's style and line. + assert_eq!(texts(&report)[0].plain_text(), "AB"); +} + +#[test] +fn actual_text_fill_and_stroke_is_not_duplicated() { + let report = open("2 Tr /Span << /ActualText (AB) >> BDC [(xy) 0 (zw)] TJ EMC (C) Tj"); + assert_eq!(plain(&report), "ABC"); + assert!(texts(&report).iter().flat_map(|t| &t.runs).all(|r| r.style.has_stroke())); +} + +#[test] +fn actual_text_page_reset_and_unbalanced_end() { + let bytes = pdf(&["/Span << /ActualText (AB) >> BDC (xy) Tj", "EMC (C) Tj"]); + let report = import_with_report(&bytes, &ImportOptions::default()).unwrap(); + assert_eq!(plain(&report), "ABC"); +} + +#[test] +fn actual_text_outlines_are_unchanged() { + let outline = |content| { + let report = import_with_report(&pdf(&[content]), &ImportOptions { text_as: TextAs::Outlines, ..Default::default() }).unwrap(); + assert!(texts(&report).is_empty()); + let mut paths = vec![]; + report.document.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + paths.push(path.to_bezpath()); + } + }); + paths + }; + assert_eq!(outline("/Span << /ActualText (AB) >> BDC (xy) Tj EMC"), outline("(xy) Tj")); +} + +#[test] +fn actual_text_bounded_replacements_recover_siblings() { + let oversized = "A".repeat(64 * 1024 + 1); + let report = open(&format!("/Span << /ActualText ({oversized}) >> BDC (x) Tj EMC /Span << /ActualText (B) >> BDC (y) Tj EMC")); + assert_eq!(plain(&report), "xB"); + assert!(report.warnings.iter().any(|w| w.contains("ActualText beyond"))); + let deep = format!( + "{} /Span << /ActualText (ignored) >> BDC (x) Tj EMC {} /Span << /ActualText (B) >> BDC (y) Tj EMC", + "/Span BMC ".repeat(128), + "EMC ".repeat(128) + ); + assert_eq!(plain(&open(&deep)), "xB"); +} + +#[test] +fn actual_text_survives_reader_windows() { + let content = format!("/Span << /ActualText (AB) >> BDC (x) Tj {} (y) Tj EMC (C) Tj", "q Q ".repeat(20_000)); + assert_eq!(plain(&open(&content)), "ABC"); +} + +fn custom_pdf(content: &str, font: &[u8], resources: &str, extra: &[Vec]) -> Vec { + let mut objects = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(), + font.to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F 3 0 R >> {resources} >> /Contents 5 0 R >>") + .into_bytes(), + stream("", format!("BT /F 20 Tf 1 0 0 1 20 700 Tm {content} ET").as_bytes()), + ]; + objects.extend_from_slice(extra); + document(&objects) +} + +#[test] +fn actual_text_mixed_line_still_reconstructs_ordinary_rtl() { + let map = stream("", b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <00> endcodespacerange 4 beginbfchar <78> <05DD> <79> <05D5> <7A> <05DC> <77> <05E9> endbfchar endcmap end end"); + let content = format!("(xyzw) Tj /Span << /ActualText {} >> BDC (xy) Tj EMC (xyzw) Tj", utf16("ل\u{200d}ا")); + let bytes = custom_pdf(&content, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /ToUnicode 6 0 R >>", "", &[map]); + let report = import_with_report(&bytes, &ImportOptions::default()).unwrap(); + assert_eq!(texts(&report).len(), 1); + assert_eq!(plain(&report), "שלוםل\u{200d}اשלום"); +} + +#[test] +fn actual_text_rescues_a_glyph_without_unicode() { + // Identity-H with no ToUnicode or embedded font: CID 65535 has no character mapping. + let font = b"<< /Type /Font /Subtype /Type0 /BaseFont /Unmapped /Encoding /Identity-H /DescendantFonts [6 0 R] >>"; + let descendant = b"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Unmapped /CIDSystemInfo << /Registry (Unknown) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /DW 500 >>".to_vec(); + let descriptor = b"<< /Type /FontDescriptor /FontName /Unmapped /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 >>".to_vec(); + let extra = [descendant, descriptor]; + let import = |content| import_with_report(&custom_pdf(content, font, "", &extra), &ImportOptions::default()).unwrap(); + assert!(plain(&import("3 Tr Tj")).is_empty(), "the fixture really has no usable mapping"); + assert_eq!(plain(&import("3 Tr /Span << /ActualText (AB) >> BDC Tj EMC")), "AB"); +} + +#[test] +fn actual_text_matches_embedded_glyph_identity_not_replacement() { + let font = b"<< /Type /Font /Subtype /TrueType /BaseFont /SourceSans3-Regular /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R /FirstChar 120 /LastChar 121 /Widths [500 500] >>"; + let descriptor = b"<< /Type /FontDescriptor /FontName /SourceSans3-Regular /Flags 32 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1000 /Descent -300 /CapHeight 700 /StemV 80 /FontFile2 7 0 R >>".to_vec(); + let data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf"); + let bytes = custom_pdf("/Span << /ActualText (AB) >> BDC (xy) Tj EMC", font, "", &[descriptor, stream("", data)]); + let report = import_with_report(&bytes, &ImportOptions::default()).unwrap(); + assert_eq!(plain(&report), "AB"); + assert!(!report.warnings.iter().any(|w| w.contains("differ from the installed font")), "{:?}", report.warnings); +} + +#[test] +fn actual_text_nested_mask_interpretation_does_not_consume_page_owner() { + let mask = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 600 800] /Group << /S /Transparency /CS /DeviceGray >> /Resources << /Font << /F 3 0 R >> >>", + b"/Span << /ActualText (MASK) >> BDC BT /F 20 Tf 1 0 0 1 20 700 Tm (mask) Tj ET EMC 1 g 0 0 600 800 re f", + ); + let bytes = custom_pdf( + "/Span << /ActualText (AB) >> BDC /Mask gs (xy) Tj EMC", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "/ExtGState << /Mask << /SMask << /S /Luminosity /G 6 0 R >> >> >>", + &[mask], + ); + let report = import_with_report(&bytes, &ImportOptions::default()).unwrap(); + assert_eq!(plain(&report), "AB"); +} + +#[test] +fn actual_text_empty_span_keeps_following_position_without_slots() { + for (content, baseline) in [ + ("(P) Tj /Span << /ActualText () >> BDC (xy) Tj EMC (CD) Tj", "(PxyCD) Tj"), + ("/Span << /ActualText () >> BDC (xy) Tj EMC (CD) Tj", "(xyCD) Tj"), + ] { + let report = open_positioned(content); + let ordinary = open_positioned(baseline); + assert_eq!(plain(&report), if content.starts_with("(P)") { "PCD" } else { "CD" }); + for c in ['C', 'D'] { + assert!((character_origin(&report, c) - character_origin(&ordinary, c)).hypot() < 0.05, "{content}: {c}"); + } + } +} + +#[test] +fn actual_text_waits_for_a_placeable_glyph() { + let report = open("/Span << /ActualText (AB) >> BDC -1 0 0 1 20 700 Tm (x) Tj 1 0 0 1 20 660 Tm (y) Tj EMC (C) Tj"); + assert_eq!(plain(&report), "ABC"); +} + +#[test] +fn actual_text_stroke_does_not_restyle_ordinary_prefix() { + let report = open("(P) Tj 2 Tr /Span << /ActualText (AB) >> BDC (xyzw) Tj EMC"); + assert_eq!(plain(&report), "PAB"); + for text in texts(&report) { + for run in text.runs { + assert_eq!(run.style.has_stroke(), !run.text.contains('P'), "{:?}", run.text); + } + } +} + +#[test] +fn actual_text_vertical_column_keeps_replacement_once() { + let font = b"<< /Type /Font /Subtype /Type0 /BaseFont /Vertical /Encoding /Identity-V /DescendantFonts [6 0 R] /ToUnicode 8 0 R >>"; + let descendant = b"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Vertical /CIDSystemInfo << /Registry (Unknown) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /DW 500 /DW2 [880 -1000] >>".to_vec(); + let descriptor = b"<< /Type /FontDescriptor /FontName /Vertical /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 >>".to_vec(); + let map = stream("", b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 3 beginbfchar <0001> <0078> <0002> <0079> <0003> <0043> endbfchar endcmap end end"); + let extra = [descendant, descriptor, map]; + for (replacement, expected) in [("AB", "ABC"), ("", "C")] { + let content = format!("/Span << /ActualText ({replacement}) >> BDC <00010002> Tj EMC <0003> Tj"); + let bytes = custom_pdf(&content, font, "", &extra); + let report = import_with_report(&bytes, &ImportOptions::default()).unwrap(); + assert_eq!(plain(&report), expected); + assert!(texts(&report).iter().all(|text| text.vertical)); + } +} diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 974557e50..31ffc6351 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -184,8 +184,8 @@ fn shadow_rasters_keep_their_node_and_a_byte_budget() { /// kept for any pan: its grid starts at the reach's first whole device pixel, which a pan by whole /// pixels moves with the art, so the raster drawn after the pan is the one a fresh renderer makes, /// also for pans that aren't multiples of the raster's scale. A pan by a fraction of a pixel -/// places the raster at the nearest whole pixel, as a full-size raster kept whole is placed, and -/// here draws as a fresh renderer draws too. A 100 pt glow at 500% (scale 2) and a 2 pt glow +/// keeps the raster and places it at the nearest whole pixel, as a full-size raster kept whole is +/// placed. A 100 pt glow at 500% (scale 2) and a 2 pt glow /// round the page at 800% (scale 3, near the narrowest blur made coarser). #[test] fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() { @@ -212,7 +212,9 @@ fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() { assert_eq!(image(&r), kept, "blur {blur}, pan {pan:?}: the raster kept is drawn"); let mut fresh = Renderer::new(); fresh.threads = 2; - assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}"); + if pan.0.fract() == 0.0 && pan.1.fract() == 0.0 { + assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}"); + } } } } diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index c8df531a8..2d67db207 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -176,7 +176,7 @@ impl Ink { peniko::Color::from_rgba8(r, g, b, a) } - fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] { + pub(crate) fn fixed8(self, [r, g, b, a]: [u8; 4]) -> [u8; 4] { match self.base() { Ink::Display | Ink::RichBlack => [r, g, b, a], _ => { diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 2a188fc0a..f5bcecaaa 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -243,7 +243,9 @@ impl Renderer { self.live.sweep(g); } - /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. + /// Draw frame `f` (`w`×`h` pixels): the artboards and the page's art, then the background + /// under them. The art blends only with itself, as it does on the canvas, where the paper is + /// drawn apart from it: a Screen glow on the bare page stays visible on a white background (#6125). fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec { let (threads, opts) = (self.threads, f.opts); let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h); @@ -258,11 +260,6 @@ impl Renderer { ctx.set_aliasing_threshold(opts.anti_alias.threshold()); let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed }; self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() }; - if let Some(bg) = opts.background { - ctx.set_transform(Affine::IDENTITY); - ctx.set_paint(f.ink.fixed(bg)); - ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64)); - } if opts.artboards && !opts.outline { // Paper: white on screen, no ink on the ink planes. ctx.set_transform(f.view); @@ -289,7 +286,11 @@ impl Renderer { if self.contexts.len() > 2 { self.contexts.remove(0); } - pm.data_as_u8_slice().to_vec() + let mut pixels = pm.data_as_u8_slice().to_vec(); + if let Some(bg) = opts.background { + over_background(&mut pixels, f.ink.fixed8(bg)); + } + pixels } /// The page's art: the layers (or the pattern being edited), inside the page group when the @@ -403,10 +404,6 @@ impl Renderer { let view = Affine::translate((w as f64 / 2.0, w as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); let mut ctx = single_threaded_context(w, w); ctx.set_aliasing_threshold(opts.anti_alias.threshold()); - if let Some(bg) = background { - ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); - ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64)); - } let symbols = doc.symbol_boxes().quick(); let frame = Frame { mt: false, @@ -426,6 +423,22 @@ impl Renderer { self.draw_forced_arc(&mut ctx, &frame, &Arc::new(n.clone())); let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default()); self.end_frame(); - Some(Rendered { width: w as u32, height: w as u32, pixels: pm.data_as_u8_slice().to_vec() }) + let mut pixels = pm.data_as_u8_slice().to_vec(); + if let Some(bg) = background { + over_background(&mut pixels, bg); + } + Some(Rendered { width: w as u32, height: w as u32, pixels }) + } +} + +/// Premultiplied RGBA `pixels` drawn over the solid color `background` (`[r, g, b, a]`, straight). +pub(crate) fn over_background(pixels: &mut [u8], [r, g, b, a]: [u8; 4]) { + let alpha = u32::from(a); + let under = [u32::from(r) * alpha / 255, u32::from(g) * alpha / 255, u32::from(b) * alpha / 255, alpha]; + for p in pixels.as_chunks_mut::<4>().0 { + let rest = 255 - u32::from(p[3]); + for (v, bg) in p.iter_mut().zip(under) { + *v = (u32::from(*v) + (bg * rest + 127) / 255).min(255) as u8; + } } } diff --git a/crates/render/src/tests/knockout.rs b/crates/render/src/tests/knockout.rs index 2a7e51e81..4043173ae 100644 --- a/crates/render/src/tests/knockout.rs +++ b/crates/render/src/tests/knockout.rs @@ -116,15 +116,63 @@ fn page_knockout_and_isolated_blending() { Arc::make_mut(&mut d.layers[0]).knockout = Knockout::Off; assert!(close(render(&d).pixel(50, 50), BLUE_OVER_RED)); - // Page Isolated Blending: a Difference object no longer blends with the white under the page. + // A Difference object on the bare page keeps its color: the background goes under the + // finished art (#6125), with Page Isolated Blending or without it. let mut d = Document::new(100.0, 100.0); let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0)); red.opacity = 1.0; red.blend = Blend::Difference; d.insert(Some(d.layers[0].id), 0, red).unwrap(); - assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white: {:?}", render(&d).pixel(30, 50)); + assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "the bare page: {:?}", render(&d).pixel(30, 50)); d.page_isolate = true; assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50)); + // A white object under it is art: the Difference object inverts it. + let id = d.alloc_id(); + let white = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0)); + d.insert(Some(d.layers[0].id), 0, white).unwrap(); + assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white object: {:?}", render(&d).pixel(30, 50)); +} + +/// On a white or blue background, every blend mode on the bare page gives the transparent +/// render over that color (#6125). +#[test] +fn no_blend_mode_reads_the_background() { + for mode in Blend::ALL { + let mut d = Document::new(100.0, 100.0); + let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0)); + red.blend = mode; + d.insert(Some(d.layers[0].id), 0, red).unwrap(); + let clear = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(30, 50); + for bg in [[255, 255, 255, 255], [0, 0, 255, 255]] { + let got = + Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some(bg), ..Default::default() }).pixel(30, 50); + let a = i32::from(clear[3]); + let want = [0, 1, 2].map(|c| (i32::from(clear[c]) * a + i32::from(bg[c]) * (255 - a)) / 255); + assert!((0..3).all(|c| (i32::from(got[c]) - want[c]).abs() <= 3) && got[3] == 255, "{mode:?} on {bg:?}: {got:?}, want {want:?}"); + } + } +} + +/// Premultiplied pixels of alpha 0, 128 and 255 over backgrounds of alpha 0, 128 and 255. +#[test] +fn over_background_composites_premultiplied_pixels() { + let over = |p: [u8; 4], a: u8| { + let mut px = p.to_vec(); + crate::renderer::over_background(&mut px, [200, 100, 0, a]); + [px[0], px[1], px[2], px[3]] + }; + const CLEAR: [u8; 4] = [0; 4]; + const HALF: [u8; 4] = [64, 10, 100, 128]; + const SOLID: [u8; 4] = [9, 8, 7, 255]; + assert_eq!(over(CLEAR, 0), CLEAR); + assert_eq!(over(HALF, 0), HALF); + assert_eq!(over(SOLID, 0), SOLID); + assert_eq!(over(CLEAR, 128), [100, 50, 0, 128]); + assert_eq!(over(HALF, 128), [114, 35, 100, 192]); + assert_eq!(over(SOLID, 128), SOLID); + assert_eq!(over(CLEAR, 255), [200, 100, 0, 255]); + assert_eq!(over(HALF, 255), [164, 60, 100, 255]); + assert_eq!(over(SOLID, 255), SOLID); } #[test] diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index ef8972c3a..fe1e5ee1d 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -771,6 +771,64 @@ fn rasterize_on_an_image_takes_in_its_strokes_shape() { assert_eq!((lum(img.pixel(8, 35)), lum(img.pixel(12, 35)), img.pixel(35, 35)), (WHITE, 0, [255, 0, 0, 255])); } +/// A 40 pt square filled with `fill` carrying effect `id`, in a 100×100 document. +fn glowing_square(fill: Color, id: &str, params: Value) -> Document { + let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)); + n.appearance.effects.push(fx(id, params)); + doc_with(Document::new(100.0, 100.0), n) +} + +/// A glow on the bare page is drawn before the background goes under the art: the pixel is the +/// transparent render's over that color, also for a background other than white (#6125). +#[test] +fn a_screen_outer_glow_on_the_bare_page_shows_on_a_background() { + let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({})); + let clear = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(27, 50); + assert!(clear[0] > 20 && clear[0] == clear[1] && clear[2] == 0, "the glow is yellow: {clear:?}"); + for (bg, name) in [([255, 255, 255, 255], "white"), ([0, 0, 255, 255], "blue")] { + let p = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some(bg), ..Default::default() }).pixel(27, 50); + let a = u32::from(clear[3]); + let want: Vec = (0..3).map(|c| (u32::from(clear[c]) * a + u32::from(bg[c]) * (255 - a)) / 255).collect(); + assert!(a > 0 && (0..3).all(|c| u32::from(p[c]).abs_diff(want[c]) <= 2), "{name}: {p:?}, want {want:?}"); + assert_eq!(p[3], 255, "{name}: opaque"); + } +} + +/// A thumbnail with a background shows a Screen glow on the bare page, opaque (#6125). +#[test] +fn a_screen_outer_glow_is_in_a_thumbnail_on_a_background() { + let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({})); + let n = d.layers[0].children().unwrap()[0].clone(); + let img = Renderer::new().render_node_thumbnail(&d, &n, 100, Some([255; 4])).unwrap(); + let pixels = img.pixels.as_chunks::<4>().0; + assert!(pixels.iter().all(|p| p[3] == 255), "every pixel is opaque"); + assert!(pixels.iter().any(|p| p[0] > 235 && p[1] > 235 && p[2] < 235), "the glow shows as yellow on white"); +} + +/// The ink planes put the background under the art too: on a white background a Screen glow on +/// the bare page prints yellow ink, and the bare page around it prints none (#6125). +#[test] +fn a_screen_outer_glow_prints_ink_on_a_background() { + let d = glowing_square(Color::WHITE, "stylize.outerGlow", json!({})); + let opts = RenderOptions { background: Some([255; 4]), ..Default::default() }; + let inks = Renderer::new().render_region_inks(&d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, &opts); + let at = |x: usize, y: usize| &inks[(y * 100 + x) * 4..][..4]; + assert!(at(27, 50)[2] > 10, "yellow ink in the glow: {:?}", at(27, 50)); + assert_eq!(at(5, 50), [0; 4], "no ink on the bare page"); +} + +/// An Inner Glow blends with the object's own fill whatever is under the page: white Screen +/// lightens a black square, red Screen leaves a white one white, and red Normal shows on it. +#[test] +fn an_inner_glow_blends_with_its_own_fill_on_a_background() { + let red = |mode: &str| json!({"color": "#ff0000", "mode": mode, "opacity": 100}); + let dark = render(&glowing_square(Color::BLACK, "stylize.innerGlow", json!({"color": "#ffffff", "opacity": 100}))).pixel(32, 50); + assert!(dark[0] > 20 && dark[0] == dark[1], "the white glow lightens the black edge: {dark:?}"); + assert_eq!(render(&glowing_square(Color::WHITE, "stylize.innerGlow", red("screen"))).pixel(32, 50), [255, 255, 255, 255]); + let normal = render(&glowing_square(Color::WHITE, "stylize.innerGlow", red("normal"))).pixel(32, 50); + assert!(normal[0] == 255 && normal[1] < 245, "red on the white edge: {normal:?}"); +} + #[test] fn a_strokes_own_glow_paints_around_that_stroke_only() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index ab196f3cf..4ba8ae273 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4645,7 +4645,7 @@ fn wheel_over_a_numeric_panel_field_does_not_start_a_canvas_zoom_glide() { /// `ui.wheel`: with Zoom with Mouse Wheel off, Cmd/Ctrl-wheel zooms about the pointer while the /// option is off and scrolls sideways while it is on, and a trackpad pinch, two fingers spread on a touch /// screen, Alt + Cmd/Ctrl-wheel and Alt-wheel still zoom. A Cmd/Ctrl-wheel notch with a plain -/// one right after it scrolls two notches in all, and a trackpad gesture during which Cmd/Ctrl +/// one right after it scrolls two notches in all, and a wheel gesture during which Cmd/Ctrl /// is pressed and released scrolls sideways until the gesture ends, while an Alt gesture that /// pauses past egui's scroll timeout no longer holds Alt for the next move. With Zoom with /// Mouse Wheel also on, Cmd/Ctrl-wheel scrolls sideways and the wheel zooms. @@ -4747,16 +4747,16 @@ fn cmd_wheel_scrolls_sideways_preference() { (zoom - 1.25).abs() < 1e-6 && moved.x > notch - 0.5 && moved.x < 1.25 * notch + 0.5 && moved.y.abs() < 1e-3, "on: a pinch during a Cmd-wheel notch zooms by the pinch, and the notch scrolls sideways ({zoom}, {moved:?})" ); - // A trackpad gesture starts with no modifier, and Cmd is pressed and released during it: - // egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps scrolling - // sideways until then. + // A gesture egui names as a wheel starts with no modifier, and Cmd is pressed and released + // during it: egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps + // scrolling sideways until then. let before = *app.view().unwrap(); let swipe = |phase, modifiers| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 6.0), phase, modifiers, - source: egui::MouseWheelSource::Unknown, + source: egui::MouseWheelSource::Wheel, }; app.synthetic.extend([ egui::Event::PointerMoved(at), @@ -4772,7 +4772,7 @@ fn cmd_wheel_scrolls_sideways_preference() { let scroll = (after.center - before.center) * after.zoom; assert!( (after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 12.0).abs() < 0.01 && scroll.y == 0.0, - "on: a trackpad gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})", + "on: a wheel gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})", after.zoom / before.zoom ); // An Alt-wheel gesture pauses past egui's scroll timeout with no frame in between: egui @@ -4820,6 +4820,79 @@ fn cmd_wheel_scrolls_sideways_preference() { assert!(zooms_in(turn(&mut app, "")), "with Zoom with Mouse Wheel: the wheel zooms in about the pointer"); } +/// With Scroll Sideways with Cmd/Ctrl + Wheel on, Cmd/Ctrl with a touchpad zooms about the pointer +/// while whole line notches scroll sideways: a trackpad or point source, line deltas smaller than +/// a notch, and a whole notch later in the same turn. With Zoom with Mouse Wheel on, Cmd/Ctrl with +/// a touchpad zooms too. +#[test] +fn cmd_with_a_touchpad_zooms_and_a_whole_notch_scrolls_sideways() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let mut t = 0.0; + let mut run = |app: &mut VectorcraftApp, events: Vec| { + t += 1.0 / 60.0; + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(t), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + run(&mut app, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + let ev = |unit, dy: f32, source| egui::Event::MouseWheel { + unit, + delta: vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source, + }; + // Sends each event on its own frame, then lets egui finish and end the turn: + // → (zoom ratio, scroll in points, how far the point under `at` moved). + let mut turn = |app: &mut VectorcraftApp, events: Vec| { + let before = *app.view().unwrap(); + run(app, vec![egui::Event::PointerMoved(at)]); + for e in events { + run(app, vec![e]); + } + for _ in 0..40 { + run(app, vec![]); + } + let after = *app.view().unwrap(); + let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at)); + (after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved) + }; + use egui::{MouseWheelSource as S, MouseWheelUnit as U}; + let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.0005 && moved < 1e-6; + let sideways = |(zoom, scroll, _): (f64, vectorcraft_geom::Vec2, f64)| (zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0; + assert!(app.session.prefs.ctrl_wheel_scrolls_sideways, "the option is on by default"); + // A wheel message of 30 is a quarter of a 120 notch, and arrives as a quarter line. + assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a line delta smaller than a notch zooms"); + assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 0.25, S::Unknown)])), "several small deltas zoom"); + assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])), "a whole notch in the same turn zooms"); + assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Unknown)])), "point deltas of an unnamed source zoom"); + assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "a trackpad scroll zooms"); + assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Momentum)])), "its momentum zooms"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "a whole notch scrolls sideways"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 2.0, S::Unknown)])), "a turn of two notches scrolls sideways"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 0.25, S::Wheel)])), "a part of a line from a named wheel scrolls sideways"); + // A turn ends when the wheel goes idle, so the next whole notch scrolls sideways again. + assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a touchpad turn"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "the notch after it scrolls sideways"); + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); + assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "with Zoom with Mouse Wheel: a small line delta zooms"); + assert!( + zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])), + "with Zoom with Mouse Wheel: a whole notch in the same turn zooms" + ); + assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "with Zoom with Mouse Wheel: a trackpad scroll zooms"); + assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "with Zoom with Mouse Wheel: a whole notch scrolls sideways"); +} + /// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms /// about the pointer by egui's Cmd-wheel zoom, as Cmd-wheel does with the option off, and /// Cmd-wheel alone scrolls sideways. diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index 39bc80545..3ed6583ae 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -65,6 +65,9 @@ pub(super) struct WheelTurn { /// When the last wheel event came (`InputState::time`), and whether a trackpad sent it. last: f64, trackpad: bool, + /// A wheel event of this turn came from a touchpad ([`fine_steps`]), so Cmd/Ctrl zooms for the + /// rest of the turn, also through a whole notch. + fine: bool, /// The zoom (natural log) wheel notches still have to glide in. pub(super) glide: f64, } @@ -351,11 +354,26 @@ struct ThumbDrag { max: f32, } +/// Whether a wheel event comes from a touchpad and not from a mouse wheel's notches. egui names +/// the source on the Mac and reports `Unknown` elsewhere. There, point deltas (Wayland) and line +/// deltas that are not whole notches (Windows and X11) count as a touchpad. A browser reports a +/// mouse wheel in points, so the web build counts only line deltas that are not whole notches. +fn fine_steps(unit: egui::MouseWheelUnit, delta: egui::Vec2, source: egui::MouseWheelSource) -> bool { + let notch = |d: f32| (d - d.round()).abs() < 1e-3; + match source { + egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum => true, + egui::MouseWheelSource::Wheel => false, + egui::MouseWheelSource::Unknown if unit == egui::MouseWheelUnit::Point => !cfg!(target_arch = "wasm32"), + egui::MouseWheelSource::Unknown => !notch(delta.x) || !notch(delta.y), + } +} + /// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame: /// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option /// on the Mac) zoom; with General › Scroll Sideways with Cmd/Ctrl + Wheel, Cmd/Ctrl-wheel scrolls /// sideways instead, and Alt + Cmd/Ctrl-wheel still zooms. With General › Zoom with Mouse Wheel the -/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points +/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. Cmd/Ctrl +/// with a touchpad ([`fine_steps`]) zooms under either preference. `line` and `page`: points /// per wheel line and page. `turn`: kept by the caller across frames. With Performance › Animated /// Zoom (`animate`) a mouse wheel's notches zoom in a glide over a few frames, as scrolling does /// (#888); off, each notch zooms on the frame it arrives (#4283). A trackpad's fine steps zoom at @@ -385,7 +403,7 @@ pub(super) fn wheel( // `turn.alt` follows `turn.held` after each Start or Move, so a trackpad gesture that held // Alt keeps zooming until it ends, also after Alt is let go. for e in &i.events { - if let egui::Event::MouseWheel { phase, modifiers, source, .. } = e { + if let egui::Event::MouseWheel { unit, delta, phase, modifiers, source, .. } = e { // A wheel event that comes after egui's scroll timeout ends the turn first, as // `on_wheel_event` does (1 s for fingers resting on a trackpad mid-gesture, else // 0.15 s), also when no frame ran in between. @@ -393,9 +411,11 @@ pub(super) fn wheel( if i.time - turn.last > timeout { turn.gesture = false; turn.held = egui::Modifiers::NONE; + turn.fine = false; } turn.last = i.time; turn.trackpad = *source == egui::MouseWheelSource::Trackpad; + turn.fine |= fine_steps(*unit, *delta, *source); match phase { egui::TouchPhase::Start => { turn.gesture = true; @@ -413,6 +433,7 @@ pub(super) fn wheel( egui::TouchPhase::End | egui::TouchPhase::Cancel => { turn.gesture = false; turn.held = egui::Modifiers::NONE; + turn.fine = false; } } } @@ -420,6 +441,7 @@ pub(super) fn wheel( if !i.is_scrolling() { turn.gesture = false; turn.held = egui::Modifiers::NONE; + turn.fine = false; } let (mut zoom, mut scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta); // Scroll Sideways with Cmd/Ctrl + Wheel: while egui's wheel state holds Cmd/Ctrl without @@ -427,7 +449,7 @@ pub(super) fn wheel( // default speed, which the app keeps. The canvas scrolls sideways by those points instead. // A trackpad or web pinch still zooms. With fingers on a touch screen, egui's zoom is the // fingers' alone, and the canvas leaves it as it is. - if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && i.multi_touch().is_none() { + if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && !turn.fine && i.multi_touch().is_none() { let pinch: f64 = i.events.iter().filter_map(|e| if let egui::Event::Zoom(f) = e { Some(f64::from(*f)) } else { None }).product(); let wheel_zoom = zoom / pinch; if wheel_zoom.is_finite() && wheel_zoom > 0.0 { @@ -461,9 +483,15 @@ pub(super) fn wheel( let mut scroll = pan; for e in &i.events { match e { - egui::Event::MouseWheel { unit, delta, modifiers, .. } => { + egui::Event::MouseWheel { unit, delta, modifiers, source, .. } => { let d = points(unit, *delta); - if modifiers.command { + // The turn ends after egui's scroll timeout, as above. + if i.time - turn.last > 0.150 { + turn.fine = false; + } + turn.last = i.time; + turn.fine |= fine_steps(*unit, *delta, *source); + if modifiers.command && !turn.fine { scroll.x += d.x + d.y; } else if modifiers.shift { scroll.y += d.x + d.y; diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 93688542c..febbc6488 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -24,9 +24,40 @@ pub(super) const SPEC: DialogSpec = DialogSpec { preview: true, // Load Texture… adds the texture it reads. passes: |d, field| field == vectorcraft_effects::pixel::TEXTURE_IMAGE && matches!(d.str("__effect").as_str(), GLASS | TEXTURIZER), + // OK could only fail the same way: off, beside the reason the dialog shows (#6140). + ok_enabled: Some(|_, d| !(vectorcraft_effects::is_three_d(&d.str("__effect")) && d.fields.contains_key(form::REFUSED))), ..DialogSpec::FORM }; +/// The field holding the values a 3D effect's dialog last tried with Preview off. +const TRIED: &str = "__tried"; + +/// A 3D effect refuses objects it can't build from and values it can't draw, in the dialog as OK +/// would: shows why under Preview (#6140). With Preview off no preview has tried `params`, so they +/// are tried here, when they change, and rolled back at once. +fn three_d_refusal(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, cmd: &str, params: Value) { + if d.bool("preview") { + d.fields.remove(TRIED); + } else if !app.session.in_interaction() && d.fields.get(TRIED) != Some(¶ms) { + // Waits for another interaction to end: trying inside it would replace its preview and + // cancel it. A failure to begin leaves no interaction: the preview below then reports it. + let _ = app.session.begin_interaction(&d.str("__label")); + match app.session.preview(cmd, ¶ms) { + Ok(_) => { + d.fields.remove(form::REFUSED); + } + Err(e) => form::note_refusal(d, &e), + } + // Only a try: without an interaction there is nothing to roll back. + let _ = app.session.cancel_interaction(); + d.fields.insert(TRIED.into(), params); + } + if let Some(why) = d.fields.get(form::REFUSED).and_then(Value::as_str) { + ui.add_space(6.0); + ui.add(egui::Label::new(egui::RichText::new(why).color(Tokens::get(ui.ctx()).warning)).wrap()); + } +} + /// The effect's name (`__label`, English) in the UI language: the catalog may carry it with or /// without its trailing ellipsis. A plug-in effect's name is shown as it is. fn heading_label(d: &Dialog) -> String { @@ -170,7 +201,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if vectorcraft_effects::is_three_d(&id) { super::revolve::body(app, ui, d); let (cmd, params) = command(d); - form::preview(app, ui, d, &d.str("__label"), cmd, params); + form::preview(app, ui, d, &d.str("__label"), cmd, params.clone()); + three_d_refusal(app, ui, d, cmd, params); return false; } // Plug-in effects get fields from their parameter schema; colour adjustments their sliders. diff --git a/crates/ui-egui/src/dialogs/effect_gallery.rs b/crates/ui-egui/src/dialogs/effect_gallery.rs index 4fe560bb5..03c862171 100644 --- a/crates/ui-egui/src/dialogs/effect_gallery.rs +++ b/crates/ui-egui/src/dialogs/effect_gallery.rs @@ -1,15 +1,31 @@ //! Effect → Effect Gallery: browse Photoshop-style raster filters by category, build a stack, and //! apply it as separate appearance effects in one undo step. +//! +//! Each filter has a thumbnail, and the dialog has its own preview, as the +//! [Filter Gallery](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html) +//! describes. Both are an original drawing made for VectorCraft (a small landscape), passed through +//! the filter. The thumbnails use each filter's defaults. The preview uses the visible stack, or +//! the filter being browsed when the stack is empty. The canvas preview and the one-undo OK are +//! unchanged. + +use std::collections::HashMap; use egui; use serde_json::{Map, Value, json}; -use vectorcraft_effects::{EffectInfo, PIXEL_EFFECTS}; +use vectorcraft_effects::{EffectInfo, PIXEL_EFFECTS, PixelSpace}; +use vectorcraft_geom::{Affine, Point}; use super::{DialogSpec, effect, form}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, widgets}; +/// Thumbnail of one filter, in pixels. The drawing is original; the filter is this app's. +const THUMB: usize = 36; +/// In-dialog preview, in pixels, drawn at twice this size. +const PREVIEW_W: usize = 120; +const PREVIEW_H: usize = 84; + /// Dialog kind for the Effect Gallery. pub const KIND: &str = "effectGallery"; @@ -248,6 +264,196 @@ fn effect_label(id: &str) -> String { vectorcraft_effects::effect_info(id).map(|e| tl!(e.label).trim_end_matches('…').to_string()).unwrap_or_else(|| id.to_string()) } +/// Where the preview rasters sit: one document point per pixel, origin at the top-left. +fn preview_space(w: usize, h: usize) -> PixelSpace { + let (wf, hf) = (w as f64, h as f64); + PixelSpace { + to_doc: Affine::IDENTITY, + px: 1.0, + center: Point::new(wf / 2.0, hf / 2.0), + channels: vectorcraft_effects::pixel::Channels::Rgb, + line: 1.0, + extent: 0.5 * wf.hypot(hf), + } +} + +fn byte(v: f32) -> u8 { + if !v.is_finite() { + return 0; + } + v.round().clamp(0.0, 255.0) as u8 +} + +/// An original landscape for the gallery (sky, sun, hills, a house, a river). Not a copied picture. +fn sample_drawing(w: usize, h: usize) -> Vec { + let mut out = vec![0u8; w.saturating_mul(h).saturating_mul(4)]; + if w == 0 || h == 0 || out.len() != w * h * 4 { + return out; + } + for y in 0..h { + for x in 0..w { + let xf = x as f32 / w as f32; + let yf = y as f32 / h as f32; + let mut rgb = [90.0 + 90.0 * (1.0 - yf), 150.0 + 50.0 * (1.0 - yf), 200.0 + 40.0 * yf]; + let (dx, dy) = (xf - 0.78, yf - 0.22); + if dx * dx + dy * dy < 0.012 { + rgb = [252.0, 206.0, 64.0]; + } + let hill = 0.58 + 0.08 * (xf * std::f32::consts::TAU).sin(); + if yf > hill { + rgb = [46.0, 110.0 + 40.0 * (1.0 - (yf - hill).clamp(0.0, 1.0)), 52.0]; + } + if (0.16..0.40).contains(&xf) && (0.46..0.70).contains(&yf) { + rgb = [196.0, 64.0, 48.0]; + } + let roof = 0.46 - (xf - 0.28).abs() * 1.15; + if (0.12..0.44).contains(&xf) && yf > roof && yf < 0.48 { + rgb = [92.0, 48.0, 36.0]; + } + if (0.22..0.32).contains(&xf) && (0.52..0.62).contains(&yf) { + rgb = [255.0, 236.0, 150.0]; + } + if yf > 0.80 && (yf * 18.0 + xf * 9.0).sin() > -0.15 { + rgb = [36.0, 78.0, 196.0]; + } + let i = (y * w + x) * 4; + out[i] = byte(rgb[0]); + out[i + 1] = byte(rgb[1]); + out[i + 2] = byte(rgb[2]); + out[i + 3] = 255; + } + } + out +} + +/// Run one Photoshop-style filter on a premultiplied RGBA buffer. Leaves it unchanged when `id` +/// is not one of those filters or the buffer is the wrong size. +fn apply_pixel(id: &str, params: &Map, data: &mut [u8], w: usize, h: usize) { + let Some(n) = w.checked_mul(h).and_then(|n| n.checked_mul(4)) else { return }; + if n != data.len() || w == 0 || h == 0 { + return; + } + let effect = vectorcraft_doc::Effect { id: id.to_string(), params: Value::Object(params.clone()), visible: true }; + let Some(vectorcraft_effects::RasterFx::Pixel(fx)) = vectorcraft_effects::raster_effects(std::slice::from_ref(&effect)).into_iter().next() else { + return; + }; + fx.apply(data, w, h, &preview_space(w, h)); +} + +fn filtered(id: &str, params: &Map, w: usize, h: usize) -> Vec { + let mut data = sample_drawing(w, h); + apply_pixel(id, params, &mut data, w, h); + data +} + +/// The drawing with `jobs` applied in order (the visible stack, or the filter being browsed). +fn preview_rgba(jobs: &[(String, Map)], w: usize, h: usize) -> Vec { + let mut data = sample_drawing(w, h); + for (id, params) in jobs { + apply_pixel(id, params, &mut data, w, h); + } + data +} + +fn thumb_pixels(id: &str) -> Vec { + static CACHE: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + if let Some(px) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).get(id).cloned() { + return px; + } + // The filter runs outside the lock: a thumbnail is a few thousand pixels, and two threads + // computing the same one just keep one copy. + let px = filtered(id, &default_params(id), THUMB, THUMB); + CACHE.lock().unwrap_or_else(|e| e.into_inner()).insert(id.to_string(), px.clone()); + px +} + +/// Straight RGBA for egui, from the filters' premultiplied bytes. +fn color_image(data: &[u8], w: usize, h: usize) -> egui::ColorImage { + let mut pixels = Vec::with_capacity(w.saturating_mul(h)); + let (rows, _) = data.as_chunks::<4>(); + for p in rows { + let a = u32::from(p[3]); + let (r, g, b) = if a == 0 { + (0, 0, 0) + } else if a == 255 { + (p[0], p[1], p[2]) + } else { + let s = |c: u8| ((u32::from(c) * 255 + a / 2) / a).min(255) as u8; + (s(p[0]), s(p[1]), s(p[2])) + }; + pixels.push(egui::Color32::from_rgba_unmultiplied(r, g, b, p[3])); + } + if pixels.len() != w.saturating_mul(h) { + return egui::ColorImage::filled([w.max(1), h.max(1)], egui::Color32::TRANSPARENT); + } + egui::ColorImage::new([w, h], pixels) +} + +fn thumb_tex(ctx: &egui::Context, id: &str) -> egui::TextureHandle { + let key = egui::Id::unique("effect-gallery-thumbs"); + if let Some(tex) = ctx.data(|d| d.get_temp::>(key).and_then(|m| m.get(id).cloned())) { + return tex; + } + let tex = ctx.load_texture(format!("effect-gallery-thumb-{id}"), color_image(&thumb_pixels(id), THUMB, THUMB), egui::TextureOptions::NEAREST); + ctx.data_mut(|d| { + d.get_temp_mut_or_insert_with(key, HashMap::new).insert(id.to_string(), tex.clone()); + }); + tex +} + +/// What the in-dialog preview shows: the visible stack, or the browsed filter when nothing is stacked. +fn preview_jobs(d: &Dialog, stack: &[StackEntry], browse: &str) -> Vec<(String, Map)> { + if !stack.is_empty() { + return stack.iter().filter(|e| e.visible).map(|e| (e.effect.clone(), e.params.clone())).collect(); + } + if browse.is_empty() { + return Vec::new(); + } + let params = if d.fields.get("browseEffect").and_then(Value::as_str) == Some(browse) { browse_params(d) } else { default_params(browse) }; + vec![(browse.to_string(), params)] +} + +fn preview_key(jobs: &[(String, Map)]) -> String { + let rows: Vec = jobs.iter().map(|(id, params)| json!({ "effect": id, "params": params })).collect(); + serde_json::to_string(&Value::Array(rows)).unwrap_or_default() +} + +fn preview_tex(ctx: &egui::Context, key: &str, rgba: &[u8]) -> egui::TextureHandle { + let id = egui::Id::unique("effect-gallery-preview"); + if let Some(tex) = ctx.data(|d| d.get_temp::<(String, egui::TextureHandle)>(id).filter(|(k, _)| k == key).map(|(_, tex)| tex)) { + return tex; + } + let tex = ctx.load_texture("effect-gallery-preview", color_image(rgba, PREVIEW_W, PREVIEW_H), egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, (key.to_string(), tex.clone()))); + tex +} + +fn preview_area(ui: &mut egui::Ui, d: &Dialog, stack: &[StackEntry], browse: &str) { + let t = Tokens::get(ui.ctx()); + widgets::subheader(ui, tl!("Preview")); + let jobs = preview_jobs(d, stack, browse); + let key = preview_key(&jobs); + let rgba = preview_rgba(&jobs, PREVIEW_W, PREVIEW_H); + let tex = preview_tex(ui.ctx(), &key, &rgba); + #[cfg(test)] + PREVIEWS_PAINTED.with(|c| c.set(c.get() + 1)); + let (img_w, img_h) = (PREVIEW_W as f32 * 2.0, PREVIEW_H as f32 * 2.0); + ui.horizontal(|ui| { + let spare = (ui.available_width() - img_w).max(0.0); + ui.add_space(spare / 2.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(img_w, img_h), egui::Sense::hover()); + ui.painter().rect_filled(rect, 2.0, t.panel_darker); + let inner = rect.shrink(4.0); + ui.painter().image(tex.id(), inner, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + }); + let caption = jobs.iter().map(|(id, _)| effect_label(id)).collect::>().join(", "); + if !caption.is_empty() { + ui.label(egui::RichText::new(caption).color(t.text_dim)); + } + ui.add_space(8.0); +} + fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); let cat = category_ix(d); @@ -263,6 +469,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if stack_sel >= stack.len() && !stack.is_empty() { stack_sel = stack.len() - 1; } + preview_area(ui, d, &stack, &sel); + let (param_effect, mut param_values) = param_target(d, &stack, stack_sel, &sel); if !param_effect.is_empty() { widgets::subheader(ui, tl!("Settings")); @@ -293,12 +501,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_space(8.0); ui.vertical(|ui| { widgets::subheader(ui, tl!("Filters")); - ui.set_width(220.0); - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + ui.set_width(248.0); + egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { for e in &effects { let picked = e.id == sel; let name = tl!(e.label).trim_end_matches('…').to_string(); - let resp = ui.add(egui::Button::selectable(picked, name)); + let image = egui::Image::from_texture(&thumb_tex(ui.ctx(), e.id)).fit_to_exact_size(egui::vec2(THUMB as f32, THUMB as f32)); + #[cfg(test)] + THUMBS_PAINTED.with(|c| c.set(c.get() + 1)); + let resp = ui.add(egui::Button::selectable(picked, (image, name)).min_size(egui::vec2(200.0, THUMB as f32 + 4.0))); if resp.clicked() { set_selected(d, e.id); set_stack_focus(d, false); @@ -418,6 +629,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { r } +#[cfg(test)] +thread_local! { + static THUMBS_PAINTED: std::cell::Cell = const { std::cell::Cell::new(0) }; + static PREVIEWS_PAINTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] mod tests { use serde_json::json; @@ -476,4 +693,49 @@ mod tests { assert!(!n.appearance.effects[1].visible); assert_eq!(st.history.undo.len(), steps + 1); } + + #[test] + fn gallery_shows_thumbnails_and_an_in_dialog_preview() { + let plain = super::sample_drawing(super::THUMB, super::THUMB); + let mut seen = Vec::new(); + for cat in 0..super::CATEGORIES.len() { + for e in super::effects_in_category(cat) { + let thumb = super::thumb_pixels(e.id); + assert_eq!(thumb.len(), super::THUMB * super::THUMB * 4, "{}", e.id); + assert_ne!(thumb, plain, "{} thumbnail is the untouched drawing", e.id); + seen.push(thumb); + } + } + assert!(seen.len() > 2); + assert_ne!(seen[0], seen[1], "two filters share one thumbnail"); + + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + app.run("ui.effectGalleryDialog", json!({})).unwrap(); + super::THUMBS_PAINTED.with(|c| c.set(0)); + super::PREVIEWS_PAINTED.with(|c| c.set(0)); + frame(&mut app); + let thumbs = super::THUMBS_PAINTED.with(|c| c.get()); + let previews = super::PREVIEWS_PAINTED.with(|c| c.get()); + let artistic = super::effects_in_category(0).len() as u32; + assert!(thumbs >= artistic, "painted {thumbs} thumbnails, Artistic has {artistic}"); + assert!(previews >= 1, "the dialog has a preview area"); + + let d = app.ui.dialog.as_ref().unwrap(); + let stack = super::stack_of(d); + let jobs = super::preview_jobs(d, &stack, &super::selected_id(d)); + assert_eq!(jobs.len(), 1, "an empty stack previews the filter being browsed"); + let shown = super::preview_rgba(&jobs, super::PREVIEW_W, super::PREVIEW_H); + assert_ne!(shown, super::sample_drawing(super::PREVIEW_W, super::PREVIEW_H)); + + let soft = super::filtered("artistic.cutout", &super::default_params("artistic.cutout"), super::PREVIEW_W, super::PREVIEW_H); + let hard = super::filtered( + "artistic.cutout", + &serde_json::from_value(json!({"levels": 8, "edgeSimplicity": 0, "edgeFidelity": 3})).unwrap(), + super::PREVIEW_W, + super::PREVIEW_H, + ); + assert_ne!(soft, hard, "the preview follows the filter's settings"); + } } diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index be4f9e80c..c87cfb7e6 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -966,6 +966,19 @@ fn rail_ends_w(ui: &mut egui::Ui, width: f32, (left, right): (&str, &str)) { /// The field where [`preview`] keeps the parameters it last previewed. pub(super) const PREVIEWED: &str = "__previewed"; +/// The field holding why the dialog's command last refused its values, for the dialog to show. +pub(super) const REFUSED: &str = "__refused"; + +/// Keeps why the command refused the dialog's values in [`REFUSED`]: a refused value's message is +/// written for the user, so it goes without the command's id that the error's text puts before it. +pub(super) fn note_refusal(d: &mut Dialog, e: &vectorcraft_engine::EngineError) { + let why = match e { + vectorcraft_engine::EngineError::BadParams { msg, .. } => msg.clone(), + other => other.to_string(), + }; + d.fields.insert(REFUSED.into(), json!(why)); +} + /// The Preview checkbox of a dialog that previews `cmd` on the canvas: while it is on, `params` run /// again on the interaction's snapshot whenever they differ from the last preview (whether a widget /// or `ui.dialog.set` changed them); turning it off rolls back. @@ -1022,8 +1035,14 @@ pub(super) fn preview_now(app: &mut crate::VectorcraftApp, d: &mut Dialog, label if d.fields.get(PREVIEWED) != Some(¶ms) || !app.session.in_interaction() { let _ = app.session.begin_interaction(label); match app.session.preview(cmd, ¶ms) { - Ok(r) => result = Some(r), - Err(e) => app.status(e.to_string()), + Ok(r) => { + d.fields.remove(REFUSED); + result = Some(r); + } + Err(e) => { + note_refusal(d, &e); + app.status(e.to_string()); + } } d.fields.insert(PREVIEWED.into(), params); } diff --git a/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs index 0249bb199..75aecde46 100644 --- a/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/tests_revolve_gizmo.rs @@ -2,6 +2,7 @@ use super::*; use crate::{dialogs, theme}; use egui::{Event, Modifiers, PointerButton}; +use serde_json::Value; use vectorcraft_effects::revolve_options; use vectorcraft_engine::Session; @@ -284,3 +285,94 @@ fn selected_light_gizmo_edits_only_that_light_and_escape_restores_the_list() { h.release(p + vec2(80.0, 0.0), Modifiers::NONE); assert_eq!(h.app.ui.dialog.as_ref().unwrap().fields["lights"], before); } + +impl Harness { + /// A fresh document where `build` runs and Extrude & Bevel's dialog opens, its Preview `on`. + fn extrude_dialog(build: &[(&str, Value)], on: bool) -> Self { + let mut h = Self::new(); + dialogs::cancel(&mut h.app); + h.app.run("file.new", json!({"width":300,"height":300})).unwrap(); + for (id, p) in build { + h.app.run(id, p.clone()).unwrap(); + } + h.app.run("effect.dialog", json!({"effect": vectorcraft_effects::EXTRUDE})).unwrap(); + h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(on)); + h.settle(); + h + } + + fn ok_is_on(&self) -> bool { + let d = self.app.ui.dialog.as_ref().unwrap(); + dialogs::spec(&d.kind).ok_enabled.is_none_or(|f| f(&self.app, d)) + } + + /// Does the next frame of the dialog draw a text holding `text`? + fn shows(&mut self, text: &str) -> bool { + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0))), ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| dialogs::show(&mut self.app, ui.ctx())); + out.textures_delta.clear(); + out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.job.text.contains(text))) + } +} + +fn clipping_group() -> Vec<(&'static str, Value)> { + vec![ + ("shape.ellipse", json!({"x":20,"y":20,"width":100,"height":100})), + ("shape.rectangle", json!({"x":60,"y":60,"width":100,"height":100})), + ("select.all", json!({})), + ("object.clippingMask.make", json!({})), + ] +} + +/// #6140: on an object a 3D effect can't take, the dialog says why in its own words, and OK, which +/// could only fail, is off, where it used to look as if OK did nothing. +#[test] +fn a_refused_3d_effect_says_why_and_turns_ok_off() { + let mut h = Harness::extrude_dialog(&clipping_group(), true); + assert!(!h.ok_is_on()); + assert!(h.shows("requires a path")); + assert!(!h.shows("effect.apply"), "the reason is written without the command's id"); + let enter = Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + h.frame(vec![enter], Modifiers::NONE); + assert!(h.app.ui.dialog.is_some()); +} + +/// With Preview off from the start, nothing has tried the effect: the dialog still finds out, +/// and trying it leaves no preview open. +#[test] +fn a_refused_3d_effect_is_found_with_preview_off() { + let mut h = Harness::extrude_dialog(&clipping_group(), false); + assert!(!h.ok_is_on()); + assert!(h.shows("requires a path")); + assert!(!h.app.session.in_interaction()); +} + +/// With Preview off, trying the values waits for another interaction to end: trying them during +/// one would replace its preview and cancel it. +#[test] +fn a_3d_effect_with_preview_off_leaves_another_interaction_alone() { + let mut h = Harness::extrude_dialog(&clipping_group(), false); + h.app.session.begin_interaction("Drag").unwrap(); + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.remove("__tried"); + d.fields.remove(dialogs::form::REFUSED); + h.settle(); + assert!(h.app.session.in_interaction(), "the other interaction goes on"); + assert!(h.ok_is_on(), "nothing has tried the values yet"); + h.app.session.cancel_interaction().unwrap(); + assert!(h.shows("requires a path")); + assert!(!h.ok_is_on()); + assert!(!h.app.session.in_interaction()); +} + +/// An object the effect can take keeps OK on, with Preview on or off, and shows no reason. +#[test] +fn an_accepted_3d_effect_keeps_ok_on() { + let build = [("shape.rectangle", json!({"x":60,"y":60,"width":100,"height":100}))]; + for on in [true, false] { + let mut h = Harness::extrude_dialog(&build, on); + assert!(h.ok_is_on(), "Preview {on}"); + assert!(!h.shows("requires a path"), "Preview {on}"); + assert_eq!(h.app.session.in_interaction(), on); + } +} diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 8f90ce5f4..d21cc2192 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Effect Gallery: a thumbnail for each filter and an in-dialog preview on an original drawing; the 1–2 h for those is done; Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -12,7 +12,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Group | Illustrator | VectorCraft | % | Remaining (h) | |---|---:|---:|---:|---:| | Illustrator effects (vector and stylize) | 50 | 50 | 100% | — | -| Photoshop effects (raster, Effect Gallery) | 57 | 57 | 100% | 1–2 (Effect Gallery thumbnails and in-dialog preview) | +| Photoshop effects (raster, Effect Gallery) | 57 | 57 | 100% | — | | 3D and Materials | 4 (+3 Classic) | 4 (+1 Classic; initial: Extrude & Bevel, Revolve, Inflate, Rotate, Extrude & Bevel (Classic)) | ~25% | 25–45 | | Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — | @@ -48,12 +48,12 @@ its resolution, PDF export warns once per object with the requested and the lowe | Stylize | Glowing Edges | 1/1 | — | | Texture | Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer | 6/6 | — | | Video | De-Interlace, NTSC Colors | 2/2 | — | -| Effect Gallery | the combined browser and stack dialog | 1/1 (Effect › Effect Gallery…, #2883: six categories, per-filter settings, ordered stack with add/reorder/hide/remove, live canvas preview, one undo step; `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`) | filter thumbnails; a preview area inside the dialog ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | +| Effect Gallery | the combined browser and stack dialog | 1/1 (Effect › Effect Gallery…, #2883: six categories, per-filter settings, ordered stack with add/reorder/hide/remove, live canvas preview, one undo step; a thumbnail for each filter and a preview area inside the dialog, both an original landscape drawing passed through the filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html); `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`, `gallery_shows_thumbnails_and_an_in_dialog_preview`) | — | The pixel-filter pipeline (`vectorcraft_effects::pixel`) is in place: each new filter is one function, a catalogue row, a menu entry and translations. Measured throughput: 0.7–1.2 agent hours per filter (see [calibration](target-app-parity.md#calibration)). Every Photoshop-style filter is in the catalogue, and the Effect -Gallery dialog shipped (#2883); its filter thumbnails and in-dialog preview area are the 1–2 h that remain. +Gallery dialog shipped (#2883), with a thumbnail for each filter and a preview area inside the dialog. Both use an original landscape drawing passed through the filter; the canvas preview and the one-undo OK are unchanged. Film Grain (`artistic.filmGrain`, Effect › Artistic) adds stable monochrome noise to shadows and midtones, with Grain, Highlight Area and Intensity sliders, live preview and appearance editing. @@ -193,6 +193,7 @@ angular cut caps, exact visibility, the rest of 3D Classic (Revolve, Rotate, Map | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Effect Gallery: each filter has a thumbnail, and the dialog has a preview of the visible stack (or the browsed filter when the stack is empty). Both are an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). The canvas preview and one-undo OK are unchanged. Remaining hours for the gallery's thumbnails and in-dialog preview: 1–2 → none. Counts unchanged (57/57). | | 2026-11-08 | minor | Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap (a glyph's arm across its stem) before building a solid, so the solid follows the shape as it is filled; others are built as drawn ([three-d.md](three-d.md)); scores and estimates unchanged | | 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged | | 2026-11-06 | minor | The Transform effect's copies of a path or compound path whose fills and strokes stack (a fill and a stroke, several of either, an even-odd fill, or a partly transparent or blended one) are drawn each as an object of its own above the one before, as Expand Appearance makes them, instead of one path of every copy whose fill lay under every copy's stroke (#5920). A single opaque stroke or nonzero fill still draws as one path. Counts unchanged. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9861de2ae..a04c8926e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (PDF import: Separation images keep their ink, #6314; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -280,7 +280,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) | | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | -| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | +| PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) | @@ -310,6 +310,10 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, ## PDF editable imports +- [x] Explicit `/ActualText` replaces covered glyph text once, including empty strings and nested spans (outer owner wins), while later glyphs retain pen geometry. Synthetic regressions in `crates/pdf/src/tests/actual_text.rs`; no added fonts/assets. UTF-16BE, BOM UTF-8 and PDFDocEncoding reuse the reader's text-string decoder. +- [x] Authored ordering is protected per stretch; ordinary stretches still reconstruct visual-order text. Outlines mode is unchanged. +- [ ] Exact redistribution of one replacement over several lines/styles, and general mixed-direction paragraph layout. Replacement uses the first placed glyph's line/style. Tags deeper than 128 and replacements over 64 KiB encoded (at most 192 KiB decoded) fall back to glyph mappings with a warning; an outer accepted owner survives deeper tags. + - [x] Place from bytes or unlinked paths, and PDF clipboard paste, keep the selected page's live text. Synthetic two-page regressions in `crates/engine/src/cmd/fileio/tests_pdffidelity.rs` cover both sources, page selection and explicit outlines. The single-page importer honors the same text choice as Open; existing PDF fallback rules are unchanged. ## Corpus download integrity @@ -322,6 +326,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, |---|---|---| | 2026-11-09 | minor | PDF import: an image in a Separation color space (one ink, ISO 32000-1 §8.6.6.4) opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1, 2, 4, 8 and 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder) colorized by its ink at 100 %: a spot ink's swatch, a process plate's CMYK color, or Registration. It used to open as an RGB image, losing the ink, and Fill and Recolor Artwork didn't reach its color. Its soft mask or mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space (kind, colorant name and tint transform), once per image. As with any colorized image, where it lays no ink the art under it shows, where the RGB image was opaque white. The ink `None`, which paints nothing, an ink whose color can't be told, an image inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read (data cut short, a decoder's output of another layout) open in RGB with a warning, and the ink gets no swatch. Tests `colorize::a_separation_image_opens_colorized_by_its_ink`, `colorize::a_separation_image_reads_its_samples_and_decode_array`, `colorize::a_separation_image_of_a_process_plate_or_of_no_ink`, `colorize::a_separation_image_keeps_its_soft_mask`, `colorize::a_separation_image_drawn_twice_is_colorized_twice`, `colorize::a_separation_jpeg_opens_colorized_by_its_ink`, `colorize::a_separation_image_that_cant_be_read_stays_rgb` (#6314) | | 2026-11-09 | minor | PDF import: an image mask (`/ImageMask true`, ISO 32000-1 §8.9.6.2) painted in a solid color opens as a grayscale image of what it paints (black, the rest white) colorized by that color, as the `.ai` reader opens a 1-bit image mask (#5790). It used to open as an RGB image with the color in its pixels: Fill and Recolor Artwork didn't reach the color, a spot color lost its ink, and the same mask painted again in another color took the first color, in an uncolored pattern's cell too. Its color now counts toward the document's color mode, as a fill's does. A spot color is found by its color space, as a fill's is when the page's walk names no ink: two spaces of other inks with the same alternate and tint transform can't be told apart, and the color comes in as RGB. Inside an alpha soft mask, or painted with a pattern, a mask opens as before. Tests `colorize::an_image_mask_opens_colorized_by_its_spot_color`, `colorize::an_image_mask_drawn_in_two_colors_keeps_both`, `colorize::an_uncolored_pattern_drawing_an_image_mask_keeps_each_color` (#6313) | +| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. | | 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. | | 2026-11-07 | minor | EPS and `.ai` editing data: when the page's type is matched with the text objects, a glyph the page's font encoding names with a private-use character (a ligature such as `uniE028`, which says nothing of the letters it stands for) splits the page's word there, and the pieces are matched as words. A ligature in "Quality" no longer reads "Qualy" against the text object: four users' Illustrator 30 EPS files (used locally, not committed) lose the false "some of the page's type has no text object" note, their renders unchanged | | 2026-11-07 | minor | EPS import: a CID-keyed font over a predefined Unicode CMap the file doesn't define (Adobe's public CMap resources named `Uni…-UCS2`, `-UTF8`, `-UTF16` or `-UTF32`, `-H` or `-V`: [cmap-resources](https://github.com/adobe-type-tools/cmap-resources)), found by its `CIDFont-CMap` name (`/Ryumin-Light-UniJIS-UCS2-H findfont`, the usual name of a CID-keyed font: its CIDFont's and CMap's names joined by a hyphen) or made with `composefont`, sets its codes' characters as live type in the font of its CIDFont's name, and `stringwidth` measures them. Such type was read as Latin-1 codes, or stopped the program at `composefont`. Vertical CMaps are set horizontally, and the show operators' extra spacing isn't kept; CIDFonts over other CMaps the file doesn't carry still draw nothing, with a warning. | diff --git a/docs/gaps.md b/docs/gaps.md index a62d829f3..0939dbf3d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -94,7 +94,7 @@ stage ([ROADMAP.md](../ROADMAP.md)). | 5 | [G11 Correctness bugs in core tools](#g11-correctness-bugs-in-core-tools) | 8–15 | yes | [target-app-parity.md](target-app-parity.md) | | 6 | [G4 Advanced type](#g4-advanced-type) | 34–47 | core part | [type-parity.md](type-parity.md) | | 7 | [G12 Preferences and panels that don't act yet](#g12-preferences-and-panels-that-dont-act-yet) | 13–20 | yes | [ui-parity.md](ui-parity.md) | -| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 1–2 | no | [effects-parity.md](effects-parity.md) | +| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 0 | no | [effects-parity.md](effects-parity.md) | | 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 13–23 | no | [target-app-parity.md](target-app-parity.md) | | 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 4–10 | Wayland pressure | [hardware-parity.md](hardware-parity.md) | | 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 11–21 | no | [target-app-parity.md](target-app-parity.md) | @@ -197,6 +197,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G9 Illustrator `.ai` and EPS files from real users +- **Narrowed (#6165, 2026-11-09):** editable PDF import preserves explicit ActualText once, with empty/nested spans, authored order and glyph advances covered by `crates/pdf/src/tests/actual_text.rs`. One replacement uses the first placed glyph's line/style; exact redistribution across lines/styles and general mixed-direction layout remain. Input is bounded to depth 128 and 64 KiB encoded per accepted replacement. This does not close G9's broader `.ai`/EPS gaps or change its estimate. + - **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. - **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. @@ -313,7 +315,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G11 Correctness bugs in core tools -- **Export-only improvement (#6309):** PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. Broader G11 scope, scores and estimates unchanged. +- **Export-only improvement (#6309):** PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. Broader G11 scope, scores and estimates unchanged. - **Done, deletion only (#3380):** deleting anchors or segments that disconnect an ordinary contour creates independent selected path objects, preserving appearance, baked transforms, sibling order and one-step Undo/Redo. Existing multi-subpath paths and compound members stay intact. A second Delete removes all selected survivors. Bounds-fitted gradients keep their original placement on the new objects; already placed gradients stay unchanged. Segment endpoint highlighting remains open; broader G11 scope, scores and estimates are unchanged. Evidence: `crates/engine/tests/it/delete_path_pieces.rs` and the Delete/Backspace frame regression in `crates/ui-egui/src/tests/removeanchors.rs`. @@ -510,7 +512,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G2 Photoshop-style raster effects and the Effect Gallery - **Done:** Effect › Texture › Texturizer › Load Texture… (stored grayscale `textureImage`, Loaded Texture in the Texture menu), matching Glass. -- **Missing:** the Effect Gallery's filter thumbnails and a preview area inside the dialog ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); all 57 Photoshop-style filters are in the catalogue. Measured in +- **Done:** the Effect Gallery's filter thumbnails and in-dialog preview ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Each thumbnail is an original landscape drawing passed through that filter at its defaults; the preview area shows the same drawing through the visible stack, or through the filter being browsed when the stack is empty. The canvas preview and the one-undo OK are unchanged (`dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`). All 57 Photoshop-style filters are in the catalogue. Measured in [effects-parity.md](effects-parity.md). - **Done:** the Effect Gallery (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step (`dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`). - **Done:** Effect › Sketch › Conté Crayon (`sketch.conteCrayon`: Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; original visual approximation, defaults unverified), the last missing filter. @@ -518,7 +520,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** the pixel-filter pipeline and all 57 Photoshop-style filters (Artistic, Blur, Brush Strokes, Distort, Pixelate, Sharpen, Sketch, Stylize, Texture, Video), with live preview and editable native settings. Smart Blur supports Edge Only and Overlay Edge modes. Per-filter detail: [effects-parity.md](effects-parity.md). -- **Estimate:** 1–2 h (Effect Gallery thumbnails and in-dialog preview). +- **Estimate:** 0 h. ## G13 Illustrator 2026 (30.x) additions @@ -851,10 +853,12 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | G2: the Effect Gallery shows a thumbnail of each filter and a preview area inside the dialog (an original landscape drawing passed through the filter). Estimate 1–2 h → 0. The canvas preview and one-undo OK are unchanged. | | 2026-11-09 | minor | G4 type: Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, #4936; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain; scores and estimates unchanged. | | 2026-11-09 | minor | G5: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); Missing trimmed; estimate 12–22 → 11–21 h. | | 2026-11-09 | minor | G5: Direct Selection of a dynamic instance part takes opacity, blend, gradient and pattern fill/stroke; Art brush Overlap and Pattern auto corners documented Done; estimate 15–25 → 12–22 h. | | 2026-11-09 | minor | G5: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out documented as Done (already in code); Missing trimmed; estimate unchanged. | +| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. | | 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-09 | minor | G4 type: the custom dictionary (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, kept in a file beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; the Check Spelling and Edit Custom Dictionary dialogs and Auto Spell Check remain; scores and estimates unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 8f605eee0..81dd91344 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -779,7 +779,7 @@ point (72 times that value in ppi); `options.ppi` wins over either scale option. default 72 ppi still use 72 ppi; use `--ppi 300` for print output from those documents. Raster exports take more options in `options`: `ppi` (72, 150, 300…; stored in the file, except in GIF, WebP and -Targa), `background` (`transparent`, `white`, `black` or `"#rrggbb"`; `#rrggbbaa` and `#rgba` are refused), `antiAlias` (`none`, `art`, `type`: text +Targa), `background` (`transparent`, `white`, `black` or `"#rrggbb"`; `#rrggbbaa` and `#rgba` are refused; drawn under the finished art, so blend modes do not read it), `antiAlias` (`none`, `art`, `type`: text snapped to pixels) and, for PNG, `interlaced`. `width` or `height` sets the size of each file in pixels: each artboard (with `useArtboards`, each file), `rect` or art bounds gets the scale that makes it that wide or high, within 0.01–64 pixels per point, and the file stores the resolution that results, as with `ppi`. `width` wins over `height`, and @@ -2872,7 +2872,7 @@ where the layout puts it. `object.flattenTransparency {ids?, preset?, …options}` turns transparent art into opaque art that looks the same, in one undo step. The targets split into groups of overlapping objects; groups without transparency stay as they are. Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over -white; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, +white, which blend modes do not read; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without). `preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`, `textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`) @@ -3995,9 +3995,11 @@ desktop app): in View › Pixel Preview, on softens placed and embedded bitmaps; `disableAutoAddDelete` (off by default): on, the Pen no longer adds or deletes an anchor when a click lands on a segment or an anchor of a selected path. -`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming while -`zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel and a pinch on a Mac -trackpad, on a touch screen or in the web build still zoom about the pointer. With `zoomWithMouseWheel` off, once a +`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways +instead of zooming while `zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel +and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer. Under either +wheel preference, Cmd/Ctrl zooms about the pointer for the rest of a wheel turn once the turn has a trackpad event, +point deltas from an unnamed source in the desktop app, or line deltas that are not whole notches. With `zoomWithMouseWheel` off, once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. The control channel's `ui.wheel` with `cmd: true` turns the wheel with Cmd/Ctrl held. diff --git a/docs/roadmap.md b/docs/roadmap.md index d23133073..19369b9fa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -113,7 +113,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l 2. **`.ai` files from real users** (G9): layer structure (#951). 10–20 h. 3. **Launch and stability** (G10): the Windows and Linux start-up reports. 20–35 h. 4. **Core-tool bugs** (G11): SVG units reverting to points (#864) and a Transform effect in a fill's or stroke's own effects (#885). Shape Builder regions (#937), Asset Export ghost borders (#983 / #1037) and the stalling bezier drag preview (#834) are fixed. 8–15 h. -5. **Raster effects** (G2) alongside: the Effect Gallery's filter thumbnails and in-dialog preview area (the gallery and every Photoshop-style filter are in), 1–2 h. +5. **Then:** advanced type (G4) and the Illustrator 2026 additions (G13), then 3D (G3). The Effect Gallery's filter thumbnails and in-dialog preview are in (G2). ## Next @@ -133,7 +133,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | @@ -190,7 +190,9 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Current focus: the Effect Gallery's filter thumbnails and in-dialog preview are in (G2). Scores and estimates of other areas unchanged. | | 2026-11-09 | minor | M7: Auto Spell Check (#4936); the dialogs and the right-click Spelling menu remain; scores and estimates unchanged. | +| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. | | 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-09 | minor | M7: the custom dictionary commands and Ignore All (#4936); the dialogs and Auto Spell Check remain; scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8754a12f6..b8d7604ed 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -222,14 +222,14 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check done, #4936; the dialogs and the right-click Spelling menu remain), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Raster effects (Effect Gallery) | 4 | 52% | the Effect Gallery's filter thumbnails and in-dialog preview area ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | 1–2 | estimated (raised from 49%: the Effect Gallery shipped, #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; depth estimated | +| Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes and UI Frames with default 9-slice are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) and on-canvas 9-slice guides (#5280) are done | 4–7 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); import ActualText (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); import ActualText (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); import ActualText (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | @@ -542,6 +542,9 @@ since 2026-10-01. ## Evidence: what ships today, by area +- **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h. + + - **Selection — disconnected path deletion (#3380, deletion only):** Clear/Delete and Cut turn surviving disconnected pieces of an ordinary contour into independent selected sibling objects. The first keeps its ID; all keep appearance, baked transforms and stacking order. `deletion_split_preserves_gradient_placement` compares rendered survivors with the original paint space and checks linear/radial fill/stroke vectors, already placed gradients and atomic Undo/Redo; connected remainders keep automatic fitting. Existing multi-subpath paths and explicit compound members retain their semantics. Original synthetic regressions in `crates/engine/tests/it/delete_path_pieces.rs` cover styled/transformed rectangles, cubic handles, compound preservation and atomic Undo/Redo; `crates/ui-egui/src/tests/removeanchors.rs` covers real-frame Delete/Backspace and a second Delete removing all survivors. Segment endpoint highlighting remains open. Scores and hours unchanged. - **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged. @@ -593,6 +596,8 @@ since 2026-10-01. - **Effects in the art's bounds:** Export with Use Artboards off, Fit to Artwork Bounds, and the other exports of the whole art (EPS without artboards, Export for Screens' Full Document, Save for Web (Legacy) with Clip to Artboard off) measure each visible object as the export crops above do. The drop shadows and other effects of groups, layers, type and images are inside the image or the artboard. A Shaper group without effects of its own is measured by the art it shows, and the hidden members of a group with a drop shadow, a glow or another raster effect do not count unless it is a Shaper group or also has a geometry, Pathfinder, Crop Marks or 3D effect or a visible effect on its own fill or stroke. The SVG of an artboard keeps an object whose effects alone reach the artboard. Tests measure a group, type and an image with a drop shadow, export a group and type with Use Artboards off and fit an artboard to them. +- **Raster export backgrounds (#6125):** a raster export draws its background under the finished art, and blend modes read only the art below them, as on the canvas. An Outer Glow in Screen mode on the bare page shows in a PNG on white and in a JPEG. Flatten Transparency puts its white under the finished art the same way. Evidence: `tests::file::a_screen_outer_glow_is_in_raster_exports`, `tests::objectfx::a_screen_outer_glow_on_the_bare_page_shows_on_a_background`, `tests::knockout::page_knockout_and_isolated_blending`, `tests::knockout::no_blend_mode_reads_the_background`, `tests::cmykflatten::every_blend_mode_flattens_to_the_canvas_pixels_in_cmyk`. + - **Selected artboard deletion:** the Artboards panel menu, panel trash button and Properties use the same selected set; selecting all retains artboard 1. Pointer-event tests cover nonadjacent and single selections, the original first-board survivor, ordinary artwork, Undo/Redo and the single-board guard. Public reference: [Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). - **Delete Empty Artboards:** the Artboards panel menu item ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html)) runs `artboard.deleteEmpty`: every unlocked artboard with no artwork goes with its guides in one undo step, artboard 1 stays when every artboard is empty and none is locked, and the panel makes an artboard that is left active; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). Engine tests in `tests/panelcmds.rs` (`delete_empty_artboards_*`) and the panel test `delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left`. The menu item's enabled check measures nothing in a document with one artboard, and Save Gradient Preset's check reads the proxy's paint without measuring the object; both give the same answer as before. An artboard that no path's control points, grown by the farthest its strokes reach, can meet is found empty without measuring the path. With an open path of 200,000 anchors selected, the checks take under 0.01 ms instead of 21.9 ms and 17.2 ms in a release build; with a second, empty artboard, Delete Empty Artboards' check takes 0.85 ms instead of 42.1 ms. Tests `delete_empty_artboards_is_enabled_exactly_when_it_deletes_something`, `delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents`, `empty_artboard_checks_match_the_visual_bounds_on_varied_strokes`, `the_empty_artboard_check_matches_measuring_every_artboard`, `the_save_check_matches_saving_with_and_without_a_gradient`, `the_unmeasured_proxy_paint_is_the_measured_one` and `the_unmeasured_proxy_paint_is_the_measured_one_on_varied_objects`. @@ -787,7 +792,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Type › Highlight Alternate Glyphs (on by default: pink behind OpenType substitutions on screen — ligatures, contextual forms; Document Setup's Highlight Substituted Glyphs stays separate; `prefs.set`, #394). About 14 stored options still do nothing yet (the rest of #394: Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - The desktop app keeps the Color panel's Show Options or Hide Options between sessions, and `ui.colorPanel` sets its mode and options as its panel menu does. - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). Then Units › East Asian Type (the bullet below). Then User Interface › Open Documents As Tabs (see Window › Arrange). Three stored options still do nothing yet (the audit below). - - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. + - Preferences › General › Scroll Sideways with Cmd/Ctrl + Wheel (on by default; dimmed while Zoom with Mouse Wheel is on): while Zoom with Mouse Wheel is off, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways instead of zooming. Alt + Cmd/Ctrl-wheel and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer, and once a trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. Under either wheel preference, Cmd/Ctrl with a touchpad zooms about the pointer. - Preferences › Type › Highlight Substituted Fonts (on by default) paints pink behind text drawn in a substitute for a missing font in every document, and turning it off removes that highlight everywhere (`prefs.set highlightSubstitutedFonts`); Document Setup keeps Highlight Substituted Glyphs ([help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html)). - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only (Units › East Asian Type and User Interface › Open Documents As Tabs, stored only on the day of the audit, act now: East Asian Type since 2026-10-31, Open Documents As Tabs as the Window › Arrange bullet says); [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - Units › East Asian Type (#394, `unitsAsianType`): Points by default; it offers every unit and Q and Ha, a quarter of a millimetre each (0.709 pt: the [Illustrator Scripting Guide](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/); `q` and `h` are the field suffixes [InDesign's notation table](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html) lists), and only it lists them: rulers, Document Setup and the other Units preferences keep their ten. The public pages do not say which fields follow it, so no field uses it yet and that waits for a source. The model stays in points; any length field reads `13q`/`13h`. Evidence: `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` (`crates/engine/src/tests/units.rs`). @@ -1307,7 +1312,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The font database looks up a name that is neither a loaded nor an installed family among the other names of the loaded and installed fonts once for each style it is asked in, and keeps the answer until fonts are added or rescanned. Laying out a story of 1,000 paragraphs set in a font that is not installed, with 400 more fonts loaded, takes a median of 56 to 71 ms instead of 409 to 507 ms on Windows 11 (`CRAFT_FONTS_DIR=<craft-fonts checkout> cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`). A font family removed while the app runs leaves the font menus when the app scans the font folders again (when its window comes to the front after a font folder changed, or Refresh Font List), also when type had used it: that type then draws in the fallback font and Find Font lists the font as missing, until the font is installed again and the folders are scanned. - **Files (M4):** - - PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. + - PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. - PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source. PDF export measures each member of a plain group or layer once to leave out what is off the page: the group or layer is measured through its members (`Node::visual_of_members`), and each member uses that box when it is written. The elements of a knockout group are measured twice, once for the group's box and once to choose the elements on the page (`pdf/src/tests/measuring.rs` counts the measures). A local byte comparison with the previous build found identical output on one open path of 200,000 anchors, 20,000 rectangles in 200 groups of 100, 5,000 point type objects and a document of plain, see-through, clipping and knockout groups. The walk that writes link annotations measures only an object with a URL that opens and a clipping group (`links.rs`), and its output on a document of linked objects in plain and clipping groups is the same byte for byte as when it measured every object. Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). @@ -1906,11 +1911,13 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Raster effects: the Effect Gallery shows a thumbnail of each filter and a preview of the visible stack (or the filter being browsed) inside the dialog, both an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); 52% → 55%, 1–2 h → none (estimated). The canvas preview and one-undo OK are unchanged. Headline numbers unchanged. | | 2026-11-09 | minor | Canvas: a 3D effect changed on type or a group redraws where its solid was, measured by the effect's art through the renderer that drew the frame; exports and Object › Rasterize take the solid of 3D type; Fit to Artwork Bounds and per-artboard SVG export measure all artboards through one renderer; a 3D effect on a fill or stroke that builds nothing no longer overflows the stack on the canvas, in Object › Rasterize and in raster exports (evidence in Feature revisions). Scores and estimates unchanged. | | 2026-11-09 | minor | Type advanced: Auto Spell Check (`edit.autoSpellCheck`), #4936; evidence added; the dialogs and the right-click Spelling menu remain; scores and hours unchanged. | | 2026-11-09 | minor | Bundled content: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); symbol libraries evidence updated; ~30% → ~32%; more built-in symbol libraries remain. | | 2026-11-09 | minor | Symbols: on-canvas dynamic part appearance takes gradient/pattern fill and stroke and opacity/blend; evidence updated; area ~87% → ~88%; strokes remaining no longer lists Overlap/auto corners. | | 2026-11-09 | minor | Strokes/brushes: Art brush Overlap and Pattern brush auto corners removed from remaining (evidence already covered them); symbols remaining clarified for dynamic on-canvas beyond solid fill/stroke; scores and hours unchanged. | +| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. | | 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | | 2026-11-09 | minor | Type advanced: the custom dictionary commands (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, saved beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; evidence added; the dialogs and Auto Spell Check remain; scores and hours unchanged. |