diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 1dd1548363..ef6b5654f3 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -115,7 +115,7 @@ pub fn specs() -> Vec { "Rasterize…", ["Object"], None, - "{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", + "{ppi?, background?: transparent|white|black|\"#rrggbb\" (#rrggbbaa and #rgba are refused), antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; grayscale makes a gray PNG, and bitmap a 1-bit TIFF or, where it has transparency, a black-and-white gray PNG with alpha; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; an image larger than 64 Mpx or 32,768 px a side is refused; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", has_selection, rasterize ), @@ -729,7 +729,9 @@ fn cmyk_raster( /// The image Object › Rasterize makes of some art, before it goes in a document /// ([`rasterize_art`]). pub(crate) struct Rasterized { - /// A PNG, or a CMYK image of the art's inks ([`cmyk_raster`]). + /// A PNG (greyscale for the Grayscale colour model), a 1-bit TIFF or greyscale PNG for Bitmap + /// ([`vectorcraft_render::Rendered::to_bilevel`]), or a CMYK image of the art's inks + /// ([`cmyk_raster`]). blob: vectorcraft_doc::ImageBlob, /// A CMYK image's transparency, a greyscale PNG: it becomes the image's opacity mask. alpha: Option>, @@ -769,7 +771,8 @@ impl Rasterized { /// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command /// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws /// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels. With `inks`, -/// a CMYK document's colour model makes a CMYK image ([`cmyk_raster`]); otherwise images are PNGs. +/// a CMYK document's colour model makes a CMYK image ([`cmyk_raster`]); otherwise images are PNGs, +/// greyscale for the Grayscale and Bitmap colour models (an opaque Bitmap image is a 1-bit TIFF). pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec, p: &Value, cmd: &str, inks: bool) -> Result { check_numbers(p, &["ppi"], cmd)?; let ppi = f64_or(p, "ppi", doc.raster_effects_ppi); @@ -836,10 +839,17 @@ pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec, p let tmp = isolated_doc(doc, nodes); let opts = raster.render_options(RasterFormat::Png); let (img, warning) = render_pixels(&tmp, region, scale, &opts, look.color_model)?; - let (blob, alpha) = if inks && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && look.color_model == RasterColorModel::Document { - cmyk_raster(&tmp, region, scale, &opts, &img)? - } else { - (vectorcraft_doc::ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?), None) + let png = + |bytes: std::result::Result, String>| bytes.map(|b| vectorcraft_doc::ImageBlob::new("image/png", b)).map_err(EngineError::Other); + let (blob, alpha) = match look.color_model { + RasterColorModel::Document if inks && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => cmyk_raster(&tmp, region, scale, &opts, &img)?, + RasterColorModel::Document => (png(img.to_png())?, None), + // Grayscale images, as the colour models say (#6969). + RasterColorModel::Grayscale => (png(img.to_gray_png())?, None), + RasterColorModel::Bitmap => { + let (mime, bytes) = img.to_bilevel(ppi).map_err(EngineError::Other)?; + (vectorcraft_doc::ImageBlob::new(mime, bytes), None) + } }; let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); Ok(Rasterized { blob, alpha, width: img.width, height: img.height, xf, clip, warning }) diff --git a/crates/engine/src/tests/rastersettings.rs b/crates/engine/src/tests/rastersettings.rs index 2110e611ea..75659faf32 100644 --- a/crates/engine/src/tests/rastersettings.rs +++ b/crates/engine/src/tests/rastersettings.rs @@ -455,3 +455,50 @@ fn rasterize_to_cmyk_makes_a_cmyk_image() { assert_eq!((alpha.get_pixel(1, 1).0, alpha.get_pixel(25, 25).0), ([0], [255])); assert!(mask.clip); } + +/// Rasterize with the Grayscale or Bitmap colour model makes a grayscale image (#6969): the +/// image says Grayscale, its file holds one gray channel (one bit a pixel for an opaque Bitmap, +/// gray and alpha over a transparent background), in a CMYK document too, and a fill color +/// colorizes it. +#[test] +fn rasterize_to_grayscale_or_bitmap_makes_a_grayscale_image() { + for mode in ["rgb", "cmyk"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "colorMode": mode})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap(); + let mut rasterize = |model: &str, background: &str| { + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let p = json!({"ppi": 72, "colorModel": model, "background": background, "addAround": 5}); + let img = id(&s.execute("object.rasterize", &p).unwrap()); + let info = s.execute("image.info", &json!({"id": img.0})).unwrap(); + let d = &s.doc().unwrap().doc; + let n = d.node(img).unwrap(); + assert!(n.extras().mask.is_none(), "{mode} {model} {background}: no opacity mask"); + let NodeKind::Image(im) = &n.kind else { panic!("an image") }; + let blob = d.images[&im.key].clone(); + let pixels = image::load_from_memory(&blob.bytes).unwrap().to_luma_alpha8(); + assert_eq!(pixels.dimensions(), (50, 50)); + let at = |x, y| pixels.get_pixel(x, y).0; + s.execute("edit.undo", &json!({})).unwrap(); + (info["colorMode"].clone(), blob.gray_source(), at(25, 25), at(1, 1)) + }; + let gray = |one_bit, alpha| Some(vectorcraft_doc::GraySource { one_bit, alpha }); + let (info, source, [level, alpha], corner) = rasterize("grayscale", "white"); + assert_eq!((info, source, alpha, corner), (json!("Grayscale"), gray(false, false), 255, [255, 255]), "{mode}"); + if mode == "rgb" { + assert_eq!(level, 54, "red's Rec. 709 luma"); + } else { + assert!((1..=254).contains(&level), "the CMYK red's screen color as a gray: {level}"); + } + assert_eq!(rasterize("grayscale", "transparent"), (json!("Grayscale"), gray(false, true), [level, 255], [255, 0]), "{mode}"); + assert_eq!(rasterize("bitmap", "white"), (json!("Grayscale"), gray(true, false), [0, 255], [255, 255]), "{mode}"); + assert_eq!(rasterize("bitmap", "transparent"), (json!("Grayscale"), gray(false, true), [0, 255], [255, 0]), "{mode}"); + // A fill color colorizes the image, as it does any grayscale image. + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let img = id(&s.execute("object.rasterize", &json!({"ppi": 72, "colorModel": "grayscale"})).unwrap()); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [img.0]})).unwrap(); + assert!(s.doc().unwrap().doc.node(img).unwrap().extras().colorize.is_some(), "{mode}: colorized"); + } +} diff --git a/crates/render/src/encode/png.rs b/crates/render/src/encode/png.rs index f55fd316db..6a62034e10 100644 --- a/crates/render/src/encode/png.rs +++ b/crates/render/src/encode/png.rs @@ -53,6 +53,21 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result Result, String> { + check_size(width, height)?; + if gray_alpha.len() as u64 != u64::from(width) * u64::from(height) * 2 { + return Err("PNG encoding failed: pixel buffer doesn't match the image size".into()); + } + let px = gray_alpha.as_chunks::<2>().0; + if px.iter().all(|p| p[1] == 255) { + let levels: Vec = px.iter().map(|p| p[0]).collect(); + return write(&levels, width, height, Layout { channels: 1, bits: 8, color_type: 0 }, &[], o); + } + write(gray_alpha, width, height, Layout { channels: 2, bits: 8, color_type: 4 }, &[], o) +} + /// Encode a palette image as an indexed PNG (colour type 3: `PLTE`, and `tRNS` for its /// transparent entry) at 1, 2, 4 or 8 bits per pixel, as few as the palette needs. pub fn encode_indexed(img: &Indexed, o: &PngOptions) -> Result, String> { diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index 0935ba3567..a910480641 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -67,6 +67,41 @@ fn png_refuses_a_buffer_of_the_wrong_size() { assert!(encode(&[], 0, 0, &PngOptions::default()).is_err()); } +/// Object › Rasterize's Grayscale and Bitmap images (#6969): a grey PNG of one channel when +/// opaque and grey and alpha otherwise; Bitmap's black and white as a 1-bit TIFF when opaque, +/// else as a grey PNG with alpha. Odd widths keep each row on a byte. +#[test] +fn gray_and_bilevel_images_store_gray() { + let header = |file: &[u8]| (file[24], file[25]); + let gray = |file: &[u8]| image::load_from_memory(file).unwrap().to_luma_alpha8().into_raw(); + // 3 × 2, premultiplied: black, red, light grey; white, mid grey, almost black. + let mut img = Rendered { + width: 3, + height: 2, + pixels: [[0, 0, 0], [255, 0, 0], [200; 3], [255; 3], [128; 3], [127; 3]].iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect(), + }; + let levels: Vec = [0u8, 54, 200, 255, 128, 127].iter().flat_map(|&g| [g, 255]).collect(); + let file = img.to_gray_png().unwrap(); + assert_eq!(header(&file), (8, 0), "8-bit grey"); + assert_eq!(gray(&file), levels); + let (mime, file) = img.to_bilevel(150.0).unwrap(); + assert_eq!(mime, "image/tiff"); + let mut d = ::tiff::decoder::Decoder::new(std::io::Cursor::new(&file)).unwrap(); + assert_eq!(d.colortype().unwrap(), ::tiff::ColorType::Gray(1)); + let bw: Vec = [0u8, 0, 255, 255, 255, 0].iter().flat_map(|&g| [g, 255]).collect(); + assert_eq!(gray(&file), bw, "from 128 up is white"); + // Half-covered white over the light grey; a clear pixel is white. + img.pixels[8..12].copy_from_slice(&[100, 100, 100, 100]); + img.pixels[20..24].copy_from_slice(&[0; 4]); + let file = img.to_gray_png().unwrap(); + assert_eq!(header(&file), (8, 4), "grey and alpha"); + assert_eq!(gray(&file), [0, 255, 54, 255, 255, 100, 255, 255, 128, 255, 255, 0]); + let (mime, file) = img.to_bilevel(150.0).unwrap(); + assert_eq!((mime, header(&file)), ("image/png", (8, 4)), "no 1-bit grey with alpha"); + assert_eq!(gray(&file), [0, 255, 0, 255, 255, 100, 255, 255, 255, 255, 255, 0], "still black and white"); + assert!(super::png::encode_gray(&levels[..10], 3, 2, &PngOptions::default()).is_err()); +} + /// A 40×30 pt document with a black circle off the pixel grid. fn circle_doc() -> Document { let mut d = Document::new(40.0, 30.0); diff --git a/crates/render/src/options.rs b/crates/render/src/options.rs index 8ca670eb3f..7a9a9b81f2 100644 --- a/crates/render/src/options.rs +++ b/crates/render/src/options.rs @@ -356,6 +356,58 @@ impl Rendered { img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).map_err(|e| format!("PNG encoding failed: {e}"))?; Ok(buf) } + /// Each pixel's grey level and alpha: the Rec. 709 luma of its straight colour, with `bilevel` + /// black below 128 and white from there up. A clear pixel is white: no ink where a fill + /// colour colorizes the image. + fn gray_alpha(&self, bilevel: bool) -> Vec { + let straight = self.to_straight(); + let px = straight.as_chunks::<4>().0; + px.iter() + .flat_map(|&[r, g, b, a]| { + let y = (0.2126 * f32::from(r) + 0.7152 * f32::from(g) + 0.0722 * f32::from(b)).round().clamp(0.0, 255.0) as u8; + let y = if a == 0 || (bilevel && y >= 128) { + 255 + } else if bilevel { + 0 + } else { + y + }; + [y, a] + }) + .collect() + } + /// Encode as a greyscale PNG without metadata (Object › Rasterize's Grayscale colour model): + /// grey levels ([`Self::gray_alpha`]), with alpha only when some pixel isn't opaque. + pub fn to_gray_png(&self) -> Result, String> { + encode::png::encode_gray(&self.gray_alpha(false), self.width, self.height, &encode::png::PngOptions::default()) + } + /// Encode in black and white (Object › Rasterize's Bitmap colour model): a 1-bit TIFF at `ppi` + /// when every pixel is opaque, else a grey PNG with alpha of only black and white (one-bit grey + /// with alpha is no format readers take). Returns the media type and the file. + pub fn to_bilevel(&self, ppi: f64) -> Result<(&'static str, Vec), String> { + let gray_alpha = self.gray_alpha(true); + let px = gray_alpha.as_chunks::<2>().0; + if !px.iter().all(|p| p[1] == 255) { + return Ok(("image/png", encode::png::encode_gray(&gray_alpha, self.width, self.height, &encode::png::PngOptions::default())?)); + } + // WhiteIsZero: a set bit is black. Each row starts on a byte. + let mut bits = Vec::with_capacity(px.len() / 8 + self.height as usize); + for row in px.chunks(self.width.max(1) as usize) { + bits.extend(row.chunks(8).map(|byte| byte.iter().enumerate().fold(0u8, |b, (i, p)| b | u8::from(p[0] == 0) << (7 - i)))); + } + let image = encode::tiff::Image { + width: self.width, + height: self.height, + data: &bits, + photometric: encode::tiff::Photometric::WhiteIsZero, + samples: 1, + bits: 1, + alpha: false, + }; + let layout = + encode::tiff::Layout { byte_order: encode::tiff::ByteOrder::Little, compression: encode::tiff::Compression::PackBits, ppi, icc: None }; + Ok(("image/tiff", encode::tiff::write(&image, &layout)?)) + } /// Encode as JPEG (flattened on white) at `quality` 1..=100. pub fn to_jpeg(&self, quality: u8) -> Result, String> { encode::jpeg(self, quality, None) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d5851d78ce..02f6df0edb 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-13 · **Change:** minor (`.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously 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) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -1698,6 +1698,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). - **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests/pdfraster.rs`). - **Rasterize effect:** Effect › Rasterize… (`rasterize`) draws an object as an image of square pixels at its own resolution or the document's raster effects resolution, with Object › Rasterize's color model, background, anti-aliasing, clipping mask and room around the object, while its paths stay editable. Expand Appearance and PDF and SVG export of an object whose only raster effect is Rasterize give the image Object › Rasterize gives (`crates/engine/src/tests/rastersettings.rs`, `crates/render/src/tests/rasterize.rs`). Behavior reference: [How to save artwork in Illustrator](https://helpx.adobe.com/illustrator/using/saving-artwork.html), [Work with effects](https://helpx.adobe.com/illustrator/using/effects.html). +- **Rasterize colour models:** Object › Rasterize with the Grayscale colour model makes a grayscale image (a gray PNG, with alpha over a transparent background), and with Bitmap a 1-bit image (a TIFF), or a black-and-white gray PNG with alpha over a transparent background; `image.info` and the Control bar say Grayscale, and a fill color colorizes the image, in RGB and CMYK documents (#6969). Expand Appearance and PDF export of a Rasterize effect make the same images. Regression coverage: `rasterize_to_grayscale_or_bitmap_makes_a_grayscale_image` (`crates/engine/src/tests/rastersettings.rs`) and `gray_and_bilevel_images_store_gray` (`crates/render/src/encode/tests.rs`). Scores and estimates unchanged. - **SVG filters:** Effect › SVG Filters lists the document's own SVG filters below Apply SVG Filter… and Import SVG Filter…, and choosing one applies it to the selection as a live effect in one undo step (`svgFilter.apply`). Apply SVG Filter applies the selected filter, New and Edit open its code, and Delete and Import… remove filters or add those of an SVG file (`svgFilter.define`, `svgFilter.delete`, `svgFilter.import`). VectorCraft ships no default filters. The canvas, raster export and PDF draw the filter at the Document Raster Effects resolution; SVG export writes it as a `` element when it is the object's last effect and otherwise draws the object as an image; SVG import keeps filters the other live effects can't express as this effect instead of leaving them out; the native format keeps the filters (`crates/engine/src/tests/svgfilter.rs`, `crates/effects/src/svg_filter/tests.rs`). Behavior reference: [Apply SVG filter effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-svg-filter-effects.html), [CS5 help, Apply SVG effects](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=402), [SVG 1.1 Filter Effects](https://www.w3.org/TR/SVG11/filters.html). - **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames. @@ -2135,6 +2136,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Object › Rasterize with the Grayscale colour model makes a grayscale image (one gray channel, with alpha over a transparent background), so the Properties panel says Grayscale and a fill color colorizes it; Bitmap makes a 1-bit image (a TIFF), or a black-and-white gray image with alpha over a transparent background, since 1-bit gray with alpha is no format readers take. Both were RGB images of gray pixels (#6969). Scores and estimates unchanged. | | 2026-11-12 | minor | `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints (its strokes' miter tips, and strokes a Transform effect moves, included), after its glyphs grown by half its strokes' weight; `.ai` import evidence updated (#6824); scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | Glyphs review (#6084): Entire Font uses the selection's version and variable axes for previews and alternate applicability. The default cell can reset an unlisted glyph choice when GSUB lists no alternates. Encoded Latin and other ordinary characters insert unchanged even if their glyph also has Arabic positional provenance; Arabic presentation forms retain base-letter insertion. Remove a redundant charmap scan from unlisted-glyph checks. Scores and estimates unchanged. |