diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 4ab382d5c..dfd9b1671 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -67,7 +67,7 @@ vectorcraft-render = { workspace = true } # with default features off). Pinned exactly: review the diff before bumping. muda = { version = "=0.21.2", default-features = false } objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-quartz-core", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-quartz-core", "NSApplication", "NSCursor", "NSImage", "NSImageRep", "NSBitmapImageRep", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] } # Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd. objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] } block2 = "0.6" diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index f47b10e68..fa0f0dca2 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -95,12 +95,18 @@ impl eframe::App for App { // A ready update installs once the process has exited. The macOS exit below skips // dropping the app, so the helper is started here. self.app.start_pending_update(); + #[cfg(target_os = "macos")] + super::mac_cursor::release(); end_before_teardown(self.frames); } } impl App { fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + #[cfg(target_os = "macos")] + if self.winit_window.as_ref().is_some_and(|w| !w.has_focus() || w.is_visible() == Some(false) || w.is_minimized() == Some(true)) { + super::mac_cursor::release(); + } self.frames = ctx.cumulative_frame_nr(); if let Some(why) = self.graphics_loss.take() { // eframe can't give a window a new device: the user saves and starts again. diff --git a/apps/vectorcraft/src/mac_cursor.rs b/apps/vectorcraft/src/mac_cursor.rs new file mode 100644 index 000000000..85fea6c78 --- /dev/null +++ b/apps/vectorcraft/src/mac_cursor.rs @@ -0,0 +1,241 @@ +//! AppKit delivery of the UI's existing cursor artwork, independent of canvas presentation. +#![forbid(unsafe_code)] +// Interim #2292: remove when pinned winit correctly handles macOS cursor image and hotspot sizing. + +use egui::{Context, FullOutput, Plugin, ViewportId}; +use objc2::{AllocAnyThread, MainThreadMarker, rc::Retained}; +use objc2_app_kit::{NSApplication, NSBitmapImageRep, NSCursor, NSImage, NSWindow}; +use objc2_foundation::{NSData, NSPoint, NSSize}; +use std::cell::RefCell; +use vectorcraft_ui_egui::cursors::native_request::{self, Bitmap, Key, Request}; + +const CAPACITY: usize = 128; + +fn build(bitmap: &Bitmap) -> Option> { + let one = &bitmap.representations[0]; + let size = NSSize::new(f64::from(one.size[0]), f64::from(one.size[1])); + let image = NSImage::initWithSize(NSImage::alloc(), size); + for bitmap in &bitmap.representations { + let pixels = image::RgbaImage::from_raw(u32::from(bitmap.size[0]), u32::from(bitmap.size[1]), bitmap.rgba.to_vec())?; + let mut png = std::io::Cursor::new(Vec::new()); + pixels.write_to(&mut png, image::ImageFormat::Png).ok()?; + let rep = NSBitmapImageRep::imageRepWithData(&NSData::with_bytes(png.get_ref()))?; + rep.setSize(size); + image.addRepresentation(&rep); + } + Some(NSCursor::initWithImage_hotSpot(NSCursor::alloc(), &image, NSPoint::new(f64::from(one.hotspot[0]), f64::from(one.hotspot[1])))) +} + +/// The ownership protocol also runs against a headless backend in tests. +trait Backend { + type Window: Clone + PartialEq; + type Cursor: Clone + PartialEq; + fn window(&self) -> Option; + fn enabled(&self, window: &Self::Window) -> bool; + fn suspend(&mut self, window: &Self::Window); + fn restore(&mut self, window: &Self::Window); + fn build(&mut self, request: &Request) -> Option; + fn current(&self) -> Option; + fn set(&mut self, cursor: &Self::Cursor); +} + +struct State { + backend: B, + candidate: Option, + cache: Vec<(Key, Option)>, + // Retain the active object independently of cache eviction. + active: Option<(Key, B::Window, B::Cursor)>, +} + +impl State { + fn new(backend: B) -> Self { + Self { backend, candidate: None, cache: Vec::new(), active: None } + } + + fn begin(&mut self) { + self.candidate = self.backend.window(); + } + + fn release(&mut self) { + self.candidate = None; + if let Some((_, window, _)) = self.active.take() { + self.backend.restore(&window); + } + } + + fn finish(&mut self, request: Option) { + let window = self.candidate.take().filter(|w| self.backend.window().as_ref() == Some(w)); + let (Some(request), Some(window)) = (request, window) else { + self.release(); + return; + }; + if let Some((key, owned, cursor)) = &self.active + && *key == request.key + && *owned == window + { + if self.backend.current().as_ref() != Some(cursor) { + self.backend.set(cursor); + } + return; + } + self.release(); + // Never take another component's suspension (a sheet or native tracking session). + if !self.backend.enabled(&window) { + return; + } + let cursor = if let Some((_, cursor)) = self.cache.iter().find(|(key, _)| *key == request.key) { + cursor.clone() + } else { + let cursor = self.backend.build(&request); + if self.cache.len() >= CAPACITY { + self.cache.clear(); + } + self.cache.push((request.key, cursor.clone())); + cursor + }; + if let Some(cursor) = cursor { + self.backend.suspend(&window); + self.backend.set(&cursor); + self.active = Some((request.key, window, cursor)); + } + } +} + +#[derive(Default)] +struct AppKit { + root: std::sync::Weak, +} +impl Backend for AppKit { + type Window = Retained; + type Cursor = Retained; + fn window(&self) -> Option { + let mtm = MainThreadMarker::new()?; + // Bind the candidate to the host's actual root window without converting raw pointers. + // A floating native panel may be key while egui still has stale root focus input. + let root = self.root.upgrade()?; + if !root.has_focus() || root.is_visible() == Some(false) || root.is_minimized() == Some(true) { + return None; + } + NSApplication::sharedApplication(mtm).keyWindow().filter(|w| w.isVisible() && w.isKeyWindow()) + } + fn enabled(&self, window: &Self::Window) -> bool { + window.areCursorRectsEnabled() + } + fn suspend(&mut self, window: &Self::Window) { + window.disableCursorRects(); + } + fn restore(&mut self, window: &Self::Window) { + window.enableCursorRects(); + window.resetCursorRects(); + // egui-winit may skip an unchanged fallback icon. Rebuild its stored rectangle anyway. + if let Some(view) = window.contentView() { + window.invalidateCursorRectsForView(&view); + } + } + fn build(&mut self, request: &Request) -> Option { + build(&request.bitmap) + } + fn current(&self) -> Option { + Some(NSCursor::currentCursor()) + } + fn set(&mut self, cursor: &Self::Cursor) { + cursor.set(); + } +} + +thread_local! { static STATE: RefCell> = RefCell::new(State::new(AppKit::default())); } + +fn with_state(f: impl FnOnce(&mut State)) { + // All production AppKit access, including restoration, stays on the main thread. + if MainThreadMarker::new().is_some() { + STATE.with(|state| { + if let Ok(mut state) = state.try_borrow_mut() { + f(&mut state); + } + }); + } +} + +/// Also called synchronously when native menu tracking begins and before process teardown. +pub(crate) fn release() { + with_state(State::release); +} + +struct EndedPass { + viewport: ViewportId, + discarded: bool, + request: Option, +} + +struct Delivery { + begin: fn(), + finish: fn(Option), + release: fn(), + tracking: fn() -> bool, + ended: Vec, +} + +impl Plugin for Delivery { + fn debug_name(&self) -> &'static str { + "macOS canvas cursor" + } + fn on_begin_pass(&mut self, ui: &mut egui::Ui) { + native_request::clear(ui.ctx()); + // Floating panels must not acquire or release the main canvas's window. + if ui.ctx().viewport_id() == ViewportId::ROOT { + (self.begin)(); + } + } + fn on_end_pass(&mut self, ui: &mut egui::Ui) { + // egui restores the parent viewport before output_hook. Capture this pass here so + // a floating panel's output cannot consume or release its parent's cursor. + self.ended.push(EndedPass { viewport: ui.ctx().viewport_id(), discarded: ui.ctx().will_discard(), request: native_request::take(ui.ctx()) }); + } + fn input_hook(&mut self, _: &Context, input: &mut egui::RawInput) { + if input.viewport_id == ViewportId::ROOT + && (!input.focused || input.viewports.get(&ViewportId::ROOT).is_some_and(|v| v.minimized == Some(true))) + { + (self.release)(); + } + } + fn output_hook(&mut self, ctx: &Context, output: &mut FullOutput) { + let Some(ended) = self.ended.pop() else { return }; + if ended.viewport != ViewportId::ROOT { + return; + } + if ended.discarded + || (output.platform_output.requested_discard() && output.platform_output.num_completed_passes < ctx.options(|o| o.max_passes.get())) + { + return; + } + let request = ended.request.filter(|r| { + r.viewport == ended.viewport + && r.pass.checked_add(1) == Some(ctx.cumulative_pass_nr_for(ended.viewport)) + && ctx.input(|i| i.focused && i.pointer.hover_pos().is_some()) + && output.platform_output.cursor_icon == r.fallback + && output.platform_output.cursor_image.is_none() + && !(self.tracking)() + }); + // This is BEFORE egui-winit's platform output. In pinned winit, fallback changes update + // the view's stored cursor and invalidate rectangles. Our owned suspension is intended + // to keep that update from replacing the native cursor; native timing needs hand testing. + (self.finish)(request); + } + fn on_exit(&mut self, _: &Context) { + (self.release)(); + } +} + +pub(crate) fn install(ctx: &Context, window: Option<&std::sync::Arc>) { + with_state(|state| state.backend.root = window.map(std::sync::Arc::downgrade).unwrap_or_default()); + ctx.add_plugin(Delivery { + begin: || with_state(State::begin), + finish: |request| with_state(|state| state.finish(request)), + release, + tracking: super::mac_menu::is_tracking, + ended: Vec::new(), + }); +} + +#[cfg(test)] +mod tests; diff --git a/apps/vectorcraft/src/mac_cursor/tests.rs b/apps/vectorcraft/src/mac_cursor/tests.rs new file mode 100644 index 000000000..648492c4b --- /dev/null +++ b/apps/vectorcraft/src/mac_cursor/tests.rs @@ -0,0 +1,236 @@ +use super::*; +use egui::{CursorIcon, RawInput}; +use std::cell::Cell; + +#[derive(Default)] +struct Fake { + window: Option, + suspended: Vec, + current: Option, + sets: usize, + builds: usize, + restores: Vec, + fail: bool, +} +impl Backend for Fake { + type Window = u8; + type Cursor = Key; + fn window(&self) -> Option { + self.window + } + fn enabled(&self, w: &u8) -> bool { + !self.suspended.contains(w) + } + fn suspend(&mut self, w: &u8) { + self.suspended.push(*w); + } + fn restore(&mut self, w: &u8) { + self.suspended.retain(|v| v != w); + self.restores.push(*w); + self.current = None; + } + fn build(&mut self, r: &Request) -> Option { + self.builds += 1; + (!self.fail).then_some(r.key) + } + fn current(&self) -> Option { + self.current + } + fn set(&mut self, cursor: &Key) { + self.current = Some(*cursor); + self.sets += 1; + } +} +fn fake_state() -> State { + State::new(Fake { window: Some(1), ..Default::default() }) +} +fn raw(id: ViewportId, focused: bool) -> RawInput { + let mut input = RawInput { viewport_id: id, focused, events: vec![egui::Event::PointerMoved(egui::pos2(10.0, 10.0))], ..Default::default() }; + input.viewports.entry(id).or_default(); + input +} +fn publish(ctx: &Context, scale: f32) { + let mut session = vectorcraft_engine::Session::new(); + let cursor = session.cursor(Default::default(), Default::default(), Default::default()); + let fallback = native_request::request(ctx, cursor, CursorIcon::Default, true, scale); + ctx.set_cursor_icon(fallback); +} +fn request(scale: f32) -> Request { + let ctx = Context::default(); + let _ = headless(&ctx, raw(ViewportId::ROOT, true), |ui| publish(ui.ctx(), scale)); + native_request::take(&ctx).unwrap() +} +fn deliver(state: &mut State, request: Option) { + state.begin(); + state.finish(request); +} + +#[test] +fn mac_cursor_ownership_deduplicates_reasserts_and_restores() { + let req = request(1.0); + let mut state = fake_state(); + deliver(&mut state, Some(req.clone())); + assert_eq!(state.backend.suspended, [1]); + assert_eq!((state.backend.builds, state.backend.sets), (1, 1)); + deliver(&mut state, Some(req.clone())); + assert_eq!(state.backend.sets, 1); + // Simulate a platform fallback replacing the cursor after finalization. The next pass + // reasserts; this proves the protocol, not AppKit timing or absence of transient flicker. + state.backend.current = None; + deliver(&mut state, Some(req.clone())); + assert_eq!(state.backend.sets, 2); + deliver(&mut state, None); + assert_eq!(state.backend.restores, [1]); + deliver(&mut state, Some(req.clone())); + state.backend.window = None; + deliver(&mut state, Some(req.clone())); + assert!(state.active.is_none()); + state.backend.window = Some(1); + deliver(&mut state, Some(req.clone())); + state.begin(); + state.backend.window = Some(2); + state.finish(Some(req)); + assert!(state.active.is_none(), "never acquire a different key window mid-pass"); + assert_eq!(state.backend.restores, [1, 1, 1]); +} + +#[test] +fn mac_cursor_respects_other_suspensions_failures_and_cache_eviction() { + let mut state = fake_state(); + state.backend.suspended.push(1); + deliver(&mut state, Some(request(1.0))); + assert_eq!(state.backend.builds, 0); + state.release(); + assert_eq!(state.backend.suspended, [1]); + assert!(state.backend.restores.is_empty()); + state.backend.suspended.clear(); + state.backend.fail = true; + for _ in 0..2 { + deliver(&mut state, Some(request(1.0))); + } + assert_eq!(state.backend.builds, 1, "cache failed constructions"); + state.backend.fail = false; + for i in 1..140 { + deliver(&mut state, Some(request(1.0 + i as f32 / 1000.0))); + } + assert!(state.cache.len() <= CAPACITY); + let active = state.active.unwrap(); + state.cache.clear(); + assert_eq!(state.active, Some(active)); + state.release(); + assert!(state.backend.suspended.is_empty()); +} + +thread_local! { + static FAKE: RefCell> = RefCell::new(fake_state()); + static TRACKING: Cell = const { Cell::new(false) }; +} +fn install_fake(ctx: &Context) { + ctx.add_plugin(Delivery { + begin: || FAKE.with(|s| s.borrow_mut().begin()), + finish: |r| FAKE.with(|s| s.borrow_mut().finish(r)), + release: || FAKE.with(|s| s.borrow_mut().release()), + tracking: || TRACKING.with(Cell::get), + ended: Vec::new(), + }); +} +#[test] +fn mac_cursor_plugin_consumes_passes_and_releases_for_menu_focus_and_overrides() { + FAKE.with(|s| *s.borrow_mut() = fake_state()); + TRACKING.with(|t| t.set(false)); + let ctx = Context::default(); + install_fake(&ctx); + install_fake(&ctx); + let root = ViewportId::ROOT; + let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0)); + FAKE.with(|s| assert_eq!(s.borrow().backend.sets, 1, "one installed plugin consumes the request")); + let _ = headless(&ctx, raw(ViewportId::from_hash_of("floating-panel"), true), |_| {}); + FAKE.with(|s| assert!(s.borrow().active.is_some())); + let _ = headless(&ctx, raw(root, true), |_| {}); + FAKE.with(|s| assert!(s.borrow().active.is_none(), "empty pass hands back even an unchanged Default icon")); + let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0)); + let _ = headless(&ctx, raw(root, true), |ui| { + publish(ui.ctx(), 1.0); + ui.ctx().set_cursor_icon(CursorIcon::Text); + }); + FAKE.with(|s| assert!(s.borrow().active.is_none(), "changed-icon handback")); + let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0)); + // The notification calls release without waiting for another UI pass. + TRACKING.with(|t| t.set(true)); + FAKE.with(|s| s.borrow_mut().release()); + FAKE.with(|s| assert!(s.borrow().active.is_none())); + let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0)); + FAKE.with(|s| assert!(s.borrow().active.is_none())); + TRACKING.with(|t| t.set(false)); + let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0)); + let _ = headless(&ctx, raw(root, false), |_| {}); + FAKE.with(|s| assert!(s.borrow().active.is_none())); + let _ = headless(&ctx, raw(root, true), |ui| publish(ui.ctx(), 1.0)); + FAKE.with(|s| assert!(s.borrow().active.is_some())); +} + +#[test] +fn mac_cursor_discarded_pass_does_not_commit_and_installation_does_not_repaint() { + FAKE.with(|s| *s.borrow_mut() = fake_state()); + TRACKING.with(|t| t.set(false)); + let ctx = Context::default(); + install_fake(&ctx); + let mut passes = 0; + let _ = headless(&ctx, raw(ViewportId::ROOT, true), |ui| { + passes += 1; + if passes == 1 { + publish(ui.ctx(), 1.0); + ui.ctx().request_discard("cursor regression"); + } + }); + assert_eq!(passes, 2); + FAKE.with(|s| assert_eq!(s.borrow().backend.sets, 0)); + let idle = || { + let mut input = raw(ViewportId::ROOT, true); + input.events.clear(); + input.time = Some(ctx.input(|i| i.time) + 0.1); + input + }; + for _ in 0..3 { + let _ = headless(&ctx, idle(), |ui| publish(ui.ctx(), 1.0)); + } + let out = headless(&ctx, idle(), |ui| publish(ui.ctx(), 1.0)); + assert!(out.viewport_output[&ViewportId::ROOT].repaint_delay > std::time::Duration::ZERO); +} + +#[test] +fn mac_cursor_appkit_representations_have_pixel_sizes_and_point_hotspots() { + // Only construct and inspect image/cursor objects: no NSApplication, window or cursor.set(). + for scale in [0.75, 1.0, 1.25, 1.5, 2.0] { + let request = request(scale); + let cursor = build(&request.bitmap).unwrap(); + let image = cursor.image(); + let one = &request.bitmap.representations[0]; + let size = NSSize::new(f64::from(one.size[0]), f64::from(one.size[1])); + assert_eq!(image.size(), size); + assert_eq!(cursor.hotSpot(), NSPoint::new(f64::from(one.hotspot[0]), f64::from(one.hotspot[1]))); + let reps = image.representations(); + assert_eq!(reps.len(), 2); + let mut dimensions: Vec<_> = reps + .iter() + .map(|rep| { + assert_eq!(rep.size(), size); + (rep.pixelsWide(), rep.pixelsHigh()) + }) + .collect(); + dimensions.sort_unstable(); + assert_eq!( + dimensions, + [ + (isize::try_from(one.size[0]).unwrap(), isize::try_from(one.size[1]).unwrap()), + (isize::try_from(one.size[0]).unwrap() * 2, isize::try_from(one.size[1]).unwrap() * 2) + ] + ); + } +} + +fn headless(ctx: &egui::Context, input: egui::RawInput, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut output = ctx.run_ui(input, draw); + output.textures_delta.clear(); + output +} diff --git a/apps/vectorcraft/src/mac_menu.rs b/apps/vectorcraft/src/mac_menu.rs index 0b94fa03c..f90e4f730 100644 --- a/apps/vectorcraft/src/mac_menu.rs +++ b/apps/vectorcraft/src/mac_menu.rs @@ -31,6 +31,10 @@ use vectorcraft_ui_egui::native_menu::{self, Backend, Event, MenuBar, MenuRole, // asynchronous menu presentation). Keep the native tracking state separate from egui popups. static TRACKING: AtomicBool = AtomicBool::new(false); +pub(crate) fn is_tracking() -> bool { + TRACKING.load(Ordering::Relaxed) +} + fn observe_tracking(ctx: &egui::Context) { static INSTALL: Once = Once::new(); INSTALL.call_once(|| { @@ -41,13 +45,17 @@ fn observe_tracking(ctx: &egui::Context) { let repaint = ctx.clone(); let block = RcBlock::new(move |_: std::ptr::NonNull| { TRACKING.store(tracking, Ordering::Relaxed); + if tracking { + super::mac_cursor::release(); + } // Apply any deferred menu updates as soon as the tracking session ends. if !tracking { repaint.request_repaint(); } }); // SAFETY: these are AppKit's notification names. The notification center retains - // the observer and block for the application's lifetime; callbacks update an atomic and request repaint. + // the observer and block for the application's lifetime; callbacks update tracking, restore + // owned cursor rectangles and request repaint. #[allow(unsafe_code)] unsafe { NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(Some(name), None, None, &block); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index eb2011218..7e4f245d3 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -36,6 +36,8 @@ mod logging; #[cfg(all(target_os = "macos", feature = "wgpu"))] mod mac_color_space; #[cfg(target_os = "macos")] +mod mac_cursor; +#[cfg(target_os = "macos")] mod mac_fonts; #[cfg(target_os = "macos")] mod mac_menu; @@ -551,6 +553,7 @@ fn main() -> std::process::ExitCode { if !in_window_menus { app.services.native_menu = mac_menu::install(&cc.egui_ctx, &app); } + mac_cursor::install(&cc.egui_ctx, winit_window.as_ref()); } #[cfg(not(target_os = "macos"))] { diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs new file mode 100644 index 000000000..b7c4f1060 --- /dev/null +++ b/crates/text/src/harakat.rs @@ -0,0 +1,216 @@ +//! Arabic marks (harakat) styled apart from their letters. +//! +//! A designer colours the harakat apart from the letters (or hides them with no fill) without +//! breaking the letters' joining or the marks' attachment: runs that differ only in paint +//! ([`paint_only`]) are not a shaping boundary at a harakat ([`paint_only_mark_boundary`]), so the +//! letter and its marks are still shaped together, glyph for glyph as before. When the character- +//! level shaping pass matches the monotone pass ([`own_chars`]), a glyph may take a later +//! in-cluster character's run only when it is positively a mark: the face's GDEF GlyphClassDef +//! names it class 3 (mark) and that character is a harakat. Anything else—including fonts with no +//! GDEF, AAT/`morx` faces where the class is unavailable, ligatures, and multi-component +//! glyphs—keeps the shaping cluster's first character. Conservative means a mark may fail to take +//! its colour, but a base letter must never take a mark's colour. AAT/`morx` fonts may still leave +//! a mark on a ligature (for example Geeza Pro's Allah ligature) painted as its letter. Renderers +//! and exporters read the glyph's run as they always did, so they need no change. The text itself +//! is never changed. + +use skrifa::raw::TableProvider; +use skrifa::raw::tables::layout::ClassDef; +use vectorcraft_doc::CharStyle; + +use crate::fontdb::FontFace; + +/// Is `c` an Arabic combining mark: harakat (fatha, damma, kasra, tanwin, shadda, sukun), the +/// superscript alef, Quranic annotation marks, and the extended-A marks? +pub(crate) fn is_harakat(c: char) -> bool { + matches!( + c as u32, + 0x0610..=0x061A + | 0x064B..=0x065F + | 0x0670 + | 0x06D6..=0x06DC + | 0x06DF..=0x06E4 + | 0x06E7..=0x06E8 + | 0x06EA..=0x06ED + | 0x08D3..=0x08FF + ) +} + +/// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the +/// stroke is drawn, overprint, and the character style name? Those are not read by the shaper +/// and change no glyph, advance or position. Stricter than [`crate::shape`]'s `shapes_alike` +/// (which also ignores baseline shift, tracking, underline, …). +pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { + // Exhaustive: a new attribute must explicitly keep or relax this boundary rule. + let CharStyle { + font_family, + font_style, + font_version, + font_axes, + size, + leading, + tracking, + kerning, + optical_kerning, + baseline_shift, + h_scale, + v_scale, + rotation, + underline, + strikethrough, + all_caps, + features, + position, + small_caps, + char_align, + char_direction, + kashidas, + proportional_metrics, + tate_chu_yoko, + tcy_up_down, + tcy_left_right, + standard_vertical_roman, + digits, + font_height_reference, + no_break, + language, + glyph_alternate, + fill, + stroke, + stroke_width, + stroke_miter_limit, + stroke_dash, + overprint_fill: _, + overprint_stroke: _, + stroke_cap: _, + stroke_join: _, + stroke_align: _, + style_name: _, + } = a; + // Previously b's paint was replaced with a's before comparing the whole styles. Keep + // that PartialEq behavior for non-reflexive paint values (NaN), without cloning paint. + let original_paint = (&a.fill, &a.stroke, &a.stroke_width, &a.stroke_miter_limit, &a.stroke_dash); + let normalized_paint = (fill, stroke, stroke_width, stroke_miter_limit, stroke_dash); + original_paint == normalized_paint + && font_family == &b.font_family + && font_style == &b.font_style + && font_version == &b.font_version + && font_axes == &b.font_axes + && size == &b.size + && leading == &b.leading + && tracking == &b.tracking + && kerning == &b.kerning + && optical_kerning == &b.optical_kerning + && baseline_shift == &b.baseline_shift + && h_scale == &b.h_scale + && v_scale == &b.v_scale + && rotation == &b.rotation + && underline == &b.underline + && strikethrough == &b.strikethrough + && all_caps == &b.all_caps + && features == &b.features + && position == &b.position + && small_caps == &b.small_caps + && char_align == &b.char_align + && char_direction == &b.char_direction + && kashidas == &b.kashidas + && proportional_metrics == &b.proportional_metrics + && tate_chu_yoko == &b.tate_chu_yoko + && tcy_up_down == &b.tcy_up_down + && tcy_left_right == &b.tcy_left_right + && standard_vertical_roman == &b.standard_vertical_roman + && digits == &b.digits + && font_height_reference == &b.font_height_reference + && no_break == &b.no_break + && language == &b.language + && glyph_alternate == &b.glyph_alternate +} + +/// Is the run boundary at byte `at` of `text`, between styles `before` and `after`, one that +/// stays inside a shaping cluster: they differ only in paint and a harakat is on either side? +pub(crate) fn paint_only_mark_boundary(text: &str, at: usize, before: &CharStyle, after: &CharStyle) -> bool { + let next = text.get(at..).and_then(|s| s.chars().next()); + let previous = text.get(..at).and_then(|s| s.chars().next_back()); + (next.is_some_and(is_harakat) || previous.is_some_and(is_harakat)) && paint_only(before, after) +} + +/// The byte ranges of the harakat in `text[range]`, each run of consecutive harakat as one range. +/// The range is normalised as [`crate::edit::style_range`] takes one: its ends clamped to the +/// text, each moved back to the start of the character it falls in, and put in order. +#[cfg(test)] +pub(crate) fn harakat_ranges(text: &str, range: std::ops::Range) -> Vec> { + let (mut start, mut end) = (crate::edit::floor_char(text, range.start), crate::edit::floor_char(text, range.end)); + if start > end { + std::mem::swap(&mut start, &mut end); + } + let Some(part) = text.get(start..end) else { + return vec![]; + }; + let mut out: Vec> = vec![]; + for (i, c) in part.char_indices().filter(|(_, c)| is_harakat(*c)) { + let (a, b) = (start + i, start + i + c.len_utf8()); + match out.last_mut() { + Some(last) if last.end == a => last.end = b, + _ => out.push(a..b), + } + } + out +} + +/// Does `face`'s GDEF GlyphClassDef name glyph `gid` as class 3 (mark)? False when the face has +/// no GDEF, no class def, or the class is unavailable (typical of AAT/`morx` paths). +#[cfg(test)] +pub(crate) fn is_gdef_mark(face: &FontFace, gid: u32) -> bool { + mark_classes(face).as_ref().is_some_and(|classes| classes.get(skrifa::GlyphId::new(gid)) == 3) +} + +/// Borrow the face's mark classification once for a shaping pass. No GDEF, missing classes +/// or malformed tables keep the conservative cluster-start ownership. +pub(crate) fn mark_classes(face: &FontFace) -> Option> { + face.skrifa()?.gdef().ok()?.glyph_class_def()?.ok() +} + +/// Each glyph's own character, from the shaping `by_char` at character cluster level of the +/// glyphs `raw` (glyph id, cluster, x advance, x and y offset; `by_char` with the y advance after +/// the x advance), the segment ending at byte `end`, and `text` for character identity. +/// `is_gdef_mark(gid)` is true only when the face's GDEF GlyphClassDef names that glyph class 3. +/// Empty unless `by_char` sets the same glyphs the same way (glyph id, advance and offsets, glyph +/// by glyph): repeated glyph ids alone could hide a changed correspondence. A glyph whose character +/// lies outside its own shaping cluster of `raw` (from its cluster value up to the next larger one, +/// else `end`) gets its cluster's first character instead. A later in-cluster character is kept +/// only when the glyph is a GDEF mark and that character is a harakat; otherwise the cluster start +/// is kept (conservative: a mark may miss its colour; a base must never take a mark's). `raw`'s +/// clusters are never changed. These are HarfBuzz shaping clusters, which can span more than one +/// grapheme (a ligature). +pub(crate) fn own_chars( + raw: &[(u32, u32, i32, i32, i32)], + by_char: &[(u32, u32, i32, i32, i32, i32)], + end: usize, + text: &str, + is_gdef_mark: impl Fn(u32) -> bool, +) -> Vec { + let same = by_char.len() == raw.len() && by_char.iter().zip(raw).all(|(c, r)| c.0 == r.0 && c.2 == r.2 && c.4 == r.3 && c.5 == r.4); + if !same { + return vec![]; + } + let mut starts: Vec = raw.iter().map(|g| g.1).collect(); + starts.sort_unstable(); + starts.dedup(); + let cluster_end = |cl: u32| starts.get(starts.partition_point(|&c| c <= cl)).map_or(end, |&c| c as usize); + by_char + .iter() + .zip(raw) + .map(|(c, r)| { + let in_cluster = r.1 <= c.1 && (c.1 as usize) < cluster_end(r.1); + if !in_cluster { + return r.1; + } + if c.1 == r.1 { + return c.1; + } + // Later in-cluster character: only a positively identified mark glyph on a harakat. + let mark_char = text.get(c.1 as usize..).and_then(|s| s.chars().next()).is_some_and(is_harakat); + if is_gdef_mark(r.0) && mark_char { c.1 } else { r.1 } + }) + .collect() +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 9d96dca04..8e594c036 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -503,11 +503,22 @@ impl Ctx<'_> { }); } + /// Source-run index covering byte `b` (the cluster start for a letter with its marks). + fn run_index_at(&self, b: usize) -> Option { + let find = |b: usize| { + let i = self.runs.partition_point(|(r, _)| r.end <= b); + self.runs.get(i).filter(|(r, _)| r.start <= b).map(|_| i) + }; + find(b).or_else(|| b.checked_sub(1).and_then(find)) + } + /// Draw a kashida `len` points long from `at` (the pen on the baseline) on line `line`, as /// tatweels in the face, size and style of glyph `g`. Short, Medium and Long kashidas are one /// tatweel stretched along the baseline; a Stylistic one is tatweels side by side. Each tatweel /// is a glyph at `g`'s byte offset that stands for no character (length 0) and does not - /// advance: `g`'s advance takes the kashida in. + /// advance: `g`'s advance takes the kashida in. Paint comes from the base letter's run at the + /// cluster start (`g.byte`), not from a trailing mark that may be the last glyph of the + /// cluster when harakat are coloured or hidden apart from their letters. fn emit_kashida(&mut self, g: &SGlyph, kind: Kashida, len: f64, at: Point, line: usize) { let Some((gid, t)) = kashida::tatweel(g) else { return }; let mut k = g.clone(); @@ -521,6 +532,10 @@ impl Ctx<'_> { k.ch = kashida::TATWEEL; k.inline = None; k.tcy = None; + // Base letter's run: `g` may be a mark (last in the shaping cluster) with mark paint. + if let Some(run) = self.run_index_at(g.byte) { + k.run = run; + } if kind == Kashida::Stylistic { let n = (len / t).round().clamp(1.0, kashida::MAX_TATWEELS) as usize; for m in 0..n { @@ -535,3 +550,167 @@ impl Ctx<'_> { #[cfg(test)] mod wrapping_tests; + +/// Forced mark-last kashida emitter: the absorbing glyph carries a mark run while the cluster +/// start is a letter run; the tatweel must take the letter run. Reverting the base-run assignment +/// in [`Ctx::emit_kashida`] fails these. +#[cfg(test)] +mod emit_kashida_mark_last { + use super::*; + use std::sync::Arc; + use vectorcraft_doc::color::{Color, Paint}; + + fn arabic_face() -> Option> { + let db = FontDb::global(); + ["Noto Sans Arabic", "Arial", "Tahoma", "Times New Roman"] + .into_iter() + .filter_map(|name| db.face(name, "Regular")) + .find(|face| face.covers('ب') && face.covers('\u{064E}') && face.covers('\u{0640}')) + } + + fn force_mark_last(mark_fill: Paint) { + let Some(face) = arabic_face() else { + eprintln!("SKIPPED: emit_kashida_mark_last; no Arabic face with tatweel"); + return; + }; + let Some((tat_gid, _)) = kashida::tatweel(&SGlyph { + face: Arc::clone(&face), + gid: 0, + byte: 0, + len: 2, + run: 0, + adv: 10.0, + dx: 0.0, + dy: 0.0, + kern_shift: 0.0, + sx: face.upem.recip() * 48.0, + sy: face.upem.recip() * 48.0, + bshift: 0.0, + rotation: 0.0, + ascent: 40.0, + descent: 10.0, + leading: 0.0, + cap: 30.0, + xh: 20.0, + ch: 'ب', + tcy: None, + inline: None, + word_end: false, + lead: 0.0, + proportional: false, + vpal: None, + level: Level::rtl(), + no_break: false, + vertical_roman: false, + cursive: true, + hyphen_language: crate::hyphen::Language::English, + }) else { + eprintln!("SKIPPED: emit_kashida_mark_last; face has no tatweel"); + return; + }; + let letter = CharStyle { font_family: face.family.clone(), size: 48.0, fill: Paint::solid(Color::BLACK), ..CharStyle::default() }; + let mut mark = letter.clone(); + mark.fill = mark_fill.clone(); + // "بَ": letter run 0 covers 0..2, mark run 1 covers 2..4. Cluster starts at byte 0. + let text = "بَ"; + let opts = LayoutOptions::default(); + let db = FontDb::global(); + let mut cx = Ctx { + on_path: false, + db, + text, + runs: vec![(0..2, &letter), (2..4, &mark)], + set_runs: Vec::new(), + inlines: vec![None, None], + faces: vec![std::cell::OnceCell::new(), std::cell::OnceCell::new()], + default: letter.clone(), + opts: &opts, + vertical: false, + system_layout: false, + fractional_widths: true, + upright_cluster: None, + out: TextLayout::default(), + }; + // Last visual glyph of the cluster: a mark that wrongly keeps the mark run (run 1). + let mut g = SGlyph { + face: Arc::clone(&face), + gid: face.glyph_for('\u{064E}'), + byte: 0, + len: 4, + run: 1, + adv: 0.0, + dx: 0.0, + dy: 0.0, + kern_shift: 0.0, + sx: face.upem.recip() * 48.0, + sy: face.upem.recip() * 48.0, + bshift: 0.0, + rotation: 0.0, + ascent: 40.0, + descent: 10.0, + leading: 0.0, + cap: 30.0, + xh: 20.0, + ch: '\u{064E}', + tcy: None, + inline: None, + word_end: false, + lead: 0.0, + proportional: false, + vpal: None, + level: Level::rtl(), + no_break: false, + vertical_roman: false, + cursive: true, + hyphen_language: crate::hyphen::Language::English, + }; + assert_ne!(g.run, 0, "precondition: absorbing glyph has mark run ≠ cluster-start run"); + assert_eq!(cx.style_at(g.byte).fill, letter.fill, "precondition: cluster start is letter paint"); + assert_eq!(mark.fill, mark_fill, "precondition: mark paint differs"); + cx.emit_kashida(&g, Kashida::Long, 20.0, Point::ZERO, 0); + let tatweels: Vec<_> = cx.out.glyphs.iter().filter(|pg| pg.gid == tat_gid && pg.len == 0).collect(); + assert!(!tatweels.is_empty(), "tatweel emitted"); + for t in tatweels { + assert_eq!(t.run, 0, "tatweel must use base letter run, not mark run {}", g.run); + let fill = cx.runs.get(t.run).map(|(_, s)| &s.fill).expect("run"); + assert_eq!(fill, &letter.fill, "tatweel paint is the letter's"); + assert_ne!(fill, &mark_fill, "tatweel must not inherit mark paint"); + } + // Without the base-run assignment, emit_kashida would keep g.run == 1. + g.run = 1; + let mut broken = Ctx { + on_path: false, + db, + text, + runs: vec![(0..2, &letter), (2..4, &mark)], + set_runs: Vec::new(), + inlines: vec![None, None], + faces: vec![std::cell::OnceCell::new(), std::cell::OnceCell::new()], + default: letter.clone(), + opts: &opts, + vertical: false, + system_layout: false, + fractional_widths: true, + upright_cluster: None, + out: TextLayout::default(), + }; + // Simulate the pre-fix path: clone run from the mark glyph only. + let mut k = g.clone(); + k.gid = tat_gid; + k.len = 0; + k.ch = kashida::TATWEEL; + // k.run stays 1 — what emit_kashida did before the fix. + broken.emit(&k, Affine::IDENTITY, Point::ZERO, 0.0, 0.0, 0); + assert_eq!(broken.out.glyphs[0].run, 1, "sanity: unrepaired path keeps mark run"); + } + + #[test] + fn coloured_mark_last_tatweel_uses_letter_run() { + force_mark_last(Paint::solid(Color::rgb8(200, 30, 30))); + } + + #[test] + fn hidden_mark_last_tatweel_uses_letter_run() { + force_mark_last(Paint::None); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 27d35c170..cbc5157ed 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -19,6 +19,7 @@ pub mod embed; mod features; mod fontconfig; mod fontdb; +mod harakat; pub mod hyphen; mod justify; mod kashida; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 88f9a1f39..b913c7cc2 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -8,7 +8,7 @@ use unicode_bidi::Level; use unicode_script::{Script, UnicodeScript}; use unicode_segmentation::UnicodeSegmentation; -use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer}; +use harfrust::{BufferClusterLevel, Direction, Feature, ShapeOptions, Tag, UnicodeBuffer}; use kurbo::Shape; use skrifa::MetadataProvider; use skrifa::charmap::Charmap; @@ -1007,6 +1007,10 @@ struct Shaped { raw: Vec<(u32, u32, i32, i32, i32)>, proportional: Vec, vpal: Vec>, + /// Each glyph's own character (cluster at character level), when harakat are styled apart + /// from their letters and the character-level pass matches ([`crate::harakat`]); empty + /// otherwise (glyphs keep their shaping-cluster start). + chars: Vec, } fn is_mark(c: char) -> bool { @@ -1073,6 +1077,14 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe if !manual_ends.is_empty() { return shape_apart(manual_ends, out); } + // Harakat styled apart from their letters (paint only): shaped with them, each glyph then + // takes its own character's run ([`crate::harakat`]). + let mark_boundaries: Vec = boundaries() + .filter(|&b| run_at(b - 1).1 != run_at(b).1 && crate::harakat::paint_only_mark_boundary(text, b, run_at(b - 1).1, run_at(b).1)) + .collect(); + // Source run starts are sorted. Reuse their classification when checking cluster splits. + let styled_marks = !mark_boundaries.is_empty(); + let char_level = std::cell::Cell::new(false); // Shape the segment as if all of it were in `st`, with the features `more` on top. let shape_in = |st: &CharStyle, more: &[Feature]| { @@ -1083,12 +1095,15 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe .filter(|l| l.len() <= 35 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) .and_then(|l| l.parse::().ok()); let mut shaped = Shaped::default(); - let Shaped { raw, proportional, vpal } = &mut shaped; + let Shaped { raw, proportional, vpal, chars } = &mut shaped; let hb_shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); + // The baseline and character-level pass use exactly the same resolved features. + // Alternate positioning passes still resolve their own features, with `more` last. + let with_more = |resolved: Vec| resolved.into_iter().chain(more.iter().cloned()).collect::>(); + let along_features = with_more(feats.resolve(st)); // gid, cluster, x and y advance, x and y offset. let shape = |feats: &[Feature], direction: Direction| { - let feats: Vec = feats.iter().chain(more).cloned().collect(); let mut buf = UnicodeBuffer::new(); for (i, c) in text_seg.char_indices() { let cl = (range.start + i) as u32; @@ -1103,6 +1118,9 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe } buf.set_direction(direction); buf.guess_segment_properties(); + if char_level.get() { + buf.set_cluster_level(BufferClusterLevel::Characters); + } // The characters' language picks the font's forms for it (`locl`: Turkish keeps fi // apart and the dot on its i). if let Some(l) = &language { @@ -1119,8 +1137,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe } // A buffer whose characters name no script reads as `UNKNOWN`: its plan has none. let script = Some(buf.script()).filter(|s| *s != harfrust::script::UNKNOWN); - let plan = face.shape_plan(&shaper, buf.direction(), script, language.as_ref(), &feats); - let gb = shaper.shape(buf, ShapeOptions::new().features(&feats).plan(Some(&plan))); + let plan = face.shape_plan(&shaper, buf.direction(), script, language.as_ref(), feats); + let gb = shaper.shape(buf, ShapeOptions::new().features(feats).plan(Some(&plan))); gb.glyph_infos() .iter() .zip(gb.glyph_positions()) @@ -1128,13 +1146,25 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe .collect::>() }; let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight }; - *raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect(); + *raw = shape(&along_features, along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect(); let raw = &*raw; let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(raw).all(|(a, b)| a.0 == b.0); + // The same shaping at character level names each glyph's own character (kept only when + // it sets the same glyphs alike, each character in its glyph's cluster). + if let Some(classes) = styled_marks.then(|| crate::harakat::mark_classes(face)).flatten() + // Without a mark glyph, no glyph can take a later character's paint. Avoid + // reshaping; the original clusters already give the conservative ownership. + && raw.iter().any(|g| classes.get(skrifa::GlyphId::new(g.0)) == 3) + { + char_level.set(true); + let by_char = shape(&along_features, along); + char_level.set(false); + *chars = crate::harakat::own_chars(raw, &by_char, range.end, text, |gid| classes.get(skrifa::GlyphId::new(gid)) == 3); + } if feats.proportional(st) { // `palt` is positioning only: the same glyphs set on their full widths tell which // advances it changed (kerning and the rest change both alike). - let full = shape(&feats.resolve_fixed_width(st), along); + let full = shape(&with_more(feats.resolve_fixed_width(st)), along); if same_glyphs(&full) { *proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect(); } @@ -1145,7 +1175,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe // and without either tell how it moves each and changes its advance down the column // (y up, advances negative). Which advances `vpal` itself changed comes from setting // them with it alone. - let ttb = |proportional, kerning| shape(&feats.resolve_vertical(st, proportional, kerning), Direction::TopToBottom); + let ttb = |proportional, kerning| shape(&with_more(feats.resolve_vertical(st, proportional, kerning)), Direction::TopToBottom); let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false)); if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) { *vpal = with @@ -1185,7 +1215,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe shaped }; let first_optical = if optical(range.start) { Some(range.start) } else { boundaries().find(|&b| optical(b)) }; - let Shaped { raw, proportional, vpal } = if let Some(first) = first_optical.filter(|_| !feats.vertical) { + let Shaped { raw, proportional, vpal, chars } = if let Some(first) = first_optical.filter(|_| !feats.vertical) { // The pair entering Optical keeps the left character's font kerning. Subsequent // Optical pairs use outline spacing, so font pair features stop at its second character. let optical_off = if first == range.start { @@ -1245,7 +1275,11 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe cluster_starts.dedup(); // A glyph is drawn in one style: where a cluster would take in characters of runs in different // styles, the runs are shaped apart, as runs that do not shape alike are. - let inside_clusters: Vec = boundaries().filter(|&b| cluster_starts.binary_search(&b).is_err() && run_at(b - 1).1 != run_at(b).1).collect(); + // Except at harakat styled apart only in paint ([`crate::harakat`]). + let inside_clusters: Vec = boundaries() + .filter(|&b| cluster_starts.binary_search(&b).is_err() && run_at(b - 1).1 != run_at(b).1) + .filter(|b| mark_boundaries.binary_search(b).is_err()) + .collect(); if !inside_clusters.is_empty() { return shape_apart(inside_clusters, out); } @@ -1305,6 +1339,11 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() { let cl = cl as usize; let (run, gst) = run_at(cl); + // A glyph of a harakat styled apart takes its own character's run (paint only). + let run = match chars.get(gi).map(|&c| run_at(c as usize)) { + Some((own, own_st)) if crate::harakat::paint_only(gst, own_st) => own, + _ => run, + }; let end = cluster_end(cl); let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl; let ch = first_char(cl); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 68fe65528..2dd3c424c 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -14,6 +14,7 @@ mod devanagari; mod embed; mod fit; mod hang; +mod harakat; mod inline; mod joiners; mod justify; diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs new file mode 100644 index 000000000..c097fc48b --- /dev/null +++ b/crates/text/src/tests/harakat.rs @@ -0,0 +1,651 @@ +//! Arabic marks (harakat) styled apart from their letters ([`crate::harakat`]). +//! Layout cases need an Arabic face: craft-fonts' Noto Sans Arabic when the build has +//! `CRAFT_FONTS_DIR`, else an installed OpenType Arabic face. Without either they skip. + +use std::sync::Arc; + +use kurbo::{Affine, Point, Rect, Shape}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::text::Kashida; +use vectorcraft_doc::{CharStyle, Justify, TextKind, TextObject}; +use vectorcraft_geom::PathData; + +use crate::harakat::{harakat_ranges, is_gdef_mark, is_harakat, own_chars, paint_only, paint_only_mark_boundary}; +use crate::{FontDb, FontFace, PositionedGlyph, TextLayout, layout}; + +const FAMILY: &str = "Noto Sans Arabic"; +const VOCALIZED: &str = "بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ"; +/// Short vocalized word used to probe whether a face's shaped mark glyphs are GDEF class 3. +const GDEF_PROBE: &str = "بَبَب"; + +/// An OpenType Arabic face covering harakat whose shaped fixture glyphs include GDEF mark +/// (class 3) glyphs. Strict colour/ownership layout tests need that: without GDEF, production +/// correctly keeps the letter's paint and those assertions would fail. Fonts with coverage and +/// GSUB but no mark classes are skipped here; synthetic `own_chars` cases and +/// `installed_fonts_keep_their_glyphs_with_coloured_harakat` still cover the no-GDEF fallback. +fn arabic_face(test: &str) -> Option> { + let opentype = |face: &FontFace| { + use skrifa::raw::TableProvider; + face.skrifa().is_some_and(|f| f.gsub().is_ok()) + }; + let db = FontDb::global(); + let face = ["Noto Sans Arabic", "Arial", "Geeza Pro", "SF Arabic", "Tahoma", "Times New Roman"] + .into_iter() + .filter_map(|name| db.face(name, "Regular")) + .find(|face| { + if !(face.covers('ب') && face.covers('\u{064E}') && face.covers('\u{0640}') && opentype(face)) { + return false; + } + // Shape the probe: every zero-advance authored glyph must be a GDEF mark, and at least + // one must appear, so strict colour assertions match production ownership. + let shaped = layout(db, &text_with_harakat(GDEF_PROBE, &face.family, None)); + let marks: Vec<_> = shaped.glyphs.iter().filter(|g| g.len > 0 && g.advance.abs() < 1e-6).collect(); + !marks.is_empty() && marks.iter().all(|g| is_gdef_mark(face, g.gid)) + }); + match &face { + Some(face) => eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {} (GDEF marks)", face.family, face.style), + None => { + eprintln!("SKIPPED: {test}; no OpenType Arabic font with harakat and GDEF mark classes (set CRAFT_FONTS_DIR or install one)") + } + } + face +} + +fn red() -> Paint { + Paint::solid(Color::rgb8(200, 30, 30)) +} + +fn style(family: &str) -> CharStyle { + CharStyle { font_family: family.into(), size: 48.0, ..CharStyle::default() } +} + +/// `text` in `family`, its harakat filled with `fill` (`None`: as the letters). +fn text_with_harakat(text: &str, family: &str, fill: Option) -> TextObject { + let mut t = TextObject::point(Point::ZERO, text, style(family)); + if let Some(fill) = fill { + for r in harakat_ranges(text, 0..text.len()) { + crate::edit::style_range(&mut t.runs, r.start, r.end, |st| st.fill = fill.clone()); + } + } + t +} + +/// One glyph's geometry, bit for bit: face, glyph id, cluster, pen position, transform, advance. +type Geometry = (u32, u32, usize, usize, [u64; 2], [u64; 6], u64); + +fn geometry(l: &TextLayout) -> Vec { + l.glyphs + .iter() + .map(|g| { + (g.font_id, g.gid, g.byte, g.len, [g.origin.x.to_bits(), g.origin.y.to_bits()], g.xf.as_coeffs().map(f64::to_bits), g.advance.to_bits()) + }) + .collect() +} + +fn painted(t: &TextObject, l: &TextLayout, fill: &Paint) -> usize { + l.glyphs.iter().filter(|g| run_of(t, g).style.fill == *fill).count() +} + +fn run_of<'a>(t: &'a TextObject, g: &PositionedGlyph) -> &'a vectorcraft_doc::TextRun { + t.runs.get(g.run).unwrap_or_else(|| panic!("glyph {} at byte {} names missing run {}", g.gid, g.byte, g.run)) +} + +type LetterGlyphs = std::collections::HashSet<(u32, u32)>; + +/// Glyphs drawn by the fixture's bare letters, including separately shaped letter dots. +/// Include the face id: glyph ids are only meaningful within their own font. +fn letter_glyphs(text: &str, family: &str) -> LetterGlyphs { + let bare: String = text.chars().filter(|&c| !is_harakat(c)).collect(); + layout(FontDb::global(), &text_with_harakat(&bare, family, None)).glyphs.iter().map(|g| (g.font_id, g.gid)).collect() +} + +fn fixture_mark(g: &PositionedGlyph, letters: &LetterGlyphs) -> bool { + // Some fonts draw a letter's dots as zero-advance GDEF marks. They keep the letter's paint. + g.advance.abs() < 1e-6 && !letters.contains(&(g.font_id, g.gid)) +} + +/// Expected fill from the fixture's source runs, independent of the glyph's assigned `run`: +/// zero-advance glyphs absent from the bare letters take the first harakat's fill in their +/// cluster; letter glyphs (including their dots) take the cluster-start fill. +fn expected_fill(t: &TextObject, text: &str, g: &PositionedGlyph, letters: &LetterGlyphs) -> Paint { + let end = (g.byte + g.len).min(text.len()); + let span = text.get(g.byte..end).unwrap_or(""); + let letter_fill = style_fill_at(t, g.byte); + if fixture_mark(g, letters) && span.chars().any(is_harakat) { + // Mark glyph: first harakat in the cluster span. + let off = span.char_indices().find(|(_, c)| is_harakat(*c)).map(|(i, _)| g.byte + i).unwrap_or(g.byte); + return style_fill_at(t, off); + } + letter_fill +} + +fn style_fill_at(t: &TextObject, byte: usize) -> Paint { + let mut off = 0; + for r in &t.runs { + let end = off + r.text.len(); + if byte < end || (byte == end && byte == off) { + return r.style.fill.clone(); + } + off = end; + } + t.runs.last().map(|r| r.style.fill.clone()).unwrap_or_else(|| Paint::solid(Color::BLACK)) +} + +#[test] +fn paint_only_differences_are_told_apart() { + let a = style(FAMILY); + let mut b = a.clone(); + b.fill = red(); + b.stroke = red(); + b.stroke_width = 2.0; + assert!(paint_only(&a, &b) && paint_only(&a, &a)); + let mut c = a.clone(); + c.size = 12.0; + assert!(!paint_only(&a, &c)); + let mut d = b.clone(); + d.baseline_shift = 3.0; + assert!(!paint_only(&a, &d), "a raised harakat is not paint only"); + assert!(paint_only_mark_boundary("بِ", "ب".len(), &a, &b)); + assert!(!paint_only_mark_boundary("بب", "ب".len(), &a, &b), "between two letters"); + assert!(!paint_only_mark_boundary("e\u{301}", 1, &a, &b), "a Latin accent keeps the existing rule"); + assert_eq!(harakat_ranges("بِسْمِ", 0.."بِسْمِ".len()), [2..4, 6..8, 10..12]); + assert_eq!(harakat_ranges("اللَّهِ", 0.."اللَّهِ".len()), [6..10, 12..14], "shadda and fatha together"); +} + +/// The allocation-free comparison must keep the previous comparison's complete contract, +/// including attributes that affect layout rather than shaping and non-reflexive file values. +#[test] +fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { + use vectorcraft_doc::{CharAlign, CharPosition, Dash, Digits, FontHeightReference, GlyphChoice, LineCap, LineJoin, ParaDirection, StrokeAlign}; + + fn previous(a: &CharStyle, b: &CharStyle) -> bool { + let mut b = b.clone(); + b.fill = a.fill.clone(); + b.stroke = a.stroke.clone(); + b.stroke_width = a.stroke_width; + b.overprint_fill = a.overprint_fill; + b.overprint_stroke = a.overprint_stroke; + b.stroke_cap = a.stroke_cap; + b.stroke_join = a.stroke_join; + b.stroke_miter_limit = a.stroke_miter_limit; + b.stroke_dash = a.stroke_dash.clone(); + b.stroke_align = a.stroke_align; + b.style_name = a.style_name.clone(); + *a == b + } + + fn check(a: &CharStyle, b: &CharStyle, want: bool, name: &str) { + for (left, right) in [(a, b), (b, a)] { + assert_eq!(paint_only(left, right), previous(left, right), "{name}: old contract"); + } + assert_eq!(paint_only(a, b), want, "{name}"); + } + + let a = CharStyle { + fill: Paint::Pattern { pattern: "named pattern".repeat(8), xf: Affine::IDENTITY }, + stroke: Paint::Solid { color: Color::BLACK, swatch: Some("global swatch".repeat(8)), tint: 0.5 }, + stroke_dash: Some(Dash { pattern: vec![2.0, 3.0, 4.0, 5.0], offset: 0.5, align_corners: true }), + style_name: Some("named character style".repeat(8)), + ..style(FAMILY) + }; + macro_rules! field { + ($field:ident, $value:expr, $want:expr) => {{ + let mut b = a.clone(); + b.$field = $value; + check(&a, &b, $want, stringify!($field)); + }}; + } + field!(font_family, "other family".into(), false); + field!(font_style, "Bold".into(), false); + field!(font_version, Some("other version".into()), false); + field!(font_axes, [("wght".into(), 700.0)].into(), false); + field!(size, 13.0, false); + field!(leading, Some(60.0), false); + field!(tracking, 20.0, false); + field!(kerning, Some(0.0), false); + field!(optical_kerning, true, false); + field!(baseline_shift, 1.0, false); + field!(h_scale, 90.0, false); + field!(v_scale, 90.0, false); + field!(rotation, 3.0, false); + field!(underline, true, false); + field!(strikethrough, true, false); + field!(all_caps, true, false); + field!(features, vec!["-liga".into()], false); + field!(position, CharPosition::Superscript(Default::default()), false); + field!(small_caps, Some(80.0), false); + field!(char_align, CharAlign::EmBoxTop, false); + field!(char_direction, Some(ParaDirection::RightToLeft), false); + field!(kashidas, false, false); + field!(proportional_metrics, true, false); + field!(tate_chu_yoko, true, false); + field!(tcy_up_down, 1.0, false); + field!(tcy_left_right, 1.0, false); + field!(standard_vertical_roman, true, false); + field!(digits, Digits::Hindi, false); + field!(font_height_reference, FontHeightReference::CapHeight, false); + field!(no_break, true, false); + field!(language, Some("ar".into()), false); + field!(glyph_alternate, Some(GlyphChoice { ch: 'a', gid: 1, font: "other".into(), version: String::new() }), false); + field!(fill, Paint::None, true); + field!(stroke, Paint::None, true); + field!(stroke_width, 2.0, true); + field!(overprint_fill, true, true); + field!(overprint_stroke, true, true); + field!(stroke_cap, LineCap::Round, true); + field!(stroke_join, LineJoin::Round, true); + field!(stroke_miter_limit, 2.0, true); + field!(stroke_dash, None, true); + field!(stroke_align, StrokeAlign::Outside, true); + field!(style_name, None, true); + // Only the first style's ignored fields survive the old normalization. A NaN in the + // second style's paint is ignored, but reversing the arguments must still return false. + field!(fill, Paint::solid(Color::Gray { k: f32::NAN }), true); + field!(stroke, Paint::solid(Color::Gray { k: f32::NAN }), true); + field!(stroke_width, f64::NAN, true); + field!(stroke_miter_limit, f64::NAN, true); + field!(stroke_dash, Some(Dash { pattern: vec![f64::NAN], offset: 0.0, align_corners: false }), true); + field!(tracking, f64::NAN, false); +} + +/// A face without usable mark classification keeps cluster-start paint, without changing +/// its glyphs or positioning when a harakat's paint is edited. All variants are in-memory. +#[test] +fn synthetic_harakat_keep_geometry_with_and_without_gdef_marks() { + use skrifa::raw::TableProvider; + use write_fonts::FontBuilder; + use write_fonts::tables::gdef::Gdef; + use write_fonts::tables::layout::ClassDefFormat1; + use write_fonts::types::{GlyphId16, Tag}; + + let bytes = crate::test_fonts::lam_alef_font(b"rlig").unwrap(); + let font = skrifa::FontRef::new(&bytes).unwrap(); + for variant in ["marks", "no GDEF", "no class definition", "base classes"] { + let mut builder = FontBuilder::new(); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if variant == "marks" || tag != Tag::new(b"GDEF") { + builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes()); + } + } + if variant == "no class definition" { + builder.add_table(&Gdef::new(None, None, None, None)).unwrap(); + } else if variant == "base classes" { + let classes = ClassDefFormat1::new(GlyphId16::new(0), vec![1; usize::from(font.maxp().unwrap().num_glyphs())]); + builder.add_table(&Gdef::new(Some(classes.into()), None, None, None)).unwrap(); + } + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(builder.build()), 1, "{variant}"); + let family = crate::test_fonts::lam_alef_family(b"rlig"); + let plain = text_with_harakat("بَبَبَ", family, None); + let coloured = text_with_harakat("بَبَبَ", family, Some(red())); + let before = layout(&db, &plain); + let after = layout(&db, &coloured); + assert!(!before.glyphs.is_empty(), "{variant}"); + assert_eq!(geometry(&before), geometry(&after), "{variant}"); + assert_eq!(coloured.plain_text(), plain.plain_text(), "{variant}"); + if variant == "marks" { + assert!(painted(&coloured, &after, &red()) > 0, "{variant}: harakat own their paint"); + } else { + assert!(after.glyphs.iter().all(|g| run_of(&coloured, g).style.fill != red()), "{variant}: cluster-start paint"); + } + } +} + +/// A range is normalised as `edit::style_range` takes one: clamped, each end moved back to the +/// start of its character, put in order. Never a silent empty result for a usable range. +#[test] +fn harakat_ranges_normalise_their_range() { + let word = "بِسْمِ"; // ب 0..2, kasra 2..4, س 4..6, sukun 6..8, م 8..10, kasra 10..12 + let all = harakat_ranges(word, 0..word.len()); + assert_eq!(harakat_ranges(word, word.len()..0), all, "reversed"); + assert_eq!(harakat_ranges(word, 0..usize::MAX), all, "past the end"); + assert!(harakat_ranges(word, usize::MAX..usize::MAX).is_empty(), "wholly past the end"); + assert_eq!(harakat_ranges(word, 1..word.len()), all, "a start inside ب moves back to it"); + assert_eq!(harakat_ranges(word, 0..7), [std::ops::Range { start: 2, end: 4 }], "an end inside the sukun moves back before it"); + assert_eq!(harakat_ranges(word, 3..9), [2..4, 6..8], "both ends inside characters"); + assert_eq!(harakat_ranges(word, std::ops::Range { start: 9, end: 3 }), [2..4, 6..8], "reversed, inside characters"); + assert!(harakat_ranges(word, 4..4).is_empty(), "empty"); +} + +/// The character-level pass names each glyph's own character only when it sets the same glyphs +/// the same way, and only a character in that glyph's shaping cluster: repeated glyph ids alone +/// don't do. A later in-cluster owner is kept only for a GDEF mark glyph on a harakat character; +/// everything else stays at the cluster start (conservative). +#[test] +fn own_chars_need_the_same_setting_and_each_character_in_its_cluster() { + // بَبَب right to left: final beh (cluster 8), fatha and medial beh (cluster 4), fatha and + // initial beh (cluster 0). Bytes: ب0 ف2 ب4 ف6 ب8. Glyph id 20 is the fatha (GDEF mark). + const TEXT: &str = "بَبَب"; + let marks = |gid: u32| gid == 20; + let raw = [(10, 8, 500, 0, 0), (20, 4, 0, -30, 400), (11, 4, 300, 0, 0), (20, 0, 0, -40, 420), (12, 0, 450, 0, 0)]; + let by_char = |own: [u32; 5]| raw.iter().zip(own).map(|(g, c)| (g.0, c, g.2, 0, g.3, g.4)).collect::>(); + let ok = by_char([8, 6, 4, 2, 0]); + assert_eq!(own_chars(&raw, &ok, 10, TEXT, marks), [8, 6, 4, 2, 0]); + // The fathas' characters swapped (the same glyph ids): each is outside its glyph's cluster, + // so both keep their cluster's (their letter's) character, never the other fatha's. + assert_eq!(own_chars(&raw, &by_char([8, 2, 4, 6, 0]), 10, TEXT, marks), [8, 4, 4, 0, 0]); + // A character before its glyph's cluster, or past the segment's end: the cluster's own. + assert_eq!(own_chars(&raw, &by_char([8, 3, 4, 2, 0]), 10, TEXT, marks), [8, 4, 4, 2, 0]); + assert_eq!(own_chars(&raw, &by_char([9, 6, 4, 2, 0]), 9, TEXT, marks), [8, 6, 4, 2, 0]); + // A base letter named as its mark keeps the letter (not a GDEF mark). + assert_eq!(own_chars(&raw, &by_char([8, 6, 6, 2, 0]), 10, TEXT, marks), [8, 6, 4, 2, 0]); + // Without GDEF mark classification every later owner falls back to the cluster start. + assert_eq!(own_chars(&raw, &ok, 10, TEXT, |_| false), [8, 4, 4, 0, 0]); + // Wide mark (advance 500) beside a narrower base (300): mark keeps 2; base keeps 0. + // Review counterexample: advance must not decide ownership. + let wide = [(20, 0, 500, 0, 0), (11, 0, 300, 0, 0)]; + let wide_by = [(20, 2, 500, 0, 0, 0), (11, 2, 300, 0, 0, 0)]; + assert_eq!(own_chars(&wide, &wide_by, 4, "بَ", marks), [2, 0], "wide GDEF mark keeps harakat owner"); + // Equal advances: same rule. + let equal = [(20, 0, 300, 0, 0), (11, 0, 300, 0, 0)]; + let equal_by = [(20, 2, 300, 0, 0, 0), (11, 2, 300, 0, 0, 0)]; + assert_eq!(own_chars(&equal, &equal_by, 4, "بَ", marks), [2, 0], "equal advances"); + // Multi-component glyph (GDEF class 4, not mark) named as a later harakat stays at cluster start. + let multi = [(40, 0, 100, 0, 0), (11, 0, 300, 0, 0)]; + let multi_by = [(40, 2, 100, 0, 0, 0), (11, 0, 300, 0, 0, 0)]; + assert_eq!(own_chars(&multi, &multi_by, 4, "بَ", marks), [0, 0], "component is not a mark"); + // Single-glyph ligature (one output for letter+mark) named as the mark stays at cluster start: + // one glyph can represent a ligature containing a base, not a lone mark. + let liga = [(50, 0, 400, 0, 0)]; + let liga_by = [(50, 2, 400, 0, 0, 0)]; + assert_eq!(own_chars(&liga, &liga_by, 4, "بَ", |_| false), [0], "ligature without GDEF mark class"); + assert_eq!(own_chars(&liga, &liga_by, 4, "بَ", |gid| gid == 50), [2], "only if GDEF names it mark"); + // Sole advancing glyph without GDEF mark proof stays at the cluster start. + let advancing = [(20, 0, 100, 0, 0)]; + let advancing_by = [(20, 2, 100, 0, 0, 0)]; + assert_eq!(own_chars(&advancing, &advancing_by, 4, "بَ", |_| false), [0]); + assert_eq!(own_chars(&advancing, &advancing_by, 4, "بَ", marks), [2]); + // The same ids set otherwise (an advance or an offset changed, another glyph, one short): + // nothing is borrowed. + let mut moved = ok.clone(); + moved[1].5 += 1; + assert!(own_chars(&raw, &moved, 10, TEXT, marks).is_empty(), "a mark moved"); + let mut wider = ok.clone(); + wider[2].2 += 1; + assert!(own_chars(&raw, &wider, 10, TEXT, marks).is_empty(), "an advance changed"); + let mut other = ok.clone(); + other[0].0 = 13; + assert!(own_chars(&raw, &other, 10, TEXT, marks).is_empty(), "another glyph"); + assert!(own_chars(&raw, &ok[1..], 10, TEXT, marks).is_empty(), "a glyph short"); +} + +/// A word whose letters and marks repeat the same glyph ids (بَبَب): each fatha takes its own +/// colour, on its own letter, and every glyph is set as without colour. +#[test] +fn repeated_glyphs_take_their_own_harakat_colour() { + let Some(face) = arabic_face("repeated_glyphs_take_their_own_harakat_colour") else { + return; + }; + const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8 + let db = FontDb::global(); + let green = Paint::solid(Color::rgb8(30, 140, 70)); + let plain = text_with_harakat(WORD, &face.family, None); + let mut coloured = plain.clone(); + crate::edit::style_range(&mut coloured.runs, 2, 4, |st| st.fill = green.clone()); + crate::edit::style_range(&mut coloured.runs, 6, 8, |st| st.fill = red()); + let (a, b) = (layout(db, &plain), layout(db, &coloured)); + assert_eq!(geometry(&a), geometry(&b), "joining and mark positions unchanged"); + assert_authored_ownership(&coloured, WORD, &b, "repeated"); + let fill = |g: &PositionedGlyph| run_of(&coloured, g).style.fill.clone(); + let letters = letter_glyphs(WORD, &face.family); + let marks: Vec<&PositionedGlyph> = b.glyphs.iter().filter(|g| fixture_mark(g, &letters) && !g.outline.is_empty()).collect(); + assert_eq!(marks.len(), 2, "two fathas"); + assert_eq!(marks[0].gid, marks[1].gid, "the same glyph id twice"); + for m in &marks { + let want = if m.byte == 0 { green.clone() } else { red() }; + assert!(m.byte == 0 || m.byte == 4, "a fatha in its letter's cluster: {}", m.byte); + assert_eq!(fill(m), want, "the fatha of the letter at {} keeps its own colour", m.byte); + } + let letter_fill = plain.runs[0].style.fill.clone(); + assert!(b.glyphs.iter().filter(|g| letters.contains(&(g.font_id, g.gid))).all(|g| fill(g) == letter_fill), "letters keep theirs"); +} + +/// Coloured harakat: the letters keep their joining and the marks their attachment, glyph for +/// glyph and bit for bit; every mark glyph (and only the marks) is drawn in the harakat's run. +#[test] +fn harakat_coloured_apart_keep_every_glyph_and_take_their_own_colour() { + let Some(face) = arabic_face("harakat_coloured_apart_keep_every_glyph_and_take_their_own_colour") else { + return; + }; + let db = FontDb::global(); + let family = face.family.as_str(); + let plain = text_with_harakat(VOCALIZED, family, None); + let coloured = text_with_harakat(VOCALIZED, family, Some(red())); + assert!(coloured.runs.len() > 1); + let (a, b) = (layout(db, &plain), layout(db, &coloured)); + assert_eq!(geometry(&a), geometry(&b), "joining and mark positions unchanged"); + let letters = letter_glyphs(VOCALIZED, family); + let marks = b.glyphs.iter().filter(|g| fixture_mark(g, &letters) && !g.outline.is_empty()).count(); + assert!(marks > 0, "vocalized text has mark glyphs"); + assert_eq!(painted(&coloured, &b, &red()), marks); + assert_authored_ownership(&coloured, VOCALIZED, &b, "coloured"); + let hidden = text_with_harakat(VOCALIZED, family, Some(Paint::None)); + let h = layout(db, &hidden); + assert_eq!(geometry(&a), geometry(&h)); + assert_eq!(painted(&hidden, &h, &Paint::None), marks); + assert_authored_ownership(&hidden, VOCALIZED, &h, "hidden"); + let mut sized = text_with_harakat(VOCALIZED, family, None); + let first = harakat_ranges(VOCALIZED, 0..VOCALIZED.len())[0].clone(); + crate::edit::style_range(&mut sized.runs, first.start, first.end, |st| st.size = 30.0); + assert_ne!(geometry(&layout(db, &sized)), geometry(&a)); +} + +/// Justified kashida lines with coloured or hidden harakat keep authored glyph ownership, and +/// inserted tatweels take the base letter's paint—not a trailing mark's. +#[test] +fn coloured_harakat_keep_kashida() { + let Some(face) = arabic_face("coloured_harakat_keep_kashida") else { + return; + }; + let db = FontDb::global(); + let family = face.family.as_str(); + let justified = |t: &mut TextObject, width: f64| { + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, width, 2000.0).to_path(0.1)) }; + t.xf = Affine::IDENTITY; + t.para.justify = Justify::JustifyAll; + t.para.kashida = Kashida::Long; + }; + const PARTLY: &str = "بِسْمِ اللَّهِ الرحمن الرحيم الحمد لله رب العالمين"; + let tat = face.glyph_for('\u{0640}'); + let mut tatweels = 0; + for width in [200.0, 260.0, 300.0, 420.0, 600.0] { + let mut plain = text_with_harakat(PARTLY, family, None); + let mut coloured = text_with_harakat(PARTLY, family, Some(red())); + let before = layout(db, &coloured); + assert_authored_ownership(&coloured, PARTLY, &before, &format!("before kashida {width}")); + justified(&mut plain, width); + justified(&mut coloured, width); + let (a, b) = (layout(db, &plain), layout(db, &coloured)); + tatweels += a.glyphs.iter().filter(|g| g.gid == tat && g.len == 0).count(); + assert_eq!(geometry(&a), geometry(&b), "{width}"); + assert_kashida_ownership(&coloured, &before, &b, &red(), &format!("coloured {width}"), false); + } + assert!(tatweels > 0, "the text takes kashida"); + let mut stretched = 0; + for width in [200.0, 260.0, 300.0, 420.0, 600.0] { + let mut plain = text_with_harakat(FULLY, family, None); + let mut coloured = text_with_harakat(FULLY, family, Some(red())); + let mut hidden = text_with_harakat(FULLY, family, Some(Paint::None)); + let before_c = layout(db, &coloured); + let before_h = layout(db, &hidden); + assert_authored_ownership(&coloured, FULLY, &before_c, &format!("full before {width}")); + justified(&mut plain, width); + justified(&mut coloured, width); + justified(&mut hidden, width); + let (a, b, h) = (layout(db, &plain), layout(db, &coloured), layout(db, &hidden)); + assert_eq!(geometry(&a), geometry(&b), "{width}"); + assert_eq!(geometry(&a), geometry(&h), "hidden {width}"); + stretched += stretched_words_with_harakat(FULLY, &b, tat); + assert_kashida_ownership(&coloured, &before_c, &b, &red(), &format!("full coloured {width}"), false); + assert_kashida_ownership(&hidden, &before_h, &h, &Paint::None, &format!("full hidden {width}"), false); + } + assert!(stretched > 0, "words with coloured harakat take kashida"); +} + +/// Harakat on every word. +const FULLY: &str = "بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ الْحَمْدُ لِلَّهِ رَبِّ الْعَالَمِينَ الرَّحْمَنِ الرَّحِيمِ مَالِكِ يَوْمِ الدِّينِ"; + +/// The words of `text` with harakat that `l` stretched with a tatweel (glyph `tat`). +fn stretched_words_with_harakat(text: &str, l: &TextLayout, tat: u32) -> usize { + let mut words: Vec = l + .glyphs + .iter() + .filter(|g| g.gid == tat && g.len == 0) + .map(|g| text[..g.byte].rfind(' ').map_or(0, |i| i + 1)) + .filter(|&start| text[start..].split(' ').next().is_some_and(|w| w.chars().any(is_harakat))) + .collect(); + words.dedup(); + words.len() +} + +/// Authored glyphs paint as the independent expected fill from source runs; run indices must exist. +fn assert_authored_ownership(t: &TextObject, text: &str, l: &TextLayout, what: &str) { + let letters = letter_glyphs(text, &t.runs[0].style.font_family); + for g in &l.glyphs { + if g.len == 0 { + continue; + } + let actual = &run_of(t, g).style.fill; + let want = expected_fill(t, text, g, &letters); + assert_eq!(actual, &want, "{what}: glyph {} at {} len {} adv {}", g.gid, g.byte, g.len, g.advance); + } +} + +/// Compare authored glyph run indices before/after Insert Kashidas (matched by byte and gid; +/// layout order can differ between point and justified area), and assert every inserted tatweel +/// uses the base letter's paint at the cluster start—not `mark_fill`. When `require_mark_last`, +/// at least one tatweel must follow a same-cluster glyph whose run is the mark paint (so the +/// emitter fix is exercised: reverting `emit_kashida`'s base-run assignment fails). +fn assert_kashida_ownership( + coloured: &TextObject, + before: &TextLayout, + with_kashida: &TextLayout, + mark_fill: &Paint, + what: &str, + require_mark_last: bool, +) -> usize { + let mut before_runs: std::collections::HashMap<(usize, u32), Vec> = std::collections::HashMap::new(); + for g in before.glyphs.iter().filter(|g| g.len > 0) { + before_runs.entry((g.byte, g.gid)).or_default().push(g.run); + } + let mut after_n = 0; + for g in with_kashida.glyphs.iter().filter(|g| g.len > 0) { + after_n += 1; + let runs = + before_runs.get_mut(&(g.byte, g.gid)).unwrap_or_else(|| panic!("{what}: authored glyph {} at {} missing before kashida", g.gid, g.byte)); + let want_run = runs.pop().unwrap_or_else(|| panic!("{what}: no remaining before-run for {} at {}", g.gid, g.byte)); + assert_eq!(g.run, want_run, "{what}: authored run at {} gid {}", g.byte, g.gid); + let want_fill = coloured.runs.get(want_run).map(|r| &r.style.fill).unwrap_or_else(|| panic!("{what}: missing run {want_run}")); + assert_eq!(&run_of(coloured, g).style.fill, want_fill, "{what}: authored fill at {}", g.byte); + } + assert_eq!(after_n, before.glyphs.iter().filter(|g| g.len > 0).count(), "{what}: authored glyph count"); + assert!(before_runs.values().all(|v| v.is_empty()), "{what}: unjustified authored glyphs left unmatched"); + let mut mark_last = 0; + for (i, g) in with_kashida.glyphs.iter().enumerate().filter(|(_, g)| g.len == 0) { + let letter_fill = style_fill_at(coloured, g.byte); + let fill = &run_of(coloured, g).style.fill; + assert_eq!(fill, &letter_fill, "{what}: tatweel at {} must use base-letter paint", g.byte); + assert_ne!(fill, mark_fill, "{what}: tatweel must not inherit mark paint at {}", g.byte); + // The glyph that absorbed the kashida (last of the cluster in visual order) is emitted + // immediately before the tatweel and shares its cluster byte. + if let Some(prev) = i.checked_sub(1).and_then(|j| with_kashida.glyphs.get(j)) + && prev.byte == g.byte + && prev.len > 0 + && &run_of(coloured, prev).style.fill == mark_fill + && letter_fill != *mark_fill + { + mark_last += 1; + } + } + if require_mark_last { + assert!(mark_last > 0, "{what}: need a mark-last insertion (last visual glyph has mark paint ≠ letter) to exercise emit_kashida"); + } + mark_last +} + +/// Installed OpenType Arabic faces (when present): coloured harakat change no glyph, position or +/// advance; letter gids from the bare text must not take harakat paint. Glyphs absent from that +/// bare set may take either the harakat colour or the letter colour (AAT/`morx` ligature fallback +/// keeps the letter)—this test does not prove every mark owns its character. Selected AAT faces +/// below record an explicit letter-fill allowance for non-letter gids; they may also paint no +/// glyph in the harakat colour when GDEF is unavailable. +#[test] +fn installed_fonts_keep_their_glyphs_with_coloured_harakat() { + const FONTS: &[&str] = &[ + "Geeza Pro", + "SF Arabic", + "Al Nile", + "Baghdad", + "Damascus", + "KufiStandardGK", + "DecoType Naskh", + "Diwan Thuluth", + "Farah", + "Noto Nastaliq Urdu", + "Arial", + "Times New Roman", + "Tahoma", + "Noto Naskh Arabic", + "Noto Kufi Arabic", + "Noto Sans Arabic", + "Amiri", + "Aref Ruqaa", + "IBM Plex Sans Arabic", + ]; + let db = FontDb::with_font_dirs(crate::system_font_dirs()); + let harakat = VOCALIZED.chars().filter(|c| is_harakat(*c)).count(); + let mut rows = vec![]; + for &family in FONTS { + if !db.has_family(family) { + continue; + } + let Some(face) = db.face(family, "Regular") else { + continue; + }; + let plain = text_with_harakat(VOCALIZED, &face.family, None); + let coloured = text_with_harakat(VOCALIZED, &face.family, Some(red())); + let bare: String = VOCALIZED.chars().filter(|c| !is_harakat(*c)).collect(); + let (a, b) = (layout(&db, &plain), layout(&db, &coloured)); + assert_eq!(geometry(&a), geometry(&b), "{family}: coloured harakat changed the glyphs"); + let letter_fill = style_fill_at(&coloured, 0); + let letter_gids: std::collections::HashSet = + layout(&db, &text_with_harakat(&bare, &face.family, None)).glyphs.iter().map(|g| g.gid).collect(); + // AAT faces where a ligature mark may keep the letter's colour (explicit allowance, not a + // per-glyph ownership proof). + let aat_letter_ok = matches!(family, "Geeza Pro" | "Al Nile" | "Baghdad" | "Damascus" | "KufiStandardGK" | "DecoType Naskh"); + for g in b.glyphs.iter().filter(|g| g.len > 0) { + let actual = &run_of(&coloured, g).style.fill; + if letter_gids.contains(&g.gid) { + // A glyph the bare letters also draw: must not take harakat paint. + assert_ne!(actual, &red(), "{family}: letter gid {} at {} took harakat paint", g.gid, g.byte); + } else if aat_letter_ok { + assert!( + actual == &red() || actual == &letter_fill, + "{family}: non-letter gid {} at {} may be harakat or letter paint (AAT), got {actual:?}", + g.gid, + g.byte + ); + } else { + // OpenType with GDEF: prefer harakat paint; letter paint still allowed when the + // character-level pass falls back (does not prove every mark owns its character). + assert!( + actual == &red() || actual == &letter_fill, + "{family}: non-letter at {} should be harakat or letter paint, got {actual:?}", + g.byte + ); + } + } + let red_glyphs = painted(&coloured, &b, &red()); + // Without GDEF (or when the character-level pass falls back), every glyph may keep the + // letter's paint—conservative and allowed. This is not every-mark ownership. + let all_letter = b.glyphs.iter().filter(|g| g.len > 0).all(|g| run_of(&coloured, g).style.fill == letter_fill); + assert!(red_glyphs > 0 || all_letter, "{family}: expected some harakat paint or a full letter-paint fallback (aat={aat_letter_ok})"); + rows.push(format!("{family}: {} glyphs identical; {red_glyphs} glyphs in the harakat colour ({harakat} harakat)", b.glyphs.len())); + } + if rows.is_empty() { + eprintln!("SKIPPED: installed_fonts_keep_their_glyphs_with_coloured_harakat; none of the candidate families are installed"); + } else { + eprintln!("{rows:#?}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 19244247d..8ab3cc068 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -511,6 +511,11 @@ fn canvas_cursor(app: &mut VectorcraftApp, ui: &Ui, view_info: vectorcraft_engin // (#444). The system cursor stands in for cursors without a glyph. Over a window // without the focus, macOS shows its own pointer whichever cursor the window sets, // and the glyph is not painted there. + // Interim #2292: remove when pinned winit correctly handles macOS cursor image and hotspot sizing. + #[cfg(target_os = "macos")] + Some(_) if !app.services.wayland => { + crate::cursors::native_request::request(ui.ctx(), c, cursor_icon(c), custom, ui.ctx().zoom_factor() / ui_scale) + } Some(_) if custom && crate::cursors::os_cursors(crate::cursors::OS_CURSORS, app.services.wayland) => { let ppp = ui.ctx().pixels_per_point() / ui_scale; ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp)); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index c328d6e47..822e007c3 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -10801,3 +10801,74 @@ fn a_ruler_double_click_follows_a_rotated_view() { } } } + +/// The real canvas decision must stay visible without a native host, with no painted glyph. +#[cfg(target_os = "macos")] +#[test] +fn mac_cursor_canvas_without_host_keeps_visible_fallback() { + use crate::cursors::native_request; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let view = vectorcraft_engine::ViewInfo::default(); + for (tool, expected) in [("selection", egui::CursorIcon::Default), ("type", egui::CursorIcon::Text), ("pen", egui::CursorIcon::Crosshair)] { + app.session.select_tool(tool, view).unwrap(); + app.hover_doc = Some(Point::new(200.0, 150.0)); + let output = + mac_cursor_headless(&ctx, egui::RawInput { events: vec![egui::Event::PointerMoved(pos2(200.0, 150.0))], ..Default::default() }, |ui| { + native_request::clear(ui.ctx()); + canvas_cursor(&mut app, ui, view, true); + }); + assert_eq!(output.platform_output.cursor_icon, expected, "{tool}: the fallback stays visible without a host"); + assert!(output.platform_output.cursor_image.is_none()); + assert!(output.shapes.iter().all(|s| matches!(s.shape, egui::Shape::Noop)), "no tool glyph is painted"); + assert!(native_request::take(&ctx).is_some(), "{tool}: publish a native request"); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn mac_cursor_canvas_respects_precise_scaling_focus_and_presentation() { + use crate::cursors::native_request; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let view = vectorcraft_engine::ViewInfo::default(); + app.session.select_tool("pen", view).unwrap(); + app.hover_doc = Some(Point::new(200.0, 150.0)); + let ctx = egui::Context::default(); + ctx.set_zoom_factor(1.5); + let frame = |app: &mut VectorcraftApp, focused| { + let _ = mac_cursor_headless( + &ctx, + egui::RawInput { focused, events: vec![egui::Event::PointerMoved(pos2(200.0, 150.0))], ..Default::default() }, + |ui| { + native_request::clear(ui.ctx()); + canvas_cursor(app, ui, view, true); + }, + ); + native_request::take(&ctx) + }; + let normal = frame(&mut app, true).unwrap(); + app.run("prefs.set", json!({"key": "usePreciseCursors", "value": true})).unwrap(); + let precise = frame(&mut app, true).unwrap(); + assert_ne!(normal.key, precise.key); + let _ = mac_cursor_headless(&ctx, egui::RawInput { events: vec![egui::Event::PointerMoved(pos2(200.0, 150.0))], ..Default::default() }, |ui| { + native_request::request(ui.ctx(), Cursor::Crosshair, egui::CursorIcon::Crosshair, true, 1.0); + }); + assert_eq!(native_request::take(&ctx).unwrap().key, precise.key); + app.run("prefs.set", json!({"key": "scaleCursorWithUi", "value": true})).unwrap(); + let scaled = frame(&mut app, true).unwrap(); + assert_ne!(scaled.key, precise.key); + assert!(scaled.bitmap.representations[0].size[0] > precise.bitmap.representations[0].size[0]); + assert!(frame(&mut app, false).is_none()); + assert_eq!(frame(&mut app, true).unwrap().key, scaled.key); + app.ui.screen_mode = 3; + assert!(frame(&mut app, true).is_none()); +} + +#[cfg(target_os = "macos")] +fn mac_cursor_headless(ctx: &egui::Context, input: egui::RawInput, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut output = ctx.run_ui(input, draw); + output.textures_delta.clear(); + output +} diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index eda747fc8..c26cae36a 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -5,6 +5,10 @@ //! The desktop app shows them as OS cursors ([`Images`]): the system moves those itself, at once, //! while a cursor painted into the window follows the pointer a few frames late (#444). +/// Platform-neutral bitmap requests for the interim macOS host adapter. +#[cfg(any(target_os = "macos", test))] +pub mod native_request; + use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex}; use egui::{Color32, CustomCursorImage, Painter, Pos2, Shape, Stroke, Vec2, pos2, vec2}; use vectorcraft_tools::Cursor; @@ -691,7 +695,8 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2, scale: f32) -> bool { /// Does the window show the glyphs as OS cursors ([`Images`])? Not on macOS, which sizes a cursor /// bitmap in points (a Retina one would show twice as big, or blurred), nor on the web (eframe has no -/// bitmap cursors there): those paint them into the window ([`paint`]). +/// bitmap cursors there): the web paints them into the window ([`paint`]); the macOS desktop host +/// uses its native request adapter instead. pub const OS_CURSORS: bool = !cfg!(any(target_os = "macos", target_arch = "wasm32")); /// Does this window show the glyphs as OS cursors, on a build that can ([`OS_CURSORS`])? Not on diff --git a/crates/ui-egui/src/cursors/native_request.rs b/crates/ui-egui/src/cursors/native_request.rs new file mode 100644 index 000000000..61f81d570 --- /dev/null +++ b/crates/ui-egui/src/cursors/native_request.rs @@ -0,0 +1,136 @@ +//! Prepared, platform-neutral requests. The desktop host alone owns native cursor state. + +use super::{Cursor, rasterize}; +use egui::{Context, CursorIcon, CustomCursorImage, Id, ViewportId}; +use std::sync::Arc; + +const CAPACITY: usize = 128; +const MAX_PADDED_SIDE: u16 = 258; + +/// Opaque identity: the resolved tool cursor and logical scale, independent of display scale. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Key(Cursor, u32); + +/// The same artwork at one and two pixels per logical point, aligned without resampling. +#[derive(Clone, Debug)] +pub struct Bitmap { + /// Straight-alpha RGBA representations; the second size/hotspot is exactly twice the first. + pub representations: [CustomCursorImage; 2], +} + +/// One canvas request, valid only for its viewport and pass. +#[derive(Clone, Debug)] +pub struct Request { + /// Identity owned by the UI; the host need not depend on the tools crate. + pub key: Key, + /// Shared prepared pixels, retained independently of cache eviction. + pub bitmap: Arc, + /// Visible fallback, also used to detect a later UI override. + pub fallback: CursorIcon, + /// Viewport that selected this cursor. + pub viewport: ViewportId, + /// Cumulative pass number when the request was made. + pub pass: u64, +} + +#[derive(Clone, Default)] +struct Cache(Vec<(Key, Option>)>); + +impl Cache { + fn get(&mut self, cursor: Cursor, scale: f32) -> Option<(Key, Arc)> { + // Bound before tessellation, including malicious/non-finite preference values. + if !scale.is_finite() || !(0.125..=4.0).contains(&scale) { + return None; + } + let key = Key(cursor, scale.to_bits()); + if let Some((_, bitmap)) = self.0.iter().find(|(k, _)| *k == key) { + return bitmap.clone().map(|bitmap| (key, bitmap)); + } + let bitmap = prepare(cursor, scale).map(Arc::new); + if self.0.len() >= CAPACITY { + self.0.clear(); + } + self.0.push((key, bitmap.clone())); + bitmap.map(|bitmap| (key, bitmap)) + } +} + +fn prepare(cursor: Cursor, scale: f32) -> Option { + align([rasterize(cursor, scale)?, rasterize(cursor, scale * 2.0)?]) +} + +fn valid(image: &CustomCursorImage) -> bool { + let [w, h] = image.size; + w > 0 && h > 0 && w <= 256 && h <= 256 && image.hotspot[0] < w && image.hotspot[1] < h && image.rgba.len() == usize::from(w) * usize::from(h) * 4 +} + +fn align(source: [CustomCursorImage; 2]) -> Option { + if !source.iter().all(valid) { + return None; + } + let [one, two] = &source; + let mut size = [0; 2]; + let mut hotspot = [0; 2]; + for axis in 0..2 { + hotspot[axis] = one.hotspot[axis].max(two.hotspot[axis].div_ceil(2)); + let after = (one.size[axis] - one.hotspot[axis]).max((two.size[axis] - two.hotspot[axis]).div_ceil(2)); + size[axis] = hotspot[axis].checked_add(after)?; + if size[axis].checked_mul(2)? > MAX_PADDED_SIDE { + return None; + } + } + Some(Bitmap { representations: [pad(one, size, hotspot, 1)?, pad(two, size, hotspot, 2)?] }) +} + +fn pad(source: &CustomCursorImage, size: [u16; 2], hotspot: [u16; 2], factor: u16) -> Option { + let size = [size[0].checked_mul(factor)?, size[1].checked_mul(factor)?]; + let hotspot = [hotspot[0].checked_mul(factor)?, hotspot[1].checked_mul(factor)?]; + let x = usize::from(hotspot[0].checked_sub(source.hotspot[0])?); + let y = usize::from(hotspot[1].checked_sub(source.hotspot[1])?); + let stride = usize::from(size[0]) * 4; + let row = usize::from(source.size[0]) * 4; + let mut rgba = vec![0; stride.checked_mul(usize::from(size[1]))?]; + for (i, pixels) in source.rgba.chunks_exact(row).enumerate() { + let start = (y + i) * stride + x * 4; + rgba.get_mut(start..start + row)?.copy_from_slice(pixels); + } + Some(CustomCursorImage { rgba: rgba.into(), size, hotspot }) +} + +fn pending_id(ctx: &Context) -> Id { + Id::unique(("native-canvas-cursor", ctx.viewport_id())) +} + +/// Expire the current viewport's request at the start of every host pass. +pub fn clear(ctx: &Context) { + let id = pending_id(ctx); + ctx.data_mut(|data| data.remove::(id)); +} + +/// Consume only this viewport's request. The host validates its pass at finalization. +pub fn take(ctx: &Context) -> Option { + let id = pending_id(ctx); + ctx.data_mut(|data| { + let request = data.get_temp::(id); + data.remove::(id); + request + }) +} + +/// Publish eligible pixels and always return a visible system fallback, even without a host. +pub fn request(ctx: &Context, cursor: Cursor, fallback: CursorIcon, custom: bool, scale: f32) -> CursorIcon { + clear(ctx); + let fallback = if fallback == CursorIcon::None { CursorIcon::Default } else { fallback }; + if custom && ctx.input(|i| i.focused && i.pointer.hover_pos().is_some()) { + let prepared = ctx.data_mut(|data| data.get_temp_mut_or_default::(Id::unique("native-cursor-bitmaps")).get(cursor, scale)); + if let Some((key, bitmap)) = prepared { + let request = Request { key, bitmap, fallback, viewport: ctx.viewport_id(), pass: ctx.cumulative_pass_nr() }; + let id = pending_id(ctx); + ctx.data_mut(|data| data.insert_temp(id, request)); + } + } + fallback +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/cursors/native_request/tests.rs b/crates/ui-egui/src/cursors/native_request/tests.rs new file mode 100644 index 000000000..719fc5bcc --- /dev/null +++ b/crates/ui-egui/src/cursors/native_request/tests.rs @@ -0,0 +1,98 @@ +use super::*; + +#[test] +fn every_glyph_and_rotation_preserves_pixels_and_logical_hotspots() { + let cursors = super::super::GLYPHS.into_iter().chain((0..8).map(Cursor::RotateToward)); + for cursor in cursors { + for scale in [0.75, 1.0, 1.25, 1.5, 2.0] { + let bitmap = prepare(cursor, scale).unwrap(); + let [one, two] = &bitmap.representations; + assert_eq!(two.size, one.size.map(|x| x * 2)); + assert_eq!(two.hotspot, one.hotspot.map(|x| x * 2)); + for (i, dest) in bitmap.representations.iter().enumerate() { + let source = rasterize(cursor, scale * (i + 1) as f32).unwrap(); + let dx = dest.hotspot[0] - source.hotspot[0]; + let dy = dest.hotspot[1] - source.hotspot[1]; + for y in 0..dest.size[1] { + for x in 0..dest.size[0] { + let offset = (usize::from(y) * usize::from(dest.size[0]) + usize::from(x)) * 4; + let actual = &dest.rgba[offset..offset + 4]; + if x >= dx && x < dx + source.size[0] && y >= dy && y < dy + source.size[1] { + let at = (usize::from(y - dy) * usize::from(source.size[0]) + usize::from(x - dx)) * 4; + assert_eq!(actual, &source.rgba[at..at + 4], "{cursor:?}, {scale}"); + } else { + assert_eq!(actual, [0; 4]); + } + } + } + } + } + } +} + +#[test] +fn invalid_inputs_and_system_states_are_bounded_and_cached() { + let mut cache = Cache::default(); + for scale in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::MAX, 100.0] { + assert!(cache.get(Cursor::Arrow, scale).is_none()); + } + for cursor in [Cursor::Busy, Cursor::NotAllowed, Cursor::ScissorsNotAllowed] { + assert!(cache.get(cursor, 1.0).is_none()); + assert!(cache.get(cursor, 1.0).is_none()); + } + assert_eq!(cache.0.len(), 3); + let good = rasterize(Cursor::Pen, 1.0).unwrap(); + for bad in [ + CustomCursorImage { rgba: Arc::from([]), ..good.clone() }, + CustomCursorImage { size: [u16::MAX, 1], ..good.clone() }, + CustomCursorImage { hotspot: good.size, ..good.clone() }, + ] { + assert!(align([bad, good.clone()]).is_none()); + } + let (_, retained) = cache.get(Cursor::Pen, 1.0).unwrap(); + assert!(Arc::ptr_eq(&retained, &cache.get(Cursor::Pen, 1.0).unwrap().1)); + for i in 0..140 { + cache.get(Cursor::Pen, 1.0 + i as f32 / 1000.0); + } + assert!(cache.0.len() <= CAPACITY); + assert!(valid(&retained.representations[0])); +} + +#[test] +fn requests_are_consumed_per_viewport_and_expire_on_empty_passes() { + let ctx = Context::default(); + let raw = |id| { + let mut input = egui::RawInput { viewport_id: id, events: vec![egui::Event::PointerMoved(egui::pos2(10.0, 10.0))], ..Default::default() }; + input.viewports.entry(id).or_default(); + input + }; + let root = ViewportId::ROOT; + let panel = ViewportId::from_hash_of("panel"); + let _ = headless(&ctx, raw(root), |ui| { + request(ui.ctx(), Cursor::Pen, CursorIcon::Crosshair, true, 1.0); + }); + let _ = headless(&ctx, raw(panel), |ui| { + clear(ui.ctx()); + assert!(take(ui.ctx()).is_none()); + }); + assert!(ctx.data(|d| d.get_temp::(Id::unique(("native-canvas-cursor", root)))).is_some()); + let _ = headless(&ctx, raw(root), |ui| { + clear(ui.ctx()); + assert!(take(ui.ctx()).is_none()); + }); + let _ = headless(&ctx, raw(root), |ui| { + request(ui.ctx(), Cursor::Pen, CursorIcon::Crosshair, true, 1.25); + let req = take(ui.ctx()).unwrap(); + assert_eq!(req.viewport, root); + assert_eq!(req.pass, ui.ctx().cumulative_pass_nr()); + assert!(take(ui.ctx()).is_none()); + request(ui.ctx(), Cursor::Pen, CursorIcon::Crosshair, false, 1.0); + assert!(take(ui.ctx()).is_none()); + }); +} + +fn headless(ctx: &egui::Context, input: egui::RawInput, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut output = ctx.run_ui(input, draw); + output.textures_delta.clear(); + output +} diff --git a/crates/ui-egui/src/cursors/tests.rs b/crates/ui-egui/src/cursors/tests.rs index e70f7d488..63ab6c23a 100644 --- a/crates/ui-egui/src/cursors/tests.rs +++ b/crates/ui-egui/src/cursors/tests.rs @@ -349,6 +349,9 @@ fn the_canvas_hands_the_tool_cursor_to_the_os_and_idles_while_the_pointer_rests( assert_eq!(img, &app.canvas.cursors.get(Cursor::Arrow, 1.0).unwrap()); // Where the OS can't show the bitmap, its system cursor. assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Default); + } else if cfg!(target_os = "macos") { + assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::Default); + assert!(out.platform_output.cursor_image.is_none()); } else { assert_eq!(out.platform_output.cursor_icon, egui::CursorIcon::None); } diff --git a/docs/gaps.md b/docs/gaps.md index 8a244e0db..9e19ce7db 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 (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; 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-09 · **Change:** minor (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; 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 @@ -116,6 +116,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G1 Interaction fidelity +macOS cursor delivery (#2292) is narrowed to a native host adapter using the existing artwork and aligned 1×/2× representations. Headless tests cover visible fallbacks, preparation and ownership; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain open validation work (see [hardware parity](hardware-parity.md)). This does not close the interaction-fidelity gap or change its score/estimate. + - **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. - **Done:** Distance Guides ([public page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html)): with Smart Guides on and something selected, the Selection tool with Alt/Option held over another object shows the horizontal and vertical distances between them, and over an empty part of an artboard the selection's distances from its four edges, labelled in the General unit; Preferences › Smart Guides › Invoke Distance Guides turns them off. Hover only, as documented: not while moving or nudging. No entry closed; estimates unchanged. @@ -843,6 +845,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 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. | | 2026-11-09 | minor | G9: EPS import: 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. | | 2026-11-08 | minor | G4 type: the spell check engine is done (`text.spelling.check`, `text.spelling.suggest` on craft-proof, #4936); the Check Spelling dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5f6c87145..ce9f55cf5 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Windows: the pen's tilt and barrel rotation from Windows Ink, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Windows: the pen's tilt and barrel rotation from Windows Ink, #372) · **Target:** Adobe Illustrator 2026 (30.x) What hardware Illustrator uses, per platform, against VectorCraft. Illustrator's side is from its public documentation (GPU Performance, touch workspace, pen pressure pages); ours from the code @@ -26,7 +26,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and | Pen eraser end | yes | the eraser end (AppKit's tablet proximity events) selects the Eraser tool and the tip brings back the tool used before, as on Linux (#852; not yet tried on a Mac) | with Windows Ink, an inverted pen or its eraser button (`PEN_FLAG_INVERTED`, `PEN_FLAG_ERASER` in the pen's pointer messages) selects the Eraser tool and the tip brings back the tool used before, as on Linux (not yet tried with a pen) | under X11 and XWayland, the eraser end selects the Eraser tool and the tip brings back the tool used before (#764); **no** on native Wayland | a press of the eraser end (Pointer Events eraser button, `button` 5) selects the Eraser tool, and that press erases; a press of the tip brings back the tool used before; Pointer Events report no end while the pen hovers | partial | | Touch gestures (pinch zoom, two-finger pan, rotate view, Touch workspace) | yes (Windows touch, Surface) | trackpad pinch, scroll and rotate | pinch, pan (a second finger cancels the first finger's tool press, #4198); two-finger tap undo, three-finger redo (#585) | same where winit reports touch | eframe until the first pen contact, then Pointer Events (pen and fingers told apart); long press opens the context menu | partial: touch workspace and touch-screen rotation missing, 3–5 h | | Trackpad (smooth scroll, pinch) | yes | yes | precision touchpads | yes | yes | done | -| HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done; a window dragged between monitors of different scale can still grow on Windows (#1459, open); the custom title bar's window on Windows stays truly borderless so the pointer lines up with what is drawn (#1791, #1758; awaiting the reporter's confirmation) | +| HiDPI / Retina, per-monitor scale | yes | yes | yes | yes | yes | done; a window dragged between monitors of different scale can still grow on Windows (#1459, open); the custom title bar's window on Windows stays truly borderless so the pointer lines up with what is drawn (#1791, #1758; awaiting the reporter's confirmation) ; macOS cursors use aligned 1×/2× native image representations (#2292), covered by headless construction tests; mixed-display transitions and physical cursor sizing remain unverified, no additional hardware validated | | Multiple monitors: panels and document windows on another screen | yes | no (one OS window) | no | no | — | missing: 6–10 h (with multiple windows, gap G7) | | Display colour profile (proof and display through the monitor's ICC) | yes | no | no | no | — | missing: 3–5 h | | 3D GPU rendering (Materials, ray tracing) | yes | — | — | — | — | blocked on 3D (gap G3) | @@ -35,6 +35,7 @@ It reads the pen's tilt and barrel rotation on macOS and on Linux under X11 and | Date | Change | Summary | |---|---|---| +| 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-05 | minor | Windows (#372): with Windows Ink, each pen pointer message's `POINTER_PEN_INFO` gives the pen's tilt (`tiltX`, `tiltY`) and barrel rotation where its pen mask says the pen reports them, written into the canvas's pen feed as on macOS and Linux, so a 6D Art Pen's barrel turns a Calligraphic brush's nib. Unit-tested (`windows_stylus::tests::a_pens_tilt_and_rotation_come_where_it_reports_them`); not yet tried with a pen, so the score is unchanged (estimated) | | 2026-11-01 | patch | Linux (#4967): on a Wayland session, Linux Display Server › Automatic opens the window through XWayland when a pen is attached as the app starts, as X11 (XWayland) When a Pen Is Attached does and as PhotoCraft does; on native Wayland winit 0.30 gets no tablet input and KWin 6.3+ and Mutter emulate no pointer, so the pen moved only the compositor's cursor. `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. Unit-tested (`xwayland::tests`); not tried with a tablet; score unchanged (estimated) | | 2026-10-30 | patch | Touch (#4198): a second finger landing cancels the tool press the first finger began, also in the same frame (the Pencil's stroke and a Selection move are rolled back, nothing enters the undo history), and a finger landing while another is down presses nothing, so a pinch or two-finger pan only navigates. Headless canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`; not tried on a touch screen; score unchanged (estimated) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 82608fb4b..fb546a446 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 (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-09 · **Change:** minor (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 @@ -137,9 +137,9 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | 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 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands (#4936; the dialogs and Auto Spell Check remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands (#4936; the dialogs and Auto Spell Check remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands (#4936; the dialogs and Auto Spell Check remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands (#4936; the dialogs and Auto Spell Check remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands (#4936; the dialogs and Auto Spell Check remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands (#4936; the dialogs and Auto Spell Check remain); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | @@ -153,7 +153,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; broader scripting and batch work remains | 18–27 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset , UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset , UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 | | M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above | @@ -190,6 +190,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 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. | | 2026-11-09 | minor | M4/G9: EPS import: 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. | | 2026-11-08 | minor | M7: spell check engine, `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936); the dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1a452925f..965bc5309 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 (current Guides feature-area rows consolidated using existing ruler evidence; 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; `.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-09 · **Change:** minor (interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; harakat paint fallback clarified; current Guides feature-area rows consolidated using existing ruler evidence; 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; `.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 @@ -80,6 +80,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-05 | minor | Optical kerning moves each pair part of the way toward the font's own stem spacing, with a minimum gap and tighter limits, and leaves ideographs, kana, fullwidth forms and dashes on the font's spacing, so text is no longer squeezed (#5336). Scores and estimates unchanged. | | 2026-11-05 | minor | Gradient panel: Delete Stop sits at the right end of the ramp, so it shows whenever the ramp does instead of under the stop fields, where a short panel hid it (#5403); scores and estimates unchanged | | 2026-11-04 | minor | Record Select Text by Font's artboard and layer scope (#5529, a VectorCraft addition); scores and estimates unchanged. | +| 2026-11-07 | minor | Type: paint-only Arabic harakat keep joining forms; a matching character-level pass assigns a later in-cluster run only for GDEF mark glyphs on harakat characters (otherwise cluster start); Insert Kashidas tatweels take the base letter's paint (`tests::harakat`). Scores and estimates unchanged. Marks may retain letter paint without GDEF mark classes or with some AAT/`morx` ligatures. | | 2026-11-04 | minor | Type core: a Type-range Copy or Cut publishes styled art alongside plain text, so canvas Paste keeps font family, size and color after editing ends (#5636); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | | 2026-11-04 | minor | Raster/PSD defaults honor document resolution; CLI run/convert accept --ppi, matching MCP options.ppi precedence. Synthetic regressions added; scores and hours unchanged. | @@ -211,7 +212,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 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, painter visibility); materials, light presets, 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 core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636) | +| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine and custom dictionary done, #4936; the dialogs and Auto Spell Check remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine and custom dictionary done, #4936; the dialogs and Auto Spell Check remain), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | @@ -235,7 +236,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated; MCP `add_text` exposes existing vertical type; score and hours unchanged | -| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), scores and hours unchanged | +| UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, 3 Preferences options stored but unused (#394, audited 2026-10-30: Open Documents As Tabs, Scratch Disks, legacy vector clipboard), SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; floating panels in OS windows of their own on the desktop (#4135), score and hours unchanged; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates (sample widths cached by language/unit/DPI; regression and contributor original-file evidence below; scores/hours unchanged); effect dialogs' colour fields show a swatch that opens the Color Picker (#5365), score and hours unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), scores and hours unchanged ; interim macOS cursor delivery (#2292), headless coverage only; native validation pending, score and hours unchanged | | Libraries, Links, Package | 2 | 82% | shared libraries, linked graphics, gradients and patterns as library items (no cloud by design) | 2–3 | estimated | | Generative AI (Text to Vector, Generative Recolor, Shape Fill, Concept to Vector, Prompt to Edit, Rewrite Text) | 2 | 0% | a pluggable provider and the panels; needs an owner decision on models | 30–60 | measured (absent) | | **Feature depth** | **105** | **~75%** | | **~250–420** | | @@ -485,7 +486,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine and custom dictionary commands are done, #4936: the Check Spelling and Edit Custom Dictionary dialogs and Auto Spell Check remain), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | -| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | +| Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | more built-in symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | @@ -1107,6 +1108,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. The Character panel menu's Kashidas switch (with the Indic options; `text.setFormat` / `text.setRangeStyle {kashidas}`) allows Kashidas on selected characters, on by default for new and old text. A pair of joined letters receives one only when both characters allow it; turning one off leaves other eligible pairs in the word available. Character styles and the native file keep the switch. [Public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html) documents the menu control and the separate paragraph length choices; the default and pair rule are VectorCraft choices. + Paint-only Arabic harakat (marks whose character styles differ only in fill, stroke and related paint) stay inside one shaping cluster so letters keep their joining forms; when a second character-level shaping pass matches the monotone glyphs, a glyph may take a later in-cluster character's run only when GDEF classifies it as a mark (class 3) and that character is a harakat; otherwise it keeps the shaping cluster's first character. Insert Kashidas tatweels take the base letter's paint rather than a trailing mark's. Fonts without GDEF and some AAT/`morx` ligatures can leave a mark painted as its letter (`crates/text/src/tests/harakat.rs`). The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and Convert to Text are not built yet. The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1. @@ -1458,7 +1460,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur anchor or with Alt held over a path (convert) and over another open path's end (join). The Eyedropper has an original pipette glyph with its tip at the hotspot; Use Precise Cursors restores the crosshair. The same glyph feeds OS bitmaps and painted cursors. DPI/hotspot and preference checks in `cursors.rs` and `tests/prefs.rs` cover #1269; - actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the tool cursor is painted only while the window has the focus. Over a window behind another app, or under the File › Open… sheet, the system pointer shows alone instead of next to the painted cursor (#2020). + actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). On macOS the root document canvas now requests native AppKit delivery of the existing artwork, with aligned 1×/2× representations and a visible system fallback (#2292). `cursors/native_request` and `canvas` tests cover pixel/hotspot alignment, preferences and fallback routing; `mac_cursor` tests cover real AppKit image construction and the ownership protocol against a fake backend. The host releases on native-menu tracking and loss of eligibility; detached document viewports retain the visible system fallback. The pre-platform-output rectangle strategy, real focus/menu/handback behavior, mixed displays, Spotlight and native full-screen Spaces remain unverified; #2020 and #2292 are not declared closed. In a rotated view the resize and rotate cursors turn with the view, to the nearest 45°. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502). Hard-edge rendering, including the internal hard-edge frame of Art Optimized supersampled raster exports, draws a knockout element with one opaque solid fill once instead of erasing and adding it through separate layers. Analytical antialiasing keeps the existing compositor and rounding. Synthetic path/compound-hole tests compare exact pixels against the general knockout path across precision, supersampling, translucent parent groups and backdrop blending; translucent fills retain their knockout behavior. @@ -1901,6 +1903,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 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. | | 2026-11-09 | minor | File formats: EPS import: 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. | | 2026-11-08 | minor | Type advanced: spell check engine, `text.spelling.check` lists unknown words per run language at `text.editRange` byte offsets and `text.spelling.suggest` gives suggestions, from craft-proof (#4936); evidence added; the dialog, Auto Spell Check and the custom dictionary remain; scores and hours unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index d407c433c..6c547b2a4 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -57,7 +57,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Thai, Lao, Khmer, Myanmar: shaping (marks, reordering), lines broken between words, word-by-word caret and double-click | done | words found by ICU4X's line segmenter: dictionaries on the desktop, LSTM models on the web | | Middle Eastern: bidirectional layout, Paragraph Direction | done | | | Middle Eastern and Indic: digit types (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; Devanagari, Bengali, Gurmukhi, Gujarati, Odia, Tamil, Telugu, Kannada, Malayalam) | done | `text.setFormat` / `text.setRangeStyle {digits}` (`"devanagari"`, `"bengali"`, `"gurmukhi"`, `"gujarati"`, `"odia"`, `"tamil"`, `"telugu"`, `"kannada"`, `"malayalam"` added, #4942); the text keeps the typed digits, the glyphs and SVG export follow the style; carried by character styles. Not yet: `.ai` export of the setting; Indic digits typed as such stay as typed under another system. Evidence: `text::tests::indic_digits_map_to_their_scripts_block`, `tests::bidi::indic_digits_draw_the_scripts_own_digits`, `typecmd::area_tests::indic_digits_round_trip_through_the_commands_and_the_file` | -| Middle Eastern: Kashidas | done | Paragraph Insert Kashidas sets the length for justified Arabic area type; Character-menu Kashidas allows or excludes selected characters, on by default. A kashida is inserted only between two allowed joined characters. The character switch is retained in character styles and the native file; the length is retained in paragraph styles and the native file. [Public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html). | +| Middle Eastern: Kashidas | done | Paragraph Insert Kashidas sets the length for justified Arabic area type; Character-menu Kashidas allows or excludes selected characters, on by default. A kashida is inserted only between two allowed joined characters. The character switch is retained in character styles and the native file; the length is retained in paragraph styles and the native file. Paint-only harakat (coloured or hidden marks) stay in one shaping cluster so letters keep joining; when the character-level pass matches, a glyph may take a later in-cluster character's run only if GDEF names it a mark (class 3) and that character is a harakat—otherwise the cluster start (a mark may miss its colour; a base never takes a mark's). Inserted tatweels take the base letter's paint—not a trailing mark's. Fonts without GDEF and some AAT/`morx` ligatures (e.g. Geeza Pro Allah) can leave a mark painted as its letter. No Character-panel harakat colour command yet. Evidence: `tests::harakat`. [Public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html). | | Middle Eastern: Middle Eastern composers, split caret | missing | Character Direction is done; editable bidirectional SVG text remains incomplete. | | CJK: input methods (marked text, candidate window) | done | | | CJK: kinsoku (Hard, Soft, None), Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, the Mojikumi Settings dialog and the other mojikumi sets missing | @@ -97,6 +97,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | 2026-11-04 | minor | A Type-range Copy or Cut publishes styled art alongside plain text, so a later canvas Paste keeps its character formatting after editing ends (#5636). Scores and estimates unchanged. | | 2026-11-03 | minor | Glyphs panel adds Alternates for Current Selection. A chosen alternate changes one character's drawn glyph while keeping its Unicode text; character-style capture does not copy that per-character choice. Scores and estimates unchanged. | | 2026-11-04 | minor | Find Font's Replace With Font lists family and style rows, one click setting both, with a search over them; the list scrolls apart from the document's font list and refreshes after each replacement (#4972); scores and estimates unchanged. | +| 2026-11-07 | minor | Paint-only Arabic harakat stay in one shaping cluster; a matching character-level pass assigns a later in-cluster run only for GDEF mark glyphs on harakat characters (otherwise cluster start); Insert Kashidas tatweels take the base letter's paint (AAT/no-GDEF marks may keep the letter's colour). Evidence: `tests::harakat`. Scores and estimates unchanged. | | 2026-11-03 | minor | The OpenType panel lists the selected font's available Stylistic Sets with its custom names or numbered labels; sets can be combined and deselected. Scores and estimates unchanged. | | 2026-11-03 | minor | Character-menu Kashidas controls selected characters separately from the paragraph's Insert Kashidas length. Existing text allows Kashidas by default; scores and estimates unchanged. | | 2026-11-03 | minor | Type on a Path Options has a Preview checkbox: the type follows the fields while it is on, Cancel restores it, OK is one undo step (#5360, as public tutorials of the dialog show); scores and estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d23d2bb3b..cfc8a53cd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; 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; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (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; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; 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; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -318,6 +318,7 @@ tools, never checked side by side) isn't captured row by row. | Tool double-click opens its options | done | | | Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | | | Eyedropper cursor; Use Precise Cursors | partial | original pipette at the sampling tip, distinct from the precise crosshair at five DPI scales (#1269); OS cursor display unverified; while Caps Lock is on, the tools that Use Precise Cursors changes show the crosshair, and the saved preference does not change (the desktop app reads the lock state on macOS, Windows and Linux X11, not on native Wayland; `view.capsLock` reads or sets it); a Use Precise Cursors command toggles the preference and can take a shortcut (#1663); cursors are painted into the window on Wayland (#1652, not run on Linux) | +| macOS canvas cursor delivery (#2292) | partial | The UI prepares the existing glyphs as aligned 1×/2× RGBA requests; the desktop host builds AppKit cursors and owns cursor rectangles for the root document window. Detached document viewports and requests without a usable native bitmap retain the visible system fallback; precise-cursor and UI-scale preferences retained. Headless regressions cover routing, pixels/hotspots and lifecycle; native focus/menu/handback timing, mixed displays, Spotlight and full-screen Spaces remain unverified. Wayland still paints its glyphs. | | Every tool's modifiers checked against the shortcut reference | missing | this is the fidelity pass | ## Keyboard shortcuts @@ -496,6 +497,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| | 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | +| 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-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. | | 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | | 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, as with Direct Selection chosen, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged |