diff --git a/ASSETS.md b/ASSETS.md index 7747ac965..5658882d4 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -160,6 +160,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-fx.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-gamut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-grad-conic.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-grad-diamond.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-grad-freeform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-grad-linear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-grad-radial.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/Cargo.lock b/Cargo.lock index f6bebf778..02e109b6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -967,6 +967,17 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-affinity" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=228f6d0c50f1fdee4c5124603602993fc79d4c9d#228f6d0c50f1fdee4c5124603602993fc79d4c9d" +dependencies = [ + "crc32fast", + "flate2", + "image", + "ruzstd", +] + [[package]] name = "craft-docpath" version = "0.1.0" @@ -4879,9 +4890,6 @@ name = "ruzstd" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" -dependencies = [ - "twox-hash", -] [[package]] name = "ryu" @@ -6066,12 +6074,6 @@ dependencies = [ "core_maths", ] -[[package]] -name = "twox-hash" -version = "2.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" - [[package]] name = "type-map" version = "0.5.1" @@ -6362,11 +6364,10 @@ dependencies = [ name = "vectorcraft-affinity" version = "0.24.1" dependencies = [ + "craft-affinity", "crc32fast", - "flate2", "image", "proptest", - "ruzstd", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b733a0f3b..29f43866c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63 # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } # craft-update: app-specific pretend versions apply to startup and manual checks. +# Native Affinity documents (craft-libs#914). +craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } diff --git a/ROADMAP.md b/ROADMAP.md index 327036c37..3e8cfb9d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-01: Group Overlapping Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects whose visual bounds overlap or touch, chains included, go into one group each, within their own layer or group, at the stacking place of their frontmost member; objects that overlap nothing stay as they are. One undo step, a sweep along x so thousands of loose pieces group quickly (at most 20000 objects). A VectorCraft addition: headline numbers unchanged. - 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged. - 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged. +- 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets. - 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index f7dd201af..3808c281f 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -7,7 +7,7 @@ //! Differences from the desktop app: //! - no TCP control server (browsers can't listen on sockets); //! - File → Open uses the browser file picker; bytes arrive asynchronously through -//! `Services::inbox`; +//! `Services::picked_inbox`; //! - Save / Export trigger a browser download; //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; //! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index b4ca434d9..2de7f3b48 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -17,7 +17,7 @@ use vectorcraft_ui_egui::i18n; use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox}; use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer}; use vectorcraft_ui_egui::stylus::StylusFeed; -use vectorcraft_ui_egui::{Services, VectorcraftApp}; +use vectorcraft_ui_egui::{PickedInbox, Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; use crate::locks::WebLocks; @@ -25,6 +25,18 @@ use crate::pencil::SharedPointers; pub(crate) type Inbox = Arc)>>>; +/// The files waiting to open, by where they came from (`Services::inbox`, `picked_inbox` and +/// `page_inbox`). +#[derive(Clone, Default)] +struct OpenInboxes { + /// Dropped on the page. + dropped: Inbox, + /// The open picker's answers. + picked: PickedInbox, + /// Handed over by the page's script (`window.vectorcraft.openFile`). + page: Inbox, +} + /// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and /// whether Shift was held: where dropped files land. type DragPos = Rc>>; @@ -48,7 +60,7 @@ const MAX_RESTARTS: u32 = 3; #[derive(Clone)] struct Host { runner: eframe::WebRunner, - inbox: Inbox, + inbox: OpenInboxes, place_inbox: PlaceInbox, script_inbox: ScriptInbox, drag: DragPos, @@ -77,7 +89,7 @@ pub fn start() { crate::fonts::load_startup_fonts().await; let host = Host { runner: eframe::WebRunner::new(), - inbox: Arc::default(), + inbox: OpenInboxes::default(), place_inbox: Arc::default(), script_inbox: Arc::default(), drag: DragPos::default(), @@ -132,13 +144,13 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option { // `window.vectorcraft`: the page's control channel (crate::api). let (control_tx, control_rx) = std::sync::mpsc::channel(); - crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone()); + crate::api::connect(Some(control_tx), host.inbox.page.clone(), ctx.clone()); if let Err(e) = crate::api::install() { log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e)); } @@ -409,7 +421,7 @@ impl eframe::App for WebShell { for f in dropped { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); let target = app.drop_target(&name, pos, shift); - let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone()); + let (inbox, place_inbox, repaint) = (self.host.inbox.dropped.clone(), self.host.place_inbox.clone(), ctx.clone()); wasm_bindgen_futures::spawn_local(async move { match f.bytes_async().await { Ok(bytes) => { @@ -432,14 +444,14 @@ impl eframe::App for WebShell { } fn services( - inbox: Inbox, + inbox: OpenInboxes, place_inbox: PlaceInbox, script_inbox: ScriptInbox, ctx: egui::Context, locks: Option, stylus: StylusFeed, ) -> Services { - let open_inbox = inbox.clone(); + let open_inbox = inbox.picked.clone(); let picked = place_inbox.clone(); let place_ctx = ctx.clone(); let screen_color = eye_dropper(ctx.clone()); @@ -488,11 +500,12 @@ fn services( let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| { d.add_filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts) }); - let Some(file) = dialog.pick_file().await else { - return; + // A canceled pick answers `None`. + let answer = match dialog.pick_file().await { + Some(file) => Some((file.file_name(), file.read().await)), + None => None, }; - let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(answer); ctx.request_repaint(); }); })), @@ -500,7 +513,9 @@ fn services( screen_color, // A download that couldn't be started fails the save or export it is for (#1693). download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))), - inbox: Some(inbox), + inbox: Some(inbox.dropped), + picked_inbox: Some(inbox.picked), + page_inbox: Some(inbox.page), recovery_store: Some(Arc::new(BrowserStore { locks })), // File → Print: the browser's print dialog. print: Some(Box::new(BrowserPrint)), diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index fa0f0dca2..0391b7534 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -9,6 +9,7 @@ use super::{App, VectorcraftApp, file_access, window}; #[cfg(target_os = "macos")] use super::{mac_menu, mac_window, open_documents}; use vectorcraft_ui_egui::dialogs::safe_mode; +use vectorcraft_ui_egui::hang_beacon::{self, BEACON, FrameTime, Stage}; /// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating /// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel @@ -34,12 +35,16 @@ impl eframe::App for App { // control channel asks for, and the frames carrying input it injected, to the automation roots // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + let _timed = BEACON.enter_timed(Stage::Logic); file_access::unconfined(|| self.logic_frame(ctx, frame)); self.app.finish_ime_logic(ctx); + BEACON.enter(Stage::Idle); } /// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the /// app's raw-input hook while the keyboard user is unconfined. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + let _timed = BEACON.enter_timed(Stage::Input); + self.frame_start = Some(std::time::Instant::now()); // winit gives the window its caption back when it changes the window's flags (maximize, // restore, …): take it off again before the frame is laid out (#1758). if let Some(window_frame) = self.window_frame.as_mut() { @@ -60,9 +65,10 @@ impl eframe::App for App { // that no mouse button is down. #[cfg(target_os = "macos")] self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0); + BEACON.enter(Stage::Idle); } - #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + let _timed = BEACON.enter_timed(Stage::Interface); #[cfg(target_os = "macos")] { let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT; @@ -89,8 +95,13 @@ impl eframe::App for App { if !self.app.file_dialog_open() && self.app.take_ime_discard() { discard_marked_text(); } + self.end_frame(ui.ctx(), frame); } fn on_exit(&mut self) { + // The window is closing: saving and teardown are not timed for the hang report, and a report + // of a stall the interface came back from is removed. + let log_dir = super::prefs::log_dir().filter(|_| super::prefs::prefs_enabled()); + super::hang_watch::closing(log_dir.as_deref(), &super::hang_watch::CLOSING, &BEACON); file_access::unconfined(|| save_prefs(&self.app)); // A ready update installs once the process has exited. The macOS exit below skips // dropping the app, so the helper is started here. @@ -102,6 +113,35 @@ impl eframe::App for App { } impl App { + /// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a + /// focused, shown window whose windows had input waits for the renderer and the next frame, + /// which the input asks for at once; any other frame leaves the interface thread idle + /// (`hang_watch`). + fn end_frame(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { + // A pass egui discards and runs again is not the frame's end: the pass after it is. + if ctx.will_discard() { + return; + } + if let Some(start) = self.frame_start.take() { + let work_ms = start.elapsed().as_secs_f32() * 1000.0; + let previous_total_ms = frame.info().cpu_usage.map(|s| s * 1000.0); + BEACON.frame(FrameTime { work_ms, previous_total_ms }, self.app.session.documents().len()); + } + // Hide on macOS hides the window at once, before the window reports losing focus. + let main_shown = ctx.input(|i| i.focused && i.viewport().minimized != Some(true)) + && self.winit_window.as_ref().is_none_or(|w| w.is_visible() != Some(false)); + // A floating panel or document window with the focus counts as well, with the commands it + // was sent after its pass, and those its pass sent (`hang_beacon::watch_input`). + let shown = ctx.input(|i| hang_beacon::shown_window(main_shown, &i.raw.viewports)); + let handed_off = BEACON.take_handed_off(); + let input = BEACON.take_input(); + let stage = match shown { + Some(id) => ctx.viewport_for(id, |vp| hang_beacon::after_frame(input && !handed_off.contains(&id), true, &vp.commands)), + None => hang_beacon::after_frame(input, false, &[]), + }; + BEACON.enter(stage); + } + 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)) { diff --git a/apps/vectorcraft/src/hang_watch.rs b/apps/vectorcraft/src/hang_watch.rs new file mode 100644 index 000000000..e5adb77df --- /dev/null +++ b/apps/vectorcraft/src/hang_watch.rs @@ -0,0 +1,445 @@ +//! The hang report. A thread reads the interface thread's marks +//! ([`vectorcraft_ui_egui::hang_beacon`]) every [`POLL`]. When the interface thread has made no +//! mark for [`STALL`] while inside a stage of its frame, the thread writes what the app knows to the +//! log (`logs/vectorcraft.log`) and to `logs/hang-report-.txt`: the stage, a clipboard +//! call in progress, the last commands, the last frames' times, the platform, the window system, the +//! graphics processor and the present mode the app asked for, and how long the session has run with +//! how many documents open. The process holds a lock on the file until the interface thread answers +//! again; then the log says after how long and the file is removed. A file nobody holds at a later +//! launch was left by a session that was closed while it did not answer, and a locked one belongs to +//! another instance that is stalled now and is left alone. That launch writes the report to the log, +//! moves the newest abandoned file to `hang-report.1.txt` and removes the others, says so in the +//! status bar, and Help › Copy Troubleshooting Info copies the reports. Where the file system has no +//! file locks, the file is written and taken without one. +//! +//! No native stack is recorded: the marks are what the report has. The report holds command ids and +//! counts, never document contents or file paths. [`STALL`] is VectorCraft's own value. +//! +//! A stall in the renderer is timed only after a frame that had input in any window: input asks for +//! the next frame at once, so a frame that does not start then is late. After a frame without +//! input, the window waiting for input looks the same as one stuck drawing, so that time is not +//! timed. Nor is the time after a frame that minimizes or hides the main window or hands it to the +//! system to move or resize. A window the system stops redrawing while it is hidden can still look +//! like a stall after a frame with input; the report's stage says so. A system file dialog shown in +//! line (on Windows and Linux) and an open menu of the macOS menu bar wait for the user and are not +//! timed. + +use std::fs::File; +use std::io::{Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant, SystemTime}; + +#[cfg(target_os = "macos")] +use vectorcraft_ui_egui::hang_beacon::Activity; +use vectorcraft_ui_egui::hang_beacon::{BEACON, Beacon, FrameTime, Sample, Stage}; + +use super::logging::timestamp; + +/// How long the interface thread may stay in one stage before the report is written. +pub const STALL: Duration = Duration::from_secs(3); +/// How often the watching thread reads the marks. +pub const POLL: Duration = Duration::from_millis(500); +/// The report of a stall that has not ended, in the log folder: this, the process id, `.txt`. +pub const REPORT_PREFIX: &str = "hang-report-"; +/// At most this many report files are taken when a launch looks for abandoned ones; older abandoned +/// ones are removed. +const MAX_REPORT_FILES: usize = 16; +/// At most this many entries of the log folder are looked at for report files. +const MAX_SCANNED_ENTRIES: usize = 1024; +/// The report the last session left, moved here at launch. +pub const PREVIOUS_FILE: &str = "hang-report.1.txt"; +/// A report file larger than this is not read. +pub const MAX_REPORT_BYTES: u64 = 64 * 1024; +/// Environment values in the report are cut to this many characters. +const MAX_VALUE_CHARS: usize = 64; + +/// What [`Watch::check`] found. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Event { + /// The interface thread has been in the sample's stage, without a mark, for this long. + Stalled(Duration), + /// The interface thread made a mark again after a [`Event::Stalled`], about this long after + /// the stall began. + Answered(Duration), +} + +/// Decides from successive samples when the interface thread has stalled and when it answers +/// again. Time comes from the caller, so tests drive it without waiting. +#[derive(Debug)] +pub struct Watch { + stall: Duration, + /// The step seen in a busy stage and when it was first seen. + seen: Option<(u64, Duration)>, + reported: bool, +} + +impl Watch { + pub fn new(stall: Duration) -> Watch { + Watch { stall, seen: None, reported: false } + } + + /// The marks read at `now` (any monotonic clock that started before the first call). + pub fn check(&mut self, now: Duration, sample: Sample) -> Option { + let same = self.seen.is_some_and(|(step, _)| step == sample.step); + if same && sample.busy() { + let since = self.seen.map_or(now, |(_, since)| since); + let stalled = now.saturating_sub(since); + if !self.reported && stalled >= self.stall { + self.reported = true; + return Some(Event::Stalled(stalled)); + } + return None; + } + let answered = match (self.reported, self.seen) { + (true, Some((_, since))) => Some(Event::Answered(now.saturating_sub(since))), + _ => None, + }; + self.reported = false; + self.seen = sample.busy().then_some((sample.step, now)); + answered + } +} + +/// What the report says about the platform. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Facts { + /// The window system the window opened on: Wayland, X11, AppKit, Win32, other or unknown. + pub window_system: String, + /// `XDG_CURRENT_DESKTOP`. + pub desktop: Option, + /// `XDG_SESSION_TYPE`. + pub session_type: Option, + /// Running in a Flatpak sandbox. + pub flatpak: bool, + /// The graphics processor the window draws with. + pub graphics: Option, + /// The present mode and frame queue the app asked the window surface for. + pub surface: String, +} + +impl Facts { + /// The facts from the environment (`env`, `flatpak_info`: whether `/.flatpak-info` exists) and + /// what the app knows. + pub fn new(env: impl Fn(&str) -> Option, flatpak_info: bool, window_system: &str, graphics: Option, surface: String) -> Facts { + let value = |name: &str| env(name).map(|v| clean(&v)).filter(|v| !v.is_empty()); + Facts { + window_system: window_system.to_string(), + desktop: value("XDG_CURRENT_DESKTOP"), + session_type: value("XDG_SESSION_TYPE"), + flatpak: flatpak_info || env("FLATPAK_ID").is_some_and(|v| !v.is_empty()), + graphics, + surface, + } + } + + /// The facts as report lines. + pub fn lines(&self) -> String { + let or_unknown = |v: &Option| v.clone().unwrap_or_else(|| "unknown".to_string()); + format!( + "VectorCraft {} on {} {}\nWindow system: {}; desktop: {}; session type: {}; Flatpak: {}\nGraphics: {}; {}\n", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + self.window_system, + or_unknown(&self.desktop), + or_unknown(&self.session_type), + if self.flatpak { "yes" } else { "no" }, + or_unknown(&self.graphics), + self.surface, + ) + } +} + +/// `v` with control characters removed, cut to [`MAX_VALUE_CHARS`] characters. +fn clean(v: &str) -> String { + v.chars().filter(|c| !c.is_control()).take(MAX_VALUE_CHARS).collect::().trim().to_string() +} + +/// The marks other than the stage: the last commands, the last frames and the session. +fn activity_lines(beacon: &Beacon, uptime: Duration) -> String { + let commands = match beacon.recent_commands() { + Some(c) if c.is_empty() => "none".to_string(), + Some(c) => c.join(", "), + None => "unavailable (being written)".to_string(), + }; + let (work, total) = match beacon.recent_frames() { + Some(frames) => { + let list = |f: &dyn Fn(&FrameTime) -> Option| { + let times: Vec = frames.iter().filter_map(f).map(|ms| format!("{ms:.1}")).collect(); + if times.is_empty() { "none".to_string() } else { times.join(", ") } + }; + (list(&|t| Some(t.work_ms)), list(&|t| t.previous_total_ms)) + } + None => ("unavailable (being written)".to_string(), "unavailable (being written)".to_string()), + }; + format!( + "Recent commands, oldest first: {commands}\nApp work per frame (ms), oldest first: {work}\nWork and drawing per frame, without waiting for the display (ms), oldest first: {total}\nSession: running for {} s, {} documents open\n", + uptime.as_secs(), + beacon.documents(), + ) +} + +/// The report of a stall that has lasted `stalled` in `sample`'s stage, written at `at`. +pub fn report(at: SystemTime, stalled: Duration, sample: Sample, beacon: &Beacon, uptime: Duration, facts: &Facts) -> String { + let waiting = sample.activity.describe().map(|a| format!("Waiting on: {a}\n")).unwrap_or_default(); + format!( + "VectorCraft hang report, {}\nThe interface thread has not moved on for {:.1} s.\nStage: {}\n{waiting}{}{}No native stack is recorded: the stage and the activity are what the app marks.\n", + timestamp(at), + stalled.as_secs_f64(), + sample.stage.describe(), + activity_lines(beacon, uptime), + facts.lines(), + ) +} + +/// What the watching thread and Help › Copy Troubleshooting Info share. +pub struct Shared { + pub facts: Facts, + pub started: Instant, + /// This session's last report. + pub last: Mutex>, + /// The report the last session left ([`take_previous`]). + pub previous: Option, +} + +/// The text Help › Copy Troubleshooting Info copies. +pub fn troubleshooting(shared: &Shared, beacon: &Beacon, at: SystemTime) -> String { + let last = shared.last.lock().unwrap_or_else(PoisonError::into_inner).clone(); + format!( + "VectorCraft troubleshooting info, {}\n{}{}\nThis session's last hang report:\n{}\nThe hang report the last session left (it was closed while it did not answer):\n{}\n", + timestamp(at), + shared.facts.lines(), + activity_lines(beacon, shared.started.elapsed()), + last.as_deref().unwrap_or("none\n"), + shared.previous.as_deref().unwrap_or("none\n"), + ) +} + +/// The report file of this process in `dir`. +fn report_path(dir: &Path) -> PathBuf { + dir.join(format!("{REPORT_PREFIX}{}.txt", std::process::id())) +} + +/// A report file this process wrote and holds locked until the interface answers again. +#[derive(Debug)] +pub struct Held { + path: PathBuf, + _file: File, +} + +/// Write `text` to this process's report file in `dir` and lock it. Where the file system has no +/// file locks, the file is written unlocked and the log says so. +fn write_report(dir: &Path, text: &str) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let path = report_path(dir); + let mut file = File::create(&path)?; + if let Err(e) = file.lock() { + log::warn!("the hang report file is written without a lock: {e}"); + } + file.write_all(text.as_bytes())?; + Ok(Held { path, _file: file }) +} + +/// Whether a report file whose `try_lock` gave `locked` was abandoned: nobody holds it. A file +/// system without file locks answers [`std::io::ErrorKind::Unsupported`], and its files count as +/// abandoned; any other error leaves the file alone. +fn abandoned(locked: Result<(), std::fs::TryLockError>) -> bool { + match locked { + Ok(()) => true, + Err(std::fs::TryLockError::Error(e)) => e.kind() == std::io::ErrorKind::Unsupported, + Err(std::fs::TryLockError::WouldBlock) => false, + } +} + +/// The report file at `path`, opened and locked, with its length, when it was abandoned +/// ([`abandoned`]) and is not empty (an empty one may be being written). +fn open_abandoned(path: &Path) -> Option<(File, u64)> { + let file = File::options().read(true).write(true).open(path).ok()?; + if !abandoned(file.try_lock()) { + return None; + } + let len = file.metadata().map_or(0, |m| m.len()); + (len > 0).then_some((file, len)) +} + +/// The reports that sessions closed while they did not answer left in `dir`, oldest first; `None` +/// when there are none. The newest file is moved to [`PREVIOUS_FILE`] and the others are removed +/// once read. Of more than [`MAX_REPORT_FILES`], the newest are taken and the older ones removed +/// unread; the first [`MAX_SCANNED_ENTRIES`] entries of `dir` are looked at. A report file another +/// process holds locked, or one still empty, is left alone. A file over [`MAX_REPORT_BYTES`] is +/// taken without being read. A file that can't be moved is removed, so no later launch offers it +/// again: the caller writes the reports to the log. Each file is opened once. +pub fn take_previous(dir: &Path) -> Option { + let mut found: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(dir) + .ok()? + .take(MAX_SCANNED_ENTRIES) + .filter_map(Result::ok) + .filter(|e| { + e.file_name() + .to_str() + .and_then(|n| n.strip_prefix(REPORT_PREFIX)?.strip_suffix(".txt")) + .is_some_and(|pid| !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit())) + }) + .map(|e| (e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH), e.path())) + .collect(); + found.sort(); + // Newest first: only abandoned reports count toward the limit. + let mut texts = Vec::new(); + for (_, path) in found.into_iter().rev() { + let Some((file, len)) = open_abandoned(&path) else { continue }; + if texts.len() == MAX_REPORT_FILES { + drop(file); + remove_quietly(&path); + continue; + } + let mut bytes = Vec::new(); + let text = if len <= MAX_REPORT_BYTES && (&file).take(MAX_REPORT_BYTES).read_to_end(&mut bytes).is_ok() { + String::from_utf8_lossy(&bytes).into_owned() + } else { + format!("The report was larger than {MAX_REPORT_BYTES} bytes and was not read.\n") + }; + // Closed (and unlocked) before it is moved: Windows moves no open file. + drop(file); + if !texts.is_empty() { + remove_quietly(&path); + } else if let Err(e) = std::fs::rename(&path, dir.join(PREVIOUS_FILE)) { + log::warn!("couldn't move the hang report file to {PREVIOUS_FILE}: {e}"); + remove_quietly(&path); + } + texts.push(text); + } + texts.reverse(); + (!texts.is_empty()).then(|| texts.join("\n")) +} + +/// Remove the report file at `path`; a failure goes to the log. +fn remove_quietly(path: &Path) { + if let Err(e) = std::fs::remove_file(path) { + log::warn!("couldn't remove the hang report file: {e}"); + } +} + +/// Set when the app closes normally ([`closing`]): the watching thread writes no report file after it. +pub static CLOSING: AtomicBool = AtomicBool::new(false); + +/// Remove `held`'s file, closing it first. → the error message; a file already gone is none. +fn remove(held: Held) -> Option { + let Held { path, _file: file } = held; + drop(file); + match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Some(e.to_string()), + _ => None, + } +} + +/// Act on what [`Watch::check`] found: write (and hold) or remove this process's report file in +/// `dir`, then log it. The file comes first: logging can wait on what the interface thread waits on. +#[allow(clippy::too_many_arguments)] +pub fn record( + event: Event, + sample: Sample, + beacon: &Beacon, + shared: &Shared, + dir: Option<&Path>, + held: &mut Option, + closing: &AtomicBool, + at: SystemTime, +) { + match event { + Event::Stalled(stalled) => { + let text = report(at, stalled, sample, beacon, shared.started.elapsed(), &shared.facts); + let mut failed = None; + if let Some(dir) = dir.filter(|_| !closing.load(Ordering::Acquire)) { + match write_report(dir, &text) { + Ok(file) => *held = Some(file), + Err(e) => failed = Some(e.to_string()), + } + // The app began to close while the file was written: it is not a report to leave. + if closing.load(Ordering::Acquire) { + failed = held.take().and_then(remove).or(failed); + } + } + *shared.last.lock().unwrap_or_else(PoisonError::into_inner) = Some(text.clone()); + log::warn!("the interface stopped answering:\n{}", text.trim_end()); + if let Some(e) = failed { + log::warn!("couldn't write or remove the hang report file: {e}"); + } + } + Event::Answered(after) => { + let failed = held.take().and_then(remove); + log::warn!("the interface answered again about {:.1} s after it stopped", after.as_secs_f64()); + if let Some(e) = failed { + log::warn!("couldn't remove the hang report file: {e}"); + } + } + } +} + +/// The app is closing normally: mark the interface thread idle on `beacon` (saving and teardown are +/// not timed), remove this process's report file in `dir` and have the watching thread write no +/// more. It does not wait for the watching thread. The file can be removed while that thread holds +/// it open and locked: the standard library opens files with `FILE_SHARE_DELETE` on Windows, and a +/// byte-range lock does not prevent deletion. Where Windows deletes a file only once its last +/// handle closes, the name goes when the process ends. +pub fn closing(dir: Option<&Path>, closing: &AtomicBool, beacon: &Beacon) { + beacon.enter(Stage::Idle); + closing.store(true, Ordering::Release); + if let Some(dir) = dir { + match std::fs::remove_file(report_path(dir)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("couldn't remove the hang report file: {e}"), + _ => {} + } + } +} + +/// The window system `window` opened on, for [`Facts::window_system`]. +pub fn window_system(window: Option<&winit::window::Window>) -> &'static str { + use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + match window.and_then(|w| w.window_handle().ok()).map(|h| h.as_raw()) { + Some(RawWindowHandle::Wayland(_)) => "Wayland", + Some(RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)) => "X11", + Some(RawWindowHandle::AppKit(_)) => "AppKit", + Some(RawWindowHandle::Win32(_)) => "Win32", + Some(_) => "other", + None => "unknown", + } +} + +/// The present mode and frame queue the app asked the window surface for, for [`Facts::surface`]; +/// `None` when the window has no wgpu renderer. +pub fn surface(config: Option<(String, Option)>) -> String { + match config { + Some((mode, Some(frames))) => format!("requested present mode {mode}, frame latency {frames}"), + Some((mode, None)) => format!("requested present mode {mode}"), + None => "no wgpu renderer".to_string(), + } +} + +/// Start the watching thread; the report file goes in `dir` (none: the log only). +pub fn start(dir: Option, shared: Arc) { + let spawned = std::thread::Builder::new().name("hang-watch".into()).spawn(move || { + let mut watch = Watch::new(STALL); + let mut held = None; + loop { + std::thread::sleep(POLL); + #[cfg_attr(not(target_os = "macos"), allow(unused_mut))] + let mut sample = BEACON.sample(); + // An open menu of the menu bar waits for the user. + #[cfg(target_os = "macos")] + if super::mac_menu::is_tracking() { + sample.activity = Activity::Menu; + } + if let Some(event) = watch.check(shared.started.elapsed(), sample) { + record(event, sample, &BEACON, &shared, dir.as_deref(), &mut held, &CLOSING, SystemTime::now()); + } + } + }); + if let Err(e) = spawned { + log::warn!("no hang report this session: the watching thread didn't start: {e}"); + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/vectorcraft/src/hang_watch/tests.rs b/apps/vectorcraft/src/hang_watch/tests.rs new file mode 100644 index 000000000..09cf74e40 --- /dev/null +++ b/apps/vectorcraft/src/hang_watch/tests.rs @@ -0,0 +1,270 @@ +use super::*; +use std::time::UNIX_EPOCH; +use vectorcraft_ui_egui::hang_beacon::{Activity, Stage}; + +fn ms(n: u64) -> Duration { + Duration::from_millis(n) +} + +fn sample(step: u64, stage: Stage) -> Sample { + Sample { step, stage, activity: Activity::None } +} + +fn facts() -> Facts { + let env = |name: &str| match name { + "XDG_CURRENT_DESKTOP" => Some("KDE".to_string()), + "XDG_SESSION_TYPE" => Some("wayland\u{7}".to_string()), + _ => None, + }; + Facts::new(env, true, "Wayland", Some("Test GPU (Vulkan, DiscreteGpu)".to_string()), surface(Some(("AutoVsync".to_string(), Some(1))))) +} + +fn shared(previous: Option) -> Shared { + Shared { facts: facts(), started: Instant::now(), last: Mutex::new(None), previous } +} + +#[test] +fn a_stage_held_for_the_stall_time_is_reported_once_and_its_end_after_it() { + let mut w = Watch::new(STALL); + assert_eq!(w.check(ms(0), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(2_999), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(3_000), sample(5, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); + assert_eq!(w.check(ms(9_000), sample(5, Stage::Interface)), None); + assert_eq!(w.check(ms(9_500), sample(6, Stage::Idle)), Some(Event::Answered(ms(9_500)))); + assert_eq!(w.check(ms(20_000), sample(6, Stage::Idle)), None); +} + +#[test] +fn frames_that_keep_moving_and_an_idle_window_are_never_reported() { + let mut w = Watch::new(STALL); + // A long animation: the step changes at every read. + for n in 0..100 { + assert_eq!(w.check(ms(n * 500), sample(n, Stage::Drawing)), None); + } + // A window waiting for input for a minute. + for n in 0..120 { + assert_eq!(w.check(ms(50_000 + n * 500), sample(1_000, Stage::Idle)), None); + } +} + +#[test] +fn a_file_dialog_shown_in_line_is_waiting_for_the_user_not_stalled() { + let mut w = Watch::new(STALL); + let dialog = Sample { step: 7, stage: Stage::Interface, activity: Activity::Dialog }; + for n in 0..60 { + assert_eq!(w.check(ms(n * 500), dialog), None); + } + // Back from the dialog, the command goes on and is timed again. + assert_eq!(w.check(ms(30_000), sample(8, Stage::Interface)), None); + assert_eq!(w.check(ms(33_000), sample(8, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); +} + +#[test] +fn an_open_menu_bar_menu_is_waiting_for_the_user_not_stalled() { + let mut w = Watch::new(STALL); + let menu = Sample { step: 3, stage: Stage::Drawing, activity: Activity::Menu }; + for n in 0..60 { + assert_eq!(w.check(ms(n * 500), menu), None); + } +} + +#[test] +fn a_new_busy_step_starts_the_time_again() { + let mut w = Watch::new(STALL); + assert_eq!(w.check(ms(0), sample(1, Stage::Logic)), None); + assert_eq!(w.check(ms(2_500), sample(2, Stage::Interface)), None); + assert_eq!(w.check(ms(5_000), sample(2, Stage::Interface)), None); + assert_eq!(w.check(ms(5_500), sample(2, Stage::Interface)), Some(Event::Stalled(ms(3_000)))); + // A mark inside the stall (a clipboard read ending) is an answer. + assert_eq!(w.check(ms(6_000), sample(3, Stage::Interface)), Some(Event::Answered(ms(3_500)))); +} + +#[test] +fn the_report_names_the_stage_the_activity_the_commands_and_the_platform() { + let beacon = Beacon::new(); + beacon.command("edit.copy"); + beacon.command("edit.paste"); + beacon.frame(FrameTime { work_ms: 1.24, previous_total_ms: Some(3.5) }, 2); + let s = Sample { step: 9, stage: Stage::Interface, activity: Activity::ClipboardRead }; + let text = report(UNIX_EPOCH + Duration::from_secs(1_794_385_145), ms(3_240), s, &beacon, Duration::from_secs(754), &facts()); + for want in [ + "VectorCraft hang report, 2026-11-11T08:19:05.000Z", + "has not moved on for 3.2 s", + "Stage: running the interface", + "Waiting on: reading the system clipboard", + "Recent commands, oldest first: edit.copy, edit.paste", + "App work per frame (ms), oldest first: 1.2", + "without waiting for the display (ms), oldest first: 3.5", + "Session: running for 754 s, 2 documents open", + "Window system: Wayland; desktop: KDE; session type: wayland; Flatpak: yes", + "Graphics: Test GPU (Vulkan, DiscreteGpu); requested present mode AutoVsync, frame latency 1", + ] { + assert!(text.contains(want), "{want:?} missing from:\n{text}"); + } +} + +#[test] +fn environment_values_lose_control_characters_and_are_cut_short() { + let long = "x".repeat(500); + let env = |name: &str| (name == "XDG_CURRENT_DESKTOP").then(|| format!("A\nB{long}")); + let f = Facts::new(env, false, "X11", None, String::new()); + assert_eq!(f.desktop.as_deref().map(str::len), Some(MAX_VALUE_CHARS)); + assert!(f.desktop.as_deref().is_some_and(|d| d.starts_with("ABx"))); + assert!(!f.flatpak); + assert!(f.lines().contains("Graphics: unknown")); +} + +#[test] +fn a_stall_writes_the_report_file_and_its_end_removes_it() { + let dir = tempfile::tempdir().unwrap(); + let shared = shared(None); + let beacon = Beacon::new(); + let s = sample(4, Stage::Drawing); + let (mut held, open) = (None, AtomicBool::new(false)); + record(Event::Stalled(ms(3_000)), s, &beacon, &shared, Some(dir.path()), &mut held, &open, UNIX_EPOCH); + let path = report_path(dir.path()); + // Windows refuses reads through another handle while the file is locked: the length is read. + let last = shared.last.lock().unwrap().clone().unwrap_or_default(); + assert!(last.contains("Stage: after a frame with input went to the renderer")); + assert_eq!(std::fs::metadata(&path).unwrap().len(), last.len() as u64); + // While the stall lasts, a launch of another instance leaves the file alone. + assert_eq!(take_previous(dir.path()), None); + assert!(path.exists()); + record(Event::Answered(ms(4_000)), s, &beacon, &shared, Some(dir.path()), &mut held, &open, UNIX_EPOCH); + assert!(!path.exists()); + // This session's report stays for Copy Troubleshooting Info. + assert!(shared.last.lock().unwrap().is_some()); +} + +#[test] +fn closing_normally_removes_the_report_and_writes_no_more() { + let dir = tempfile::tempdir().unwrap(); + let shared = shared(None); + let beacon = Beacon::new(); + let (mut held, flag) = (None, AtomicBool::new(false)); + record(Event::Stalled(ms(3_000)), sample(4, Stage::Interface), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + let path = report_path(dir.path()); + assert!(path.exists()); + // The interface answered after a frame with input, and the app quit before the watching thread + // looked again: saving and teardown are not timed. + beacon.enter(Stage::Drawing); + closing(Some(dir.path()), &flag, &beacon); + assert_eq!(beacon.sample().stage, Stage::Idle); + assert!(!path.exists()); + assert_eq!(take_previous(dir.path()), None); + // The watching thread then sees the answer. + record(Event::Answered(ms(3_500)), sample(5, Stage::Idle), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + assert!(held.is_none()); + // A stall the watching thread finds while the app closes goes to the log only. + record(Event::Stalled(ms(3_000)), sample(9, Stage::Interface), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH); + assert!(!path.exists()); + assert!(held.is_none()); +} + +#[test] +fn the_next_launch_takes_a_left_report_once_and_keeps_a_copy() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(take_previous(dir.path()), None); + let left = dir.path().join(format!("{REPORT_PREFIX}4242.txt")); + std::fs::write(&left, "VectorCraft hang report, then\n").unwrap(); + // Other files in the log folder are not reports. + std::fs::write(dir.path().join(format!("{REPORT_PREFIX}notes.txt")), "x").unwrap(); + std::fs::write(dir.path().join(format!("{REPORT_PREFIX}17.txt")), "").unwrap(); + assert_eq!(take_previous(dir.path()).as_deref(), Some("VectorCraft hang report, then\n")); + assert!(!left.exists()); + assert_eq!(std::fs::read_to_string(dir.path().join(PREVIOUS_FILE)).unwrap(), "VectorCraft hang report, then\n"); + assert_eq!(take_previous(dir.path()), None); + // An empty file may be one being written: it stays. + assert!(dir.path().join(format!("{REPORT_PREFIX}17.txt")).exists()); +} + +#[test] +fn of_several_left_reports_the_newest_is_kept_and_all_are_offered_oldest_first() { + let dir = tempfile::tempdir().unwrap(); + for (pid, text, secs) in [(9, "newer\n", 2_000), (10, "older\n", 1_000)] { + let path = dir.path().join(format!("{REPORT_PREFIX}{pid}.txt")); + std::fs::write(&path, text).unwrap(); + let file = std::fs::File::options().write(true).open(&path).unwrap(); + file.set_modified(UNIX_EPOCH + Duration::from_secs(secs)).unwrap(); + } + assert_eq!(take_previous(dir.path()).as_deref(), Some("older\n\nnewer\n")); + assert_eq!(std::fs::read_to_string(dir.path().join(PREVIOUS_FILE)).unwrap(), "newer\n"); +} + +#[test] +fn a_report_that_cannot_be_moved_is_offered_once() { + let dir = tempfile::tempdir().unwrap(); + // Something that is not a file is in the way. + std::fs::create_dir_all(dir.path().join(PREVIOUS_FILE).join("x")).unwrap(); + let left = dir.path().join(format!("{REPORT_PREFIX}5.txt")); + std::fs::write(&left, "kept\n").unwrap(); + assert_eq!(take_previous(dir.path()).as_deref(), Some("kept\n")); + assert!(!left.exists()); + assert_eq!(take_previous(dir.path()), None); +} + +#[test] +fn reports_beyond_the_newest_are_removed_not_left_for_a_later_launch() { + let dir = tempfile::tempdir().unwrap(); + let count = MAX_REPORT_FILES + 3; + for n in 0..count { + let path = dir.path().join(format!("{REPORT_PREFIX}{}.txt", 100 + n)); + std::fs::write(&path, format!("report {n}\n")).unwrap(); + let file = std::fs::File::options().write(true).open(&path).unwrap(); + file.set_modified(UNIX_EPOCH + Duration::from_secs(1_000 + n as u64)).unwrap(); + } + let text = take_previous(dir.path()).unwrap(); + assert!(text.starts_with("report 3\n"), "{text}"); + assert!(text.ends_with(&format!("report {}\n", count - 1)), "{text}"); + assert!(!text.contains("report 2\n")); + assert_eq!(take_previous(dir.path()), None); + let left: Vec<_> = std::fs::read_dir(dir.path()).unwrap().filter_map(Result::ok).map(|e| e.file_name()).collect(); + assert_eq!(left, vec![std::ffi::OsString::from(PREVIOUS_FILE)]); +} + +#[test] +fn newer_empty_files_do_not_push_out_an_abandoned_report() { + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join(format!("{REPORT_PREFIX}1.txt")); + std::fs::write(&old, "old\n").unwrap(); + std::fs::File::options().write(true).open(&old).unwrap().set_modified(UNIX_EPOCH + Duration::from_secs(1_000)).unwrap(); + for n in 0..MAX_REPORT_FILES + 1 { + let path = dir.path().join(format!("{REPORT_PREFIX}{}.txt", 100 + n)); + std::fs::write(&path, "").unwrap(); + std::fs::File::options().write(true).open(&path).unwrap().set_modified(UNIX_EPOCH + Duration::from_secs(2_000)).unwrap(); + } + assert_eq!(take_previous(dir.path()).as_deref(), Some("old\n")); +} + +#[test] +fn a_file_system_without_locks_does_not_hide_a_report() { + use std::io::{Error, ErrorKind}; + assert!(abandoned(Ok(()))); + assert!(!abandoned(Err(std::fs::TryLockError::WouldBlock))); + assert!(abandoned(Err(std::fs::TryLockError::Error(Error::from(ErrorKind::Unsupported))))); + // Any other error may hide a live session's lock: the file is left alone. + assert!(!abandoned(Err(std::fs::TryLockError::Error(Error::from(ErrorKind::PermissionDenied))))); +} + +#[test] +fn an_oversized_report_file_is_not_read() { + let dir = tempfile::tempdir().unwrap(); + let big = vec![b'x'; usize::try_from(MAX_REPORT_BYTES).unwrap() + 1]; + let left = dir.path().join(format!("{REPORT_PREFIX}7.txt")); + std::fs::write(&left, big).unwrap(); + let text = take_previous(dir.path()).unwrap(); + assert!(text.starts_with("The report was larger than")); + assert!(!left.exists()); +} + +#[test] +fn troubleshooting_info_holds_both_sessions_reports() { + let shared = shared(Some("VectorCraft hang report, last session\n".to_string())); + *shared.last.lock().unwrap() = Some("VectorCraft hang report, this session\n".to_string()); + let beacon = Beacon::new(); + let text = troubleshooting(&shared, &beacon, UNIX_EPOCH); + assert!(text.starts_with("VectorCraft troubleshooting info, 1970-01-01T00:00:00.000Z\n")); + assert!(text.contains("This session's last hang report:\nVectorCraft hang report, this session\n")); + assert!(text.contains("(it was closed while it did not answer):\nVectorCraft hang report, last session\n")); + assert!(text.contains("Recent commands, oldest first: none")); +} diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 187c42cde..50f1792eb 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -120,7 +120,6 @@ fn make_folder(request: &PickRequest) { #[cfg(not(target_os = "macos"))] #[cfg_attr(windows, allow(unused_variables))] fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec { - make_folder(request); #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] if let PickRequest::Save(pick) = request && let Some(answer) = portal_save::pick(pick, parent.as_deref()) @@ -139,7 +138,30 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) /// Show the dialog for `request` over `parent` now → the paths picked. #[cfg(not(target_os = "macos"))] fn pick_now(request: PickRequest, parent: &Parent) -> Vec { - show_dialog(file_dialog(&request, parent), &request, parent) + let beacon = &vectorcraft_ui_egui::hang_beacon::BEACON; + marked_dialog(beacon, || make_folder(&request), || show_dialog(file_dialog(&request, parent), &request, parent)) +} + +/// Run `prepare` (making the dialog's folder), then `show`, marked on `beacon` as a dialog: the +/// dialog waits for the user and is not a stall for the hang report (`hang_watch`), while `prepare` +/// is still timed. +#[cfg(not(target_os = "macos"))] +fn marked_dialog(beacon: &vectorcraft_ui_egui::hang_beacon::Beacon, prepare: impl FnOnce(), show: impl FnOnce() -> T) -> T { + prepare(); + let _mark = beacon.activity(vectorcraft_ui_egui::hang_beacon::Activity::Dialog); + show() +} + +#[cfg(all(test, not(target_os = "macos")))] +#[test] +fn making_a_dialogs_folder_is_timed_and_the_dialog_is_not() { + use vectorcraft_ui_egui::hang_beacon::{Activity, Beacon}; + let beacon = Beacon::new(); + let mut preparing = None; + let showing = marked_dialog(&beacon, || preparing = Some(beacon.sample().activity), || beacon.sample().activity); + assert_eq!(preparing, Some(Activity::None), "making the folder is timed"); + assert_eq!(showing, Activity::Dialog, "the dialog is not"); + assert_eq!(beacon.sample().activity, Activity::None, "the mark ends with the dialog"); } /// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an @@ -239,6 +261,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti std::thread::Builder::new() .name("file-dialog".into()) .spawn(move || { + make_folder(&request); // The app gone meanwhile has no use for the answer. let _ = tx.send(show_dialog(dialog, &request, &parent)); ui.request_repaint(); @@ -363,7 +386,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { fileio::write_file_with(p, fill).map_err(|e| e.to_string()) })), // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). - system_clipboard: Some(clipboard::system_clipboard(clip)), + // Its calls are marked for the hang report (`hang_watch`). + system_clipboard: Some(Box::new(vectorcraft_ui_egui::hang_beacon::Marked(clipboard::system_clipboard(clip)))), // Linux checks whether Paste has something to take on a background thread: an X11 clipboard // owner that never answers holds a read for up to 4 s. Windows only asks which formats the // clipboard holds and macOS asks the pasteboard server, so they check in line. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index bb94bad1c..33e03b1f7 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -30,6 +30,7 @@ mod control_server; mod cursor_theme; #[cfg(feature = "wgpu")] mod gpu; +mod hang_watch; #[cfg(target_os = "linux")] mod linux_stylus; mod logging; @@ -123,6 +124,8 @@ struct App { held_files: Vec, /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). monitors_listed: Option<(f64, f32)>, + /// When this frame's input hook started, for the hang report's frame times (`hang_watch`). + frame_start: Option, } /// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s. @@ -293,6 +296,11 @@ fn main() -> std::process::ExitCode { None => logger.no_file(), } } + // The report a session left when it was closed while it did not answer (`hang_watch`). + let previous_hang = log_dir().filter(|_| prefs_enabled()).and_then(|dir| hang_watch::take_previous(&dir)); + if let Some(report) = &previous_hang { + log::warn!("the last session was closed while it did not answer; its report:\n{}", report.trim_end()); + } // The roots confine the control channel: a bad one keeps the app from starting rather than // leaving agents unconfined. Without a control port there is nothing to confine. let roots = if control_port.is_none() { @@ -437,6 +445,9 @@ fn main() -> std::process::ExitCode { open_documents::install(); #[cfg(feature = "wgpu")] let created = startup.clone(); + // For the hang report (`hang_watch`). + #[cfg(feature = "wgpu")] + let requested_surface = options.wgpu_options.surface; // A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash. let run = |options: eframe::NativeOptions, app_creator: eframe::AppCreator<'_>| { #[cfg(target_os = "linux")] @@ -537,6 +548,36 @@ fn main() -> std::process::ExitCode { let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone()); rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}"))); } + // The hang report and Help › Copy Troubleshooting Info (`hang_watch`). + // The surface settings the options asked for; the driver resolves the present mode. + #[cfg(feature = "wgpu")] + let surface = cc + .wgpu_render_state + .as_ref() + .map(|_| (format!("{:?}", requested_surface.present_mode), requested_surface.desired_maximum_frame_latency)); + #[cfg(not(feature = "wgpu"))] + let surface = None; + let hang = std::sync::Arc::new(hang_watch::Shared { + facts: hang_watch::Facts::new( + |name| std::env::var(name).ok(), + std::path::Path::new("/.flatpak-info").exists(), + hang_watch::window_system(winit_window.as_deref()), + app.graphics_adapter.clone(), + hang_watch::surface(surface), + ), + started: std::time::Instant::now(), + last: std::sync::Mutex::new(None), + previous: previous_hang.clone(), + }); + vectorcraft_ui_egui::hang_beacon::watch_input(&vectorcraft_ui_egui::hang_beacon::BEACON, &cc.egui_ctx); + hang_watch::start(log_dir().filter(|_| prefs_enabled()), hang.clone()); + app.services.troubleshooting = Some(Box::new(move || { + hang_watch::troubleshooting(&hang, &vectorcraft_ui_egui::hang_beacon::BEACON, std::time::SystemTime::now()) + })); + // A message about the graphics processor, or a stopped plug-in, comes first. + if previous_hang.is_some() && app.ui.status.is_empty() { + vectorcraft_ui_egui::troubleshooting::last_session_stopped(&mut app); + } app.titlebar_from_env = titlebar_from_env(); app.custom_titlebar = custom_titlebar; app.desktop_frame = !custom_titlebar; @@ -583,6 +624,7 @@ fn main() -> std::process::ExitCode { maximize_tries: 8, held_files, monitors_listed: None, + frame_start: None, })) }), ) diff --git a/assets/icons/dc-grad-diamond.svg b/assets/icons/dc-grad-diamond.svg new file mode 100644 index 000000000..2efeca27e --- /dev/null +++ b/assets/icons/dc-grad-diamond.svg @@ -0,0 +1 @@ + diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml index c335c7b16..798b0734f 100644 --- a/crates/affinity/Cargo.toml +++ b/crates/affinity/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vectorcraft-affinity" -description = "Bounded reader for native Affinity documents: archive, object stream and document model" +description = "craft-affinity under its old name, for the corpus tests and fuzz targets" version.workspace = true edition.workspace = true license.workspace = true @@ -11,13 +11,13 @@ workspace = true [features] # Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter. -synth = [] +synth = ["craft-affinity/synth"] [dependencies] -crc32fast = { workspace = true } -flate2 = { workspace = true } -image = { workspace = true } -ruzstd = "0.9.1" +craft-affinity = { workspace = true } [dev-dependencies] proptest = { workspace = true } +# The tests build containers and decode previews. +crc32fast = { workspace = true } +image = { workspace = true } diff --git a/crates/affinity/README.md b/crates/affinity/README.md index d0a641697..38c096cbc 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -1,5 +1,7 @@ # vectorcraft-affinity +The reader is [`craft-affinity`](https://github.com/storytold/craft-libs/tree/main/crates/affinity) in craft-libs; this crate re-exports it under its old name for the corpus tests and fuzz targets. The engine maps its document onto VectorCraft art (`crates/engine/src/cmd/fileio/affinity`), as described below. + An independent, bounded Rust reader for native Affinity documents: `.af` from Affinity 3 and `.afdesign`, `.afphoto` and `.afpub` from Affinity 1 and 2 (container versions 8 to 12). It reads the archive, the tagged object stream in `doc.dat` and the part of the document model listed below, diff --git a/crates/affinity/src/access.rs b/crates/affinity/src/access.rs deleted file mode 100644 index 0b60ed7db..000000000 --- a/crates/affinity/src/access.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has -//! another type, so the document mapping treats odd data as absent rather than failing. - -use crate::stream::{ObjId, Object, Stream, Tag, Value}; - -impl Stream { - pub fn object(&self, id: ObjId) -> Option<&Object> { - self.objects.get(id) - } - - pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> { - self.object(id)?.get(Tag::of(tag)) - } - - pub fn class(&self, id: ObjId) -> Option { - self.object(id).map(|o| o.class) - } - - pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool { - self.class(id) == Some(Tag::of(class)) - } - - /// A child object (shared, linked or inline); `None` for null or missing. - pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option { - match self.field(id, tag)? { - Value::Obj(o) => *o, - _ => None, - } - } - - /// An array of objects (nulls skipped). - pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec { - match self.field(id, tag) { - Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(), - _ => Vec::new(), - } - } - - pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option { - match self.field(id, tag)? { - Value::Float(f) if f.is_finite() => Some(*f), - _ => None, - } - } - - pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option { - match self.field(id, tag)? { - Value::Int(i) => Some(*i), - Value::UInt(u) => i64::try_from(*u).ok(), - _ => None, - } - } - - pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option { - match self.field(id, tag)? { - Value::Bool(b) => Some(*b), - _ => None, - } - } - - pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> { - match self.field(id, tag)? { - Value::Str(s) => Some(s), - _ => None, - } - } - - pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> { - match self.field(id, tag)? { - Value::Enum { id, version } => Some((*id, *version)), - _ => None, - } - } - - /// A fixed vector of `N` finite floats (points, rectangles, transforms). - pub fn floats(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> { - match self.field(id, tag)? { - Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => { - let mut a = [0.0; N]; - a.copy_from_slice(v); - Some(a) - } - _ => None, - } - } - - pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> { - match self.field(id, tag)? { - Value::Bytes(b) | Value::Blob(b) => Some(b), - _ => None, - } - } - - pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> { - match self.field(id, tag)? { - Value::Entry(s) => Some(s), - _ => None, - } - } - - /// Untagged fields of a positional object, in order. - pub fn positional(&self, id: ObjId) -> impl Iterator { - self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v)) - } -} diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs deleted file mode 100644 index f26948e91..000000000 --- a/crates/affinity/src/container.rs +++ /dev/null @@ -1,425 +0,0 @@ -//! The Affinity archive: header, the chain of file allocation tables (one per save) and -//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles. - -use std::collections::{HashMap, HashSet}; -use std::io::Read; - -use crate::Error; - -pub const MAGIC: &[u8; 4] = b"\x00\xffKA"; -/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8, -/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists. -pub const VERSIONS: std::ops::RangeInclusive = 8..=12; -/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic. -const DOCUMENT_KIND: &[u8; 4] = b"nsrP"; - -/// A save appends one table; real files have one to a few hundred. -const MAX_TABLES: usize = 4096; -/// Allocation records read over the whole table chain. Each save lists every entry again, so a -/// document with a few hundred saves of a few thousand entries stays far below; tables whose -/// record regions overlap (or a table chain of thousands of huge tables) can't multiply the work. -const MAX_INDEX_RECORDS: usize = 1 << 22; -/// Bytes of entry names kept by the index (after lossy UTF-8 decoding, which can triple a name). -/// Real names (`doc.dat`, `d/12`) are a few bytes each. -const MAX_INDEX_NAME_BYTES: usize = 16 << 20; -/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. -const MAX_ZSTD_WINDOW: u64 = 64 << 20; - -/// Decompression budget for one import. Every entry declares its size, which is checked against -/// these limits before anything is allocated, and the decoded length must match it exactly. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Limits { - /// Largest single entry (`doc.dat` or one tile). - pub max_entry: usize, - /// Total bytes extracted over the whole import. - pub max_total: usize, -} - -impl Default for Limits { - fn default() -> Self { - Self { max_entry: 256 << 20, max_total: 1 << 30 } - } -} - -/// The container header, all integers little-endian. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Header { - pub version: u16, - /// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected. - pub flags: u16, - pub(crate) fat_offset: u64, - pub(crate) thumbnail_offset: u64, - /// Save time in Unix seconds, as written by the application. - pub saved: u32, -} - -/// One FAT record, as of the newest table that mentions its id. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Entry { - pub id: u32, - pub name: String, - offset: u64, - size: u64, - compressed: u64, - crc: u32, - compression: u8, -} - -impl Entry { - /// Decompressed size declared by the table. - pub fn size(&self) -> u64 { - self.size - } -} - -#[derive(Debug)] -pub struct Archive<'a> { - bytes: &'a [u8], - pub header: Header, - /// Head revision of every named, non-deleted entry. - entries: HashMap, - /// Number of tables (saves) in the chain. - pub revisions: usize, - extracted: usize, - limits: Limits, - /// Entries whose extraction failed after their size was charged: asked again, they fail - /// again without being decompressed again. - failed: HashMap, -} - -pub(crate) struct Cursor<'a> { - bytes: &'a [u8], - pub(crate) pos: usize, -} - -impl<'a> Cursor<'a> { - pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self { - Self { bytes, pos } - } - pub(crate) fn remaining(&self) -> usize { - self.bytes.len().saturating_sub(self.pos) - } - pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> { - let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?; - let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?; - self.pos = end; - Ok(s) - } - pub(crate) fn array(&mut self) -> Result<[u8; N], Error> { - let s = self.take(N)?; - let mut a = [0u8; N]; - a.copy_from_slice(s); - Ok(a) - } - pub(crate) fn u8(&mut self) -> Result { - Ok(self.array::<1>()?[0]) - } - pub(crate) fn u16(&mut self) -> Result { - Ok(u16::from_le_bytes(self.array()?)) - } - pub(crate) fn u32(&mut self) -> Result { - Ok(u32::from_le_bytes(self.array()?)) - } - pub(crate) fn u64(&mut self) -> Result { - Ok(u64::from_le_bytes(self.array()?)) - } -} - -fn offset(v: u64) -> Result { - usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory")) -} - -/// True when `bytes` start with the Affinity container signature (documents and libraries). -pub fn is_affinity(bytes: &[u8]) -> bool { - bytes.starts_with(MAGIC) -} - -/// Check the signature, version and document kind. -pub fn header(bytes: &[u8]) -> Result { - if !is_affinity(bytes) { - return Err(Error::Malformed("missing container signature")); - } - let mut c = Cursor::new(bytes, 4); - let version = c.u16()?; - let flags = c.u16()?; - if !VERSIONS.contains(&version) { - return Err(Error::Unsupported("unknown container version")); - } - if flags & 3 != 0 { - return Err(Error::Unsupported("container variant")); - } - if c.take(4)? != DOCUMENT_KIND { - return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document")); - } - if c.take(4)? != b"#Inf" { - return Err(Error::Malformed("missing information header")); - } - let fat_offset = c.u64()?; - let thumbnail_offset = c.u64()?; - c.take(16)?; - let saved = u32::try_from(c.u64()?).unwrap_or(0); - c.take(8)?; - if c.take(4)? != b"Prot" { - return Err(Error::Malformed("missing protocol header")); - } - Ok(Header { version, flags, fat_offset, thumbnail_offset, saved }) -} - -impl<'a> Archive<'a> { - pub fn open(bytes: &'a [u8], limits: Limits) -> Result { - Self::open_within(bytes, limits, MAX_INDEX_RECORDS, MAX_INDEX_NAME_BYTES) - } - - /// [`Archive::open`] reading at most `max_records` allocation records and keeping at most - /// `max_name_bytes` bytes of entry names, both over the whole table chain. - pub(crate) fn open_within(bytes: &'a [u8], limits: Limits, max_records: usize, max_name_bytes: usize) -> Result { - let header = header(bytes)?; - let mut records_left = max_records; - let mut name_bytes_left = max_name_bytes; - // Newest record per id: (table date, table index, record). - let mut latest: HashMap)> = HashMap::new(); - let mut names: HashMap = HashMap::new(); - let mut seen = HashSet::new(); - let mut next = header.fat_offset; - let mut tables = 0usize; - while next != 0 { - if !seen.insert(next) { - return Err(Error::Malformed("allocation tables form a cycle")); - } - tables += 1; - if tables > MAX_TABLES { - return Err(Error::Limit("too many saved revisions")); - } - let mut c = Cursor::new(bytes, offset(next)?); - let tag = c.array::<4>()?; - let level = match &tag { - b"#FAT" => 1, - b"#FT2" => 2, - b"#FT3" => 3, - b"#FT4" => 4, - _ => return Err(Error::Malformed("missing allocation table")), - }; - next = c.u64()?; - let date = c.u64()?; - c.take(24)?; - let files = c.u32()?; - c.take(8)?; - let dirs = c.u16()?; - c.take(1)?; - for _ in 0..files { - records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?; - let id = c.u32()?; - let flag = c.u8()?; - let mut record = None; - match flag { - 0 | 1 => { - let offset = c.u64()?; - let size = c.u64()?; - let compressed = c.u64()?; - let crc = c.u32()?; - let stored = c.u8()?; - if level >= 2 { - c.take(4)?; - } - if level >= 4 { - c.take(4)?; - } - // Old tables number the methods; newer ones store the method byte itself. - let compression = if level <= 2 { - match stored { - 1 => 0x01, - 2 => 0x41, - 3 => 0x81, - 4 => 0xC1, - _ => 0, - } - } else { - stored - }; - record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression }); - } - 2 => {} - _ => return Err(Error::Malformed("unknown allocation record")), - } - if flag == 0 { - let len = usize::from(c.u16()?); - let raw = c.take(len)?; - // Only the first name of an id is kept: later ones are not even decoded. - if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) { - let name = String::from_utf8_lossy(raw); - name_bytes_left = name_bytes_left.checked_sub(name.len()).ok_or(Error::Limit("archive entry names too long"))?; - slot.insert(name.into_owned()); - } - } - // Equal dates: the first table in the chain (the newest) wins, as in afread. - let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d); - if replace { - latest.insert(id, (date, tables, record)); - } - } - for _ in 0..dirs { - records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?; - let len = usize::from(c.u16()?); - c.take(10)?; - c.take(len)?; - } - } - let mut entries = HashMap::new(); - for (id, (_, _, record)) in latest { - if let (Some(mut e), Some(name)) = (record, names.remove(&id)) { - e.name = name; - entries.insert(e.name.clone(), e); - } - } - Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits, failed: HashMap::new() }) - } - - pub fn entry(&self, name: &str) -> Option<&Entry> { - self.entries.get(name) - } - - pub fn entry_names(&self) -> impl Iterator { - self.entries.keys().map(String::as_str) - } - - /// Bytes extracted so far, counted against [`Limits::max_total`]. - pub fn extracted(&self) -> usize { - self.extracted - } - - /// Extract an entry by name: decompress, undo the byte predictor and verify the CRC. - /// What is left of the limits for documents embedded in this one. - pub(crate) fn remaining(&self) -> Limits { - Limits { max_entry: self.limits.max_entry, max_total: self.limits.max_total.saturating_sub(self.extracted) } - } - - /// Count `n` bytes extracted elsewhere (from embedded documents) against this import. - pub(crate) fn charge(&mut self, n: usize) -> Result<(), Error> { - let total = self.extracted.checked_add(n).ok_or(Error::Limit("document too large"))?; - if total > self.limits.max_total { - return Err(Error::Limit("document too large")); - } - self.extracted = total; - Ok(()) - } - - pub fn read(&mut self, name: &str) -> Result, Error> { - if let Some(e) = self.failed.get(name) { - return Err(*e); - } - let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?; - let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?; - if size > self.limits.max_entry { - return Err(Error::Limit("archive entry too large")); - } - let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?; - if total > self.limits.max_total { - return Err(Error::Limit("document too large")); - } - // Charged before the work, and kept when it fails: an entry that fails its checksum - // can't be decompressed over and over for free. - self.extracted = total; - let result = extract(self.bytes, e, size); - if let Err(err) = result { - self.failed.insert(name.to_owned(), err); - } - result - } -} - -/// Decompress entry `e` of `bytes` (declared `size` bytes), undo its predictor and check its CRC. -fn extract(bytes: &[u8], e: &Entry, size: usize) -> Result, Error> { - let mut c = Cursor::new(bytes, offset(e.offset)?); - if c.take(4)? != b"#Fil" { - return Err(Error::Malformed("archive entry has no data block")); - } - let method = e.compression & 3; - let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size }; - let data = c.take(offset(stored_len)?)?; - let mut out = match method { - 1 => inflate(data, size)?, - 2 => unzstd(data, size)?, - _ => data.to_vec(), - }; - if out.len() != size { - return Err(Error::Malformed("archive entry size mismatch")); - } - unpredict(&mut out, e.compression)?; - if crc32fast::hash(&out) != e.crc { - return Err(Error::Malformed("archive entry checksum mismatch")); - } - Ok(out) -} - -/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught -/// without unbounded allocation. -fn read_bounded(mut r: impl Read, size: usize) -> Result, Error> { - let mut out = Vec::with_capacity(size.min(64 << 20)); - let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1); - r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?; - Ok(out) -} - -fn inflate(data: &[u8], size: usize) -> Result, Error> { - read_bounded(flate2::read::ZlibDecoder::new(data), size) -} - -fn unzstd(data: &[u8], size: usize) -> Result, Error> { - let mut src = data; - let mut out = Vec::new(); - // Entries may hold several concatenated frames. - while !src.is_empty() { - let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?; - let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW) - .map_err(|_| Error::Malformed("corrupt compressed entry"))?; - let frame = read_bounded(decoder, left)?; - if frame.is_empty() { - return Err(Error::Malformed("corrupt compressed entry")); - } - out.extend_from_slice(&frame); - if out.len() > size { - return Err(Error::Malformed("archive entry size mismatch")); - } - } - Ok(out) -} - -/// Undo the byte predictor selected by the high bits of the compression byte. -fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> { - match (compression & 0xC0, compression & 0x20 != 0) { - (0x00, _) => {} - (0x40, _) => byte_delta(b), - (0x80, false) => { - let mut prev = 0u16; - for pair in b.as_chunks_mut::<2>().0.iter_mut() { - let v = u16::from_le_bytes([pair[0], pair[1]]).wrapping_add(prev); - pair.copy_from_slice(&v.to_le_bytes()); - prev = v; - } - } - (0x80, true) => { - byte_delta(b); - // A 64 KiB tile of 16-bit samples is stored as all high bytes, then all low bytes. - if b.len() == 0x10000 { - let planes = b.to_vec(); - let (hi, lo) = planes.split_at(0x8000); - for (i, out) in b.as_chunks_mut::<4>().0.iter_mut().enumerate() { - let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else { - return Err(Error::Malformed("tile size")); - }; - out.copy_from_slice(&[l[0], h[0], l[1], h[1]]); - } - } - } - _ => return Err(Error::Unsupported("archive entry predictor")), - } - Ok(()) -} - -fn byte_delta(b: &mut [u8]) { - let mut prev = 0u8; - for v in b { - *v = v.wrapping_add(prev); - prev = *v; - } -} diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs deleted file mode 100644 index bb1c45746..000000000 --- a/crates/affinity/src/geometry.rs +++ /dev/null @@ -1,360 +0,0 @@ -//! Outlines of curves (`PCvD` node lists), parametric shapes (`Shpe` objects) and compounds, in -//! node space. - -use crate::model::{Affine, Path, Point, Reader, SubPath}; -use crate::stream::{ObjId, Tag, Value}; - -/// Nodes per curve object. -const MAX_NODES: usize = 4_000_000; -/// A shared-object stream is bounded, but a compound graph can expand exponentially. Count -/// each visit and each curve record / generated contour / transformed segment across the whole -/// outline, including repeated references. Match the document reader's nesting limit. -const MAX_OUTLINE_DEPTH: usize = 128; -const MAX_OUTLINE_VISITS: usize = 100_000; - -struct OutlineBudget { - active: Vec, - visits: usize, - work: usize, - failed: bool, -} - -impl OutlineBudget { - fn fail(&mut self, r: &mut Reader, warning: &'static str) -> Option<()> { - if !self.failed { - r.warn(warning); - } - self.failed = true; - None - } - - fn spend(&mut self, r: &mut Reader, work: usize) -> Option<()> { - if work > self.work { - return self.fail(r, "compound outlines exceeding the geometry work limit"); - } - self.work -= work; - Some(()) - } - - fn path(&mut self, r: &mut Reader, path: &Path) -> Option<()> { - for subpath in &path.subpaths { - self.spend(r, 1)?; - self.spend(r, subpath.segments.len())?; - } - Some(()) - } -} - -/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd. -/// Instances (master-page or symbol copies) take the geometry of their master. -pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> { - let mut budget = OutlineBudget { active: Vec::new(), visits: MAX_OUTLINE_VISITS, work: MAX_NODES, failed: false }; - bounded_outline(r, id, class, world, &mut budget) -} - -fn bounded_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> { - if budget.active.contains(&id) { - budget.fail(r, "a compound outline that contains itself")?; - } - if budget.active.len() >= MAX_OUTLINE_DEPTH { - budget.fail(r, "compound outlines nested deeper than 128 levels")?; - } - if budget.visits == 0 { - budget.fail(r, "compound outlines exceeding the operand visit limit")?; - } - budget.visits -= 1; - budget.active.push(id); - let result = outline_inner(r, id, class, world, budget); - // The popped value is our own stack marker, and is no longer needed. - let _ = budget.active.pop(); - result -} - -fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> { - let s = r.s; - match &class.0.to_be_bytes() { - b"PCrv" | b"TxtC" => { - let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?; - let path = curves(r, crvs, budget)?; - budget.path(r, &path)?; - let multi = path.subpaths.len() > 1; - Some((path, multi)) - } - b"Comp" => { - // One path of all the operands' contours in the compound's space, filled even-odd: this - // matches add and subtract operands in every public sample; a true union of overlapping - // added operands would differ. - let mut path = Path::default(); - for c in s.objs(id, b"Chld") { - let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - let class = s.class(c)?; - if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) { - r.warn("compound shapes that intersect or exclude (imported as add)"); - } - let child_world = local.then(world); - if !child_world.is_finite() { - budget.fail(r, "a compound operand with an invalid transform")?; - } - if let Some((mut p, _)) = bounded_outline(r, c, class, child_world, budget) { - // Charge before transforming or extending. Transform the owned path in place - // so nested compounds do not make an additional copy of every contour. - budget.path(r, &p)?; - for subpath in &mut p.subpaths { - subpath.start = local.apply(subpath.start); - for segment in &mut subpath.segments { - *segment = segment.map(|point| local.apply(point)); - } - } - path.subpaths.extend(p.subpaths); - } else if budget.failed { - // Never present a partial compound as complete after a cycle or budget limit. - return None; - } - } - Some((path, true)) - } - _ => { - let (shape, box_owner) = match s.obj(id, b"Shpe") { - Some(sh) => (sh, id), - None => { - let master = s.obj(id, b"ShpM")?; - (s.obj(master, b"Shpe")?, master) - } - }; - let b = s.floats::<4>(box_owner, b"ShpB")?; - let path = crate::shapes::shape(r, shape, b, world)?; - budget.path(r, &path)?; - Some((path, false)) - } - } -} - -/// `PCvD.Data`: positional `[u8, u32 count, then per subpath: bool closed, 18-byte records]`. -/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the -/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first -/// anchor at the end. `CnrD` live corners are rounded on the stored polyline. -fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option { - let s = r.s; - let data = s.obj(crvs, b"Data")?; - let mut fields = s.positional(data); - fields.next()?; - let count = match fields.next()? { - Value::UInt(n) => usize::try_from(*n).ok()?, - _ => return None, - }; - budget.spend(r, count)?; - let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect())); - let mut path = Path::default(); - let mut seen = 0usize; - for index in 0..count { - let closed = match fields.next()? { - Value::Bool(b) => *b, - _ => return None, - }; - let Value::Array(records) = fields.next()? else { return None }; - seen = seen.checked_add(records.len())?; - if seen > MAX_NODES { - budget.fail(r, "curves with more than four million nodes")?; - } - budget.spend(r, records.len())?; - let anchors = anchors(records)?; - let radii = if index == 0 { corners.as_ref() } else { None }; - if let Some(sp) = subpath(&anchors, closed, radii) { - path.subpaths.push(sp); - } - } - if corners.is_some() && count > 1 { - r.warn("rounded corners on curves with several subpaths (only the first is rounded)"); - } - Some(path) -} - -/// Most segments a stroke's pressure curve may have; the curves in real documents have a handful. -const MAX_PRESSURE_SEGMENTS: usize = 256; - -/// Most regular samples of a pressure curve: each segment gets an equal share, at least 2 (its -/// middle and its end) and 8 at most. The points where a segment's width stops rising or falling -/// come on top (at most two per segment), so a curve has at most 1 + 4 × [`MAX_PRESSURE_SEGMENTS`] -/// points; the document's limit counts all of them. -const PRESSURE_POINTS: usize = 512; - -/// Most points one pressure curve can have (see [`PRESSURE_POINTS`]). A curve that can't be read -/// counts this many against the document's limit. -pub(crate) const MAX_PRESSURE_CURVE_POINTS: usize = 1 + 4 * MAX_PRESSURE_SEGMENTS; - -/// A stroke's pressure curve (`LDsc.LDeP`, a `CrvD` whose positional `Data` holds an array of -/// 18-byte records as in [`curves`]): x is the position along the path (0 to 1) and y the width as -/// a fraction of the stroke's weight. Each segment is sampled at 2 to 8 points, at most -/// [`PRESSURE_POINTS`] in all, and also where its width stops rising or falling (at most two more -/// points), so a dip between samples is kept → (position, width) in order along the path, -/// positions clamped into 0..1 and never going back, widths clamped into 0..1. `None` when the -/// curve can't be read or has more than [`MAX_PRESSURE_SEGMENTS`] segments. -pub(crate) fn pressure(r: &Reader, crvd: ObjId) -> Option> { - let s = r.s; - let data = s.obj(crvd, b"Data")?; - let records = s.positional(data).find_map(|v| if let Value::Array(a) = v { Some(a) } else { None })?; - // Every segment takes at least an anchor record. - if records.len() > MAX_PRESSURE_SEGMENTS * 3 + 1 { - return None; - } - let curve = subpath(&anchors(records)?, false, None)?; - if curve.segments.len() > MAX_PRESSURE_SEGMENTS { - return None; - } - let steps = u32::try_from((PRESSURE_POINTS / curve.segments.len().max(1)).clamp(2, 8)).unwrap_or(2); - let mut samples = vec![(curve.start.x, curve.start.y)]; - let mut from = curve.start; - for [c1, c2, to] in &curve.segments { - let mut ts: Vec = (1..=steps).map(|i| f64::from(i) / f64::from(steps)).collect(); - ts.extend(turning_points(from.y, c1.y, c2.y, to.y).into_iter().flatten()); - ts.sort_by(f64::total_cmp); - ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); - for t in ts { - let u = 1.0 - t; - let at = |a: f64, b: f64, c: f64, d: f64| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d; - samples.push((at(from.x, c1.x, c2.x, to.x), at(from.y, c1.y, c2.y, to.y))); - } - from = *to; - } - let mut last = 0.0f64; - Some( - samples - .into_iter() - .map(|(x, y)| { - last = x.clamp(last, 1.0); - (last, y.clamp(0.0, 1.0)) - }) - .collect(), - ) -} - -/// The parameters strictly between 0 and 1 where the cubic Bézier with control values `a`, `b`, -/// `c` and `d` stops rising or falling (where its derivative is 0). The values are scaled into -/// -1..1 first, so huge ones can't overflow the discriminant. -fn turning_points(a: f64, b: f64, c: f64, d: f64) -> [Option; 2] { - let scale = a.abs().max(b.abs()).max(c.abs()).max(d.abs()); - if !(scale.is_finite() && scale > 0.0) { - return [None, None]; - } - let (a, b, c, d) = (a / scale, b / scale, c / scale, d / scale); - let (d0, d1, d2) = (b - a, c - b, d - c); - let (qa, qb, qc) = (d0 - 2.0 * d1 + d2, 2.0 * (d1 - d0), d0); - let inside = |t: f64| (t.is_finite() && t > 0.0 && t < 1.0).then_some(t); - if qa.abs() < 1e-12 { - return [if qb.abs() < 1e-12 { None } else { inside(-qc / qb) }, None]; - } - let disc = qb * qb - 4.0 * qa * qc; - if disc < 0.0 { - return [None, None]; - } - let root = disc.sqrt(); - [inside((-qb - root) / (2.0 * qa)), inside((-qb + root) / (2.0 * qa))] -} - -struct Anchor { - p: Point, - inh: Option, - out: Option, - /// Index of the anchor's record (live corners refer to records). - record: usize, -} - -fn record(v: &Value) -> Option<(Point, u8)> { - let Value::Bytes(b) = v else { return None }; - if b.len() != 18 { - return None; - } - let x = f64::from_le_bytes(b.get(0..8)?.try_into().ok()?); - let y = f64::from_le_bytes(b.get(8..16)?.try_into().ok()?); - if !x.is_finite() || !y.is_finite() { - return None; - } - Some((Point { x, y }, *b.get(17)?)) -} - -fn anchors(records: &[Value]) -> Option> { - let mut out: Vec = Vec::with_capacity(records.len() / 3 + 1); - let mut pending_in = None; - for (i, v) in records.iter().enumerate() { - let (p, role) = record(v)?; - match role { - 0 => { - out.push(Anchor { p, inh: pending_in.take(), out: None, record: i }); - } - 1 => { - // A leading out-handle without an anchor is edit state only. - if let Some(a) = out.last_mut() { - a.out = Some(p); - } - } - 2 => pending_in = Some(p), - _ => return None, - } - } - Some(out) -} - -fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option { - let first = anchors.first()?; - if let Some(radii) = radii.filter(|r| !r.is_empty()) { - // The largest radius of each record, once (not a scan of all of them for each anchor). - let mut largest: std::collections::HashMap = std::collections::HashMap::new(); - for (rec, r) in radii { - let e = largest.entry(*rec).or_insert(0.0); - *e = e.max(*r); - } - let corner = |a: &Anchor| largest.get(&a.record).copied().unwrap_or(0.0); - let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p)); - if polyline { - let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect(); - // A closed subpath repeats its first anchor; its radius may be on either copy. - if closed - && pts.len() > 1 - && pts.first().map(|p| p.0) == pts.last().map(|p| p.0) - && let Some((_, r)) = pts.pop() - && let Some(f) = pts.first_mut() - { - f.1 = f.1.max(r); - } - return Some(crate::shapes::fillet(&pts, closed)); - } - } - let segments = anchors - .windows(2) - .filter_map(|w| { - let [a, b] = w else { return None }; - Some([a.out.unwrap_or(a.p), b.inh.unwrap_or(b.p), b.p]) - }) - .collect(); - Some(SubPath { start: first.p, segments, closed }) -} - -/// `CnrD`: positional `[u8, i32[] record indices, f64[] radii, u8[] types]`. -fn corner_radii(fields: Vec<&Value>) -> Option> { - let (Some(Value::Array(idx)), Some(Value::Array(rad))) = (fields.get(1), fields.get(2)) else { return None }; - Some( - idx.iter() - .zip(rad) - .filter_map(|(i, r)| match (i, r) { - (Value::Int(i), Value::Float(r)) if r.is_finite() && *r > 0.0 => Some((usize::try_from(*i).ok()?, *r)), - _ => None, - }) - .collect(), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn turning_points_of_huge_control_values_are_found() { - let small = turning_points(1.0, -3.0, 3.0, 1.0); - let huge = turning_points(1e200, -3e200, 3e200, 1e200); - assert!(small.iter().all(Option::is_some), "{small:?}"); - for (s, h) in small.iter().zip(&huge) { - let (Some(s), Some(h)) = (s, h) else { panic!("{huge:?}") }; - assert!((s - h).abs() < 1e-12, "{small:?} {huge:?}"); - } - } -} diff --git a/crates/affinity/src/lib.rs b/crates/affinity/src/lib.rs index 38d86a899..e14c1439a 100644 --- a/crates/affinity/src/lib.rs +++ b/crates/affinity/src/lib.rs @@ -1,52 +1,6 @@ -//! Reader for native Affinity documents (`.af`, and `.afdesign`/`.afphoto`/`.afpub` from -//! Affinity 1 and 2): the archive container, the tagged object stream in `doc.dat`, and the -//! subset of the document model VectorCraft imports. -//! -//! Layouts follow the MIT-licensed afread reference and were checked against public documents -//! saved by Affinity 1.x to 3.x (container versions 8 to 12); see the crate README for provenance -//! and the exact supported subset. Nothing here writes Affinity files. +//! The Affinity reader is `craft-affinity` (storytold/craft-libs). This crate keeps its name so +//! the corpus tests and fuzz targets under it, and the CI job that runs them, still build. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use std::fmt; - -mod access; -pub mod container; -mod geometry; -pub mod model; -pub mod overlay; -pub mod paint; -pub mod preview; -mod raster; -mod shapes; -pub mod stream; -#[cfg(any(test, feature = "synth"))] -pub mod synth; -mod text; - -pub use container::{Archive, Limits, MAGIC, is_affinity}; -pub use model::{Document, read}; -pub use preview::{MAX_PREVIEW_BYTES, MAX_PREVIEW_DIMENSION, Preview, preview}; - -/// Why a file could not be read. Messages are short reasons; callers add advice. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Error { - Malformed(&'static str), - Unsupported(&'static str), - Limit(&'static str), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Malformed(s) => write!(f, "damaged Affinity file: {s}"), - Self::Unsupported(s) => write!(f, "unsupported Affinity file: {s}"), - Self::Limit(s) => write!(f, "Affinity file exceeds a safety limit: {s}"), - } - } -} - -impl std::error::Error for Error {} - -#[cfg(test)] -mod tests; +pub use craft_affinity::*; diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs deleted file mode 100644 index a6d18f339..000000000 --- a/crates/affinity/src/model.rs +++ /dev/null @@ -1,471 +0,0 @@ -//! The document model the Craft apps import: spreads, artboards, layers, groups, curves, shapes, -//! text and images, with transforms composed into document space. Anything else is reported -//! by [`Document::warnings`] instead of being dropped silently. - -use std::collections::{BTreeMap, HashSet}; - -use crate::paint::{self, Paint, Stroke}; -use crate::stream::{ObjId, Stream, Tag}; -use crate::{Archive, Error, Limits, stream}; - -mod bounds; -mod read; - -pub(crate) use bounds::rect; -pub use bounds::{kind_bounds, nodes_bounds}; -pub(crate) use read::Reader; -pub use read::read; -#[cfg(test)] -pub(crate) use read::read_within; - -/// Affine map `x' = a·x + c·y + e`, `y' = b·x + d·y + f` (kurbo's coefficient order). -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Affine(pub [f64; 6]); - -impl Affine { - pub const IDENTITY: Self = Self([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); - - /// Affinity's `Xfrm`, stored row by row: (a, c, e, b, d, f). - pub(crate) fn from_xfrm(m: [f64; 6]) -> Self { - Self([m[0], m[3], m[1], m[4], m[2], m[5]]) - } - - pub fn then(self, outer: Self) -> Self { - let [a, b, c, d, e, f] = self.0; - let [oa, ob, oc, od, oe, of] = outer.0; - Self([oa * a + oc * b, ob * a + od * b, oa * c + oc * d, ob * c + od * d, oa * e + oc * f + oe, ob * e + od * f + of]) - } - - pub fn apply(&self, p: Point) -> Point { - let [a, b, c, d, e, f] = self.0; - Point { x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f } - } - - /// Geometric mean of the axis scales: how much a stroke width or font size grows. - pub fn scale(&self) -> f64 { - let [a, b, c, d, ..] = self.0; - (a * d - b * c).abs().sqrt() - } - - pub fn is_finite(&self) -> bool { - self.0.iter().all(|v| v.is_finite()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Point { - pub x: f64, - pub y: f64, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Rect { - pub x0: f64, - pub y0: f64, - pub x1: f64, - pub y1: f64, -} - -/// One cubic Bézier subpath, in document pixels. -#[derive(Debug, Clone, PartialEq)] -pub struct SubPath { - pub start: Point, - /// (control 1, control 2, end) per segment. - pub segments: Vec<[Point; 3]>, - pub closed: bool, -} - -/// An outline in document pixels. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Path { - pub subpaths: Vec, -} - -impl Path { - pub fn transformed(&self, m: Affine) -> Self { - Self { - subpaths: self - .subpaths - .iter() - .map(|s| SubPath { - start: m.apply(s.start), - segments: s.segments.iter().map(|seg| seg.map(|p| m.apply(p))).collect(), - closed: s.closed, - }) - .collect(), - } - } -} - -/// Affinity's blend modes, numbered by the `Blnd` enum. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Blend { - #[default] - Normal, - PassThrough, - Darken, - DarkerColor, - Multiply, - ColorBurn, - Lighten, - LighterColor, - Screen, - ColorDodge, - Add, - Overlay, - SoftLight, - HardLight, - VividLight, - PinLight, - LinearLight, - HardMix, - Difference, - Exclusion, - Subtract, - Hue, - Saturation, - Luminosity, - Color, - Average, - Negation, - Reflect, - Glow, - Erase, -} - -impl Blend { - /// The enum (id, version) pairs observed in a document whose layers are named after their mode. - fn from_enum(id: u16, version: u16) -> Option { - use Blend::*; - Some(match (id, version) { - (0, _) => Normal, - (1, 0) => Darken, - (2, 1) => DarkerColor, - (2, 0) => Multiply, - (3, 0) => ColorBurn, - (4, 0) => Lighten, - (6, 1) => LighterColor, - (5, 0) => Screen, - (6, 0) => ColorDodge, - (7, 0) => Add, - (8, 0) => Overlay, - (9, 0) => SoftLight, - (10, 0) => HardLight, - (11, 0) => VividLight, - (12, 0) => PinLight, - (15, 1) => LinearLight, - (13, 0) => HardMix, - (14, 0) => Difference, - (15, 0) => Exclusion, - (16, 0) => Subtract, - (17, 0) => Hue, - (18, 0) => Saturation, - (19, 0) => Luminosity, - (20, 0) => Color, - (21, 0) => Average, - (22, 0) => Negation, - (23, 0) => Reflect, - (24, 0) => Glow, - (25, 0) => Erase, - _ => return None, - }) - } -} - -/// A run of text with one set of character attributes. -#[derive(Debug, Clone, PartialEq)] -pub struct TextRun { - pub text: String, - /// PostScript name and family of the chosen font. - pub postscript: String, - pub family: String, - /// CSS weight (400 regular, 700 bold). - pub weight: i64, - pub italic: bool, - /// Boolean OpenType feature overrides, as tags or `-tag` for disabled features. - pub features: Vec, - pub all_caps: bool, - /// Underlined characters (`Ints` slot 0 is 1; identified on underlined words in a document its - /// owner made, all other characters there and in the public corpus have 0). - pub underline: bool, - /// Struck-through characters (`Ints` slot 1 is 1; identified the same way, on prices struck - /// through in another document its owner made). - pub strikethrough: bool, - /// Font size in document pixels, before the node's transform. - pub size: f64, - /// Tracking in em. - pub tracking: f64, - /// Horizontal scale of the glyphs (1 = as designed). - pub h_scale: f64, - /// Fixed line pitch in document pixels, when the run overrides automatic leading. - pub leading: Option, - pub fill: Paint, - /// Outlined characters: the outline's paint and width (node space). - pub stroke: Option, - /// The run's paragraph: alignment, indents and spacing. - pub paragraph: Paragraph, -} - -/// Paragraph alignment, indents, spacing and leading, in node space (document pixels before the -/// node's text scale). -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Paragraph { - pub align: Align, - /// Indent of every line but the first, from the left. - pub left: f64, - pub right: f64, - /// Indent of the first line, from the left. - pub first: f64, - pub space_before: f64, - pub space_after: f64, - /// Fixed distance between baselines, when the paragraph sets one. - pub leading: Option, - /// Automatic leading as a multiple of the font's own line height (ascent plus descent). - pub auto_leading: f64, -} - -impl Default for Paragraph { - fn default() -> Self { - Self { align: Align::Left, left: 0.0, right: 0.0, first: 0.0, space_before: 0.0, space_after: 0.0, leading: None, auto_leading: 1.0 } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Align { - #[default] - Left, - Center, - Right, - Justify, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Text { - pub runs: Vec, - pub align: Align, - /// Artistic text: the first baseline's anchor point in node space (start, centre or end of the - /// line according to `align`). Frame text: the frame box in node space. - pub anchor: Point, - pub frame: Option, - /// Node space to document pixels. - pub transform: Affine, - /// Node space to document pixels for the type (its linear part matters): artistic text scales - /// with its node, frame text only with its frame's text scale. - pub text_transform: Affine, -} - -/// Vertical alignment of a table cell's text. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VAlign { - Top, - Center, - Bottom, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct TableCell { - pub runs: Vec, - /// Space between the cell edges and its text, in node space: left, top, right, bottom. - pub inset: [f64; 4], - pub valign: VAlign, - /// Part of the cell to its left (merged): it has no text of its own. - pub merged_left: bool, -} - -/// A table: a grid of cells with text, and a line on every cell edge. -#[derive(Debug, Clone, PartialEq)] -pub struct Table { - /// Column boundaries (one more than the columns), increasing, in node space. - pub columns: Vec, - /// Row boundaries (one more than the rows), increasing, in node space. - pub rows: Vec, - /// Row by row. - pub cells: Vec, - /// Vertical edges row by row, one more per row than the columns; `None` draws no line, and - /// the list is empty when the file's didn't match the grid. - pub vertical: Vec>, - /// Horizontal edges boundary by boundary (one more than the rows), one per column (empty - /// as `vertical` can be). - pub horizontal: Vec>, - /// Node space to document pixels. - pub transform: Affine, - /// Node space to document pixels for the cells' type: resizing a table moves its grid, not - /// its type. - pub text_transform: Affine, -} - -impl Table { - pub fn ncols(&self) -> usize { - self.columns.len().saturating_sub(1) - } - pub fn nrows(&self) -> usize { - self.rows.len().saturating_sub(1) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum Pixels { - /// Straight-alpha RGBA, 8 bits per channel, row by row. - Rgba8(Vec), - /// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and - /// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a - /// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals - /// into a blue cast. - Cmyka8(Vec), - /// The original file a placed image was made from (PNG, JPEG…), as embedded. - Encoded(Vec), -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Image { - pub width: u32, - pub height: u32, - pub pixels: Pixels, - /// Pixel space (0..w, 0..h) to document pixels. - pub transform: Affine, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum Kind { - /// Layer container (`Scop`). - Layer, - Group, - /// Artboard: an export rectangle and vector outline that clips its children. - /// `background` paints behind them, and `strokes` over them. - Artboard { - rect: Rect, - /// Actual clipping outline in document pixels. The artboard's export rectangle is its - /// bounding box, but a rotated or non-rectangular board still clips to this outline. - path: Path, - even_odd: bool, - background: Vec, - strokes: Vec, - }, - /// Curve, parametric shape or compound shape: an outline with its fills and strokes. - /// Children of a shape are clipped by its outline (painted over its fills, under its strokes). - Shape { - path: Path, - fills: Vec, - strokes: Vec, - even_odd: bool, - }, - Text(Text), - Table(Table), - Image(Image), - /// A master page shown on this spread's pages: pages `page..page + count` show the master - /// spread [`Document::masters`]`[master]`'s pages from `master_page` on. - MasterInstance { - master: usize, - page: usize, - master_page: usize, - count: usize, - }, - /// Something this reader does not import; its children are still imported. - Unsupported, -} - -/// A layer effect. Lengths are document pixels; `angle` is the direction the shadow is offset in -/// (radians, clockwise from +x as y points down: π/2 puts it below). -#[derive(Debug, Clone, PartialEq)] -pub enum Effect { - DropShadow { - color: crate::paint::Color, - opacity: f64, - radius: f64, - distance: f64, - angle: f64, - }, - InnerShadow { - color: crate::paint::Color, - opacity: f64, - radius: f64, - distance: f64, - angle: f64, - }, - OuterGlow { - color: crate::paint::Color, - opacity: f64, - radius: f64, - }, - /// An outline of `width` around the node's shape: `align` 0 outside, 1 inside, 2 centred. - Outline { - color: crate::paint::Color, - opacity: f64, - width: f64, - align: u16, - }, - /// Bevel and emboss: lit and shaded edges `size` wide. `angle` is the light's azimuth - /// (radians, counter-clockwise from +x with y up), `depth` Affinity's depth (5 by default); - /// `inner` is false for the outer, emboss and pillow kinds. - Bevel { - size: f64, - depth: f64, - angle: f64, - highlight: crate::paint::Color, - highlight_opacity: f64, - shadow: crate::paint::Color, - shadow_opacity: f64, - inner: bool, - }, - /// Colour overlay: everything the node paints takes `color`, laid over it in `blend` mode at - /// `opacity` (the node's own alpha is kept). - ColorOverlay { - color: crate::paint::Color, - opacity: f64, - blend: Blend, - }, - /// Gradient overlay: a colour overlay whose colour comes from `gradient` (document pixels). - GradientOverlay { - gradient: crate::paint::Gradient, - opacity: f64, - blend: Blend, - }, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Node { - pub class: Tag, - pub name: String, - pub visible: bool, - pub locked: bool, - pub opacity: f64, - pub blend: Blend, - pub kind: Kind, - /// Vector mask in document pixels: the node shows only inside it (nonzero fill). - pub mask: Option, - /// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden. - pub pixel_mask: Option, - /// Layer effects the importer can show or bake in (others are reported). - pub effects: Vec, - /// Bottom to top. - pub children: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Spread { - /// Spread bounds in document pixels (the canvas, or the union of the artboards). - pub bounds: Rect, - /// Pages of a Publisher-style spread (one, or two facing pages), in document pixels. - pub pages: Vec, - /// Transparent background; otherwise the page is white. - pub transparent: bool, - /// Bottom to top. - pub nodes: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Document { - /// Pixels per inch: all geometry is in document pixels at this resolution. - pub dpi: f64, - pub spreads: Vec, - /// Master spreads, which [`Kind::MasterInstance`] nodes show on pages. - pub masters: Vec, - /// Application that last saved the file, as stored (`Affinity 3.0.2`), when present. - pub saved_by: Option, - /// Content that was not imported or was approximated, one line per kind, deduplicated. - pub warnings: Vec, - /// The document's CMYK colour profile (an ICC file), when it embeds one: the root's `CMYP`, - /// which each CMYK pixel layer's colour settings (`Prof`) also name, with relative - /// colorimetric intent and black-point compensation in every document checked. - pub cmyk_profile: Option>, -} diff --git a/crates/affinity/src/model/bounds.rs b/crates/affinity/src/model/bounds.rs deleted file mode 100644 index 7ef9fff0b..000000000 --- a/crates/affinity/src/model/bounds.rs +++ /dev/null @@ -1,142 +0,0 @@ -use super::read::MAX_TREE_DEPTH; -use super::{Affine, Kind, Node, ObjId, Point, Rect, Stream, Tag}; - -/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames -/// and tables), in document pixels. -pub fn nodes_bounds(nodes: &[Node]) -> Option { - let mut out = None; - walk_bounds(nodes, true, 0, &mut out); - out -} - -/// The bounds of `node` and everything in it, hidden or not. -pub(super) fn tree_bounds(node: &Node) -> Option { - let mut out = None; - walk_bounds(std::slice::from_ref(node), false, 0, &mut out); - out -} - -fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option) { - if depth > MAX_TREE_DEPTH { - return; - } - for n in nodes.iter().filter(|n| n.visible || !visible_only) { - kind_points(&n.kind, &mut |p| add_point(out, p)); - walk_bounds(&n.children, visible_only, depth + 1, out); - } -} - -/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they -/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here: -/// artistic text, whose glyphs only the layout places. -pub(super) fn mask_extent(kind: &Kind, children: &[Node]) -> Option { - fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option) -> bool { - if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) { - return false; - } - let mut own = kind_bounds(kind); - if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) { - // As far as a stroke can reach out of its path, miter joins included. - let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max); - own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach }); - } - if let Some(r) = own { - *out = Some(out.map_or(r, |o| union(o, r))); - } - children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out)) - } - let mut out = None; - if visit(kind, children, 0, &mut out) { out } else { None } -} - -/// The bounds of what one node's own kind draws (not its children): its outline's points, its -/// image's or frame's corners, or artistic text's anchor. -pub fn kind_bounds(kind: &Kind) -> Option { - let mut out = None; - kind_points(kind, &mut |p| add_point(&mut out, p)); - out -} - -fn add_point(out: &mut Option, p: Point) { - if p.x.is_finite() && p.y.is_finite() { - let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; - *out = Some(out.map_or(r, |o| union(o, r))); - } -} - -fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) { - let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y })); - match kind { - Kind::Shape { path, .. } | Kind::Artboard { path, .. } => { - for sp in &path.subpaths { - add(sp.start); - for seg in &sp.segments { - seg.iter().for_each(|p| add(*p)); - } - } - } - Kind::Image(img) => { - corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add) - } - Kind::Text(t) => match t.frame { - Some(f) => corners(f, t.transform).into_iter().for_each(add), - None => add(t.transform.apply(t.anchor)), - }, - Kind::Table(t) => { - if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) { - corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add); - } - } - Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} - } -} - -/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` -/// equal pages (Publisher 1 facing spreads). -pub(super) fn pages(s: &Stream, spread: ObjId) -> Vec { - if let Some(md) = s.obj(spread, b"SpMd") { - let v: Vec = - s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect(); - if !v.is_empty() { - return v; - } - } - match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) { - (Some(b), Some(n @ 2..=16)) => { - let w = (b.x1 - b.x0) / n as f64; - (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect() - } - _ => Vec::new(), - } -} - -pub(crate) fn union(a: Rect, b: Rect) -> Rect { - Rect { x0: a.x0.min(b.x0), y0: a.y0.min(b.y0), x1: a.x1.max(b.x1), y1: a.y1.max(b.y1) } -} - -pub(super) fn is_container(class: Tag) -> bool { - class == Tag::of(b"Scop") || class == Tag::of(b"Grup") -} - -pub(crate) fn rect(v: [f64; 4]) -> Rect { - Rect { x0: v[0], y0: v[1], x1: v[2], y1: v[3] } -} - -/// A readable name for a layer class in warnings. -pub(crate) fn describe(class: Tag) -> &'static str { - match &class.0.to_be_bytes() { - b"PCrv" => "curve", - b"ShpN" | b"SNEN" | b"SNRR" | b"ShRN" => "shape", - b"Comp" => "compound", - b"Rstr" => "pixel", - b"ImgN" => "image", - b"EmbN" => "embedded document", - b"MPIN" => "master page instance", - b"FRst" => "fill", - b"MRst" => "mask", - b"Slic" | b"SlcP" => "slice", - b"3DLA" => "3D", - [_, _, b'R', b'A'] => "adjustment", - _ => "unknown", - } -} diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs deleted file mode 100644 index 80f4e0993..000000000 --- a/crates/affinity/src/model/read.rs +++ /dev/null @@ -1,685 +0,0 @@ -use super::bounds::{describe, is_container, mask_extent, nodes_bounds, pages, rect, tree_bounds, union}; -use super::{ - Affine, Archive, BTreeMap, Blend, Document, Effect, Error, HashSet, Image, Kind, Limits, Node, ObjId, Paint, Path, Point, Rect, Spread, Stream, - Tag, paint, stream, -}; - -/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes). -const MAX_PROFILE_BYTES: usize = 32 << 20; - -/// Deepest layer nesting followed; deeper content is reported, not imported. -pub(super) const MAX_TREE_DEPTH: usize = 128; - -/// Total layer nodes imported. -const MAX_NODES: usize = 500_000; - -/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits -/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704). -const MAX_LAYER_PIXELS: u64 = 512 << 20; - -/// Embedded documents inside embedded documents are followed this deep. -const MAX_EMBED_DEPTH: usize = 4; - -/// Characters of all stories together (a story may be shared by many text layers). -const MAX_DOCUMENT_CHARS: usize = 16 << 20; - -/// Table cells of all tables together (each becomes a text object with its edge lines). -const MAX_DOCUMENT_CELLS: usize = 1 << 18; - -/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers). -const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20; - -/// Child references inspected, page rectangles produced, and outline segments retained, in all -/// (shared objects are visited once per link, so each visit is charged). Real documents stay -/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that. -const MAX_DOCUMENT_WORK: usize = 1 << 24; - -/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy). -const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 20; - -/// What a whole read may still use. Shared objects can be linked from many layers, and embedded -/// documents are read with what their embedding document left, so linking or embedding the -/// same content many times can't multiply the work. -#[derive(Clone, Copy, Debug)] -pub(crate) struct Budget { - /// Layer nodes ([`MAX_NODES`] in all). - pub(crate) nodes: usize, - /// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all). - pub(crate) pixels: u64, - /// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all). - pub(crate) chars: usize, - /// Table cells ([`MAX_DOCUMENT_CELLS`] in all). - pub(crate) cells: usize, - /// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all). - pub(crate) pressure: usize, - /// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all). - pub(crate) work: usize, - /// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all). - pub(crate) name_chars: usize, -} - -impl Budget { - pub(crate) const FULL: Budget = Budget { - nodes: MAX_NODES, - pixels: MAX_LAYER_PIXELS, - chars: MAX_DOCUMENT_CHARS, - cells: MAX_DOCUMENT_CELLS, - pressure: MAX_DOCUMENT_PRESSURE_POINTS, - work: MAX_DOCUMENT_WORK, - name_chars: MAX_DOCUMENT_NAME_CHARS, - }; - - /// Take `n` units of work, or fail the read. - pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> { - self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?; - Ok(()) - } -} - -/// Read a document: archive, `doc.dat` and the model. -pub fn read(bytes: &[u8], limits: Limits) -> Result { - read_within(bytes, limits, MAX_LAYER_PIXELS) -} - -/// [`read`], its pixel layers holding at most `pixels` pixels together. -pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result { - read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document -} - -/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted -/// either way. -struct ReadAt { - document: Result, - extracted: usize, -} - -/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth` -/// deep). What it uses is taken from `budget`, also when it fails part of the way. -/// The most characters of a layer's name kept. -const MAX_NAME_CHARS: usize = 1024; - -fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt { - let mut archive = match Archive::open(bytes, limits) { - Ok(a) => a, - Err(e) => return ReadAt { document: Err(e), extracted: 0 }, - }; - let document = read_archive(&mut archive, budget, base, depth); - ReadAt { document, extracted: archive.extracted() } -} - -fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result { - let doc = archive.read("doc.dat")?; - let s = stream::parse(&doc)?; - let mut reader = Reader::new(&s, archive, base, depth); - reader.budget = *budget; - let document = reader.document(); - *budget = reader.budget; - document -} - -pub(crate) struct Reader<'s, 'a, 'b> { - pub(crate) s: &'s Stream, - pub(crate) archive: &'b mut Archive<'a>, - pub(crate) dpi: f64, - pub(crate) warnings: BTreeMap, - active: HashSet, - /// Where the spreads go: the identity, or an embedding document's placement. - base: Affine, - /// The linear part of `base`, which also scales frame text. - pub(crate) type_base: Affine, - /// How deep inside embedded documents this one is. - depth: usize, - /// An embedded document's content, waiting to become its node's children. - pending: Option>, - /// The master spreads, in the order of [`Document::masters`]. - master_ids: Vec, - /// What the rest of the read may use. - pub(crate) budget: Budget, -} - -impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { - pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self { - let [a, b, c, d, ..] = base.0; - Self { - s, - archive, - dpi: 72.0, - warnings: BTreeMap::new(), - active: HashSet::new(), - base, - type_base: Affine([a, b, c, d, 0.0, 0.0]), - depth, - pending: None, - master_ids: Vec::new(), - budget: Budget::FULL, - } - } - - pub(crate) fn warn(&mut self, what: impl Into) { - *self.warnings.entry(what.into()).or_default() += 1; - } - - /// The document. - fn document(&mut self) -> Result { - let s = self.s; - let root = s.root; - self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0); - let saved_by = s.obj(root, b"NVer").map(|v| { - let product = s.str(v, b"Prod").unwrap_or("Affinity"); - match (s.int(v, b"Majr"), s.int(v, b"Minr"), s.int(v, b"Bild")) { - (Some(a), Some(b), Some(c)) => format!("{product} {a}.{b}.{c}"), - _ => product.to_string(), - } - }); - let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?; - self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect(); - let mut spreads = Vec::new(); - for spread in s.objs(doc, b"Chld") { - if let Some(sp) = self.spread(doc, spread)? { - spreads.push(sp); - } - } - if spreads.is_empty() { - return Err(Error::Malformed("document has no pages")); - } - let mut masters = Vec::new(); - for master in self.master_ids.clone() { - match self.spread(doc, master)? { - Some(sp) => masters.push(sp), - // Keep the indices of `MasterInstance::master` valid. - None => masters.push(Spread { - bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 }, - pages: Vec::new(), - transparent: true, - nodes: Vec::new(), - }), - } - } - let cmyk_profile = s - .obj(root, b"CMYP") - .and_then(|p| s.bytes(p, b"Data")) - .filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice())) - .map(<[u8]>::to_vec); - let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect(); - Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile }) - } - - /// A spread (`Sprd`) of the document `doc`, or of its master spreads. - fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result, Error> { - let s = self.s; - if !s.is(spread, b"Sprd") { - self.warn("an unknown kind of page"); - return Ok(None); - } - let pages = pages(s, spread); - self.budget.spend(pages.len() + 1)?; - let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); - let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { - x0: 0.0, - y0: 0.0, - x1: 0.0, - y1: 0.0, - }); - let transparent = s.bool(spread, b"SprT").unwrap_or(false); - let (nodes, mask) = self.children(spread, self.base, 0)?; - if mask.is_some() { - self.warn("mask layers directly on a page (imported without them)"); - } - Ok(Some(Spread { bounds, pages, transparent, nodes })) - } - - /// The children of `parent`, and the mask layer among them that masks the parent (with its - /// placement), read later over just what it masks. - #[allow(clippy::type_complexity)] - fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option<(ObjId, Affine)>), Error> { - let s = self.s; - let mut out = Vec::new(); - let mut mask = None; - let kids = s.objs(parent, b"Chld"); - // Invisible masks and skipped children cost no node, so the visit is charged here. - self.budget.spend(kids.len())?; - for c in kids { - if s.is(c, b"MRst") { - // A mask layer inside a group or layer masks that container. - if s.bool(c, b"Visi") == Some(false) { - continue; - } - let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - if mask.is_none() { - mask = Some((c, local.then(world))); - } else { - self.warn("several pixel masks on one layer (only the first is used)"); - } - continue; - } - if let Some(n) = self.node(c, world, depth + 1)? { - out.push(n); - } - } - Ok((out, mask)) - } - - fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result, Error> { - let s = self.s; - if depth > MAX_TREE_DEPTH { - self.warn("layers nested deeper than 128 levels"); - return Ok(None); - } - let Some(left) = self.budget.nodes.checked_sub(1) else { - return Err(Error::Limit("too many layers")); - }; - self.budget.nodes = left; - // A node that contains itself (a shared object linked back into its own subtree). - if !self.active.insert(id) { - self.warn("a layer that contains itself"); - return Ok(None); - } - let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - let world = local.then(parent); - let class = s.class(id).unwrap_or(Tag(0)); - // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. - let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect(); - self.budget.name_chars -= name.chars().count(); - let visible = s.bool(id, b"Visi").unwrap_or(true); - let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); - let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); - let blend = match s.enumeration(id, b"Blnd") { - None if s.bool(id, b"PasT") != Some(false) && is_container(class) => Blend::PassThrough, - None => Blend::Normal, - Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { - self.warn("an unknown blend mode (imported as Normal)"); - Blend::Normal - }), - }; - let kind = if !world.is_finite() { - self.warn("a layer with an invalid transform"); - Kind::Unsupported - } else { - self.kind(id, class, world, parent)? - }; - // This node's embedded content, before its children's `node` calls can take it. - let embedded = self.pending.take(); - // A compound's children are its operands, already merged into its outline. - let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; - if let Some(embedded) = embedded { - children.extend(embedded); - } - // Pixel masks are read only over what they mask (they can span a whole spread at print - // resolution, a gigapixel). - let over = mask_extent(&kind, &children); - let child_mask = match child_mask { - Some((c, at)) => crate::raster::mask(self, c, at, over)?, - None => None, - }; - let (mask, attached_mask) = self.attached_masks(id, world, over)?; - let pixel_mask = attached_mask.or(child_mask); - self.active.remove(&id); - let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children }; - // Gradient overlays are laid out over the node's bounds, also while it is hidden. - let bounds = tree_bounds(&node); - node.effects = self.effects(id, world, bounds); - Ok(Some(node)) - } - - fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result { - let s = self.s; - Ok(match &class.0.to_be_bytes() { - b"Scop" => Kind::Layer, - b"Grup" => Kind::Group, - b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => { - // Affinity 3.2.3's two-board public fixture marks board shapes with an `aprp` - // property object in `phrp`, while older documents use `ABEn`. Ordinary shapes - // in that fixture have neither. See the pinned corpus and validation audit. - if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) { - return Ok(self.artboard(id, world)); - } - let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else { - self.warn(format!("a {} that could not be read", describe(class))); - return Ok(Kind::Unsupported); - }; - self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?; - let (fills, strokes) = paint::node_paint(self, id, world); - Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } - } - b"TxtT" => match crate::text::table(self, id, world) { - Some(t) => Kind::Table(t), - None => { - self.warn("tables that could not be read"); - Kind::Unsupported - } - }, - b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) { - Some(t) => Kind::Text(t), - None => { - self.warn("text whose story could not be read (master-page text, or an unknown layout)"); - Kind::Unsupported - } - }, - b"ImgN" | b"Rstr" => match crate::raster::node_image(self, id, world)? { - Some(img) => Kind::Image(img), - None => Kind::Unsupported, - }, - b"MPIN" => { - // `SLnk` links the instance with the master spread it shows. - let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default(); - let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o)); - let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096); - match master { - Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) }, - None => { - self.warn("master page instances without their master"); - Kind::Unsupported - } - } - } - b"EmbN" => match self.embedded_document(id, world) { - // The embedded document itself, as editable content. - Some(nodes) => { - self.pending = Some(nodes); - Kind::Group - } - None => match crate::raster::embedded(self, id, world)? { - Some(img) => { - self.warn("embedded documents and symbols (imported as pictures of them)"); - Kind::Image(img) - } - None => { - self.warn("embedded documents and symbols without a cached picture"); - Kind::Unsupported - } - }, - }, - b"FRst" => { - // A fill layer paints its whole bitmap area; its gradient lives in page space. - let b = s.obj(id, b"Bitm"); - let size = |t: &[u8; 4]| b.and_then(|b| s.int(b, t)).map(|v| v as f64).filter(|v| *v > 0.0); - match (size(b"BmpW"), size(b"BmpH")) { - (Some(w), Some(h)) => { - let local = Path { subpaths: vec![crate::shapes::rectangle(0.0, 0.0, w, h)] }; - let fills = paint::fills(self, id, parent, b"BFFl"); - Kind::Shape { path: local.transformed(world), fills, strokes: Vec::new(), even_odd: false } - } - _ => { - self.warn("fill layers without an area"); - Kind::Unsupported - } - } - } - _ => { - self.warn(format!("{} layers", describe(class))); - Kind::Unsupported - } - }) - } - - /// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the - /// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below. - fn effects(&mut self, id: ObjId, world: Affine, bounds: Option) -> Vec { - let s = self.s; - let mut out = Vec::new(); - for e in s.objs(id, b"FiEf").into_iter().take(64) { - if s.bool(e, b"Enab") == Some(false) { - continue; - } - // "Scale with object": lengths follow the node's transform; otherwise they are absolute. - let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 }; - let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k; - let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0); - let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0); - let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c)); - let class = s.class(e).map(|c| c.0.to_be_bytes()); - match (class.as_ref(), color) { - (Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), - (Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), - (Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }), - (Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => { - let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a); - out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align }); - } - (Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"), - (Some(b"BevE" | b"EmbE"), _) => { - // No radius, no bevel (as Affinity draws it). - let size = len(b"Radi"); - if size > 0.0 { - let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; - let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; - let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white); - let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black); - let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0); - let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0); - let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0); - let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0); - out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner }); - } - } - (Some(b"ColO"), Some(color)) => { - let blend = self.effect_blend(e); - out.push(Effect::ColorOverlay { color, opacity, blend }); - } - (Some(b"GrdO"), _) => { - let blend = self.effect_blend(e); - // The gradient's unit space spans the node's bounding box from -1 to 1, centred - // on it (y down): checked against the stored previews of real documents. - let space = - bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0])); - match (s.obj(e, b"GrFl"), space) { - (Some(fill), Some(space)) => match paint::descriptor(self, fill, space) { - Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }), - Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }), - _ => {} - }, - // Nothing visible to lay it over. - (Some(_), None) => {} - (None, _) => self.warn("gradient overlay effects that could not be read (left out)"), - } - } - (Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"), - (Some(b"Gaus"), _) => self.warn("blur effects (left out)"), - _ => self.warn("layer effects of other kinds (left out)"), - } - } - for a in s.objs(id, b"AdCh") { - if let Some(effect) = self.attached_recolor(a) { - out.push(effect); - } - } - out - } - fn attached_recolor(&mut self, a: ObjId) -> Option { - let s = self.s; - if !s.is(a, b"RcRA") { - return None; - } - if s.bool(a, b"Visi") == Some(false) { - return None; - } - let adjp = s.obj(a, b"AdjP")?; - if !s.is(adjp, b"RecP") { - return None; - } - let h = s.f64(adjp, b"RecH").unwrap_or(0.0); - let sat = s.f64(adjp, b"RecS").unwrap_or(1.0); - let l = s.f64(adjp, b"RecL").unwrap_or(0.5); - let (r, g, b) = crate::paint::hsl(h, sat, l); - let color = crate::paint::Color::Rgb { r, g, b, a: 1.0 }; - let opacity = s.f64(a, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0); - let blend = self.effect_blend(a); - Some(Effect::ColorOverlay { color, opacity, blend }) - } - - /// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned). - fn effect_blend(&mut self, e: ObjId) -> Blend { - match self.s.enumeration(e, b"BlnM") { - None => Blend::Normal, - Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { - self.warn("an unknown blend mode (imported as Normal)"); - Blend::Normal - }), - } - } - - /// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines; - /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. - fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option) -> Result<(Option, Option), Error> { - let s = self.s; - let attached = s.objs(id, b"AdCh"); - let mut mask: Option = None; - let mut pixels: Option = None; - for a in attached { - let class = s.class(a).unwrap_or(Tag(0)); - if s.bool(a, b"Visi") == Some(false) { - continue; - } - if class == Tag::of(b"PCrv") || class == Tag::of(b"ShpN") || class == Tag::of(b"Comp") { - let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); - if let Some((p, _)) = crate::geometry::outline(self, a, class, local) { - mask.get_or_insert_with(Path::default).subpaths.extend(p.transformed(local).subpaths); - } - } else if class == Tag::of(b"MRst") { - let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); - match crate::raster::mask(self, a, local, over)? { - Some(m) if pixels.is_none() => pixels = Some(m), - Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), - None => {} - } - } else if class == Tag::of(b"RcRA") { - // Handled in `effects` as a colour overlay. - } else { - self.warn(format!("{} layers attached to other layers (imported without them)", describe(class))); - } - } - Ok((mask, pixels)) - } - - /// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds - /// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes, - /// placed so the centre of its page, first artboard or content is the node's origin. - fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option> { - let s = self.s; - let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string(); - if entry.is_empty() { - return None; - } - if self.depth >= MAX_EMBED_DEPTH { - self.warn("embedded documents nested more than four deep"); - return None; - } - let data = self.archive.read(&entry).ok()?; - let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?; - if !crate::is_affinity(inner) { - return None; - } - // A first reading finds its page and resolution, the second places it. - // Both readings are charged to the budget and the archive's limits, also when they fail: - // each does the work. - let mut budget = self.budget; - let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1); - self.budget = budget; - if self.archive.charge(probe.extracted).is_err() { - self.warn("embedded documents beyond the size limit (left out)"); - return None; - } - let probe = match probe.document { - Ok(d) => d, - Err(e) => { - self.warn(format!("embedded documents that could not be read ({e})")); - return None; - } - }; - let spread = probe.spreads.first()?; - // `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page. - let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2); - let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None }); - let page = if content { None } else { spread.pages.first().copied().or(first_board) }; - let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds); - // Its pixels aren't needed any more: never hold both readings' at once. - drop(probe); - // Its document pixels are the node's units, whatever either document's resolution. - let k = 1.0; - let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0); - let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world); - if !base.is_finite() { - self.warn("embedded documents with an invalid placement"); - return None; - } - let mut budget = self.budget; - let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1); - self.budget = budget; - if self.archive.charge(placed.extracted).is_err() { - self.warn("embedded documents beyond the size limit (left out)"); - return None; - } - let doc = placed.document.ok()?; - for w in doc.warnings { - self.warn(format!("{w} [in embedded documents]")); - } - if doc.spreads.len() > 1 { - self.warn("embedded documents with several pages (the first is used)"); - } - let mut nodes = doc.spreads.into_iter().next()?.nodes; - // Its master page instances count its own masters, which aren't kept: in this document - // they would show other masters. - fn strip_masters(nodes: &mut [Node], depth: usize) -> bool { - let mut found = false; - for n in nodes { - if matches!(n.kind, Kind::MasterInstance { .. }) { - n.kind = Kind::Unsupported; - found = true; - } - if depth < MAX_TREE_DEPTH { - found |= strip_masters(&mut n.children, depth + 1); - } - } - found - } - if strip_masters(&mut nodes, 0) { - self.warn("master pages of embedded documents (left out)"); - } - Some(nodes) - } - - fn artboard(&mut self, id: ObjId, world: Affine) -> Kind { - let s = self.s; - let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else { - self.warn("an artboard without valid bounds (its children are kept without clipping)"); - return Kind::Unsupported; - }; - let corners = - [Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p)); - let [a, b2, c, d, ..] = world.0; - if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 { - self.warn("a rotated or flipped artboard (export bounds are its bounding box; artwork keeps its outline)"); - } - let xs = corners.map(|p| p.x); - let ys = corners.map(|p| p.y); - let rect = Rect { - x0: xs.iter().copied().fold(f64::INFINITY, f64::min), - y0: ys.iter().copied().fold(f64::INFINITY, f64::min), - x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), - y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), - }; - if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) || rect.x1 <= rect.x0 || rect.y1 <= rect.y0 { - self.warn("an artboard with an invalid transformed size (its children are kept without clipping)"); - return Kind::Unsupported; - } - let class = s.class(id).unwrap_or(Tag::of(b"ShpN")); - let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| { - self.warn("an artboard whose outline could not be read (clipped to its rectangle)"); - (Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false) - }); - let (background, strokes) = paint::node_paint(self, id, world); - Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes } - } -} - -#[cfg(test)] -mod budget_tests { - use super::*; - - #[test] - fn shared_work_is_bounded() { - let mut b = Budget::FULL; - assert!(b.spend(MAX_DOCUMENT_WORK).is_ok()); - assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data"))); - } -} diff --git a/crates/affinity/src/overlay.rs b/crates/affinity/src/overlay.rs deleted file mode 100644 index 80f54f68e..000000000 --- a/crates/affinity/src/overlay.rs +++ /dev/null @@ -1,675 +0,0 @@ -//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what -//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's -//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps). -//! -//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid -//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow, -//! Darker/Lighter Color) use their usual definitions. - -use std::borrow::Cow; - -use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point}; -use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke}; - -/// Deepest subtree an overlay is baked into (the reader bounds trees well below this). -const MAX_DEPTH: usize = 256; - -/// Most pixels an overlay is baked into per image. -const MAX_PIXELS: usize = 64 << 20; - -/// Work the overlays of one import may take together: each overlay counts the nodes and pixels -/// of the subtree it is baked into (which is copied once more for them all). Nested overlays -/// and shared content would otherwise multiply it; past it, overlays are left out and said. -pub const MAX_WORK: u64 = 1 << 29; - -#[derive(Clone, Copy)] -enum Source<'a> { - Solid(Color), - Gradient(&'a Gradient), -} - -#[derive(Clone, Copy)] -struct Overlay<'a> { - source: Source<'a>, - opacity: f64, - blend: Blend, -} - -/// `node` with its colour and gradient overlays baked in, and those of everything in it -/// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`], -/// can't pay for copying it). `warn` hears what could only be approximated or was left out. -pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> { - if overlays(&node.effects).is_empty() { - return Cow::Borrowed(node); - } - let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else { - warn("overlay effects beyond the work one import may take (left out)"); - return Cow::Borrowed(node); - }; - *work = left; - let mut out = node.clone(); - bake_tree(&mut out, work, 0, warn); - Cow::Owned(out) -} - -/// The visible colour and gradient overlays among `effects`. -fn overlays(effects: &[Effect]) -> Vec> { - effects - .iter() - .filter_map(|e| match e { - Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }), - Effect::GradientOverlay { gradient, opacity, blend } => { - Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend }) - } - _ => None, - }) - .filter(|o| o.opacity > 0.0) - .collect() -} - -fn is_overlay(e: &Effect) -> bool { - matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. }) -} - -/// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay -/// lies over what its node shows, its children's overlays included. Each overlay pays for the -/// subtree it is baked into out of `work`; past it, it is left out (and said). -fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) { - if depth > MAX_DEPTH { - return; - } - for c in &mut node.children { - bake_tree(c, work, depth + 1, warn); - } - if !node.effects.iter().any(is_overlay) { - return; - } - let (effects, rest): (Vec, Vec) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay); - node.effects = rest; - let overlays = overlays(&effects); - if overlays.is_empty() { - return; - } - let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX); - let cost = subtree_cost(node, *work / count, 0).saturating_mul(count); - let Some(left) = work.checked_sub(cost) else { - warn("overlay effects beyond the work one import may take (left out)"); - return; - }; - *work = left; - for o in &overlays { - if matches!(o.blend, Blend::Erase | Blend::PassThrough) { - warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)"); - } - bake_node(node, o, 0, warn); - } -} - -/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`. -fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 { - if depth > MAX_DEPTH { - return 0; - } - let mut cost: u64 = match &node.kind { - Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1), - Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1), - Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1), - _ => 1, - }; - for c in &node.children { - if cost > limit { - break; - } - cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1)); - } - cost -} - -fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) { - if depth > MAX_DEPTH { - return; - } - match &mut node.kind { - Kind::Shape { fills, strokes, .. } => { - for f in fills.iter_mut() { - *f = paint(f, o, warn); - } - strokes.iter_mut().for_each(|s| stroke(s, o, warn)); - } - Kind::Artboard { background, strokes, .. } => { - for f in background.iter_mut() { - *f = paint(f, o, warn); - } - strokes.iter_mut().for_each(|s| stroke(s, o, warn)); - } - Kind::Text(t) => { - for r in &mut t.runs { - r.fill = paint(&r.fill, o, warn); - if let Some(s) = &mut r.stroke { - stroke(s, o, warn); - } - } - } - Kind::Table(t) => { - for c in &mut t.cells { - for r in &mut c.runs { - r.fill = paint(&r.fill, o, warn); - if let Some(s) = &mut r.stroke { - stroke(s, o, warn); - } - } - } - for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() { - stroke(s, o, warn); - } - } - Kind::Image(img) => { - if !pixels(img, o) { - warn("overlay effects on images too large to recolour (left out)"); - } - } - Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} - } - for e in &mut node.effects { - match e { - Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o), - Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {} - Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {} - } - } - for c in &mut node.children { - bake_node(c, o, depth + 1, warn); - } -} - -fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) { - s.paint = paint(&s.paint, o, warn); -} - -/// A paint with the overlay over it. -fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint { - match (p, o.source) { - (Paint::None, _) => Paint::None, - (Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)), - (Paint::Gradient(g), Source::Solid(s)) => { - Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() }) - } - // Over one colour, the result is the overlay's gradient with each stop laid over it. - (Paint::Solid(c), Source::Gradient(g)) => { - Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() }) - } - (Paint::Gradient(base), Source::Gradient(g)) => { - if !(o.blend == Blend::Normal && o.opacity >= 1.0) { - warn("gradient overlays on gradients (approximated)"); - } - // Exact when the overlay covers it; otherwise over the base gradient's average colour. - let avg = average(base); - Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() }) - } - } -} - -impl Source<'_> { - /// One colour for the source (a gradient's average) where only one fits. - fn at_solid(&self) -> Color { - match self { - Source::Solid(c) => *c, - Source::Gradient(g) => average(g), - } - } -} - -fn average(g: &Gradient) -> Color { - let n = g.stops.len().max(1) as f64; - let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0); - for s in &g.stops { - let [sr, sg, sb] = rgb(s.color); - r += sr; - gr += sg; - b += sb; - a += s.color.alpha(); - } - Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n } -} - -/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept. -fn over(base: Color, source: Color, o: &Overlay) -> Color { - let k = (o.opacity * source.alpha()).clamp(0.0, 1.0); - let alpha = base.alpha(); - // Normal mixes within the colour model when both share it, so CMYK stays CMYK. - if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) { - let mix = |a: f64, b: f64| a + (b - a) * k; - match (base, source) { - (Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => { - return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha }; - } - (Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha }, - (_, s) if k >= 1.0 => return with_alpha(s, alpha), - _ => {} - } - } - let b = rgb(base); - let s = rgb(source); - let blended = blend(o.blend, b, s); - let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k); - Color::Rgb { r, g, b: bl, a: alpha } -} - -fn with_alpha(c: Color, a: f64) -> Color { - match c { - Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a }, - Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a }, - Color::Gray { v, .. } => Color::Gray { v, a }, - Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a }, - } -} - -/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the -/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour. -fn rgb(c: Color) -> [f64; 3] { - let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; - match c { - Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)], - Color::Gray { v, .. } => [f(v); 3], - Color::Cmyk { c, m, y, k, .. } => { - let w = 1.0 - f(k); - [(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w] - } - Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f), - } -} - -/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65. -fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { - let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; - let (l, a, b) = (finite(l), finite(a), finite(b)); - let fy = (l + 16.0) / 116.0; - let (fx, fz) = (fy + a / 500.0, fy - b / 200.0); - let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) }; - // D50 white. - let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz)); - // XYZ (D50) to linear sRGB, Bradford-adapted. - let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z; - let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z; - let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z; - let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 }; - [gamma(r), gamma(g), gamma(bl)] -} - -/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many. -fn pixels(img: &mut Image, o: &Overlay) -> bool { - let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false }; - let Some(len) = n.checked_mul(4) else { return false }; - let mut px = match &img.pixels { - Pixels::Rgba8(p) => match p.get(..len) { - Some(p) => p.to_vec(), - None => return false, - }, - Pixels::Encoded(bytes) => match decode(bytes, img.width, img.height) { - Some(p) => p, - None => return false, - }, - // An overlay is blended in RGB: CMYK pixels take the profile-free formula here. - Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) { - Some(p) if p.len() == n * 5 => p - .as_chunks::<5>() - .0 - .iter() - .flat_map(|[c, m, y, k, a]| { - let w = 255 - u16::from(*k); - let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8; - [ch(*c), ch(*m), ch(*y), *a] - }) - .collect(), - _ => return false, - }, - }; - let width = img.width.max(1) as usize; - // A solid source is converted once; a gradient is sampled at each pixel's centre. - let solid = match o.source { - Source::Solid(c) => Some((rgb(c), c.alpha())), - Source::Gradient(_) => None, - }; - let to_unit = match o.source { - Source::Gradient(g) => match invert(g.transform) { - Some(m) => Some((img.transform.then(m), g, stop_reach(g))), - None => return false, - }, - Source::Solid(_) => None, - }; - for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() { - if p[3] == 0 { - continue; - } - let (s, sa) = match (solid, &to_unit) { - (Some(s), _) => s, - (None, Some((m, g, reach))) => { - let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 }); - let c = sample(g, reach, q); - (rgb(c), c.alpha()) - } - (None, None) => continue, - }; - let k = (o.opacity * sa).clamp(0.0, 1.0); - let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0); - let m = blend(o.blend, b, s); - for c in 0..3 { - p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8; - } - } - img.pixels = Pixels::Rgba8(px); - true -} - -/// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says. -fn decode(bytes: &[u8], width: u32, height: u32) -> Option> { - let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; - if probe.into_dimensions().ok()? != (width, height) { - return None; - } - let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; - let mut limits = image::Limits::default(); - limits.max_image_width = Some(width); - limits.max_image_height = Some(height); - limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8); - reader.limits(limits); - Some(reader.decode().ok()?.into_rgba8().into_raw()) -} - -/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset of stops `1..=k + 1`: -/// the first pair whose end offset a `t` doesn't pass is the first whose reach it doesn't pass, and -/// reaches never decrease, so [`sample`] finds it by binary search, sorted stops or not. -fn stop_reach(g: &Gradient) -> Vec { - g.stops - .iter() - .skip(1) - .scan(f64::NEG_INFINITY, |m, s| { - *m = m.max(s.offset); - Some(*m) - }) - .collect() -} - -/// The gradient's colour at unit-space point `q` (`reach`: [`stop_reach`] of `g`). -fn sample(g: &Gradient, reach: &[f64], q: Point) -> Color { - let t = match g.kind { - GradientKind::Linear => q.x, - GradientKind::Radial => q.x.hypot(q.y), - GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU, - }; - let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; - let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } }; - if t <= first.offset { - return first.color; - } - let k = reach.partition_point(|r| *r < t); - if let (Some(a), Some(b)) = (g.stops.get(k), g.stops.get(k + 1)) { - let span = b.offset - a.offset; - let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 }; - // Affinity's midpoint bias (see `Stop::half_point`). - let k = 1.0 + 8.0 * (a.bias - 0.5).abs(); - let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) }; - let (x, y) = (rgb(a.color), rgb(b.color)); - let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u); - return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u }; - } - last.color -} - -fn invert(m: Affine) -> Option { - let [a, b, c, d, e, f] = m.0; - let det = a * d - b * c; - if !(det.is_finite() && det.abs() > 1e-12) { - return None; - } - let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); - Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)])) -} - -/// The blend of backdrop `b` and source `s` (sRGB, 0..1). -fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] { - use Blend::*; - let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0)); - match mode { - Normal | PassThrough | Erase => s, - Darken => sep(f64::min), - Lighten => sep(f64::max), - Multiply => sep(|b, s| b * s), - Screen => sep(screen), - ColorBurn => sep(burn), - ColorDodge => sep(dodge), - Blend::Overlay => sep(|b, s| hard_light(s, b)), - HardLight => sep(hard_light), - SoftLight => sep(soft_light), - Difference => sep(|b, s| (b - s).abs()), - Exclusion => sep(|b, s| b + s - 2.0 * b * s), - Add => sep(|b, s| b + s), - Subtract => sep(|b, s| b - s), - VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }), - LinearLight => sep(|b, s| b + 2.0 * s - 1.0), - PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }), - HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }), - Average => sep(|b, s| (b + s) / 2.0), - Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()), - Reflect => sep(reflect), - Glow => sep(|b, s| reflect(s, b)), - DarkerColor => { - if lum(s) < lum(b) { - s - } else { - b - } - } - LighterColor => { - if lum(s) > lum(b) { - s - } else { - b - } - } - Hue => set_lum(set_sat(s, sat(b)), lum(b)), - Saturation => set_lum(set_sat(b, sat(s)), lum(b)), - Blend::Color => set_lum(s, lum(b)), - Luminosity => set_lum(b, lum(s)), - } -} - -fn screen(b: f64, s: f64) -> f64 { - b + s - b * s -} - -fn burn(b: f64, s: f64) -> f64 { - if b >= 1.0 { - 1.0 - } else if s <= 0.0 { - 0.0 - } else { - 1.0 - ((1.0 - b) / s).min(1.0) - } -} - -fn dodge(b: f64, s: f64) -> f64 { - if b <= 0.0 { - 0.0 - } else if s >= 1.0 { - 1.0 - } else { - (b / (1.0 - s)).min(1.0) - } -} - -fn hard_light(b: f64, s: f64) -> f64 { - if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) } -} - -fn soft_light(b: f64, s: f64) -> f64 { - if s <= 0.5 { - b - (1.0 - 2.0 * s) * b * (1.0 - b) - } else { - let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; - b + (2.0 * s - 1.0) * (d - b) - } -} - -fn reflect(b: f64, s: f64) -> f64 { - if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) } -} - -fn lum(c: [f64; 3]) -> f64 { - 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] -} - -fn clip_color(c: [f64; 3]) -> [f64; 3] { - let l = lum(c); - let n = c[0].min(c[1]).min(c[2]); - let x = c[0].max(c[1]).max(c[2]); - c.map(|v| { - let mut v = v; - if n < 0.0 && l - n > 1e-12 { - v = l + (v - l) * l / (l - n); - } - if x > 1.0 && x - l > 1e-12 { - v = l + (v - l) * (1.0 - l) / (x - l); - } - v.clamp(0.0, 1.0) - }) -} - -fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] { - let d = l - lum(c); - clip_color(c.map(|v| v + d)) -} - -fn sat(c: [f64; 3]) -> f64 { - c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) -} - -fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] { - let max = c[0].max(c[1]).max(c[2]); - let min = c[0].min(c[1]).min(c[2]); - if max - min <= 1e-12 { - return [0.0; 3]; - } - c.map(|v| (v - min) * s / (max - min)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn solid(c: Color, opacity: f64, blend: Blend) -> Node { - Node { - class: crate::stream::Tag::of(b"ShpN"), - name: String::new(), - visible: true, - locked: false, - opacity: 1.0, - blend: Blend::Normal, - kind: Kind::Shape { - path: Default::default(), - fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })], - strokes: vec![], - even_odd: false, - }, - mask: None, - pixel_mask: None, - effects: vec![Effect::ColorOverlay { color: c, opacity, blend }], - children: vec![], - } - } - - fn fill(n: &Node) -> Color { - match &n.kind { - Kind::Shape { fills, .. } => match fills.first() { - Some(Paint::Solid(c)) => *c, - other => panic!("{other:?}"), - }, - other => panic!("{other:?}"), - } - } - - #[test] - fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() { - let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; - let n = apply(&solid(white, 1.0, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned(); - assert_eq!(fill(&n), white); - assert!(n.effects.is_empty(), "the overlay is baked, not kept"); - // Half way, mixed in RGB. - let n = apply(&solid(white, 0.5, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned(); - assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 }); - } - - #[test] - fn blend_modes_follow_their_formulas() { - let b = [0.25, 0.5, 1.0]; - assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]); - assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]); - assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]); - assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]); - // Color keeps the backdrop's luminosity with the source's hue and saturation. - let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]); - assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}"); - } - - #[test] - fn overlays_recolour_pixels_inside_their_alpha() { - let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal); - n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY }); - let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned(); - let Kind::Image(img) = &out.kind else { panic!() }; - assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0])); - } - - #[test] - fn a_gradient_overlay_over_one_colour_becomes_that_gradient() { - let g = Gradient { - kind: GradientKind::Linear, - stops: vec![ - Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 }, - Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 }, - ], - transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]), - }; - let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal); - n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }]; - let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned(); - let Kind::Shape { fills, .. } = &out.kind else { panic!() }; - assert_eq!(fills.first(), Some(&Paint::Gradient(g))); - } - - #[test] - fn a_group_overlay_lies_over_its_childrens_overlays() { - let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }; - let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }; - let mut group = solid(red, 0.5, Blend::Normal); - group.kind = Kind::Group; - group.children = vec![solid(blue, 1.0, Blend::Normal)]; - // The copy (2 nodes), the child's overlay (1) and the group's (2). - let mut work = 5; - let out = apply(&group, &mut work, &mut |_| {}).into_owned(); - assert_eq!(work, 0); - let child = out.children.first().unwrap(); - // Blue first, then half of the group's red over it. - assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 }); - assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked"); - // With no work left for the group's overlay, the child's still applies. - let mut warnings = Vec::new(); - let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned(); - assert_eq!(fill(out.children.first().unwrap()), blue); - assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}"); - } - - #[test] - fn overlays_stop_at_the_work_one_import_may_take() { - let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal); - n.kind = Kind::Image(Image { width: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY }); - // 16 pixels and the node, for the copy and for the overlay: 34. - let mut work = 40; - let mut warnings = Vec::new(); - assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_))); - assert_eq!((work, warnings.len()), (6, 0)); - assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_))); - assert_eq!(work, 6); - assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}"); - } -} diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs deleted file mode 100644 index c7b2acbbd..000000000 --- a/crates/affinity/src/paint.rs +++ /dev/null @@ -1,403 +0,0 @@ -//! Fills, strokes and colours. Two schemas exist: Affinity 1 (`BFil` fill, `PFil` stroke paint, -//! `LSty` stroke style) and Affinity 2/3 (`BFFl`, `LIFl` and `LILn` lists with a current index). - -use crate::model::{Affine, Reader}; -use crate::stream::{ObjId, Value}; - -/// A colour as stored: RGB and grey values are encoded in the document's RGB space (sRGB unless -/// the document embeds another profile), CMYK values go through its CMYK profile. Alpha is straight. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Color { - Rgb { - r: f64, - g: f64, - b: f64, - a: f64, - }, - Cmyk { - c: f64, - m: f64, - y: f64, - k: f64, - a: f64, - }, - Gray { - v: f64, - a: f64, - }, - /// CIE L*a*b* (D50). - Lab { - l: f64, - a: f64, - b: f64, - alpha: f64, - }, -} - -impl Color { - pub fn alpha(&self) -> f64 { - match self { - Color::Rgb { a, .. } | Color::Cmyk { a, .. } | Color::Gray { a, .. } => *a, - Color::Lab { alpha, .. } => *alpha, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GradientKind { - Linear, - /// Elliptical and radial: distance from the centre in unit space. - Radial, - /// Angle around the centre. - Conical, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Stop { - pub offset: f64, - pub color: Color, - /// Affinity's midpoint bias for the segment from this stop to the next (0.5 = even blend). - pub bias: f64, -} - -impl Stop { - /// Where, between this stop (0) and the next (1), the blend reaches half way: the bias curve - /// fitted to Affinity's renders is `t^k` (bias ≥ 0.5) or `1-(1-t)^k`, with `k = 1 + 8·|bias-0.5|`. - pub fn half_point(&self) -> f64 { - let k = 1.0 + 8.0 * (self.bias - 0.5).abs(); - let h = 0.5f64.powf(1.0 / k); - if self.bias >= 0.5 { h } else { 1.0 - h } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Gradient { - pub kind: GradientKind, - pub stops: Vec, - /// Unit space to document pixels: linear runs from unit (0,0) to (1,0); radial is the unit - /// circle; conical starts along unit +x and turns towards unit +y. - pub transform: Affine, -} - -#[derive(Debug, Clone, PartialEq, Default)] -pub enum Paint { - #[default] - None, - Solid(Color), - Gradient(Gradient), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Cap { - Butt, - Round, - Square, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Join { - Miter, - Round, - Bevel, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Align { - Center, - Inside, - Outside, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Stroke { - pub paint: Paint, - /// Width in document pixels. - pub width: f64, - pub cap: Cap, - pub join: Join, - pub miter_limit: f64, - pub align: Align, - /// Dash and gap lengths in document pixels, and the phase. - pub dash: Option<(Vec, f64)>, - /// Painted below the fill instead of above it. - pub behind: bool, - /// The pressure curve: (position 0..1 along the path, width as a fraction of `width`) in - /// order along the path, `None` for a stroke of the same width all along. - pub profile: Option>, -} - -/// Gradient stops per gradient. -const MAX_STOPS: usize = 1024; - -/// Dash and gap lengths per dash pattern (a pattern has a few; strokes and text runs copy theirs). -const MAX_DASHES: usize = 64; - -fn floats(v: &Value) -> Option> { - match v { - Value::Floats(f) => Some(f.clone()), - Value::Bytes(b) if b.len() % 4 == 0 => Some(b.as_chunks::<4>().0.iter().map(|c| f64::from(f32::from_le_bytes(*c))).collect()), - _ => None, - } -} - -/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`). -pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { - color_within(r, id, 0) -} - -/// The most spot colours that stand for one another followed. -const MAX_SPOT_CHAIN: u32 = 8; - -/// [`color`], `depth` spot colours deep: one that stands for itself (or a ring of them) ends here. -fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option { - let s = r.s; - let class = s.class(id)?; - let col = || -> Option> { - let v = floats(s.field(id, b"_col")?)?; - v.iter().all(|f| f.is_finite()).then_some(v) - }; - Some(match &class.0.to_be_bytes() { - b"RGBA" => { - let v = col()?; - Color::Rgb { r: *v.first()?, g: *v.get(1)?, b: *v.get(2)?, a: v.get(3)?.clamp(0.0, 1.0) } - } - b"HSLA" => { - let v = col()?; - let (r2, g, b) = hsl(*v.first()?, *v.get(1)?, *v.get(2)?); - Color::Rgb { r: r2, g, b, a: v.get(3)?.clamp(0.0, 1.0) } - } - b"CMYK" => { - let v = col()?; - Color::Cmyk { c: *v.first()?, m: *v.get(1)?, y: *v.get(2)?, k: *v.get(3)?, a: v.get(4)?.clamp(0.0, 1.0) } - } - b"GRAY" => { - let v = col()?; - Color::Gray { v: *v.first()?, a: v.get(1)?.clamp(0.0, 1.0) } - } - b"LABA" => { - let b = s.bytes(id, b"_col")?; - let u = |i: usize| -> Option { Some(f64::from(u16::from_le_bytes([*b.get(2 * i)?, *b.get(2 * i + 1)?])) / 65535.0) }; - Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? } - } - b"Pant" => { - r.warn("spot colours (imported as their RGB equivalent)"); - if depth >= MAX_SPOT_CHAIN { - return None; - } - return s - .obj(id, b"srgb") - .and_then(|c| color_within(r, c, depth + 1)) - .or_else(|| s.obj(id, b"base").and_then(|c| color_within(r, c, depth + 1))); - } - b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 }, - _ => { - r.warn("colours in an unknown model"); - return None; - } - }) -} - -pub(crate) fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) { - let c = (1.0 - (2.0 * l - 1.0).abs()) * s; - let hp = (h.rem_euclid(1.0)) * 6.0; - let x = c * (1.0 - (hp % 2.0 - 1.0).abs()); - let (r, g, b) = match hp as u32 { - 0 => (c, x, 0.0), - 1 => (x, c, 0.0), - 2 => (0.0, c, x), - 3 => (0.0, x, c), - 4 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - let m = l - c / 2.0; - (r + m, g + m, b + m) -} - -/// A fill descriptor (`FDsc`) or, in Affinity 1 files, a bare `FilS`; `local` maps the node's -/// space to document pixels. -pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option { - let s = r.s; - let (fill, unit) = if s.is(id, b"FDsc") { - let unit = s.floats::<6>(id, b"FDeX").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - (s.obj(id, b"FDeF")?, unit) - } else { - (id, Affine::IDENTITY) - }; - match &s.class(fill)?.0.to_be_bytes() { - b"FilN" => Some(Paint::None), - b"FilS" => Some(Paint::Solid(color(r, s.obj(fill, b"Colr")?)?)), - b"FilG" => { - let kind = match s.enumeration(fill, b"Type").map(|(i, _)| i) { - Some(0) | None => GradientKind::Linear, - Some(1 | 2) => GradientKind::Radial, - Some(3) => GradientKind::Conical, - Some(_) => { - r.warn("gradients of an unknown kind (imported as linear)"); - GradientKind::Linear - } - }; - let grad = s.obj(fill, b"Grad")?; - let cols = s.objs(grad, b"Cols"); - // Convert only the positions that pair with a colour, as shared attributes repeat this per run. - let posn: &[Value] = match s.field(grad, b"Posn") { - Some(Value::Array(a)) => a, - _ => &[], - }; - let mut stops = Vec::new(); - for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) { - let color = color(r, *c)?; - let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); - let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0); - stops.push(Stop { offset, color, bias }); - } - if stops.is_empty() { - return Some(Paint::None); - } - Some(Paint::Gradient(Gradient { kind, stops, transform: unit.then(local) })) - } - b"FilB" => { - r.warn("bitmap and pattern fills (left empty)"); - Some(Paint::None) - } - _ => { - r.warn("fills of an unknown kind (left empty)"); - Some(Paint::None) - } - } -} - -fn current(r: &Reader, id: ObjId, list: &[u8; 4], index: &[u8; 4]) -> Option { - let items = r.s.objs(id, list); - let i = r.s.int(id, index).and_then(|i| usize::try_from(i).ok()).unwrap_or(0); - items.get(i).or_else(|| items.first()).copied() -} - -/// The fills of `list` (artboard backgrounds use `BFFl`). -pub(crate) fn fills(r: &mut Reader, id: ObjId, local: Affine, list: &[u8; 4]) -> Vec { - let d = current(r, id, list, b"BFCr").or_else(|| r.s.obj(id, b"BFil")); - d.and_then(|d| descriptor(r, d, local)).filter(|p| *p != Paint::None).into_iter().collect() -} - -/// A node's fill and stroke. `local` maps the node's space to document pixels. -pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec, Vec) { - let s = r.s; - let fills = fills(r, id, local, b"BFFl"); - if r.s.objs(id, b"BFFl").len() > 1 || r.s.objs(id, b"LIFl").len() > 1 { - r.warn("several fills or strokes on one object (only the active one is imported)"); - } - if s.f64(id, b"FOpc").is_some_and(|o| o < 0.999) { - r.warn("fill opacity (imported at full fill opacity)"); - } - if s.obj(id, b"Trns").is_some() { - r.warn("transparency gradients (imported without them)"); - } - let (pen, style) = if s.field(id, b"LIFl").is_some() { - (current(r, id, b"LIFl", b"LICr"), current(r, id, b"LILn", b"LICr")) - } else { - (s.obj(id, b"PFil"), s.obj(id, b"LSty")) - }; - let behind = matches!(s.field(id, b"DrwO"), Some(Value::Array(a)) if a.first() == Some(&Value::Enum { id: 1, version: 0 })); - let stroke = match (pen, style) { - (Some(pen), Some(style)) => stroke(r, pen, style, local, behind), - _ => None, - }; - (fills, stroke.into_iter().collect()) -} - -pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option { - let s = r.s; - let paint = descriptor(r, pen, local)?; - if paint == Paint::None { - return None; - } - let lsty = if s.is(ldsc, b"LDsc") { s.obj(ldsc, b"LDeL")? } else { ldsc }; - let weight = s.f64(lsty, b"Wght").filter(|w| *w > 0.0 && *w < 1e7)?; - // "Scale with object": the weight follows the object's transform; otherwise it is absolute. - let scales = s.bool(ldsc, b"LDSc").unwrap_or(true); - let width = if scales { weight * local.scale() } else { weight }; - let data = s.bytes(lsty, b"Data").unwrap_or_default(); - // Byte 10 is the stroke's style: 0 none, 1 solid, 2 dashed, 3 a brush. A shape without a - // stroke still stores one (black, 1 px wide), with style 0. Identified on two documents their - // owner made, where every such stroke is invisible in Affinity's own PDF export, and checked - // against the public corpus's thumbnails. - if data.get(10) == Some(&0) { - return None; - } - let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0); - // Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample; - // (2, 2) ends plain lines square and brush lines round; joins import round, which never - // looks wrong. - let (cap, join) = match (data.get(8).copied(), data.get(9).copied(), data.get(10).copied()) { - (Some(0), Some(0), _) => (Cap::Butt, Join::Miter), - (Some(0), Some(_), _) => (Cap::Butt, Join::Round), - (Some(2), Some(2), Some(3)) => (Cap::Round, Join::Round), - (Some(2), Some(2), _) => (Cap::Square, Join::Round), - _ => (Cap::Round, Join::Round), - }; - let align = match s.int(ldsc, b"LDSa") { - Some(1) => Align::Inside, - Some(2) => Align::Outside, - _ => Align::Center, - }; - let dash = match s.field(lsty, b"Patn") { - Some(Value::Array(a)) if a.len() > MAX_DASHES => { - r.warn("dash patterns of more than 64 lengths (imported as solid lines)"); - None - } - Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => { - let mut v: Vec = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect(); - while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) { - v.truncate(v.len() - 2); - } - let valid = !v.is_empty() && v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0); - valid.then(|| (v.iter().map(|x| x * width).collect(), s.f64(lsty, b"Phse").unwrap_or(0.0) * width)) - } - _ => None, - }; - if s.obj(lsty, b"Brus").is_some() { - r.warn("brush strokes (imported as plain strokes)"); - } - // The pressure curve sets the width along the path; one at full width all along is a plain - // stroke. Every curve read counts against the document's points: one at full width counts its - // points, and one that can't be read counts the most a curve may have. Once the points are - // used up the rest are not read. - const BEYOND: &str = "pressure strokes beyond the pressure points one document may hold (imported as plain strokes)"; - let ldep = s.obj(ldsc, b"LDeP"); - if ldep.is_some() && r.budget.pressure == 0 { - r.warn(BEYOND); - } - let profile = match ldep.filter(|_| r.budget.pressure > 0).map(|p| crate::geometry::pressure(r, p)) { - Some(Some(points)) => match r.budget.pressure.checked_sub(points.len()) { - Some(left) => { - r.budget.pressure = left; - (!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-9)).then_some(points) - } - None => { - r.budget.pressure = 0; - r.warn(BEYOND); - None - } - }, - Some(None) => { - r.budget.pressure = r.budget.pressure.saturating_sub(crate::geometry::MAX_PRESSURE_CURVE_POINTS); - r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)"); - None - } - None => None, - }; - Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind, profile }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hsl_lightness_1_is_white_and_0_is_black() { - assert_eq!(hsl(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)); - assert_eq!(hsl(0.5, 1.0, 1.0), (1.0, 1.0, 1.0)); - assert_eq!(hsl(0.0, 1.0, 0.0), (0.0, 0.0, 0.0)); - let (r, g, b) = hsl(0.0, 1.0, 0.5); - assert!((r - 1.0).abs() < 1e-6 && g.abs() < 1e-6 && b.abs() < 1e-6); - } -} diff --git a/crates/affinity/src/preview.rs b/crates/affinity/src/preview.rs deleted file mode 100644 index d3341eeb1..000000000 --- a/crates/affinity/src/preview.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the -//! document, at most 512 pixels on the long side in real files, never the document itself. - -use crate::Error; -use crate::container::header; - -pub const MAX_PREVIEW_BYTES: usize = 16 << 20; -pub const MAX_PREVIEW_DIMENSION: u32 = 4096; - -/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions. -#[derive(Debug)] -pub struct Preview<'a> { - pub png: &'a [u8], - pub width: u32, - pub height: u32, -} - -fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> { - let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?; - bytes.get(offset..end).ok_or(Error::Malformed("truncated record")) -} - -fn le32(bytes: &[u8], offset: usize) -> Result { - let b = bytes_at(bytes, offset, 4)?; - Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?)) -} - -fn be32(bytes: &[u8], offset: usize) -> Result { - let b = bytes_at(bytes, offset, 4)?; - Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?)) -} - -/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed -/// pixels must still be validated by the consuming image decoder, with allocation limits. -pub fn preview(bytes: &[u8]) -> Result, Error> { - let h = header(bytes)?; - if h.thumbnail_offset == 0 { - return Err(Error::Unsupported("no embedded preview")); - } - let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?; - if offset < 72 { - return Err(Error::Malformed("thumbnail overlaps the file header")); - } - let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?; - if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" { - return Err(Error::Malformed("missing indexed thumbnail")); - } - if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] { - return Err(Error::Unsupported("thumbnail record version or encoding")); - } - let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?; - if size > MAX_PREVIEW_BYTES { - return Err(Error::Limit("encoded preview exceeds 16 MiB")); - } - if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 { - return Err(Error::Malformed("inconsistent thumbnail record lengths")); - } - let png = bytes_at(record, 29, size)?; - if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" { - return Err(Error::Malformed("thumbnail is not a PNG")); - } - let width = be32(png, 16)?; - let height = be32(png, 20)?; - if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION { - return Err(Error::Limit("preview dimensions must be 1..4096")); - } - // Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG. - let mut pos = 8usize; - let (mut palette, mut image_data, mut image_data_ended) = (false, false, false); - loop { - let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?; - let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?; - let chunk = bytes_at(png, pos, chunk_size)?; - let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?; - if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? { - return Err(Error::Malformed("PNG chunk checksum")); - } - let kind = bytes_at(chunk, 4, 4)?; - if !kind.iter().all(u8::is_ascii_alphabetic) { - return Err(Error::Malformed("PNG chunk type")); - } - match kind { - b"IHDR" if pos == 8 => {} - b"IHDR" => return Err(Error::Malformed("duplicate PNG header")), - b"PLTE" => { - if palette || image_data { - return Err(Error::Malformed("PNG palette order")); - } - palette = true; - } - b"IDAT" => { - if image_data_ended { - return Err(Error::Malformed("non-contiguous PNG image data")); - } - image_data = true; - } - b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")), - b"IEND" => {} - _ if kind.first().is_some_and(u8::is_ascii_uppercase) => { - return Err(Error::Unsupported("unknown critical PNG chunk")); - } - _ => {} - } - if image_data && kind != b"IDAT" { - image_data_ended = true; - } - // Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not - // necessarily bound aggregate compressed metadata; leave these layouts unsupported. - if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") { - return Err(Error::Unsupported("compressed PNG metadata in the preview")); - } - if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") { - return Err(Error::Unsupported("animated PNG preview")); - } - pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?; - if kind == b"IEND" { - if length != 0 || pos != png.len() { - return Err(Error::Malformed("PNG end or trailing data")); - } - break; - } - } - Ok(Preview { png, width, height }) -} diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs deleted file mode 100644 index bb8c1c88b..000000000 --- a/crates/affinity/src/raster.rs +++ /dev/null @@ -1,650 +0,0 @@ -//! Pixel layers (`Rstr`) and placed images (`ImgN`). A placed image keeps its original file -//! (`DyBm.Bckg` → archive entry `c/N`), which is imported as is; other pixels are planar tiles of -//! 256 bytes × 256 rows per channel, each its own archive entry. - -use std::collections::HashMap; - -use crate::Error; -use crate::model::{Affine, Image, Pixels, Reader}; -use crate::stream::{self, ObjId, Tag, Value}; - -/// Pixels decoded per layer (the content's bounding box, not the canvas). -const MAX_PIXELS: u64 = 64 << 20; -const TILE: usize = 256; -const TILE_BYTES: usize = TILE * TILE; - -struct CachedPixels { - tiles: HashMap>, - source: Option<(usize, Vec)>, -} - -pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result, Error> { - let s = r.s; - let Some(bitmap) = s.obj(id, b"Bitm") else { - r.warn("pixel layers without pixels"); - return Ok(None); - }; - if !s.is(bitmap, b"DyBm") { - r.warn("pixel layers stored in an unknown way"); - return Ok(None); - } - let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - if width == 0 || height == 0 { - return Ok(None); - } - // A placed image: the original file, byte for byte. - let has_tiles = s.field(bitmap, b"Sta1").is_some(); - if let Some(name) = s.entry(bitmap, b"Bckg").filter(|n| !n.is_empty()) - && (s.is(id, b"ImgN") || !has_tiles) - { - match original(r, name) { - // HEIF/HEIC (phone photos) and AVIF originals can't be decoded here: the same pixels - // stored as tiles are used instead (see `decode` when the tiles point back at the original). - Ok(bytes) if is_heif(&bytes) && has_tiles => {} - Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })), - Err(Error::Limit(e)) => return Err(Error::Limit(e)), - Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"), - } - } - // The content's bounding box, so a mostly empty canvas-sized layer stays small. - let crop = s - .field(id, b"BitR") - .and_then(|v| if let Value::Ints(i) = v { Some(i.clone()) } else { None }) - .filter(|v| v.len() == 4 && v[0] > i64::from(i32::MIN) + 1) - .map(|v| { - ( - v[0].clamp(0, i64::from(width)) as u32, - v[1].clamp(0, i64::from(height)) as u32, - v[2].clamp(0, i64::from(width)) as u32, - v[3].clamp(0, i64::from(height)) as u32, - ) - }) - .unwrap_or((0, 0, width, height)); - let source_tiles_only = - (1..=4).all(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if !a.is_empty() && a.iter().all(|v| *v == Value::UInt(5)))); - if s.field(bitmap, b"Bckg").is_some() && has_tiles && !source_tiles_only && !s.is(id, b"ImgN") { - r.warn("edited placed images use their stored pixels"); - } - decode(r, bitmap, crop, world) -} - -/// An ISO base media file holding a HEIF image (HEIC, AVIF): `ftyp` with an image brand. -fn is_heif(bytes: &[u8]) -> bool { - bytes.get(4..8) == Some(b"ftyp".as_slice()) - && bytes.get(8..12).is_some_and(|brand| { - [b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis"].iter().any(|b| brand == b.as_slice()) - }) -} - -/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides), read -/// over `over` (document pixels) when given; outside its pixels a mask hides everything. -pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine, over: Option) -> Result, Error> { - let s = r.s; - let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) }; - let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - if width == 0 || height == 0 || !matches!(s.enumeration(bitmap, b"Frmt"), Some((6 | 7, _))) { - r.warn("pixel masks in an unknown format (imported without them)"); - return Ok(None); - } - let crop = match over.map(|o| crop_to(o, world, width, height)) { - Some(Some(c)) => c, - // What it masks lies outside it: all hidden, which one black pixel says as well. - Some(None) => return Ok(Some(Image { width: 1, height: 1, pixels: Pixels::Rgba8(vec![0, 0, 0, 255]), transform: world })), - None => (0, 0, width, height), - }; - decode(r, bitmap, crop, world) -} - -/// The bitmap pixels (x0, y0, x1, y1) of a `width` × `height` bitmap placed by `world` that cover -/// document rectangle `over`, one pixel wider all round; `None` if they don't overlap. -fn crop_to(over: crate::model::Rect, world: Affine, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { - let inv = invert(world)?; - let pts = [(over.x0, over.y0), (over.x1, over.y0), (over.x1, over.y1), (over.x0, over.y1)].map(|(x, y)| inv.apply(crate::model::Point { x, y })); - let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); - for p in pts { - if !(p.x.is_finite() && p.y.is_finite()) { - return Some((0, 0, width, height)); - } - (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); - } - let clamp = |v: f64, max: u32| v.clamp(0.0, f64::from(max)) as u32; - let (cx0, cy0, cx1, cy1) = - (clamp(x0.floor() - 1.0, width), clamp(y0.floor() - 1.0, height), clamp(x1.ceil() + 1.0, width), clamp(y1.ceil() + 1.0, height)); - (cx1 > cx0 && cy1 > cy0).then_some((cx0, cy0, cx1, cy1)) -} - -fn invert(m: Affine) -> Option { - let [a, b, c, d, e, f] = m.0; - let det = a * d - b * c; - if !(det.is_finite() && det.abs() > 1e-12) { - return None; - } - let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); - Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)])) -} - -/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches. -pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result, Error> { - let s = r.s; - let Some(cache) = s.obj(id, b"Bitm").and_then(|e| s.obj(e, b"Cach")) else { return Ok(None) }; - let Some(bbox) = s.obj(id, b"Bitm").and_then(|e| s.floats::<4>(e, b"ChBB")) else { return Ok(None) }; - let width = s.int(cache, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - let height = s.int(cache, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - if width == 0 || height == 0 { - return Ok(None); - } - let [x0, y0, x1, y1] = bbox; - // The instance's origin is the centre of the embedded content (checked against the - // thumbnails of documents with embedded documents and SVG files). - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - let place = Affine([(x1 - x0) / f64::from(width), 0.0, 0.0, (y1 - y0) / f64::from(height), x0 - cx, y0 - cy]).then(world); - decode(r, cache, (0, 0, width, height), place) -} - -/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document). -fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result, Error> { - decode_within(r, bitmap, crop, world, MAX_PIXELS) -} - -/// [`decode`] with at most `max_pixels` decoded pixels. -fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine, max_pixels: u64) -> Result, Error> { - let s = r.s; - let (x0, y0, x1, y1) = crop; - if x1 <= x0 || y1 <= y0 { - return Ok(None); - } - let (w, h) = (x1 - x0, y1 - y0); - // Larger layers (mostly masks of whole spreads) are read at a half, a quarter… of their - // resolution: every `step`th pixel of every `step`th row. - let mut step = 1u32; - while u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)) > max_pixels { - step *= 2; - if step > 1 << 12 { - r.warn("pixel layers too large to read (left out)"); - return Ok(None); - } - } - // Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract. - let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)); - let Some(left) = r.budget.pixels.checked_sub(pixels) else { - r.warn("pixel layers beyond the pixels one document may hold"); - return Ok(None); - }; - r.budget.pixels = left; - let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f); - let (channels, bps) = match format { - Some(0) => (4, 1), - Some(1) => (4, 2), - Some(6) => (1, 1), - Some(7) => (1, 2), - Some(4) => (5, 1), - _ => { - r.warn("pixel layers in grey, Lab or 32-bit formats"); - return Ok(None); - } - }; - let mut planes = Vec::with_capacity(channels); - // Current .af documents can keep level-zero tiles in their embedded JPEG/PNG instead of - // duplicating the pixels. State 5 is checked in the public Patchy embedded-jpeg fixture. - // Support only the observed, same-size RGBA8 image with a zero bitmap origin; other - // representations still receive the existing loss warning rather than guessing a mapping. - let needs_source = (1..=channels).any(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if a.contains(&Value::UInt(5)))); - let source = if needs_source && format == Some(0) && s.int(bitmap, b"LInf") == Some(0) && s.int(bitmap, b"TInf") == Some(0) { - if let Some(name) = s.entry(bitmap, b"Bckg") { - let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); - // The whole source is decoded for every layer that reads it, so charge it all, not only the crop. - let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else { - return Err(Error::Limit("embedded image pixels")); - }; - r.budget.pixels = left; - match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) { - Ok(pixels) => Some((width as usize, pixels)), - Err(Error::Limit(e)) => return Err(Error::Limit(e)), - Err(_) => None, - } - } else { - None - } - } else { - None - }; - // Tiles kept only in an original that can't be decoded here are left empty (transparent). Where - // a tile's colour is in the original but its alpha is stored, as in CMYK layers, it would come - // out opaque white instead: such a layer (a HEIC photo in a CMYK document) is left out, and said. - if needs_source && source.is_none() && channels > 1 { - let states = |c: usize| match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.clone(), - _ => Vec::new(), - }; - let alpha = states(channels); - let blank = (1..channels).any(|c| states(c).iter().enumerate().any(|(i, v)| *v == Value::UInt(5) && alpha.get(i) != Some(&Value::UInt(5)))); - if blank { - r.warn("placed images whose pixels are only in an original the reader can't decode, such as HEIC photos (left out)"); - return Ok(None); - } - } - if step > 1 { - // Its stored tiles must fit what is left of the import's extraction budget. - let stored: usize = (1..=channels).map(|c| tiles_in(s, bitmap, c, bps, (x0, y0, w, h)).len()).sum(); - if stored.saturating_mul(TILE_BYTES) > r.archive.remaining().max_total { - r.warn("pixel layers too large to read (left out)"); - return Ok(None); - } - r.warn("pixel layers larger than 64 megapixels (read at a lower resolution)"); - } - let mut cache = CachedPixels { tiles: HashMap::new(), source }; - for c in 1..=channels { - planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), step, &mut cache)?); - } - let (w, h) = (w.div_ceil(step), h.div_ceil(step)); - let n = (w as usize) * (h as usize); - let k = f64::from(step); - let transform = Affine([k, 0.0, 0.0, k, f64::from(x0), f64::from(y0)]).then(world); - if channels == 5 { - // Ink amounts and alpha as stored (8 bits), interleaved. - let mut cmyka = vec![0u8; n * 5]; - for (i, px) in cmyka.as_chunks_mut::<5>().0.iter_mut().enumerate() { - for (c, v) in px.iter_mut().enumerate() { - *v = planes.get(c).and_then(|p| p.get(i)).copied().unwrap_or(0); - } - } - return Ok(Some(Image { width: w, height: h, pixels: Pixels::Cmyka8(cmyka), transform })); - } - let mut rgba = vec![0u8; n * 4]; - let sample = |p: &Vec, i: usize| -> f64 { - if bps == 2 { - f64::from(u16::from_le_bytes([p.get(2 * i).copied().unwrap_or(0), p.get(2 * i + 1).copied().unwrap_or(0)])) / 65535.0 - } else { - f64::from(p.get(i).copied().unwrap_or(0)) / 255.0 - } - }; - for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { - let v: Vec = planes.iter().map(|p| sample(p, i)).collect(); - let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } else { (v[0], v[1], v[2], v[3]) }; - px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8)); - } - Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform })) -} - -/// The original image file: entry `c/N` is a stream whose root (`Blck`) holds it as `Data`. -fn original(r: &mut Reader, name: &str) -> Result, Error> { - let bytes = r.archive.read(name)?; - let s = stream::parse(&bytes)?; - let root = s.object(s.root).ok_or(Error::Malformed("embedded image"))?; - match root.get(Tag::of(b"Data")) { - Some(Value::Blob(b)) if !b.is_empty() => Ok(b.clone()), - _ => Err(Error::Malformed("embedded image without data")), - } -} - -/// Decode the source under the same pixel budget as tiled layers, checking its actual -/// dimensions before allocating decoded pixels. Only JPEG and PNG sources are supported. -fn source_pixels(bytes: &[u8], width: u32, height: u32) -> Result, Error> { - use image::{ImageFormat, ImageReader}; - use std::io::Cursor; - - if u64::from(width) * u64::from(height) > MAX_PIXELS { - return Err(Error::Limit("embedded image pixels")); - } - let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(|_| Error::Malformed("embedded image format"))?; - if !matches!(reader.format(), Some(ImageFormat::Jpeg | ImageFormat::Png)) { - return Err(Error::Unsupported("embedded source pixel format")); - } - let dimensions = ImageReader::new(Cursor::new(bytes)) - .with_guessed_format() - .map_err(|_| Error::Malformed("embedded image format"))? - .into_dimensions() - .map_err(|_| Error::Malformed("embedded image dimensions"))?; - if dimensions != (width, height) || width == 0 || height == 0 { - return Err(Error::Malformed("embedded source and bitmap dimensions differ")); - } - let mut limits = image::Limits::default(); - limits.max_image_width = Some(width); - limits.max_image_height = Some(height); - limits.max_alloc = Some(MAX_PIXELS * 8); - reader.limits(limits); - let decoded = reader.decode().map_err(|e| match e { - image::ImageError::Limits(_) => Error::Limit("embedded image decoding"), - _ => Error::Malformed("embedded image decoding"), - })?; - Ok(decoded.into_rgba8().into_raw()) -} - -fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] { - [prefix[0], prefix[1], prefix[2], b'0' + c as u8] -} - -/// Channel `c`'s tile grid: (tiles across, tiles down, each tile's state). -fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usize, Vec) { - let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); - let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); - let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE)); - let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); - // A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference. - let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE)); - let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.iter().take(tiles).map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), - _ => Vec::new(), - }; - (tw, th, states) -} - -/// The stored tiles (state 4) of channel `c` that overlap the crop, by their index in `Idx`. -fn tiles_in(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32)) -> Vec { - let (tw, th, states) = grid(s, bitmap, c, bps); - let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); - let (py0, py1) = (y0 as usize, (y0 + h) as usize); - let mut out = Vec::new(); - let mut next = 0usize; - for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) { - if *state != 4 { - continue; - } - let (ox, oy) = ((t % tw.max(1)) * TILE, (t / tw.max(1)) * TILE); - if !(ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0) { - out.push(next); - } - next += 1; - } - out -} - -/// One channel of level 0, cropped to (x0, y0, w, h) pixels and taking every `step`th pixel of -/// every `step`th row; `bps` bytes per sample. -fn plane( - r: &mut Reader, - bitmap: ObjId, - c: usize, - bps: usize, - (x0, y0, w, h): (u32, u32, u32, u32), - step: u32, - cache: &mut CachedPixels, -) -> Result, Error> { - let CachedPixels { tiles: cache, source } = cache; - let s = r.s; - let (tw, th, states) = grid(s, bitmap, c, bps); - let tiles = s.objs(bitmap, &tag(b"Idx", c)); - let at = Crop { bps, x0: x0 as usize, y0: y0 as usize, w: w as usize, h: h as usize, step: step.max(1) as usize }; - let row = at.w.div_ceil(at.step) * bps; - let mut out = vec![0u8; row * at.h.div_ceil(at.step)]; - let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); - let (py0, py1) = (y0 as usize, (y0 + h) as usize); - let mut next = 0usize; - for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) { - let (ty, tx) = (t / tw.max(1), t % tw.max(1)); - let fill = match state { - 0 | 1 => None, - 2 => Some(0xFF), - 4 => { - let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) }; - next += 1; - let (ox, oy) = (tx * TILE, ty * TILE); - if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 { - continue; - } - let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string(); - let read = |r: &mut Reader| -> Result, Error> { - let mut data = r.archive.read(&name)?; - // Older files wrap a tile in a one-field stream: header, then a 64 KiB blob. - if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") { - data = data.get(21..21 + TILE_BYTES).ok_or(Error::Malformed("pixel tile"))?.to_vec(); - } - if data.len() != TILE_BYTES { - return Err(Error::Malformed("pixel tile size")); - } - Ok(data) - }; - // A layer read at a lower resolution is too large to keep its tiles around. - if at.step > 1 { - let tile = read(r)?; - copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); - continue; - } - if !cache.contains_key(&name) { - let data = read(r)?; - cache.insert(name.clone(), data); - } - let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?; - copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); - continue; - } - 3 => { - r.warn("32-bit pixel layers"); - None - } - 5 => { - if let Some((source_width, pixels)) = source.as_ref() { - let (ox, oy) = (tx * TILE, ty * TILE); - copy(&mut out, &at, (ox, oy), |x, y| pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)); - continue; - } - r.warn("pixel layers drawn from an embedded image (left empty)"); - None - } - _ => return Err(Error::Malformed("unknown pixel tile state")), - }; - if let Some(v) = fill { - copy(&mut out, &at, (tx * TILE, ty * TILE), |_, _| v); - } - } - Ok(out) -} - -/// Where a plane's pixels come from: the crop (pixels) and the sampling step. -struct Crop { - bps: usize, - x0: usize, - y0: usize, - w: usize, - h: usize, - step: usize, -} - -/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane, keeping every -/// `step`th pixel of every `step`th row. -fn copy(out: &mut [u8], c: &Crop, (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) { - let row = c.w.div_ceil(c.step) * c.bps; - let (bx0, bx1) = (c.x0 * c.bps, (c.x0 + c.w) * c.bps); - for y in oy.max(c.y0)..(oy + TILE).min(c.y0 + c.h) { - if !(y - c.y0).is_multiple_of(c.step) { - continue; - } - let out_row = (y - c.y0) / c.step * row; - for x in ox.max(bx0)..(ox + TILE).min(bx1) { - let px = x / c.bps - c.x0; - if !px.is_multiple_of(c.step) { - continue; - } - if let Some(b) = out.get_mut(out_row + px / c.step * c.bps + x % c.bps) { - *b = at(x - ox, y - oy); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::model::Kind; - use crate::synth::{self, F, Method, tag}; - - #[test] - fn heif_originals_are_recognised_by_their_brand() { - assert!(is_heif(b"\0\0\0\x18ftypheic\0\0\0\0")); - assert!(is_heif(b"\0\0\0\x1cftypavif")); - assert!(!is_heif(b"\0\0\0\x18ftypisom"), "a video is not a photo"); - assert!(!is_heif(&png())); - assert!(!is_heif(b"ftyp")); - } - - fn png() -> Vec { - let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255])); - let mut bytes = std::io::Cursor::new(Vec::new()); - pixels.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); - bytes.into_inner() - } - - /// An original synthetic mixed source/cache bitmap. It exercises reader recovery, - /// not a claim that Affinity itself accepts the synthetic container. - fn fixture(source: &[u8], origin: i32) -> Vec { - fixture_in(source, origin, false) - } - - /// `cmyk`: a CMYK bitmap whose colour tiles are all in the source and whose alpha is stored. - fn fixture_in(source: &[u8], origin: i32, cmyk: bool) -> Vec { - let channels = if cmyk { 5 } else { 4 }; - let mut bitmap = vec![ - (tag(b"Frmt"), F::Enum(if cmyk { 4 } else { 0 }, 0)), - (tag(b"BmpW"), F::I32(300)), - (tag(b"BmpH"), F::I32(2)), - (tag(b"LInf"), F::I32(origin)), - (tag(b"TInf"), F::I32(0)), - (tag(b"Bckg"), F::Entry("c/1".into())), - ]; - for c in 1..=channels { - let states = match (cmyk, c) { - (false, _) => vec![5, 4], - (true, 5) => vec![4, 4], - (true, _) => vec![5, 5], - }; - bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2))); - bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1))); - bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(states))); - bitmap.push(( - tag(&super::tag(b"Idx", c)), - F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]), - )); - } - let mut crop = vec![0x17]; - crop.extend(tag(b"BitR").0.to_le_bytes()); - crop.extend([250i32, 0, 270, 2].into_iter().flat_map(i32::to_le_bytes)); - let raster = F::Def( - 10, - vec![tag(b"Rstr")], - vec![ - (tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)), - (tag(b"BitR"), F::Raw(crop)), - (tag(b"Xfrm"), F::F64s(vec![0.0, -2.0, 10.0, 2.0, 0.0, 20.0])), - ], - ); - let spread = F::Def(11, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![raster]))]); - let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); - let mut blob = vec![0x2d]; - blob.extend(tag(b"Data").0.to_le_bytes()); - blob.extend((source.len() as u32).to_le_bytes()); - blob.extend(source); - let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]); - let tiles: Vec<_> = [200u8, 10, 30, 255, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect(); - let names: Vec = (1..=channels).map(|c| format!("d/{c}")).collect(); - let mut entries = vec![("doc.dat", doc.as_slice(), Method::Zlib), ("c/1", block.as_slice(), Method::Zlib)]; - for (name, tile) in names.iter().zip(&tiles) { - entries.push((name.as_str(), tile.as_slice(), Method::Zlib)); - } - synth::container(&entries, None) - } - - #[test] - fn a_cmyk_layer_whose_colours_are_only_in_an_unreadable_original_is_left_out_not_white() { - let doc = crate::read(&fixture_in(b"\0\0\0\x18ftypheic not decodable", 0, true), crate::Limits::default()).unwrap(); - assert!(doc.warnings.iter().any(|w| w.starts_with("placed images whose pixels are only in an original")), "{:?}", doc.warnings); - assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported), "{:?}", doc.spreads[0].nodes[0].kind); - } - - #[test] - fn layers_over_the_pixel_budget_are_read_at_a_lower_resolution() { - let bytes = fixture(&png(), 0); - let mut archive = crate::Archive::open(&bytes, crate::Limits::default()).unwrap(); - let doc = archive.read("doc.dat").unwrap(); - let s = stream::parse(&doc).unwrap(); - let bitmap = s.objects.iter().position(|o| o.class == Tag::of(b"DyBm")).unwrap(); - let mut r = Reader::new(&s, &mut archive, Affine::IDENTITY, 0); - // 20 × 2 pixels over a budget of 10: every other pixel of every other row. - let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 10).unwrap().unwrap(); - assert_eq!((image.width, image.height), (10, 1)); - assert_eq!(image.transform, Affine([2.0, 0.0, 0.0, 2.0, 250.0, 0.0]), "each pixel covers two"); - let Pixels::Rgba8(pixels) = &image.pixels else { panic!() }; - for x in 0..10 { - let sx = 2 * x; - let expected = if sx + 250 < 256 { [((sx + 250) % 251) as u8, 0, 77, 255] } else { [200, 10, 30, 255] }; - assert_eq!(&pixels[x * 4..x * 4 + 4], &expected, "pixel {x}"); - } - assert!(r.warnings.contains_key("pixel layers larger than 64 megapixels (read at a lower resolution)")); - // Within budget, nothing changes. - let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 40).unwrap().unwrap(); - assert_eq!((image.width, image.height), (20, 2)); - } - - #[test] - fn source_tiles_and_cached_edits_survive_crop_rotation_and_tile_boundaries() { - let doc = crate::read(&fixture(&png(), 0), crate::Limits::default()).unwrap(); - assert_eq!(doc.warnings, ["edited placed images use their stored pixels"]); - let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() }; - assert_eq!((image.width, image.height), (20, 2)); - assert_eq!(image.transform, Affine([0.0, 2.0, -2.0, 0.0, 10.0, 520.0])); - let Pixels::Rgba8(pixels) = &image.pixels else { panic!() }; - for y in 0..2 { - for x in 0..20 { - let expected = if x + 250 < 256 { [((x + 250) % 251) as u8, (y * 31) as u8, 77, 255] } else { [200, 10, 30, 255] }; - assert_eq!(&pixels[(y * 20 + x) * 4..(y * 20 + x + 1) * 4], &expected); - } - } - } - - #[test] - fn unreadable_sources_and_unknown_origins_preserve_cached_edits_and_warn() { - for bytes in [fixture(b"invalid image", 0), fixture(&png(), 1)] { - let doc = crate::read(&bytes, crate::Limits::default()).unwrap(); - assert!(doc.warnings.iter().any(|w| w == "pixel layers drawn from an embedded image (left empty) (4×)")); - let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() }; - let Pixels::Rgba8(pixels) = &image.pixels else { panic!() }; - assert_eq!(&pixels[..4], &[0, 0, 0, 0]); - assert_eq!(&pixels[6 * 4..7 * 4], &[200, 10, 30, 255]); - } - } - - /// Pixel layers of one flat colour: nothing in the archive but `doc.dat` (state 2 is a tile - /// of 0xFF). Written by hand for the reader, like [`fixture`]. - fn flat_layers(count: u32, side: i32) -> Vec { - let layer = |i: u32| { - let mut bitmap = vec![(tag(b"Frmt"), F::Enum(0, 0)), (tag(b"BmpW"), F::I32(side)), (tag(b"BmpH"), F::I32(side))]; - for c in 1..=4 { - bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(1))); - bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1))); - bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![2]))); - } - F::Def(10 + i, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap))]) - }; - let spread = F::Def(1, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared((0..count).map(layer).collect()))]); - let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); - synth::container(&[("doc.dat", &doc, Method::Zlib)], None) - } - - /// #1704: the pixels of all pixel layers together are bounded, also when the archive holds - /// no data for them: the layers past the allowance are left out with a note. - #[test] - fn pixel_layers_share_one_allowance_for_the_whole_document() { - let file = flat_layers(5, 16); - let layers = |pixels: u64| { - let doc = crate::model::read_within(&file, crate::Limits::default(), pixels).unwrap(); - let n = doc.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count(); - (n, doc.warnings) - }; - assert_eq!(layers(5 * 256), (5, vec![])); - let (kept, warnings) = layers(3 * 256 + 255); - assert_eq!(kept, 3); - assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("pixel layers beyond the pixels one document may hold"), "{warnings:?}"); - assert_eq!(crate::read(&file, crate::Limits::default()).unwrap().spreads[0].nodes.len(), 5); - } - - #[test] - fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() { - let bytes = png(); - assert!(matches!(source_pixels(&bytes, 299, 2), Err(Error::Malformed(_)))); - assert!(matches!(source_pixels(&bytes, u32::MAX, u32::MAX), Err(Error::Limit(_)))); - assert_eq!(source_pixels(&bytes, 300, 2).unwrap().len(), 300 * 2 * 4); - } -} diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs deleted file mode 100644 index a2503539f..000000000 --- a/crates/affinity/src/shapes.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! Parametric shapes: Affinity stores the shape's parameters and its box, never the outline. -//! Formulas were fitted to Affinity's own renders (the document thumbnails) of public files. - -use std::f64::consts::{FRAC_PI_2, PI, TAU}; - -use crate::model::{Affine, Path, Point, Reader, SubPath}; -use crate::stream::{ObjId, Tag, Value}; - -/// Handle length of a quarter circle of radius 1. -const KAPPA: f64 = 0.552_284_749_830_793_4; -/// Vertices of a polygon or star. -const MAX_SIDES: i64 = 10_000; - -/// The last value of a tag: a shape changed from one kind to another keeps a stale first block. -fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> { - r.s.object(id)?.get_last(Tag::of(tag)) -} - -fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option { - match last(r, id, tag)? { - Value::Float(f) if f.is_finite() => Some(*f), - Value::UInt(u) => Some(*u as f64), - Value::Int(i) => Some(*i as f64), - _ => None, - } -} - -fn sides(r: &Reader, id: ObjId, tag: &[u8; 4], default: f64) -> usize { - let n = num(r, id, tag).unwrap_or(default).round(); - n.clamp(3.0, MAX_SIDES as f64) as usize -} - -fn pt(x: f64, y: f64) -> Point { - Point { x, y } -} - -fn polygon(pts: impl IntoIterator) -> Option { - let mut it = pts.into_iter(); - let start = it.next()?; - let segments = it.map(|p| [p, p, p]).collect(); - Some(SubPath { start, segments, closed: true }) -} - -/// Outline of `shape` (a `Shpe` object) in the node box `b` = (x0, y0, x1, y1). -pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> Option { - let [x0, y0, x1, y1] = b; - let (w, h) = (x1 - x0, y1 - y0); - let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, w / 2.0, h / 2.0); - let on_ellipse = |a: f64, f: f64| pt(cx + f * rx * a.cos(), cy + f * ry * a.sin()); - let class = r.s.class(shape)?; - let sub = match &class.0.to_be_bytes() { - b"ShNR" | b"ShRR" => { - // Only the four corners' entries can matter: longer arrays are read no further. - let types: Vec> = match last(r, shape, b"CTyp") { - Some(Value::Array(a)) => a.iter().take(4).map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), - _ => Vec::new(), - }; - let radii: Vec = match last(r, shape, b"ShCR") { - Some(Value::Floats(v)) => v.iter().take(4).map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), - _ => Vec::new(), - }; - // Corners follow the first one (top left) unless they were unlocked; then each has - // its own radius and type, clockwise from the top left. - let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false))); - let corners: [(f64, Option); 4] = std::array::from_fn(|i| { - let i = if locked { 0 } else { i }; - (radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten()) - }); - if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) { - polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])? - } else { - if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) { - r.warn("rectangles with concave or other corner types (imported with round corners)"); - } - let absolute = r.s.bool(shape, b"AbSz") == Some(true); - let [a, b2, c, d, ..] = world.0; - let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt()); - cornered_rect( - b, - corners.map(|(f, t)| { - let (crx, cry) = if !absolute { - (f * w.min(h), f * w.min(h)) - } else if sx > 0.0 && sy > 0.0 { - // An absolute radius is in document pixels, whatever the node's scale. - (f / sx, f / sy) - } else { - (0.0, 0.0) - }; - (crx, cry, t == Some(1)) - }), - ) - } - } - b"ShpE" => ellipse(cx, cy, rx, ry), - b"ShPy" => { - let n = sides(r, shape, b"Side", 5.0); - let a = |i: usize| -FRAC_PI_2 + TAU * i as f64 / n as f64; - if r.s.object(shape).is_some_and(|_| matches!(last(r, shape, b"Smth"), Some(Value::Bool(true)))) { - // Smoothed: tangent handles of length curv·4/3·tan(π/2n) along the inscribed ellipse. - let k = num(r, shape, b"Curv").unwrap_or(0.0) * 4.0 / 3.0 * (PI / (2.0 * n as f64)).tan(); - let v = |i: usize| on_ellipse(a(i), 1.0); - let t = |i: usize| pt(-rx * a(i).sin(), ry * a(i).cos()); - let segments = (0..n) - .map(|i| { - let (p, q, tp, tq) = (v(i), v(i + 1), t(i), t(i + 1)); - [pt(p.x + k * tp.x, p.y + k * tp.y), pt(q.x - k * tq.x, q.y - k * tq.y), q] - }) - .collect(); - SubPath { start: v(0), segments, closed: true } - } else { - polygon((0..n).map(|i| on_ellipse(a(i), 1.0)))? - } - } - b"ShSt" => { - let n = sides(r, shape, b"Pnts", 5.0); - let q = num(r, shape, b"IRad").unwrap_or(0.382).clamp(0.0, 1.0); - if [b"CrcI", b"CrcO", b"CrvL", b"CrvR"].iter().any(|t| num(r, shape, t).is_some_and(|v| v.abs() > 1e-9)) { - r.warn("stars with rounded or curved points (imported with sharp points)"); - } - polygon((0..2 * n).map(|i| on_ellipse(-FRAC_PI_2 + PI * i as f64 / n as f64, if i % 2 == 0 { 1.0 } else { q })))? - } - b"ShSS" => { - let n = sides(r, shape, b"Side", 5.0); - let ri = num(r, shape, b"COut").unwrap_or(0.5).clamp(0.0, 1.0); - let step = PI / n as f64; - let (l, hw, inner) = (step.cos(), ri * step.sin(), ri * step.cos()); - polygon((0..n).flat_map(|k| { - let phi = -FRAC_PI_2 + step + TAU * k as f64 / n as f64; - let (c, s) = (phi.cos(), phi.sin()); - [(inner, -hw), (l, -hw), (l, hw), (inner, hw)].map(|(u, v)| pt(cx + rx * (u * c - v * s), cy + ry * (u * s + v * c))) - }))? - } - b"ShPi" => { - let raw_start = num(r, shape, b"AngS").unwrap_or(0.0); - let raw_end = num(r, shape, b"AngE").unwrap_or(TAU); - // Normalize in constant time. Repeatedly adding TAU can hang on finite values - // large enough that floating-point addition no longer changes the endpoint. - let start = raw_start.rem_euclid(TAU); - let delta = raw_end - raw_start; - let sweep = if raw_end >= raw_start { - delta.min(TAU) - } else if delta.is_finite() { - delta.rem_euclid(TAU) - } else { - (raw_end.rem_euclid(TAU) - start).rem_euclid(TAU) - }; - let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0); - pie(cx, cy, rx, ry, start, start + sweep, inner) - } - b"ShpT" => { - let p = num(r, shape, b"Pos ").unwrap_or(0.5); - polygon([pt(x0 + p * w, y0), pt(x1, y1), pt(x0, y1)])? - } - b"ShTz" => { - let (l, rr) = (num(r, shape, b"PosL").unwrap_or(0.25), num(r, shape, b"PosR").unwrap_or(0.75)); - polygon([pt(x0 + l * w, y0), pt(x0 + rr * w, y0), pt(x1, y1), pt(x0, y1)])? - } - _ => { - r.warn("cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse)"); - ellipse(cx, cy, rx, ry) - } - }; - Some(Path { subpaths: vec![sub] }) -} - -pub(crate) fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> SubPath { - SubPath { start: pt(x0, y0), segments: [pt(x1, y0), pt(x1, y1), pt(x0, y1)].map(|p| [p, p, p]).to_vec(), closed: true } -} - -fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath { - let (kx, ky) = (KAPPA * rx, KAPPA * ry); - SubPath { - start: pt(cx + rx, cy), - segments: vec![ - [pt(cx + rx, cy + ky), pt(cx + kx, cy + ry), pt(cx, cy + ry)], - [pt(cx - kx, cy + ry), pt(cx - rx, cy + ky), pt(cx - rx, cy)], - [pt(cx - rx, cy - ky), pt(cx - kx, cy - ry), pt(cx, cy - ry)], - [pt(cx + kx, cy - ry), pt(cx + rx, cy - ky), pt(cx + rx, cy)], - ], - closed: true, - } -} - -/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded, -/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along -/// one side shrink in proportion until they do. -fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath { - let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r }; - let [tl, tr, br, bl] = corners; - let (w, h) = (x1 - x0, y1 - y0); - let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)]; - let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)]; - let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r)); - // (where the corner leaves the previous side, its two handles, where it joins the next side) - let arcs = [ - [pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])], - [pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)], - [pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])], - [pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)], - ]; - let mut segments = Vec::with_capacity(8); - for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) { - segments.push([s, s, s]); - if s != e { - segments.push(if straight { [e, e, e] } else { [c1, c2, e] }); - } - } - SubPath { start: pt(x0 + rx[0], y0), segments, closed: true } -} - -/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are), -/// as cubic segments of at most a quarter turn. -fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, out: &mut Vec<[Point; 3]>) { - let n = ((a1 - a0).abs() / FRAC_PI_2).ceil().clamp(1.0, 8.0) as usize; - let step = (a1 - a0) / n as f64; - let k = 4.0 / 3.0 * (step / 4.0).tan(); - let p = |a: f64| pt(cx + rx * a.cos(), cy - ry * a.sin()); - let d = |a: f64| pt(-rx * a.sin(), -ry * a.cos()); - for i in 0..n { - let (s, e) = (a0 + step * i as f64, a0 + step * (i + 1) as f64); - let (ps, pe, ds, de) = (p(s), p(e), d(s), d(e)); - out.push([pt(ps.x + k * ds.x, ps.y + k * ds.y), pt(pe.x - k * de.x, pe.y - k * de.y), pe]); - } -} - -fn pie(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, a1: f64, inner: f64) -> SubPath { - let start = pt(cx + rx * a0.cos(), cy - ry * a0.sin()); - let mut segments = Vec::new(); - arc(cx, cy, rx, ry, a0, a1, &mut segments); - if inner > 0.0 { - let p = pt(cx + inner * rx * a1.cos(), cy - inner * ry * a1.sin()); - segments.push([p, p, p]); - arc(cx, cy, inner * rx, inner * ry, a1, a0, &mut segments); - } else { - let c = pt(cx, cy); - segments.push([c, c, c]); - } - SubPath { start, segments, closed: true } -} - -/// A polyline with circular corners of the given radii (Affinity's live corners on curves). -pub(crate) fn fillet(pts: &[(Point, f64)], closed: bool) -> SubPath { - let n = pts.len(); - let get = |i: isize| -> Option { - if closed { pts.get(i.rem_euclid(n as isize) as usize).map(|p| p.0) } else { usize::try_from(i).ok().and_then(|i| pts.get(i)).map(|p| p.0) } - }; - let mut corners: Vec<(Point, Point, Point, Point)> = Vec::with_capacity(n); - for (i, (p, radius)) in pts.iter().enumerate() { - let (prev, next) = (get(i as isize - 1), get(i as isize + 1)); - let (Some(a), Some(b)) = (prev, next) else { - corners.push((*p, *p, *p, *p)); - continue; - }; - let (u1, l1) = unit(*p, a); - let (u2, l2) = unit(*p, b); - let cos = (u1.x * u2.x + u1.y * u2.y).clamp(-1.0, 1.0); - let phi = cos.acos(); - if *radius <= 0.0 || phi < 1e-6 || (PI - phi) < 1e-6 { - corners.push((*p, *p, *p, *p)); - continue; - } - let t = (radius / (phi / 2.0).tan()).min(l1 / 2.0).min(l2 / 2.0); - let r = t * (phi / 2.0).tan(); - let k = 4.0 / 3.0 * ((PI - phi) / 4.0).tan() * r; - let s = pt(p.x + u1.x * t, p.y + u1.y * t); - let e = pt(p.x + u2.x * t, p.y + u2.y * t); - corners.push((s, pt(s.x - u1.x * k, s.y - u1.y * k), pt(e.x - u2.x * k, e.y - u2.y * k), e)); - } - let Some(first) = corners.first().copied() else { return SubPath { start: pt(0.0, 0.0), segments: vec![], closed } }; - let mut segments = Vec::with_capacity(2 * n); - let mut cur = first.0; - let start = cur; - for (i, (s, c1, c2, e)) in corners.iter().enumerate() { - if i > 0 { - segments.push([cur, *s, *s]); - } - if s != e { - segments.push([*c1, *c2, *e]); - } - cur = *e; - } - if closed && cur != start { - segments.push([cur, start, start]); - } - SubPath { start, segments, closed } -} - -fn unit(from: Point, to: Point) -> (Point, f64) { - let (dx, dy) = (to.x - from.x, to.y - from.y); - let l = (dx * dx + dy * dy).sqrt(); - if l > 0.0 { (pt(dx / l, dy / l), l) } else { (pt(0.0, 0.0), 0.0) } -} diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs deleted file mode 100644 index 9d31f35e7..000000000 --- a/crates/affinity/src/stream.rs +++ /dev/null @@ -1,491 +0,0 @@ -//! The tagged object stream inside `doc.dat`: a self-describing tree of objects whose fields carry -//! a type code and a four-character tag. Parsing needs no schema; unknown fields are kept, so the -//! document mapping can name what it could not import. - -use crate::Error; -use crate::container::Cursor; - -/// A four-character tag in its readable order (`Desc`, `Chld`). -#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct Tag(pub u32); - -impl Tag { - pub const fn of(s: &[u8; 4]) -> Self { - Self(u32::from_be_bytes(*s)) - } -} - -impl std::fmt::Debug for Tag { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", String::from_utf8_lossy(&self.0.to_be_bytes())) - } -} - -impl std::fmt::Display for Tag { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Debug::fmt(self, f) - } -} - -/// Index of an object in [`Stream::objects`]. -pub type ObjId = usize; - -#[derive(Debug, Clone, PartialEq)] -pub enum Value { - Int(i64), - UInt(u64), - Float(f64), - Bool(bool), - Enum { - id: u16, - version: u16, - }, - Str(String), - /// Fixed-length vectors of i32/f32/f64 (points, rectangles, transforms). - Ints(Vec), - Floats(Vec), - /// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components). - Bytes(Vec), - Blob(Vec), - /// Name of another archive entry (raster tiles, embedded files). - Entry(String), - Flags(u64), - /// `None` is a null object. - Obj(Option), - Array(Vec), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Kind { - Root, - /// Defined once and referenced by id elsewhere. - Shared, - Inline, - /// Untagged fields identified by position (curve data). - Positional, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Object { - pub class: Tag, - pub kind: Kind, - /// Class chain, most derived first (`ShpN`, `VNod`, `Node`). - pub chain: Vec, - /// Fields in stream order; positional fields have tag 0. - pub fields: Vec<(Tag, Value)>, - /// For objects with many fields: each tag with the positions of its first and last field, - /// sorted by tag, so a lookup isn't a scan of every field (a shared object is looked up - /// again on every visit). Built by [`Object::index`]; empty for small objects. - index: Vec<(Tag, u32, u32)>, -} - -/// Objects with more fields than this get an [`Object::index`]; fewer are scanned. -const INDEXED_FIELDS: usize = 16; - -impl Object { - pub fn new(class: Tag, kind: Kind, chain: Vec, fields: Vec<(Tag, Value)>) -> Self { - let mut o = Self { class, kind, chain, fields, index: Vec::new() }; - o.index(); - o - } - - /// (Re)build the tag index after `fields` changed. - fn index(&mut self) { - self.index.clear(); - if self.fields.len() <= INDEXED_FIELDS { - return; - } - let mut index: Vec<(Tag, u32, u32)> = Vec::new(); - if index.try_reserve_exact(self.fields.len()).is_err() { - return; - } - // Fields beyond u32 positions are impossible: a stream holds at most MAX_VALUES values. - index.extend(self.fields.iter().enumerate().filter_map(|(i, (t, _))| u32::try_from(i).ok().map(|i| (*t, i, i)))); - // Stable: equal tags stay in stream order, so the run of a tag starts at its first field. - index.sort_by_key(|(t, _, _)| *t); - index.dedup_by(|later, first| { - if later.0 == first.0 { - first.2 = later.2; - true - } else { - false - } - }); - index.shrink_to_fit(); - self.index = index; - } - - fn position(&self, tag: Tag, last: bool) -> Option { - if self.index.is_empty() { - let mut fields = self.fields.iter().enumerate(); - let hit = if last { fields.rev().find(|(_, (t, _))| *t == tag) } else { fields.find(|(_, (t, _))| *t == tag) }; - return hit.map(|(i, _)| i); - } - let at = self.index.binary_search_by_key(&tag, |(t, _, _)| *t).ok()?; - let (_, first, end) = self.index.get(at)?; - usize::try_from(if last { *end } else { *first }).ok() - } - - /// The first field tagged `tag`. - pub fn get(&self, tag: Tag) -> Option<&Value> { - self.fields.get(self.position(tag, false)?).map(|(_, v)| v) - } - - /// The last field tagged `tag` (a shape changed from one kind to another keeps a stale first one). - pub fn get_last(&self, tag: Tag) -> Option<&Value> { - self.fields.get(self.position(tag, true)?).map(|(_, v)| v) - } -} - -#[derive(Debug)] -pub struct Stream { - pub root: ObjId, - pub objects: Vec, -} - -/// Deepest object nesting accepted; real documents stay well below (each layer level adds three). -pub const MAX_DEPTH: usize = 384; - -const MAX_OBJECTS: usize = 8_000_000; - -/// Most values (fields and array elements) one stream may decode. A value takes about 40 bytes -/// in memory however few it took in the file (a bool array packs eight in a byte), so the size -/// of the input alone doesn't bound what parsing allocates: a small compressed entry could -/// otherwise ask for tens of gigabytes. Real documents stay far below. -pub const MAX_VALUES: usize = 1 << 24; - -struct Parser<'a> { - c: Cursor<'a>, - objects: Vec, - /// Shared ids, defined at first use and referenced backwards. - shared: std::collections::HashMap, - depth: usize, - /// Values decoded so far, against [`MAX_VALUES`]. - values: usize, -} - -/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored. -pub fn parse(bytes: &[u8]) -> Result { - let mut c = Cursor::new(bytes, 0); - if c.take(4)? != b"\x00\xffKS" { - return Err(Error::Malformed("document stream signature")); - } - let version = c.u16()?; - if version > 2 { - return Err(Error::Unsupported("document stream version")); - } - let class = Tag(c.u32()?); - c.u16()?; - if version == 2 { - c.u32()?; - } - let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 }; - let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?; - let fields = p.fields(true, class)?; - if let Some(o) = p.objects.get_mut(root) { - o.fields = fields; - o.index(); - } - Ok(Stream { root, objects: p.objects }) -} - -impl Parser<'_> { - fn push(&mut self, o: Object) -> Result { - if self.objects.len() >= MAX_OBJECTS { - return Err(Error::Limit("too many objects")); - } - self.objects.push(o); - Ok(self.objects.len() - 1) - } - - /// Counts `n` more values against [`MAX_VALUES`], before anything is allocated for them. - fn spend(&mut self, n: usize) -> Result<(), Error> { - self.values = self.values.checked_add(n).filter(|&v| v <= MAX_VALUES).ok_or(Error::Limit("too many values in the document stream"))?; - Ok(()) - } - - /// An empty array with room for `n` values, paid for first; an allocation that still fails - /// is an error, not an abort. - fn vec(&mut self, n: usize) -> Result, Error> { - self.spend(n)?; - let mut v = Vec::new(); - v.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for the document stream"))?; - Ok(v) - } - - /// Element count of an array: each element takes at least `min` bytes, so a count the - /// remaining input cannot hold is rejected before anything is allocated. - fn count(&mut self, min: usize) -> Result { - let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; - if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) { - return Err(Error::Malformed("array longer than its data")); - } - Ok(n) - } - - fn fields(&mut self, tagged: bool, class: Tag) -> Result, Error> { - self.depth += 1; - if self.depth > MAX_DEPTH { - return Err(Error::Limit("objects nested too deeply")); - } - let mut out = Vec::new(); - loop { - let t = self.c.u8()?; - let code = t & 0x7F; - if code == 0 { - break; - } - self.spend(1)?; - let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) }; - let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? }; - out.push((tag, v)); - } - self.depth -= 1; - Ok(out) - } - - fn string(&mut self) -> Result { - let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?; - Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned()) - } - - fn floats(&mut self, n: usize, wide: bool) -> Result { - let mut v = Vec::with_capacity(n); - for _ in 0..n { - v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) }); - } - Ok(Value::Floats(v)) - } - - fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result { - let c = &mut self.c; - Ok(match code { - 0x01 => Value::UInt(c.u8()?.into()), - 0x02 => Value::UInt(c.u16()?.into()), - 0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()), - 0x04 => Value::UInt(c.u64()?), - 0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()), - 0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()), - 0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()), - 0x08 => Value::Int(i64::from_le_bytes(c.array()?)), - 0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()), - 0x0a => Value::Float(f64::from_le_bytes(c.array()?)), - 0x15..=0x19 => { - let n = usize::from(code - 0x13); - let mut v = Vec::with_capacity(n); - for _ in 0..n { - v.push(i32::from_le_bytes(c.array()?).into()); - } - Value::Ints(v) - } - 0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false), - 0x24..=0x28 => return self.floats(usize::from(code - 0x22), true), - 0x29 => Value::Bool(c.u8()? != 0), - 0x2a => Value::Enum { id: c.u16()?, version: c.u16()? }, - 0x2b | 0x2e => Value::Str(self.string()?), - 0x2c => { - let n = usize::from(c.u16()?); - Value::Bytes(c.take(n)?.to_vec()) - } - 0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()), - 0x2d => { - let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?; - Value::Blob(c.take(n)?.to_vec()) - } - 0x33 => { - c.u32()?; - let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?; - Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned()) - } - 0x75 => { - c.u16()?; - let n = usize::from(c.u8()?); - if n > 8 { - return Err(Error::Malformed("flags wider than 64 bits")); - } - let mut b = [0u8; 8]; - b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?); - Value::Flags(u64::from_le_bytes(b)) - } - 0x30 => { - let fields = self.fields(false, class)?; - let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?; - Value::Obj(Some(id)) - } - 0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")), - 0x31 => self.shared()?, - 0x32 => self.inline(None)?, - _ => return Err(Error::Unsupported("unknown field type in document stream")), - }) - } - - fn array(&mut self, code: u8, class: Tag) -> Result { - let min = match code { - 0x01 | 0x05 | 0x29 => 1, - 0x02 | 0x06 => 2, - 0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4, - 0x04 | 0x08 | 0x0a => 8, - 0x15..=0x19 => 4 * usize::from(code - 0x13), - 0x1f..=0x23 => 4 * usize::from(code - 0x1d), - 0x24..=0x28 => 8 * usize::from(code - 0x22), - 0x35..=0x74 => usize::from(code - 0x34), - _ => 1, - }; - match code { - 0x29 => { - let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; - let bits = self.c.take(n.div_ceil(8))?; - let mut v = self.vec(n)?; - v.extend((0..n).map(|i| Value::Bool(bits.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 != 0)))); - Ok(Value::Array(v)) - } - 0x2a => { - let n = self.count(2)?; - let version = self.c.u16()?; - let mut v = self.vec(n)?; - for _ in 0..n { - v.push(Value::Enum { id: self.c.u16()?, version }); - } - Ok(Value::Array(v)) - } - 0x2b | 0x2e => { - self.c.u32()?; - let n = self.count(4)?; - let mut v = self.vec(n)?; - for _ in 0..n { - v.push(Value::Str(self.string()?)); - } - Ok(Value::Array(v)) - } - 0x2c => { - let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; - let size = usize::from(self.c.u16()?); - // Records of no bytes take nothing from the data, whatever their number. - if n > 0 && size == 0 { - return Err(Error::Malformed("array of records without bytes")); - } - if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) { - return Err(Error::Malformed("array longer than its data")); - } - let mut v = self.vec(n)?; - for _ in 0..n { - v.push(Value::Bytes(self.c.take(size)?.to_vec())); - } - Ok(Value::Array(v)) - } - 0x32 => { - let n = self.count(1)?; - let class = Tag(self.c.u32()?); - self.c.u16()?; - let mut v = self.vec(n)?; - for _ in 0..n { - v.push(self.inline(Some(class))?); - } - Ok(Value::Array(v)) - } - 0x2d | 0x33 | 0x75 => Err(Error::Malformed("array of an unarrayable type")), - _ => { - let n = self.count(min)?; - let mut v = self.vec(n)?; - for _ in 0..n { - v.push(self.scalar(code, true, class)?); - } - Ok(Value::Array(v)) - } - } - } - - /// Inline object; `class` is given once for a whole array of them. - fn inline(&mut self, class: Option) -> Result { - match self.c.u8()? { - 0 => return Ok(Value::Obj(None)), - 1 => {} - _ => return Err(Error::Malformed("object marker")), - } - let class = match class { - Some(c) => c, - None => { - let c = Tag(self.c.u32()?); - self.c.u16()?; - c - } - }; - let fields = self.fields(true, class)?; - let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?; - Ok(Value::Obj(Some(id))) - } - - fn shared(&mut self) -> Result { - let flag = self.c.u8()?; - if flag == 0 { - return Ok(Value::Obj(None)); - } - let key = self.c.u32()?; - match flag { - 2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")), - 1 => {} - _ => return Err(Error::Malformed("object marker")), - } - if self.shared.contains_key(&key) { - return Err(Error::Malformed("object defined twice")); - } - // Register before the fields: an object's own fields may refer back to it. - let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?; - self.shared.insert(key, id); - let mut chain = Vec::new(); - let mut fields = Vec::new(); - loop { - match self.c.u8()? { - 0 => { - let tag = Tag(self.c.u32()?); - self.c.u16()?; - chain.push(tag); - fields.extend(self.fields(true, tag)?); - } - 1 => { - chain.push(Tag(self.c.u32()?)); - break; - } - 2 => break, - _ => return Err(Error::Malformed("object class chain")), - } - if chain.len() > 64 { - return Err(Error::Limit("object class chain too long")); - } - } - let class = chain.first().copied().unwrap_or(Tag(0)); - fields.extend(self.fields(true, class)?); - if let Some(o) = self.objects.get_mut(id) { - *o = Object::new(class, Kind::Shared, chain, fields); - } - Ok(Value::Obj(Some(id))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn lookups_in_a_large_object_find_the_first_and_last_fields_of_a_tag() { - let tag = |i: u32| Tag(0x4100_0000 + i); - let mut fields: Vec<(Tag, Value)> = (0..1000).map(|i| (tag(i % 300), Value::UInt(u64::from(i)))).collect(); - fields.push((tag(7), Value::Str("last".into()))); - let o = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), fields.clone()); - assert!(!o.index.is_empty()); - // The index has one entry per distinct tag, not per field. - assert_eq!(o.index.len(), 300); - for t in 0..300 { - let first = fields.iter().find(|(k, _)| *k == tag(t)).map(|(_, v)| v); - let last = fields.iter().rev().find(|(k, _)| *k == tag(t)).map(|(_, v)| v); - assert_eq!(o.get(tag(t)), first); - assert_eq!(o.get_last(tag(t)), last); - } - assert_eq!(o.get(tag(300)), None); - assert_eq!(o.get_last(tag(7)), Some(&Value::Str("last".into()))); - let small = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), vec![(tag(1), Value::Bool(true)), (tag(1), Value::Bool(false))]); - assert!(small.index.is_empty()); - assert_eq!(small.get(tag(1)), Some(&Value::Bool(true))); - assert_eq!(small.get_last(tag(1)), Some(&Value::Bool(false))); - } -} diff --git a/crates/affinity/src/synth.rs b/crates/affinity/src/synth.rs deleted file mode 100644 index 5b75ca0bf..000000000 --- a/crates/affinity/src/synth.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`). -//! -//! These builders write the layout the reader accepts, so tests can describe documents in a few -//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use crate::stream::Tag; - -/// A field value to encode. -#[derive(Debug, Clone)] -pub enum F { - U8(u8), - U32(u32), - I32(i32), - F32(f32), - F64(f64), - Bool(bool), - Enum(u16, u16), - Str(String), - /// f64 vector of 2..6 values (points, rectangles, transforms). - F64s(Vec), - /// Fixed-size struct (colour components), 1..64 bytes. - Struct(Vec), - /// Array of fixed-size records, all `size` bytes. - Records(u16, Vec>), - U8s(Vec), - Entry(String), - /// Shared object: `Def` defines id with class chain and fields, `Ref` links back. - Def(u32, Vec, Vec<(Tag, F)>), - Ref(u32), - Null, - /// Inline object. - Obj(Tag, Vec<(Tag, F)>), - /// Positional (untagged) object. - Pos(Vec), - /// Array of shared objects / links. - Shared(Vec), - /// Array of inline objects of one class. - Objs(Tag, Vec>), - /// Raw bytes appended verbatim (malformed-input tests). - Raw(Vec), -} - -pub fn tag(s: &[u8; 4]) -> Tag { - Tag::of(s) -} - -fn put_tag(out: &mut Vec, t: Tag) { - out.extend(t.0.to_le_bytes()); -} - -fn code(f: &F) -> u8 { - match f { - F::U8(_) => 0x01, - F::U32(_) => 0x03, - F::I32(_) => 0x07, - F::F32(_) => 0x09, - F::F64(_) => 0x0a, - F::Bool(_) => 0x29, - F::Enum(..) => 0x2a, - F::Str(_) => 0x2b, - F::F64s(v) => 0x22 + v.len() as u8, - F::Struct(b) => 0x34 + b.len() as u8, - F::Records(..) => 0x80 | 0x2c, - F::U8s(_) => 0x80 | 0x01, - F::Entry(_) => 0x33, - F::Def(..) | F::Ref(_) => 0x31, - F::Null => 0x31, - F::Obj(..) => 0x32, - F::Pos(_) => 0x30, - F::Shared(_) => 0x80 | 0x31, - F::Objs(..) => 0x80 | 0x32, - F::Raw(_) => 0, - } -} - -fn payload(out: &mut Vec, f: &F) { - match f { - F::U8(v) => out.push(*v), - F::U32(v) => out.extend(v.to_le_bytes()), - F::I32(v) => out.extend(v.to_le_bytes()), - F::F32(v) => out.extend(v.to_le_bytes()), - F::F64(v) => out.extend(v.to_le_bytes()), - F::Bool(v) => out.push(u8::from(*v)), - F::Enum(id, ver) => { - out.extend(id.to_le_bytes()); - out.extend(ver.to_le_bytes()); - } - F::Str(s) => { - out.extend((s.len() as u32).to_le_bytes()); - out.extend(s.as_bytes()); - } - F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())), - F::Struct(b) => out.extend(b), - F::Records(size, recs) => { - out.extend((recs.len() as u32).to_le_bytes()); - out.extend(size.to_le_bytes()); - recs.iter().for_each(|r| out.extend(r)); - } - F::U8s(v) => { - out.extend((v.len() as u32).to_le_bytes()); - out.extend(v); - } - F::Entry(name) => { - put_tag(out, tag(b"Data")); - out.extend((name.len() as u32).to_le_bytes()); - out.extend(name.as_bytes()); - } - F::Def(id, chain, fields) => { - out.push(1); - out.extend(id.to_le_bytes()); - // Class chain: each level but the last carries no fields here; the last ends the chain. - let (last, rest) = chain.split_last().expect("class chain"); - for t in rest { - out.push(0); - put_tag(out, *t); - out.extend(0u16.to_le_bytes()); - out.push(0); - } - out.push(1); - put_tag(out, *last); - fields_into(out, fields, true); - } - F::Ref(id) => { - out.push(2); - out.extend(id.to_le_bytes()); - } - F::Null => out.push(0), - F::Obj(class, fields) => { - out.push(1); - put_tag(out, *class); - out.extend(0u16.to_le_bytes()); - fields_into(out, fields, true); - } - F::Pos(items) => { - for f in items { - out.push(code(f)); - payload(out, f); - } - out.push(0); - } - F::Shared(items) => { - out.extend((items.len() as u32).to_le_bytes()); - items.iter().for_each(|f| payload(out, f)); - } - F::Objs(class, items) => { - out.extend((items.len() as u32).to_le_bytes()); - put_tag(out, *class); - out.extend(0u16.to_le_bytes()); - for fields in items { - out.push(1); - fields_into(out, fields, true); - } - } - F::Raw(b) => out.extend(b), - } -} - -fn fields_into(out: &mut Vec, fields: &[(Tag, F)], tagged: bool) { - for (t, f) in fields { - if let F::Raw(b) = f { - out.extend(b); - continue; - } - out.push(code(f)); - if tagged { - put_tag(out, *t); - } - payload(out, f); - } - out.push(0); -} - -/// A `doc.dat` stream with root class `Pers`. -pub fn stream(root: &[(Tag, F)]) -> Vec { - let mut out = b"\x00\xffKS".to_vec(); - out.extend(2u16.to_le_bytes()); - put_tag(&mut out, tag(b"Pers")); - out.extend(0u16.to_le_bytes()); - out.extend(30u32.to_le_bytes()); - fields_into(&mut out, root, true); - out -} - -/// How an entry is stored. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Method { - Stored, - Zlib, - Zstd, -} - -/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record. -pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec { - let mut out = vec![0u8; 72]; - out[..4].copy_from_slice(crate::container::MAGIC); - out[4..6].copy_from_slice(&12u16.to_le_bytes()); - out[6..8].copy_from_slice(&4u16.to_le_bytes()); - out[8..12].copy_from_slice(b"nsrP"); - out[12..16].copy_from_slice(b"#Inf"); - out[64..68].copy_from_slice(b"Prot"); - let mut records = Vec::new(); - for (i, (name, data, method)) in entries.iter().enumerate() { - let packed = match method { - Method::Stored => data.to_vec(), - Method::Zlib => { - use std::io::Write; - let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); - e.write_all(data).unwrap(); - e.finish().unwrap() - } - Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest), - }; - let offset = out.len() as u64; - out.extend(b"#Fil"); - out.extend(&packed); - let mut r = Vec::new(); - r.extend((i as u32 + 1).to_le_bytes()); - r.push(0); - r.extend(offset.to_le_bytes()); - r.extend((data.len() as u64).to_le_bytes()); - r.extend((packed.len() as u64).to_le_bytes()); - r.extend(crc32fast::hash(data).to_le_bytes()); - r.push(match method { - Method::Stored => 0, - Method::Zlib => 1, - Method::Zstd => 2, - }); - r.extend(0u32.to_le_bytes()); - r.extend(0u32.to_le_bytes()); - r.extend((name.len() as u16).to_le_bytes()); - r.extend(name.as_bytes()); - records.push(r); - } - let fat = out.len() as u64; - out.extend(b"#FT4"); - out.extend(0u64.to_le_bytes()); - out.extend(1_700_000_000u64.to_le_bytes()); - out.extend([0u8; 24]); - out.extend((records.len() as u32).to_le_bytes()); - out.extend([0u8; 8]); - out.extend(0u16.to_le_bytes()); - out.push(0); - records.iter().for_each(|r| out.extend(r)); - let thumb = out.len() as u64; - out[16..24].copy_from_slice(&fat.to_le_bytes()); - if let Some(png) = thumbnail_png { - out[24..32].copy_from_slice(&thumb.to_le_bytes()); - out.extend(b"\xff\xff\xff\xffThmb"); - out.extend(1u32.to_le_bytes()); - out.extend((png.len() as u32 + 13).to_le_bytes()); - out.extend(29u32.to_le_bytes()); - out.extend(0u32.to_le_bytes()); - out.extend((png.len() as u32).to_le_bytes()); - out.push(1); - out.extend(png); - } - out -} diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs deleted file mode 100644 index 75b6b7b14..000000000 --- a/crates/affinity/src/tests.rs +++ /dev/null @@ -1,408 +0,0 @@ -//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents. - -mod preview { - use crate::container::header; - use crate::*; - use proptest::prelude::*; - - fn fixture() -> Vec { - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - png.extend(13u32.to_be_bytes()); - png.extend(b"IHDR"); - png.extend(2u32.to_be_bytes()); - png.extend(1u32.to_be_bytes()); - png.extend([8, 6, 0, 0, 0]); - png.extend(crc32fast::hash(&png[12..]).to_be_bytes()); - png.extend([0; 4]); - png.extend(b"IDAT"); - png.extend(crc32fast::hash(b"IDAT").to_be_bytes()); - png.extend([0; 4]); - png.extend(b"IEND"); - png.extend(crc32fast::hash(b"IEND").to_be_bytes()); - let mut b = vec![0; 72]; - b[..4].copy_from_slice(MAGIC); - b[4..6].copy_from_slice(&12u16.to_le_bytes()); - b[8..12].copy_from_slice(b"nsrP"); - b[12..16].copy_from_slice(b"#Inf"); - b[24..32].copy_from_slice(&72u64.to_le_bytes()); - b[64..68].copy_from_slice(b"Prot"); - b.extend(b"\xff\xff\xff\xffThmb"); - b.extend(1u32.to_le_bytes()); - b.extend((png.len() as u32 + 13).to_le_bytes()); - b.extend(29u32.to_le_bytes()); - b.extend(0u32.to_le_bytes()); - b.extend((png.len() as u32).to_le_bytes()); - b.push(1); - b.extend(png); - b - } - - #[test] - fn indexed_preview_and_header() { - let b = fixture(); - assert_eq!(header(&b).unwrap().version, 12); - let p = preview(&b).unwrap(); - assert_eq!((p.width, p.height), (2, 1)); - assert_eq!(p.png, &b[101..]); - } - - #[test] - fn every_truncation_is_rejected() { - let b = fixture(); - for end in 0..b.len() { - assert!(preview(&b[..end]).is_err(), "{end}"); - } - } - - #[test] - fn rejects_assets_versions_offsets_lengths_and_bombs() { - let b = fixture(); - for (offset, value) in [ - (4, 13u64.to_le_bytes().to_vec()), - (8, b"urBR".to_vec()), - (24, u64::MAX.to_le_bytes().to_vec()), - (24, 1u64.to_le_bytes().to_vec()), - (24, 0u64.to_le_bytes().to_vec()), - (84, u32::MAX.to_le_bytes().to_vec()), - (88, 30u32.to_le_bytes().to_vec()), - (96, u32::MAX.to_le_bytes().to_vec()), - (117, 5000u32.to_be_bytes().to_vec()), - ] { - let mut broken = b.clone(); - broken[offset..offset + value.len()].copy_from_slice(&value); - assert!(preview(&broken).is_err(), "offset {offset}"); - } - // Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files). - for version in 8..12 { - let mut legacy = b.clone(); - legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes()); - assert!(preview(&legacy).is_ok()); - } - // Flags 4 and 8 occur in real files; the low two bits mark other container variants. - for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] { - let mut variant = b.clone(); - variant[6..8].copy_from_slice(&flags.to_le_bytes()); - assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}"); - } - let mut old = b.clone(); - old[4..6].copy_from_slice(&7u16.to_le_bytes()); - assert!(matches!(preview(&old), Err(Error::Unsupported(_)))); - } - - #[test] - fn an_unindexed_png_or_resource_is_never_used() { - let mut b = fixture(); - b[24..32].copy_from_slice(&0u64.to_le_bytes()); - assert!(preview(&b).is_err()); - b[24..32].copy_from_slice(&72u64.to_le_bytes()); - b[76..80].copy_from_slice(b"Meta"); - assert!(preview(&b).is_err()); - } - - #[test] - fn every_chunk_checksum_including_the_end_is_checked() { - let b = fixture(); - for at in [130, b.len() - 1] { - let mut damaged = b.clone(); - damaged[at] ^= 1; - assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum")); - } - let mut ancillary = vec![0; 4]; - ancillary.extend(b"tEXt"); - ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes()); - let mut with_ancillary = b.clone(); - let at = b.len() - 12; - with_ancillary.splice(at..at, ancillary); - let png_length = (with_ancillary.len() - 101) as u32; - with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes()); - with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes()); - assert!(preview(&with_ancillary).is_ok()); - with_ancillary[at + 8] ^= 1; - assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum")); - } - - #[test] - fn compressed_metadata_and_animation_are_not_preview_support() { - for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] { - let mut extra = vec![0; 4]; - extra.extend(kind); - extra.extend(crc32fast::hash(kind).to_be_bytes()); - let mut b = fixture(); - let at = b.len() - 12; - b.splice(at..at, extra); - let length = (b.len() - 101) as u32; - b[84..88].copy_from_slice(&(length + 13).to_le_bytes()); - b[96..100].copy_from_slice(&length.to_le_bytes()); - assert!(matches!(preview(&b), Err(Error::Unsupported(_)))); - } - } - - #[test] - fn critical_chunks_follow_a_single_complete_png_layout() { - fn with_chunks(kinds: &[&[u8; 4]]) -> Vec { - let original = fixture(); - let mut b = original[..134].to_vec(); // File, record and complete first IHDR. - for kind in kinds { - b.extend([0; 4]); - b.extend(*kind); - b.extend(crc32fast::hash(*kind).to_be_bytes()); - } - let length = (b.len() - 101) as u32; - b[84..88].copy_from_slice(&(length + 13).to_le_bytes()); - b[96..100].copy_from_slice(&length.to_le_bytes()); - b - } - assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok()); - // PNG readers must not reject an otherwise valid unknown ancillary chunk just - // because its reserved (third-letter) bit is set; future versions can define it. - assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok()); - for kinds in [ - vec![b"IEND"], - vec![b"IDAT", b"IHDR", b"IEND"], - vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"], - vec![b"IDAT", b"PLTE", b"IEND"], - vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"], - vec![b"ABCD", b"IDAT", b"IEND"], - vec![b"IDAT", b"IEND", b"tEXt"], - ] { - assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}"); - } - } - - proptest! { - #![proptest_config(ProptestConfig::with_cases(256))] - #[test] - fn arbitrary_input_never_panics(b in prop::collection::vec(any::(), 0..2048)) { - let _ = header(&b); - let _ = preview(&b); - } - #[test] - fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::()), 0..32)) { - let mut b = fixture(); - for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } } - let _ = preview(&b); - } - } -} - -mod archive { - use crate::Error; - use crate::container::{Archive, Limits}; - use crate::stream::{self, Kind, Tag, Value}; - use crate::synth::{self, F, Method, tag}; - use proptest::prelude::*; - - fn doc() -> Vec { - synth::stream(&[ - (tag(b"Desc"), F::Str("Document".into())), - (tag(b"Visi"), F::Bool(true)), - (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])), - (tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])), - (tag(b"Same"), F::Ref(7)), - (tag(b"Null"), F::Null), - (tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])), - ( - tag(b"Crvs"), - F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))]), - ), - (tag(b"Enum"), F::Enum(2, 1)), - (tag(b"Tile"), F::Entry("d/3".into())), - (tag(b"Stat"), F::U8s(vec![4, 0, 2])), - ]) - } - - #[test] - fn every_method_round_trips_with_checksums() { - let d = doc(); - for method in [Method::Stored, Method::Zlib, Method::Zstd] { - let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None); - let mut a = Archive::open(&file, Limits::default()).unwrap(); - assert_eq!(a.header.version, 12); - assert_eq!(a.read("doc.dat").unwrap(), d); - assert_eq!(a.read("d/3").unwrap(), b"tile"); - assert_eq!(a.extracted(), d.len() + 4); - assert!(a.read("missing").is_err()); - } - } - - #[test] - fn the_stream_parses_into_shared_objects_and_links() { - let s = stream::parse(&doc()).unwrap(); - let root = &s.objects[s.root]; - assert_eq!(root.class, Tag::of(b"Pers")); - assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into()))); - assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0]))); - let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() }; - assert_eq!(a, b); - assert_eq!(s.objects[*a].class, Tag::of(b"RGBA")); - assert_eq!(s.objects[*a].kind, Kind::Shared); - let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() }; - assert_eq!(kids[0], kids[1]); - let Value::Obj(Some(g)) = kids[0] else { panic!() }; - assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]); - assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 })); - assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into()))); - assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None))); - } - - #[test] - fn checksums_sizes_and_budgets_are_enforced() { - let d = doc(); - let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); - // Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72. - let mut broken = file.clone(); - broken[90] ^= 0x55; - let mut a = Archive::open(&broken, Limits::default()).unwrap(); - assert!(a.read("doc.dat").is_err()); - let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap(); - assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); - let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap(); - a.read("doc.dat").unwrap(); - assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); - } - - #[test] - fn an_entry_that_fails_its_checksum_is_charged_once_and_not_decompressed_again() { - let d = doc(); - let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); - let mut broken = file.clone(); - broken[90] ^= 0x55; - let mut a = Archive::open(&broken, Limits::default()).unwrap(); - let first = a.read("doc.dat").unwrap_err(); - // The work was done, so it counts against the import even though it failed. - assert_eq!(a.extracted(), d.len()); - assert_eq!(a.read("doc.dat").unwrap_err(), first); - assert_eq!(a.extracted(), d.len()); - // A budget that only covers one attempt is not exceeded by asking again. - let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len() }).unwrap(); - assert!(a.read("doc.dat").is_err()); - assert_eq!(a.read("doc.dat").unwrap_err(), first); - } - - #[test] - fn the_table_index_has_a_budget_for_records_and_names() { - let d = doc(); - let long = "n".repeat(300); - let file = synth::container(&[("doc.dat", &d, Method::Stored), (&long, b"tile", Method::Stored)], None); - Archive::open_within(&file, Limits::default(), 2, 307).unwrap(); - assert_eq!(Archive::open_within(&file, Limits::default(), 1, 1 << 20).unwrap_err(), Error::Limit("too many archive entries")); - assert_eq!(Archive::open_within(&file, Limits::default(), 2, 306).unwrap_err(), Error::Limit("archive entry names too long")); - } - - // Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's - // window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than - // the format permits for a block. 0.9.1 refuses it. - #[test] - fn a_zstd_block_larger_than_its_frames_window_is_refused() { - const LEN: u32 = 100_000; - let plain = vec![0x61u8; LEN as usize]; - let file = synth::container(&[("doc.dat", &plain, Method::Zstd)], None); - // A Zstandard frame with a 1 KiB window (descriptor byte 0, window byte 0) holding one RLE - // block, the last, that repeats a byte LEN times: block header = last | RLE << 1 | LEN << 3. - let mut frame = vec![0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00]; - frame.extend((1 | (1 << 1) | (LEN << 3)).to_le_bytes().get(..3).unwrap()); - frame.push(0x61); - // Swap it in for the entry's compressed bytes (they follow the "#Fil" tag at offset 72). - let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; - let old_len = u64::from_le_bytes(file[fat + 59 + 21..fat + 59 + 29].try_into().unwrap()) as usize; - let mut swapped = file[..76].to_vec(); - swapped.extend(&frame); - swapped.extend(&file[76 + old_len..]); - let new_fat = fat - old_len + frame.len(); - swapped[16..24].copy_from_slice(&(new_fat as u64).to_le_bytes()); - swapped[new_fat + 59 + 21..new_fat + 59 + 29].copy_from_slice(&(frame.len() as u64).to_le_bytes()); - let mut a = Archive::open(&swapped, Limits::default()).unwrap(); - assert!(a.read("doc.dat").is_err(), "a block of {LEN} bytes was decompressed from a frame with a 1 KiB window"); - } - - #[test] - fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() { - let big = vec![7u8; 1 << 20]; - let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None); - // Rewrite the declared size in the table to 1 KiB: decoding must stop and fail. - let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; - let size_at = fat + 59 + 5 + 8; - file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes()); - let mut a = Archive::open(&file, Limits::default()).unwrap(); - assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch")); - } - - #[test] - fn libraries_and_cyclic_tables_are_rejected() { - let d = doc(); - let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None); - let mut library = file.clone(); - library[8..12].copy_from_slice(b"rAsA"); - assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_)))); - let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()); - let at = fat as usize + 4; - file[at..at + 8].copy_from_slice(&fat.to_le_bytes()); - assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle")); - } - - #[test] - fn hostile_streams_fail_cleanly() { - let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]); - assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply")); - // An array claiming four billion elements in a few bytes. - let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]); - assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data")); - let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]); - assert!(stream::parse(&forward).is_err()); - let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]); - assert!(stream::parse(&twice).is_err()); - let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]); - assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_)))); - let d = doc(); - for end in 0..d.len() { - assert!(stream::parse(&d[..end]).is_err(), "{end}"); - } - } - - #[test] - fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() { - // Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB - // of zero bits, a few KiB once compressed, would ask for 2^27 values (about 4 GiB). An - // array of one-byte values just past the budget fails the same way, before allocating. - let field = |code: u8, n: u32, data: usize| { - let mut f = vec![0x80 | code]; - f.extend(tag(b"Big_").0.to_le_bytes()); - f.extend(n.to_le_bytes()); - f.resize(f.len() + data, 0); - F::Raw(f) - }; - let bools = 1u32 << 27; - let bytes = u32::try_from(stream::MAX_VALUES).unwrap() + 1; - for f in [field(0x29, bools, (bools / 8) as usize), field(0x01, bytes, bytes as usize)] { - let d = synth::stream(&[(tag(b"Big_"), f)]); - let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); - assert!(file.len() < d.len() / 100, "{} of {}", file.len(), d.len()); - let entry = Archive::open(&file, Limits::default()).unwrap().read("doc.dat").unwrap(); - assert_eq!(stream::parse(&entry).unwrap_err(), Error::Limit("too many values in the document stream")); - } - } - - proptest! { - #![proptest_config(ProptestConfig::with_cases(512))] - #[test] - fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::()), 1..24)) { - let d = doc(); - for method in [Method::Stored, Method::Zlib, Method::Zstd] { - let mut file = synth::container(&[("doc.dat", &d, method)], None); - for (i, v) in &edits { - let n = file.len(); - file[i % n] = *v; - } - if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) { - let _ = stream::parse(&bytes); - } - } - let mut s = d.clone(); - for (i, v) in &edits { - let n = s.len(); - s[i % n] = *v; - } - let _ = stream::parse(&s); - } - } -} diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs deleted file mode 100644 index d2bb55ca0..000000000 --- a/crates/affinity/src/text.rs +++ /dev/null @@ -1,564 +0,0 @@ -//! Artistic and frame text: the story's characters with their font, size, colour, tracking and -//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning, -//! OpenType features) is left to the importing application. - -use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect}; -use crate::paint::{self, Color, Paint}; -use crate::stream::{ObjId, Value}; - -/// Characters read per text node (stories are split into blocks; this caps hostile ones). -const MAX_CHARS: usize = 1 << 20; -/// Shared character attributes must not amplify into unbounded font/feature/gradient data. -const MAX_ATTRIBUTE_BYTES: usize = 16 << 20; - -/// Ends a table cell in a table's story (Affinity's break glyph of kind 4). -pub const CELL_BREAK: char = '\u{1F}'; - -pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option { - let s = r.s; - let story = s.obj(id, b"StSt")?; - let frame = s.obj(id, b"TxtH")?; - let artistic = s.is(frame, b"ArFr"); - let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?; - let (runs, align) = story_runs(r, story, world); - let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0); - let anchor_x = match align { - Align::Center => (bounds.x0 + bounds.x1) / 2.0, - Align::Right => bounds.x1, - _ => bounds.x0, - }; - if !artistic { - if s.objs(frame, b"ColW").len() > 1 || matches!(s.field(frame, b"ColW"), Some(Value::Array(a)) if a.len() > 1) { - r.warn("text frames with several columns (imported as one column)"); - } - if s.class(id) == Some(crate::stream::Tag::of(b"TxtC")) { - r.warn("text frames with a curved outline (imported as rectangular frames)"); - } - } - let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) }; - Some(Text { - runs, - align, - anchor: Point { x: anchor_x, y: first_baseline }, - frame: if artistic { None } else { Some(bounds) }, - transform: world, - text_transform, - }) -} - -/// Rows and columns a table may have. -const MAX_TABLE_LINES: usize = 4096; -/// Cells a table may have (rows × columns): each becomes a text object with its edge lines. -const MAX_TABLE_CELLS: usize = 1 << 16; - -/// Work charged for each copy of a run's formatting a table cell keeps. -const TABLE_RUN_COST: usize = 16; - -/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges. -pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option { - let s = r.s; - let story = s.obj(id, b"StSt")?; - let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?; - let bounds = |pos: Option| -> Option> { - let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None }; - let v: Vec = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::>()?; - (v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v) - }; - let columns = bounds(s.obj(tabl, b"CPos"))?; - let rows = bounds(s.obj(tabl, b"RPos"))?; - let (ncols, nrows) = (columns.len() - 1, rows.len() - 1); - let count = ncols.checked_mul(nrows)?; - if count > MAX_TABLE_CELLS { - r.warn("tables with more than 65536 cells (left out)"); - return None; - } - let Some(left) = r.budget.cells.checked_sub(count) else { - r.warn("tables beyond the cells one document may hold (left out)"); - return None; - }; - r.budget.cells = left; - let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default(); - // Formatting that doesn't match the grid would land on the wrong cells (and merging them, - // move text between cells): only text then. - let cell_objs = if cell_objs.len() == count { - cell_objs - } else { - r.warn("tables whose cells don't match their grid (cell formatting left out)"); - Vec::new() - }; - let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false }; - let mut cells: Vec = (0..count).map(|_| blank()).collect(); - for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() { - if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) { - cell.inset = inset; - } - cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) { - Some(1) => VAlign::Center, - Some(2) => VAlign::Bottom, - _ => VAlign::Top, - }; - // A cell merged into the one on its left; the first column has nothing to its left. - cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true); - } - // The story holds the text of every cell that isn't merged away, row by row, each ended by a - // cell break. - let owner_list: Vec = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect(); - let mut owners = owner_list.into_iter(); - let mut target = owners.next(); - let mut extra = false; - let (runs, _) = story_runs(r, story, world); - for run in runs { - let mut parts = run.text.split(CELL_BREAK).peekable(); - while let Some(part) = parts.next() { - if !part.is_empty() { - // Each cell keeps its own copy of the run's formatting, so each copy is charged. - match target.and_then(|i| cells.get_mut(i)) { - Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), - _ => extra = true, - } - } - if parts.peek().is_some() { - target = owners.next(); - } - } - } - if extra { - r.warn("table text beyond its last cell (left out)"); - } - let mut edges = |tag: &[u8; 4], expected: usize| -> Vec> { - let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default(); - if objs.len() != expected { - r.warn("tables whose lines don't match their grid (lines left out)"); - return Vec::new(); - } - objs.into_iter() - .map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) { - (Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false), - _ => None, - }) - .collect() - }; - let vertical = edges(b"CEdg", (ncols + 1) * nrows); - let horizontal = edges(b"REdg", ncols * (nrows + 1)); - if let Some(format) = s.obj(tabl, b"TFmt") { - let filled = - s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN"))); - if filled { - r.warn("table cell fills (left out)"); - } - } - let text_transform = text_scale(r, id).then(r.type_base); - Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform }) -} - -/// A story's character runs, each with its paragraph's format, and the first paragraph's -/// alignment. Table cells end with [`CELL_BREAK`]. -pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec, Align) { - let s = r.s; - let mut runs = Vec::new(); - let mut first_align = None; - let mut total = 0usize; - let mut placeholders = false; - let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES); - for block in s.objs(story, b"Blok") { - // Shared blocks and run lists are visited once per link, so every visit is charged, empty or not. - let paragraph_runs = s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default(); - let attribute_runs = s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default(); - if r.budget.spend(1 + paragraph_runs.len() + attribute_runs.len()).is_err() { - r.warn("text beyond the work one document may hold (truncated)"); - break; - } - let Some(glyphs) = s.obj(block, b"Glyp") else { - r.warn("a text block without readable characters (left out)"); - continue; - }; - // Bound collection itself, including placeholders, before allocating the character buffer: - // by the story's limit and by what the document's other stories left. - let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(total)); - - let mut chars: Vec = match s.str(glyphs, b"Utf8") { - Some(t) => t.chars().take(budget + 1).collect(), - None => { - // Inline objects (page numbers, anchors, cell ends) each take one index before - // the segment text. - let segments = s.objs(glyphs, b"Mixd"); - if segments.is_empty() { - r.warn("a text block without readable characters (left out)"); - continue; - } - let mut v = Vec::new(); - for seg in segments { - for inline in s.objs(seg, b"Glys") { - if v.len() > budget { - break; - } - if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) { - v.push(CELL_BREAK); - } else { - v.push('\u{FFFC}'); - placeholders = true; - } - } - v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len()))); - if v.len() > budget { - break; - } - } - v - } - }; - let truncated = chars.len() > budget; - if truncated { - chars.truncate(budget); - if budget < MAX_CHARS - total { - r.warn("text beyond the characters one document may hold (truncated)"); - } else { - r.warn("text longer than a million characters (truncated)"); - } - } - total += chars.len(); - // Paragraph formats by end index; a run without an index covers the rest of the block. - let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new(); - let mut open: Option = None; - for run in paragraph_runs { - let Some(attrs) = s.obj(run, b"Item") else { continue }; - let a = paragraph(r, attrs); - first_align.get_or_insert(a.align); - match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) { - Some(end) => paragraphs.push((end, a)), - None => match open { - None => open = Some(a), - Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"), - _ => {} - }, - } - } - // Sorted by end (they are in order in every file), so both lookups below are binary - // searches: a block can have as many paragraphs and runs as characters. - paragraphs.sort_by_key(|(end, _)| *end); - let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).map(|(_, a)| *a).or(open).unwrap_or_default(); - let mut cuts: Vec = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect(); - cuts.dedup(); - let push = |r: &mut Reader, runs: &mut Vec, attrs: Option, from: usize, to: usize, budget: &mut Option| { - // Split at paragraph ends so every run has one paragraph format. - let inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default(); - let mut a = from; - for &c in inside.iter().chain(std::iter::once(&to)) { - push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a)); - a = c; - } - }; - let mut start = 0usize; - for run in attribute_runs { - let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else { - r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); - continue; - }; - let end = index.min(chars.len()); - if index > chars.len() || end < start { - r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); - } - if end <= start { - continue; - } - push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget); - start = end; - } - if start < chars.len() { - push(r, &mut runs, None, start, chars.len(), &mut attribute_budget); - } - if truncated { - break; - } - } - r.budget.chars = r.budget.chars.saturating_sub(total); - if placeholders { - r.warn("text fields such as page numbers (left out of the text)"); - } - (runs, first_align.unwrap_or(Align::Left)) -} - -fn characters(chars: &[char], start: usize, end: usize) -> String { - chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect() -} - -fn push_run( - r: &mut Reader, - runs: &mut Vec, - attrs: Option, - text: String, - world: Affine, - attribute_budget: &mut Option, - paragraph: Paragraph, -) { - if text.is_empty() { - return; - } - let run = match attrs { - Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) }, - _ => { - // This is a recovery policy, not an assertion about unspecified Affinity attributes. - if attrs.is_none() { - r.warn("text without character attributes (characters preserved with default attributes)"); - } - TextRun { - text, - postscript: String::new(), - family: String::new(), - weight: 400, - italic: false, - features: Vec::new(), - all_caps: false, - underline: false, - strikethrough: false, - size: 12.0 * r.dpi / 72.0, - tracking: 0.0, - h_scale: 1.0, - leading: None, - fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }), - stroke: None, - paragraph, - } - } - }; - runs.push(run); -} - -fn slot(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option) -> Option { - match v? { - Value::Array(a) => a.get(i).and_then(f), - _ => None, - } -} - -fn float(v: &Value) -> Option { - match v { - Value::Float(f) if f.is_finite() => Some(*f), - _ => None, - } -} - -fn int(v: &Value) -> Option { - match v { - Value::Int(i) => Some(*i), - Value::UInt(u) => i64::try_from(*u).ok(), - _ => None, - } -} - -fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribute_budget: &mut Option) -> TextRun { - let s = r.s; - let doubles = s.field(attrs, b"Doub"); - let ints = s.field(attrs, b"Ints"); - let size = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 1e6).unwrap_or(12.0 * r.dpi / 72.0); - let tracking = slot(doubles, 4, float).unwrap_or(0.0); - let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0); - let objects = match s.field(attrs, b"Objs") { - Some(Value::Array(a)) => a.iter().take(8).map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(), - _ => Vec::new(), - }; - let mut fill = match objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)) { - Some(fill) => fill, - None => { - r.warn("text without readable fill attributes (default black used)"); - Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }) - } - }; - // A single descriptor decode is bounded by paint::MAX_STOPS. Charge its owned stop data - // before keeping it; subsequent runs skip attribute decoding once the budget is exhausted. - if let Paint::Gradient(gradient) = &fill { - let bytes = gradient.stops.len().saturating_mul(std::mem::size_of::()); - if !charge_attributes(r, attribute_budget, bytes) { - fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }); - } - } - // Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes). - let mut stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) { - (true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false), - _ => None, - }; - // Its gradient stops and dashes are owned data too, charged like the fill's. - if let Some(st) = &stroke { - let stops = match &st.paint { - Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::()), - _ => 0, - }; - let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::())); - if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) { - stroke = None; - } - } - // Underline (slot 0) and strikethrough (slot 1): 1 is a single line. - let mut decoration = |i: usize| match slot(ints, i, int) { - Some(1) => true, - Some(0) | None => false, - Some(_) => { - r.warn("text decorations of an unknown kind (left out)"); - false - } - }; - let (underline, strikethrough) = (decoration(0), decoration(1)); - let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0); - // A resolved font is populated on the emoji run of the public Affinity 3 text-runs file - // (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common. - // Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af - let requested = s.obj(attrs, b"DFnt"); - let resolved = - s.obj(attrs, b"RFnt").filter(|f| !s.str(*f, b"Famy").unwrap_or_default().is_empty() || !s.str(*f, b"Post").unwrap_or_default().is_empty()); - let font = resolved.or(requested); - if let Some(resolved) = resolved { - let differs = [b"Famy", b"Post"] - .iter() - .any(|tag| s.str(resolved, tag).unwrap_or_default() != requested.and_then(|f| s.str(f, tag)).unwrap_or_default()); - if differs { - r.warn("text uses a stored fallback font (imported with that font)"); - } - } - if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) { - r.warn("font width variants (exact face depends on the installed PostScript font)"); - } - let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default()); - let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default()); - let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget); - let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400); - let weight = if (1..=1000).contains(&weight) { - weight - } else { - r.warn("invalid font weight (imported as regular)"); - 400 - }; - TextRun { - text, - postscript, - family, - weight, - italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false), - features, - all_caps, - underline, - strikethrough, - size, - tracking, - h_scale, - leading, - fill, - stroke, - paragraph: Paragraph::default(), - } -} - -fn charge_attributes(r: &mut Reader, budget: &mut Option, bytes: usize) -> bool { - let Some(remaining) = *budget else { return false }; - match remaining.checked_sub(bytes) { - Some(remaining) => { - *budget = Some(remaining); - true - } - None => { - *budget = None; - r.warn("text attribute budget exceeded (excess font names, features and gradients left out)"); - false - } - } -} - -/// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it. -const MAX_FONT_NAME: usize = 256; - -fn font_name(r: &mut Reader, budget: &mut Option, value: &str) -> String { - if value.len() > MAX_FONT_NAME { - r.warn("font names longer than 256 bytes (the default font is used)"); - return String::new(); - } - metadata_string(r, budget, value) -} - -fn metadata_string(r: &mut Reader, budget: &mut Option, value: &str) -> String { - let bytes = value.len().saturating_add(std::mem::size_of::()); - if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() } -} - -/// The public Affinity 3 caps fixture stores `smcp` and the private `CAP\x01` selector in -/// OtAt/Setn. Only the observed all-caps selector and boolean OpenType tags are mapped; other -/// values are reported, never guessed. Standard tag syntax: https://learn.microsoft.com/en-us/typography/opentype/spec/featuretags -fn font_features(r: &mut Reader, attrs: Option, attribute_budget: &mut Option) -> (Vec, bool) { - let mut features = Vec::new(); - let mut all_caps = false; - if attribute_budget.is_none() { - return (features, all_caps); - } - let s = r.s; - let Some(Value::Array(settings)) = attrs.and_then(|a| s.field(a, b"Setn")) else { return (features, all_caps) }; - for (index, setting) in settings.iter().take(257).enumerate() { - if index == 256 { - r.warn("more than 256 text feature settings (truncated)"); - break; - } - let Value::Obj(Some(setting)) = setting else { - r.warn("unreadable text feature settings (imported with defaults)"); - continue; - }; - let setting = *setting; - let tag = s.int(setting, b"Feat").and_then(|v| u32::try_from(v).ok()).map(u32::to_be_bytes); - let value = r.s.int(setting, b"Valu"); - match (tag, value) { - (Some([b'C', b'A', b'P', 1]), Some(0 | 1)) => all_caps = value == Some(1), - (Some(tag), Some(value @ (0 | 1))) if tag.iter().all(u8::is_ascii_alphanumeric) => { - if !charge_attributes(r, attribute_budget, std::mem::size_of::() + if value == 0 { 5 } else { 4 }) { - break; - } - let name = String::from_utf8_lossy(&tag); - features.push(if value == 0 { format!("-{name}") } else { name.into_owned() }); - } - _ => r.warn("text feature selectors or non-boolean OpenType settings (imported with defaults)"), - } - } - (features, all_caps) -} - -/// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic -/// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading -/// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the -/// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space -/// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing -/// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the -/// thumbnail of a document whose owner made it earlier. -fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph { - let doubles = r.s.field(p, b"Doub"); - let ints = r.s.field(p, b"Ints"); - let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0); - let align = match slot(ints, 0, int) { - Some(1) => Align::Center, - Some(2) => Align::Right, - Some(3) => Align::Justify, - Some(0) | None => Align::Left, - Some(_) => { - r.warn("an unknown paragraph alignment (imported as left)"); - Align::Left - } - }; - let leading = match slot(ints, 1, int) { - Some(2) => Some(at(1)).filter(|l| *l > 0.0), - Some(0) | None => None, - Some(_) => { - r.warn("paragraph leading of an unknown kind (imported as automatic)"); - None - } - }; - let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0); - Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading } -} - -/// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set. -/// Neither the frame's own transform nor its groups' scale its type (checked against the -/// thumbnails of tables and frames inside scaled groups). -fn text_scale(r: &mut Reader, id: ObjId) -> Affine { - match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) { - Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]), - Some(_) => { - r.warn("text frames with an invalid text scale (imported unscaled)"); - Affine::IDENTITY - } - None => Affine::IDENTITY, - } -} diff --git a/crates/affinity/tests/it/outline_limits.rs b/crates/affinity/tests/it/outline_limits.rs index 74151c192..15e08eb6b 100644 --- a/crates/affinity/tests/it/outline_limits.rs +++ b/crates/affinity/tests/it/outline_limits.rs @@ -20,6 +20,10 @@ fn shape(id: u32, class: &[u8; 4], parameters: Vec<(vectorcraft_affinity::stream } fn read(definitions: Vec, top: u32) -> Document { + try_read(definitions, top).unwrap() +} + +fn try_read(definitions: Vec, top: u32) -> Result { let data = synth::stream(&[ (tag(b"Defs"), F::Shared(definitions)), ( @@ -39,7 +43,7 @@ fn read(definitions: Vec, top: u32) -> Document { ), ]); let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None); - vectorcraft_affinity::read(&bytes, Limits::default()).unwrap() + vectorcraft_affinity::read(&bytes, Limits::default()) } fn path(document: &Document) -> &Path { @@ -200,3 +204,79 @@ fn unlocked_rectangle_corners_keep_their_own_radius_and_type() { assert_eq!(sub.segments.len(), 8); assert_eq!(sub.segments[3][2], p(5.0, 20.0)); } + +/// The special shapes come in as their own outlines: one closed contour of finite points inside +/// (or, for a callout's tail, just below) the node's box, and none as the bounding ellipse. +#[test] +fn special_shapes_have_their_own_closed_outlines() { + for class in [b"ShCl", b"ShHt", b"ShCg", b"ShCR", b"ShCE", b"ShDA", b"ShDS", b"ShTr", b"ShCr", b"ShpD", b"ShSg"] { + let name = String::from_utf8_lossy(class).into_owned(); + let document = read(vec![shape(100, class, vec![])], 100); + assert!(!document.warnings.iter().any(|w| w.contains("bounding ellipse")), "{name}: {:?}", document.warnings); + let (Kind::Shape { path, even_odd, .. }, cog) = (&document.spreads[0].nodes[0].kind, class == b"ShCg") else { panic!("{name}: no shape") }; + // A cog is its teeth and its hole, filled even-odd. + assert_eq!((path.subpaths.len(), *even_odd), if cog { (2, true) } else { (1, false) }, "{name}"); + for sub in &path.subpaths { + assert!(sub.closed && sub.segments.len() >= 2, "{name}"); + for p in std::iter::once(sub.start).chain(sub.segments.iter().flatten().copied()) { + assert!(p.x.is_finite() && p.y.is_finite(), "{name}: {p:?}"); + assert!((-5.0..=15.0).contains(&p.x) && (-10.0..=30.0).contains(&p.y), "{name}: {p:?}"); + } + } + } + // A class the reader does not know is still its bounding ellipse, and said. + let document = read(vec![shape(100, b"ShZZ", vec![])], 100); + assert!(document.warnings.iter().any(|w| w.contains("unrecognized parametric shape")), "{:?}", document.warnings); +} + +fn group(id: u32, children: Vec) -> F { + F::Def(id, vec![tag(b"Grup")], vec![(tag(b"Chld"), F::Shared(children))]) +} + +/// A list's null slots yield no node, operand or segment, but each visit of a shared object scans +/// them all: they are charged to the document's work before the scan. +#[test] +fn null_slots_of_shared_lists_are_charged_for_every_visit() { + // A compound of a million null operands, placed 20 times: 20 million slots, over the 2^24. + let document = read(vec![compound(100, vec![F::Null; 1_000_000]), group(200, vec![F::Ref(100); 20])], 200); + assert!(document.warnings.iter().any(|w| w.contains("geometry work limit")), "{:?}", document.warnings); + let shapes = document.spreads[0].nodes[0].children.iter().filter(|n| matches!(n.kind, Kind::Shape { .. })).count(); + assert_eq!(shapes, 16, "each placement costs a million slots of the 2^24"); + // A group of a million null children, placed 20 times, fails the read like too many layers. + let result = try_read(vec![group(100, vec![F::Null; 1_000_000]), group(200, vec![F::Ref(100); 20])], 200); + assert!(matches!(result, Err(vectorcraft_affinity::Error::Limit(_))), "{:?}", result.map(|d| d.warnings)); + // Within the work of one document the same lists are read, nulls skipped. + let document = read(vec![group(100, vec![F::Null; 1_000]), group(200, vec![F::Ref(100); 20])], 200); + assert_eq!((document.spreads[0].nodes[0].children.len(), document.warnings.len()), (20, 0)); +} + +fn boxed(class: &[u8; 4], b: Vec) -> F { + F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(b)), (tag(b"Shpe"), F::Def(10_100, vec![tag(class)], vec![]))]) +} + +/// A callout rectangle (`ShCR`) whose box runs right to left (x0 > x1) panicked in `f64::clamp` +/// (min > max), found by the `stream` fuzz target. It reads, inside its box. +#[test] +fn callout_rectangle_with_a_reversed_box_reads_inside_it() { + for b in [vec![10.0_f64, 0.0, 0.0, 20.0], vec![6.8e38, 0.0, 10.0, 20.0]] { + let (lo, hi) = (b[0].min(b[2]), b[0].max(b[2])); + let document = read(vec![boxed(b"ShCR", b)], 100); + let sub = &path(&document).subpaths[0]; + let points = std::iter::once(&sub.start).chain(sub.segments.iter().flatten()); + assert!(points.into_iter().all(|q| q.x.is_finite() && q.y.is_finite() && (lo..=hi).contains(&q.x)), "{sub:?}"); + } +} + +/// A box with finite corners whose width overflows gave infinite and NaN outline points. Such a +/// shape imports as its box, with a warning. +#[test] +fn a_shape_too_large_to_compute_imports_as_its_box() { + for class in [b"ShCR", b"ShpE", b"ShCg"] { + let document = read(vec![boxed(class, vec![f64::MAX, 0.0, -f64::MAX, 20.0])], 100); + let sub = &path(&document).subpaths; + assert_eq!(sub.len(), 1, "{class:?}"); + let corners: Vec = std::iter::once(sub[0].start).chain(sub[0].segments.iter().map(|s| s[2])).collect(); + assert!(corners.iter().all(|p| p.x.abs() == f64::MAX && (p.y == 0.0 || p.y == 20.0)), "{class:?}: {corners:?}"); + assert!(document.warnings.iter().any(|w| w.contains("too large to compute")), "{:?}", document.warnings); + } +} diff --git a/crates/affinity/tests/support/affinity3-signatures.txt b/crates/affinity/tests/support/affinity3-signatures.txt index 13e8f0c53..79ed6df5e 100644 --- a/crates/affinity/tests/support/affinity3-signatures.txt +++ b/crates/affinity/tests/support/affinity3-signatures.txt @@ -19,7 +19,7 @@ patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=8776a8a0;counts={"depth": 1, patchy-live-filter.af dpi=72;spreads=1;model_crc32=12235fe9;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"] patchy-rgba16.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] patchy-rgba8.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-shapes-2.af dpi=72;spreads=1;model_crc32=886e9852;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"] +patchy-shapes-2.af dpi=72;spreads=1;model_crc32=6f174318;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 168, "shapes": 12, "strokes": 0, "subpaths": 13};boards=[];fonts={};warnings=[] patchy-shapes.af dpi=72;spreads=1;model_crc32=c1790e97;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[] patchy-text-artistic.af dpi=72;spreads=1;model_crc32=1d33192c;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[] patchy-text-caps.af dpi=72;spreads=1;model_crc32=24e52518;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"] diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml index 2ea6dea4f..3e262b076 100644 --- a/crates/cad/Cargo.toml +++ b/crates/cad/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-cad" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018)" +description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader" [lints] workspace = true diff --git a/crates/cad/src/hpgl.rs b/crates/cad/src/hpgl.rs new file mode 100644 index 000000000..5016e301f --- /dev/null +++ b/crates/cad/src/hpgl.rs @@ -0,0 +1,195 @@ +//! HP-GL import: a plotter file (HP-GL/2, `.plt`, `.hpgl`, `.hgl`, `.hpg`) as a document. Older +//! HP-GL files are read by the HP-GL/2 rules for the instructions the two share. +//! +//! The pen's moves become paths: each run drawn with the pen down is one stroked path in the +//! selected pen's color and width (0.35 mm unless PW sets another), with the line type as a dash +//! pattern and the line attributes as caps and joins. Circles, arcs, wedges, rectangles and Bézier +//! curves are drawn as curves (the chord angle isn't used). Filled rectangles, wedges and polygons +//! (polygon mode, FP) become filled paths, edged ones stroked paths. Plotter units (1016 to the +//! inch, y up) become points, through user units (SC, IP, IR) and the rotation (RO). The artboard +//! is the bounds of what the art paints, its strokes included. +//! +//! VectorCraft's own rules where the device decides: before the first SP the pen is pen 1 (a +//! device draws nothing until SP), and the hard-clip limits are 11 × 8.5 in from the origin, so +//! P2 lies there when the file sets no scaling points. SP0 draws nothing. Labels, symbol mode's symbols and raster +//! images (HP RTL) aren't drawn, the input window doesn't clip, hatched fills are filled solid, +//! and the art of every page goes on one artboard; each comes back as a warning. Printer escape +//! sequences and PJL lines around the instructions are skipped. +//! +//! - `lex`: the bytes as instructions, numbers, labels and PE data. +//! - `plot`: the instructions played on the pen, as shapes. + +mod lex; +mod plot; + +use std::sync::Arc; + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, LayerColor, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Affine, PathData, Rect}; + +use crate::Imported; +use lex::Lexer; +use plot::Plot; + +/// Points per plotter unit. +const PT_PER_PLU: f64 = 72.0 / 1016.0; +/// The weight of a zero-width pen (one device dot), in points. +const THINNEST: f64 = 0.25; + +/// Instructions the reader knows and draws nothing for (their parameters are read and dropped). +const IGNORED: &[[u8; 2]] = &[ + *b"AC", *b"AD", *b"AF", *b"AH", *b"AP", *b"AS", *b"CA", *b"CF", *b"CM", *b"CP", *b"CS", *b"CT", *b"CV", *b"DC", *b"DI", *b"DL", *b"DP", *b"DR", + *b"DS", *b"DV", *b"EC", *b"ES", *b"FI", *b"FN", *b"FS", *b"GM", *b"IM", *b"IV", *b"KY", *b"LM", *b"LO", *b"MC", *b"MG", *b"MT", *b"NR", *b"OA", + *b"OC", *b"OD", *b"OE", *b"OF", *b"OG", *b"OH", *b"OI", *b"OL", *b"OP", *b"OS", *b"OT", *b"OW", *b"PB", *b"PP", *b"PS", *b"PT", *b"QL", *b"RF", + *b"RP", *b"SA", *b"SB", *b"SD", *b"SG", *b"SI", *b"SL", *b"SR", *b"SS", *b"ST", *b"SV", *b"TD", *b"TL", *b"TR", *b"UC", *b"VA", *b"VN", *b"VS", + *b"XT", *b"YT", +]; + +/// Instructions the reader plays. +const PLAYED: &[[u8; 2]] = &[ + *b"AA", *b"AR", *b"AT", *b"BL", *b"BP", *b"BR", *b"BZ", *b"CI", *b"CO", *b"CR", *b"DF", *b"DT", *b"EA", *b"EP", *b"ER", *b"EW", *b"FP", *b"FR", + *b"FT", *b"IN", *b"IP", *b"IR", *b"IW", *b"LA", *b"LB", *b"LT", *b"NP", *b"PA", *b"PC", *b"PD", *b"PE", *b"PG", *b"PM", *b"PR", *b"PU", *b"PW", + *b"RA", *b"RO", *b"RR", *b"RT", *b"SC", *b"SM", *b"SP", *b"UL", *b"WD", *b"WG", *b"WU", +]; + +/// Instructions a plot file starts with. +const FIRST: &[[u8; 2]] = &[ + *b"IN", *b"DF", *b"BP", *b"CO", *b"PS", *b"SP", *b"PU", *b"PD", *b"PA", *b"PR", *b"IP", *b"IR", *b"SC", *b"NP", *b"PC", *b"PW", *b"WU", *b"RO", + *b"QL", *b"MC", *b"PP", *b"TR", *b"CR", *b"LT", *b"LA", *b"FT", *b"VS", *b"FS", *b"AC", *b"PG", *b"PE", *b"DT", +]; + +/// Does `bytes` start like an HP-GL file? After any printer escapes and PJL lines (however long), +/// it must hold nothing but instructions the reader knows, the first one of those a plot file +/// starts with, up to the eighth instruction or 4 KB past the first. Their parameters must be +/// numbers, but for a comment's quoted string and the text, characters and encoded data of LB, BL, +/// WD, DT, SM and PE. An instruction the reader doesn't know counts when its numbers end with a +/// semicolon. +pub fn is_hpgl(bytes: &[u8]) -> bool { + holds_instructions(bytes, true) +} + +/// Does `bytes` hold HP-GL instructions as [`is_hpgl`] checks? When `strict` is false, any of them +/// may come first: a file picked by its extension may start with one a plot file seldom starts +/// with (`SI`, `DI`). Either way, at least one of the instructions checked must be one the reader +/// knows. +fn holds_instructions(bytes: &[u8], strict: bool) -> bool { + let mut lex = Lexer::new(bytes); + let mut terminator = lex::ETX; + let mut first = 0; + let mut any_known = false; + for n in 0..8 { + if n > 0 && lex.pos() > first + 4096 { + return any_known; + } + let Some(op) = lex.op(true) else { return n > 0 && any_known && lex.done() }; + if n == 0 { + first = lex.pos(); + } + if strict && n == 0 && !FIRST.contains(&op) { + return false; + } + let known = PLAYED.contains(&op) || IGNORED.contains(&op); + any_known |= known; + match &op { + b"CO" => { + if !lex.comment() { + return false; + } + } + b"LB" | b"BL" | b"WD" => lex.label(terminator), + b"DT" => { + terminator = lex.char().unwrap_or(lex::ETX); + lex.params(); + } + b"SM" => { + // The symbol character is read so it isn't taken for an instruction. + let _ = lex.char(); + } + b"PE" => lex.skip_pe(), + _ if known => lex.params(), + // An instruction the reader doesn't know (of an older or a device's own set) counts + // when numbers and a semicolon follow it. + _ => { + if !lex.closed_params() { + return false; + } + } + } + } + any_known +} + +/// Read an HP-GL file. It may start with any instruction the reader knows, or one of another set +/// that a semicolon ends; the rest of [`is_hpgl`]'s check applies, and at least one instruction the +/// reader knows must be among those it checks. +pub fn import(bytes: &[u8]) -> Result { + if !holds_instructions(bytes, false) { + return Err("not an HP-GL file".into()); + } + let mut lex = Lexer::new(bytes); + let mut plot = Plot::new(); + while !plot.full + && let Some(op) = lex.op(false) + { + plot.op(op, &mut lex); + } + Ok(build(plot.finish(lex.raster))) +} + +fn rgb(c: [u8; 3]) -> Paint { + Paint::solid(Color::rgb8(c[0], c[1], c[2])) +} + +/// The plot's shapes as paths on one layer, the artboard round what they paint. +fn build(plot: Plot) -> Imported { + // Plotter units, y up, to points, y down. + let flip = Affine::scale_non_uniform(PT_PER_PLU, -PT_PER_PLU); + let mut nodes: Vec = plot.shapes.iter().map(|s| node(s, flip)).collect(); + // The strokes' caps and miter joins included. + let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()); + let frame = nodes.iter().filter_map(Node::visual_bounds).filter(finite).reduce(|a, b| a.union(b)).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); + let mut doc = Document::new(frame.width().max(1.0), frame.height().max(1.0)); + doc.layers.clear(); + let shift = Affine::translate(-frame.origin().to_vec2()); + for n in &mut nodes { + n.id = doc.alloc_id(); + if let NodeKind::Path { path, .. } = &mut n.kind { + path.transform(shift); + } + } + let mut layer = Node::layer(doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); + if let Some(c) = layer.children_mut() { + *c = nodes.into_iter().map(Arc::new).collect(); + } + doc.layers = vec![Arc::new(layer)]; + Imported { document: doc, warnings: plot.warnings } +} + +/// A shape as a path, its plotter units through `to_points`. +fn node(s: &plot::Shape, to_points: Affine) -> Node { + let mut path = s.path.clone(); + path.apply_affine(to_points); + let mut items = vec![]; + if let Some((c, _)) = s.fill { + items.push(AppearanceItem::Fill(FillLayer::new(rgb(c)))); + } + if let Some(st) = &s.stroke { + let mut layer = StrokeLayer::new(rgb(st.color), (st.width * PT_PER_PLU).max(THINNEST)); + (layer.cap, layer.join, layer.miter_limit) = (st.cap, st.join, st.miter); + layer.dash = st.dash.as_ref().map(|d| Dash { + pattern: d.pattern.iter().map(|v| v * PT_PER_PLU).collect(), + offset: d.offset * PT_PER_PLU, + align_corners: d.adaptive, + }); + items.push(AppearanceItem::Stroke(layer)); + } + let mut n = Node::path(NodeId(0), PathData::from_bezpath(&path), Appearance { items, ..Appearance::default() }); + if let (NodeKind::Path { rule, .. }, Some((_, r))) = (&mut n.kind, s.fill) { + *rule = r; + } + n +} + +#[cfg(test)] +mod tests; diff --git a/crates/cad/src/hpgl/lex.rs b/crates/cad/src/hpgl/lex.rs new file mode 100644 index 000000000..5025d07c6 --- /dev/null +++ b/crates/cad/src/hpgl/lex.rs @@ -0,0 +1,428 @@ +//! The bytes of an HP-GL file as instructions: two-letter mnemonics and the parameters that follow +//! them, read one at a time as the plot asks for them. Printer escape sequences (PCL, HP RTL, the +//! HP-GL device-control escapes) and PJL lines around and between the instructions are skipped, +//! with the data their escapes carry. + +/// The end-of-text character, the default label terminator. +pub(super) const ETX: u8 = 3; +const ESC: u8 = 0x1b; + +/// The most digits of a number that count: longer ones are read as their first digits. +const MAX_DIGITS: usize = 18; + +/// What the bytes being read hold. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mode { + /// HP-GL instructions. + Hpgl, + /// PCL or HP RTL: escape sequences and their data, nothing to draw. + Pcl, + /// After a Universal Exit Language: PJL lines until a language starts. + Pjl, +} + +/// One item of a PE (Polyline Encoded) instruction. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum PeItem { + /// Select pen. + Pen(i64), + /// Rectangle mode: the following coordinates come in fours. + Rect, + /// A coordinate pair, after a pen-up flag (`up`) and an absolute flag (`absolute`). + Point { x: f64, y: f64, up: bool, absolute: bool }, +} + +/// The state of one PE instruction being read. +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct Pe { + seven_bit: bool, + fraction_bits: u32, + up: bool, + absolute: bool, +} + +pub(super) struct Lexer<'a> { + b: &'a [u8], + at: usize, + mode: Mode, + /// The file holds HP RTL or PCL raster image data. + pub raster: bool, +} + +/// Is `c` a byte that separates numeric parameters (or white space between instructions)? +fn separator(c: u8) -> bool { + matches!(c, b' ' | b',' | b'\t' | b'\r' | b'\n' | 0) +} + +impl<'a> Lexer<'a> { + pub fn new(b: &'a [u8]) -> Self { + Self { b, at: 0, mode: Mode::Hpgl, raster: false } + } + + fn peek(&self) -> Option { + self.b.get(self.at).copied() + } + + /// Whether everything has been read. + pub fn done(&self) -> bool { + self.at >= self.b.len() + } + + /// How many bytes have been read. + pub fn pos(&self) -> usize { + self.at + } + + /// The next instruction's mnemonic, in upper case; `strict`: only white space, semicolons and + /// escape sequences may come before it (anything else gives `None`, as the end does). + pub fn op(&mut self, strict: bool) -> Option<[u8; 2]> { + loop { + let c = self.peek()?; + if c == ESC { + self.escape(); + continue; + } + match self.mode { + Mode::Pcl => { + self.at += 1; + continue; + } + Mode::Pjl => { + if separator(c) { + self.at += 1; + } else if c == b'@' { + self.pjl_line(); + } else { + self.mode = Mode::Hpgl; + } + continue; + } + Mode::Hpgl => {} + } + let next = self.b.get(self.at + 1).copied(); + if c.is_ascii_alphabetic() + && let Some(n) = next.filter(u8::is_ascii_alphabetic) + { + self.at += 2; + return Some([c.to_ascii_uppercase(), n.to_ascii_uppercase()]); + } + if strict && !(separator(c) || c == b';' || c < 0x20) { + return None; + } + self.at += 1; + } + } + + /// One `@PJL` line: an `ENTER LANGUAGE` command picks what follows. + fn pjl_line(&mut self) { + let rest = &self.b[self.at..]; + let end = rest.iter().position(|&c| c == b'\n').map_or(rest.len(), |n| n + 1); + // A command names its language within its first 256 bytes; longer lines aren't copied. + let line = String::from_utf8_lossy(&rest[..end.min(256)]).to_ascii_uppercase(); + if let Some(lang) = line.split_once("LANGUAGE").map(|(_, l)| l.trim_start_matches([' ', '=']).trim()) { + self.mode = if lang.starts_with("HPGL") { Mode::Hpgl } else { Mode::Pcl }; + } + self.at += end; + } + + /// An escape sequence at the current byte, with the data it carries. + fn escape(&mut self) { + self.at += 1; + let Some(c) = self.peek() else { return }; + self.at += 1; + match c { + // Universal Exit Language (`ESC%-12345X`) and the language switches (`ESC%#B` to HP-GL, + // `ESC%#A` to PCL or HP RTL). + b'%' => { + let (_, end) = self.value(); + match end { + Some(b'X') => self.mode = Mode::Pjl, + Some(b'B') => self.mode = Mode::Hpgl, + Some(b'A') => self.mode = Mode::Pcl, + _ => {} + } + } + // Reset: PCL follows, until a language switch. + b'E' => self.mode = Mode::Pcl, + // HP-GL device control: `ESC.` and a letter, with parameters up to a colon. + b'.' => { + let Some(kind) = self.peek() else { return }; + self.at += 1; + if !b"()YZBEJKLORUX".contains(&kind) { + let rest = &self.b[self.at..self.b.len().min(self.at + 80)]; + if let Some(n) = rest.iter().position(|&c| c == b':') + && rest[..n].iter().all(|c| c.is_ascii_digit() || matches!(c, b';' | b' ' | b',')) + { + self.at += n + 1; + } + } + } + // Parameterized PCL: a group character, then values each ending in a letter (upper case + // ends the sequence); `W`, `*b…V` (raster data by plane) and `&p…X` (transparent print + // data) are followed by that many bytes of data. Without a group character (`ESC(8U`, + // `ESC)0N`), one value and the letter ending it. + 0x21..=0x2f => { + let Some(group) = self.peek().filter(|g| (0x60..=0x7e).contains(g)) else { + self.value(); + return; + }; + self.at += 1; + loop { + let (v, end) = self.value(); + let Some(end) = end else { return }; + let upper = end.to_ascii_uppercase(); + if upper == b'W' || (c, group, upper) == (b'*', b'b', b'V') || (c, group, upper) == (b'&', b'p', b'X') { + let n = v.max(0.0).min((self.b.len() - self.at) as f64) as usize; + if (c, group) == (b'*', b'b') && n > 0 { + self.raster = true; + } + self.at += n; + } + if (c, group, end) == (b'*', b'r', b'A') { + self.raster = true; + } + if !end.is_ascii_lowercase() { + return; + } + } + } + // A two-character escape. + _ => {} + } + } + + /// A PCL value (`[+-]digits[.digits]`, any part missing) and the character that ends it; `None` + /// when that is not a letter or `@` (which is then left unread). + fn value(&mut self) -> (f64, Option) { + let v = self.number_here().unwrap_or(0.0); + match self.peek() { + Some(c) if c.is_ascii_alphabetic() || c == b'@' => { + self.at += 1; + (v, Some(c)) + } + _ => (v, None), + } + } + + /// A number starting at the current byte (`[+-]digits[.digits]`), `None` when it has no digit + /// (its sign and point are read all the same). + fn number_here(&mut self) -> Option { + let mut neg = false; + if let Some(c @ (b'+' | b'-')) = self.peek() { + neg = c == b'-'; + self.at += 1; + } + let (mut v, mut digits, mut scale) = (0.0f64, 0usize, 1.0f64); + let mut point = false; + while let Some(c) = self.peek() { + if c.is_ascii_digit() { + if digits < MAX_DIGITS { + if point { + scale /= 10.0; + v += f64::from(c - b'0') * scale; + } else { + v = v * 10.0 + f64::from(c - b'0'); + } + } else if !point { + // Past the digits that count, an integer part keeps its size. + v *= 10.0; + } + digits += 1; + } else if c == b'.' && !point { + point = true; + } else { + break; + } + self.at += 1; + } + let v = v.min(f64::MAX); + (digits > 0).then_some(if neg { -v } else { v }) + } + + /// The next numeric parameter of the instruction being read, or `None` at its end (a semicolon + /// ending it is read). + pub fn number(&mut self) -> Option { + loop { + let c = self.peek()?; + if separator(c) { + self.at += 1; + } else if c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.') { + if let Some(v) = self.number_here() { + return Some(v); + } + } else { + if c == b';' { + self.at += 1; + } + return None; + } + } + } + + /// Up to `out.len()` numeric parameters into `out` (the rest are read and dropped) → how many. + pub fn numbers(&mut self, out: &mut [f64]) -> usize { + let mut n = 0; + while let Some(v) = self.number() { + if let Some(slot) = out.get_mut(n) { + *slot = v; + } + n += 1; + } + n.min(out.len()) + } + + /// Read and drop the parameters of the instruction being read: numbers and quoted strings. + pub fn params(&mut self) { + loop { + while self.number().is_some() {} + if self.peek() != Some(b'"') { + return; + } + self.quoted(); + } + } + + /// Read and drop numeric parameters → whether a semicolon ended them (an instruction the reader + /// doesn't know, written out in full). + pub fn closed_params(&mut self) -> bool { + while self.number().is_some() {} + self.at > 0 && self.b.get(self.at - 1) == Some(&b';') + } + + /// A comment (CO): its quoted string or nothing, then the terminator or the end → whether it + /// is one. + pub fn comment(&mut self) -> bool { + while self.peek().is_some_and(separator) { + self.at += 1; + } + match self.peek() { + Some(b'"') => self.quoted(), + Some(b';') => { + self.at += 1; + true + } + None => true, + Some(_) => false, + } + } + + /// The text of a label, up to and including `terminator` (or the end). + pub fn label(&mut self, terminator: u8) { + let rest = &self.b[self.at..]; + self.at += rest.iter().position(|&c| c == terminator).map_or(rest.len(), |n| n + 1); + } + + /// A quoted string (CO): the parameters up to its closing quote, then the terminator → whether + /// there was one. + pub fn quoted(&mut self) -> bool { + while self.peek().is_some_and(separator) { + self.at += 1; + } + let found = self.peek() == Some(b'"'); + if found { + self.at += 1; + let rest = &self.b[self.at..]; + self.at += rest.iter().position(|&c| c == b'"').map_or(rest.len(), |n| n + 1); + } + if self.peek() == Some(b';') { + self.at += 1; + } + found + } + + /// The character right after the mnemonic (DT, SM), `None` when it is a semicolon or the end + /// (the semicolon is read). + pub fn char(&mut self) -> Option { + let c = self.peek()?; + self.at += 1; + (c != b';').then_some(c) + } + + /// The next item of a PE instruction, `None` at its end (its semicolon is read). + pub fn pe(&mut self, st: &mut Pe) -> Option { + loop { + let raw = self.peek()?; + let c = raw & 0x7f; + // Spaces, deletes, control characters and bytes 128 to 160 and 255 are ignored. + if raw <= 32 || raw == 127 || (128..=160).contains(&raw) || raw == 255 { + self.at += 1; + continue; + } + match c { + b';' => { + self.at += 1; + return None; + } + // Flags keep their meaning with the eighth bit set; digits are 63 and up (191 and up). + b':' | b'<' | b'>' | b'=' | b'7' | b'9' if raw < 63 || (0xb7..191).contains(&raw) => { + self.at += 1; + match c { + b':' => return self.pe_value(st).map(PeItem::Pen).or_else(|| self.end_pe()), + b'<' => st.up = true, + b'>' => match self.pe_value(st) { + Some(n) => st.fraction_bits = n.clamp(0, 52) as u32, + None => return self.end_pe(), + }, + b'=' => st.absolute = true, + b'7' => st.seven_bit = true, + _ => return Some(PeItem::Rect), + } + } + _ => { + let scale = 0.5f64.powi(st.fraction_bits as i32); + let (Some(x), Some(y)) = (self.pe_value(st), self.pe_value(st)) else { return self.end_pe() }; + let item = PeItem::Point { x: x as f64 * scale, y: y as f64 * scale, up: st.up, absolute: st.absolute }; + (st.up, st.absolute) = (false, false); + return Some(item); + } + } + } + } + + /// The rest of a PE instruction that can't be decoded: see [`Lexer::skip_pe`]. + fn end_pe(&mut self) -> Option { + self.skip_pe(); + None + } + + /// The data of a PE instruction, up to and including its semicolon, unread: its data is never + /// read as instructions. + pub fn skip_pe(&mut self) { + let rest = &self.b[self.at..]; + self.at += rest.iter().position(|&c| c == b';').map_or(rest.len(), |n| n + 1); + } + + /// One encoded PE number, `None` when the instruction ends inside it (or a byte that is no + /// digit comes first, which is read). + fn pe_value(&mut self, st: &Pe) -> Option { + let (base, low, high): (u64, std::ops::RangeInclusive, std::ops::RangeInclusive) = + if st.seven_bit { (32, 63..=94, 95..=126) } else { (64, 63..=126, 191..=254) }; + let (mut v, mut place) = (0u64, 1u64); + loop { + let raw = self.peek()?; + let c = if st.seven_bit { raw & 0x7f } else { raw }; + if raw <= 32 || raw == 127 || (128..=160).contains(&raw) || raw == 255 { + self.at += 1; + continue; + } + if c == b';' { + return None; + } + self.at += 1; + let (digit, last) = if low.contains(&c) { + (u64::from(c - 63), false) + } else if high.contains(&c) { + (u64::from(c - high.start()), true) + } else { + return None; + }; + v = v.saturating_add(digit.saturating_mul(place)); + place = place.saturating_mul(base); + if last { + // The low bit is the sign. + let n = i64::try_from(v >> 1).unwrap_or(i64::MAX); + return Some(if v & 1 == 1 { -n } else { n }); + } + } + } +} diff --git a/crates/cad/src/hpgl/plot.rs b/crates/cad/src/hpgl/plot.rs new file mode 100644 index 000000000..2ee70da75 --- /dev/null +++ b/crates/cad/src/hpgl/plot.rs @@ -0,0 +1,1331 @@ +//! The HP-GL/2 instructions played on a model of the plotter: the coordinate system (scaling +//! points, user units, rotation), the pen (position, up or down, number, width, color), the line +//! and fill attributes and the polygon buffer. What the pen draws is collected as shapes in +//! rotated plotter units, y up. + +use std::collections::HashMap; + +use kurbo::{Affine, BezPath, PathEl, Point, Shape as _, Vec2}; +use vectorcraft_doc::{LineCap, LineJoin}; +use vectorcraft_geom::FillRule; + +use super::lex::{ETX, Lexer, Pe, PeItem}; + +/// Plotter units per millimeter (0.025 mm each). +pub(super) const PLU_PER_MM: f64 = 40.0; +/// The hard-clip limits taken for P1 and P2 when the file sets none (plotter units): 11 × 8.5 in. +pub(super) const HARD_CLIP: Point = Point::new(11176.0, 8636.0); +/// The pen width after IN, in millimeters. +const DEFAULT_WIDTH_MM: f64 = 0.35; +/// The default line pattern length, in percent of the P1-P2 diagonal. +const DEFAULT_PATTERN: f64 = 4.0; +/// How far from the origin art may lie (plotter units, twice the range the format defines). +pub(super) const LIMIT: f64 = 16_777_216.0; +/// The widest pen (plotter units): 409.6 mm, the least a device must take before it draws its own +/// widest pen instead. +const MAX_WIDTH: f64 = 16_384.0; +/// The most objects an import makes. +const MAX_SHAPES: usize = 1 << 20; +/// The most path elements an import makes. +const MAX_ELEMENTS: usize = if cfg!(test) { 200_000 } else { 4_000_000 }; +/// The most pens a palette holds. +const MAX_PENS: i64 = 32_768; + +pub(super) const LABELS: &str = "labels were left out"; +pub(super) const SYMBOLS: &str = "symbols drawn at plotted points (symbol mode) were left out"; +pub(super) const RASTER: &str = "raster images in the file were left out"; +pub(super) const HATCHES: &str = "hatched and patterned fills are filled solid"; +pub(super) const WINDOW: &str = "the input window doesn't clip the art"; +pub(super) const PAGES: &str = "the art of later pages is drawn over the first page"; +pub(super) const FAR_AWAY: &str = "objects far outside the drawing were left out"; +pub(super) const TOO_MANY: &str = "the file has more than 1,048,576 objects: the rest are left out"; +pub(super) const TOO_MANY_POINTS: &str = "the file has more than 4,000,000 path points: the rest are left out"; + +/// The instructions polygon mode plays (the others are ignored there). +const POLYGON_MODE: &[[u8; 2]] = + &[*b"AA", *b"AR", *b"AT", *b"BP", *b"BR", *b"BZ", *b"CI", *b"CO", *b"DF", *b"IN", *b"PA", *b"PD", *b"PE", *b"PM", *b"PR", *b"PU", *b"RT"]; + +/// The colors of pens 0 to 7 until PC sets others. +const PALETTE: [[u8; 3]; 8] = [[255, 255, 255], [0, 0, 0], [255, 0, 0], [0, 255, 0], [255, 255, 0], [0, 0, 255], [255, 0, 255], [0, 255, 255]]; + +/// Line types 1 to 8: pen-down and pen-up lengths in percent of the pattern length. +const LINE_TYPES: [&[f64]; 8] = [ + &[0.0, 100.0], + &[50.0, 50.0], + &[70.0, 30.0], + &[80.0, 10.0, 0.0, 10.0], + &[70.0, 10.0, 10.0, 10.0], + &[50.0, 10.0, 10.0, 10.0, 10.0, 10.0], + &[70.0, 10.0, 0.0, 10.0, 0.0, 10.0], + &[50.0, 10.0, 0.0, 10.0, 10.0, 10.0, 0.0, 10.0], +]; + +/// A stroke as the pen draws it (lengths in plotter units). +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Stroke { + pub color: [u8; 3], + pub width: f64, + pub cap: LineCap, + pub join: LineJoin, + pub miter: f64, + pub dash: Option, +} + +/// A line type as dashes (lengths in plotter units). +#[derive(Clone, Debug, PartialEq)] +pub(super) struct LinePattern { + /// Dash, gap, dash, gap…: an even number of lengths. + pub pattern: Vec, + /// How far into the pattern each line starts. + pub offset: f64, + /// The pattern stretches to fit each line, half its first dash at each end (adaptive line + /// types). + pub adaptive: bool, +} + +impl LinePattern { + /// The pattern of `percent` (pen-down and pen-up stretches, in turn, starting pen down) of + /// `length`. A pen-down stretch of zero is a dot, a vector one plotter unit long that the gap + /// after it gives up. An odd count repeats with its last pen-down stretch running into the + /// first one, which the pattern then starts within. + fn new(percent: &[f64], length: f64, adaptive: bool) -> Self { + let mut pattern: Vec = percent.iter().map(|p| p / 100.0 * length).collect(); + for pair in pattern.chunks_mut(2) { + if let [dash, rest @ ..] = pair + && *dash == 0.0 + { + *dash = 1.0; + if let Some(gap) = rest.first_mut() { + *gap = (*gap - 1.0).max(0.0); + } + } + } + let mut offset = 0.0; + if pattern.len() % 2 == 1 { + match pattern.len() { + // One pen-down stretch: a solid line. + 1 => pattern.push(0.0), + _ => { + let last = pattern.pop().unwrap_or(0.0); + if let Some(first) = pattern.first_mut() { + *first += last; + } + offset = last; + } + } + } + Self { pattern, offset, adaptive } + } +} + +/// What the plot draws: an outline with a fill, a stroke or both. +#[derive(Clone, Debug)] +pub(super) struct Shape { + pub path: BezPath, + pub fill: Option<([u8; 3], FillRule)>, + pub stroke: Option, +} + +/// A piece of a line the pen moves along, by its end. +#[derive(Clone, Copy, Debug)] +enum Piece { + Line(Point), + Curve(Point, Point, Point), +} + +impl Piece { + fn end(self) -> Point { + match self { + Self::Line(p) | Self::Curve(_, _, p) => p, + } + } + + fn push(self, bp: &mut BezPath) { + match self { + Self::Line(p) => bp.line_to(p), + Self::Curve(a, b, c) => bp.curve_to(a, b, c), + } + } +} + +/// User units: an SC instruction in force. +#[derive(Clone, Copy, Debug)] +enum Scaling { + /// The user ranges mapped onto P1 and P2; isotropic with the left and bottom percentages. + Fit { x0: f64, x1: f64, y0: f64, y1: f64, iso: Option<(f64, f64)> }, + /// P1's user coordinates and the plotter units per user unit. + Factor { x0: f64, fx: f64, y0: f64, fy: f64 }, +} + +/// How areas are filled. +#[derive(Clone, Copy, Debug, PartialEq)] +enum FillKind { + Solid, + /// The pen's color at this percentage, on white. + Shade(f64), + /// Hatching and patterns, filled solid. + Pattern, +} + +/// The polygon buffer: its outline for filling (every edge) and for edging (pen-down edges only). +#[derive(Clone, Debug, Default)] +struct Poly { + fill: BezPath, + edge: BezPath, + /// A subpolygon is open, from `start`; its last point. + open: bool, + start: Point, + last: Point, + /// The open subpolygon's points, and where its elements start in `fill` and `edge`. + points: usize, + marks: (usize, usize), + /// Where the edge outline ends (`None`: it has to move before drawing). + edge_at: Option, + /// After PM1 or a circle: the next move starts a subpolygon. + fresh: bool, +} + +impl Poly { + fn add(&mut self, a: Point, pieces: &[Piece], down: bool) { + let Some(end) = pieces.last().map(|p| p.end()) else { return }; + // The move after PM1 goes to the next subpolygon's first point with the pen up, whatever + // the pen's state (an arc's first chord aside): it is neither filled nor edged. + if std::mem::take(&mut self.fresh) && matches!(pieces, [Piece::Line(_)]) { + (self.start, self.last) = (end, end); + return; + } + if !self.open { + self.marks = (self.fill.elements().len(), self.edge.elements().len()); + self.fill.move_to(a); + (self.start, self.open, self.points) = (a, true, 1); + } + pieces.iter().for_each(|p| p.push(&mut self.fill)); + self.points += pieces.len(); + if down { + if self.edge_at != Some(a) { + self.edge.move_to(a); + } + pieces.iter().for_each(|p| p.push(&mut self.edge)); + self.edge_at = Some(end); + } else { + self.edge_at = None; + } + self.last = end; + } + + /// Close the open subpolygon, adding a point at its start when it ends elsewhere; the edge to + /// that point is drawn when `down`. A subpolygon of fewer than three points (that one + /// included) is neither filled nor edged. → The point added. + fn close(&mut self, down: bool) -> Option { + if !self.open { + return None; + } + let closure = self.last != self.start; + if self.points + usize::from(closure) < 3 { + self.fill.truncate(self.marks.0); + self.edge.truncate(self.marks.1); + } else { + if closure && down { + if self.edge_at != Some(self.last) { + self.edge.move_to(self.last); + } + self.edge.line_to(self.start); + } + self.fill.close_path(); + } + (self.open, self.edge_at) = (false, None); + closure.then_some(self.start) + } + + /// A closed outline of its own (a circle): the open subpolygon is closed with the pen up + /// first, and the next move starts a subpolygon. + fn closed(&mut self, path: &BezPath) { + self.close(false); + self.fill.extend(path.iter()); + self.edge.extend(path.iter()); + self.fresh = true; + } +} + +/// The line drawn so far with the pen down, not yet a shape. +struct Run { + path: BezPath, + stroke: Stroke, + start: Point, + end: Point, +} + +pub(super) struct Plot { + pub shapes: Vec, + pub warnings: Vec, + /// A budget ran out: nothing more is drawn. + pub full: bool, + elements: usize, + // The coordinate system. + p1: Point, + p2: Point, + scaling: Option, + /// User units to plotter units (identity when scaling is off). + user: Affine, + /// The RO rotation (rotated plotter units to where the art lies) and its angle. + rot: Affine, + ro: u16, + // The pen. + relative: bool, + down: bool, + /// The pen's position, in plotter units before rotation. + pos: Point, + /// The pen SP selected (`None`: none yet, which draws with pen 1). + pen: Option, + pens: i64, + colors: HashMap, + ranges: [(f64, f64); 3], + width: f64, + widths: HashMap, + relative_widths: bool, + // Line and fill attributes. + line_type: Option, + saved_line_type: Option, + pattern: f64, + pattern_mm: bool, + user_types: [Option>; 8], + cap: LineCap, + join: LineJoin, + miter: f64, + fill: FillKind, + // Polygons. + poly: Option, + buffer: Option, + run: Option, + terminator: u8, + /// Instructions of kinds the reader doesn't know. + pub unknown: usize, + /// How many shapes there were at the first page advance. + page_end: Option, +} + +impl Plot { + pub fn new() -> Self { + let mut p = Self { + shapes: vec![], + warnings: vec![], + full: false, + elements: 0, + p1: Point::ZERO, + p2: HARD_CLIP, + scaling: None, + user: Affine::IDENTITY, + rot: Affine::IDENTITY, + ro: 0, + relative: false, + down: false, + pos: Point::ZERO, + pen: None, + pens: 8, + colors: HashMap::new(), + ranges: [(0.0, 255.0); 3], + width: DEFAULT_WIDTH_MM * PLU_PER_MM, + widths: HashMap::new(), + relative_widths: false, + line_type: None, + saved_line_type: None, + pattern: DEFAULT_PATTERN, + pattern_mm: false, + user_types: Default::default(), + cap: LineCap::Butt, + join: LineJoin::Miter, + miter: 5.0, + fill: FillKind::Solid, + poly: None, + buffer: None, + run: None, + terminator: ETX, + unknown: 0, + page_end: None, + }; + p.init(); + p + } + + pub fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + // ---------- defaults ---------- + + /// DF: the line and fill attributes, scaling, plotting mode and polygon mode as they start. + fn defaults(&mut self) { + self.flush(); + self.relative = false; + (self.line_type, self.saved_line_type, self.pattern, self.pattern_mm) = (None, None, DEFAULT_PATTERN, false); + self.user_types = Default::default(); + (self.cap, self.join, self.miter) = (LineCap::Butt, LineJoin::Miter, 5.0); + self.fill = FillKind::Solid; + (self.poly, self.buffer) = (None, None); + self.scaling = None; + self.ranges = [(0.0, 255.0); 3]; + self.terminator = ETX; + self.update_user(); + } + + /// IN: DF, and the pen up at the origin, default widths, palette and scaling points, no + /// rotation. The selected pen stays. + fn init(&mut self) { + self.defaults(); + (self.pos, self.down) = (Point::ZERO, false); + (self.width, self.relative_widths) = (DEFAULT_WIDTH_MM * PLU_PER_MM, false); + self.widths.clear(); + (self.rot, self.ro) = (Affine::IDENTITY, 0); + (self.p1, self.p2) = (Point::ZERO, HARD_CLIP); + self.pens = 8; + self.colors.clear(); + self.update_user(); + } + + // ---------- coordinates ---------- + + /// User units to plotter units for the scaling in force; scaling that maps everything onto one + /// point is ignored. + fn update_user(&mut self) { + let (p1, p2) = (self.p1, self.p2); + let user = match self.scaling { + None => Affine::IDENTITY, + Some(Scaling::Factor { x0, fx, y0, fy }) => { + Affine::translate(p1.to_vec2()) * Affine::scale_non_uniform(fx, fy) * Affine::translate((-x0, -y0)) + } + Some(Scaling::Fit { x0, x1, y0, y1, iso }) => { + let (w, h, ux, uy) = (p2.x - p1.x, p2.y - p1.y, x1 - x0, y1 - y0); + let (mut sx, mut sy, mut off) = (w / ux, h / uy, Vec2::ZERO); + if let Some((left, bottom)) = iso { + let s = sx.abs().min(sy.abs()); + let (extra_x, extra_y) = (w.abs() - ux.abs() * s, h.abs() - uy.abs() * s); + off = Vec2::new(extra_x * left / 100.0 * w.signum(), extra_y * bottom / 100.0 * h.signum()); + (sx, sy) = (s * sx.signum(), s * sy.signum()); + } + Affine::translate(p1.to_vec2() + off) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-x0, -y0)) + } + }; + let det = user.determinant(); + self.user = if det.is_finite() && det != 0.0 && user.as_coeffs().iter().all(|c| c.is_finite()) { user } else { Affine::IDENTITY }; + } + + /// A point in current units, in plotter units. + fn to_plu(&self, x: f64, y: f64) -> Point { + self.user * Point::new(x, y) + } + + /// An increment in current units, in plotter units. + fn delta(&self, x: f64, y: f64) -> Vec2 { + self.to_plu(x, y) - self.to_plu(0.0, 0.0) + } + + /// The pen's position in current units. + fn pos_user(&self) -> Point { + self.user.inverse() * self.pos + } + + /// Plotter units (before rotation) to where the art lies. + fn dev(&self, p: Point) -> Point { + self.rot * p + } + + /// User units to where the art lies. + fn user_dev(&self) -> Affine { + self.rot * self.user + } + + /// The far corner of the hard-clip limits in the current orientation (their sides swap at 90 + /// and 270 degrees). + fn hard_clip(&self) -> Point { + if self.ro % 180 == 90 { Point::new(HARD_CLIP.y, HARD_CLIP.x) } else { HARD_CLIP } + } + + /// RO: plotter units rotated `angle` degrees (0, 90, 180 or 270) to where the art lies, shifted + /// so the hard-clip limits stay where they were. + fn rotation(angle: u16) -> Affine { + let (w, h) = (HARD_CLIP.x, HARD_CLIP.y); + match angle { + 90 => Affine::new([0.0, 1.0, -1.0, 0.0, w, 0.0]), + 180 => Affine::new([-1.0, 0.0, 0.0, -1.0, w, h]), + 270 => Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, h]), + _ => Affine::IDENTITY, + } + } + + fn diagonal(&self) -> f64 { + (self.p2 - self.p1).hypot().max(1.0) + } + + // ---------- pens and attributes ---------- + + /// The palette entry the selected pen draws with (`None`: pen 0, no pen). + fn pen_index(&self) -> Option { + let n = self.pen.unwrap_or(1); + if n <= 0 { + return None; + } + Some((n - 1) % (self.pens - 1).max(1) + 1) + } + + fn color(&self, i: i64) -> [u8; 3] { + let default = usize::try_from(i).ok().and_then(|i| PALETTE.get(i)).copied().unwrap_or([0; 3]); + self.colors.get(&i).copied().unwrap_or(default) + } + + /// The stroke the selected pen draws (`None` for no pen). + fn stroke(&self) -> Option { + let i = self.pen_index()?; + let width = self.widths.get(&i).copied().unwrap_or(self.width).clamp(0.0, MAX_WIDTH); + let dash = self.line_type.filter(|k| *k != 0).map(|k| { + let index = k.unsigned_abs().clamp(1, 8) as usize - 1; + let length = if self.pattern_mm { self.pattern * PLU_PER_MM } else { self.pattern / 100.0 * self.diagonal() }; + match &self.user_types[index] { + Some(pct) => LinePattern::new(pct, length, k < 0), + None => LinePattern::new(LINE_TYPES[index], length, k < 0), + } + }); + Some(Stroke { color: self.color(i), width, cap: self.cap, join: self.join, miter: self.miter, dash }) + } + + /// The color the selected pen fills with (`None` for no pen). + fn fill_color(&mut self) -> Option<[u8; 3]> { + let c = self.color(self.pen_index()?); + match self.fill { + FillKind::Solid => Some(c), + FillKind::Pattern => { + self.warn(HATCHES); + Some(c) + } + FillKind::Shade(level) => { + let t = (level / 100.0).clamp(0.0, 1.0); + Some(c.map(|v| (255.0 - (255.0 - f64::from(v)) * t).round() as u8)) + } + } + } + + // ---------- output ---------- + + /// Count `n` path elements against the budget → whether they fit. + fn spend(&mut self, n: usize) -> bool { + self.elements += n; + if self.elements > MAX_ELEMENTS { + self.warn(TOO_MANY_POINTS); + self.full = true; + } + !self.full + } + + fn push(&mut self, shape: Shape) { + if shape.path.elements().len() < 2 { + return; + } + if self.shapes.len() >= MAX_SHAPES { + self.warn(TOO_MANY); + self.full = true; + return; + } + let b = shape.path.bounding_box(); + if ![b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) { + return self.warn(FAR_AWAY); + } + self.shapes.push(shape); + } + + /// End the line being drawn. + fn flush(&mut self) { + let Some(run) = self.run.take() else { return }; + let mut els = run.path.into_elements(); + // Vectors of no length alone draw a dot: a vector one plotter unit long. + if els.len() == 1 { + els.push(PathEl::LineTo(run.start + Vec2::new(1.0, 0.0))); + } + // A line that ends where it started is closed, so its ends join. + if els.len() > 3 && run.end == run.start { + if matches!(els.last(), Some(PathEl::LineTo(p)) if *p == run.start) { + els.pop(); + } + els.push(PathEl::ClosePath); + } + self.push(Shape { path: BezPath::from_vec(els), fill: None, stroke: Some(run.stroke) }); + } + + /// A dot (a vector one plotter unit long) at `at`, drawn with the selected pen. + fn dot(&mut self, at: Point) { + if let Some(stroke) = self.stroke() { + let mut path = BezPath::new(); + path.move_to(at); + path.line_to(at + Vec2::new(1.0, 0.0)); + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + } + + /// The pen moves from `a` along `pieces` (where the art lies), drawing when `down`; in polygon + /// mode the move goes into the polygon buffer. + fn trace(&mut self, a: Point, pieces: &[Piece], down: bool) { + // Only what goes into a path counts against the budget: outside polygon mode, moves with the + // pen up or with no pen (SP0) make nothing. + let draws = down && self.pen_index().is_some(); + if pieces.is_empty() || self.full || (!draws && self.poly.is_none()) || !self.spend(pieces.len()) { + return; + } + if let Some(poly) = &mut self.poly { + return poly.add(a, pieces, down); + } + let Some(stroke) = self.stroke() else { return }; + if self.line_type == Some(0) { + // Line type 0 draws a dot at each point given (an arc's or a curve's end). + self.flush(); + if let Some(p) = pieces.last() { + self.dot(p.end()); + } + return; + } + if self.run.as_ref().is_some_and(|r| r.stroke != stroke || r.end != a) { + self.flush(); + } + let run = self.run.get_or_insert_with(|| { + let mut path = BezPath::new(); + path.move_to(a); + Run { path, stroke, start: a, end: a } + }); + for p in pieces { + // A vector of no length adds nothing to a line (a line of nothing else is a dot). + if matches!(p, Piece::Line(b) if *b == run.end) { + continue; + } + p.push(&mut run.path); + run.end = p.end(); + } + } + + /// Move the pen to `target` (plotter units), drawing when it is down. + fn line_to(&mut self, target: Point) { + if !(target.x.is_finite() && target.y.is_finite()) { + return; + } + let (a, b) = (self.dev(self.pos), self.dev(target)); + let down = self.down; + self.trace(a, &[Piece::Line(b)], down); + self.pos = target; + } + + /// The curves of an arc around `center` (user units) from `start` through `sweep` degrees, + /// where the art lies, and its end (user units). `None` when it has no radius or sweep. + fn arc(&self, center: Point, start: Point, sweep: f64) -> Option<(Vec, Point)> { + let r = (start - center).hypot(); + let sweep = sweep.clamp(-32768.0, 32767.0).to_radians(); + if !(r > 0.0 && r.is_finite() && sweep != 0.0 && sweep.is_finite()) { + return None; + } + let a0 = (start - center).atan2(); + let arc = kurbo::Arc { center, radii: Vec2::new(r, r), start_angle: a0, sweep_angle: sweep, x_rotation: 0.0 }; + let m = self.user_dev(); + let pieces = arc + .append_iter(r * 1e-6) + .filter_map(|el| match el { + PathEl::CurveTo(a, b, c) => Some(Piece::Curve(m * a, m * b, m * c)), + PathEl::LineTo(p) => Some(Piece::Line(m * p)), + _ => None, + }) + .collect(); + Some((pieces, center + Vec2::from_angle(a0 + sweep) * r)) + } + + /// An arc from the pen around `center` (user units), drawn with the pen as it is. + fn arc_from_pen(&mut self, center: Point, sweep: f64) { + let start = self.pos_user(); + match self.arc(center, start, sweep) { + Some((pieces, end)) => { + let (a, down) = (self.dev(self.pos), self.down); + self.trace(a, &pieces, down); + self.pos = self.user * end; + } + None => { + let at = self.dev(self.pos); + let down = self.down; + self.trace(at, &[Piece::Line(at)], down); + } + } + } + + /// A closed outline through `pieces` from `a` (where the art lies). + fn outline(a: Point, pieces: &[Piece]) -> BezPath { + let mut path = BezPath::new(); + path.move_to(a); + pieces.iter().for_each(|p| p.push(&mut path)); + path.close_path(); + path + } + + /// A circle of `radius` (current units) around the pen, starting at 0 degrees (180 for a + /// negative radius), where the art lies. + fn circle(&self, radius: f64) -> Option { + let c = self.pos_user(); + let start = c + Vec2::new(radius, 0.0); + let (pieces, _) = self.arc(c, start, 360.0)?; + Some(Self::outline(self.user_dev() * start, &pieces)) + } + + /// A wedge of `radius` (current units) around the pen, from `start` through `sweep` degrees. + fn wedge(&self, radius: f64, start: f64, sweep: f64) -> Option { + let c = self.pos_user(); + let reference = if radius < 0.0 { 180.0 } else { 0.0 }; + let sweep = sweep.clamp(-360.0, 360.0); + let first = c + Vec2::from_angle((reference + start.clamp(-32768.0, 32767.0) % 360.0).to_radians()) * radius.abs(); + let m = self.user_dev(); + if sweep.abs() >= 360.0 { + let (pieces, _) = self.arc(c, first, sweep)?; + return Some(Self::outline(m * first, &pieces)); + } + let mut pieces = vec![Piece::Line(m * first)]; + if let Some((arc, _)) = self.arc(c, first, sweep) { + pieces.extend(arc); + } + (radius != 0.0).then(|| Self::outline(m * c, &pieces)) + } + + /// The rectangle from the pen to `corner` (plotter units), where the art lies. + fn rect(&self, corner: Point) -> BezPath { + let (a, b) = (self.pos_user(), self.user.inverse() * corner); + let m = self.user_dev(); + let pieces = [Point::new(b.x, a.y), b, Point::new(a.x, b.y)].map(|p| Piece::Line(m * p)); + Self::outline(m * a, &pieces) + } + + /// `path` as the polygon buffer, filled (`fill`) or edged. + fn buffer_and_draw(&mut self, path: Option, fill: bool) { + self.flush(); + if self.poly.is_some() { + return; + } + let Some(path) = path else { + return self.dot(self.dev(self.pos)); + }; + if !self.spend(path.elements().len()) { + return; + } + let mut poly = Poly::default(); + poly.closed(&path); + self.buffer = Some(poly); + if fill { self.fill_polygon(FillRule::EvenOdd) } else { self.edge_polygon() } + } + + /// FP: the polygon buffer filled (each fill is a copy of the buffer, counted against the + /// budget). + fn fill_polygon(&mut self, rule: FillRule) { + self.flush(); + let n = self.buffer.as_ref().map_or(0, |b| b.fill.elements().len()); + if n == 0 || !self.spend(n) { + return; + } + let Some(c) = self.fill_color() else { return }; + let Some(path) = self.buffer.as_ref().map(|b| b.fill.clone()) else { return }; + self.push(Shape { path, fill: Some((c, rule)), stroke: None }); + } + + /// EP: the pen-down edges of the polygon buffer stroked (counted as FP's are). + fn edge_polygon(&mut self) { + self.flush(); + let n = self.buffer.as_ref().map_or(0, |b| b.edge.elements().len()); + if n == 0 || !self.spend(n) { + return; + } + let Some(stroke) = self.stroke() else { return }; + let Some(path) = self.buffer.as_ref().map(|b| b.edge.clone()) else { return }; + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + + // ---------- instructions ---------- + + /// Read and play the instruction `op`. + pub fn op(&mut self, op: [u8; 2], lex: &mut Lexer) { + if self.poly.is_some() && !POLYGON_MODE.contains(&op) { + return self.skip_op(op, lex); + } + match &op { + b"IN" | b"BP" | b"DF" | b"PU" | b"PD" | b"PA" | b"PR" | b"SP" => self.setup_op(op, lex), + b"PW" | b"WU" | b"NP" | b"PC" | b"CR" => self.pen_op(op, lex), + b"LT" | b"UL" => self.line_type_op(op, lex), + b"LA" | b"FT" | b"SC" => self.attribute_op(op, lex), + b"IP" | b"IR" | b"RO" | b"CI" => self.frame_op(op, lex), + b"AA" | b"AR" | b"AT" | b"RT" | b"BZ" | b"BR" => self.curve_op(op, lex), + b"EA" | b"ER" | b"RA" | b"RR" | b"EW" | b"WG" | b"PM" | b"FP" | b"EP" | b"PE" => self.area_op(op, lex), + _ => self.other_op(op, lex), + } + } + + /// An instruction polygon mode ignores: its parameters (or text) are read. + fn skip_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + match &op { + b"LB" | b"BL" | b"WD" => lex.label(self.terminator), + b"DT" => { + lex.char(); + lex.params(); + } + b"SM" => { + lex.char(); + } + _ => lex.params(), + } + } + + /// Initialization, pen moves and pen selection. + fn setup_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"IN" | b"BP" => { + lex.params(); + self.init(); + } + b"DF" => { + lex.params(); + self.defaults(); + } + b"PU" | b"PD" => { + self.down = op[1] == b'D'; + self.points(lex); + } + b"PA" | b"PR" => { + self.relative = op[1] == b'R'; + self.points(lex); + } + b"SP" => { + let n = lex.numbers(&mut v); + self.flush(); + self.pen = Some(if n == 0 { 0 } else { v[0].round().clamp(-1.0, 1e9) as i64 }); + } + _ => {} + } + } + + /// Pen widths, the palette and its colors. + fn pen_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"PW" => { + let n = lex.numbers(&mut v); + self.flush(); + let width = |s: &Self, w: f64| if s.relative_widths { w / 100.0 * s.diagonal() } else { w * PLU_PER_MM }; + let default = if self.relative_widths { 0.1 } else { DEFAULT_WIDTH_MM }; + let w = width(self, if n == 0 { default } else { v[0].clamp(0.0, 32767.0) }); + if n >= 2 { + let pen = v[1].round().clamp(0.0, 1e9) as i64; + if pen > 0 && pen < self.pens.max(8) { + self.widths.insert(pen, w); + } + } else { + self.width = w; + self.widths.clear(); + } + } + b"WU" => { + let n = lex.numbers(&mut v); + self.flush(); + self.relative_widths = n > 0 && v[0].round() == 1.0; + self.width = if self.relative_widths { 0.1 / 100.0 * self.diagonal() } else { DEFAULT_WIDTH_MM * PLU_PER_MM }; + self.widths.clear(); + } + b"NP" => { + let n = lex.numbers(&mut v); + self.flush(); + if n == 0 { + self.pens = 8; + } else if v[0] >= 2.0 { + self.pens = i64::from((v[0].min(MAX_PENS as f64).ceil() as u32).next_power_of_two()).min(MAX_PENS); + } + } + b"PC" => { + let n = lex.numbers(&mut v); + self.flush(); + let pen = v[0].round().clamp(0.0, 1e9) as i64; + match n { + 0 => self.colors.clear(), + 1 => { + self.colors.remove(&pen); + } + // Two or three parameters: the instruction is ignored. + 2 | 3 => {} + _ if pen < MAX_PENS => { + let channel = |i: usize| { + let (b, w) = self.ranges[i]; + let t = if w == b { 0.0 } else { ((v[i + 1] - b) / (w - b)).clamp(0.0, 1.0) }; + (t * 255.0).round() as u8 + }; + let c = [channel(0), channel(1), channel(2)]; + self.colors.insert(pen, c); + } + _ => {} + } + } + b"CR" => { + let n = lex.numbers(&mut v); + if n == 0 { + self.ranges = [(0.0, 255.0); 3]; + } else if n >= 6 { + self.ranges = [(v[0], v[1]), (v[2], v[3]), (v[4], v[5])]; + } + } + _ => {} + } + } + + /// Line types and their patterns. + fn line_type_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"LT" => { + let n = lex.numbers(&mut v); + self.flush(); + if n == 0 { + if self.line_type.is_some() { + self.saved_line_type = self.line_type; + } + self.line_type = None; + return; + } + let k = v[0].round().clamp(-100.0, 100.0) as i32; + if k == 99 { + if self.line_type.is_none() { + self.line_type = self.saved_line_type; + } + return; + } + if !(-8..=8).contains(&k) { + return; + } + // A pattern length of 0 or less, or a mode other than 0 and 1, makes the instruction ignored. + if (n >= 2 && !(v[1] > 0.0 && v[1] <= 32767.0)) || (n >= 3 && !matches!(v[2].round() as i32, 0 | 1)) { + return; + } + self.line_type = Some(k); + if n >= 2 { + self.pattern = v[1]; + } + if n >= 3 { + self.pattern_mm = v[2].round() == 1.0; + } + } + b"UL" => { + let mut gaps = [0.0f64; 21]; + let n = lex.numbers(&mut gaps); + self.flush(); + let index = gaps[0].round().abs(); + if n == 0 { + self.user_types = Default::default(); + } else if (1.0..=8.0).contains(&index) { + let slot = index as usize - 1; + let values = &gaps[1..n]; + let sum: f64 = values.iter().sum(); + self.user_types[slot] = if n == 1 { + None + } else if values.iter().all(|g| *g >= 0.0) && sum > 0.0 { + Some(values.iter().map(|g| g / sum * 100.0).collect()) + } else { + return; + }; + } + } + _ => {} + } + } + + /// Line ends and joins, the fill type and scaling. + fn attribute_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"LA" => { + let n = lex.numbers(&mut v[..6]); + self.flush(); + if n == 0 { + (self.cap, self.join, self.miter) = (LineCap::Butt, LineJoin::Miter, 5.0); + } + for pair in v[..n - n % 2].chunks(2) { + let value = pair[1].round() as i32; + match (pair[0].round() as i32, value) { + (1, 1) => self.cap = LineCap::Butt, + (1, 2) => self.cap = LineCap::Square, + (1, 3 | 4) => self.cap = LineCap::Round, + (2, 1 | 2) => self.join = LineJoin::Miter, + (2, 4) => self.join = LineJoin::Round, + (2, 3 | 5 | 6) => self.join = LineJoin::Bevel, + (3, _) => self.miter = pair[1].clamp(1.0, 32767.0), + _ => {} + } + } + } + b"FT" => { + let n = lex.numbers(&mut v[..3]); + let kind = if n == 0 { 1 } else { v[0].round() as i32 }; + self.fill = match kind { + 1 | 2 => FillKind::Solid, + 10 => FillKind::Shade(if n >= 2 { v[1] } else { 100.0 }), + 3 | 4 | 11 | 21 | 22 => FillKind::Pattern, + _ => self.fill, + }; + } + b"SC" => { + let n = lex.numbers(&mut v[..7]); + self.flush(); + let kind = if n >= 5 { v[4].round() as i32 } else { 0 }; + self.scaling = match (n, kind) { + (0, _) => None, + (5, 2) if v[1] != 0.0 && v[3] != 0.0 => Some(Scaling::Factor { x0: v[0], fx: v[1], y0: v[2], fy: v[3] }), + (4..=7, 0 | 1) if v[0] != v[1] && v[2] != v[3] => { + let iso = (kind == 1).then(|| if n >= 7 { (v[5].clamp(0.0, 100.0), v[6].clamp(0.0, 100.0)) } else { (50.0, 50.0) }); + Some(Scaling::Fit { x0: v[0], x1: v[1], y0: v[2], y1: v[3], iso }) + } + _ => return, + }; + self.update_user(); + } + _ => {} + } + } + + /// The scaling points, the rotation and circles. + fn frame_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"IP" | b"IR" => { + let n = lex.numbers(&mut v[..4]); + self.flush(); + // IR's percentages and the defaults are of the hard-clip limits in the current + // orientation. Scaling points past the art's range are held at its edge, so lengths + // taken from them stay finite. + let clip = self.hard_clip(); + let at = |x: f64, y: f64| { + let p = if op[1] == b'R' { Point::new(x / 100.0 * clip.x, y / 100.0 * clip.y) } else { Point::new(x, y) }; + Point::new(p.x.clamp(-LIMIT, LIMIT), p.y.clamp(-LIMIT, LIMIT)) + }; + match n { + 0 => (self.p1, self.p2) = (Point::ZERO, clip), + 2 | 3 => { + let p1 = at(v[0], v[1]); + (self.p1, self.p2) = (p1, p1 + (self.p2 - self.p1)); + } + 4 => { + let (p1, mut p2) = (at(v[0], v[1]), at(v[2], v[3])); + // A coordinate of P2 equal to P1's is one plotter unit more. + if p2.x == p1.x { + p2.x += 1.0; + } + if p2.y == p1.y { + p2.y += 1.0; + } + (self.p1, self.p2) = (p1, p2); + } + _ => {} + } + self.update_user(); + } + b"RO" => { + let n = lex.numbers(&mut v[..1]); + self.flush(); + let angle = if n == 0 { 0.0 } else { v[0].round().rem_euclid(360.0) }; + if [0.0, 90.0, 180.0, 270.0].contains(&angle) { + let ro = angle as u16; + let rot = Self::rotation(ro); + // The pen keeps its place on the page, its coordinates changing with the axes; + // P1 and P2 keep their coordinates, and the polygon buffer turns with the axes. + let turn = rot * self.rot.inverse(); + self.pos = rot.inverse() * (self.rot * self.pos); + if let Some(b) = &mut self.buffer { + b.fill.apply_affine(turn); + b.edge.apply_affine(turn); + } + (self.rot, self.ro) = (rot, ro); + } + } + b"CI" => { + let n = lex.numbers(&mut v[..2]); + if n == 0 { + return; + } + let path = self.circle(v[0]); + // In polygon mode the circle is a subpolygon of its own; the pen stays at its + // center. + if self.poly.is_some() { + if let Some(path) = path + && self.spend(path.elements().len()) + && let Some(poly) = &mut self.poly + { + poly.closed(&path); + } + return; + } + self.flush(); + // Line type 0 draws a dot at the center. + if self.line_type == Some(0) { + return self.dot(self.dev(self.pos)); + } + let Some(path) = path else { return self.dot(self.dev(self.pos)) }; + if self.spend(path.elements().len()) + && let Some(stroke) = self.stroke() + { + self.push(Shape { path, fill: None, stroke: Some(stroke) }); + } + } + _ => {} + } + } + + /// Arcs and Bézier curves from the pen. + fn curve_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"AA" | b"AR" => { + if lex.numbers(&mut v[..4]) < 3 { + return; + } + let center = if op[1] == b'R' { self.pos_user() + Vec2::new(v[0], v[1]) } else { Point::new(v[0], v[1]) }; + self.arc_from_pen(center, v[2]); + } + b"AT" | b"RT" => { + if lex.numbers(&mut v[..5]) < 4 { + return; + } + let a = self.pos_user(); + let off = if op[0] == b'R' { a.to_vec2() } else { Vec2::ZERO }; + let (b, c) = (Point::new(v[0], v[1]) + off, Point::new(v[2], v[3]) + off); + self.three_point_arc(a, b, c); + } + b"BZ" | b"BR" => { + while let Some(x) = lex.number() { + let mut p = [x, 0.0, 0.0, 0.0, 0.0, 0.0]; + for slot in &mut p[1..] { + let Some(n) = lex.number() else { return }; + *slot = n; + } + let a = self.pos_user(); + let off = if op[1] == b'R' { a.to_vec2() } else { Vec2::ZERO }; + let [c1, c2, end] = [0, 2, 4].map(|i| Point::new(p[i], p[i + 1]) + off); + let m = self.user_dev(); + let (from, down) = (self.dev(self.pos), self.down); + self.trace(from, &[Piece::Curve(m * c1, m * c2, m * end)], down); + self.pos = self.user * end; + } + } + _ => {} + } + } + + /// Rectangles, wedges, polygon mode and encoded polylines. + fn area_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 8]; + match &op { + b"EA" | b"ER" | b"RA" | b"RR" => { + if lex.numbers(&mut v[..2]) < 2 { + return; + } + let corner = if op[1] == b'R' { self.pos + self.delta(v[0], v[1]) } else { self.to_plu(v[0], v[1]) }; + let path = self.rect(corner); + self.buffer_and_draw(Some(path), op[0] == b'R'); + } + b"EW" | b"WG" => { + if lex.numbers(&mut v[..4]) < 3 { + return; + } + let path = self.wedge(v[0], v[1], v[2]); + self.buffer_and_draw(path, op[0] == b'W'); + } + b"PM" => { + let n = lex.numbers(&mut v[..1]); + self.flush(); + // The point that closes a subpolygon moves the pen there: PM1 closes it with the + // pen up, PM2 with the pen as it is. + let mut closure = None; + match if n == 0 { 0 } else { v[0].round() as i32 } { + 0 if self.poly.is_none() => { + let at = self.dev(self.pos); + self.poly = Some(Poly { start: at, last: at, ..Poly::default() }); + self.buffer = None; + } + 1 => { + if let Some(p) = &mut self.poly { + closure = p.close(false); + p.fresh = true; + } + } + 2 => { + if let Some(mut p) = self.poly.take() { + closure = p.close(self.down); + self.buffer = Some(p); + } + } + _ => {} + } + if let Some(at) = closure { + self.pos = self.rot.inverse() * at; + } + } + b"FP" => { + let n = lex.numbers(&mut v[..1]); + self.fill_polygon(if n > 0 && v[0].round() == 1.0 { FillRule::NonZero } else { FillRule::EvenOdd }); + } + b"EP" => { + lex.params(); + self.edge_polygon(); + } + b"PE" => self.polyline_encoded(lex), + _ => {} + } + } + + /// Labels, comments, pages, the input window, and instructions that draw nothing. + fn other_op(&mut self, op: [u8; 2], lex: &mut Lexer) { + let mut v = [0.0f64; 4]; + match &op { + b"LB" | b"BL" | b"WD" => { + lex.label(self.terminator); + self.warn(LABELS); + } + b"DT" => { + self.terminator = lex.char().unwrap_or(ETX); + lex.params(); + } + b"SM" => { + // The symbol character is read so it isn't taken for an instruction; symbols + // aren't drawn. + if lex.char().is_some() { + self.warn(SYMBOLS); + } + } + b"CO" => { + lex.quoted(); + } + b"PG" | b"FR" => { + lex.params(); + self.flush(); + self.page_end.get_or_insert(self.shapes.len()); + // The next page starts with the pen up at the origin (FR also empties the + // polygon buffer). + (self.pos, self.down) = (Point::ZERO, false); + if op[0] == b'F' { + self.buffer = None; + } + } + b"IW" => { + if lex.numbers(&mut v) == 4 { + self.warn(WINDOW); + } + } + _ if super::IGNORED.contains(&op) => lex.params(), + _ => { + lex.params(); + self.unknown += 1; + } + } + } + + /// The coordinate pairs of PU, PD, PA and PR, plotted in the current mode. + fn points(&mut self, lex: &mut Lexer) { + while let Some(x) = lex.number() { + let Some(y) = lex.number() else { return }; + let target = if self.relative { self.pos + self.delta(x, y) } else { self.to_plu(x, y) }; + self.line_to(target); + if self.full { + return; + } + } + } + + /// AT and RT: the arc from `a` through `b` to `c` (user units). + fn three_point_arc(&mut self, a: Point, b: Point, c: Point) { + let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); + let center = if a == c { + // Back to the start: the circle whose diameter ends at the start and the middle point. + Some(a.midpoint(b)) + } else if d.abs() > 1e-12 * (b - a).hypot2().max((c - a).hypot2()).max(1.0) { + let (a2, b2, c2) = (a.to_vec2().hypot2(), b.to_vec2().hypot2(), c.to_vec2().hypot2()); + Some(Point::new( + (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d, + (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d, + )) + } else { + None + }; + let Some(center) = center.filter(|p| p.x.is_finite() && p.y.is_finite()) else { + // Points in a line: a line to the end. + return self.line_to(self.user * c); + }; + let tau = std::f64::consts::TAU; + let angle = |p: Point| (p - center).atan2(); + let sweep = if a == c { + tau + } else { + let (s, t) = ((angle(c) - angle(a)).rem_euclid(tau), (angle(b) - angle(a)).rem_euclid(tau)); + if t < s { s } else { s - tau } + }; + self.arc_from_pen(center, sweep.to_degrees()); + if a != c { + self.pos = self.user * c; + } + } + + /// PE: encoded pen moves, pen selections and rectangles. + fn polyline_encoded(&mut self, lex: &mut Lexer) { + let mut st = Pe::default(); + let (mut rect, mut corner) = (false, false); + while let Some(item) = lex.pe(&mut st) { + if self.full { + continue; + } + match item { + PeItem::Pen(n) => { + if self.poly.is_none() { + self.flush(); + self.pen = Some(n.clamp(-1, 1_000_000_000)); + } + } + PeItem::Rect => (rect, corner) = (true, false), + PeItem::Point { x, y, up, absolute } => { + if up { + rect = false; + } + let target = if absolute { self.to_plu(x, y) } else { self.pos + self.delta(x, y) }; + if rect && corner { + // The second pair of a rectangle: its opposite corner, relative. It is filled + // as RR fills one, so polygon mode, which doesn't allow RR, ignores it. + let path = self.rect(self.pos + self.delta(x, y)); + self.buffer_and_draw(Some(path), true); + corner = false; + } else if rect { + self.down = false; + self.line_to(target); + corner = true; + } else { + self.down = !up; + self.line_to(target); + } + } + } + } + if rect { + self.down = false; + } + } + + /// The plot's end: the last line drawn, and notes on what wasn't read. + pub fn finish(mut self, raster: bool) -> Self { + self.flush(); + if let Some(n) = self.page_end + && self.shapes.len() > n + { + self.warn(PAGES); + } + if raster { + self.warn(RASTER); + } + if self.unknown > 0 { + let n = self.unknown; + self.warnings.push(format!( + "{n} instruction{} of kinds VectorCraft doesn't read {} skipped", + if n == 1 { "" } else { "s" }, + if n == 1 { "was" } else { "were" } + )); + } + self + } +} diff --git a/crates/cad/src/hpgl/tests.rs b/crates/cad/src/hpgl/tests.rs new file mode 100644 index 000000000..9a350707a --- /dev/null +++ b/crates/cad/src/hpgl/tests.rs @@ -0,0 +1,630 @@ +//! HP-GL import: units and the y axis, pens, runs, curves, filled and edged areas, polygon mode, +//! line types, scaling, rotation, encoded polylines, escapes around the instructions, what is +//! skipped with a warning, which files are taken to be HP-GL, and hostile files. + +use kurbo::{BezPath, PathEl, Point, Shape as _}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeLayer}; +use vectorcraft_geom::FillRule; + +use super::plot::{FAR_AWAY, HATCHES, LABELS, PAGES, RASTER, SYMBOLS, TOO_MANY_POINTS, WINDOW}; +use super::*; + +/// Points per millimeter. +const MM: f64 = 72.0 / 25.4; + +fn plot(s: &str) -> Imported { + plot_bytes(s.as_bytes()) +} + +fn plot_bytes(b: &[u8]) -> Imported { + match import(b) { + Ok(i) => i, + Err(e) => panic!("{e}: {:?}", String::from_utf8_lossy(b)), + } +} + +/// Every object, in paint order. +fn leaves(doc: &Document) -> Vec { + let mut out = vec![]; + doc.walk(|n| { + if n.children().is_none() { + out.push(n.clone()); + } + }); + out +} + +fn bez(n: &Node) -> BezPath { + match &n.kind { + NodeKind::Path { path, .. } => path.to_bezpath(), + k => panic!("a path, got {k:?}"), + } +} + +fn stroke(n: &Node) -> StrokeLayer { + n.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }).expect("a stroke") +} + +fn fill(n: &Node) -> Option { + n.appearance.items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f.paint.clone()) } else { None }) +} + +fn rule(n: &Node) -> FillRule { + match &n.kind { + NodeKind::Path { rule, .. } => *rule, + k => panic!("a path, got {k:?}"), + } +} + +fn rgb(r: u8, g: u8, b: u8) -> Paint { + Paint::solid(Color::rgb8(r, g, b)) +} + +/// The end points of `bp`'s elements. +fn ends(bp: &BezPath) -> Vec { + bp.elements().iter().filter_map(|e| e.end_point()).collect() +} + +fn near(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-6 +} + +fn assert_points(got: &[Point], want: &[(f64, f64)]) { + assert_eq!(got.len(), want.len(), "{got:?}"); + for (g, w) in got.iter().zip(want) { + assert!(near(*g, Point::new(w.0, w.1)), "{got:?} != {want:?}"); + } +} + +/// A PE number as the reference guide's encoding writes it (base 64, or base 32 for 7-bit data). +fn pe_number(v: i64, seven_bit: bool) -> Vec { + let mut n = if v >= 0 { 2 * v as u64 } else { 2 * v.unsigned_abs() + 1 }; + let (base, high) = if seven_bit { (32, 95) } else { (64, 191) }; + let mut out = vec![]; + while n >= base { + out.push(63 + (n % base) as u8); + n /= base; + } + out.push(high + n as u8); + out +} + +#[test] +fn plotter_units_become_points_with_y_down() { + // An inch right and an inch up, with the thinnest pen (0.25 pt): the artboard reaches 0.125 pt + // past the line at the corner's miter, and stops at the line's butt ends. + let i = plot("IN;SP1;PW0;PU0,0;PD1016,0,1016,1016;PU;"); + let doc = &i.document; + let r = doc.artboards[0].rect; + assert!((r.width() - 72.125).abs() < 1e-9 && (r.height() - 72.125).abs() < 1e-9, "{r:?}"); + let shapes = leaves(doc); + assert_eq!(shapes.len(), 1); + assert_points(&ends(&bez(&shapes[0])), &[(0.0, 72.0), (72.0, 72.0), (72.0, 0.0)]); + let s = stroke(&shapes[0]); + assert_eq!((s.width, s.paint.clone(), s.cap, s.join, s.miter_limit), (0.25, rgb(0, 0, 0), LineCap::Butt, LineJoin::Miter, 5.0)); + assert!(fill(&shapes[0]).is_none() && i.warnings.is_empty(), "{:?}", i.warnings); + + // The artboard holds what the strokes paint, the tip of a sharp miter join included. + let i = plot("IN;SP1;PW2;PD2032,0,0,1000;"); + let r = i.document.artboards[0].rect; + let painted = leaves(&i.document)[0].visual_bounds().unwrap(); + let path = bez(&leaves(&i.document)[0]).bounding_box(); + assert!(painted.x1 - path.x1 > 2.0 * MM, "the miter reaches past half the width: {painted:?} {path:?}"); + let inside = r.x0 <= painted.x0 + 1e-9 && r.y0 <= painted.y0 + 1e-9 && r.x1 >= painted.x1 - 1e-9 && r.y1 >= painted.y1 - 1e-9; + assert!(inside, "{r:?} {painted:?}"); +} + +#[test] +fn pens_pick_colors_and_widths() { + // Before any SP the pen is pen 1; SP0 draws nothing; pens past the palette wrap round it. + let i = plot("IN;PD0,0,100,0;PU;SP2;PD0,100,100,100;PU;SP0;PD0,200,100,200;PU;SP9;PD0,300,100,300;PU;SP3;PD0,400,100,400;PU;"); + let colors: Vec = leaves(&i.document).iter().map(|n| stroke(n).paint).collect(); + assert_eq!(colors, [rgb(0, 0, 0), rgb(255, 0, 0), rgb(255, 0, 0), rgb(0, 255, 0)]); + + // PC gives a pen a color in the color range; PC with the pen alone gives back its default. + let i = plot("IN;CR0,1,0,1,0,1;PC3,0,0,1;SP3;PD0,0,10,0;PU;PC3;PD0,10,10,10;PU;"); + let colors: Vec = leaves(&i.document).iter().map(|n| stroke(n).paint).collect(); + assert_eq!(colors, [rgb(0, 0, 255), rgb(0, 255, 0)]); + + // PW in millimeters for every pen, or for one; IN gives back 0.35 mm. + let i = plot("IN;SP1;PW1;PD0,0,10,0;PU;PW2,2;SP2;PD0,10,10,10;PU;SP1;PD0,20,10,20;PU;IN;SP1;PD0,30,10,30;"); + let widths: Vec = leaves(&i.document).iter().map(|n| stroke(n).width).collect(); + let want = [MM, 2.0 * MM, MM, 0.35 * MM]; + assert!(widths.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "{widths:?}"); +} + +#[test] +fn each_run_with_the_pen_down_is_one_path() { + // Relative moves; a run that comes back to its start is closed; a pen-up move elsewhere starts + // another path, and one back to where the line ended continues it. + let i = plot("IN;SP1;PW0;PA0,0;PR;PD1016,0,0,1016,-1016,0,0,-1016;PU2032,0;PD0,1016;PU0,1016;PU0,-1016;PD508,0;PU0,500;PD0,500;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 3, "{shapes:#?}"); + let NodeKind::Path { path: square, .. } = &shapes[0].kind else { panic!("a path") }; + assert_eq!((square.subpaths.len(), square.subpaths[0].anchors.len(), square.subpaths[0].closed), (1, 4, true)); + assert_eq!(ends(&bez(&shapes[1])).len(), 3, "the run went on from where it ended"); + assert_eq!(ends(&bez(&shapes[2])).len(), 2); + + // A pen-down move of no length draws a dot. + let i = plot("IN;SP1;PA500,500;PD;PA500,500;PU;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 1); + let b = bez(&shapes[0]).bounding_box(); + assert!((b.width() - 72.0 / 1016.0).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn circles_arcs_and_curves_are_drawn_as_curves() { + // A circle of an inch round the pen, which stays at its center (the next line starts there). + let i = plot("IN;SP1;PW0;PA2032,2032;CI1016;PD3048,2032;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 2); + let c = bez(&shapes[0]); + assert!(c.elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))) && matches!(c.elements().last(), Some(PathEl::ClosePath))); + // The curves stay within a thousandth of a point of the circle. + let b = c.bounding_box(); + assert!((b.width() - 144.0).abs() < 1e-3 && (b.height() - 144.0).abs() < 1e-3, "{b:?}"); + let line = ends(&bez(&shapes[1])); + assert!((line[0] - b.center()).hypot() < 1e-3, "{line:?} {b:?}"); + + // AA: a quarter turn counterclockwise round (0, 0) from (1016, 0) ends at (0, 1016), and the + // pen goes on from there. AR takes its center relative to the pen. + for s in ["IN;SP1;PW0;PA1016,0;PD;AA0,0,90;PA0,2032;", "IN;SP1;PW0;PA1016,0;PD;AR-1016,0,90;PA0,2032;"] { + let shapes = leaves(&plot(s).document); + assert_eq!(shapes.len(), 1, "{s}"); + let p = bez(&shapes[0]); + let pts = ends(&p); + // y down: (1016, 0) is at the bottom right, the arc's end on the left a quarter turn up. + let (start, end) = (pts[0], pts[pts.len() - 2]); + assert!(near(start, Point::new(72.125, 144.0)) && near(end, Point::new(0.125, 72.0)), "{s}: {pts:?}"); + assert!(near(*pts.last().unwrap(), Point::new(0.125, 0.0)), "{s}: {pts:?}"); + } + + // AT: the arc from the pen through a point to an end; points in a line give a line. + let shapes = leaves(&plot("IN;SP1;PW0;PA1016,0;PD;AT0,1016,-1016,0;").document); + let pts = ends(&bez(&shapes[0])); + let b = bez(&shapes[0]).bounding_box(); + assert!((b.width() - 144.0).abs() < 1e-6 && (b.height() - 72.0).abs() < 1e-6, "a half circle: {b:?}"); + assert!(near(pts[0], Point::new(144.125, 72.125)) && near(*pts.last().unwrap(), Point::new(0.125, 72.125)), "{pts:?}"); + let shapes = leaves(&plot("IN;SP1;PW0;PA0,0;PD;AT508,0,1016,0;").document); + assert_eq!(ends(&bez(&shapes[0])).len(), 2); + + // BZ and BR: cubic Béziers, BR's points relative to each curve's start. + for s in [ + "IN;SP1;PW0;PA0,0;PD;BZ0,1016,1016,1016,1016,0,1016,-1016,2032,-1016,2032,0;", + "IN;SP1;PW0;PA0,0;PD;BR0,1016,1016,1016,1016,0,0,-1016,1016,-1016,1016,0;", + ] { + let shapes = leaves(&plot(s).document); + let els = bez(&shapes[0]).elements().to_vec(); + assert_eq!(els.iter().filter(|e| matches!(e, PathEl::CurveTo(..))).count(), 2, "{s}"); + // The curves reach 762 plotter units (three quarters of their control points) up and down. + assert!(near(els.last().and_then(PathEl::end_point).unwrap(), Point::new(144.125, 54.125)), "{s}: {els:?}"); + } +} + +#[test] +fn rectangles_wedges_and_polygons_fill_and_edge() { + // RA fills, EA edges; neither moves the pen. + let i = plot("IN;SP2;PW0;PA0,0;RA1016,1016;EA1016,1016;PD508,0;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 3); + assert_eq!((fill(&shapes[0]), shapes[0].appearance.items.len()), (Some(rgb(255, 0, 0)), 1)); + assert!(fill(&shapes[1]).is_none() && stroke(&shapes[1]).paint == rgb(255, 0, 0)); + assert!(near(ends(&bez(&shapes[2]))[0], Point::new(0.125, 72.125)), "the line starts where the pen was"); + + // WG fills a wedge, EW edges one; a whole turn is a circle. + let i = plot("IN;SP1;PA0,0;WG1016,0,90;EW1016,0,360;"); + let shapes = leaves(&i.document); + let wedge = bez(&shapes[0]); + assert!(fill(&shapes[0]).is_some() && ends(&wedge).len() >= 3); + let ring = bez(&shapes[1]).bounding_box(); + assert!((ring.width() - 144.0).abs() < 1e-6); + + // Polygon mode: two squares, one inside the other; FP fills even-odd by default, FP1 + // non-zero; EP edges only what was drawn with the pen down. + let poly = "IN;SP1;PW0;PA0,0;PM0;PD3048,0,3048,3048,0,3048,0,0;PM1;PU1016,1016;PD2032,1016;PU2032,2032;PD1016,2032,1016,1016;PM2;"; + let i = plot(&format!("{poly}FP;FP1;EP;")); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 3); + assert_eq!((rule(&shapes[0]), rule(&shapes[1])), (FillRule::EvenOdd, FillRule::NonZero)); + let filled = bez(&shapes[0]); + assert_eq!(filled.elements().iter().filter(|e| matches!(e, PathEl::ClosePath)).count(), 2); + let edged = bez(&shapes[2]); + assert_eq!(edged.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(..))).count(), 3, "the pen-up side isn't edged: {edged:?}"); + // Nothing is drawn in polygon mode itself. + assert!(leaves(&plot(poly).document).is_empty()); + + // A circle in polygon mode is a subpolygon of its own; CI and RA after PM2 work as before. + let i = plot("IN;SP1;PA2032,2032;PM0;CI1016;PM2;FP;"); + assert_eq!(leaves(&i.document).len(), 1); + + // Fill types: shading mixes the pen with white; hatching fills solid with a warning. + let i = plot("IN;SP5;PA0,0;FT10,50;RA100,100;FT3,10,45;RA200,200;"); + let shapes = leaves(&i.document); + assert_eq!((fill(&shapes[0]), fill(&shapes[1])), (Some(rgb(128, 128, 255)), Some(rgb(0, 0, 255)))); + assert_eq!(i.warnings, [HATCHES]); +} + +#[test] +fn line_types_become_dash_patterns() { + // A 10 mm pattern of line type 2 is 5 mm on, 5 mm off. + let i = plot("IN;SP1;LT2,10,1;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + assert!(d.pattern.iter().all(|v| (v - 5.0 * MM).abs() < 1e-9) && d.pattern.len() == 2 && !d.align_corners, "{d:?}"); + // The default length is 4% of the P1-P2 diagonal; IP sets P1 and P2. + let i = plot("IN;IP0,0,3000,4000;SP1;LT3;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + let total = 200.0 * 72.0 / 1016.0; + assert!((d.pattern[0] - 0.7 * total).abs() < 1e-9 && (d.pattern[1] - 0.3 * total).abs() < 1e-9, "{d:?}"); + // Adaptive types stretch to each line; a zero length is a dot one plotter unit long. + let i = plot("IN;SP1;LT-4,10,1;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + assert!(d.align_corners && (d.pattern[2] - 72.0 / 1016.0).abs() < 1e-12, "{d:?}"); + // UL redefines a type; LT; draws solid and LT99 gives the type back. + let i = plot("IN;SP1;UL2,1,3;LT2,10,1;PD0,0,4000,0;PU;LT;PD0,100,4000,100;PU;LT99;PD0,200,4000,200;"); + let shapes = leaves(&i.document); + let d = stroke(&shapes[0]).dash.expect("dashed"); + assert!((d.pattern[0] - 2.5 * MM).abs() < 1e-9 && (d.pattern[1] - 7.5 * MM).abs() < 1e-9, "{d:?}"); + assert!(stroke(&shapes[1]).dash.is_none() && stroke(&shapes[2]).dash.is_some()); + // Line type 0 draws a dot at each point. + let i = plot("IN;SP1;LT0;PD0,0,1000,0,2000,0;"); + assert_eq!(leaves(&i.document).len(), 3); + // LA sets the caps, joins and miter limit. + let s = stroke(&leaves(&plot("IN;SP1;LA1,4,2,4,3,9;PD0,0,100,0;").document)[0]); + assert_eq!((s.cap, s.join, s.miter_limit), (LineCap::Round, LineJoin::Round, 9.0)); +} + +#[test] +fn scaling_maps_user_units_onto_p1_and_p2() { + // Anisotropic: 100 user units across an inch. + let size = |s: &str| { + let shapes = leaves(&plot(s).document); + bez(&shapes[0]).bounding_box().size() + }; + let s = size("IN;IP0,0,1016,2032;SC0,100,0,100;SP1;PD0,0,100,100;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 144.0).abs() < 1e-9, "{s:?}"); + // Isotropic: the smaller of the two scales on both axes. + let s = size("IN;IP0,0,1016,2032;SC0,100,0,100,1;SP1;PU0,0;PD100,100;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 72.0).abs() < 1e-9, "{s:?}"); + // Point factor: 10 plotter units per user unit. + let s = size("IN;SC0,10,0,10,2;SP1;PD0,0,101.6,50.8;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 36.0).abs() < 1e-9, "{s:?}"); + // Without IP, P2 lies 11 × 8.5 in from the origin. + let s = size("IN;SC0,11,0,8.5;SP1;PD0,0,1,1;"); + assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 72.0).abs() < 1e-9, "{s:?}"); + // SC; goes back to plotter units; scaling that makes no area is ignored. + let s = size("IN;SC0,1,0,1;SC;SC0,0,0,1;SP1;PD0,0,1016,1016;"); + assert!((s.width - 72.0).abs() < 1e-9, "{s:?}"); + // RO turns the coordinate system: x runs up. + let shapes = leaves(&plot("IN;RO90;SP1;PW0;PA0,0;PD1016,0;").document); + let b = bez(&shapes[0]).bounding_box(); + assert!(b.width() < 1e-9 && (b.height() - 72.0).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn polyline_encoded_data_is_read() { + let mut f = b"IN;SP1;PW0;PE".to_vec(); + // Pen 2, a pen-up move to the absolute (1016, 0), then a relative line an inch up. + f.push(b':'); + f.extend(pe_number(2, false)); + f.extend(b"<="); + f.extend(pe_number(1016, false)); + f.extend(pe_number(0, false)); + f.extend(pe_number(0, false)); + f.extend(pe_number(1016, false)); + f.push(b';'); + let i = plot_bytes(&f); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 1); + assert_eq!(stroke(&shapes[0]).paint, rgb(255, 0, 0)); + assert_points(&ends(&bez(&shapes[0])), &[(0.125, 72.0), (0.125, 0.0)]); + + // 7-bit data with two fractional bits: 4064 / 4 = 1016. + let mut f = b"IN;SP1;PW0;PE7>".to_vec(); + f.extend(pe_number(2, true)); + f.extend(pe_number(-4064, true)); + f.extend(pe_number(0, true)); + f.push(b';'); + let b = bez(&leaves(&plot_bytes(&f).document)[0]).bounding_box(); + assert!((b.width() - 72.0).abs() < 1e-9, "{b:?}"); + + // Data that can't be decoded ends the instruction at its semicolon: none of it is read as + // instructions. + let shapes = leaves(&plot("IN;SP1;PE#PD5000,5000;PD1016,0;").document); + assert_eq!(shapes.len(), 1); + assert!((bez(&shapes[0]).bounding_box().width() - 72.0).abs() < 1e-9); + + // Rectangle mode: a pen-up move, then a filled rectangle relative to it. + let mut f = b"IN;SP3;PE9".to_vec(); + for v in [100, 100, 1016, 508] { + f.extend(pe_number(v, false)); + } + f.push(b';'); + let shapes = leaves(&plot_bytes(&f).document); + assert_eq!(fill(&shapes[0]), Some(rgb(0, 255, 0))); + let b = bez(&shapes[0]).bounding_box(); + assert!((b.width() - 72.0).abs() < 1e-9 && (b.height() - 36.0).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn printer_escapes_and_job_lines_are_skipped() { + let mut f = b"\x1b%-12345X@PJL JOB\r\n@PJL ENTER LANGUAGE=PCL\r\n\x1bE\x1b%0BIN;SP1;PW0;PD0,0,1016,0;PU;\x1b%0A".to_vec(); + // An HP RTL raster image whose data spells out instructions. + f.extend(b"\x1b*r1A\x1b*b11WPD9999,9999\x1b*rC"); + f.extend(b"\x1b%1BPD1016,1016;\x1b%-12345X@PJL EOJ\r\n"); + assert!(is_hpgl(&f)); + let i = plot_bytes(&f); + let shapes = leaves(&i.document); + // The pen went up and came down where the line ended, so the line goes on. + assert_eq!(shapes.len(), 1, "{shapes:#?}"); + assert_eq!(ends(&bez(&shapes[0])).len(), 3); + assert_eq!(i.warnings, [RASTER], "{:?}", i.warnings); + let r = i.document.artboards[0].rect; + assert!((r.width() - 72.125).abs() < 1e-9 && (r.height() - 72.125).abs() < 1e-9, "{r:?}"); + + // The device-control escapes of older plotters. + let f = b"\x1b.(\x1b.I81;;17:\x1b.N;19:IN;SP1;PD0,0,1016,0;\x1b.)"; + assert!(is_hpgl(f)); + assert_eq!(leaves(&plot_bytes(f).document).len(), 1); +} + +#[test] +fn labels_comments_and_unknown_instructions_are_skipped() { + // A label's text, up to its terminator (ETX, or the one DT sets), isn't read as instructions. + let i = plot("IN;SP1;LBPD9999,9999\x03DT*,1;LBPU0,0;PD5000,5000*CO\"PD8000,8000\";PD1016,0;PU;ZZ1,2;QQ;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 1); + assert_eq!(i.warnings, [LABELS.to_string(), "2 instructions of kinds VectorCraft doesn't read were skipped".to_string()]); + // Symbol mode's symbols aren't drawn, with a warning; its lines are. + let i = plot("IN;SP1;SM*;PD100,0;SM;"); + assert_eq!((i.warnings.clone(), leaves(&i.document).len()), (vec![SYMBOLS.to_string()], 1)); + // Later pages and the input window are warned about. + let i = plot("IN;IW0,0,10,10;SP1;PD100,0;PU;PG;PD0,100;PG;"); + assert_eq!(i.warnings, [WINDOW, PAGES]); + // A page advance raises the pen at the origin: nothing joins the pages. + let i = plot("IN;SP1;PD100,0;PG;PA200,0;PD300,0;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 2); + assert_eq!(ends(&bez(&shapes[1])).len(), 2, "the second page's line starts at (200, 0)"); + // A page advance at the end changes nothing. + assert!(plot("IN;SP1;PD100,0;PG;").warnings.is_empty()); + // A file that draws nothing opens as a letter-size page. + let i = plot("IN;SP1;PU100,100;"); + assert!(leaves(&i.document).is_empty() && i.document.artboards[0].rect.size() == kurbo::Size::new(612.0, 792.0)); +} + +#[test] +fn only_plot_files_are_taken_for_hpgl() { + for f in [ + &b"IN;SP1;PU0,0;PD100,100;"[..], + b"in;sp1;pu0,0;pd100,100;", + b";IN;\r\nSP1;\r\nPA100,100;", + b"\x03IN;", + b"SP1PD0,0,10,10PU", + b"IN", + b"CO\"made by hand\";IN;", + b"IN;SP1;DT*;LBhello world*PA0,0;SMx;PA1,1;SM;", + b"IN;SP1;PE<=\xbf\xbf;PU;", + b"\x1b%-12345X@PJL ENTER LANGUAGE=HPGL2\nIN;SP1;", + ] { + assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + } + for f in [ + &b""[..], + b"IN THE BEGINNING", + b"set terminal png\nplot sin(x)\n", + b"Paris, 10 to 12", + b"SPDX-License-Identifier: MIT", + b"CONTENTS", + b"CO \"a comment\";hello world", + b"DT*;LBtext*nope", + b"\x89PNG\r\n\x1a\n", + b"IN;SP1;PD0,0;hello world", + b"\x1b%-12345X@PJL ENTER LANGUAGE=PCL\n\x1bE\x1b*r1A", + ] { + assert!(!is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f)); + } + // A comment needs no string; instructions of an older or a device's own set count when a + // semicolon ends them. + for f in [&b"CO;IN;SP1;PD0,0,10,10;"[..], b"IN;CO;SP1;PD0,0,10,10;", b"IN;SP1;VA;VS10;PU0,0;PD1016,0;", b"IN;GC5;SP1;PD0,0,10,0;"] { + assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + } + assert!(!is_hpgl(b"IN;GC5") && !is_hpgl(b"IN;Hello, world;")); + // A printer prolog of any length comes before the first instruction. + let long = [&b"\x1b%-12345X"[..], &b"@PJL COMMENT a job line of some length\r\n".repeat(400), b"\x1bE\x1b%0BIN;SP1;PD100,0;"].concat(); + assert!(long.len() > 16_000 && is_hpgl(&long)); + assert_eq!(leaves(&plot_bytes(&long).document).len(), 1); + // Instructions of any kind may follow the first eight, garbage included. + assert!(is_hpgl(b"IN;SP1;PU;PD;PU;PD;PU;PD;hello world")); +} + +/// A file read as HP-GL (picked by its extension) may start with any instruction the reader knows, +/// or one of another set that a semicolon ends; its content alone doesn't make it HP-GL. +#[test] +fn a_plot_file_may_start_with_any_instruction() { + for f in [&b"SI0.2,0.3;SP1;PA0,0;PD1016,0;"[..], b"DI1,0;SP1;PA0,0;PD1016,0;", b"GC5;SP1;PA0,0;PD1016,0;"] { + assert!(!is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + assert_eq!(leaves(&plot_bytes(f).document).len(), 1, "{:?}", String::from_utf8_lossy(f)); + } + for f in [&b"GC5"[..], b"SIhello world", b"set terminal png\n"] { + assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f)); + } + // Instructions of another set alone, however many or long, are not taken for HP-GL. + let long = [&b"GC"[..], "1".repeat(5000).as_bytes(), b";ZZ1;"].concat(); + for f in [&b"GC5;"[..], b"GC5;ZZ1;", b"GC5;ZZ1;YY2;XX3;WW4;VV5;UU6;TT7;", &long] { + assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f)); + } +} + +#[test] +fn escapes_that_carry_values_or_data_are_skipped_whole() { + // A reset leaves HP-GL until a language switch; symbol-set escapes carry a value and a letter. + for f in [ + &b"\x1bE\x1b(8U\x1b%1BIN;SP1;PD0,0,1016,0;"[..], + b"\x1bE\x1b(10U\x1b)0N\x1b%1BIN;SP1;PD0,0,1016,0;", + b"\x1bEHello\x1b%1BIN;SP1;PD0,0,1016,0;", + ] { + assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f)); + assert_eq!(leaves(&plot_bytes(f).document).len(), 1, "{:?}", String::from_utf8_lossy(f)); + } + // A letter left over from an escape doesn't join the next mnemonic. + let i = plot_bytes(b"IN;SP1;\x1b(8UPD0,0,1016,0;"); + assert!(i.warnings.is_empty() && leaves(&i.document).len() == 1, "{:?}", i.warnings); + // Raster data by plane and transparent print data are skipped by their byte counts, however + // they read. + let line = |data_escape: &[u8]| { + let data = b"\x1b%1BPD0,5000;"; + let mut f = b"IN;SP1;PW0;PD0,0,0,1016;\x1b%0A".to_vec(); + f.extend(data_escape); + f.extend(data.len().to_string().as_bytes()); + f.extend(if data_escape.ends_with(b"b") { &b"V"[..] } else { b"X" }); + f.extend(data); + f.extend(b"\x1b*rC\x1b%1BPD0,0;"); + bez(&leaves(&plot_bytes(&f).document)[0]).bounding_box() + }; + for escape in [&b"\x1b*r1A\x1b*b"[..], b"\x1b&p"] { + let b = line(escape); + assert!((b.height() - 72.0).abs() < 1e-9, "{:?}: {b:?}", String::from_utf8_lossy(escape)); + } +} + +#[test] +fn polygon_mode_follows_its_own_rules() { + // The point after a circle starts a new subpolygon: the triangle after it doesn't reach back + // to the circle's center, filled or edged. + let i = plot("IN;SP1;PW0;PA1000,1000;PM0;CI200;PD2000,1000,2000,2000,1000,2000;PM2;FP;EP;"); + let shapes = leaves(&i.document); + assert_eq!(shapes.len(), 2); + for s in &shapes { + let p = bez(s); + let first = p.elements().iter().position(|e| matches!(e, PathEl::ClosePath)).unwrap_or(p.elements().len()); + let circle = BezPath::from_vec(p.elements()[..first].to_vec()).bounding_box(); + let center = circle.center(); + assert!(ends(&p).iter().all(|e| !near(*e, center)), "{p:?}"); + } + // PM1 closes with the pen up: EP draws two sides of the triangle, not the third; PM2 closes + // with the pen as it is. + let sides = |s: &str| { + let p = bez(&leaves(&plot(s).document)[0]); + p.elements().iter().filter(|e| matches!(e, PathEl::LineTo(..))).count() + }; + assert_eq!(sides("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM1;PM2;EP;"), 2); + assert_eq!(sides("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM2;EP;"), 3); + // The closing point moves the pen: a relative move after PM1 starts from the start. + let i = plot("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM1;PU;PR0,2000;PD1000,0,0,1000;PM2;FP;"); + let b = bez(&leaves(&i.document)[0]).bounding_box(); + assert!((b.height() - 3000.0 * PT_PER_PLU).abs() < 1e-9, "{b:?}"); + // A subpolygon of fewer than three points (the closing point counted) is neither filled nor + // edged. + assert!(leaves(&plot("IN;SP1;PA0,0;PM0;PD0,0;PM2;FP;EP;").document).is_empty()); + let i = plot("IN;SP1;PA0,0;PM0;PR0,0;PM1;PA;PD1000,0,0,1000,0,0;PM2;FP;"); + let filled = bez(&leaves(&i.document)[0]); + assert_eq!(filled.elements().iter().filter(|e| matches!(e, PathEl::ClosePath)).count(), 1, "{filled:?}"); + // Polygon mode ignores the instructions it doesn't allow: pens, scaling and labels. + let i = plot("IN;SP1;PA0,0;PM0;SP2;SC0,1,0,1;LBPD9999,9999\x03PD1016,0,1016,1016;PM2;FP;"); + let shapes = leaves(&i.document); + assert_eq!((shapes.len(), fill(&shapes[0])), (1, Some(rgb(0, 0, 0)))); + assert!((bez(&shapes[0]).bounding_box().width() - 72.0).abs() < 1e-9 && i.warnings.is_empty(), "{:?}", i.warnings); +} + +#[test] +fn rotation_keeps_the_pen_and_turns_the_defaults() { + // After RO90, IP's default P2 is the rotated hard-clip corner: the user square spans 8.5 in + // across its rotated x axis and 11 in up it, which lie up and across the page. + let i = plot("IN;RO90;IP;SC0,100,0,100;SP1;PW0;PA0,0;PD100,0,100,100,0,100,0,0;"); + let b = bez(&leaves(&i.document)[0]).bounding_box(); + assert!((b.width() - 792.0).abs() < 1e-9 && (b.height() - 612.0).abs() < 1e-9, "{b:?}"); + // IR's percentages are of the rotated limits too. + let i = plot("IN;RO90;IR0,0,100,100;SC0,1,0,1;SP1;PW0;PA0,0;PD1,1;"); + let b = bez(&leaves(&i.document)[0]).bounding_box(); + assert!((b.width() - 792.0).abs() < 1e-9 && (b.height() - 612.0).abs() < 1e-9, "{b:?}"); + // The pen stays where it was on the page: a line after RO starts where the one before ended. + let i = plot("IN;SP1;PW0;PD0,0,1016,0;PU;RO90;PD;PR1016,0;"); + let all = leaves(&i.document).iter().map(|n| bez(n).bounding_box()).reduce(|a, b| a.union(b)).unwrap(); + assert!((all.width() - 72.0).abs() < 1e-9 && (all.height() - 72.0).abs() < 1e-9, "{all:?}"); +} + +#[test] +fn line_type_zero_and_odd_patterns_follow_the_reference() { + // Line type 0: one dot for an arc (at its end) and one for a circle (at its center). + for s in ["IN;SP1;LT0;PA0,0;PD;AA1016,0,180;", "IN;SP1;LT0;PA0,0;CI500;"] { + let shapes = leaves(&plot(s).document); + assert_eq!(shapes.len(), 1, "{s}"); + assert!(bez(&shapes[0]).bounding_box().width() < 0.1, "{s}"); + } + // An odd number of gaps repeats with the last pen-down stretch running into the first. + let i = plot("IN;SP1;UL3,50,25,25;LT3,10,1;PD0,0,4000,0;"); + let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed"); + assert!((d.pattern[0] - 7.5 * MM).abs() < 1e-9 && (d.pattern[1] - 2.5 * MM).abs() < 1e-9 && d.pattern.len() == 2, "{d:?}"); + assert!((d.offset - 2.5 * MM).abs() < 1e-9, "{d:?}"); + // One pen-down stretch is a solid line; a dot keeps the pattern's length. + let d = stroke(&leaves(&plot("IN;SP1;UL3,100;LT3,10,1;PD0,0,4000,0;").document)[0]).dash.expect("dashed"); + assert_eq!(d.pattern[1], 0.0); + let d = stroke(&leaves(&plot("IN;SP1;LT4,10,1;PD0,0,4000,0;").document)[0]).dash.expect("dashed"); + assert!((d.pattern.iter().sum::() - 10.0 * MM).abs() < 1e-9, "{d:?}"); +} + +#[test] +fn pen_colors_ignore_two_or_three_parameters() { + let i = plot("IN;PC3,0,0,255;PC3,255;PC3,255,0;SP3;PD0,0,10,0;"); + assert_eq!(stroke(&leaves(&i.document)[0]).paint, rgb(0, 0, 255)); +} + +#[test] +fn hostile_files_import_without_panics() { + let mut cases: Vec> = vec![ + // Coordinates past any plotter's range, and numbers with too many digits. + b"IN;SP1;PD99999999999999999999999999999,0;PU0,0;PD1016,1016;".to_vec(), + b"IN;SP1;PD0.000000000000000000000000001,1e999,--,++,..;".to_vec(), + // Unterminated labels, comments and encoded polylines. + b"IN;SP1;LBno end".to_vec(), + b"IN;CO\"no end".to_vec(), + b"IN;SP1;PE<=\xff\xff\xff\xfe\xfe".to_vec(), + b"IN;SP1;PE>\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\xfe\x40\xc0;".to_vec(), + // Arcs and wedges of every degenerate kind, with huge sweeps. + b"IN;SP1;PD;AA0,0,32767;AR0,0,90;AT0,0,0,0;RT1,1,2,2;CI0;CI-5;WG0,0,0;EW5,99999,-99999;WG-5,0,360;CI99999999999999;".to_vec(), + // Scaling onto one point, rotations the format doesn't define, huge palettes and pens. + b"IN;IP5,5,5,5;SC0,1,0,1,1;SP1;PD1,1;SC0,0,0,0,2;RO45;NP99999999;SP99999999999;PC99999999,1,2,3;PW99999,99999;PD2,2;".to_vec(), + // Polygon mode left open, PM1 and PM2 without PM0, FP and EP with nothing buffered. + b"IN;SP1;FP;EP;PM1;PM2;PM0;PD10,10;PM1;PM1;PM1;CI5;PE<\xc0\xc0;".to_vec(), + // Escapes cut short, and data lengths past the end. + b"\x1b%-12345X@PJL".to_vec(), + b"IN;SP1;PD0,0;\x1b*b99999999999W".to_vec(), + b"IN;\x1b".to_vec(), + b"IN;\x1b.".to_vec(), + ]; + // A job line with no end. + cases.push([&b"\x1b%-12345X@PJL "[..], &b"x".repeat(1 << 20)].concat()); + // Bytes of every value after a valid start. + cases.push([&b"IN;SP1;PD"[..], &(0..=255u8).collect::>()].concat()); + for f in &cases { + let r = std::panic::catch_unwind(|| import(f)); + assert!(r.is_ok(), "{:?}", String::from_utf8_lossy(f)); + } + let far = import(&cases[0]).unwrap(); + assert!(far.warnings.iter().any(|w| w == FAR_AWAY), "{:?}", far.warnings); + assert_eq!(leaves(&far.document).len(), 1, "the line in range is kept"); + + // Past the budget for path points, the rest is left out with a warning; pen-up moves, which + // make no path, don't count. + let many = format!("IN;SP1;PR;PD{}", "1,0,".repeat(300_000)); + let i = plot(&many); + assert!(i.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", i.warnings); + // Scaling points past any range give relative pen widths no wider than the widest pen. + let i = plot("IN;IP-99999999999999999999,0,99999999999999999999,0;WU1;PW32767;SP1;PD100,0;"); + let r = i.document.artboards[0].rect; + assert!(r.width().is_finite() && r.height() <= 16384.0 * 72.0 / 1016.0 + 1e-6, "{r:?}"); + assert!((stroke(&leaves(&i.document)[0]).width - 16384.0 * 72.0 / 1016.0).abs() < 1e-9); + + // Each fill of the polygon buffer is a copy of it, and counts. + let big = format!("IN;SP1;PM0;PR;PD{}PM2;{}", "1,1,".repeat(1000), "FP;EP;".repeat(1000)); + let i = plot(&big); + assert!(i.warnings.iter().any(|w| w == TOO_MANY_POINTS) && leaves(&i.document).len() < 400, "{:?}", i.warnings); + for pen in ["SP1;PU", "SP0;PD"] { + let moves = format!("IN;{pen}{};SP1;PU0,0;PD1016,0;", "1,0,".repeat(300_000)); + let i = plot(&moves); + assert!(i.warnings.is_empty() && leaves(&i.document).len() == 1, "{pen}: {:?}", i.warnings); + } +} diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 603521160..8125d02c9 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -1,4 +1,5 @@ -//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader. +//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an +//! HP-GL/2 plot file reader. //! //! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF //! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths @@ -11,13 +12,16 @@ //! clipping) is approximated or left out, and each such loss comes back as a warning. //! //! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`]. +//! [`hpgl::import`] reads an HP-GL plotter file: see [`hpgl`]. //! //! - `aci`: the 256-colour index palette and the nearest index of a colour. //! - `writer`: group codes, handles and the file's sections, tables and objects. //! - `scene`: the document walk that turns objects into entities. //! - `import`: the reader, entities to art, blocks to symbols. +//! - `hpgl`: the HP-GL reader, the pen's moves to paths. mod aci; +pub mod hpgl; pub mod import; mod scene; mod writer; @@ -277,6 +281,10 @@ pub struct DxfOptions { pub scale_lineweights: bool, pub colors: ColorDepth, pub raster: RasterFormat, + /// Placed images are written as IMAGE entities with their files (else left out, undecoded). + pub include_images: bool, + /// Filled areas get a solid HATCH after their outlines (else the outlines alone). + pub include_hatches: bool, pub preserve: Preserve, /// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`] /// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have @@ -299,6 +307,8 @@ impl Default for DxfOptions { scale_lineweights: false, colors: ColorDepth::default(), raster: RasterFormat::default(), + include_images: true, + include_hatches: true, preserve: Preserve::default(), alter_paths: false, outline_text: false, @@ -318,7 +328,8 @@ pub enum DxfPreset { /// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight /// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize /// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths), - /// and placed images as PNG. + /// and placed images and fill hatches left out (fills are their outlines), with PNG as the + /// format images take when they are put back in. Cnc, } @@ -356,6 +367,8 @@ impl DxfPreset { scale_lineweights: false, colors: ColorDepth::Aci256, raster: RasterFormat::Png, + include_images: false, + include_hatches: false, preserve: Preserve::Editability, alter_paths: false, outline_text: true, @@ -373,6 +386,8 @@ impl DxfPreset { && o.scale_lineweights == c.scale_lineweights && o.colors == c.colors && o.raster == c.raster + && o.include_images == c.include_images + && o.include_hatches == c.include_hatches && o.preserve == c.preserve && o.alter_paths == c.alter_paths && o.outline_text == c.outline_text; diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 3faf94423..7c5d8b675 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1,5 +1,6 @@ //! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint -//! order. Fills are their outlines then solid hatches (the outlines alone in R12), strokes +//! order. Fills are their outlines then solid hatches (the outlines alone in R12 or with +//! hatches left out), placed images are linked image files unless left out, strokes //! polylines (with bulges for circular arcs) and cubic splines with a lineweight and dashes, or //! filled outlines where the look needs them. @@ -28,6 +29,8 @@ const NO_TRUE_COLOR: &str = "true colour needs DXF 2004 or later: colours are wr const NO_TRANSPARENCY: &str = "transparency needs DXF 2004 or later: transparent objects are written opaque"; const NO_FILLS: &str = "DXF R12 has no fills: filled areas are written as their outlines"; const NO_IMAGES: &str = "DXF R12 has no images: placed images are left out"; +const IMAGES_OFF: &str = "placed images are left out (Include Placed Images is off)"; +const HATCHES_OFF: &str = "fill hatches are left out: filled areas are written as their outlines (Include Fill Hatches is off)"; const WIDE_LINES: &str = "lines wider than 2.11 mm (the widest lineweight) are written at 2.11 mm: Alter Paths for Appearance writes their true width"; const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)"; @@ -339,6 +342,32 @@ fn subpaths(bp: &BezPath) -> Vec { out } +/// `bp` with each open subpath that can enclose an area closed, as a fill closes it: two or more +/// segments, or one curve. A single straight segment stays open. +fn closed_fill(bp: &BezPath) -> BezPath { + let mut out = BezPath::new(); + let (mut segs, mut curved) = (0usize, false); + let close = |out: &mut BezPath, segs: usize, curved: bool| { + if segs >= 2 || (segs == 1 && curved) { + out.close_path(); + } + }; + for el in bp.elements() { + match *el { + PathEl::MoveTo(_) => { + close(&mut out, segs, curved); + (segs, curved) = (0, false); + } + PathEl::ClosePath => (segs, curved) = (0, false), + PathEl::LineTo(_) => segs += 1, + PathEl::QuadTo(..) | PathEl::CurveTo(..) => (segs, curved) = (segs + 1, true), + } + out.push(*el); + } + close(&mut out, segs, curved); + out +} + /// The most pixels of an image written (it is held decoded, as RGBA and encoded at once). const MAX_IMAGE_PIXELS: u64 = 64 << 20; @@ -878,13 +907,13 @@ impl<'a> Scene<'a> { Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st)) } - /// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser - /// and cutter software reads the outlines and skips hatches. `clean`: the path has no + /// A filled area as its outlines followed by a solid hatch (R12, or with hatches left out: the + /// outlines alone). Laser and cutter software reads the outlines and skips hatches. `clean`: the path has no /// overlaps, so the hatch's even-odd rule reads it as it is. fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) { if !self.o.version.handles() { self.warn(NO_FILLS); - self.lines(bp, style, 0.0); + self.lines(&closed_fill(bp), style, 0.0); return; } let several = bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() > 1; @@ -893,11 +922,17 @@ impl<'a> Scene<'a> { .flatten() .map(|p| p.to_bezpath()); let outline = normalized.as_ref().unwrap_or(bp); + // A fill with no loop of three points or more encloses no area: no outline and no hatch. let loops: Vec> = polygons(outline).into_iter().filter(|p| p.len() >= 3).collect(); if loops.is_empty() { return; } - self.lines(outline, style, 0.0); + // The outlines close as the fill does, so an open path's fill keeps its closing edge. + self.lines(&closed_fill(outline), style, 0.0); + if !self.o.include_hatches { + self.warn(HATCHES_OFF); + return; + } let h = self.handle(); let layer = self.layer.clone(); self.e.entity("HATCH", h, fixed::MODEL_RECORD, &layer, style, "AcDbHatch"); @@ -1285,6 +1320,10 @@ impl<'a> Scene<'a> { self.warn(NO_IMAGES); return; } + if !self.o.include_images { + self.warn(IMAGES_OFF); + return; + } let Some(i) = self.image_def(im) else { return }; let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return }; let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1))); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 90e4cbc2c..9d2a519e1 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -843,10 +843,151 @@ fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_stro assert!(out.images.is_empty()); assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc); assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions { include_images: true, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions { include_hatches: true, ..o.clone() }), DxfPreset::Custom); assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom); assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc)); } +/// A 4 × 2 px PNG placed at (10, 20) under key `raster-1`, and before it in paint order +/// `fill`'s rectangle. +fn image_and_fill_doc(fill: Paint) -> Document { + let mut png = vec![]; + image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = doc_with(|d| { + let im = ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + vec![ + path(d, shapes::rectangle(Rect::new(50.0, 10.0, 150.0, 60.0)), fill, Paint::None, 0.0), + Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))), + ] + }); + d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None }); + d +} + +const IMAGES_OFF: &str = "placed images are left out (Include Placed Images is off)"; +const HATCHES_OFF: &str = "fill hatches are left out: filled areas are written as their outlines (Include Fill Hatches is off)"; + +#[test] +fn fills_without_hatches_keep_their_outlines() { + let d = shapes_doc(); + let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle"); + assert_eq!(out.warnings, [HATCHES_OFF]); + let e = entities(&out); + assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight"); + // Hatches stay by default, with no note. + let on = export(&d, &opts(&d)).unwrap(); + assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF)); + // The outlines are the ones written beside hatches, also for overlapping subpaths, which are + // merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way. + let compound = doc_with(|d| { + let mut bp = shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)).to_bezpath(); + bp.extend(shapes::rectangle(Rect::new(40.0, 40.0, 90.0, 90.0)).to_bezpath().elements().iter().copied()); + let p = PathData::from_bezpath(&bp); + vec![ + path(d, p, Paint::solid(Color::BLACK), Paint::None, 0.0), + path(d, shapes::rectangle(Rect::new(10.0, 70.0, 90.0, 70.0)), Paint::solid(Color::BLACK), Paint::None, 0.0), + path(d, shapes::line(Point::new(100.0, 10.0), Point::new(150.0, 60.0)), Paint::solid(Color::BLACK), Paint::None, 0.0), + ] + }); + let without_hatches = |o: &DxfOutput| { + let mut e = entities(o); + e.retain(|x| kind(x) != "HATCH"); + e.into_iter().map(|x| x.into_iter().filter(|p| p.0 != 5).collect::()).collect::>() + }; + let (c_on, c_off) = + (export(&compound, &opts(&compound)).unwrap(), export(&compound, &DxfOptions { include_hatches: false, ..opts(&compound) }).unwrap()); + assert_eq!(kinds(&c_on), ["LWPOLYLINE", "HATCH"], "one merged outline"); + assert_eq!(without_hatches(&c_off), without_hatches(&c_on)); + assert_eq!(without_hatches(&out), without_hatches(&on)); + // Strokes outlined for their look are areas too: their outlines stay. + let altered = + export(&d, &DxfOptions { include_hatches: false, alter_paths: true, preserve: Preserve::Editability, version: DxfVersion::R14, ..opts(&d) }) + .unwrap(); + assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered)); + // Nothing filled: no note. + let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty()); + // R12 has no hatches anyway: the same outlines, and its own note alone. + let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap(); + let (off, on) = (r12(false), r12(true)); + assert_eq!(off.bytes, on.bytes); + assert_eq!(off.warnings, on.warnings); + assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); +} + +#[test] +fn an_open_path_s_fill_outline_keeps_its_closing_edge() { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((10.0, 10.0)); + bp.line_to((90.0, 10.0)); + bp.line_to((50.0, 80.0)); + let open = PathData::from_bezpath(&bp); + let d = doc_with(|d| vec![path(d, open.clone(), Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0)]); + let closed = |e: &Pairs| get(e, 70).and_then(|v| v.trim().parse::().ok()).is_some_and(|v| v & 1 == 1); + for include_hatches in [false, true] { + let out = export(&d, &DxfOptions { include_hatches, ..opts(&d) }).unwrap(); + let e: Vec = entities(&out).into_iter().filter(|x| kind(x) == "LWPOLYLINE").collect(); + assert_eq!(e.len(), 2, "the fill's outline and the stroke: {:?}", kinds(&out)); + assert!(closed(&e[0]), "the fill's outline closes as the fill does (hatches {include_hatches})"); + assert!(!closed(&e[1]), "the stroke stays open (hatches {include_hatches})"); + } + // R12 writes the outline alone, closed too. + let fill_only = doc_with(|d| vec![path(d, open.clone(), Paint::solid(Color::BLACK), Paint::None, 0.0)]); + let r12 = export(&fill_only, &DxfOptions { version: DxfVersion::R12, ..opts(&fill_only) }).unwrap(); + let e = entities(&r12); + assert_eq!(kind(&e[0]), "POLYLINE", "{:?}", kinds(&r12)); + assert!(closed(&e[0]), "R12's outline closes as the fill does"); +} + +#[test] +fn images_can_be_left_out_with_their_files() { + let d = image_and_fill_doc(Paint::None); + let out = export(&d, &DxfOptions { include_images: false, ..opts(&d) }).unwrap(); + assert!(entities(&out).is_empty() && out.images.is_empty(), "{:?}", kinds(&out)); + let p = pairs(&out.bytes); + assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES"))); + assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); + assert_eq!(out.warnings, [IMAGES_OFF]); + // An image that can't be decoded is not read when images are left out. + let mut broken = d.clone(); + broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None }); + assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]); + assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded"))); + // R12 has no images anyway: its own note alone. + let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap(); + assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF)); +} + +#[test] +fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both() { + let d = image_and_fill_doc(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let run = |o: DxfOptions| export(&d, &DxfOptions { region: d.artboards[0].rect, ..o }).unwrap(); + let both = run(DxfOptions::default()); + assert_eq!(kinds(&both), ["LWPOLYLINE", "HATCH", "IMAGE"]); + assert_eq!(both.images.len(), 1); + let no_images = run(DxfOptions { include_images: false, ..DxfOptions::default() }); + assert_eq!((kinds(&no_images), no_images.images.len()), (vec!["LWPOLYLINE".to_string(), "HATCH".into()], 0)); + let no_hatches = run(DxfOptions { include_hatches: false, ..DxfOptions::default() }); + assert_eq!((kinds(&no_hatches), no_hatches.images.len()), (vec!["LWPOLYLINE".to_string(), "IMAGE".into()], 1)); + // CNC / CAM: the cutting path alone, with no image file and both notes. + let cnc = run(DxfPreset::Cnc.options()); + assert_eq!(kinds(&cnc), ["LWPOLYLINE"]); + assert!(cnc.images.is_empty()); + assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings); + let custom = DxfPreset::Custom.options(); + assert!(custom.include_images && custom.include_hatches, "Custom keeps both"); +} + #[test] fn layers_become_dxf_layers() { let mut d = shapes_doc(); diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 18aa1bd18..c5803e322 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -19,13 +19,18 @@ pub enum GradientKind { /// the page from the vector, with a seam where the last stop meets the first. A negative /// `aspect` mirrors the sweep (see [`GradientGeom::transform`]). Conic, + /// Diamond gradients: the stops run from the center (`start`) out to a square whose + /// half-diagonal is the vector (a diamond when the vector is level), stretched across the + /// vector by `aspect` (see [`GradientGeom::diamond_frame`]). + Diamond, } impl GradientKind { /// Every kind, in the order the Gradient panel lists them. - pub const ALL: [GradientKind; 4] = [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Freeform]; - /// Parse a kind name (`linear`, `radial`, `conic`, `freeform`; any case). `angle` and - /// `conical` also mean conic. + pub const ALL: [GradientKind; 5] = + [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond, GradientKind::Freeform]; + /// Parse a kind name (`linear`, `radial`, `conic`, `diamond`, `freeform`; any case). `angle` + /// and `conical` also mean conic. pub fn parse(s: &str) -> Option { if s.eq_ignore_ascii_case("angle") || s.eq_ignore_ascii_case("conical") { return Some(GradientKind::Conic); @@ -38,12 +43,13 @@ impl GradientKind { GradientKind::Radial => "Radial", GradientKind::Freeform => "Freeform", GradientKind::Conic => "Conic", + GradientKind::Diamond => "Diamond", } } - /// Is it placed by a center (`start`) and an extent ellipse (radial and conic), rather than - /// by a line? + /// Is it placed by a center (`start`) and an extent around it (radial, conic and diamond), + /// rather than by a line? pub fn is_centered(self) -> bool { - matches!(self, GradientKind::Radial | GradientKind::Conic) + matches!(self, GradientKind::Radial | GradientKind::Conic | GradientKind::Diamond) } } @@ -668,13 +674,22 @@ fn one64() -> f64 { impl GradientGeom { /// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`, /// radial and conic are centered with radius = half the larger dimension and their vector at - /// `angle_deg`. + /// `angle_deg`, and diamond is centered with its vector at `angle_deg`, its square's edge + /// running through the box's farthest corner (at 0 or 90 degrees, through all four). pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self { let c = b.center(); let a = angle_deg.to_radians(); let d = Vec2::new(a.cos(), -a.sin()); match kind { GradientKind::Radial | GradientKind::Conic => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None }, + GradientKind::Diamond => { + // The square through the box's farthest corner: the largest reach along the vector + // plus across it, at one corner. + let (w, h) = (b.width() / 2.0, b.height() / 2.0); + let level = |x: f64, y: f64| (x * d.x + y * d.y).abs() + (y * d.x - x * d.y).abs(); + let half = level(w, h).max(level(w, -h)); + Self { start: c, end: c + d * half, aspect: 1.0, focal: None } + } _ => { // Project the box corners onto the direction to cover the whole box. let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0; @@ -694,6 +709,10 @@ impl GradientGeom { /// other way: exact for moves, rotations, uniform scales and mirrors, and the nearest frame /// that keeps the seam under a shear or a non-uniform scale. A conic whose vector has no /// length, or whose aspect is 0, still has the sign of its aspect flipped by a mirror. + /// Diamond gradients keep their vector (a corner of the square) the same way, with the size + /// of the mapped extent across it as their aspect: exact for moves, rotations, scales along or + /// across the vector and mirrors, and the nearest square stretched across its vector under a + /// shear or another scale. pub fn transform(&mut self, a: Affine, kind: GradientKind) { // The focal point is a point like any other. self.focal = self.focal.map(|f| a * f); @@ -724,6 +743,14 @@ impl GradientGeom { }; return; } + if kind == GradientKind::Diamond { + let (lu, lw) = (lin(u), lin(perp(u) * self.aspect)); + self.start = start; + self.end = start + lu; + let aspect = (lw.dot(perp(lu)) / lu.hypot2()).abs(); + self.aspect = if lu.hypot2() > 1e-18 && aspect.is_finite() { aspect } else { 1.0 }; + return; + } if kind != GradientKind::Radial { let far = a * self.end; let nrm = perp(lin(perp(u))); @@ -768,6 +795,8 @@ impl GradientGeom { /// measured from the focal point (each level is the ellipse scaled about the line from the /// focal point to the centre). For conic gradients it is the turn from the vector in /// [`Self::conic_frame`], clockwise on the page (counterclockwise when mirrored), in 0..=1. + /// For diamond gradients it is the size of the square through `p`, as a fraction of the one + /// [`Self::diamond_frame`] gives (1 everywhere for a diamond without a frame). pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 { if kind == GradientKind::Conic { let q = (self.conic_frame().inverse() * p).to_vec2(); @@ -775,6 +804,13 @@ impl GradientGeom { let t = if t < 0.0 { t + 1.0 } else { t }; return if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; } + if kind == GradientKind::Diamond { + // Without a frame the square has no inside: every point is past its edge. + let Some(m) = self.diamond_frame() else { return 1.0 }; + let q = (m.inverse() * p).to_vec2(); + let t = q.x.abs() + q.y.abs(); + return if t.is_nan() { 1.0 } else { t }; + } let u = self.end - self.start; if u.hypot2() < 1e-18 { return 0.0; @@ -835,32 +871,52 @@ impl GradientGeom { pub fn conic_wedges(&self, b: Rect, n: usize, overlap: bool) -> Vec<(Vec, f64)> { let n = n.clamp(1, MAX_CONIC_WEDGES); let frame = self.conic_frame(); - let to_unit = frame.inverse(); - let corners = [b.origin(), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; - let reach = corners.iter().map(|&c| (to_unit * c).to_vec2().hypot()).fold(0.0f64, f64::max).max(1e-6); - let reach = if reach.is_finite() { reach * 1.01 } else { 1.0 }; - // Arc points at most ARC of a turn apart, at a radius where their chords stay outside - // `reach`. - const ARC: f64 = 1.0 / 32.0; - let r = reach / (std::f64::consts::PI * ARC).cos(); - let at = |turn: f64| { - let a = turn * std::f64::consts::TAU; - frame * Point::new(r * a.cos(), r * a.sin()) - }; - let center = frame * Point::ORIGIN; + let r = fan_radius(frame, b); (0..n) .map(|k| { let from = k as f64 / n as f64; let to = if overlap { 1.0 } else { (k + 1) as f64 / n as f64 }; - let steps = ((to - from) / ARC).ceil().max(1.0) as usize; - let mut pts = Vec::with_capacity(steps + 2); - pts.push(center); - pts.extend((0..=steps).map(|i| at(from + (to - from) * i as f64 / steps as f64))); - (pts, (k as f64 + 0.5) / n as f64) + (fan(frame, r, from, to), (k as f64 + 0.5) / n as f64) }) .collect() } + /// The frame a diamond gradient is drawn in: its [`Self::unit_frame`], which maps the square + /// with corners (±1, 0) and (0, ±1) onto the diamond (its first corner at the end of the + /// vector). `None` when the square is flat or not finite (a vector without length, an aspect + /// of 0, or a file's out-of-range numbers): such a diamond has no inside, and every point + /// takes its last stop. + pub fn diamond_frame(&self) -> Option { + self.unit_frame().filter(|m| m.is_finite() && m.determinant().abs() > 1e-18) + } + + /// A diamond gradient as four linear gradients, one for each quadrant of its + /// [`Self::diamond_frame`], for formats without diamond gradients: each quadrant's outline (the + /// center, then an arc that reaches past every corner of `b`) and the linear gradient that + /// equals the diamond there (its stops run across the square's edge in that quadrant), in the + /// order they are drawn. Each of the first three runs on past its last edge under the next, + /// and the first also back past its first edge under the last, so every edge drawn lies on + /// the quadrant before it and no hairline of what lies below shows between them; where they + /// overlap, translucent stops would add up, so the stops must be opaque. `None` for a diamond + /// without a frame. + pub fn diamond_quadrants(&self, b: Rect) -> Option<[(Vec, GradientGeom); 4]> { + let frame = self.diamond_frame()?; + let r = fan_radius(frame, b); + Some(std::array::from_fn(|k| { + let first = k as f64 / 4.0; + let (from, to) = match k { + 0 => (-0.125, 0.375), + 3 => (0.75, 1.0), + _ => (first, first + 0.375), + }; + // The quadrant's corner of the square, halfway along whose edge t is 1/2. + let (sx, sy) = [(1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0)][k]; + let mut geom = GradientGeom { start: Point::ORIGIN, end: Point::new(sx / 2.0, sy / 2.0), aspect: 1.0, focal: None }; + geom.transform(frame, GradientKind::Linear); + (fan(frame, r, from, to), geom) + })) + } + /// The map that squashes a radial gradient's circle (centre `start`, radius the vector's /// length) into its extent ellipse: by `aspect` across the vector, about the centre. pub fn radial_squash(&self) -> Affine { @@ -907,6 +963,34 @@ impl GradientGeom { } } +/// Arc points of [`fan`] are at most this much of a turn apart. +const FAN_ARC: f64 = 1.0 / 32.0; + +/// The radius of the unit circle of `frame` at which the chords of [`fan`]'s arcs stay outside +/// every corner of `b`. +fn fan_radius(frame: Affine, b: Rect) -> f64 { + let to_unit = frame.inverse(); + let corners = [b.origin(), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; + let reach = corners.iter().map(|&c| (to_unit * c).to_vec2().hypot()).fold(0.0f64, f64::max).max(1e-6); + let reach = if reach.is_finite() { reach * 1.01 } else { 1.0 }; + reach / (std::f64::consts::PI * FAN_ARC).cos() +} + +/// The sector of `frame`'s unit circle, scaled to radius `r`, from turn `from` to turn `to` +/// (counted from the frame's x axis toward its y axis) as an outline: the center, then points on +/// the arc at most [`FAN_ARC`] of a turn apart. +fn fan(frame: Affine, r: f64, from: f64, to: f64) -> Vec { + let at = |turn: f64| { + let a = turn * std::f64::consts::TAU; + frame * Point::new(r * a.cos(), r * a.sin()) + }; + let steps = ((to - from) / FAN_ARC).ceil().max(1.0) as usize; + let mut pts = Vec::with_capacity(steps + 2); + pts.push(frame * Point::ORIGIN); + pts.extend((0..=steps).map(|i| at(from + (to - from) * i as f64 / steps as f64))); + pts +} + /// `p` moved from box `from` to box `to`, keeping its position relative to the box (along a side /// `from` has no extent on, its offset from the centre). fn rebase_point(p: Point, from: Rect, to: Rect) -> Point { @@ -1009,7 +1093,7 @@ impl GradientPaint { pub fn draws_as_pixels(&self, fill: bool) -> bool { self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic() } - /// A linear, radial or conic gradient placed on box `bounds` as a `cols` × `rows` grid of + /// A linear, radial, conic or diamond gradient placed on box `bounds` as a `cols` × `rows` grid of /// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours /// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered and painting a /// fill (`fill`; dither applies to fills only), each colour channel of pixel (column, row) gets @@ -1040,7 +1124,7 @@ impl GradientPaint { ) -> impl Iterator + use<> { self.grid(Ramp::new(&self.gradient, rgb), bounds, area, cols, rows, cells, fill, false) } - /// [`Self::pixels`] of a linear, radial or conic gradient whose stops are all CMYK, as C, M, Y, K + /// [`Self::pixels`] of a linear, radial, conic or diamond gradient whose stops are all CMYK, as C, M, Y, K /// and opacity levels mixed from the stops' inks; `None` when a stop is in another model, /// there are no stops, or a Perceptual curve has too many stops for [`Gradient::ink_mixes`]. /// Dither's noise adds no ink where the ramp has none. diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index c8d0a4bb6..0709d3068 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -880,3 +880,177 @@ fn conic_pixels_follow_the_sweep() { let many = GradientPaint { gradient: Gradient::new(GradientKind::Conic, many), ..g }; assert_eq!(many.pixels(b, b, 4, 4, true, &Color::to_rgb).count(), 16); } + +/// A diamond placed at `center` with its vector `dx` long to the right and aspect `aspect`. +fn diamond_at(center: Point, dx: f64, aspect: f64) -> GradientGeom { + GradientGeom { start: center, end: center + Vec2::new(dx, 0.0), aspect, focal: None } +} + +#[test] +fn a_diamond_runs_from_its_center_out_to_a_square_with_a_corner_at_the_end_of_its_vector() { + let g = diamond_at(Point::new(100.0, 100.0), 50.0, 1.0); + let t = |x: f64, y: f64| g.param_at(GradientKind::Diamond, Point::new(x, y)); + assert!(t(100.0, 100.0).abs() < 1e-12); + // The square's corners: at the end of the vector and a quarter turn on from it each way. + for (x, y) in [(150.0, 100.0), (100.0, 150.0), (50.0, 100.0), (100.0, 50.0)] { + assert!((t(x, y) - 1.0).abs() < 1e-12, "({x}, {y})"); + } + // Halfway along an edge, and halfway out along a diagonal (the middle of the edge's half size). + assert!((t(125.0, 125.0) - 1.0).abs() < 1e-12); + assert!((t(112.5, 87.5) - 0.5).abs() < 1e-12); + // Past the square: more than 1 (the last stop pads on). + assert!((t(200.0, 200.0) - 4.0).abs() < 1e-12); + // The aspect stretches the square across the vector into a rhombus. + let tall = diamond_at(Point::new(100.0, 100.0), 50.0, 2.0); + assert!((tall.param_at(GradientKind::Diamond, Point::new(100.0, 200.0)) - 1.0).abs() < 1e-12); + assert!((tall.param_at(GradientKind::Diamond, Point::new(150.0, 100.0)) - 1.0).abs() < 1e-12); + // Turned: the corner follows the vector. + let turned = GradientGeom { end: Point::new(130.0, 140.0), ..g }; + assert!((turned.param_at(GradientKind::Diamond, Point::new(130.0, 140.0)) - 1.0).abs() < 1e-12); + assert!((turned.param_at(GradientKind::Diamond, Point::new(115.0, 120.0)) - 0.5).abs() < 1e-12); +} + +#[test] +fn diamond_kind_names_and_fit() { + for s in ["diamond", "Diamond", "DIAMOND"] { + assert_eq!(GradientKind::parse(s), Some(GradientKind::Diamond), "{s}"); + } + assert_eq!(GradientKind::Diamond.label(), "Diamond"); + assert!(GradientKind::Diamond.is_centered()); + assert!(GradientKind::ALL.contains(&GradientKind::Diamond)); + // Level: the square's edges pass through the box's corners, so it covers the box exactly. + let b = Rect::new(0.0, 0.0, 200.0, 100.0); + let fit = GradientGeom::fit(GradientKind::Diamond, b, 0.0); + assert_eq!(fit.start, Point::new(100.0, 50.0)); + assert!(fit.end.distance(Point::new(250.0, 50.0)) < 1e-9, "{fit:?}"); + for c in [Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 100.0), Point::new(0.0, 100.0)] { + assert!((fit.param_at(GradientKind::Diamond, c) - 1.0).abs() < 1e-9, "{c:?}"); + } + // At any angle the square covers the box and runs through its farthest corner, on a square + // box too. + for b in [b, Rect::new(0.0, 0.0, 100.0, 100.0)] { + for angle in [30.0, 45.0, 90.0, -120.0] { + let fit = GradientGeom::fit(GradientKind::Diamond, b, angle); + let levels = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)] + .map(|c| fit.param_at(GradientKind::Diamond, c)); + assert!(levels.iter().all(|&t| t <= 1.0 + 1e-9), "{angle}: {levels:?}"); + assert!(levels.iter().any(|&t| (t - 1.0).abs() < 1e-9), "{angle}: {levels:?}"); + } + } +} + +#[test] +fn a_diamond_follows_moves_turns_scales_and_mirrors() { + let c = Point::new(10.0, 20.0); + let g0 = diamond_at(c, 30.0, 0.5); + let probes = [c + Vec2::new(5.0, 7.0), c + Vec2::new(-9.0, 2.0), c + Vec2::new(3.0, -11.0), c + Vec2::new(-20.0, -4.0)]; + for a in [ + Affine::rotate(0.7), + Affine::scale(2.5), + Affine::translate((4.0, -9.0)) * Affine::rotate(-2.0), + Affine::FLIP_X, + Affine::FLIP_Y * Affine::rotate(1.1), + // Along and across the vector. + Affine::scale_non_uniform(2.0, 0.5), + Affine::scale_non_uniform(-3.0, 0.25), + ] { + let mut g = g0; + g.transform(a, GradientKind::Diamond); + assert!(g.start.distance(a * c) < 1e-9, "{a:?}"); + // A mirror changes nothing a diamond shows: its aspect stays a size. + assert!(g.aspect > 0.0, "{a:?}: {g:?}"); + for p in probes { + let (want, got) = (g0.param_at(GradientKind::Diamond, p), g.param_at(GradientKind::Diamond, a * p)); + assert!((want - got).abs() < 1e-9, "{a:?} at {p:?}: {want} vs {got}"); + } + } + // Under a shear the corner stays where the vector maps. + let shear = Affine::new([1.0, 0.0, 0.6, 1.0, 0.0, 0.0]); + let mut g = g0; + g.transform(shear, GradientKind::Diamond); + assert!((g.param_at(GradientKind::Diamond, shear * (c + Vec2::new(30.0, 0.0))) - 1.0).abs() < 1e-9, "{g:?}"); + // Out-of-range numbers: a finite aspect after any transform. + for bad in [diamond_at(c, 30.0, f64::NAN), diamond_at(c, f64::INFINITY, 1.0), diamond_at(c, 0.0, 1.0)] { + let mut g = bad; + g.transform(Affine::rotate(0.4), GradientKind::Diamond); + assert!(g.aspect.is_finite(), "{g:?}"); + } +} + +/// A diamond without a frame (no vector, a flat or a not-finite extent) has no inside: every +/// point is past its edge and takes the last stop. Points that aren't numbers do too. +#[test] +fn a_diamond_with_no_extent_or_bad_numbers_is_its_last_stop() { + let c = Point::new(50.0, 50.0); + for g in [ + diamond_at(c, 0.0, 1.0), + diamond_at(c, 30.0, 0.0), + diamond_at(c, 30.0, f64::NAN), + diamond_at(c, f64::INFINITY, 1.0), + GradientGeom { start: Point::new(f64::NAN, 0.0), ..diamond_at(c, 30.0, 1.0) }, + ] { + assert!(g.diamond_frame().is_none(), "{g:?}"); + assert!(g.diamond_quadrants(Rect::new(0.0, 0.0, 100.0, 100.0)).is_none(), "{g:?}"); + assert_eq!(g.param_at(GradientKind::Diamond, Point::new(60.0, 40.0)), 1.0, "{g:?}"); + } + let g = diamond_at(c, 30.0, 1.0); + assert_eq!(g.param_at(GradientKind::Diamond, Point::new(f64::NAN, 1.0)), 1.0); + assert!(g.param_at(GradientKind::Diamond, Point::new(f64::INFINITY, 1.0)) >= 1.0); +} + +/// The four linear gradients a format without diamond gradients gets, drawn in order, equal the +/// diamond everywhere, and each edge drawn lies on the quadrant drawn before it. +#[test] +fn diamond_quadrants_are_linear_gradients_that_equal_the_diamond() { + let b = Rect::new(0.0, 0.0, 200.0, 100.0); + let probes: Vec = (0..=20).flat_map(|i| (0..=10).map(move |j| Point::new(i as f64 * 10.0 + 0.3, j as f64 * 10.0 + 0.7))).collect(); + // Squashed and turned, stretched twice as tall as wide, and stretched by a shear. + let mut turned = diamond_at(Point::new(60.0, 40.0), 30.0, 0.5); + turned.transform(Affine::rotate_about(0.4, Point::new(60.0, 40.0)), GradientKind::Diamond); + let mut sheared = diamond_at(Point::new(90.0, 50.0), 25.0, 1.0); + sheared.transform(Affine::new([2.0, 0.0, 0.7, 1.0, -60.0, 0.0]), GradientKind::Diamond); + for g in [turned, diamond_at(Point::new(100.0, 50.0), 20.0, 2.0), sheared] { + let quadrants = g.diamond_quadrants(b).expect("a frame"); + for p in &probes { + // The last quadrant drawn that holds the point paints it. + let Some((_, linear)) = quadrants.iter().rev().find(|(poly, _)| inside(poly, *p)) else { panic!("{p:?} is in no quadrant") }; + let (want, got) = (g.param_at(GradientKind::Diamond, *p), linear.param_at(GradientKind::Linear, *p)); + assert!((want - got).abs() < 1e-9, "{g:?} at {p:?}: {want} vs {got}"); + } + } + let g = turned; + let quadrants = g.diamond_quadrants(b).expect("a frame"); + // Next to each edge of a quadrant drawn later, on either side, the quadrant drawn before it + // lies underneath (the first quadrant's edges are covered by the second and the last). + let frame = g.diamond_frame().expect("a frame"); + for (unit, under) in [ + // The second quadrant's first edge, on the first. + (Point::new(0.01, 0.5), 0), + (Point::new(-0.01, 0.5), 0), + // The third's, on the second. + (Point::new(-0.5, 0.01), 1), + (Point::new(-0.5, -0.01), 1), + // The last's two, on the third and on the first. + (Point::new(-0.01, -0.5), 2), + (Point::new(0.01, -0.5), 2), + (Point::new(0.5, -0.01), 0), + (Point::new(0.5, 0.01), 0), + ] { + assert!(inside(&quadrants[under].0, frame * unit), "{unit:?} lies on quadrant {under}"); + } +} + +/// A diamond's pixels (what SVG and PDF draw a dithered or Perceptual one as, and what the canvas +/// draws) follow the squares. +#[test] +fn diamond_pixels_follow_the_squares() { + let b = Rect::new(0.0, 0.0, 5.0, 5.0); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Diamond, Gradient::default().stops)); + g.geom = Some(diamond_at(Point::new(2.5, 2.5), 2.5, 1.0)); + let px: Vec<[u8; 4]> = g.pixels(b, b, 5, 5, false, &Color::to_rgb).collect(); + // White at the center, black in the corners, and the same on each side of it. + assert!(px[2 * 5 + 2][0] > 200 && px[0][0] < 10 && px[24][0] < 10, "{px:?}"); + assert_eq!(px[2 * 5 + 1], px[2 * 5 + 3]); + assert_eq!(px[5 + 2], px[3 * 5 + 2]); + assert_eq!(px[2 * 5 + 1], px[5 + 2]); +} diff --git a/crates/color/src/libraries.rs b/crates/color/src/libraries.rs index e96760d5c..5b5fd3653 100644 --- a/crates/color/src/libraries.rs +++ b/crates/color/src/libraries.rs @@ -9,7 +9,7 @@ use std::sync::{Arc, OnceLock}; use serde::{Deserialize, Serialize}; -use crate::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch, SwatchGroup}; +use crate::{Color, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch, SwatchGroup}; /// A library: its name, its ungrouped swatches and its colour groups. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -72,6 +72,7 @@ pub const GRADIENT_LIBRARIES: &[BuiltinLibrary] = &[ lib!("spectrum-gradients", "Spectrum Gradients", spectrum_gradients), lib!("sky-gradients", "Sky Gradients", sky_gradients), lib!("neutral-gradients", "Neutral Gradients", neutral_gradients), + lib!("organic-fades", "Organic Fades", organic_fades), lib!("duotone-gradients", "Duotone Gradients", duotone_gradients), lib!("radial-glows", "Radial Glows", radial_glows), ]; @@ -179,6 +180,14 @@ pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32 Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops)))), global: false, spot: false } } +/// A gradient swatch mixed by `method` through `stops` of (offset, color, opacity, midpoint). +fn gradient_with(name: &str, kind: GradientKind, method: GradientMethod, stops: &[(f32, Color, f32, f32)]) -> Swatch { + let stops = + stops.iter().map(|&(offset, color, opacity, midpoint)| GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) }).collect(); + let gradient = Gradient { method, ..Gradient::new(kind, stops) }; + Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(gradient))), global: false, spot: false } +} + /// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1), /// named "`name` 1"… fn ramp(name: &str, n: usize, (l0, l1): (f64, f64), c: impl Fn(f64) -> f64, h: impl Fn(f64) -> f64) -> SwatchGroup { @@ -387,6 +396,141 @@ fn radial_glows() -> (Vec, Vec) { (s, vec![]) } +/// A stop of [`organic_fades`]: offset, OKLCH `(l, c, h°)`, opacity and midpoint. +type FadeStop = (f32, (f64, f64, f64), f32, f32); + +/// Soft fades through a few unevenly spaced stops with off-center midpoints, mixed perceptually, +/// that thin out to transparent: at the end of the linear ones and at the edge of the radial ones. +fn organic_fades() -> (Vec, Vec) { + use GradientKind::{Linear, Radial}; + let fades: [(&str, GradientKind, &[FadeStop]); 15] = [ + ( + "Mist", + Linear, + &[ + (0.0, (0.94, 0.012, 240.0), 1.0, 0.38), + (0.35, (0.86, 0.03, 235.0), 0.85, 0.62), + (0.7, (0.8, 0.035, 230.0), 0.4, 0.45), + (1.0, (0.78, 0.035, 230.0), 0.0, 0.5), + ], + ), + ( + "Petal", + Linear, + &[ + (0.0, (0.82, 0.09, 350.0), 1.0, 0.42), + (0.28, (0.88, 0.07, 10.0), 0.9, 0.66), + (0.64, (0.93, 0.04, 30.0), 0.45, 0.35), + (1.0, (0.95, 0.03, 40.0), 0.0, 0.5), + ], + ), + ("Fern", Linear, &[(0.0, (0.52, 0.1, 145.0), 1.0, 0.6), (0.4, (0.68, 0.11, 135.0), 0.75, 0.4), (1.0, (0.85, 0.08, 120.0), 0.0, 0.5)]), + ( + "Smoke", + Linear, + &[ + (0.0, (0.35, 0.01, 260.0), 1.0, 0.3), + (0.22, (0.48, 0.012, 255.0), 0.8, 0.58), + (0.58, (0.62, 0.01, 250.0), 0.35, 0.7), + (1.0, (0.72, 0.008, 250.0), 0.0, 0.5), + ], + ), + ( + "Honey", + Linear, + &[ + (0.0, (0.72, 0.15, 70.0), 1.0, 0.55), + (0.3, (0.8, 0.14, 80.0), 0.9, 0.35), + (0.75, (0.9, 0.09, 90.0), 0.3, 0.6), + (1.0, (0.95, 0.05, 95.0), 0.0, 0.5), + ], + ), + ("Seafoam", Linear, &[(0.0, (0.7, 0.09, 175.0), 1.0, 0.45), (0.45, (0.84, 0.07, 170.0), 0.6, 0.65), (1.0, (0.95, 0.03, 165.0), 0.0, 0.5)]), + ( + "Lavender", + Linear, + &[ + (0.0, (0.62, 0.12, 300.0), 1.0, 0.36), + (0.3, (0.72, 0.1, 295.0), 0.85, 0.55), + (0.55, (0.82, 0.07, 290.0), 0.5, 0.68), + (0.82, (0.9, 0.04, 285.0), 0.15, 0.4), + (1.0, (0.93, 0.03, 285.0), 0.0, 0.5), + ], + ), + ("Driftwood", Linear, &[(0.0, (0.55, 0.04, 60.0), 1.0, 0.64), (0.38, (0.68, 0.035, 70.0), 0.7, 0.42), (1.0, (0.84, 0.025, 80.0), 0.0, 0.5)]), + ( + "Ink Wash", + Linear, + &[ + (0.0, (0.25, 0.05, 265.0), 1.0, 0.28), + (0.18, (0.35, 0.06, 262.0), 0.9, 0.6), + (0.5, (0.5, 0.05, 258.0), 0.45, 0.72), + (1.0, (0.65, 0.04, 255.0), 0.0, 0.5), + ], + ), + ( + "Tide", + Linear, + &[ + (0.0, (0.45, 0.1, 230.0), 1.0, 0.4), + (0.26, (0.6, 0.1, 215.0), 0.8, 0.62), + (0.72, (0.76, 0.08, 200.0), 0.35, 0.44), + (1.0, (0.88, 0.05, 195.0), 0.0, 0.5), + ], + ), + ( + "Bloom", + Radial, + &[ + (0.0, (0.92, 0.05, 40.0), 1.0, 0.4), + (0.22, (0.8, 0.12, 20.0), 0.95, 0.6), + (0.6, (0.7, 0.14, 355.0), 0.4, 0.35), + (1.0, (0.66, 0.13, 345.0), 0.0, 0.5), + ], + ), + ("Dew", Radial, &[(0.0, (0.97, 0.01, 200.0), 1.0, 0.3), (0.3, (0.88, 0.05, 195.0), 0.7, 0.58), (1.0, (0.8, 0.06, 190.0), 0.0, 0.5)]), + ( + "Pollen", + Radial, + &[ + (0.0, (0.96, 0.08, 100.0), 1.0, 0.45), + (0.18, (0.9, 0.15, 95.0), 1.0, 0.65), + (0.55, (0.82, 0.16, 85.0), 0.5, 0.4), + (1.0, (0.76, 0.15, 75.0), 0.0, 0.5), + ], + ), + // A ring: clear at the center, then a band that fades out to the edge. + ( + "Halo", + Radial, + &[ + (0.0, (0.98, 0.005, 90.0), 0.0, 0.6), + (0.42, (0.95, 0.02, 85.0), 0.0, 0.35), + (0.62, (0.92, 0.04, 80.0), 1.0, 0.55), + (1.0, (0.88, 0.05, 75.0), 0.0, 0.5), + ], + ), + ( + "Lichen", + Radial, + &[ + (0.0, (0.8, 0.07, 120.0), 1.0, 0.58), + (0.35, (0.7, 0.08, 130.0), 0.75, 0.4), + (0.74, (0.6, 0.07, 140.0), 0.25, 0.62), + (1.0, (0.55, 0.06, 145.0), 0.0, 0.5), + ], + ), + ]; + let s = fades + .iter() + .map(|&(name, kind, stops)| { + let stops: Vec<(f32, Color, f32, f32)> = stops.iter().map(|&(at, (l, c, h), opacity, mid)| (at, oklch(l, c, h), opacity, mid)).collect(); + gradient_with(name, kind, GradientMethod::Perceptual, &stops) + }) + .collect(); + (s, vec![]) +} + /// The sequential scales of [`perceptual`]: name and anchors of rising lightness. const SEQUENTIAL: [(&str, Anchors); 5] = [ ("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]), @@ -532,6 +676,45 @@ mod tests { let st = &g.gradient.stops; assert!(st.len() >= 2 && st.windows(2).all(|w| w[0].offset <= w[1].offset), "{}", s.name); assert!(st.iter().all(|s| (0.0..=1.0).contains(&s.offset) && (0.0..=1.0).contains(&s.opacity))); + assert!(st.iter().all(|s| (0.01..=0.99).contains(&s.midpoint)), "{}: a midpoint out of range", s.name); + } + } + } + } + + #[test] + fn organic_fades_are_perceptual_multi_stop_fades_to_transparent() { + let lib = builtin_library("organic-fades").unwrap(); + assert_eq!(GRADIENT_LIBRARIES.iter().map(|b| b.name).position(|n| n == "Organic Fades"), Some(3), "after Neutral Gradients"); + assert!(lib.groups.is_empty() && (12..=16).contains(&lib.len()), "{} swatches", lib.len()); + let radial = lib.iter().filter(|s| matches!(&s.paint, Paint::Gradient(g) if g.gradient.kind == GradientKind::Radial)).count(); + assert!(radial >= 3 && radial * 2 <= lib.len(), "{radial} radial of {}", lib.len()); + for s in lib.iter() { + let Paint::Gradient(g) = &s.paint else { panic!("{} is a gradient", s.name) }; + let (g, n) = (&g.gradient, &s.name); + assert_eq!(g.method, GradientMethod::Perceptual, "{n}"); + assert!((3..=5).contains(&g.stops.len()), "{n}: {} stops", g.stops.len()); + assert!(g.stops.windows(2).all(|w| w[0].offset < w[1].offset), "{n}: rising offsets"); + assert_eq!((g.stops[0].offset, g.stops[g.stops.len() - 1].offset), (0.0, 1.0), "{n}"); + // Unevenly spaced: some gap differs from an even step. + let even = 1.0 / (g.stops.len() - 1) as f32; + assert!(g.stops.windows(2).any(|w| (w[1].offset - w[0].offset - even).abs() > 0.02), "{n}: uneven stops"); + // Every segment's midpoint is off center and within the Gradient panel's 13 to 87%. + let segments = &g.stops[..g.stops.len() - 1]; + assert!(segments.iter().all(|st| (st.midpoint - 0.5).abs() > 0.02 && (0.13..=0.87).contains(&st.midpoint)), "{n}: midpoints"); + // Fully opaque somewhere, and clear at the end (the edge of a radial one). + assert!(g.stops.iter().any(|st| st.opacity == 1.0), "{n}: an opaque stop"); + assert_eq!(g.stops[g.stops.len() - 1].opacity, 0.0, "{n}: clear at the end"); + } + } + + #[test] + fn the_other_libraries_keep_classic_mixing_and_centered_midpoints() { + for b in builtins().filter(|b| b.id != "organic-fades") { + for s in builtin_library(b.id).unwrap().iter() { + if let Paint::Gradient(g) = &s.paint { + assert_eq!(g.gradient.method, GradientMethod::Classic, "{}", s.name); + assert!(g.gradient.stops.iter().all(|st| st.midpoint == 0.5), "{}", s.name); } } } diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index 6be0f2abf..800785f6f 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -205,6 +205,23 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { GradientKind::Conic => { format!("conic-gradient(from {}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")) } + // CSS has no diamond gradient: four layers, each a quarter of the box with + // the stops running from the center out to its corner at a diagonal angle, + // make the diamond its fit gives a box of any shape: the level at a point + // is its distance from the center along both axes over the box's + // half-width plus half-height + // (https://www.w3.org/TR/css-images-3/#linear-gradients). Turned by an angle + // that is not a multiple of 90 degrees, its corners leave the box's axes, + // and a circle stands in for it, as for a radial swatch. + GradientKind::Diamond if (1e-9..90.0 - 1e-9).contains(&g.angle.rem_euclid(90.0)) => { + format!("radial-gradient(circle, {})", stops.join(", ")) + } + GradientKind::Diamond => { + let stops = stops.join(", "); + [("top left", 315), ("top right", 45), ("bottom right", 135), ("bottom left", 225)] + .map(|(corner, deg)| format!("linear-gradient({deg}deg, {stops}) {corner} / 50% 50% no-repeat")) + .join(", ") + } _ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")), } } diff --git a/crates/color/src/palette_io/tests.rs b/crates/color/src/palette_io/tests.rs index 9ee07cb62..eaee50dd5 100644 --- a/crates/color/src/palette_io/tests.rs +++ b/crates/color/src/palette_io/tests.rs @@ -186,6 +186,55 @@ fn css_writes_conic_gradient_swatches() { assert!(text.contains("--back: conic-gradient(from 0deg, #000000 0%, #ffffff 80%);"), "{text}"); } +/// A diamond gradient swatch (CSS has none) is four layered linear gradients, one a quarter of the +/// box running from the center out to its corner, for the `background` shorthand. +#[test] +fn css_writes_diamond_gradient_swatches_as_four_corner_layers() { + let lib = SwatchLibrary { + name: "x".into(), + swatches: vec![Swatch { + name: "Gem".into(), + paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Diamond, ..Gradient::default() }))), + global: false, + spot: false, + }], + groups: vec![], + }; + let text = written(&lib, PaletteFormat::Css); + // Diagonal angles, not `to `: on a box that isn't square, `to ` would turn + // each layer's lines to run through the quarter's other two corners. + let layer = |corner: &str, deg: u32| format!("linear-gradient({deg}deg, #ffffff 0%, #000000 100%) {corner} / 50% 50% no-repeat"); + let want = + format!("--gem: {}, {}, {}, {};", layer("top left", 315), layer("top right", 45), layer("bottom right", 135), layer("bottom left", 225)); + assert!(text.contains(&want), "{text}"); + // CSS's gradient line for a diagonal angle on a quarter box w × h is (w + h) / √2 long and + // starts at the quarter's corner at the box's center: the level at a point x, y from the + // center is (|x| + |y|) / (w + h), the level the diamond fitted to the whole box has there. + let (w, h) = (100.0f64, 50.0f64); + let length = (w + h) / std::f64::consts::SQRT_2; + let fit = crate::GradientGeom::fit(GradientKind::Diamond, kurbo::Rect::new(-w, -h, w, h), 0.0); + for (x, y) in [(0.0, -50.0), (-100.0, 0.0), (-30.0, -20.0), (-100.0, -50.0)] { + // The top-left quarter's layer runs toward (-1, -1). + let css = (-x - y) / std::f64::consts::SQRT_2 / length; + let model = fit.param_at(GradientKind::Diamond, kurbo::Point::new(x, y)); + assert!((css - model).abs() < 1e-9, "({x}, {y}): {css} vs {model}"); + } + // Turned a quarter or a half turn, it is the same diamond; turned 30 degrees, a circle stands + // in for it. + let turned = |angle: f64| { + let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Diamond, ..Gradient::default() }); + gp.angle = angle; + let lib = SwatchLibrary { + name: "x".into(), + swatches: vec![Swatch { name: "Gem".into(), paint: Paint::Gradient(Box::new(gp)), global: false, spot: false }], + groups: vec![], + }; + written(&lib, PaletteFormat::Css) + }; + assert!(turned(90.0).contains(&want) && turned(-180.0).contains(&want), "{}", turned(90.0)); + assert!(turned(30.0).contains("--gem: radial-gradient(circle, #ffffff 0%, #000000 100%);"), "{}", turned(30.0)); +} + #[test] fn css_preserves_fractional_gradient_stop_positions() { for kind in [GradientKind::Linear, GradientKind::Radial] { diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index c057aeb23..b1aa9727b 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -444,7 +444,8 @@ impl Appearance { } } /// Map placed gradients through a warp, given its local affine approximation at a point (taken - /// at each gradient's center: the start of a radial or conic, the middle of a linear vector). + /// at each gradient's center: the start of a radial, conic or diamond, the middle of a linear + /// vector). /// Freeform points each follow the warp at their own position. pub fn warp_gradients(&mut self, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) { for g in self.gradients_mut() { diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 088796173..407780177 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -26,11 +26,16 @@ impl WidthProfile { pub fn at(&self, t: f64) -> (f64, f64) { Self::at_points(&self.points, t) } - /// [`Self::at`] of a profile's points (a preset's or a saved profile's). Each side runs through - /// its points on a monotone cubic curve (Fritsch–Carlson): it flows through a width point - /// without a kink, turns flat at a point wider or narrower than both neighbours, and never - /// goes past either point of the span it is in (#5714). Points on one straight line stay on - /// it. A step (two points at one position, [`Self::around`]) ends the curve on each side of it. + /// [`Self::at`] of a profile's points (a preset's or a saved profile's). + /// + /// Each side runs through its points on a cubic Hermite curve, its slope at each one the + /// weighted harmonic mean of the secants either side (Fritsch–Carlson). That is C1 continuous + /// and monotone between points, so it never overshoots them, a point eases into its neighbours + /// instead of making a corner, and moving one reshapes the curve as far as its neighbours' + /// neighbours (their slopes depend on it). The ends of a run use the one-sided PCHIP slope + /// from its first two secants, or the secant when it has only two points, so a two-point + /// profile stays a straight taper and the lens is a parabola. Two points at one `t` are still + /// a step: each run either side ends independently at it. pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { if p.is_empty() { return (1.0, 1.0); @@ -71,9 +76,10 @@ impl WidthProfile { // Two swells between narrow necks. ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, ]; - /// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. Each - /// side stays between the widths of the two points of each span ([`Self::at_points`]), but the - /// two sides needn't peak together, so a span counts its wider left and its wider right. + /// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. + /// Each side's smooth curve stays between the widths of the two points of each span + /// ([`Self::at_points`]), but the two sides needn't peak together, so a span counts its wider + /// left and its wider right. pub fn widest(&self) -> f64 { let p = &self.points; if p.is_empty() { @@ -249,11 +255,27 @@ impl AngleProfile { /// How close two width points' positions are to make a step ([`WidthProfile::around`]). const STEP: f64 = 1e-7; +/// The one-sided three-point PCHIP slope at the start of a run, from the first two secants. +/// A sign change caps the slope, and a slope that turns back on the first secant is flattened. +/// Runs of only two points use their secant instead (see [`tangent`]). +fn end_slope(h0: f64, h1: f64, d0: f64, d1: f64) -> f64 { + let m = ((2.0 * h0 + h1) * d0 - h0 * d1) / (h0 + h1); + if !m.is_finite() || m * d0 <= 0.0 { + return 0.0; + } + if d0 * d1 < 0.0 { + return if d0 > 0.0 { m.min(3.0 * d0) } else { m.max(3.0 * d0) }; + } + m +} + /// The slope of one side (`v` reads it from a point) of width profile `p` at its point `j`, in /// width per unit of position: Fritsch–Carlson's choice for a monotone cubic curve. It is 0 where -/// the point is wider or narrower than both neighbours, the weighted harmonic mean of the slopes -/// of the spans on either side elsewhere (never more than three times either, so no span -/// overshoots its points), and the span's own slope at an end of the profile or beside a step. +/// the point is wider or narrower than both neighbours, and the weighted harmonic mean of the +/// slopes of the spans on either side elsewhere (never more than three times either, so no span +/// overshoots its points). The ends of a run take the one-sided three-point PCHIP slope, or their +/// secant when the run has only two points; a step ends the run on each side, so the points either +/// side of it do not see across it. fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f64 { let Some(q) = p.get(j) else { return 0.0 }; let before = j.checked_sub(1).and_then(|k| p.get(k)).filter(|a| q.0 - a.0 > STEP); @@ -268,8 +290,18 @@ fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f let (h0, h1) = (q.0 - a.0, b.0 - q.0); 3.0 * (h0 + h1) / ((2.0 * h1 + h0) / d0 + (h1 + 2.0 * h0) / d1) } - (Some(a), None) => slope(a, q), - (None, Some(b)) => slope(q, b), + // The end of a run: its previous two points set a one-sided three-point slope, or its + // secant when the run has only two points (the point before `a` is on the other side of a + // step, or does not exist). + (Some(a), None) => { + let Some(prev) = j.checked_sub(2).and_then(|k| p.get(k)).filter(|z| a.0 - z.0 > STEP) else { return slope(a, q) }; + end_slope(q.0 - a.0, a.0 - prev.0, slope(a, q), slope(prev, a)) + } + // The start of a run, symmetrically. + (None, Some(b)) => { + let Some(next) = p.get(j + 2).filter(|z| z.0 - b.0 > STEP) else { return slope(q, b) }; + end_slope(b.0 - q.0, next.0 - b.0, slope(q, b), slope(b, next)) + } (None, None) => 0.0, } } @@ -424,12 +456,12 @@ mod tests { #[test] fn widths_run_smoothly_through_width_points() { // A swell in the middle: flat at its widest, and a quarter of the way along above the - // straight ramp from 1 to 3 (2). + // straight ramp from 1 to 3 (2) because the end eases out of the first point. let swell = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 2.0), (1.0, 1.0, 1.0)] }; let (before, after) = slopes(&swell, 0.5); assert!(before.abs() < 1e-4 && after.abs() < 1e-4, "{before} {after}"); let (l, r) = swell.at(0.25); - assert!((l - 2.25).abs() < 1e-12 && (r - 1.625).abs() < 1e-12, "{l} {r}"); + assert!((l - 2.5).abs() < 1e-12 && (r - 1.75).abs() < 1e-12, "{l} {r}"); // Rising on both sides of a point: the slope is the same either side of it. let rise = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.3, 1.0, 1.0), (1.0, 4.0, 4.0)] }; let (before, after) = slopes(&rise, 0.3); diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index 0a1b3e1a5..aa2ba0100 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -197,12 +197,85 @@ fn strokes_without_pressure_round_trip_unchanged() { fn profile_interp() { let p = WidthProfile::lens(); assert_eq!(p.at(0.5), (1.0, 1.0)); - // Half way up the lens the width curves above the straight ramp (0.5), flattening into its - // widest point (#5714). - assert_eq!(p.at(0.25), (0.625, 0.625)); + // The lens is a parabola between its ends, so a quarter of the way along is 0.75: above the + // 0.5 a straight taper would give. + assert_eq!(p.at(0.25), (0.75, 0.75)); assert_eq!(p.at(2.0), (0.0, 0.0)); } +/// The width runs smoothly between points: it passes through them, has one slope either side +/// of one, and never overshoots the widths its points set. +#[test] +fn profile_is_smooth_between_points() { + let p = [(0.0, 1.0, 2.0), (0.5, 0.2, 2.0), (1.0, 1.0, 2.0)]; + assert!((WidthProfile::at_points(&p, 0.5).0 - 0.2).abs() < 1e-12); + let slope = |t: f64| (WidthProfile::at_points(&p, t + 1e-6).0 - WidthProfile::at_points(&p, t - 1e-6).0) / 2e-6; + // A local minimum at the middle point is flat there, so the two sides agree. + assert!(slope(0.5).abs() < 1e-3, "{}", slope(0.5)); + for k in 0..=100 { + let w = WidthProfile::at_points(&p, k as f64 / 100.0).0; + assert!((0.2..=1.0).contains(&w), "{w} at {k}"); + } + // The side with no points of its own stays flat. + for k in 0..=100 { + assert!((WidthProfile::at_points(&p, k as f64 / 100.0).1 - 2.0).abs() < 1e-12); + } +} + +/// A run with only two points takes its secant as both end slopes, so it stays a straight +/// taper on each side instead of becoming a smoothstep. +#[test] +fn profile_two_point_runs_are_linear() { + let p = [(0.0, 0.0, 1.0), (1.0, 1.0, 0.0)]; + for k in 0..=100 { + let t = k as f64 / 100.0; + let (l, r) = WidthProfile::at_points(&p, t); + assert!((l - t).abs() < 1e-12, "left {l} at {t}"); + assert!((r - (1.0 - t)).abs() < 1e-12, "right {r} at {t}"); + } +} + +/// The lens's one-sided end slopes make it a parabola: at a quarter of the way it is 0.75, +/// above the 0.5 a straight line between its points would give. +#[test] +fn profile_lens_is_convex() { + let p = WidthProfile::lens(); + assert!((p.at(0.25).0 - 0.75).abs() < 1e-12); + assert!((p.at(0.75).0 - 0.75).abs() < 1e-12); + for k in 1..25 { + let t = k as f64 / 100.0; + assert!(p.at(t).0 > 2.0 * t, "{} at {t}", p.at(t).0); + assert!(p.at(1.0 - t).0 > 2.0 * t, "{} at {}", p.at(1.0 - t).0, 1.0 - t); + } +} + +/// A step still steps, and the smooth runs either side meet it flat. +#[test] +fn profile_steps_stay_steps() { + let p = [(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 2.0, 2.0)]; + assert_eq!(WidthProfile::at_points(&p, 0.5), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.5 + 1e-6), (2.0, 2.0)); + assert_eq!(WidthProfile::at_points(&p, 0.5 - 1e-6), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.25), (1.0, 1.0)); + assert_eq!(WidthProfile::at_points(&p, 0.75), (2.0, 2.0)); +} + +/// Moving a point only reshapes the segments beside it (local influence), not the whole path. +#[test] +fn profile_points_only_affect_the_curve_around_them() { + // A monotone run, so no neighbouring slope is held flat by a local extremum. + let a = [(0.0, 0.0, 0.0), (0.25, 0.3, 0.3), (0.5, 0.5, 0.5), (0.75, 0.7, 0.7), (1.0, 1.0, 1.0)]; + let mut b = a; + b[3] = (0.75, 0.9, 0.9); + // Moving p[3] changes the slopes at p[2] and p[3], so p[1] and p[4] also shape the + // curve: nothing before p[1] may move. + for k in 0..=24 { + let t = k as f64 / 100.0; + assert_eq!(WidthProfile::at_points(&a, t), WidthProfile::at_points(&b, t), "at {t}"); + } + assert_ne!(WidthProfile::at_points(&a, 0.3), WidthProfile::at_points(&b, 0.3)); +} + #[test] fn profile_presets_round_trip_their_ids() { for p in WidthProfile::PRESETS { diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d6b7eda2c..05968ced7 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1052,11 +1052,27 @@ fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option> { /// its handles) moved onto the key's centre, so moving a key moves its end of the spine; /// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread /// evenly along it by arc length). +/// +/// `None` for a blend made without a spine ([`BlendSpec::no_spine`]). pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Option>)> { + if spec.no_spine { + return None; + } + layout_spine(keys, spec) +} + +/// The spine `spec` stores, unless the blend was made without one. +fn stored_spine(spec: &BlendSpec) -> Option<&PathData> { + spec.spine.as_ref().filter(|_| !spec.no_spine) +} + +/// [`blend_spine`] for placing the steps: a blend made without a spine still places them on the +/// straight lines between its key centers. +fn layout_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Option>)> { if keys.len() < 2 { return None; } - let Some(stored) = &spec.spine else { + let Some(stored) = stored_spine(spec) else { let centers: Vec = keys.iter().map(|k| center_of(k)).collect(); return Some((PathData::single(SubPath::polyline(¢ers, false)), Some((0..keys.len()).collect()))); }; @@ -1127,10 +1143,10 @@ impl Rail { /// `None` when nothing moves: no stored spine and the steps keep the page's orientation. fn new(keys: &[Arc], spec: &BlendSpec) -> Option { let rotate = spec.orientation == BlendOrientation::AlignToPath; - if spec.spine.is_none() && !rotate { + if stored_spine(spec).is_none() && !rotate { return None; } - let (path, anchors) = blend_spine(keys, spec)?; + let (path, anchors) = layout_spine(keys, spec)?; let spine = Spine::new(&path)?; let total = spine.length(); let k = keys.len(); @@ -1192,10 +1208,10 @@ impl Rail { /// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing /// moves. pub(crate) fn rail_reach(keys: &[Arc], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option { - if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath { + if stored_spine(spec).is_none() && spec.orientation != BlendOrientation::AlignToPath { return None; } - let spine = blend_spine(keys, spec)?.0.bounds()?; + let spine = layout_spine(keys, spec)?.0.bounds()?; let radius = keys .iter() .filter_map(|k| { @@ -1404,7 +1420,9 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { for j in 1..=n { // Even position of the step, then where the easing puts its shape and its colours. let u = j as f64 / (n + 1) as f64; - let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u)); + // Easing needs a spine: a blend made without one keeps its steps evenly spaced. + let eased = rail.is_some() && !spec.no_spine; + let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u)); let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { r.place(&mut s, i, t); diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index ca6b5ed88..4ec7aab1f 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -503,6 +503,19 @@ impl Document { id } + /// [`Document::next_artboard_id`] when `top` is the highest id the artboards have (`None`: there + /// are none). It reads no artboard unless that id is `u32::MAX`, so a caller that adds many + /// artboards and keeps `top` up to date gets each id in constant time. + pub fn next_artboard_id_above(&self, top: Option) -> u32 { + match top.unwrap_or(0).max(self.retired_artboard_id.get()).checked_add(1) { + Some(id) => { + self.retired_artboard_id.raise(id); + id + } + None => self.next_artboard_id(), + } + } + /// Record that the artboard with `id` was deleted: no later artboard takes its id. pub fn retire_artboard_id(&self, id: u32) { self.retired_artboard_id.raise(id); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 94fbb5ff5..6eae9c148 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -795,6 +795,10 @@ thread_local! { static LEAVES_TESTED: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// What a click at `p` hits on leaf object `n`: its outline, strokes and fills, its frame or its +/// bounds, by kind. Raster effects (drop shadows, glows, blurs) do not take clicks: the area they +/// paint beyond the object is left out here and in [`cull_reach`], so a click there reaches the +/// object under it. fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { #[cfg(test)] LEAVES_TESTED.with(|c| c.set(c.get() + 1)); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 304c4e70f..7233f88a5 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -49,7 +49,7 @@ mod expand; mod mesh; mod warp; -pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec}; +pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, BlendSpineMode}; #[cfg(test)] use blend_spec::{blend_color_shift, blend_ramp}; pub use envelope::{ diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs index 75df5eee1..56405e0d1 100644 --- a/crates/doc/src/live/blend_spec.rs +++ b/crates/doc/src/live/blend_spec.rs @@ -107,6 +107,11 @@ pub struct BlendSpec { /// on anchor points); `None`: chosen so closed shapes don't twist. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub starts: Vec>, + /// The blend was made without a spine (Create Spine › Never): it has no spine to show, edit, + /// reverse or release, and no easing. Its steps still sit on the straight lines between the + /// key centers. Replace Spine gives it one. + #[serde(default, rename = "noSpine", skip_serializing_if = "is_false", deserialize_with = "lenient_bool")] + pub no_spine: bool, } impl BlendSpec { @@ -116,6 +121,41 @@ impl BlendSpec { } } +/// The Blend panel's Create Spine setting: whether a new blend gets a spine. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendSpineMode { + /// The usual rule: every new blend gets a spine, the straight lines between its key centers. + #[default] + Auto, + /// Every new blend gets a spine, whatever its key objects are. + Always, + /// New blends get no spine. + Never, +} + +impl BlendSpineMode { + /// Every mode, in the order the Blend panel lists them. + pub const ALL: [Self; 3] = [Self::Auto, Self::Always, Self::Never]; + + pub fn id(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Always => "always", + Self::Never => "never", + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|m| m.id() == id) + } + + /// Whether a new blend made with this mode gets a spine. + pub fn creates_spine(self) -> bool { + self != Self::Never + } +} + /// Blend Options set with no blend selected: what new blends start with. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct BlendDefaults { @@ -123,6 +163,29 @@ pub struct BlendDefaults { pub spacing: BlendSpacing, #[serde(default)] pub orientation: BlendOrientation, + /// Create Spine (`object.blend.createSpine`). + #[serde(default, rename = "createSpine", skip_serializing_if = "is_auto", deserialize_with = "spine_mode")] + pub create_spine: BlendSpineMode, +} + +fn is_false(v: &bool) -> bool { + !*v +} + +fn is_auto(v: &BlendSpineMode) -> bool { + *v == BlendSpineMode::Auto +} + +/// Reading a native file never fails over a `noSpine` that is not a boolean: the blend keeps its +/// spine. +fn lenient_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(serde_json::Value::deserialize(d)?.as_bool().unwrap_or(false)) +} + +/// Reading the preferences never fails over an unknown Create Spine value: it becomes Auto. +fn spine_mode<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = serde_json::Value::deserialize(d)?; + Ok(v.as_str().and_then(BlendSpineMode::from_id).unwrap_or_default()) } /// Reading a native file never fails over one bad blend number: out-of-range values clamp, diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index f6b9a4d01..8279507e0 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -80,10 +80,8 @@ fn head_ends(st: &StrokeLayer, bp: &BezPath) -> Vec<(Point, Vec2, bool)> { /// at `t`): the profile at that fraction of the subpath's length, or wider where an end of the /// segment is a corner. The stroke draws the sides there straight to the corner's join and inner /// meeting point, which can lie outside the exact width all along the segment. -fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, t: f64) -> (f64, f64) { +fn profile_at(profile: &WidthProfile, segs: &[PathSeg], lens: &[f64], total: f64, closed: bool, i: usize, t: f64) -> (f64, f64) { let n = segs.len(); - let lens: Vec = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect(); - let total: f64 = lens.iter().sum::().max(1e-12); let before = lens[..i].iter().sum::(); let mut w = profile.at((before + segs[i].subsegment(0.0..t).arclen(ARCLEN_ACCURACY)) / total); let corner = |a: usize, b: usize| (closed || a < b) && kink(&segs[a], &segs[b]); @@ -306,7 +304,8 @@ impl StrokeLayer { /// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and /// outside strokes of closed paths count on their own side only (and within `tol` of the - /// path); a width profile counts at its width where `p` is nearest. + /// path); a width profile counts at the width of every segment that can reach `p`, as the + /// wider side of a farther one can paint past the nearest. pub fn hits(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool { let reach = self.reach() + tol; if self.width.is_nan() || self.width <= 0.0 || !bp.bounding_box().inflate(reach, reach).contains(p) { @@ -330,17 +329,27 @@ impl StrokeLayer { let segs = drawn(&els[r]); let (half, region) = self.body(closed); let full = half * self.profile_max() * dash_caps.unwrap_or(1.0); - // The nearest point on this subpath. - let near = segs.iter().enumerate().map(|(i, s)| (i, s.nearest(p, 1e-9))).min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq)); - if let Some((i, nr)) = near - && nr.distance_sq.sqrt() <= full + tol - { + // Every segment near enough to reach `p`: with a width profile the wider side of a + // farther segment can paint past the nearest one, so the nearest alone is not enough. + let near = segs.iter().enumerate().filter_map(|(i, s)| { + let nr = s.nearest(p, 1e-9); + (nr.distance_sq.sqrt() <= full + tol).then_some((i, nr)) + }); + // The lengths are only needed when a profiled candidate reaches `p`; compute them + // once for the whole subpath rather than once per candidate. + let mut lengths: Option<(Vec, f64)> = None; + for (i, nr) in near { let d = nr.distance_sq.sqrt(); let factor = if dash_caps.is_some() { full / half.max(1e-12) } else { self.profile.as_ref().map_or(1.0, |pr| { - let (l, rt) = profile_at(pr, &segs, closed, i, nr.t); + let (lens, total) = lengths.get_or_insert_with(|| { + let lens: Vec = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect(); + let total = lens.iter().sum::().max(1e-12); + (lens, total) + }); + let (l, rt) = profile_at(pr, &segs, lens, *total, closed, i, nr.t); // Nearest to a segment's end, `p` is beside a join or past a cap, which // spans the widths of both sides. let at_end = nr.t <= 1e-9 || nr.t >= 1.0 - 1e-9; diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 781bec84e..bc558d270 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -314,6 +314,13 @@ pub struct DocSetup { /// #[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")] pub system_layout: bool, + /// Align New Objects to Pixel Grid (New Document › Advanced Options, Transform panel menu): + /// paths and compound paths drawn from then on are pixel-aligned. Objects already in the + /// document, and objects pasted, duplicated or placed, keep their own setting. Off by default + /// and for documents from before it, and saved only when on. + /// + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub align_new_objects_to_pixel_grid: bool, } impl Default for DocSetup { @@ -337,6 +344,7 @@ impl Default for DocSetup { export_text: ExportText::Editable, background: Background::Transparent, system_layout: false, + align_new_objects_to_pixel_grid: false, } } } diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index f64bdb430..97d46b7cd 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -155,6 +155,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ BuiltinStyleLibrary { id: "shadows-glows", name: "Shadows and Glows", make: shadows_glows }, BuiltinStyleLibrary { id: "outlines-rules", name: "Outlines and Rules", make: outlines_rules }, BuiltinStyleLibrary { id: "hand-drawn", name: "Hand-Drawn", make: hand_drawn }, + BuiltinStyleLibrary { id: "textures", name: "Textures", make: textures }, BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes }, BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects }, BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, @@ -322,6 +323,44 @@ fn hand_drawn() -> Vec { ] } +/// Textures: fills and strokes painted with the catalog's texture effects (grain, canvas and +/// burlap surfaces, craquelure, tiles, patchwork, stained glass and water paper). +fn textures() -> Vec { + let surface = |texture: &str, scaling: f64, relief: f64, light: &str| { + fx("texture.texturizer", json!({"texture": texture, "scaling": scaling, "relief": relief, "lightDirection": light, "invert": false})) + }; + vec![ + style( + "Fine Grain", + vec![F(fill("#c9ada7")), S(stroke("#4a4e69", 1.0))], + vec![fx("texture.grain", json!({"intensity": 35.0, "contrast": 55.0, "grainType": "regular"}))], + ), + style("Canvas Weave", vec![F(fill("#e9d8a6"))], vec![surface("canvas", 100.0, 6.0, "topLeft")]), + style("Burlap Sack", vec![F(fill("#bc8a5f")), S(stroke("#6f4518", 1.5))], vec![surface("burlap", 120.0, 8.0, "top")]), + style( + "Cracked Glaze", + vec![F(fill("#94d2bd")), S(stroke("#005f73", 1.0))], + vec![fx("texture.craquelure", json!({"crackSpacing": 12.0, "crackDepth": 5.0, "crackBrightness": 8.0}))], + ), + style( + "Mosaic Floor", + vec![F(fill("#0a9396"))], + vec![fx("texture.mosaicTiles", json!({"tileSize": 10.0, "groutWidth": 3.0, "lightenGrout": 8.0}))], + ), + style("Quilted Squares", vec![F(fill("#ee9b00"))], vec![fx("texture.patchwork", json!({"squareSize": 5.0, "relief": 10.0}))]), + style( + "Leaded Glass", + vec![F(FillLayer::new(linear(-45.0, &[(0.0, "#ae2012", 1.0), (0.5, "#ee9b00", 1.0), (1.0, "#005f73", 1.0)])))], + vec![fx("texture.stainedGlass", json!({"cellSize": 8.0, "borderThickness": 4.0, "lightIntensity": 3.0}))], + ), + style( + "Damp Paper", + vec![F(fill("#4895ef")), S(stroke("#3f37c9", 1.0))], + vec![fx("sketch.waterPaper", json!({"fiberLength": 12.0, "brightness": 55.0, "contrast": 60.0}))], + ), + ] +} + fn gradient_finishes() -> Vec { let rim = |h: &str| S(stroke(h, 1.0)); vec![ @@ -550,6 +589,18 @@ mod tests { assert_eq!(status.name, "Status Effects"); let status_names: Vec<&str> = status.styles.iter().map(|g| g.name.as_str()).collect(); assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); + let textures = builtin_style_library("textures").unwrap(); + assert_eq!(textures.name, "Textures"); + let texture_names: Vec<&str> = textures.styles.iter().map(|g| g.name.as_str()).collect(); + let expected = + ["Fine Grain", "Canvas Weave", "Burlap Sack", "Cracked Glaze", "Mosaic Floor", "Quilted Squares", "Leaded Glass", "Damp Paper"]; + assert_eq!(texture_names, expected); + for b in STYLE_LIBRARIES.iter().filter(|b| b.id != "textures") { + let lib = builtin_style_library(b.id).unwrap(); + assert!(lib.styles.iter().all(|g| !expected.contains(&g.name.as_str())), "{} shares a name with Textures", b.id); + } + let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect(); + assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn"); } #[test] diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index c96dcb477..acb980204 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -173,6 +173,18 @@ fn bar(hw: f64) -> f64 { /// Where the notch of the (open) arrow sits, as a fraction of the length. const NOTCH: f64 = 0.7; +/// How far [`super::stroke_pieces`] trims the line under each end's head: the head's inset from +/// the tip, along the path. Both are 0 without a head at that end, and in [`ArrowAlign::Extend`], +/// where the stroke keeps its full length. +pub(super) fn trims(st: &StrokeLayer) -> (f64, f64) { + if st.arrow_align != ArrowAlign::Tip { + return (0.0, 0.0); + } + let cap = cap_extent(st.cap, st.width); + let inset_of = |kind: Option, end: bool| kind.map_or(0.0, |k| inset(k, st.arrow_weight(end), cap)); + (inset_of(st.start_arrow, false), inset_of(st.end_arrow, true)) +} + /// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end. fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { let l = len(hw); diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 1cb79e9c7..4bddf80b0 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -18,11 +18,11 @@ use std::collections::VecDeque; use kurbo::{BezPath, Point, Shape, Vec2}; use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint}; use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP}; -use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; +use vectorcraft_doc::{GradientMesh, LineCap, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; use vectorcraft_geom::FillRule; use super::width::{flatten, left, length, split_at}; -use super::{aligned_width, cap_extent, is_closed, segments, subpaths, unit}; +use super::{aligned_width, arrow, cap_extent, is_closed, segments, subpaths, unit}; /// Largest stretch of a rib on a turn's bisector (1 / cos of half the turn, about 74°) before the /// corner gets a fan of ribs round its outside. @@ -97,7 +97,10 @@ impl Lay<'_> { /// How far a rib at fraction `t` reaches either side of the path to cover the stroke there. fn cover(&self, t: f64) -> f64 { - self.half(t) + self.margin + // A square cap's corners stand √2 of the half-width out from where the dash ends, and a + // dash can end just past a corner: reach that far, or they paint outside every slice. + let diag = if self.st.dash.is_some() && self.st.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 }; + self.half(t) * diag + self.margin } /// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`: @@ -162,6 +165,9 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, return vec![]; } let heads = (st.head_reach(false), st.head_reach(true)); + // With a tip-aligned arrowhead the stroke's line, and with it the width profile, stops at the + // head's inset from the tip (`stroke_pieces`). + let trims = arrow::trims(st); let mut lay = Lay { st, hw, margin, ext: (0.0, 0.0) }; // Caps reach as far past the ends as the stroke is wide there. let cap = |t: f64| cap_extent(st.cap, 2.0 * lay.half(t)); @@ -170,27 +176,34 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, let count = subs.len(); let mut out = vec![]; for (k, (range, closed)) in subs.into_iter().enumerate() { - let (pts, corner) = flatten(&segments(&bp.elements()[range]), closed, tol.max(1e-4)); - let side = - (st.align != StrokeAlign::Center && closed_path && closed).then(|| band_on_left(bp, rule, &pts, st.align, (4.0 * tol).min(hw / 2.0))); + let segs = segments(&bp.elements()[range]); + let flat = flatten(&segs, closed, tol.max(1e-4)); + let side = (st.align != StrokeAlign::Center && closed_path && closed) + .then(|| band_on_left(bp, rule, &flat.pts, st.align, (4.0 * tol).min(hw / 2.0))); // Arrowheads sit on the start of the first subpath and the end of the last one. let heads = (if k == 0 { heads.0 } else { 0.0 }, if k + 1 == count { heads.1 } else { 0.0 }); - let ribs = match pts.len() { + let trims = (if k == 0 { trims.0 } else { 0.0 }, if k + 1 == count { trims.1 } else { 0.0 }); + let ribs = match flat.pts.len() { 0 => continue, // A subpath of no length: a dot (round and projecting caps paint it). 1 => { let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1)); let r = lay.cover(0.0); - vec![Rib::square(pts[0] - d * e, d, r, 0.0, b), Rib::square(pts[0] + d * e, d, r, 0.0, b)] + vec![Rib::square(flat.pts[0] - d * e, d, r, 0.0, b), Rib::square(flat.pts[0] + d * e, d, r, 0.0, b)] } _ => { - let total = length(&pts, closed); + let total = length(&flat.pts, closed); if total <= 1e-12 { continue; } let steps = if max_dt < 1.0 { (1.0 / max_dt).ceil() as usize } else { 1 }; let profile = st.profile.iter().flat_map(|p| p.points.iter().map(|q| q.0)); let heads = if closed { (0.0, 0.0) } else { heads }; + // A tip-aligned arrowhead shortens the line it sits on, and the stroke's outline + // reads the width profile along the shortened line (`stroke_pieces`); the ribs + // must read it there too, or on a profile that changes fast they cover a narrower + // band than the stroke paints. + let span = if closed { (0.0, total) } else { (trims.0.min(total), (total - trims.1).max(trims.0)) }; // Where an arrowhead's reach ends gets a rib of its own, so the ribs that reach // across a head run all the way there instead of narrowing towards the next corner // from the head's tip, which leaves the corners of its base outside (#5964). @@ -200,11 +213,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, .copied() .chain(profile) .chain((1..steps).map(|i| i as f64 / steps as f64)) - .map(|t| t * total) + .map(|t| span.0 + (span.1 - span.0) * t) .chain(head_ends) .collect(); - let (pts, _) = split_at(&pts, &corner, closed, &at); - strip(&lay, &pts, closed, total, side, heads) + let (pts, _, _) = split_at(&flat, closed, &at, &segs); + strip(&lay, &pts, closed, total, side, heads, span) } }; out.push(ribs); @@ -226,8 +239,9 @@ fn band_on_left(bp: &BezPath, rule: FillRule, pts: &[Point], align: StrokeAlign, } /// The ribs along one flattened subpath `pts` of length `total` (`heads`: how far arrowheads -/// reach from its start and end). -fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, heads: (f64, f64)) -> Vec { +/// reach from its start and end). `span` is the stretch of the subpath the width profile is laid +/// along: the whole of it, or what is left between tip-aligned arrowheads. +fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, heads: (f64, f64), span: (f64, f64)) -> Vec { let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); @@ -249,7 +263,9 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, // stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part // reaching that far would paint over a wide part beside it (#5960). The start of a closed // subpath is also its end. Ribs near an arrowhead reach across it. - let cover: Vec = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect(); + // The fraction of the profile at how far `s` is along the subpath: the profile spans `span`. + let t_at = |s: f64| if span.1 > span.0 { ((s - span.0) / (span.1 - span.0)).clamp(0.0, 1.0) } else { 0.0 }; + let cover: Vec = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(t_at(along[i])) }).collect(); let window = match lay.st.dash { Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()), _ => 0.0, @@ -279,7 +295,7 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option, let (first, last) = if closed { (0, n) } else { (1, n - 1) }; for i in first..last { let v = i % n; - let t = along[v] / total; + let t = t_at(along[v]); let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(v), t, lay.band(t, side)); // A closed subpath starts after the fan of its first corner and ends with it. let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 }; diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index 46969ff63..fc3f2eee4 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -232,16 +232,14 @@ fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3); assert!(near(plain.area().abs(), 1000.0, 1e-6)); let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3); - // The lens swells on a cubic, flat at the middle: 7/12 of the full-width band (#5714). - assert!(near(lens.area().abs(), 1000.0 * 7.0 / 12.0, 0.05)); - // Each dash takes its widths from where it sits along the lens (0 → 1 → 0). Half of the lens - // rises as w(u) = u + u² − u³ (u from 0 to 1 across that half): the dash over 0..25 covers - // its first half, ∫₀^½ w = 29/192, the one over 50..75 the wider other half, 83/192. + // The lens is a parabola between its ends: 2/3 of the full-width band. + assert!(near(lens.area().abs(), 2000.0 / 3.0, 0.1)); + // Each dash takes its widths from where it sits along the lens (0 → 1 → 0). let st = stroke(10.0, |s| { s.profile = Some(WidthProfile::lens()); s.dash = Some(dashed(&[25.0, 25.0], 0.0)); }); - assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0 * (29.0 + 83.0) / 192.0, 0.05)); + assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 1000.0 / 3.0, 0.1)); assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty()); } diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index b427fdbce..b389cab85 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -155,9 +155,10 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() { s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false }); }); let o = line_outline(&line(100.0), &st, 10.0, 1e-3); - // The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip. + // The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip, + // where the smooth profile is thinner than a straight ramp (a parabola's first tenth). assert!(o.contains(Point::new(45.0, 4.0)) && o.contains(Point::new(45.0, -4.0)), "middle dash is wide"); - assert!(!o.contains(Point::new(5.0, 2.0)) && o.contains(Point::new(5.0, 0.3)), "first dash is thin"); + assert!(o.contains(Point::new(5.0, 0.8)) && !o.contains(Point::new(5.0, 1.2)), "first dash is thin"); assert!(!o.contains(Point::new(15.0, 0.0)), "gaps stay empty"); // Dots too: a round dot near the middle is bigger than one near an end. let st = stroke(10.0, |s| { @@ -166,7 +167,7 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() { s.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false }); }); let o = line_outline(&line(100.0), &st, 10.0, 1e-3); - assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.5))); + assert!(o.contains(Point::new(50.0, 4.5)) && o.contains(Point::new(10.0, 1.4)) && !o.contains(Point::new(10.0, 2.0))); } /// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0). diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index a0056d6b3..ea966ea07 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -138,9 +138,8 @@ fn across_runs_from_the_left_edge_to_the_right() { let mut lens = st.clone(); lens.profile = Some(WidthProfile::lens()); let sl = slices(&line, &lens); - // A quarter of the way along, the lens is 0.625 as wide (its curve, #5714): its top edge is - // 3.125 up. - assert!(param(&sl, Point::new(25.0, -3.0)).unwrap() < 0.05); + // A quarter of the way along, the lens is three quarters as wide: its top edge is 3.75 up. + assert!(param(&sl, Point::new(25.0, -3.6)).unwrap() < 0.05); assert!((param(&sl, Point::new(50.0, -2.5)).unwrap() - 0.25).abs() < 0.02); } diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 4a9dc2678..0b307cc6b 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -3,17 +3,18 @@ //! A stroke with a [`WidthProfile`] is drawn as a filled outline: each subpath is flattened, and //! at every sample the left and right offsets `width/2 · factor(t)` (t = fraction of the //! subpath's length) are placed along the normal. Corners of the path take the stroke's miter, -//! round or bevel join on their outer side (smooth points bend round), open subpaths get butt, -//! round or projecting caps (the round cap blends the two side widths), and closed subpaths become -//! two loops of opposite orientation filled with the non-zero rule. Dashes are pieces of a longer -//! subpath and read the profile where they sit along it ([`outline_spans`]). +//! round or bevel join on their outer side (smooth points offset along the curve's normal), open +//! subpaths get butt, round or projecting caps (the round cap blends the two side widths), and +//! closed subpaths become two loops of opposite orientation filled with the non-zero rule. Dashes +//! are pieces of a longer subpath and read the profile where they sit along it ([`outline_spans`]). use std::f64::consts::PI; -use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Rect, Vec2}; +use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Rect, Vec2}; use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile}; +use vectorcraft_pathops::fit_cubics_through; -use super::{kink, segments, subpaths, unit}; +use super::{kink, segments, subpaths, tangent, unit}; /// Filled outline of `bp` stroked with `width` scaled by `profile`, with the caps, joins and miter /// limit of `st`, flattened to `tol`. @@ -24,15 +25,16 @@ pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, st: &Stro /// Outline only flattened segments near `near`, retaining each run's original fractions of its /// whole subpath for the width profile. `None` means no segments could be omitted. pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64, near: Rect) -> Option { - let ctx = Ctx { half: width / 2.0, profile, cap: st.cap, join: st.join, miter_limit: st.miter_limit, tol: tol.max(1e-4) }; + let ctx = Ctx::new(width, profile, st, tol); let mut out = BezPath::new(); let mut cut = false; for (r, closed) in subpaths(bp) { if closed { return None; } - let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol); - let n = pts.len(); + let segs = segments(&bp.elements()[r]); + let flat = flatten(&segs, closed, ctx.tol); + let n = flat.pts.len(); if n < 2 { continue; } @@ -41,7 +43,7 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil let mut kept = Vec::with_capacity(count); let mut total = 0.0; for i in 0..count { - let (a, b) = (pts[i], pts[i + 1]); + let (a, b) = (flat.pts[i], flat.pts[i + 1]); let len = a.distance(b); if !len.is_finite() { return None; @@ -51,7 +53,7 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil kept.push(!(a.x.max(b.x) < near.x0 || a.x.min(b.x) > near.x1 || a.y.max(b.y) < near.y0 || a.y.min(b.y) > near.y1)); } if kept.iter().all(|v| *v) { - ctx.subpath(&mut out, &pts, &corner, closed, (0.0, 1.0)); + ctx.subpath(&mut out, &flat, &segs, closed, (0.0, 1.0), count as f64); continue; } cut = true; @@ -59,27 +61,31 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil continue; } let mut walked = 0.0; - let mut run = Vec::new(); - let mut run_corner = Vec::new(); + let mut run: Option = None; let mut from = 0.0; for i in 0..count { if kept[i] { - if run.is_empty() { + if run.is_none() { from = walked / total; - run.push(pts[i]); - run_corner.push(corner[i]); + run = Some(Flat { pts: vec![flat.pts[i]], corner: vec![flat.corner[i]], seg_of: vec![], param: vec![] }); } - run.push(pts[i + 1]); - run_corner.push(corner[i + 1]); - } else if run.len() > 1 { - ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total)); - run.clear(); - run_corner.clear(); + if let Some(part) = run.as_mut() { + part.pts.push(flat.pts[i + 1]); + part.corner.push(flat.corner[i + 1]); + part.seg_of.push(flat.seg_of[i]); + part.param.push(flat.param[i]); + } + } else if let Some(part) = run.take() + && part.pts.len() > 1 + { + ctx.subpath(&mut out, &part, &segs, false, (from, walked / total), count as f64); } walked += lengths[i]; } - if run.len() > 1 { - ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total)); + if let Some(part) = run + && part.pts.len() > 1 + { + ctx.subpath(&mut out, &part, &segs, false, (from, walked / total), count as f64); } } cut.then_some(out) @@ -89,12 +95,15 @@ pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfil /// `spans[i]` of the subpath it was cut from (see [`Dashed::spans`](super::Dashed::spans)); /// subpaths without a span are whole. pub(super) fn outline_spans(bp: &BezPath, spans: &[(f64, f64)], width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64) -> BezPath { - let ctx = Ctx { half: width / 2.0, profile, cap: st.cap, join: st.join, miter_limit: st.miter_limit, tol: tol.max(1e-4) }; + let ctx = Ctx::new(width, profile, st, tol); let mut out = BezPath::new(); for (i, (r, closed)) in subpaths(bp).into_iter().enumerate() { - let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol); - if pts.len() > 1 { - ctx.subpath(&mut out, &pts, &corner, closed, spans.get(i).copied().unwrap_or((0.0, 1.0))); + let segs = segments(&bp.elements()[r]); + let flat = flatten(&segs, closed, ctx.tol); + if flat.pts.len() > 1 { + let span = spans.get(i).copied().unwrap_or((0.0, 1.0)); + let pieces = if closed { flat.pts.len() } else { flat.pts.len() - 1 }.max(1); + ctx.subpath(&mut out, &flat, &segs, closed, span, pieces as f64 / (span.1 - span.0)); } } out @@ -105,19 +114,34 @@ pub(super) fn left(t: Vec2) -> Vec2 { Vec2::new(t.y, -t.x) } +/// A flattened subpath: its points, which of them are corners, and the source segment and +/// parameter range that drew each flattened piece (so a sample added between two points can read +/// that piece's tangent instead of the chord's direction). +pub(super) struct Flat { + pub(super) pts: Vec, + pub(super) corner: Vec, + pub(super) seg_of: Vec, + /// The source segment's parameter range for each flattened piece. Restricting the tangent + /// search to this range keeps a sample on the branch that drew it when a cubic loops back + /// near itself. + pub(super) param: Vec<(f64, f64)>, +} + /// The flattened points of one subpath (consecutive duplicates removed; a closed one doesn't /// repeat its start), each marked when it is a corner of the path. -pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, Vec) { - let Some(s0) = segs.first() else { return (vec![], vec![]) }; - let (mut pts, mut corner) = (vec![s0.start()], vec![false]); - // The first and the last segment that drew something. - let (mut first, mut prev): (Option<&PathSeg>, Option<&PathSeg>) = (None, None); - for seg in segs { - let at = pts.len() - 1; +pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> Flat { + let Some(s0) = segs.first() else { return Flat { pts: vec![], corner: vec![], seg_of: vec![], param: vec![] } }; + let mut flat = Flat { pts: vec![s0.start()], corner: vec![false], seg_of: vec![], param: vec![] }; + // The last segment that drew something, and the last parameter it reached. + let mut prev: Option<&PathSeg> = None; + let mut last_added: Option<(usize, f64)> = None; + for (si, seg) in segs.iter().enumerate() { + let at = flat.pts.len() - 1; + let before = flat.pts.len(); let mut push = |p: Point| { - if pts.last().is_none_or(|l| l.distance(p) > 1e-9) { - pts.push(p); - corner.push(false); + if flat.pts.last().is_none_or(|l| l.distance(p) > 1e-9) { + flat.pts.push(p); + flat.corner.push(false); } }; match seg { @@ -130,77 +154,151 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, } }), } - if pts.len() > at + 1 { - match prev { - Some(p) => corner[at] = kink(p, seg), - None => first = Some(seg), + if flat.pts.len() > before { + let end = flat.pts.len() - 1; + flat.seg_of.resize(end, si); + for slot in flat.seg_of.iter_mut().take(end).skip(at) { + *slot = si; + } + // Find each new point's source parameter, never going backwards. A point is on the + // curve, so its nearest parameter is exact except at a self-intersection, where the + // monotone search picks the branch that continues this flattened piece. + let mut ts = Vec::with_capacity(end - at + 1); + ts.push(0.0); + let mut t = 0.0; + for p in &flat.pts[at + 1..=end] { + if t >= 1.0 - 1e-12 { + t = 1.0; + } else { + let sub = seg.subsegment(t..1.0); + let nr = sub.nearest(*p, 1e-9); + t = (t + (1.0 - t) * nr.t).clamp(0.0, 1.0); + } + ts.push(t); + } + flat.param.extend(ts.windows(2).map(|w| (w[0], w[1]))); + last_added = ts.last().map(|t| (si, *t)); + if let Some(p) = prev { + flat.corner[at] = kink(p, seg); } prev = Some(seg); } } if closed { - if pts.len() > 1 && pts[0].distance(pts[pts.len() - 1]) <= 1e-9 { - pts.pop(); - corner.pop(); + if flat.pts.len() > 1 && flat.pts[0].distance(flat.pts[flat.pts.len() - 1]) <= 1e-9 { + flat.pts.pop(); + flat.corner.pop(); } - if let (Some(a), Some(b)) = (prev, first) { - corner[0] = kink(a, b); + if let (Some(a), Some(b)) = (segs.last(), segs.first()) { + flat.corner[0] = kink(a, b); + } + // The closing piece comes from the last source segment even when its endpoint is the + // already-present start point and did not add a flattened point. Keep a tangent (and its + // whole parameter range) for it. + flat.seg_of.truncate(flat.pts.len()); + flat.param.truncate(flat.pts.len()); + if flat.seg_of.len() < flat.pts.len() { + let last = segs.len().saturating_sub(1); + // A closing curve with interior flattened points has already reached a parameter + // before its end (the duplicated start point was not added); the missing piece starts + // where it left off, not at 0. + let start = match last_added { + Some((si, t)) if si == last => t, + _ => 0.0, + }; + flat.seg_of.resize(flat.pts.len(), last); + flat.param.resize(flat.pts.len(), (start, 1.0)); } } - (pts, corner) + flat } -/// The most samples added between two width points. -const MAX_SPAN_SAMPLES: usize = 64; -/// The most samples [`densify`] adds between width points along one piece of a subpath. -const MAX_CURVE_SAMPLES: usize = 4096; - -/// Insert samples where the profile has width points (the two points of a discontinuous point -/// share one sample), and between them where the profile's curve ([`WidthProfile::at`]) bends, so -/// that a straight run follows it to within `tol` with a stroke of half width `half`. `span` maps -/// the subpath onto the profile (see [`outline_spans`]). -fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64), half: f64, tol: f64) -> (Vec, Vec) { - let total = length(pts, closed); +/// Insert samples where the profile has width points and between them, so its smooth curve is +/// drawn as a smooth outline along straight runs too (the two points of a discontinuous point +/// share one sample). `span` maps the subpath onto the profile (see [`outline_spans`]). +fn densify( + flat: &Flat, + segs: &[PathSeg], + closed: bool, + profile: &WidthProfile, + span: (f64, f64), + density: f64, +) -> (Vec, Vec, Vec) { + // Two samples per profile segment: the segment end plus its midpoint. The outline is fitted + // with cubics through these points, so a smooth width curve needs far fewer samples than it + // did when every pair of samples became a separate cubic. A profile with a strong bend gets + // one more subdivision; a flat profile draws the same straight pieces as before. + const SAMPLES: usize = 2; + const MAX_SAMPLES: usize = 8; + // The gap (in width factor) the straight line between two samples may leave: small enough + // that a sharp peak like the lens' needs one more piece, large enough that a long taper's + // gentle bend does not. + const BEND: f64 = 0.05; + let total = length(&flat.pts, closed); let range = span.1 - span.0; if total <= 1e-12 || range <= 1e-12 { - return (pts.to_vec(), corner.to_vec()); + return split_at(flat, closed, &[], segs); } - let along = |t: f64| (t - span.0) / range * total; // Where the profile's points fall along this piece (a span past 1 wraps round a closed path). - let mut marks: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(along).collect(); - let mut added = 0; - for shift in [0.0, 1.0] { - for w in profile.points.windows(2) { - let (a, b) = (&w[0], &w[1]); - let (t0, t1) = (a.0 + shift, b.0 + shift); - if t1 - t0 <= 1e-7 || t1 <= span.0 || t0 >= span.1 { - continue; - } - // A span whose curve is the straight line between its points (a two-point taper, - // points on one line) needs nothing between them. A cubic off the line is off it at a - // quarter or three quarters of the way. - let off_line = |u: f64| { - let (l, r) = profile.at(a.0 + (b.0 - a.0) * u); - half * (l - (a.1 + (b.1 - a.1) * u)).abs().max((r - (a.2 + (b.2 - a.2) * u)).abs()) - }; - if off_line(0.25) <= tol * 1e-3 && off_line(0.75) <= tol * 1e-3 { - continue; - } - // A monotone cubic span bends by at most 12 times its change in width over the span - // squared, so the chords of `n` equal parts stray at most 1.5 · change / n² from it. - let change = half * (b.1 - a.1).abs().max((b.2 - a.2).abs()); - let n = (1.5 * change / tol).sqrt().ceil(); - let n = if n.is_finite() { n.clamp(1.0, MAX_SPAN_SAMPLES as f64) as usize } else { MAX_SPAN_SAMPLES }; - for k in 1..n { - if added >= MAX_CURVE_SAMPLES { - break; - } - marks.push(along(t0 + (t1 - t0) * k as f64 / n as f64)); - added += 1; - } + let mut ts: Vec = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).collect(); + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); + // A flat profile draws the same straight pieces as before: sample only its points. + let constant = profile.points.first().is_some_and(|f| profile.points.iter().all(|p| (p.1 - f.1).abs() <= 1e-12 && (p.2 - f.2).abs() <= 1e-12)); + let cap = (4096 / ts.len().max(1)).max(1); + // A curved path's flattened points already sample the width along the subpath. On a dense + // piece the profile's own points are enough; a straight run has only its ends, so it needs + // the extra samples. This keeps curved, profiled art close to a uniform stroke's size. + let mut marks = Vec::with_capacity(ts.len() * SAMPLES); + for w in ts.windows(2) { + let (a, b) = (w[0], w[1]); + let samples = if constant { + 1 + } else { + // `ts` has the profile both at t and t + 1 for a dash crossing a closed seam; the + // shifted copy has the same shape, so measure its bend on the unshifted segment. + let start = a.rem_euclid(1.0); + let end = start + (b - a); + let want = adaptive_samples(profile, start, end, SAMPLES, MAX_SAMPLES, BEND).min(cap); + // How many of the flattened path's points already fall in this profile segment. + // `density` is measured on the whole subpath, not on this piece, so outlining only + // the piece near the view picks the same samples as outlining the whole subpath. + let existing = (b - a) * density; + if existing >= want as f64 { 1 } else { want } + }; + for k in 0..samples { + marks.push(a + (b - a) * k as f64 / samples as f64); } } - split_at(pts, corner, closed, &marks) + if let Some(&last) = ts.last() { + marks.push(last); + } + let marks: Vec = marks.into_iter().map(|t| (t - span.0) / range * total).collect(); + split_at(flat, closed, &marks, segs) +} + +/// How many pieces one profile segment needs so a cubic fit through their ends keeps the width +/// curve. A straight taper is already straight; a visible bend gets progressively more samples. +fn adaptive_samples(profile: &WidthProfile, t0: f64, t1: f64, base: usize, max: usize, bend: f64) -> usize { + let mut samples = base.max(1); + while samples < max { + // One probe in the middle of each piece: how far the curve leaves the straight line + // between the piece's ends. The more pieces, the closer the line follows the curve, so + // the loop only stops when the pieces it has are fine enough (or at `max`). + let mut dev = 0.0_f64; + for k in 0..samples { + let (a, b) = (k as f64 / samples as f64, (k + 1) as f64 / samples as f64); + let (la, ra) = profile.at(t0 + (t1 - t0) * a); + let (lb, rb) = profile.at(t0 + (t1 - t0) * b); + let (lm, rm) = profile.at(t0 + (t1 - t0) * (a + b) / 2.0); + dev = dev.max((lm - (la + lb) / 2.0).abs()).max((rm - (ra + rb) / 2.0).abs()); + } + if dev <= bend { + break; + } + samples *= 2; + } + samples } /// Length of a flattened subpath (see [`flatten`]). @@ -212,31 +310,70 @@ pub(super) fn length(pts: &[Point], closed: bool) -> f64 { /// A flattened subpath (see [`flatten`]) with points added `marks` along it (distances from its /// start; those on a point or off the subpath are ignored). -pub(super) fn split_at(pts: &[Point], corner: &[bool], closed: bool, marks: &[f64]) -> (Vec, Vec) { +pub(super) fn split_at(flat: &Flat, closed: bool, marks: &[f64], segs: &[PathSeg]) -> (Vec, Vec, Vec) { + let Flat { pts, corner, seg_of, param } = flat; let n = pts.len(); let seg_count = if closed { n } else { n.saturating_sub(1) }; - let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len())); + // The source's direction at a source parameter. The chord direction is the secant, not the + // tangent: using it for points added inside a chord jogs the normal at every flatten vertex. + let source_at = |si: usize, t: f64| -> Vec2 { segs.get(si).map_or(Vec2::new(1.0, 0.0), |seg| tangent(seg, t)) }; + // A point added inside a flattened piece searches only that piece's source parameter range: + // a looping cubic can pass near itself, and a full-segment nearest would take the other + // branch's tangent. + let source_near = |p: Point, i: usize| -> Vec2 { + let Some(si) = seg_of.get(i).copied() else { return Vec2::new(1.0, 0.0) }; + let Some(seg) = segs.get(si) else { return Vec2::new(1.0, 0.0) }; + let (t0, t1) = param.get(i).copied().unwrap_or((0.0, 1.0)); + if t1 - t0 > 1e-12 { + let sub = seg.subsegment(t0..t1); + tangent(seg, t0 + (t1 - t0) * sub.nearest(p, 1e-9).t) + } else { + tangent(seg, t0) + } + }; + // At a shared point, average the arriving and leaving directions unless it is a corner. + let dir_at = |i: usize| -> Vec2 { + let incoming = if i == 0 { + if closed { seg_of.last().copied().zip(param.last().copied()) } else { None } + } else { + seg_of.get(i - 1).copied().zip(param.get(i - 1).copied()) + }; + let outgoing = seg_of.get(i).copied().zip(param.get(i).copied()); + let a = incoming.map(|(si, (_, t))| source_at(si, t)); + let b = outgoing.map(|(si, (t, _))| source_at(si, t)); + match (a, b) { + (Some(a), Some(b)) if !corner[i] => { + let sum = a + b; + if sum.hypot() > 1e-9 { unit(sum) } else { b } + } + (_, Some(b)) => b, + (Some(a), None) => a, + (None, None) => Vec2::new(1.0, 0.0), + } + }; + let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len())); let mut acc = 0.0; - // Sorted once, so each segment finds its marks by search, not by a pass over all of them. - let mut marks = marks.to_vec(); - marks.sort_by(f64::total_cmp); for i in 0..seg_count { let (a, b) = (pts[i], pts[(i + 1) % n]); let len = a.distance(b); out.0.push(a); out.1.push(corner[i]); - let from = marks.partition_point(|d| *d <= acc + 1e-9); - let mut inner: Vec = marks.iter().skip(from).copied().take_while(|d| *d < acc + len - 1e-9).collect(); + out.2.push(dir_at(i)); + let mut inner: Vec = marks.iter().copied().filter(|d| *d > acc + 1e-9 && *d < acc + len - 1e-9).collect(); + inner.sort_by(f64::total_cmp); inner.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); for d in inner { - out.0.push(a.lerp(b, (d - acc) / len)); + let p = a.lerp(b, (d - acc) / len); + out.0.push(p); out.1.push(false); + out.2.push(source_near(p, i)); } acc += len; } if !closed && n > 0 { out.0.push(pts[n - 1]); out.1.push(corner[n - 1]); + out.2.push(dir_at(n - 1)); } out } @@ -249,6 +386,8 @@ struct Ctx<'a> { join: LineJoin, miter_limit: f64, tol: f64, + /// The profile has one (left, right) width everywhere. + uniform: bool, } /// A vertex of a flattened subpath: where it is, the directions in and out, the lengths of the @@ -257,14 +396,31 @@ struct Vertex { p: Point, a: Vec2, b: Vec2, + /// The smooth tangent: the source curve's direction at this sample, or the average of its + /// arriving and leaving directions at a smooth join. A smooth point offsets along this + /// normal, not the chord bisector; otherwise the chord normal inside each flattened piece and + /// the bisector at each flatten vertex alternate and the edge is scalloped. + n: Vec2, la: f64, lb: f64, corner: bool, } -impl Ctx<'_> { - fn subpath(&self, out: &mut BezPath, pts: &[Point], corner: &[bool], closed: bool, span: (f64, f64)) { - let (pts, corner) = densify(pts, corner, closed, self.profile, span, self.half, self.tol); +impl<'a> Ctx<'a> { + fn new(width: f64, profile: &'a WidthProfile, st: &StrokeLayer, tol: f64) -> Self { + Self { + half: width / 2.0, + profile, + cap: st.cap, + join: st.join, + miter_limit: st.miter_limit, + tol: tol.max(1e-4), + uniform: profile.points.first().is_some_and(|f| profile.points.iter().all(|p| p.1 == f.1 && p.2 == f.2)), + } + } + + fn subpath(&self, out: &mut BezPath, flat: &Flat, segs: &[PathSeg], closed: bool, span: (f64, f64), density: f64) { + let (pts, corner, dirs) = densify(flat, segs, closed, self.profile, span, density); let n = pts.len(); let seg_count = if closed { n } else { n - 1 }; let seg_dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); @@ -280,117 +436,398 @@ impl Ctx<'_> { // at a discontinuous point). let width_at = |d: f64| { let t = span.0 + (span.1 - span.0) * d / total; - let (b, a) = self.profile.around(if t > 1.0 { t - 1.0 } else { t }); + // A closed path joins its own start and end: a profile with different widths there + // is a step, not a stretch across the seam. + let (b, a) = if closed && span == (0.0, 1.0) && d <= 1e-12 { + (self.profile.around(1.0).0, self.profile.around(0.0).1) + } else { + self.profile.around(if t > 1.0 { t - 1.0 } else { t }) + }; let side = |(l, r): (f64, f64)| (self.half * l.max(0.0), self.half * r.max(0.0)); (side(b), side(a)) }; - let mut lpts = Vec::with_capacity(n); - let mut rpts = Vec::with_capacity(n); + let mut lpts: Vec<(Point, bool)> = Vec::with_capacity(n); + let mut rpts: Vec<(Point, bool)> = Vec::with_capacity(n); + // Only a butt cap makes the side end at a corner; square and round caps stay tangent to it. + let end_sharp = matches!(self.cap, LineCap::Butt); for (i, &p) in pts.iter().enumerate() { - let (before, after) = width_at(cum[i]); + let ((wl_b, wr_b), (wl_a, wr_a)) = width_at(cum[i]); if !closed && (i == 0 || i == n - 1) { - let nm = left(seg_dir[i.min(seg_count - 1)]); - let (wl, wr) = if i == 0 { after } else { before }; - lpts.push(p + nm * wl); - rpts.push(p - nm * wr); + let nm = left(dirs[i]); + let (wl, wr) = if i == 0 { (wl_a, wr_a) } else { (wl_b, wr_b) }; + lpts.push((p + nm * wl, end_sharp)); + rpts.push((p - nm * wr, end_sharp)); continue; } let ip = if i == 0 { seg_count - 1 } else { i - 1 }; - let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] }; + let v = Vertex { p, a: seg_dir[ip], b: seg_dir[i], n: dirs[i], la: cum[ip + 1] - cum[ip], lb: cum[i + 1] - cum[i], corner: corner[i] }; // A step: the side goes straight out (or in) from the width before to the one after. - for (k, (wl, wr)) in [before, after].into_iter().enumerate() { - if k == 0 || (wl, wr) != before { - self.join(&mut lpts, &v, wl, 1.0); - self.join(&mut rpts, &v, wr, -1.0); - } + // Both ends of that straight piece are corners: the run before it must not round + // into the step. + let (step_l, step_r) = (wl_a != wl_b, wr_a != wr_b); + self.join(&mut lpts, &v, wl_b, 1.0, step_l); + self.join(&mut rpts, &v, wr_b, -1.0, step_r); + if step_l { + self.join(&mut lpts, &v, wl_a, 1.0, true); + } + if step_r { + self.join(&mut rpts, &v, wr_a, -1.0, true); } } if closed { - polygon(out, lpts.into_iter()); - polygon(out, rpts.into_iter().rev()); + polygon(out, lpts.into_iter(), self.tol); + polygon(out, rpts.into_iter().rev(), self.tol); return; } - let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]); + let (t0, t1) = (dirs[0], dirs[n - 1]); let ((wl0, wr0), (wl1, wr1)) = (width_at(0.0).1, width_at(total).0); let mut ring = lpts; // End cap: from the left side around the end to the right side. - cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, self.cap); + cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, self.cap, self.tol); ring.extend(rpts.into_iter().rev()); // Start cap: from the right side around the start back to the left side. - cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, self.cap); - polygon(out, ring.into_iter()); + cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, self.cap, self.tol); + polygon(out, ring.into_iter(), self.tol); } - /// The offset points at `v` on one side (`side` 1: left, −1: right), `w` from the path: the - /// join on the outer side of a turn, the inner miter point (or a detour through the vertex - /// when that would overshoot a segment) on the inner side. - fn join(&self, pts: &mut Vec, v: &Vertex, w: f64, side: f64) { + /// The offset points at `v` on one side (`side` 1: left, −1: right), `w` from the path. A + /// smooth point follows the vertex normal, the offset of the curve itself; a corner takes the + /// join on its outer side and the inner miter point (or a detour through the vertex when that + /// would overshoot a segment) on its inner side. + /// + /// Each point says whether the outline is sharp there: a path corner, a miter tip, a width + /// step or a butt cap end. Only those break the smooth fitting; a rapid width change makes + /// the offset turn sharply, but it is not a corner and must not be fitted as one. + fn join(&self, pts: &mut Vec<(Point, bool)>, v: &Vertex, w: f64, side: f64, step: bool) { let (na, nb) = (left(v.a) * side, left(v.b) * side); let mid = na + nb; // Cosine of half the turn; the miter point is `w / c` out along `mid`. let c = mid.hypot() / 2.0; - let miter = |pts: &mut Vec| pts.push(v.p + mid * (w / (2.0 * c * c))); if w <= 0.0 { - pts.push(v.p); - } else if v.b.dot(na) <= 0.0 { + pts.push((v.p, true)); + return; + } + let miter = if c > 1e-9 { Some(v.p + mid * (w / (2.0 * c * c))) } else { None }; + // The offset of the smooth path itself is at `w` along the path normal. The polyline + // miter (`w / c`) is the offset of the flattened segments, not of the curve: at a tight + // curve it spiked every sample outward and left a scalloped edge. The source curve's + // tangent is the smooth normal; a corner still uses its two chords for the join. + let smooth = v.p + left(v.n) * (w * side); + if v.b.dot(na) <= 0.0 { // Outer side: the path turns away from it. - let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY }; - match self.join { - _ if excess <= self.tol => miter(pts), - LineJoin::Miter if v.corner && 1.0 / c <= self.miter_limit => miter(pts), - LineJoin::Miter | LineJoin::Bevel if v.corner => pts.extend([v.p + na * w, v.p + nb * w]), - _ => self.arc(pts, v, na, nb, w), + if !v.corner { + // A smooth point has no join: its offset is along the path normal. + pts.push((smooth, step)); + } else { + let excess = if c > 1e-9 { w * (1.0 / c - 1.0) } else { f64::INFINITY }; + match self.join { + _ if excess <= self.tol => pts.push((miter.unwrap_or(v.p), true)), + LineJoin::Miter if c > 1e-9 && 1.0 / c <= self.miter_limit => pts.push((miter.unwrap_or(v.p), true)), + LineJoin::Miter | LineJoin::Bevel => pts.extend([(v.p + na * w, true), (v.p + nb * w, true)]), + _ => self.arc(pts, v, na, nb, w, step), + } } - } else if c > 1e-9 && w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { - miter(pts); + } else if c <= 1e-9 { + // A reversal has no inner miter; go through the vertex. That is a notch in the + // outline, so it stays sharp: fitting a curve through it would loop. + pts.extend([(v.p + na * w, true), (v.p, true), (v.p + nb * w, true)]); + } else if !v.corner { + // A smooth point's inner offset follows its normal too. With a constant profile, once + // the half-width reaches the local radius of curvature the offset crosses the centre of + // curvature and comes back as a reversed contour, which the non-zero fill rules into a + // hole. The stroke covers that centre, so cut through it instead: the cut is inside the + // painted region and does not show. (With varying widths the crossing usually marks a + // real caustic; a cut there would show as a slit, so leave it to the offset.) + let turn = 2.0 * c.clamp(-1.0, 1.0).acos(); + let radius = if turn > 1e-9 { 0.5 * (v.la + v.lb) / turn } else { f64::INFINITY }; + if self.uniform && w >= radius { + pts.push((smooth, true)); + pts.push((v.p, true)); + pts.push((smooth, true)); + } else { + pts.push((smooth, step)); + } + } else if w * (1.0 - c * c).sqrt() / c <= v.la.min(v.lb) { + // A corner's inner offset is its miter while that stays inside both segments. + pts.push((miter.unwrap_or(v.p), true)); } else { - pts.extend([v.p + na * w, v.p, v.p + nb * w]); + // The inner offset of a corner cuts through the vertex; keep that notch sharp. + pts.extend([(v.p + na * w, true), (v.p, true), (v.p + nb * w, true)]); } } /// A round join from `na` to `nb` (unit normals) at radius `w` around `v`; a full reversal - /// rounds through the direction of travel. - fn arc(&self, pts: &mut Vec, v: &Vertex, na: Vec2, nb: Vec2, w: f64) { + /// rounds through the direction of travel. `sharp` marks a step at the join's ends. + fn arc(&self, pts: &mut Vec<(Point, bool)>, v: &Vertex, na: Vec2, nb: Vec2, w: f64, sharp: bool) { let phi = if (na + nb).hypot() < 1e-9 { if Vec2::new(-na.y, na.x).dot(v.a) > 0.0 { PI } else { -PI } } else { na.cross(nb).atan2(na.dot(nb)) }; let step = 2.0 * (1.0 - (self.tol / w).min(1.0)).acos(); let steps = ((phi.abs() / step.max(1e-3)).ceil() as usize).clamp(1, 256); for k in 0..=steps { let (s, c) = (phi * k as f64 / steps as f64).sin_cos(); - pts.push(v.p + Vec2::new(na.x * c - na.y * s, na.x * s + na.y * c) * w); + pts.push((v.p + Vec2::new(na.x * c - na.y * s, na.x * s + na.y * c) * w, sharp)); } } } /// Cap points between `c + u·r0` and `c − u·r1`, bulging towards `v`. -fn cap_points(ring: &mut Vec, c: Point, u: Vec2, v: Vec2, r0: f64, r1: f64, cap: LineCap) { +#[allow(clippy::too_many_arguments)] +fn cap_points(ring: &mut Vec<(Point, bool)>, c: Point, u: Vec2, v: Vec2, r0: f64, r1: f64, cap: LineCap, tol: f64) { match cap { LineCap::Butt => {} LineCap::Square => { let h = (r0 + r1) / 2.0; - ring.push(c + u * r0 + v * h); - ring.push(c - u * r1 + v * h); + ring.push((c + u * r0 + v * h, true)); + ring.push((c - u * r1 + v * h, true)); } LineCap::Round => { - let steps = 16; + // Enough steps that the cap's facets stay within `tol`, as a round join does. + let r = r0.max(r1).max(1e-9); + let step = 2.0 * (1.0 - (tol / r).min(1.0)).acos(); + let steps = ((PI / step.max(1e-6)).ceil() as usize).clamp(8, 256); for k in 1..steps { - let a = std::f64::consts::PI * k as f64 / steps as f64; + let a = PI * k as f64 / steps as f64; let r = r0 + (r1 - r0) * (k as f64 / steps as f64); - ring.push(c + u * (a.cos() * r) + v * (a.sin() * r)); + ring.push((c + u * (a.cos() * r) + v * (a.sin() * r), false)); } } } } -fn polygon(out: &mut BezPath, mut pts: impl Iterator) { - let Some(first) = pts.next() else { return }; - out.move_to(first); - for p in pts { - out.line_to(p); +/// Append `pts` as a closed contour (a width outline's ring or loop), through a smooth run's +/// points as cubic curves. Sampled as line segments, a curved, wide stroke shows the facets of +/// its offset whenever it is magnified; the cubics keep it smooth. A run whose points are +/// collinear stays a line, and the points marked sharp — caps, miter joins, width steps and path +/// corners — stay sharp. A rapid width change turns the offset sharply without being a corner: +/// marking corners here rather than measuring turns keeps that turn smooth. +fn polygon(out: &mut BezPath, pts: impl Iterator, tol: f64) { + let mut ring: Vec<(Point, bool)> = Vec::new(); + for (p, sharp) in pts { + if !p.x.is_finite() || !p.y.is_finite() { + return; + } + if let Some(last) = ring.last_mut() + && last.0.distance(p) <= 1e-9 + { + last.1 |= sharp; + continue; + } + ring.push((p, sharp)); + } + if ring.len() > 1 + && ring[0].0.distance(ring[ring.len() - 1].0) <= 1e-9 + && let Some(last) = ring.pop() + { + ring[0].1 |= last.1; + } + let n = ring.len(); + if n == 0 { + return; + } + if n < 3 { + out.move_to(ring[0].0); + if n == 2 { + out.line_to(ring[1].0); + } + out.close_path(); + return; + } + // Start at a sharp point when there is one, so no run has to wrap: `smooth_run` starts from + // whichever point is current, and starting anywhere else would bridge over the skipped arc. + match ring.iter().position(|&(_, sharp)| sharp) { + None => { + // A smooth closed loop: fit across the seam, so it doesn't start with a kink. + let pts: Vec = ring.iter().map(|&(p, _)| p).collect(); + out.move_to(pts[0]); + smooth_run(out, &pts, true, tol); + } + Some(first) => { + out.move_to(ring[first].0); + // From each sharp point to the next; the sharp points themselves stay sharp. + let mut i = first; + loop { + let mut j = (i + 1) % n; + while !ring[j].1 { + j = (j + 1) % n; + } + // A contour with a single sharp point comes back to it: the run is the whole + // loop, fitted non-cyclically so that point stays sharp. + let steps = if j == i { n } else { (j + n - i) % n }; + if steps == 1 { + out.line_to(ring[j].0); + } else { + let run: Vec = (0..=steps).map(|k| ring[(i + k) % n].0).collect(); + smooth_run(out, &run, false, tol); + } + if j == first { + break; + } + i = j; + } + } } out.close_path(); } +/// The derivative of the run at each point, in points per point of chord length. Each coordinate +/// is a shape-preserving cubic Hermite (Fritsch–Carlson): between two points it stays within their +/// coordinate range, so a cubic through a fast width pinch cannot bulge past the pinch and cross +/// the other side, and it has a flat slope at a local extremum. A Catmull–Rom tangent (the secant +/// across the neighbours) does neither: it overshoots a V and, with the uneven sample spacing of a +/// flattened path, leaves a scalloped edge. +fn tangents(run: &[Point], cyclic: bool) -> Vec { + let n = run.len(); + let segs = if cyclic { n } else { n.saturating_sub(1) }; + let mut h = vec![0.0; segs]; + for (i, hi) in h.iter_mut().enumerate() { + *hi = run[i].distance(run[(i + 1) % n]); + } + let slope = |d0: f64, d1: f64, h0: f64, h1: f64| { + if d0 * d1 <= 0.0 { + return 0.0; + } + let (w0, w1) = (2.0 * h1 + h0, h1 + 2.0 * h0); + let m = (w0 + w1) / (w0 / d0 + w1 / d1); + if m.is_finite() { m } else { 0.0 } + }; + // The one-sided slope of a run's end uses its two end secants, instead of only the last one. + // A coarse sample then still leaves the end of a profile curve with the right slope. + let end_slope = |d0: f64, d1: f64, h0: f64, h1: f64| { + if d0 * d1 <= 0.0 || h0 + h1 <= 1e-12 { + return 0.0; + } + let m = ((2.0 * h0 + h1) * d0 - h0 * d1) / (h0 + h1); + if m.is_finite() && m * d0 > 0.0 { + let cap = 3.0 * d0.abs(); + m.clamp(-cap, cap) + } else { + 0.0 + } + }; + let delta = |i: usize| -> Vec2 { + let hi = h[i % segs]; + if hi > 1e-12 { (run[(i + 1) % n] - run[i % n]) / hi } else { Vec2::ZERO } + }; + let mut out = vec![Vec2::ZERO; n]; + for (i, m) in out.iter_mut().enumerate() { + let (d0, d1) = (delta((i + n - 1) % n), delta(i % segs)); + let (h0, h1) = (h[(i + n - 1) % segs], h[i % segs]); + if h0 <= 1e-12 || h1 <= 1e-12 { + *m = if h1 > 1e-12 { d1 } else { d0 }; + } else if cyclic || (i > 0 && i + 1 < n) { + *m = Vec2::new(slope(d0.x, d1.x, h0, h1), slope(d0.y, d1.y, h0, h1)); + } else if i == 0 { + *m = if n >= 3 { + let (a, b) = (delta(0), delta(1)); + Vec2::new(end_slope(a.x, b.x, h[0], h[1]), end_slope(a.y, b.y, h[0], h[1])) + } else { + d1 + }; + } else { + *m = if n >= 3 { + let (a, b) = (delta(n - 2), delta(n - 3)); + Vec2::new(end_slope(a.x, b.x, h[n - 2], h[n - 3]), end_slope(a.y, b.y, h[n - 2], h[n - 3])) + } else { + d0 + }; + } + // A shape-preserving slope is flat in a coordinate at that coordinate's local extremum. + // Where both are extrema at one sample (a loop or a pinch in the sampled edge) the flat + // tangent would make a line and a kink; the secant across the sample keeps the curve C1 + // there, and the extremum itself is what trims the loop later. + if m.hypot() <= 1e-12 { + let prev = if cyclic || i > 0 { run[(i + n - 1) % n] } else { run[i] }; + let next = if cyclic || i + 1 < n { run[(i + 1) % n] } else { run[i] }; + let sec = next - prev; + if sec.hypot() > 1e-12 { + *m = sec.normalize(); + } + } + } + out +} + +/// Append a run of points as a fitted chain of cubics (or one line when they are collinear). +/// +/// A cubic through every flattened point, as this used to emit, keeps the outline smooth but puts +/// three coordinates per point into the file; a small, thin stroke then exports several times the +/// path data of the same stroke without a profile. Fit a chain within the outline tolerance +/// instead: the result is still G1 continuous and stays within `tol` of the sampled edge, but a +/// smooth side is a few cubics rather than one per flatten step. Profile bends that need finer +/// detail produce more cubics; straight runs stay lines. +fn smooth_run(out: &mut BezPath, run: &[Point], cyclic: bool, tol: f64) { + let n = run.len(); + if n < 2 { + return; + } + if !cyclic && is_straight(run, tol) { + out.line_to(run[n - 1]); + return; + } + let tans = tangents(run, cyclic); + let tol = tol.max(1e-6); + // A short run is one or two profile features, not flattened path detail. Fitting it loosely + // enough to save path data can let a handle cross the narrow side of a V; keep the + // shape-preserving per-point cubics there. + let segs = if cyclic { n } else { n - 1 }; + if n <= 3 { + for i in 0..segs { + let j = (i + 1) % n; + let h = run[i].distance(run[j]); + let (m0, m1) = (tans[i] * (h / 3.0), tans[j] * (h / 3.0)); + if m0.hypot() <= 1e-12 || m1.hypot() <= 1e-12 { + out.line_to(run[j]); + } else { + out.curve_to(run[i] + m0, run[j] - m1, run[j]); + } + } + return; + } + let unit_or = |v: Vec2, fallback: Vec2| { + let len = v.hypot(); + if len > 1e-12 { + v / len + } else { + let len = fallback.hypot(); + if len > 1e-12 { fallback / len } else { Vec2::new(1.0, 0.0) } + } + }; + let t0 = unit_or(tans[0], run[1] - run[0]); + let (pts, t0, t1) = if cyclic { + // Fit the seam too: the fit starts and arrives with the same tangent, so closing the + // contour adds no kink. + let mut pts = Vec::with_capacity(n + 1); + pts.extend_from_slice(run); + pts.push(run[0]); + (pts, t0, t0) + } else { + (run.to_vec(), t0, unit_or(tans[n - 1], run[n - 1] - run[n - 2])) + }; + // Fit tighter than the flattening tolerance: the fit's error is the whole outline's error, + // and a least-squares fit may sit on one side of the sampled edge, which would otherwise + // shift a profile's area visibly. The points lie on the outline's own curve, so the fit follows + // their curve between them rather than the chords through them. + let cubics = fit_cubics_through(&pts, t0, t1, tol * 0.1); + if cubics.is_empty() { + out.line_to(run[n - 1]); + return; + } + for c in cubics { + out.curve_to(c.p1, c.p2, c.p3); + } +} + +/// Are the run's points within the flattening tolerance of its chord? +fn is_straight(run: &[Point], tol: f64) -> bool { + let (a, b) = (run[0], run[run.len() - 1]); + let d = b - a; + let l = d.hypot(); + if l <= 1e-12 { + return false; + } + run.iter().all(|p| (d.cross(*p - a) / l).abs() <= tol.max(1e-6) * 0.25) +} + #[cfg(test)] mod tests { use super::*; @@ -423,13 +860,12 @@ mod tests { assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6); } - /// The Lens outline swells on its curve, and a profile with one side at zero paints only the other side. + /// The lens eases up to full width and back down (a parabolic bump averaging two thirds of + /// the width); a one-sided profile keeps its side at zero and stays on the other. #[test] - fn lens_profile_swells_smoothly_and_one_sided_profiles_stay_on_their_side() { - // Each half of the lens rises on a cubic from 0 to full width, flat at the middle: it - // covers 7/12 of the full-width band (a diamond would cover half). + fn lens_profile_is_a_smooth_bump_and_one_sided_profiles_stay_on_their_side() { let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01)); - assert!((a - 1000.0 * 7.0 / 12.0).abs() < 0.5, "{a}"); + assert!((a - 2000.0 / 3.0).abs() < 0.1, "{a}"); let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] }; let o = width_outline(&line(), 10.0, &one, &capped(LineCap::Butt), 0.01); assert!((area(&o) - 500.0).abs() < 1e-6); @@ -453,41 +889,103 @@ mod tests { assert!(a.is_finite() && (a - 3000.0).abs() < 150.0, "{a}"); } - /// #5714: between width points a straight run follows the profile's smooth curve, not a - /// straight line from one width to the next. #[test] - fn a_straight_run_follows_the_curve_between_width_points() { - // 5 pt either side at the ends, 15 pt at the middle: a quarter of the way along the curve - // is at 2.25 × 5 = 11.25 pt (a straight ramp would be at 10 pt). - let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (1.0, 1.0, 1.0)] }; - let o = width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01); - for y in [-11.0, 11.0] { - assert!(o.contains(kurbo::Point::new(25.0, y)), "inside at y {y}"); - } - for y in [-11.5, 11.5] { - assert!(!o.contains(kurbo::Point::new(25.0, y)), "outside at y {y}"); - } - // Round the widest point the side is flat: no corner sticks out above 15 pt. - let bb = o.bounding_box(); - assert!((bb.y0 + 15.0).abs() < 1e-6 && (bb.y1 - 15.0).abs() < 1e-6, "{bb:?}"); + fn a_closed_path_steps_where_its_profile_ends_differ() { + let mut sq = BezPath::new(); + sq.move_to((0.0, 0.0)); + sq.line_to((100.0, 0.0)); + sq.line_to((100.0, 100.0)); + sq.line_to((0.0, 100.0)); + sq.close_path(); + // 5 pt at the seam's start, 10 pt at its end: the ring must step between them, not + // stretch the last width point round the corner. + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 2.0, 2.0)] }; + let o = width_outline(&sq, 10.0, &p, &capped(LineCap::Butt), 0.01); + let at = |p: Point, x: f64, y: f64| (p.x - x).abs() < 1e-9 && (p.y - y).abs() < 1e-9; + let step = o.elements().windows(2).any(|w| match (w[0], w[1]) { + (PathEl::MoveTo(a), PathEl::LineTo(b)) | (PathEl::LineTo(a), PathEl::LineTo(b)) => { + at(a, -10.0, -10.0) && at(b, -5.0, -5.0) || at(a, -5.0, -5.0) && at(b, -10.0, -10.0) + } + _ => false, + }); + assert!(step, "{o:?}"); } - /// A taper is a straight ramp: its outline gets no samples between the width points. #[test] - fn a_straight_taper_gets_no_samples_between_its_points() { - let o = width_outline(&line(), 10.0, &WidthProfile::taper_end(), &capped(LineCap::Butt), 0.01); - assert_eq!(o.elements().len(), 5, "{:?}", o.elements()); + fn added_samples_follow_the_curve_not_the_flattened_chord() { + // A cubic with a tight S: its chords are far from its tangents. A sample inserted inside a + // chord must take the curve's direction there; the chord's direction is what made a wide + // profile scallop at every flatten vertex. + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((0.0, 120.0), (120.0, -120.0), (120.0, 0.0)); + let segs = segments(b.elements()); + let flat = flatten(&segs, false, 2.0); + assert!(flat.pts.len() >= 3); + let mut marks = vec![]; + let mut acc = 0.0; + for i in 0..flat.pts.len() - 1 { + let len = flat.pts[i].distance(flat.pts[i + 1]); + marks.push(acc + len / 2.0); + acc += len; + } + let (all, _, dirs) = split_at(&flat, false, &marks, &segs); + assert!(all.len() > flat.pts.len()); + let seg = segs[0]; + for (p, d) in all.iter().zip(&dirs) { + let want = tangent(&seg, seg.nearest(*p, 1e-9).t); + assert!(d.dot(want) > 0.999, "{d:?} vs {want:?} at {p:?}"); + } } - /// A profile read from a file may have thousands of points and absurd widths: the samples added - /// between them stay bounded and the outline finite. #[test] - fn samples_between_width_points_are_capped() { - let points = (0..=10_000).map(|i| (i as f64 / 10_000.0, if i % 2 == 0 { 0.0 } else { 1e12 }, 1.0)).collect(); - let o = width_outline(&line(), 10.0, &WidthProfile { points }, &capped(LineCap::Butt), 0.01); - let n = o.elements().len(); - assert!(n < 2 * (2 * 10_001 + MAX_CURVE_SAMPLES) + 64, "{n}"); - assert!(o.bounding_box().area().is_finite()); + fn a_wiggly_profile_on_a_curve_fits_without_kinks() { + // A rapid width change turns the offset sharply; that is not a path corner, so the + // outline must stay tangent-continuous through it (the old turn-angle test did not). + let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-3); + let p = WidthProfile { + points: vec![(0.0, 1.0, 1.0), (0.15, 3.0, 0.4), (0.3, 0.2, 2.8), (0.5, 2.5, 0.3), (0.7, 0.4, 2.2), (0.85, 2.0, 0.5), (1.0, 1.0, 1.0)], + }; + let o = width_outline(&c, 10.0, &p, &capped(LineCap::Butt), 0.01); + let mut cursor = None; + let mut prev: Option = None; + let mut kinks = 0; + let mut check = |a: Vec2, b: Vec2| { + let (la, lb) = (a.hypot(), b.hypot()); + if la > 1e-9 && lb > 1e-9 && (a.dot(b) <= 0.0 || a.cross(b).abs() / (la * lb) > 1e-6) { + kinks += 1; + } + }; + for el in o.elements() { + match el { + PathEl::MoveTo(p) => { + cursor = Some(*p); + prev = None; + } + PathEl::LineTo(p) => { + if let Some(q) = cursor { + let d = *p - q; + if let Some(a) = prev { + check(a, d); + } + prev = Some(d); + } + cursor = Some(*p); + } + PathEl::CurveTo(a, b, p) => { + if let Some(q) = cursor { + let d = if a.distance(q) > 1e-9 { *a - q } else { *b - q }; + if let Some(prev) = prev { + check(prev, d); + } + prev = Some(if p.distance(*b) > 1e-9 { *p - *b } else { *p - *a }); + } + cursor = Some(*p); + } + PathEl::QuadTo(_, _) | PathEl::ClosePath => {} + } + } + assert_eq!(kinks, 0, "{o:?}"); } #[test] @@ -512,4 +1010,108 @@ mod tests { let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0; assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); } + + #[test] + fn a_uniform_circle_narrower_than_its_stroke_has_no_hole() { + // Half-width 15 on a radius-5 circle: the inner offset crosses the centre. The constant + // stroke still covers the centre, so the outline must not leave a hole there. + let c = kurbo::Circle::new((0.0, 0.0), 5.0).to_path(1e-3); + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let o = width_outline(&c, 30.0, &p, &capped(LineCap::Butt), 0.01); + assert!(o.contains(Point::new(0.0, 0.0)), "{o:?}"); + } + + #[test] + fn a_quick_width_change_does_not_overshoot_its_samples() { + // Width falls to a point and comes back, unevenly on either side: the fitted edge must not + // dip past the point (that crossed the other edge and left a scalloped neck). + let mut run = vec![Point::new(0.0, 0.0), Point::new(10.0, -1.0), Point::new(20.0, -0.3)]; + let (mut a, mut b) = (BezPath::new(), BezPath::new()); + a.move_to(run[0]); + b.move_to(run[0]); + smooth_run(&mut a, &run, false, 0.0); + // Reverse the same points: the fit must stay inside the V from either direction too. + run.reverse(); + smooth_run(&mut b, &run, false, 0.0); + for path in [a, b] { + let mut start = Point::ZERO; + for el in path.elements() { + match el { + PathEl::MoveTo(p) => start = *p, + PathEl::CurveTo(c1, c2, p) => { + for c in [c1, c2] { + assert!(c.y >= -1.0 - 1e-9, "{c:?} in {path:?}"); + assert!(c.y <= start.y.max(p.y) + 1e-9, "{c:?} in {path:?}"); + } + start = *p; + } + _ => {} + } + } + } + } + + #[test] + fn added_samples_stay_on_their_flattened_branch() { + // This cubic loops back near itself. At a coarse tolerance a chord midpoint can be closer + // to the other branch; a full-segment nearest would then take that branch's tangent and + // spike the offset. Each inserted sample must use the parameter range of its own piece. + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((150.0, 100.0), (-50.0, 100.0), (100.0, 0.0)); + let segs = segments(b.elements()); + let seg = segs[0]; + let flat = flatten(&segs, false, 2.0); + let mut acc = 0.0; + let mut divergent = 0; + for i in 0..flat.pts.len() - 1 { + let len = flat.pts[i].distance(flat.pts[i + 1]); + if len <= 1e-12 { + continue; + } + let (t0, t1) = flat.param[i]; + let sub = seg.subsegment(t0..t1); + for k in 1..16 { + let (out, _, dirs) = split_at(&flat, false, &[acc + len * k as f64 / 16.0], &segs); + assert!(out.len() >= i + 2, "no sample inserted in piece {i}"); + let p = out[i + 1]; + let want = tangent(&seg, t0 + (t1 - t0) * sub.nearest(p, 1e-9).t); + assert!(dirs[i + 1].dot(want) > 0.999, "piece {i}, k {k}: {:?} vs {want:?}", dirs[i + 1]); + let full = tangent(&seg, seg.nearest(p, 1e-9).t); + if full.dot(want) < 0.9 { + divergent += 1; + } + } + acc += len; + } + assert!(divergent > 0, "the fixture did not cross the other branch"); + } + + #[test] + fn a_coarse_wide_circle_keeps_its_offset() { + // A wide stroke flattened coarsely: offsetting each flattened segment by its miter + // (`w / cos(θ/2)`) scalloped the circle outward at every sample. Smooth points follow the + // curve's normal instead, so the ring is the annulus between radii 10 and 30. + let c = kurbo::Circle::new((0.0, 0.0), 20.0).to_path(0.5); + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let a = area(&width_outline(&c, 20.0, &p, &capped(LineCap::Butt), 0.5)); + let want = std::f64::consts::PI * (30.0 * 30.0 - 10.0 * 10.0); + assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); + } + + #[test] + fn profiled_outlines_stay_compact() { + // A smooth profile is fitted with cubics, so it must not turn every profile sample into + // exported path data. Compare it with the same curved path's uniform outline. + let circle = kurbo::Circle::new((0.0, 0.0), 5.0).to_path(0.01); + let count = |points| { + let p = WidthProfile { points }; + width_outline(&circle, 5.0, &p, &capped(LineCap::Butt), 0.01).elements().len() + }; + let uniform = count(vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)]); + let lens = count(WidthProfile::lens().points); + let wave = count(vec![(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)]); + assert!(lens <= uniform * 3 / 2, "lens {lens} vs uniform {uniform}"); + assert!(wave <= uniform * 3 / 2, "wave {wave} vs uniform {uniform}"); + } } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 72e33b899..b8c1f82e5 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -749,6 +749,7 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap< /// The most presets one presets file imports: each is looked up among the saved ones. pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; +pub(crate) use addons::DecodedImages; pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 324d7bde5..4c4671f6b 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -6,12 +6,15 @@ mod cleanup; mod color; mod contour; mod document; +mod image; mod pathtools; mod pen; mod select; mod shapes; mod typetools; +pub(crate) use image::DecodedImages; + use super::CommandSpec; /// The commands of every built-in add-on. @@ -21,6 +24,7 @@ pub fn specs() -> Vec { v.extend(color::specs()); v.extend(contour::specs()); v.extend(document::specs()); + v.extend(image::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); v.extend(select::specs()); diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs new file mode 100644 index 000000000..b2a9d74f8 --- /dev/null +++ b/crates/engine/src/cmd/addons/image.rs @@ -0,0 +1,604 @@ +//! Image Tools (`addon.image.*`), a built-in add-on. Remove Background makes the background of the +//! selected images transparent in their own pixels, as one undo step. The background is the most +//! common color along an image's edges, the average of its four corners, or colors the caller +//! picks. It is cleared where it reaches in from an edge, or wherever it is. The work is done here, +//! on the pixels; nothing is sent anywhere. It is a VectorCraft addition. Its submenu (Object › +//! Plug-ins › Image Tools) and dialog are the UI's. + +#[cfg(test)] +mod tests; + +use std::collections::{HashMap, HashSet}; +use std::io::Cursor; +use std::sync::{Arc, Weak}; + +use image::{DynamicImage, ImageBuffer, LumaA, Rgba}; +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::{ImageBlob, NodeId, NodeKind}; +use vectorcraft_geom::Affine; + +use super::super::fileio::{MAX_RASTER_ALLOC, ppi}; +use super::super::menucmds::has_image; +use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param}; + +/// Remove Background's command. +pub const REMOVE_BACKGROUND: &str = "addon.image.removeBackground"; +const C: &str = REMOVE_BACKGROUND; + +/// The most colors `colors` lists. +const MAX_COLORS: usize = 16; + +/// Bytes a pixel takes while it is worked on, beside its pixels: its level, whether the edge search +/// reached it, and its place in the search's stack (4 bytes, twice that while the stack grows). +const WORK_BYTES: u64 = 10; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.image.removeBackground", + "Remove Background…", + ["Object", "Plug-ins", "Image Tools"], + None, + "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", + has_image, + remove_background + )] +} + +/// Where the background's colors come from. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Background { + /// The most common color along the image's edges. + Edges, + /// The average of the image's four corner pixels that are not clear. + Corners, + /// These colors. + Colors(Vec<[u8; 3]>), +} + +/// How near a color is to a background color. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Match { + /// The RGB distance, as a percentage of the largest one (black to white). + Distance, + /// The largest difference on one of red, green and blue, as a percentage of a channel's range. + Channels, +} + +/// What Remove Background clears: the background, whether only where it reaches in from an edge, +/// and how near a color must be to it (a percentage, measured as `measure` says) to be cleared, and +/// to fade out past that. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Settings { + pub background: Background, + pub contiguous: bool, + pub measure: Match, + pub tolerance: f64, + pub softness: f64, +} + +impl Settings { + /// The settings `p` gives, each checked. + fn read(p: &Value) -> Result { + let percent = |key: &str| -> Result { + let v = number_or(p, key, 10.0, C)?; + if v.is_finite() && (0.0..=100.0).contains(&v) { Ok(v) } else { Err(bad(C, format!("`{key}` must be from 0 to 100"))) } + }; + let (tolerance, softness) = (percent("tolerance")?, percent("softness")?); + let colors = match p.get("colors") { + None | Some(Value::Null) => None, + Some(v) => Some(colors(v)?), + }; + let background = match text_param(p, "background", C)? { + None | Some("edges") => Background::Edges, + Some("corners") => Background::Corners, + Some("colors") => Background::Colors(colors.ok_or_else(colors_error)?), + Some(_) => return Err(bad(C, "`background` must be edges, corners or colors")), + }; + let measure = match text_param(p, "match", C)? { + None | Some("distance") => Match::Distance, + Some("channels") => Match::Channels, + Some(_) => return Err(bad(C, "`match` must be distance or channels")), + }; + let contiguous = switch_or(p, "contiguous", true, C)?; + Ok(Self { background, contiguous, measure, tolerance, softness }) + } +} + +fn colors_error() -> crate::EngineError { + bad(C, "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue") +} + +/// The colors `v` lists: an array of strings, or one string of them split by commas or spaces. +/// Each is read as [`Color::from_hex`] reads it; one with an alpha part is refused. +fn colors(v: &Value) -> Result> { + // One past the most is enough to tell a list that is too long. + let texts: Vec<&str> = match v { + Value::String(s) => s.split([',', ' ', '\t', '\n']).filter(|t| !t.is_empty()).take(MAX_COLORS + 1).collect(), + Value::Array(a) => a.iter().take(MAX_COLORS + 1).map(Value::as_str).collect::>().ok_or_else(colors_error)?, + _ => return Err(colors_error()), + }; + if texts.is_empty() || texts.len() > MAX_COLORS { + return Err(colors_error()); + } + texts.into_iter().map(|t| rgb8(t).ok_or_else(colors_error)).collect() +} + +/// The 8-bit RGB of the color `t` writes. +fn rgb8(t: &str) -> Option<[u8; 3]> { + if hex_with_alpha(t) { + return None; + } + let [r, g, b] = Color::from_hex(t)?.to_rgb_uncalibrated(); + Some([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)) +} + +fn remove_background(s: &mut Session, p: &Value) -> Result { + let settings = Settings::read(p)?; + // A preview step keeps the pixels it decodes for the next step; other runs keep none. + let mut kept = std::mem::take(&mut s.decoded_images); + let previewing = s.previewing; + let found = changes(s, p, &settings, previewing.then_some(&mut kept)); + s.decoded_images = kept; + let changes = found?; + let ids: Vec = changes.iter().map(|(id, ..)| id.0).collect(); + let made_clear: u64 = changes.iter().map(|(_, out, _)| out.cleared).sum(); + if !changes.is_empty() { + s.edit("Remove Background", |d, _| { + for (id, out, xf) in changes { + let key = out.blob.content_key(); + d.images.entry(key.clone()).or_insert(out.blob); + if let Some(NodeKind::Image(im)) = d.node_mut(id).map(|n| &mut n.kind) { + im.key = key; + (im.width, im.height, im.xf) = (out.width, out.height, xf); + im.link = None; + } + } + Ok(()) + })?; + } + Ok(json!({ "changed": ids.len(), "ids": ids, "cleared": made_clear })) +} + +/// The images `p` names (`ids`, or the selected images) whose pixels change, each with its pixels +/// cleared as `settings` say and the transform that keeps its place and size. Decoded pixels are +/// taken from and kept in `kept` when it is given. +fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { + let st = s.doc()?; + let ids: Vec = if p.get("ids").is_some_and(|v| !v.is_null()) { + // Each image once, however often `ids` names it. + let mut given = HashSet::new(); + let ids: Vec = checked_ids_param(s, p, "ids", C)?.into_iter().filter(|id| given.insert(*id)).collect(); + if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Image(_)))) { + return Err(bad(C, format!("object {} is not an image", id.0))); + } + ids + } else { + st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))).collect() + }; + if ids.is_empty() { + return Err(bad(C, "select an image")); + } + // Every image is checked before any is decoded. + let mut images = vec![]; + for id in ids { + let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; + let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; + // The preview stands in for a file that hasn't been read, or that is kept at its preview. + if im.link.is_some() && blob.is_proxy() { + return Err(bad(C, "the linked image shows its preview: embed or relink it first")); + } + if blob.is_cmyk() { + return Err(bad(C, "a CMYK image has no transparency to set")); + } + images.push((id, im, blob)); + } + // Each image's pixels once, however many objects show them. + let mut cleared: HashMap<&str, Option> = HashMap::new(); + let mut changes = vec![]; + for (id, im, blob) in images { + let out = match cleared.get(im.key.as_str()) { + Some(out) => out.clone(), + None => { + let out = clear(blob, settings, kept.as_deref_mut())?; + cleared.insert(&im.key, out.clone()); + out + } + }; + if let Some(out) = out { + // A file read at another size than the object's keeps the object's place and size. + let xf = if (out.width, out.height) == (im.width, im.height) { + im.xf + } else { + im.xf * Affine::scale_non_uniform(f64::from(im.width) / f64::from(out.width), f64::from(im.height) / f64::from(out.height)) + }; + changes.push((id, out, xf)); + } + } + Ok(changes) +} + +/// An image with its background cleared: its PNG, its size in pixels, and how many of its pixels +/// were made fully clear. +#[derive(Clone, Debug)] +pub(crate) struct Cleared { + pub blob: ImageBlob, + pub width: u32, + pub height: u32, + pub cleared: u64, +} + +/// An image's pixels as straight RGBA, at 8 or 16 bits a sample. +#[derive(Clone)] +enum Pixels { + Eight(image::RgbaImage), + Sixteen(ImageBuffer, Vec>), +} + +/// The most bytes of decoded pixels and ICC profiles [`DecodedImages`] keeps (128 MiB, or 32 MiB in +/// the web app). +const KEEP_BYTES: u64 = MAX_RASTER_ALLOC / 16; + +/// The images Remove Background decoded during an interaction's preview steps, the most recently +/// used first, at most [`KEEP_BYTES`] in all. The next preview step runs the command again on the +/// same bytes and takes their pixels from here instead of decoding them again. The session holds +/// it and empties it when the interaction ends (OK or Cancel). A run that is not a preview step +/// does not read or fill it. +#[derive(Default)] +pub(crate) struct DecodedImages(Vec); + +/// An image decoded by [`clear`]: the bytes it was decoded from (held weakly, so they are not kept +/// alive for it, and an entry whose bytes are gone is dropped), its pixels, whether it was gray, +/// and its ICC profile. +struct Decoded { + source: Weak>, + pixels: Arc, + gray: bool, + icc: Option>, +} + +#[cfg(test)] +thread_local! { + /// How many times this thread decoded an image in [`clear`]. + pub(crate) static DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +impl DecodedImages { + /// Drops every image kept. + pub(crate) fn empty(&mut self) { + self.0.clear(); + } + + /// How many images are kept. + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.0.len() + } + + /// The pixels, gray flag and ICC profile kept for `bytes`, if they are kept. Images whose bytes + /// are gone are dropped first. + fn get(&mut self, bytes: &Arc>) -> Option<(Arc, bool, Option>)> { + self.0.retain(|d| d.source.strong_count() > 0); + let at = self.0.iter().position(|d| std::ptr::eq(d.source.as_ptr(), Arc::as_ptr(bytes)))?; + let d = self.0.remove(at); + let found = (Arc::clone(&d.pixels), d.gray, d.icc.clone()); + self.0.insert(0, d); + Some(found) + } + + /// Keeps `pixels` decoded from `bytes` first, in place of anything kept for the same bytes, and + /// drops images whose bytes are gone and the least recently used past [`KEEP_BYTES`]. + fn keep(&mut self, bytes: &Arc>, pixels: Arc, gray: bool, icc: Option>) { + self.0.retain(|d| d.source.strong_count() > 0 && !std::ptr::eq(d.source.as_ptr(), Arc::as_ptr(bytes))); + self.0.insert(0, Decoded { source: Arc::downgrade(bytes), pixels, gray, icc }); + let mut total = 0u64; + self.0.retain(|d| { + total = total.saturating_add(d.bytes()); + total <= KEEP_BYTES + }); + } +} + +impl Decoded { + /// How many bytes the entry keeps: its pixels and its ICC profile. + fn bytes(&self) -> u64 { + self.pixels.bytes().saturating_add(self.icc.as_ref().map_or(0, |icc| icc.len() as u64)) + } +} + +impl Pixels { + /// How many bytes the pixels take. + fn bytes(&self) -> u64 { + match self { + Self::Eight(img) => img.as_raw().len() as u64, + Self::Sixteen(img) => img.as_raw().len() as u64 * 2, + } + } + + /// The width and height in pixels. + fn dimensions(&self) -> (u32, u32) { + match self { + Self::Eight(img) => img.dimensions(), + Self::Sixteen(img) => img.dimensions(), + } + } + + /// Pixel `i` (row by row from the top left) at 16 bits a sample (an 8-bit sample `v` is + /// `v * 257`). + fn rgba16(&self, i: usize) -> [u16; 4] { + let mut out = [0u16; 4]; + match self { + Self::Eight(img) => { + for (o, v) in out.iter_mut().zip(img.as_raw().get(i * 4..i * 4 + 4).unwrap_or_default()) { + *o = u16::from(*v) * 257; + } + } + Self::Sixteen(img) => { + for (o, v) in out.iter_mut().zip(img.as_raw().get(i * 4..i * 4 + 4).unwrap_or_default()) { + *o = *v; + } + } + } + out + } +} + +/// `blob` with its background cleared as `settings` say, or `None` when no pixel changes. A CMYK +/// image, one that can't be decoded or is too large to change, and a result with no pixel left are +/// errors. The PNG keeps the source's ICC profile and resolution. The decoded pixels are taken +/// from and kept in `kept` when it is given. +pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result> { + if blob.is_cmyk() { + return Err(bad(C, "a CMYK image has no transparency to set")); + } + let found = kept.as_deref_mut().and_then(|k| k.get(&blob.bytes)); + let (source, gray, icc) = match found { + Some(found) => found, + None => decode(blob, kept)?, + }; + // The pixels kept for the next run stay as they are; ones not kept are worked on in place. + let mut px = Arc::try_unwrap(source).unwrap_or_else(|kept| Pixels::clone(&kept)); + let (w, h) = px.dimensions(); + let levels = levels(w as usize, h as usize, &|i| px.rgba16(i), settings); + let Some(cleared) = apply(&mut px, &levels) else { return Ok(None) }; + let left = match &px { + Pixels::Eight(img) => img.pixels().any(|p| p.0[3] > 0), + Pixels::Sixteen(img) => img.pixels().any(|p| p.0[3] > 0), + }; + if !left { + return Err(bad(C, "every pixel matches the background: no image would be left")); + } + let out = match (px, gray) { + (Pixels::Eight(img), false) => DynamicImage::ImageRgba8(img), + (Pixels::Sixteen(img), false) => DynamicImage::ImageRgba16(img), + (Pixels::Eight(img), true) => { + DynamicImage::ImageLumaA8(ImageBuffer::from_fn(w, h, |x, y| LumaA([img.get_pixel(x, y).0[0], img.get_pixel(x, y).0[3]]))) + } + (Pixels::Sixteen(img), true) => { + DynamicImage::ImageLumaA16(ImageBuffer::from_fn(w, h, |x, y| LumaA([img.get_pixel(x, y).0[0], img.get_pixel(x, y).0[3]]))) + } + }; + let mut png = Vec::new(); + let mut encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Some(icc) = icc { + use image::ImageEncoder as _; + encoder.set_icc_profile(icc).map_err(|e| bad(C, e.to_string()))?; + } + out.write_with_encoder(encoder).map_err(|e| bad(C, e.to_string()))?; + // The resolution the file declares, which sizes the image at 100% when it is replaced or relinked. + if let Some(r) = ppi::resolution(&blob.bytes) { + png = ppi::with_png_resolution(&png, r); + } + Ok(Some(Cleared { blob: ImageBlob::png(png), width: w, height: h, cleared })) +} + +/// `blob`'s pixels as straight RGBA, whether they were gray, and its ICC profile. An image that +/// can't be decoded or is too large to change is an error. The pixels are kept in `kept`, when it +/// is given, if a copy of them fits the decode allowance beside the work. +fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>) -> Result<(Arc, bool, Option>)> { + let reader = + || image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(|_| bad(C, "the image's pixels can't be read")); + let mut decoder = reader()?.into_decoder().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; + // The ICC profile the colors are in goes with them into the PNG. + let (w, h, kind, icc) = { + use image::ImageDecoder as _; + let (w, h) = decoder.dimensions(); + (w, h, decoder.color_type(), decoder.icc_profile().ok().flatten()) + }; + drop(decoder); + // The file's pixels, those worked on (as many again for the PNG made of them) and the work, + // checked from the header before anything is decoded. + let deep = kind.bytes_per_pixel() > kind.channel_count(); + let per_pixel = u64::from(kind.bytes_per_pixel()) + 2 * if deep { 8 } else { 4 } + WORK_BYTES; + if u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(per_pixel)).is_none_or(|n| n > MAX_RASTER_ALLOC) { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to change"))); + } + let mut reader = reader()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_RASTER_ALLOC); + reader.limits(limits); + let img = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; + #[cfg(test)] + DECODES.with(|c| c.set(c.get() + 1)); + let gray = matches!(img.color(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16); + let px = Arc::new(if deep { Pixels::Sixteen(img.into_rgba16()) } else { Pixels::Eight(img.into_rgba8()) }); + // A kept copy is worked on as a copy: one more set of the pixels worked on. + let with_copy = per_pixel + if deep { 8 } else { 4 }; + if let Some(kept) = kept + && u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(with_copy)).is_some_and(|n| n <= MAX_RASTER_ALLOC) + && px.bytes().saturating_add(icc.as_ref().map_or(0, |icc| icc.len() as u64)) <= KEEP_BYTES + { + kept.keep(&blob.bytes, Arc::clone(&px), gray, icc.clone()); + } + Ok((px, gray, icc)) +} + +/// Each pixel's alpha scaled by its level (255 keeps it). Returns how many pixels were made fully +/// clear, or `None` when no pixel changed. +fn apply(px: &mut Pixels, levels: &[u8]) -> Option { + let (mut changed, mut cleared) = (false, 0u64); + match px { + Pixels::Eight(img) => { + for (p, &l) in img.pixels_mut().zip(levels) { + let a = ((u32::from(p.0[3]) * u32::from(l) + 127) / 255) as u8; + changed |= a != p.0[3]; + cleared += u64::from(a == 0 && p.0[3] != 0); + p.0[3] = a; + } + } + Pixels::Sixteen(img) => { + for (p, &l) in img.pixels_mut().zip(levels) { + let a = ((u32::from(p.0[3]) * u32::from(l) + 127) / 255) as u16; + changed |= a != p.0[3]; + cleared += u64::from(a == 0 && p.0[3] != 0); + p.0[3] = a; + } + } + } + changed.then_some(cleared) +} + +/// How much of each of the `w` × `h` pixels `px` gives (row by row, straight RGBA at 16 bits a +/// sample) stays, from 0 (cleared) to 255 (kept), as `settings` say. Each pixel's distance to the +/// background is worked out once. A pixel past the first `u32::MAX` is left kept by the edge search. +pub(crate) fn levels(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4], settings: &Settings) -> Vec { + let n = w.saturating_mul(h); + let refs: Vec<[u16; 3]> = match &settings.background { + Background::Colors(refs) => refs.iter().map(|c| c.map(|v| u16::from(v) * 257)).collect(), + Background::Edges => match edge_color(w, h, px) { + Some(edge) => vec![edge], + None => return vec![255; n], + }, + Background::Corners => match corner_color(w, h, px) { + Some(corner) => vec![corner], + None => return vec![255; n], + }, + }; + let mut out: Vec = (0..n) + .map(|i| { + let [r, g, b, _] = px(i); + level([r, g, b], &refs, settings) + }) + .collect(); + if settings.contiguous { + // Out from the edges, through clear pixels and pixels that lose some alpha; the pixels it + // doesn't reach are kept. + let reaches = |out: &[u8], i: usize| out.get(i).is_some_and(|l| *l < 255) || px(i)[3] == 0; + let mut seen = vec![false; n]; + let mut stack: Vec = vec![]; + for i in border(w, h).filter(|&i| reaches(&out, i)) { + if let (Some(s), Ok(at)) = (seen.get_mut(i), u32::try_from(i)) { + *s = true; + stack.push(at); + } + } + while let Some(at) = stack.pop() { + let i = at as usize; + let (x, y) = (i % w, i / w); + let next = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)]; + for j in next.into_iter().flatten() { + if let Some(s) = seen.get_mut(j) + && !*s + && reaches(&out, j) + && let Ok(at) = u32::try_from(j) + { + *s = true; + stack.push(at); + } + } + } + for (l, s) in out.iter_mut().zip(&seen) { + if !*s { + *l = 255; + } + } + } + out +} + +/// The level of a pixel of color `c`: 0 within the tolerance of the nearest of `refs`, 255 from +/// the tolerance plus the softness on, and in proportion between them. +fn level(c: [u16; 3], refs: &[[u16; 3]], settings: &Settings) -> u8 { + let Settings { tolerance, softness, measure, .. } = *settings; + let d = percent(refs.iter().map(|r| gap(c, *r, measure)).fold(f64::INFINITY, f64::min), measure); + if d <= tolerance { + 0 + } else if d >= tolerance + softness { + 255 + } else { + ((d - tolerance) / softness * 255.0).round().clamp(0.0, 255.0) as u8 + } +} + +/// How far apart `a` and `b` are, measured as `measure` says, before `percent` converts it: the +/// squared RGB distance, or the largest difference on one channel. +fn gap(a: [u16; 3], b: [u16; 3], measure: Match) -> f64 { + match measure { + Match::Distance => a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(y)).powi(2)).sum(), + Match::Channels => f64::from(a.iter().zip(b).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)), + } +} + +/// A `gap` as a percentage: of the largest RGB distance (black to white) with distance, or of the +/// channel's range with channels. A difference of `k` 8-bit steps on one channel is exactly +/// `k / 255 × 100` with channels. +fn percent(gap: f64, measure: Match) -> f64 { + match measure { + Match::Distance => gap.sqrt() / (65_535.0 * 3f64.sqrt()) * 100.0, + Match::Channels => gap / 65_535.0 * 100.0, + } +} + +/// The pixels on the edges of a `w` × `h` image, each once. +fn border(w: usize, h: usize) -> impl Iterator { + let top = (0..w).filter(move |_| h > 0); + let bottom = (0..w).filter(move |_| h > 1).map(move |x| (h - 1) * w + x); + let sides = (1..h.saturating_sub(1)).flat_map(move |y| [Some(y * w), (w > 1).then(|| y * w + w - 1)].into_iter().flatten()); + top.chain(bottom).chain(sides) +} + +/// The most common color along an image's edges: its edge pixels grouped by 16 levels a channel, +/// with the clear ones as a group of their own, as the average of the largest group. `None` when +/// the clear edge pixels are at least as many as that group: the background is already cleared. +fn edge_color(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4]) -> Option<[u16; 3]> { + let mut groups: HashMap<[u16; 3], (u64, [u64; 3])> = HashMap::new(); + let mut clear = 0u64; + for i in border(w, h) { + let [r, g, b, a] = px(i); + if a == 0 { + clear += 1; + continue; + } + let e = groups.entry([r >> 12, g >> 12, b >> 12]).or_default(); + e.0 += 1; + for (s, v) in e.1.iter_mut().zip([r, g, b]) { + *s += u64::from(v); + } + } + // Equal groups go by color, so the result doesn't depend on the map's order. + let (_, (n, sums)) = groups.into_iter().max_by(|a, b| a.1.0.cmp(&b.1.0).then(b.0.cmp(&a.0)))?; + if clear >= n { + return None; + } + Some(sums.map(|s| ((s + n / 2) / n.max(1)) as u16)) +} + +/// The average of the four corner pixels of a `w` × `h` image that are not clear (a corner of a +/// one-pixel-wide image counts twice). `None` when all four are clear: the background is already +/// cleared. +fn corner_color(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4]) -> Option<[u16; 3]> { + if w == 0 || h == 0 { + return None; + } + let corners = [0, w - 1, (h - 1) * w, (h - 1) * w + w - 1]; + let (mut n, mut sums) = (0u64, [0u64; 3]); + for [r, g, b, a] in corners.map(px) { + if a > 0 { + n += 1; + for (s, v) in sums.iter_mut().zip([r, g, b]) { + *s += u64::from(v); + } + } + } + (n > 0).then(|| sums.map(|s| ((s + n / 2) / n) as u16)) +} diff --git a/crates/engine/src/cmd/addons/image/tests.rs b/crates/engine/src/cmd/addons/image/tests.rs new file mode 100644 index 000000000..df27180e8 --- /dev/null +++ b/crates/engine/src/cmd/addons/image/tests.rs @@ -0,0 +1,480 @@ +use std::io::Cursor; +use std::sync::Arc; + +use image::{DynamicImage, ImageBuffer, Luma, Rgb}; +use serde_json::{Value, json}; +use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind}; +use vectorcraft_geom::Affine; + +use super::{Background, Match, REMOVE_BACKGROUND as C, Settings, level, levels}; +use crate::Session; +use crate::cmd::fileio::ppi; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [200, 0, 0, 255]; + +/// The command's defaults with `background`. +fn settings(background: Background) -> Settings { + Settings { background, contiguous: true, measure: Match::Distance, tolerance: 10.0, softness: 10.0 } +} + +fn png(img: DynamicImage) -> Vec { + let mut out = Vec::new(); + img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// 12 × 10 pixels: white, with a red block from (3, 3) to (9, 7) that holds one white pixel at +/// (6, 5), which no white edge pixel reaches. +fn framed() -> image::RgbaImage { + image::RgbaImage::from_fn(12, 10, |x, y| { + let block = (3..9).contains(&x) && (3..7).contains(&y); + image::Rgba(if block && (x, y) != (6, 5) { RED } else { WHITE }) + }) +} + +/// A session with `bytes` opened as a document, its image selected. +fn open(bytes: &[u8]) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let id = s.doc().unwrap().selection.objects[0]; + (s, id) +} + +fn image_of(s: &Session, id: NodeId) -> ImageObject { + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("an image") }; + (**im).clone() +} + +fn bytes_of(s: &Session, id: NodeId) -> Arc> { + s.doc().unwrap().doc.images[&image_of(s, id).key].bytes.clone() +} + +fn pixels(s: &Session, id: NodeId) -> DynamicImage { + image::load_from_memory(&bytes_of(s, id)).unwrap() +} + +fn alpha(img: &DynamicImage, x: u32, y: u32) -> u8 { + img.to_rgba8().get_pixel(x, y).0[3] +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn the_edge_colors_are_cleared_where_they_reach_in_from_an_edge() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let before = (image_of(&s, id), undo_len(&s)); + let r = s.execute(C, &json!({})).unwrap(); + assert_eq!(r, json!({"changed": 1, "ids": [id.0], "cleared": 12 * 10 - 6 * 4})); + let out = pixels(&s, id); + assert_eq!(out.color(), image::ColorType::Rgba8); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 11, 9), alpha(&out, 2, 4)), (0, 0, 0), "the white around the block"); + assert_eq!(out.to_rgba8().get_pixel(4, 4).0, RED, "the block"); + assert_eq!(alpha(&out, 6, 5), 255, "white inside the block, which no edge reaches"); + let after = image_of(&s, id); + assert_eq!((after.width, after.height, after.xf), (12, 10, before.0.xf), "the same place and size"); + assert_eq!(undo_len(&s), before.1 + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(image_of(&s, id), before.0); +} + +/// Picked colors, like the other backgrounds, are cleared where they reach in from an edge, and with +/// `contiguous: false` wherever they are. +#[test] +fn picked_colors_are_cleared_from_the_edges_or_wherever_they_are() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + s.execute(C, &json!({"background": "colors", "colors": ["#fff"], "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 255, 255), "not inside the block"); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + s.execute(C, &json!({"background": "colors", "colors": ["#fff"], "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255), "inside the block too; the red stays"); +} + +/// With `contiguous: false` the edge color is cleared inside the subject too. +#[test] +fn the_edge_color_can_be_cleared_wherever_it_is() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let r = s.execute(C, &json!({"contiguous": false})).unwrap(); + assert_eq!(r["cleared"], 12 * 10 - 6 * 4 + 1); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255)); +} + +/// `colors` as one string split by commas and spaces, written as other color params take them. +#[test] +fn picked_colors_can_be_one_string_of_colors() { + for colors in ["#00ff00, ffffff", "green white", "#0f0,#FFF"] { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + s.execute(C, &json!({"background": "colors", "colors": colors, "contiguous": false, "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255), "{colors}"); + } +} + +/// A subject along a whole edge stays: only the most common edge color is the background. +#[test] +fn a_subject_along_one_edge_is_not_the_background() { + let img = image::RgbaImage::from_fn(10, 10, |_, y| image::Rgba(if y >= 7 { RED } else { WHITE })); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + s.execute(C, &json!({})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 5, 0), alpha(&out, 5, 9), alpha(&out, 0, 8)), (0, 255, 255)); +} + +/// The preview steps of one interaction, as the dialog's are, decode an image once and give what a +/// run on freshly decoded pixels gives. The pixels are kept only until the interaction ends (OK or +/// Cancel), and a run that is not a preview step keeps none. +#[test] +fn preview_steps_on_the_same_pixels_decode_them_once() { + let decodes = || super::DECODES.with(std::cell::Cell::get); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let start = decodes(); + let p = json!({"background": "colors", "colors": "#c80000", "contiguous": false, "tolerance": 0, "softness": 0}); + s.begin_interaction("Remove Background").unwrap(); + s.preview(C, &json!({"tolerance": 0, "softness": 0})).unwrap(); + s.preview(C, &json!({"tolerance": 20, "softness": 0})).unwrap(); + s.preview(C, &p).unwrap(); + assert_eq!((decodes() - start, s.decoded_images.len()), (1, 1), "decoded once for three preview steps"); + s.commit_interaction().unwrap(); + assert_eq!(s.decoded_images.len(), 0, "OK lets the pixels go"); + let (mut fresh, fresh_id) = open(&png(DynamicImage::ImageRgba8(framed()))); + fresh.execute(C, &p).unwrap(); + assert_eq!((decodes() - start, fresh.decoded_images.len()), (2, 0), "a run that is not a preview step keeps nothing"); + assert_eq!(bytes_of(&s, id), bytes_of(&fresh, fresh_id), "the same result"); + assert_eq!((alpha(&pixels(&s, id), 4, 4), alpha(&pixels(&s, id), 0, 0)), (0, 255), "the red cleared, the white kept"); + s.execute("edit.undo", &json!({})).unwrap(); + s.begin_interaction("Remove Background").unwrap(); + s.preview(C, &json!({})).unwrap(); + assert_eq!(s.decoded_images.len(), 1); + s.cancel_interaction().unwrap(); + assert_eq!(s.decoded_images.len(), 0, "Cancel lets the pixels go"); + s.execute(C, &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute(C, &json!({})).unwrap(); + assert_eq!(decodes() - start, 5, "each run that is not a preview step decodes"); +} + +/// Pixels a preview step takes from the step before keep what they were decoded with: a 16-bit gray +/// image with an ICC profile comes out as a run on freshly decoded pixels gives it. +#[test] +fn reused_pixels_keep_their_depth_gray_and_profile() { + use image::{ImageDecoder as _, ImageEncoder as _}; + let icc = b"a profile's bytes".to_vec(); + let gray16 = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 7_000u16 } else { 61_000 }])); + let mut bytes = Vec::new(); + let mut encoder = image::codecs::png::PngEncoder::new(&mut bytes); + encoder.set_icc_profile(icc.clone()).unwrap(); + DynamicImage::ImageLuma16(gray16).write_with_encoder(encoder).unwrap(); + let decodes = || super::DECODES.with(std::cell::Cell::get); + let (mut s, id) = open(&bytes); + let start = decodes(); + s.begin_interaction("Remove Background").unwrap(); + s.preview(C, &json!({"tolerance": 0, "softness": 0})).unwrap(); + s.preview(C, &json!({})).unwrap(); + assert_eq!(decodes() - start, 1, "the second step took the pixels of the first"); + let (mut fresh, fresh_id) = open(&bytes); + fresh.execute(C, &json!({})).unwrap(); + assert_eq!(bytes_of(&s, id), bytes_of(&fresh, fresh_id), "the same result"); + let DynamicImage::ImageLumaA16(out) = pixels(&s, id) else { panic!("16-bit gray with alpha") }; + assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535])); + let result = bytes_of(&s, id); + let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(result.as_slice())).unwrap(); + assert_eq!(decoder.icc_profile().unwrap(), Some(icc)); +} + +/// Once the background is cleared, most edge pixels are clear and a second run clears nothing: the +/// subject's color along an edge is not taken for the background. +#[test] +fn a_second_run_leaves_the_subject() { + let img = image::RgbaImage::from_fn(10, 10, |x, y| { + image::Rgba(if y >= 7 { + RED + } else if (4..6).contains(&x) && (3..5).contains(&y) { + [0, 0, 200, 255] + } else { + WHITE + }) + }); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + s.execute(C, &json!({})).unwrap(); + let (first, steps) = (image_of(&s, id), undo_len(&s)); + let r = s.execute(C, &json!({})).unwrap(); + assert_eq!(r, json!({"changed": 0, "ids": [], "cleared": 0})); + assert_eq!((image_of(&s, id), undo_len(&s)), (first, steps), "no change and no undo step"); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 5, 0), alpha(&out, 5, 9), alpha(&out, 4, 3)), (0, 255, 255), "the red and the blue stay"); +} + +#[test] +fn tolerance_and_softness_set_how_near_a_color_is_cleared() { + // White; 1.96 % from white; 21.6 % from white. + let px = [WHITE, [250, 250, 250, 255], [200, 200, 200, 255]]; + let at = |i: usize| px[i].map(|v| u16::from(v) * 257); + let run = |tolerance, softness| levels(3, 1, &at, &Settings { tolerance, softness, ..settings(Background::Colors(vec![[255; 3]])) }); + assert_eq!(run(0.0, 0.0), [0, 255, 255], "only white itself at 0"); + assert_eq!(run(2.0, 0.0), [0, 0, 255]); + assert_eq!(run(0.0, 10.0), [0, 50, 255], "1.96 % into a 10 % fade"); + assert_eq!(run(100.0, 0.0), [0, 0, 0], "everything at 100"); +} + +#[test] +fn a_result_with_no_pixel_left_is_refused_and_changes_nothing() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let before = (image_of(&s, id), undo_len(&s)); + let e = s.execute(C, &json!({"background": "colors", "colors": "#ffffff", "tolerance": 100})).unwrap_err(); + assert!(e.to_string().contains("every pixel matches the background"), "{e}"); + assert_eq!((image_of(&s, id), undo_len(&s)), before); +} + +#[test] +fn an_image_whose_pixels_do_not_change_is_left_with_no_undo_step() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let before = (image_of(&s, id), undo_len(&s)); + let r = s.execute(C, &json!({"background": "colors", "colors": "#00ff00", "tolerance": 0, "softness": 0})).unwrap(); + assert_eq!(r, json!({"changed": 0, "ids": [], "cleared": 0})); + assert_eq!((image_of(&s, id), undo_len(&s)), before); + // An image whose edges and corners are already clear has no edge or corner color to clear, + // whatever color the clear pixels hold. + let ring = + image::RgbaImage::from_fn(6, 6, |x, y| image::Rgba(if (1..5).contains(&x) && (1..5).contains(&y) { WHITE } else { [255, 255, 255, 0] })); + for p in [json!({}), json!({"background": "corners", "contiguous": false})] { + let (mut s, _) = open(&png(DynamicImage::ImageRgba8(ring.clone()))); + assert_eq!(s.execute(C, &p).unwrap()["changed"], 0, "{p}"); + } +} + +/// The corners' color: 40 × 30 pixels of light gray around a red block, cleared within 5 steps on +/// each channel (`match: channels`, a tolerance of `5 / 255 × 100`, no softness), from the edges +/// or wherever it is. +#[test] +fn the_corners_color_is_cleared() { + let img = image::RgbaImage::from_fn(40, 30, |x, y| { + image::Rgba(if (10..30).contains(&x) && (8..22).contains(&y) { [200, 40, 40, 255] } else { [240, 240, 240, 255] }) + }); + for contiguous in [false, true] { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img.clone()))); + let p = json!({"background": "corners", "contiguous": contiguous, "match": "channels", "tolerance": 5.0 / 255.0 * 100.0, "softness": 0}); + let r = s.execute(C, &p).unwrap(); + assert_eq!(r["cleared"], 40 * 30 - 14 * 20, "{p}"); + let out = pixels(&s, id).to_rgba8(); + assert_eq!((out.get_pixel(0, 0).0[3], out.get_pixel(15, 15).0), (0, [200, 40, 40, 255]), "{p}"); + } + // A clear corner is left out of the average: the other three are white, and white is cleared. + let mut img = image::RgbaImage::from_pixel(8, 8, image::Rgba(WHITE)); + img.put_pixel(0, 0, image::Rgba([0; 4])); + img.put_pixel(4, 4, image::Rgba(RED)); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + let r = s.execute(C, &json!({"background": "corners", "tolerance": 0, "softness": 0})).unwrap(); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 7, 7), alpha(&out, 3, 3), alpha(&out, 4, 4)), (0, 0, 255)); + assert_eq!(r["cleared"], 8 * 8 - 2, "the pixels made clear, not the corner that was clear already"); +} + +/// One picked color matched by channel at a tolerance of 0: cleared wherever it is, and a pixel a +/// long way from it kept. +#[test] +fn one_picked_color_is_cleared_by_channel() { + let mut img = image::RgbaImage::from_pixel(4, 4, image::Rgba([10, 20, 30, 255])); + img.put_pixel(2, 2, image::Rgba([250, 250, 250, 255])); + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img))); + let p = json!({"background": "colors", "colors": "#0a141e", "contiguous": false, "match": "channels", "tolerance": 0, "softness": 0}); + assert_eq!(s.execute(C, &p).unwrap()["cleared"], 15); + let out = pixels(&s, id); + assert_eq!((alpha(&out, 0, 0), alpha(&out, 2, 2)), (0, 255)); +} + +/// With `match: channels`, a tolerance of `t / 255 × 100` clears a color `k` 8-bit steps from the +/// background on one channel exactly when `k ≤ t`, for every `t` and `k` from 0 to 255; with +/// `distance` the same color is nearer when the others match. +#[test] +fn channels_match_the_largest_difference_on_one_channel() { + for t in 0..=255u16 { + let s = Settings { measure: Match::Channels, tolerance: f64::from(t) / 255.0 * 100.0, softness: 0.0, ..settings(Background::Edges) }; + for k in 0..=255u16 { + for c in [[k * 257, 0, 0], [0, k * 257, 0], [0, 0, k * 257]] { + assert_eq!(level(c, &[[0; 3]], &s) == 0, k <= t, "t {t}, k {k}"); + } + } + } + let at = |measure| { + level([0, 40 * 257, 0], &[[0; 3]], &Settings { measure, tolerance: 32.0 / 255.0 * 100.0, softness: 0.0, ..settings(Background::Edges) }) + }; + assert_eq!((at(Match::Channels), at(Match::Distance)), (255, 0), "40 steps on green: past 32 on one channel; 9 % of the RGB distance"); +} + +#[test] +fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() { + let deep = ImageBuffer::from_fn(8, 8, |x, y| Rgb(if (2..6).contains(&x) && (2..6).contains(&y) { [51_400, 0, 1] } else { [65_535; 3] })); + let (mut s, id) = open(&png(DynamicImage::ImageRgb16(deep))); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageRgba16(out) = pixels(&s, id) else { panic!("16-bit RGBA") }; + assert_eq!(out.get_pixel(0, 0).0[3], 0); + assert_eq!(out.get_pixel(3, 3).0, [51_400, 0, 1, 65_535], "16-bit samples kept as they were"); + // Samples one 8-bit step apart are told apart: only the background is cleared at 0. + let near = ImageBuffer::from_fn(4, 4, |x, y| Rgb(if (1..3).contains(&x) && (1..3).contains(&y) { [65_280; 3] } else { [65_535; 3] })); + let (mut s, id) = open(&png(DynamicImage::ImageRgb16(near))); + s.execute(C, &json!({"tolerance": 0, "softness": 0})).unwrap(); + let DynamicImage::ImageRgba16(out) = pixels(&s, id) else { panic!("16-bit RGBA") }; + assert_eq!((out.get_pixel(0, 0).0[3], out.get_pixel(1, 1).0[3]), (0, 65_535)); + let gray = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }])); + let (mut s, id) = open(&png(DynamicImage::ImageLuma8(gray))); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([240, 0], [30, 255])); + let gray16 = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 7_000u16 } else { 61_000 }])); + let (mut s, id) = open(&png(DynamicImage::ImageLuma16(gray16))); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageLumaA16(out) = pixels(&s, id) else { panic!("16-bit gray with alpha") }; + assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535])); +} + +/// The ICC profile the colors are in stays with them. +#[test] +fn the_color_profile_is_kept() { + use image::{ImageDecoder as _, ImageEncoder as _}; + let icc = b"a profile's bytes".to_vec(); + let mut bytes = Vec::new(); + let mut encoder = image::codecs::png::PngEncoder::new(&mut bytes); + encoder.set_icc_profile(icc.clone()).unwrap(); + DynamicImage::ImageRgba8(framed()).write_with_encoder(encoder).unwrap(); + let blob = ImageBlob::png(bytes); + let out = super::clear(&blob, &settings(Background::Edges), None).unwrap().unwrap(); + let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(out.blob.bytes.as_slice())).unwrap(); + assert_eq!(decoder.icc_profile().unwrap(), Some(icc)); +} + +/// The resolution the file declares stays with the pixels, so a later Replace or relink sizes the +/// image as before. +#[test] +fn the_resolution_is_kept() { + let bytes = ppi::with_png_resolution(&png(DynamicImage::ImageRgba8(framed())), (300.0, 150.0)); + let (mut s, id) = open(&bytes); + s.execute(C, &json!({})).unwrap(); + let (x, y) = ppi::resolution(&bytes_of(&s, id)).expect("a resolution"); + assert_eq!((x.round(), y.round()), (300.0, 150.0)); +} + +#[test] +fn a_cmyk_image_is_refused() { + let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [0, 0, 0, 0].repeat(8)).unwrap(); + let tiff = ImageBlob::cmyk_tiff(&inks).unwrap(); + let (mut s, id) = open(&tiff.bytes); + let before = (image_of(&s, id), undo_len(&s)); + let e = s.execute(C, &json!({})).unwrap_err(); + assert!(e.to_string().contains("a CMYK image has no transparency to set"), "{e}"); + assert_eq!((image_of(&s, id), undo_len(&s)), before); +} + +/// A linked image of `blob`, `width` × `height` pixels as the object records it, beside the open +/// document's image. +fn place_linked(s: &mut Session, key: &str, blob: ImageBlob, (width, height): (u32, u32)) -> NodeId { + s.edit("Place", |d, sel| { + d.images.insert(key.into(), blob); + let id = d.alloc_id(); + let im = ImageObject { + key: key.into(), + width, + height, + xf: Default::default(), + link: Some(LinkInfo::new("/nowhere/art.png")), + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +#[test] +fn a_linked_image_is_embedded_and_one_that_shows_its_preview_is_refused() { + let bytes = png(DynamicImage::ImageRgba8(framed())); + let (mut s, _) = open(&bytes); + let linked = place_linked(&mut s, "linked", ImageBlob::png(bytes.clone()), (12, 10)); + let clip = vectorcraft_doc::links::PlacementOptions { clip: true, ..Default::default() }; + s.edit("Placement", |d, _| { + if let Some(NodeKind::Image(im)) = d.node_mut(linked).map(|n| &mut n.kind) { + im.placement = clip; + } + Ok(()) + }) + .unwrap(); + let r = s.execute(C, &json!({"ids": [linked.0, linked.0]})).unwrap(); + assert_eq!(r, json!({"changed": 1, "ids": [linked.0], "cleared": 12 * 10 - 6 * 4}), "an image named twice changes once"); + let im = image_of(&s, linked); + assert_eq!((im.link, alpha(&pixels(&s, linked), 0, 0)), (None, 0), "embedded, with its background cleared"); + assert_eq!(im.placement, clip, "its Placement Options kept, as Embed keeps them"); + // Pixels the file wasn't read for: the preview, as an image whose file is missing shows it. + let preview = Arc::new(bytes); + let missing = place_linked(&mut s, "missing", ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) }, (12, 10)); + let before = (image_of(&s, missing), undo_len(&s)); + let e = s.execute(C, &json!({})).unwrap_err(); + assert!(e.to_string().contains("the linked image shows its preview: embed or relink it first"), "{e}"); + assert_eq!((image_of(&s, missing), undo_len(&s)), before); +} + +/// An object whose pixels were read at another size than it records keeps its place and size. +#[test] +fn an_image_read_at_another_size_keeps_its_place_and_size() { + let bytes = png(DynamicImage::ImageRgba8(framed())); + let (mut s, _) = open(&bytes); + let id = place_linked(&mut s, "linked", ImageBlob::png(bytes), (24, 20)); + s.execute(C, &json!({"ids": [id.0]})).unwrap(); + let im = image_of(&s, id); + assert_eq!((im.width, im.height, im.xf), (12, 10, Affine::scale_non_uniform(2.0, 2.0))); +} + +#[test] +fn bad_params_are_refused_and_change_nothing() { + let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed()))); + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let before = (image_of(&s, id), undo_len(&s)); + // With `ids`, the command is available when one of them is an image. + let not_image = format!("object {rect} is not an image"); + let cases: [(Value, &str); 18] = [ + (json!({"background": "sky"}), "`background` must be edges, corners or colors"), + (json!({"background": 5}), "`background` must be a string"), + (json!({"match": "rgb"}), "`match` must be distance or channels"), + (json!({"match": true}), "`match` must be a string"), + (json!({"contiguous": "yes"}), "`contiguous` must be true or false"), + (json!({"contiguous": 1}), "`contiguous` must be true or false"), + (json!({"tolerance": 101}), "`tolerance` must be from 0 to 100"), + (json!({"softness": -1}), "`softness` must be from 0 to 100"), + (json!({"tolerance": "5"}), "`tolerance` must be a number"), + (json!({"background": "colors"}), "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue"), + (json!({"background": "colors", "colors": []}), "`colors` must list"), + (json!({"background": "colors", "colors": "#12"}), "`colors` must list"), + (json!({"background": "colors", "colors": "#ffffff80"}), "`colors` must list"), + (json!({"colors": ["#ffffff", 7]}), "`colors` must list"), + (json!({"background": "colors", "colors": vec!["#000"; 17]}), "`colors` must list"), + (json!({"background": "colors", "colors": "#000 ".repeat(100_000)}), "`colors` must list"), + (json!({"ids": [id.0, rect]}), ¬_image), + (json!({"ids": [rect]}), "select an image"), + ]; + for (p, msg) in cases { + let e = s.execute(C, &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{p}: {e}"); + } + assert_eq!((image_of(&s, id), undo_len(&s)), before); +} + +#[test] +fn an_image_too_large_to_change_is_refused_before_it_is_decoded() { + let run = |w: u32, h: u32| { + let (mut s, _) = open(&crate::tests_linkspanel::png_header(w, h)); + s.execute(C, &json!({})).unwrap_err().to_string() + }; + // As gray samples, RGBA worked on and kept for the PNG, and the work: 19 bytes a pixel. + let over = run(10_632, 10_631); + assert!(over.contains("an image of 10632 × 10631 pixels is too large to change"), "{over}"); + // Within the allowance it is decoded, and its missing rows can't be read. + let within = run(10_631, 10_631); + assert!(within.contains("the image's pixels can't be read"), "{within}"); +} diff --git a/crates/engine/src/cmd/addons/shapes.rs b/crates/engine/src/cmd/addons/shapes.rs index 24d0e4c3a..87549e369 100644 --- a/crates/engine/src/cmd/addons/shapes.rs +++ b/crates/engine/src/cmd/addons/shapes.rs @@ -131,6 +131,8 @@ fn insert_custom(s: &mut Session, label: &str, live: Option, cs: Cust && let Some(n) = std::sync::Arc::make_mut(&mut st.doc).node_mut(NodeId(raw_id)) { cs.write_to_node(n); + // A shape drawn pixel-aligned (Align New Objects to Pixel Grid) snaps its stored outline. + super::super::object::snap_if_aligned(n); } Ok(val) } @@ -270,6 +272,7 @@ fn box3d(s: &mut Session, p: &Value) -> Result { && let Some(n) = std::sync::Arc::make_mut(&mut st.doc).node_mut(NodeId(raw_id)) { cs.write_to_node(n); + super::super::object::snap_if_aligned(n); } Ok(val) } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 2dfeb497d..1f64e582a 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -19,12 +19,12 @@ //! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the //! image as its Contents row says; new ones go right below that row. -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::appearance::SharedStack; -use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer}; use vectorcraft_geom::Rect; use super::edit::{outermost, roots_of, selected_roots}; @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {ids}", has_doc, copy_from ), @@ -1198,7 +1198,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } -/// Sample enabled attributes, refusing object transparency that a text range cannot store. +/// Sample enabled attributes with transform-aware character dimensions and atomic validation. fn copy_from(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.copyFrom"; let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; @@ -1270,9 +1270,6 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?, }; let picked = Picked::of(&node); - if !reverse && !defaults_only { - s.eyedropper_sample = Some(node); - } // The highlighted characters' type objects and ranges: after Select All in threaded text, all // the text of every frame of the story, except a frame the attributes come from, in place of // the object selection. @@ -1285,6 +1282,15 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { Some((id, start, end)) if targets.contains(&id) => vec![(id, Some((start, end)))], _ => vec![], }; + let target_ids = || targets.iter().copied().filter(|_| ranges.is_empty()).chain(ranges.iter().map(|(id, _)| *id)); + // Prepare every dimensional copy before changing defaults or the remembered sample. A bad + // source or target transform must refuse the entire pick, including a multi-frame range. + let characters = if attrs.character && !append { + let d = &s.doc()?.doc; + copied_character_styles(&node, target_ids().filter_map(|id| d.node(id)))? + } else { + HashMap::new() + }; // Character styles cannot represent object transparency. Refuse before even changing // paint defaults; applying it to the object would also alter the unselected characters. if attrs.appearance.transparency { @@ -1298,11 +1304,33 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { } } } - if !reverse { - picked.to_defaults(s, &attrs.appearance); - } + // The session's bounds check follows the selection. Check text targets reached through + // explicit IDs, a reverse pick, or a highlighted story as well. Paragraph changes can + // move point text even when character copying is disabled. + let text_ids: Vec<_> = if attrs.paragraph && !append && picked.text.is_some() { + let d = &s.doc()?.doc; + target_ids().filter(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))).collect() + } else { + characters.keys().copied().collect() + }; + let check_text_bounds = |d: &Document| { + if !text_ids.is_empty() && !crate::sanity::objects_in_range(d, &text_ids) { + return Err(bad(C, "sampled text would exceed the canvas (coordinates out of range)")); + } + Ok(()) + }; + // Remember the pick only after the document edit has passed its own validation. + let finish = |s: &mut Session, ids: &[NodeId]| { + if !reverse { + if !defaults_only { + s.eyedropper_sample = Some(node); + } + picked.to_defaults(s, &attrs.appearance); + } + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) + }; if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() { - return Ok(json!({ "ids": [] })); + return Ok(finish(s, &[])); } if !ranges.is_empty() { let ids: Vec = ranges.iter().map(|(f, _)| *f).collect(); @@ -1312,24 +1340,26 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let (start, end) = range.unwrap_or((0, vectorcraft_text::edit::runs_len(&t.runs))); let placed = picked.placed(Some(t.local_bounds())); - vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs)); + vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| Picked::run(st, &placed, &attrs, characters.get(&id))); if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) { t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone()); } super::typecmd::refresh_bounds(t); } + check_text_bounds(d)?; Ok(count_changed(d, &before, &ids)) })?; - return Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })); + return Ok(finish(s, &ids)); } // When every target comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, "Eyedropper", |d| { for id in &targets { if let Some(n) = d.node_mut(*id) { - picked.apply(n, &attrs, append); + picked.apply(n, &attrs, append, characters.get(id)); } } + check_text_bounds(d)?; // The paints the pick wrote, with a weight going with the paint of its stroke, as in `set_item`. `append` adds // only the colors of the visible rows the source has. let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke); @@ -1369,7 +1399,59 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let object = super::graph::capture_series_object_opacity(d, &objects); Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object)) })?; - Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) + Ok(finish(s, &targets)) +} + +/// Read source typography in document space, then compensate for each target's transform. +/// Paint stays in its existing sampling path; character scale never changes object placement. +fn copied_character_styles<'a>(source: &Node, targets: impl Iterator) -> Result> { + const C: &str = "appearance.copyFrom"; + let mut styles = HashMap::new(); + let NodeKind::Text(source) = &source.kind else { return Ok(styles) }; + let Some(run) = source.runs.first() else { return Ok(styles) }; + let mut targets = targets + .filter_map(|n| match &n.kind { + NodeKind::Text(t) => Some((n.id, t)), + _ => None, + }) + .peekable(); + if targets.peek().is_none() { + return Ok(styles); + } + let effective = source.effective_char_style(&run.style).ok_or_else(|| bad(C, "source text transform is collapsed or unrepresentable"))?; + if !copied_dimensions_fit(&run.style, &effective) { + return Err(bad(C, "character dimensions cannot be represented with the source text transform")); + } + for (id, text) in targets { + let scale = text.style_scale().ok_or_else(|| bad(C, "target text transform is collapsed or unrepresentable"))?; + let mut local = effective.clone(); + local.size /= scale.points; + local.leading = local.leading.map(|v| v / scale.points); + local.baseline_shift /= scale.points; + local.tcy_up_down /= scale.points; + local.tcy_left_right /= scale.points; + local.h_scale /= scale.horizontal; + // As with dimensional type commands, reject overflow and underflow before layout. + if !copied_dimensions_fit(&effective, &local) { + return Err(bad(C, "character dimensions cannot be represented with the target text transform")); + } + styles.insert(id, local); + } + Ok(styles) +} + +/// Bound each converted type dimension and refuse loss to zero before layout reads it. +fn copied_dimensions_fit(from: &CharStyle, to: &CharStyle) -> bool { + [ + (from.size, to.size), + (from.leading.unwrap_or(0.0), to.leading.unwrap_or(0.0)), + (from.baseline_shift, to.baseline_shift), + (from.tcy_up_down, to.tcy_up_down), + (from.tcy_left_right, to.tcy_left_right), + (from.h_scale, to.h_scale), + ] + .iter() + .all(|(from, to)| to.is_finite() && to.abs() <= crate::MAX_COORD && (*from == 0.0 || *to != 0.0)) } /// What the Eyedropper picks up from an object. @@ -1422,8 +1504,8 @@ impl Picked { /// Copy character attributes and the enabled paint attributes through the same path for /// whole text objects and highlighted character ranges. - fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) { - if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) { + fn run(st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs, character: Option<&CharStyle>) { + if let Some(cs) = character { *st = CharStyle { fill: std::mem::take(&mut st.fill), stroke: std::mem::take(&mut st.stroke), @@ -1439,7 +1521,7 @@ impl Picked { /// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its /// stack instead. Type takes the paints on its characters, and character and paragraph /// attributes from type. - fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool) { + fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool, character: Option<&CharStyle>) { let ap = &a.appearance; if ap.transparency { n.opacity = self.opacity; @@ -1456,7 +1538,7 @@ impl Picked { let placed = self.placed(Some(t.local_bounds())); let text = self.text.as_ref(); for r in &mut t.runs { - self.run(&mut r.style, &placed, a); + Self::run(&mut r.style, &placed, a, character); } if let Some((_, para)) = text.filter(|_| a.paragraph) { // The source's first paragraph's attributes, on every paragraph. diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index d39244924..60a6b8137 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], points?: [[x,y]…] (a drag polyline starting at point; 1 to 4096 finite canvas points; fills every crossed face of the same group), continuation?: [[[x,y]…]…] (further path chunks of 1 to 4096 finite canvas points each; each starts at the preceding chunk's last point; requires points), color?|swatch?|gradient?|none? (default: current fill; a color in #rrggbbaa or #rgba is refused), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", has_doc, lp_fill ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 207182d40..3f9b62df2 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -272,6 +272,35 @@ pub(super) fn lp_gap_options(s: &mut Session, p: &Value) -> Result { pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { const C: &str = "livePaint.fill"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + let parse_chunk = |input: &Value, start: Point| -> Result> { + let values = input + .as_array() + .filter(|points| !points.is_empty() && points.len() <= b::LP_FILL_CHUNK_POINTS) + .ok_or_else(|| bad(C, "each path chunk must contain 1 to 4096 points"))?; + let points: Vec<_> = values + .iter() + .map(|point| { + let pair = point.as_array().filter(|pair| pair.len() == 2).ok_or_else(|| bad(C, "points must be [x,y] pairs"))?; + let x = pair.first().and_then(Value::as_f64).ok_or_else(|| bad(C, "point x must be a number"))?; + let y = pair.get(1).and_then(Value::as_f64).ok_or_else(|| bad(C, "point y must be a number"))?; + let point = Point::new(x, y); + if !finite(point) { + return Err(bad(C, "points must be finite canvas points")); + } + Ok(point) + }) + .collect::>()?; + if points.first() != Some(&start) { + return Err(bad(C, "path chunks must share their boundary point")); + } + Ok(points) + }; + let points = p.get("points"); + let continuation = match p.get("continuation") { + None => &[][..], + Some(input) if points.is_some() => input.as_array().ok_or_else(|| bad(C, "continuation must be an array of path chunks"))?.as_slice(), + Some(_) => return Err(bad(C, "continuation requires points")), + }; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); let group = id_param(p, "group"); @@ -302,7 +331,33 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { .and_then(|n| b::visible_face_at(n, pt)) .map(|f| f.id) .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; - d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); + let mut targets = std::collections::BTreeSet::from([fid]); + if let Some(points) = points { + let n = d.node(gid).ok_or(EngineError::NoNode(gid))?; + // Use the existing region-crossing probes, so even a face narrower than the distance + // between pointer events is filled. Reversing matches visible_face_at's front first. + let faces: Vec<_> = b::faces(n).into_iter().rev().filter(|f| f.visible).collect(); + let mut face_ids = vec![]; + let mut regions = vec![]; + for f in faces { + if let Some(path) = f.path_data() + && let Some(bounds) = path.bounds() + { + face_ids.push(f.id); + regions.push((po::Region { path: path.clone(), sources: vec![] }, path.to_bezpath(), bounds)); + } + } + let map = b::BuilderMap { regions, ..Default::default() }; + let mut last = pt; + for input in std::iter::once(points).chain(continuation) { + let chunk = parse_chunk(input, last)?; + last = chunk.last().copied().unwrap_or(last); + targets.extend(map.hits_along(&chunk, None).regions.iter().filter_map(|i| face_ids.get(*i).copied())); + } + } + for target in targets { + d.node_mut(target).ok_or(EngineError::NoNode(target))?.appearance.set_fill(paint.clone()); + } Ok((gid, fid)) })?; s.remember_paint(&paint); diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index f265c3ff3..3c829e8a3 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -162,7 +162,8 @@ fn constrained_turn(s: &Session, rotation: f64) -> f64 { } } -/// [`insert_look`] with the new object's bounding box turned to `bbox_angle`. +/// [`insert_look`] with the new object's bounding box turned to `bbox_angle`. A new path or +/// compound path is pixel-aligned while the document's Align New Objects to Pixel Grid is on. fn insert_look_turned( s: &mut Session, label: &str, @@ -180,6 +181,7 @@ fn insert_look_turned( let id = d.alloc_id(); let mut n = Node::new(id, kind); look.apply(d, &mut n); + super::object::align_new_object(d, &mut n); n.name = name; n.bbox_angle = bbox_angle; let mut into = None; @@ -512,6 +514,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { n.name = Some(name.into()); n.blend = BlendMode::Screen; n.opacity = op; + super::object::align_new_object(d, &mut n); std::sync::Arc::new(n) }) .collect(); @@ -603,6 +606,7 @@ fn grid_group(s: &mut Session, label: &str, (paths, bbox_angle): (Vec, Node::path(d.alloc_id(), pd, Appearance::default()) }; look.apply(d, &mut n); + super::object::align_new_object(d, &mut n); n.bbox_angle = bbox_angle; std::sync::Arc::new(n) }) diff --git a/crates/engine/src/cmd/docsetup.rs b/crates/engine/src/cmd/docsetup.rs index 894d9b90a..dadafaa1b 100644 --- a/crates/engine/src/cmd/docsetup.rs +++ b/crates/engine/src/cmd/docsetup.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Document Setup", [], None, - "{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedGlyphs?: bool (Highlight Substituted Fonts is a preference: prefs.set highlightSubstitutedFonts; a highlightSubstitutedFonts key is ignored and the reply carries a warning),gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour; #rrggbbaa and #rgba are refused), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup", + "{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedGlyphs?: bool (Highlight Substituted Fonts is a preference: prefs.set highlightSubstitutedFonts; a highlightSubstitutedFonts key is ignored and the reply carries a warning),gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour; #rrggbbaa and #rgba are refused), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), alignNewObjectsToPixelGrid?: bool (Align New Objects to Pixel Grid: paths and compound paths drawn from then on are pixel-aligned, as object.setPixelAligned; art already in the document, pasted, duplicated or placed keeps its own setting), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup", has_doc, setup )] @@ -46,6 +46,7 @@ pub(crate) fn setup_json(d: &Document) -> Value { "smallCapsSize": s.small_caps_size, "exportText": s.export_text.id(), "backgroundContents": s.background.id(), + "alignNewObjectsToPixelGrid": s.align_new_objects_to_pixel_grid, }) } @@ -140,6 +141,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value "smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, vectorcraft_doc::setup::MAX_SMALL_CAPS)?, "exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?, "backgroundContents" => s.background = background_param(v, C)?, + "alignNewObjectsToPixelGrid" => s.align_new_objects_to_pixel_grid = flag(k, v)?, // Read-only values `document.setup` reports. "gridColorsName" | "warnings" => {} _ => return Err(bad(C, format!("unknown setting `{k}`"))), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1632787eb..108b8593c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 935d5e9dc..1d39e2b4e 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", + "{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}", has_selection, expand ), @@ -1283,9 +1283,12 @@ fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32 /// cover the box (linear: along the vector `t0..t1` and across it `s0..s1` in points; radial: /// `0..t1`, each level's ellipse centred on the way from the focal point to the start). /// A conic gradient also has its placement, the box and the radius in its frame that reaches -/// past the box's corners ([`Frame::pieces`] and [`Frame::mesh`] go around its center). +/// past the box's corners ([`Frame::pieces`] and [`Frame::mesh`] go around its center). A +/// diamond gradient has its frame ([`vectorcraft_color::GradientGeom::diamond_frame`]; `None` +/// when it has no extent), the box and the level of the square that reaches its corners. struct Frame { conic: Option<(vectorcraft_color::GradientGeom, Rect, f64)>, + diamond: Option<(Option, Rect, f64)>, radial: bool, start: Point, focal: Point, @@ -1323,7 +1326,11 @@ impl Frame { let reach = if reach.is_finite() { reach * 1.01 / (std::f64::consts::PI * CONIC_MESH_GAP).cos() } else { 1.0 }; (geom, bounds, reach) }); - Self { conic, radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 } + let diamond = (g.gradient.kind == GradientKind::Diamond).then(|| { + let reach = corners.iter().map(|c| geom.param_at(GradientKind::Diamond, *c)).fold(0.0f64, f64::max); + (geom.diamond_frame(), bounds, if reach.is_finite() { reach.max(1e-6) * 1.01 } else { 1.0 }) + }); + Self { conic, diamond, radial, start: geom.start, focal: geom.focal_point(), along, across, t0, t1, s0, s1 } } /// Linear: the point at parameter `t` along and `s` across. Radial: the point at parameter `t` @@ -1351,6 +1358,34 @@ impl Frame { }; return GradientMesh::from_grid(&us, &[0.0, 1.0], &at, &|_, c, _| br[c].1); } + if let Some((frame, b, reach)) = &self.diamond { + let Some(frame) = frame else { + // No extent: the whole box is the last stop. + let last = g.sample_keep_model(1.0); + let at = |u: f64, v: f64| Point::new(b.x0 + u * b.width(), b.y0 + v * b.height()); + return GradientMesh::from_grid(&[0.0, 1.0], &[0.0, 1.0], &at, &|_, _, _| last); + }; + // Rings of squares where the color changes, with columns at the corners: each patch + // lies between two rings along one side, where the level runs straight across it. + let br = breaks(g, 0.0, *reach, f64::INFINITY); + let ts: Vec = br.iter().map(|(t, _)| t / reach).collect(); + let at = |u: f64, v: f64| *frame * (diamond_perimeter(u).to_vec2() * (v * reach)).to_point(); + const CORNERS: [f64; 5] = [0.0, 0.25, 0.5, 0.75, 1.0]; + let mut mesh = GradientMesh::from_grid(&CORNERS, &ts, &at, &|r, _, _| br[r].1); + // Every column is at a corner, where `from_grid`'s tangent is the mean of the two + // sides: each handle along a ring points a third of the way along its own side, so + // the sides stay straight. + let side = |v: f64, from: usize, to: Option<&f64>| to.map_or(vectorcraft_geom::Vec2::ZERO, |&u| (at(u, v) - at(CORNERS[from], v)) / 3.0); + for (r, &v) in ts.iter().enumerate() { + for c in 0..CORNERS.len() { + if let Some(point) = mesh.points.get_mut(r * CORNERS.len() + c) { + point.handles[0] = side(v, c, CORNERS.get(c + 1)); + point.handles[1] = side(v, c, c.checked_sub(1).and_then(|i| CORNERS.get(i))); + } + } + } + return mesh; + } // Rings as close as the columns around them, so the mesh draws smoothly. let gap = if self.radial { (self.t1 - self.t0) / RADIAL_MESH_COLS as f64 } else { f64::INFINITY }; let br = breaks(g, self.t0, self.t1, gap); @@ -1367,9 +1402,9 @@ impl Frame { /// `n` solid shapes covering the box in paint order, each coloured as the gradient in the /// middle of its band: for a linear gradient, rectangles from each band's start to the far /// end, for a radial one, nested ellipses (largest first), for a conic one, wedges around the - /// center from each band's start to the seam. Each paints over the ones below except its - /// band, so no seam shows between bands. With translucent stops each shape is just its band, - /// so nothing doubles up. + /// center from each band's start to the seam, for a diamond one, nested squares (largest + /// first). Each paints over the ones below except its band, so no seam shows between bands. + /// With translucent stops each shape is just its band, so nothing doubles up. fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> { let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); if let Some((geom, b, _)) = &self.conic { @@ -1379,6 +1414,29 @@ impl Frame { .map(|(pts, t)| (PathData::single(SubPath::polyline(&pts, true)), g.sample_keep_model(t as f32))) .collect(); } + if let Some((frame, b, reach)) = &self.diamond { + let Some(frame) = frame else { + return vec![(shapes::rectangle(*b), g.sample_keep_model(1.0))]; + }; + // Nested squares about the center, largest first, as a radial gradient's ellipses. + let step = reach / n as f64; + let square = |t: f64| -> SubPath { + let pts: Vec = [0.0, 0.25, 0.5, 0.75].iter().map(|&u| *frame * (diamond_perimeter(u).to_vec2() * t).to_point()).collect(); + SubPath::polyline(&pts, true) + }; + return (0..n) + .rev() + .map(|k| { + let mut shape = PathData::single(square((k + 1) as f64 * step)); + if !opaque && k > 0 { + let mut hole = square(k as f64 * step); + hole.reverse(); + shape.subpaths.push(hole); + } + (shape, g.sample_keep_model(((k as f64 + 0.5) * step) as f32)) + }) + .collect(); + } let step = (self.t1 - self.t0) / n as f64; let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32); if self.radial { @@ -1410,3 +1468,14 @@ impl Frame { .collect() } } + +/// The point a fraction `u` (0..1) of the way around the square with corners (1, 0), (0, 1), +/// (-1, 0) and (0, -1), from the first: a diamond gradient's last stop in its frame. +fn diamond_perimeter(u: f64) -> Point { + const CORNERS: [(f64, f64); 5] = [(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0), (1.0, 0.0)]; + let s = if u.is_finite() { u.clamp(0.0, 1.0) * 4.0 } else { 0.0 }; + let i = (s.floor() as usize).min(3); + let f = s - i as f64; + let ((x0, y0), (x1, y1)) = (CORNERS.get(i).copied().unwrap_or((1.0, 0.0)), CORNERS.get(i + 1).copied().unwrap_or((1.0, 0.0))); + Point::new(x0 + (x1 - x0) * f, y0 + (y1 - y0) * f) +} diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index eaa417f4e..6478cc81b 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -2,7 +2,7 @@ //! save, export and serialize by path or bytes (base64), the format table, `command.batch` and //! `action.batch` (the Actions panel menu's Batch…). //! -//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images). +//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, HP-GL, raster images). //! - `action_batch`: play an action on every file in a folder or every data set. //! - `encode`: one encoder per writable format, with typed options parsed from the params. //! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…). @@ -205,6 +205,8 @@ mod tests_eps; #[cfg(test)] mod tests_dxfimport; #[cfg(test)] +mod tests_hpgl; +#[cfg(test)] mod tests_metafile; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 97b44597b..5e920e1b3 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -11,7 +11,7 @@ use serde_json::json; use super::super::{Result, Session, bad}; #[cfg(not(target_arch = "wasm32"))] -use super::super::{bool_or, find_command, str_param}; +use super::super::{check_switches, find_command, switch_or, text_param}; #[cfg(target_arch = "wasm32")] pub(super) fn action_batch(_: &mut Session, _: &Value) -> Result { @@ -68,12 +68,24 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { )); } } - let source = str_param(p, "source").unwrap_or("folder"); - let destination = str_param(p, "destination").unwrap_or("none"); + // Every option is checked before the destination folder is made. + let source = text_param(p, "source", C)?.unwrap_or("folder"); + if !matches!(source, "folder" | "datasets") { + return Err(bad(C, "`source` must be `folder` or `datasets`")); + } + let destination = text_param(p, "destination", C)?.unwrap_or("none"); if !matches!(destination, "none" | "saveAndClose" | "folder") { return Err(bad(C, "`destination` must be `none`, `saveAndClose` or `folder`")); } - let dest_folder = str_param(p, "destinationFolder").map(absolute_path); + let dest_folder = text_param(p, "destinationFolder", C)?.map(absolute_path); + text_param(p, "folder", C)?; + let label = text_param(p, "label", C)?.unwrap_or("Batch").to_string(); + let dataset_file_naming = text_param(p, "datasetFileNaming", C)?.unwrap_or("fileDataSetName"); + let override_open = switch_or(p, "overrideOpen", true, C)?; + let stop_on_error = switch_or(p, "stopOnError", true, C)?; + let override_save = switch_or(p, "overrideSave", true, C)?; + let override_export = switch_or(p, "overrideExport", true, C)?; + check_switches(p, &["includeSubdirs"], C)?; if destination == "folder" { let Some(ref folder) = dest_folder else { return Err(bad(C, "`destinationFolder` is required when destination is `folder`")); @@ -81,17 +93,15 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { may_write(folder)?; std::fs::create_dir_all(folder).map_err(|e| bad(C, format!("{folder}: {e}")))?; } - let label = str_param(p, "label").unwrap_or("Batch").to_string(); - let override_open = bool_or(p, "overrideOpen", true); let run = Run { label: &label, steps: cmds, - stop_on_error: bool_or(p, "stopOnError", true), - override_save: bool_or(p, "overrideSave", true), - override_export: bool_or(p, "overrideExport", true), + stop_on_error, + override_save, + override_export, destination, dest_folder: dest_folder.as_deref(), - dataset_file_naming: p.get("datasetFileNaming").map_or("fileDataSetName", |v| v.as_str().unwrap_or("")), + dataset_file_naming, }; match source { "folder" => batch_folder(s, C, p, override_open, &run), @@ -186,9 +196,9 @@ fn retargeting_save(c: &Value, redirected: bool) -> bool { fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run: &Run<'_>) -> Result { use super::{absolute_path, may_read}; - let folder = str_param(p, "folder").map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; + let folder = text_param(p, "folder", cmd)?.map(absolute_path).ok_or_else(|| bad(cmd, "missing `folder`"))?; may_read(&folder)?; - let include_subdirs = bool_or(p, "includeSubdirs", false); + let include_subdirs = switch_or(p, "includeSubdirs", false, cmd)?; let files = collect_files(&folder, include_subdirs, 0)?; if files.len() > MAX_BATCH_FILES { return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} files"))); @@ -810,6 +820,46 @@ mod tests { assert!(dest.join("b.svg").is_file(), "b.svg written"); } + #[test] + fn batch_refuses_options_of_the_wrong_type_before_it_writes() { + let src = tmp("types-src"); + let dest = tmp("types-dest"); + write_svg(&src, "a.svg", "#f00"); + let mut s = Session::new(); + let cases = [ + ("stopOnError", json!("false"), "`stopOnError` must be true or false"), + ("overrideOpen", json!(0), "`overrideOpen` must be true or false"), + ("overrideSave", json!("true"), "`overrideSave` must be true or false"), + ("overrideExport", json!([]), "`overrideExport` must be true or false"), + ("includeSubdirs", json!(1), "`includeSubdirs` must be true or false"), + ("source", json!(1), "`source` must be a string"), + ("source", json!("files"), "`source` must be `folder` or `datasets`"), + ("destination", json!(true), "`destination` must be a string"), + ("folder", json!(5), "`folder` must be a string"), + ("label", json!(5), "`label` must be a string"), + ("datasetFileNaming", json!(3), "`datasetFileNaming` must be a string"), + ]; + for (key, value, message) in cases { + let out = dest.join(key); + let mut p = json!({ + "folder": src.to_string_lossy(), + "destination": "folder", + "destinationFolder": out.to_string_lossy(), + "commands": [{"command": "object.unlockAll", "params": {}}], + }); + p[key] = value; + let e = s.execute("action.batch", &p).unwrap_err().to_string(); + assert!(e.contains(message), "{key}: {e}"); + assert!(!out.exists(), "{key}: the destination folder was created"); + } + let e = s + .execute("action.batch", &json!({"folder": src.to_string_lossy(), "destination": "folder", "destinationFolder": 7, "commands": [{"command": "object.unlockAll"}]})) + .unwrap_err() + .to_string(); + assert!(e.contains("`destinationFolder` must be a string"), "{e}"); + assert!(s.active().is_none(), "no file was opened"); + } + #[cfg(unix)] #[test] fn batch_folder_keeps_backslash_in_source_file_name() { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 13df0d5eb..dd2b257f2 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "Batch…", [], None, - "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", + "{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close; for source datasets, save the open document over its own file after each data set, and refuse a document that was never saved, was converted from an older version or was restored by Data Recovery, unless a recorded Save or Save As step writes a file first; a Save or Save As step that overrideSave points into destinationFolder makes each data set's file the document's own) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportSelection the same way, and give document.exportForScreens / assets.export the folder destinationFolder/{stem}, one for each source file or data set, in which the export names its files as it does outside Batch), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs; {stem} is the file's name without its extension and without trailing periods and spaces, with `-2`, `-3`… added to a name an earlier file used or whose file or folder has the name of one an earlier file wrote or of another source file or a folder of source files in destinationFolder), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-` and trailing periods and spaces are dropped; a name already used in the batch or whose output file or folder already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. A switch (includeSubdirs, overrideOpen, overrideSave, overrideExport, stopOnError) that is not true or false, or a source, folder, destination, destinationFolder, datasetFileNaming or label that is not a string, is an error before any folder is made or file opened; null reads as left out. → {processed: [{path|dataset, result|error}…], count, failed, stopped, warning?} (warning: for source datasets, when Save and Close without such a step, or a Save, Save a Copy, Save as Template or export step (document.export, exportSelection, exportForScreens, exportPdf, exportEps, exportDxf, exportForWeb, exportForOffice) that writes a file and is not pointed into destinationFolder, saves every data set over the same file)", always, action_batch::action_batch ), diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index b63447e22..16cf3b263 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Export DXF", [], None, - "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", + "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; includeImages and includeHatches false, so fills are their outlines and no image files are written; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), includeImages?: true (false: placed images and their files are left out), includeHatches?: true (false: fills are their outlines, without solid hatches), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", has_doc, export_dxf )] @@ -37,7 +37,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "dxfPreset", ty: "string", default: "\"custom\"", - description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it", + description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, no images, no fill hatches); the options given override it", }, FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" }, FormatOption { @@ -60,6 +60,18 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ default: "\"png\"", description: "png or jpeg: the files placed images are written to, next to the drawing", }, + FormatOption { + name: "includeImages", + ty: "boolean", + default: "true", + description: "placed images are written, linked to their files; false leaves them and their files out", + }, + FormatOption { + name: "includeHatches", + ty: "boolean", + default: "true", + description: "filled areas get a solid hatch after their outlines; false writes the outlines alone", + }, FormatOption { name: "preserve", ty: "string", @@ -133,6 +145,8 @@ struct Params { scale_lineweights: Option, colors: Option, raster_format: Option, + include_images: Option, + include_hatches: Option, preserve: Option, alter_paths: Option, outline_text: Option, @@ -176,6 +190,8 @@ fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> { scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights), colors: choice("colors", q.colors.as_ref().map(text), ColorDepth::from_id, d.colors, "8, 16, 256 or true")?, raster: choice("rasterFormat", q.raster_format, RasterFormat::from_id, d.raster, "png or jpeg")?, + include_images: q.include_images.unwrap_or(d.include_images), + include_hatches: q.include_hatches.unwrap_or(d.include_hatches), preserve: choice("preserve", q.preserve, Preserve::from_id, d.preserve, "appearance or editability")?, alter_paths: q.alter_paths.unwrap_or(d.alter_paths), outline_text: q.outline_text.unwrap_or(d.outline_text), diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index a64c74e03..290f211ad 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -48,7 +48,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "useArtboards", ty: "boolean", default: "null", - description: "true: one file per chosen artboard (default all), _.eps, and with two or more a central .eps of the whole document (none when no art shows); absent: the same when artboard, artboards or range names artboards, else one file of the visible art; false: one file of the visible art, even when artboards are named", + description: "true: one file per chosen artboard (default all), _.eps, and with two or more a central .eps of the whole document (when no art shows, Export writes none and Save writes it on the first artboard); absent: the same when artboard, artboards or range names artboards, else one file of the visible art; false: one file of the visible art, even when artboards are named (Save: the first artboard when no art shows)", }, FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" }, FormatOption { @@ -203,10 +203,15 @@ fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Re } } +/// One file to write: its artboard, region and origin. +type Page = (Option, Rect, Point); + /// The files to write: `(artboard, region, origin)` each — every chosen artboard with its corner /// as the origin, or the visible art's bounds with the first artboard's corner. With two or more /// chosen artboards, a central file of the visible art comes first (none when no art shows). -fn pages(doc: &Document, set: &Settings) -> Result, Rect, Point)>> { +/// `saving`: Save always writes the file it names, so with no visible art that file's region is +/// the first artboard, and the second value is true. +fn pages(doc: &Document, set: &Settings, saving: bool) -> Result<(Vec, bool)> { let n = doc.artboards.len(); let named = set.boards.resolve(n).map_err(|e| bad(C, e))?; let boards = match (set.use_artboards, named) { @@ -217,17 +222,25 @@ fn pages(doc: &Document, set: &Settings) -> Result, Rect, Poi }; let corner = |r: Rect| Point::new(r.x0, r.y1); let art = |art: Rect| (None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect))); + // The visible art's bounds; for Save with no visible art, the first artboard (true). + let visible = || match vectorcraft_render::encode::art_bounds(doc) { + Some(bounds) => Some((bounds, false)), + None if saving => doc.artboards.first().map(|a| (a.rect, true)), + None => None, + }; match boards { Some(boards) => { - let central = (boards.len() > 1).then(|| vectorcraft_render::encode::art_bounds(doc).map(art)).flatten(); + let central = (boards.len() > 1).then(visible).flatten(); let each = boards .into_iter() .map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))); - central.map(Ok).into_iter().chain(each).collect() + let pages = central.map(|(bounds, _)| Ok(art(bounds))).into_iter().chain(each).collect::>()?; + Ok((pages, central.is_some_and(|(_, blank)| blank))) } None => { - let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; - Ok(vec![art(bounds)]) + let missing = if saving { "the document has no artboard" } else { "nothing to export: the document has no visible art" }; + let (bounds, blank) = visible().ok_or_else(|| bad(C, missing))?; + Ok((vec![art(bounds)], blank)) } } } @@ -293,16 +306,17 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { encode_document(doc, p, false) } -/// Save keeps a single page's original frame and all native editing data, unlike Export's -/// optional art-bound crop or separate file per artboard. Existing EPS loss warnings still apply. +/// Save writes the pages Export does (the visible art's bounds, or with Use Artboards the chosen +/// artboards), with a smaller preview. A single file, the one the document becomes, carries all +/// the native editing data; with no visible art its page is the first artboard. pub(super) fn encode_save(doc: &Document, p: &Value) -> Result { encode_document(doc, p, true) } fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { let set = parse(p)?; - if saving && (set.use_artboards == Some(false) || bool_or(p, "selectedOnly", false)) { - return Err(bad(C, "EPS Save retains the artboard and all artwork: use document.export for cropped or selected-only EPS")); + if saving && bool_or(p, "selectedOnly", false) { + return Err(bad(C, "EPS Save writes all artwork: use document.export for selected-only EPS")); } let original = doc; let (full, warnings) = crate::cmd::place::document::full_documents(doc); @@ -315,18 +329,16 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { } else { Cow::Borrowed(original) }; - let pages = if saving { - let board = set.boards.one(doc.artboards.len()).map_err(|e| bad(C, e))?; - let rect = doc.artboards.get(board).ok_or_else(|| bad(C, "the document has no artboard"))?.rect; - vec![(Some(board), rect, Point::new(rect.x0, rect.y1))] - } else { - pages(doc, &set)? - }; + let (pages, blank) = pages(doc, &set, saving)?; if pages.is_empty() { return Err(bad(C, "the document has no artboard")); } - // The artboards' background colors, behind the art (the editing data keeps them as colors). - let filled = super::export::with_artboard_fills(doc, p, pages.iter().filter_map(|(b, ..)| *b)).map_err(|e| bad(C, e))?; + // The file Save writes alone becomes the document's own, so it carries every artboard. + let whole = saving && pages.len() == 1; + // The artboards' background colors, behind the art (the editing data keeps them as colors); + // a page on the first artboard because no art shows has that artboard's color. + let fills = pages.iter().filter_map(|(b, ..)| *b).chain(blank.then_some(0)); + let filled = super::export::with_artboard_fills(doc, p, fills).map_err(|e| bad(C, e))?; let doc = &*filled; let regions: Vec = pages.iter().map(|(_, region, _)| *region).collect(); let expanded = expand_transparent_tile_images(doc, &set.flatten)?; @@ -360,7 +372,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result { origin, title: doc.title.clone(), created, - native: Some(native(&editing, if saving { None } else { board }, set.include_linked)), + native: Some(native(&editing, if whole { None } else { board }, set.include_linked)), ..set.eps.clone() }; let out = vectorcraft_eps::export(drawn, &o, preview.as_ref(), thumbnail.as_ref()).map_err(|e| bad(C, e))?; diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 78ca6149a..b04104f70 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -479,6 +479,8 @@ pub const FORMATS: &[Format] = &[ }, Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS }, Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS }, + // HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths; exports don't write them. + reader("hpgl", "HP-GL", &["plt", "hpgl", "hgl", "hpg"], "application/vnd.hp-hpgl", false), Format { id: "tga", label: "Targa", @@ -552,6 +554,10 @@ pub const OPEN_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "plt", + "hpgl", + "hgl", + "hpg", "eps", "epsf", "ps", @@ -565,9 +571,10 @@ pub const OPEN_EXTS: &[&str] = &[ /// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of /// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS /// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it. +/// The packaging declares no type for HP-GL plot files either. pub const UNASSOCIATED_EXTS: &[&str] = &[ "psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc", - "heic", "heif", "jxl", + "heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg", ]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares @@ -641,6 +648,10 @@ pub const PLACE_EXTS: &[&str] = &[ "dxf", "emf", "wmf", + "plt", + "hpgl", + "hgl", + "hpg", "eps", "epsf", "ps", diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 5f8b70187..fde1ed273 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -134,6 +134,17 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) { return format("svg"); } + // HP-GL has no signature: a file that starts with plotter instructions, named as one or with an + // extension no other format (text, the formats VectorCraft names as unsupported, and the files + // File › Open takes in other ways included) reads. + let ext = super::extension(name); + let other = by_name.is_some_and(|f| f.id != "hpgl") + || super::TEXT_EXTS.contains(&ext.as_str()) + || super::unsupported(&ext).is_some() + || super::opens_as_other(&ext); + if !other && vectorcraft_cad::hpgl::is_hpgl(bytes) { + return format("hpgl"); + } by_name } @@ -487,6 +498,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let (doc, warnings) = super::metafile::import(bytes)?; (doc, warnings, false) } + "hpgl" => { + let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?; + (i.document, i.warnings, false) + } "affinity" => { let i = super::affinity::import(&title, bytes)?; preview_only = i.preview_only; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 2e42ab94e..1f166797f 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -42,7 +42,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "compatibility", ty: "string", default: "\"1.7\"", - description: "PDF version: 1.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0", + description: "PDF version: 1.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0 (a pdfA2b file at 1.4 with a page longer than 14,400 pt is written as 1.6, with a UserUnit as at 1.6, and a warning; see document.exportPdf)", }, FormatOption { name: "preserveEditing", @@ -99,7 +99,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "longPagesInPoints", ty: "boolean", default: "false", - description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files from 1.5 on keep the UserUnit)", + description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit)", }, FormatOption { name: "createLayers", @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption; PDF/X files, 1.3 and 1.4 write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files from 1.5 on keep the UserUnit); apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b057..301a7c79f 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -870,8 +870,10 @@ impl SaveJob { _ => encode_all(doc, f.id, &self.params), } .map_err(|e| named_for_save(cmd, e))?; - // A legacy .ai file per artboard. - let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?; + // A legacy .ai file per artboard, or the files the EPS encoder writes: one, or with Use + // Artboards and two or more artboards the central file at the path and a file per + // artboard beside it. + let per_artboard = f.id == "eps" || (f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?); if enc.files.len() != 1 && !per_artboard { return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export")); } @@ -1092,6 +1094,25 @@ fn format_options(s: &mut Session, p: &Value) -> Result { } } } + // An EPS save that named artboards without Use Artboards, or with it on, had it on (the + // encoder reads them so): it shows on, with those artboards as the range. + if f.id == "eps" + && let Some(m) = &saved + && m.get("useArtboards").and_then(Value::as_bool) != Some(false) + { + let range = m.get("range").and_then(Value::as_str).filter(|r| !r.trim().is_empty()); + let boards = m + .get("artboards") + .and_then(|a| serde_json::from_value::>(a.clone()).ok()) + .or_else(|| m.get("artboard").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).map(|i| vec![i])) + .filter(|b| !b.is_empty()); + if range.is_some() || boards.is_some() { + v["options"]["useArtboards"]["value"] = json!(true); + if let (None, Some(boards)) = (range, boards) { + v["options"]["range"]["value"] = json!(range_text(&boards)); + } + } + } v["saveFormats"] = SAVE_FORMATS.iter().filter_map(|id| format(id)).map(|f| json!({"id": f.id, "label": f.label, "extensions": f.extensions})).collect(); Ok(v) @@ -1104,7 +1125,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to _.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", has_doc, |s, p| save(s, SaveMode::Save, p) ), @@ -1158,7 +1179,7 @@ pub(super) fn specs() -> Vec { "Format Options", [], None, - "{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else (native, for a document not yet saved as one) as the last native save used it, else the default", + "{format?: a writable format id or ait (default: the document's own)} → {id, label, extensions, mime, read, write, raster, options: {name: {type, default, description, value}}, saveFormats: [{id, label, extensions}]}; value = the document's option as last saved in that format, else (native, for a document not yet saved as one) as the last native save used it, else the default (EPS: useArtboards is true when the last save named artboards without it, and range then names them)", always, format_options ), diff --git a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs index 9a7684686..371cdc05a 100644 --- a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs +++ b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs @@ -109,23 +109,25 @@ fn the_ai_editing_copy_for_other_apps_draws_the_color_as_the_pages_do() { } } +/// Does PostScript `t` paint the only red the document has, the first artboard's color? RGB, or +/// CMYK PostScript's (its values depend on the color conversion the platform offers, so only its +/// hue is checked). +fn red(t: &str) -> bool { + t.lines().any(|l| { + let mut w: Vec<&str> = l.split_whitespace().collect(); + let Some(op) = w.pop() else { return false }; + let v: Vec = w.iter().filter_map(|n| n.parse().ok()).collect(); + match (op, v.as_slice()) { + ("rg" | "setrgbcolor", [r, g, b]) => *r > 0.9 && *g < 0.1 && *b < 0.1, + ("k" | "setcmykcolor", [c, m, y, k]) => *c < 0.1 && *m > 0.8 && *y > 0.8 && *k < 0.1, + _ => false, + } + }) +} + #[test] fn eps_and_postscript_print_draw_the_color() { let mut s = session(); - // The only red the document has, the first artboard's color: RGB, or CMYK PostScript's (its - // values depend on the color conversion the platform offers, so only its hue is checked). - let red = |t: &str| { - t.lines().any(|l| { - let mut w: Vec<&str> = l.split_whitespace().collect(); - let Some(op) = w.pop() else { return false }; - let v: Vec = w.iter().filter_map(|n| n.parse().ok()).collect(); - match (op, v.as_slice()) { - ("rg" | "setrgbcolor", [r, g, b]) => *r > 0.9 && *g < 0.1 && *b < 0.1, - ("k" | "setcmykcolor", [c, m, y, k]) => *c < 0.1 && *m > 0.8 && *y > 0.8 && *k < 0.1, - _ => false, - } - }) - }; let eps = |s: &mut Session, more: Value| { String::from_utf8_lossy(&file(s, with(json!({"format": "eps", "useArtboards": true, "artboard": 0}), &more))).into_owned() }; @@ -138,6 +140,22 @@ fn eps_and_postscript_print_draw_the_color() { assert!(!red(&ps(&mut s, json!({"includeArtboardFill": false}))), "print without it"); } +/// EPS Save draws the color on an artboard's page: with Use Artboards, or on the first artboard +/// when no art shows. The box of the visible art, as Export's, has none. +#[test] +fn eps_save_draws_the_color_on_an_artboard_page() { + let mut s = session(); + let save = |s: &mut Session, more: Value| { + let options = with(json!({"previewFormat": "none", "thumbnails": false}), &more); + String::from_utf8_lossy(&b64(&s.execute("document.save", &json!({"format": "eps", "options": options})).unwrap())).into_owned() + }; + assert!(!red(&save(&mut s, json!({}))), "the art's box"); + assert!(red(&save(&mut s, json!({"useArtboards": true, "range": "1"}))), "the artboard's page"); + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(red(&save(&mut s, json!({}))), "the first artboard when no art shows"); +} + #[test] fn export_for_screens_includes_the_fill_unless_unchecked() { let mut s = session(); diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 849767683..54839edda 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -84,6 +84,11 @@ fn the_cnc_preset_sets_the_options_that_are_not_given() { true ) ); + assert_eq!((o.include_images, o.include_hatches), (false, false), "CNC / CAM leaves out images and fill hatches"); + let given = dxf::options(&json!({"dxfPreset": "cnc", "includeImages": true, "includeHatches": true})).unwrap(); + assert_eq!((given.include_images, given.include_hatches), (true, true), "the options given override it"); + let custom = dxf::options(&json!({})).unwrap(); + assert_eq!((custom.include_images, custom.include_hatches), (true, true), "Custom keeps both"); assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default"); let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string(); assert!(e.contains("custom or cnc"), "{e}"); @@ -151,6 +156,44 @@ fn placed_images_come_back_as_linked_files() { let _ = std::fs::remove_dir_all(dir); } +/// `includeImages` and `includeHatches` leave out placed images (with their files) and fill +/// hatches, each on its own, through both commands, with and without a path; CNC / CAM leaves +/// out both, and the options given override it. +#[test] +fn placed_images_and_fill_hatches_can_be_left_out() { + let mut s = super::tests_svg::image_session(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1, "height": 1})).unwrap(); + let counts = |r: &Value| { + let e = entities(&text(r)); + let n = |k: &str| e.iter().filter(|x| *x == k).count(); + (n("LWPOLYLINE"), n("HATCH"), n("IMAGE"), r.get("linked").and_then(Value::as_array).map_or(0, Vec::len)) + }; + let warned = |r: &Value, w: &str| r["warnings"].as_array().unwrap().iter().any(|x| x.as_str().is_some_and(|x| x.starts_with(w))); + let all = s.execute("document.export", &json!({"format": "dxf"})).unwrap(); + assert_eq!(counts(&all), (1, 1, 1, 1)); + let r = s.execute("document.export", &json!({"format": "dxf", "includeImages": false})).unwrap(); + assert_eq!(counts(&r), (1, 1, 0, 0)); + assert!(warned(&r, "placed images are left out") && !warned(&r, "fill hatches"), "{r}"); + let r = s.execute("document.exportDxf", &json!({"includeHatches": false})).unwrap(); + assert_eq!(counts(&r), (1, 0, 1, 1)); + assert!(warned(&r, "fill hatches are left out") && !warned(&r, "placed images"), "{r}"); + let r = s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap(); + assert_eq!(counts(&r), (1, 0, 0, 0)); + let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc", "includeImages": true})).unwrap(); + assert_eq!(counts(&r), (1, 0, 1, 1)); + // With a path, no image file is written next to the drawing. + let dir = std::env::temp_dir().join(format!("vc-dxf-out-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cut.dxf").to_string_lossy().to_string(); + let r = s.execute("document.exportDxf", &json!({"path": path, "dxfPreset": "cnc"})).unwrap(); + assert!(r.get("linked").is_none(), "{r}"); + let files: Vec = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert_eq!(files, ["cut.dxf"]); + let _ = std::fs::remove_dir_all(dir); +} + /// Two placed images whose pixel data can't be decoded are left out of the drawing and get no /// linked file, and `warnings` holds the note raster exports give, once, without naming either /// image's key. @@ -181,6 +224,8 @@ fn bad_dxf_options_are_refused() { json!({"rasterFormat": "gif"}), json!({"preserve": "both"}), json!({"alterPaths": "yes"}), + json!({"includeImages": "no"}), + json!({"includeHatches": 0}), ] { let e = s.execute("document.exportDxf", &p).unwrap_err().to_string(); assert!(e.contains("DXF"), "{p}: {e}"); @@ -203,6 +248,8 @@ fn formats_list_dxf_and_what_can_not_be_written() { "scaleLineweights", "colors", "rasterFormat", + "includeImages", + "includeHatches", "preserve", "alterPaths", "outlineText", diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs new file mode 100644 index 000000000..271f7b86e --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -0,0 +1,104 @@ +//! HP-GL plotter files through the commands: `document.open` by extension or by content, File › +//! Place as one group of paths, the dialogs' filters and the format list, and files that aren't +//! HP-GL refused with a message. + +use serde_json::json; +use vectorcraft_doc::NodeKind; + +use super::*; + +fn b64(bytes: &[u8]) -> String { + vectorcraft_format::base64_encode(bytes) +} + +/// An inch-wide red square and a black diagonal, with the thinnest pen. +const PLOT: &[u8] = b"IN;SP2;PW0;PA0,0;PD1016,0,1016,1016,0,1016,0,0;PU;SP1;PD1016,1016;PU;"; + +#[test] +fn document_open_reads_a_plot_file() { + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(PLOT)})).unwrap(); + assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("hpgl"), Some("part.plt")), "{r}"); + let st = s.doc().unwrap(); + let board = st.doc.artboards[0].rect; + assert!((board.width() - 72.25).abs() < 1e-9 && (board.height() - 72.25).abs() < 1e-9, "{board:?}"); + let mut paths = vec![]; + st.doc.walk(|n| { + if let NodeKind::Path { .. } = n.kind { + paths.push(n.clone()); + } + }); + assert_eq!(paths.len(), 2); + // Opened as artwork: Save asks for a name. + assert!(st.path.is_none()); + + // A plot file opens by its content in any of its names, and under a name no format reads. + for name in ["drawing.prn", "PART.HPGL", "part.hgl", "part.hpg"] { + let r = Session::new().execute("document.open", &json!({"name": name, "dataBase64": b64(PLOT)})).unwrap(); + assert_eq!(r["format"].as_str(), Some("hpgl"), "{name}: {r}"); + } +} + +#[test] +fn file_place_places_a_plot_file_as_one_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let p = json!({"name": "part.plt", "dataBase64": b64(PLOT), "thumbnail": 16}); + let info = s.execute("file.place.info", &p).unwrap(); + assert_eq!(info["format"], "hpgl", "{info}"); + assert!((info["width"].as_f64().unwrap() - 72.25).abs() < 1e-9, "{info}"); + let r = s.execute("file.place", &p).unwrap(); + assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("hpgl"), Some(1)), "{r}"); + let doc = &s.doc().unwrap().doc; + let group = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + let NodeKind::Group { children, clip, .. } = &group.kind else { panic!("a group, got {:?}", group.kind) }; + assert_eq!((children.len(), *clip), (2, false), "the art's bounds, not clipped"); +} + +#[test] +fn files_that_are_not_hpgl_are_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + let before = s.doc().unwrap().doc.artboards[0].rect.size(); + let script = b"set terminal png\nplot sin(x)\n"; + let e = s.execute("document.open", &json!({"name": "graph.plt", "dataBase64": b64(script)})).expect_err("refused"); + assert!(e.to_string().contains("not an HP-GL file"), "{e}"); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), before); + assert!(s.execute("file.place", &json!({"name": "graph.plt", "dataBase64": b64(script)})).is_err()); + // Text that isn't a plot opens by its name, not as HP-GL. + assert!(detect("notes.txt", b"IN THE BEGINNING").is_none_or(|f| f.id != "hpgl")); + // Only a plot file's own names, and names no other format reads, are read by their content: + // text, other formats' files and the formats named as unsupported (a DWG drawing starts with + // `AC` and digits) aren't, even when they start like a plot. + for name in ["notes.txt", "notes.svg", "picture.tga", "drawing.dxf", "site.dwg", "old.pct"] { + for bytes in [&b"IN"[..], b"in 5;", b"AC1032\0\0", PLOT] { + assert!(detect(name, bytes).is_none_or(|f| f.id != "hpgl"), "{name}: {:?}", String::from_utf8_lossy(bytes)); + } + } + let text = s.execute("file.place", &json!({"name": "notes.txt", "dataBase64": b64(b"IN 5; PA 10;")})).unwrap(); + assert_eq!(text["format"], "txt", "{text}"); +} + +/// A plot file named as one opens when its first instruction is one a plot file seldom starts with; +/// under another name it isn't taken for HP-GL. +#[test] +fn a_plot_file_by_its_name_may_start_with_any_instruction() { + let plot = b"SI0.2,0.3;SP1;PA0,0;PD1016,0;"; + let r = Session::new().execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(plot)})).unwrap(); + assert_eq!(r["format"].as_str(), Some("hpgl"), "{r}"); + assert!(detect("drawing.prn", plot).is_none_or(|f| f.id != "hpgl")); +} + +#[test] +fn the_dialogs_and_the_format_list_name_hpgl() { + let exts = ["plt", "hpgl", "hgl", "hpg"]; + for e in exts { + assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}"); + assert_eq!(format_for_name(&format!("a.{e}")).map(|f| f.id), Some("hpgl"), "{e}"); + } + let filter = ("HP-GL", &exts[..]); + assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter)); + let r = Session::new().execute("document.formats", &json!({})).unwrap(); + let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone(); + assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false))); +} diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index f950f8c1d..02b409768 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1,7 +1,10 @@ //! Object → Flatten Transparency: transparent art becomes opaque art that looks the same. //! -//! The targets (`ids` or the selection) split into groups of overlapping objects. Groups without -//! transparency stay as they are (their type and strokes are outlined on request). The others are +//! The targets (`ids` or the selection) split into groups: each object with transparency with the +//! objects it overlaps, and the objects without transparency whose stacking order with a member +//! would change when the group takes its topmost object's place. Objects without transparency +//! that overlap only others without it stay as they are (their type and strokes are outlined on +//! request), as do groups without transparency. The others are //! cut into atomic regions: the faces of the planar arrangement of every fill, outlined stroke and //! clipping path in them ([`po::regions`]). The same paints cover each whole region, so its colour //! is exact: those paints composited as the renderer composites them ([`Composer`], blend modes by diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index eacd12405..cf7bdb7ba 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -99,17 +99,77 @@ pub(super) fn masked(n: &Node) -> bool { n.extras().mask.as_ref().is_some_and(|m| !m.disabled) } -/// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing). -pub(super) fn reach(n: &Node) -> Option { +/// Where `n` paints: its visual bounds grown by its effects. +pub(super) fn painted(n: &Node) -> Option { if !n.visible { return None; } let b = match n.children() { - Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?, + Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| painted(c)).reduce(|a, b| a.union(b))?, _ => n.visual_bounds()?, }; let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max); - Some(b.inflate(fx + 1.0, fx + 1.0)) + Some(b.inflate(fx, fx)) +} + +/// Where `n` can paint: where it paints ([`painted`]) and a margin of 1 pt for antialiasing, +/// once, however deep its groups nest. +pub(super) fn reach(n: &Node) -> Option { + painted(n).map(|b| b.inflate(1.0, 1.0)) +} + +type Cancelled = vectorcraft_doc::cancel::Cancelled; + +/// Whether `b` has finite coordinates and isn't inside out. +pub(super) fn finite(b: &Rect) -> bool { + [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.x0 <= b.x1 && b.y0 <= b.y1 +} + +/// Whether boxes `a` and `b` overlap or touch. +fn touch(a: &Rect, b: &Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Indices joined into sets, each set knowing whether it holds an object with transparency. +struct Sets { + parent: Vec, + seeded: Vec, +} + +impl Sets { + fn find(&mut self, mut i: usize) -> usize { + while self.parent[i] != i { + self.parent[i] = self.parent[self.parent[i]]; + i = self.parent[i]; + } + i + } + + /// Joins the sets of `a` and `b`: → the set they are now, named by its lowest index. + fn join(&mut self, a: usize, b: usize) -> usize { + let (a, b) = (self.find(a), self.find(b)); + let (lo, hi) = (a.min(b), a.max(b)); + self.parent[hi] = lo; + self.seeded[lo] |= self.seeded[hi]; + lo + } + + /// The sets, each in index order, by their first index. + fn groups(&mut self) -> Vec> { + let mut groups: Vec> = vec![]; + let mut slot: Vec> = vec![None; self.parent.len()]; + for i in 0..self.parent.len() { + let r = self.find(i); + match slot[r] { + Some(g) => groups[g].push(i), + None => { + slot[r] = Some(groups.len()); + groups.push(vec![i]); + } + } + } + groups + } } /// Indices of `bounds` grouped by overlap (transitively), each group in index order. Boxes that @@ -121,16 +181,15 @@ pub(super) fn reach(n: &Node) -> Option { /// horizontal extent, would otherwise compare every pair). An open box inside a later box of /// its group, ending no further right, is closed early: whatever overlaps it overlaps that box. /// Fails only when cancelled ([`vectorcraft_doc::cancel`]). -pub(super) fn overlapping(bounds: &[Option]) -> Result>, vectorcraft_doc::cancel::Cancelled> { - fn find(p: &mut [usize], mut i: usize) -> usize { - while p[i] != i { - p[i] = p[p[i]]; - i = p[i]; - } - i - } - let mut parent: Vec = (0..bounds.len()).collect(); - let finite = |b: &Rect| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.x0 <= b.x1 && b.y0 <= b.y1; +pub(super) fn overlapping(bounds: &[Option]) -> Result>, Cancelled> { + Ok(sweep(bounds, None)?.groups()) +} + +/// [`overlapping`]'s sweep. With `see_through`, two boxes are joined only when one of them is +/// see-through, and an open box is closed early only inside a later see-through box. +fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result { + let mut sets = Sets { parent: (0..bounds.len()).collect(), seeded: (0..bounds.len()).map(|i| see_through.is_some_and(|s| s[i])).collect() }; + let joins = |a: usize, b: usize| see_through.is_none_or(|s| s[a] || s[b]); let boxes: Vec<(usize, Rect)> = bounds.iter().enumerate().filter_map(|(i, b)| b.filter(finite).map(|b| (i, b))).collect(); let n = boxes.len(); // Each box's place in the tree: by top (then index). @@ -190,29 +249,140 @@ pub(super) fn overlapping(bounds: &[Option]) -> Result>, ve stack.push((2 * node + 1, mid, hi)); stack.push((2 * node, lo, mid)); } + // Whatever overlaps a box inside this one overlaps this one, and joins it when this one is + // see-through. + let shadows = see_through.is_none_or(|s| s[boxes[k].0]); for &o in &met { - let (a, c) = (find(&mut parent, boxes[k].0), find(&mut parent, boxes[o].0)); - parent[a.max(c)] = a.min(c); + if joins(boxes[k].0, boxes[o].0) { + sets.join(boxes[k].0, boxes[o].0); + } let ob = boxes[o].1; - if ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { + if shadows && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { set(&mut low, leaf[o], f64::NEG_INFINITY); } } set(&mut low, leaf[k], b.y1); } - let mut groups: Vec> = vec![]; - let mut slot: Vec> = vec![None; bounds.len()]; - for i in 0..bounds.len() { - let r = find(&mut parent, i); - match slot[r] { - Some(g) => groups[g].push(i), - None => { - slot[r] = Some(groups.len()); - groups.push(vec![i]); + Ok(sets) +} + +/// Boxes in blocks of nearby ones, each block with the box around it, to find the boxes a box +/// touches. +struct Blocks { + order: Vec, + boxes: Vec>, + around: Vec, +} + +impl Blocks { + const SIZE: usize = 32; + + /// The finite boxes of `boxes`, in vertical strips by their centers and in blocks down each + /// strip. + fn new(boxes: &[Option]) -> Self { + let boxes: Vec> = boxes.iter().map(|b| b.filter(finite)).collect(); + let mut order: Vec = (0..boxes.len()).filter(|&i| boxes[i].is_some()).collect(); + let center = |i: usize| boxes[i].map_or_else(Default::default, |b| b.center()); + order.sort_by(|&a, &b| center(a).x.total_cmp(¢er(b).x)); + let strip = ((order.len() as f64 / Self::SIZE as f64).sqrt().ceil() as usize).max(1) * Self::SIZE; + for s in order.chunks_mut(strip) { + s.sort_by(|&a, &b| center(a).y.total_cmp(¢er(b).y)); + } + let around = order.chunks(Self::SIZE).filter_map(|c| c.iter().filter_map(|&i| boxes[i]).reduce(|a, b| a.union(b))).collect(); + Self { order, boxes, around } + } + + /// Puts the indices of the boxes that touch `q` in `out`. `work` counts the boxes looked at; + /// fails only when cancelled. + fn touching(&self, q: &Rect, out: &mut Vec, work: &mut usize) -> Result<(), Cancelled> { + out.clear(); + for (b, around) in self.around.iter().enumerate() { + *work += 1; + if work.is_multiple_of(4096) { + vectorcraft_doc::cancel::check()?; + } + if !touch(around, q) { + continue; + } + for &i in &self.order[b * Self::SIZE..((b + 1) * Self::SIZE).min(self.order.len())] { + *work += 1; + if self.boxes[i].is_some_and(|r| touch(&r, q)) { + out.push(i); + } } } + Ok(()) } - Ok(groups) +} + +/// Objects (in paint order) grouped for flattening, each group in paint order and the groups by +/// their first object; a group takes the place of its topmost object. `reach` is where each +/// object can paint ([`reach`]), `painted` where it paints ([`painted`]), and `see_through` whether +/// it has transparency. An object whose box is missing or not finite is a group of its own. +/// +/// An object joins every see-through object its reach touches. An object without transparency +/// that touches only others without transparency stays a group of its own, unless a group would +/// change its stacking order with it: it joins (and groups join each other) where its reach +/// touches a member's and moving the members to their group's top would put the two the other way +/// round. With a `fringe` (how far a group's image can reach past +/// its art and cover what is under it), an object or group under a group's top whose painted box +/// that far around a member's touches joins it too. Fails only when cancelled +/// ([`vectorcraft_doc::cancel`]). +pub(super) fn flatten_groups( + reach: &[Option], + painted: &[Option], + see_through: &[bool], + fringe: f64, +) -> Result>, Cancelled> { + let n = reach.len(); + let mut sets = sweep(reach, Some(see_through))?; + let mut members: Vec> = vec![vec![]; n]; + for i in 0..n { + let r = sets.find(i); + members[r].push(i); + } + // The topmost object of each set (by its name). + let mut top: Vec = (0..n).map(|r| members[r].last().copied().unwrap_or(r)).collect(); + let index = Blocks::new(reach); + // Each member of a set with transparency is looked at. + let mut work: Vec = vec![]; + for i in 0..n { + let r = sets.find(i); + if sets.seeded[r] { + work.push(i); + } + } + let (mut near, mut looked) = (vec![], 0usize); + while let Some(i) = work.pop() { + let Some(q) = reach[i].filter(finite) else { continue }; + // Painted boxes within the fringe have reaches within it too. + index.touching(&q.inflate(fringe, fringe), &mut near, &mut looked)?; + for &k in &near { + let (ri, rk) = (sets.find(i), sets.find(k)); + if ri == rk { + continue; + } + let (ti, tk) = (top[ri], top[rk]); + let crossed = reach[k].is_some_and(|b| touch(&q, &b)) && (i < k) != (ti < tk); + let covered = fringe > 0.0 && tk < ti && painted[i].zip(painted[k]).is_some_and(|(a, b)| touch(&a.inflate(fringe, fringe), &b)); + if !crossed && !covered { + continue; + } + // The members of a set whose top moves are looked at again, from there. + let t = ti.max(tk); + if ti != t { + work.extend_from_slice(&members[ri]); + } + if tk != t { + work.extend_from_slice(&members[rk]); + } + let r = sets.join(ri, rk); + let moved = std::mem::take(&mut members[if r == ri { rk } else { ri }]); + members[r].extend(moved); + top[r] = t; + } + } + Ok(sets.groups()) } #[cfg(test)] @@ -292,4 +462,153 @@ mod tests { let odd = [Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(f64::NAN, 0.0, 10.0, 10.0)), Some(Rect::new(5.0, 5.0, 20.0, 20.0))]; assert_eq!(overlapping(&odd).unwrap(), vec![vec![0, 2], vec![1]]); } + + /// A filled rectangle, unstroked. + fn filled(id: u64, r: Rect) -> Node { + let look = vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)), + vectorcraft_color::Paint::None, + 0.0, + ); + Node::path(vectorcraft_doc::NodeId(id), vectorcraft_geom::shapes::rectangle(r), look) + } + + /// The antialiasing margin goes around the outermost group once, not once per group. + #[test] + fn reach_adds_its_margin_once_however_deep_the_groups() { + let r = Rect::new(10.0, 20.0, 30.0, 50.0); + let inner = Node::group(vectorcraft_doc::NodeId(2), vec![Arc::new(filled(1, r))]); + let outer = Node::group(vectorcraft_doc::NodeId(3), vec![Arc::new(inner)]); + assert_eq!(painted(&outer), Some(r)); + assert_eq!(reach(&outer), Some(r.inflate(1.0, 1.0))); + assert_eq!(reach(&filled(1, r)), reach(&outer)); + } + + /// The group of each object, the topmost object of each group and whether it is see-through. + fn sets_of(groups: &[Vec], n: usize, see_through: &[bool]) -> (Vec, Vec, Vec) { + let mut of = vec![usize::MAX; n]; + for (g, ids) in groups.iter().enumerate() { + for &i in ids { + of[i] = g; + } + } + let top = groups.iter().map(|g| *g.last().unwrap()).collect(); + let seeded = groups.iter().map(|g| g.iter().any(|&i| see_through[i])).collect(); + (of, top, seeded) + } + + /// What [`flatten_groups`] must give: every object in one group, in paint order, the groups by + /// their first object; see-through objects with all that their reach touches; groups without + /// transparency of one object; no two objects that paint over each other the other way round + /// once each group takes its top's place; nothing within `fringe` of a member of a group under + /// that group's top. + fn check(reach: &[Option], painted: &[Option], see: &[bool], fringe: f64, groups: &[Vec], what: &str) { + let n = reach.len(); + let mut all: Vec = groups.iter().flatten().copied().collect(); + all.sort_unstable(); + assert_eq!(all, (0..n).collect::>(), "{what}: every object once"); + assert!(groups.iter().all(|g| g.is_sorted()) && groups.windows(2).all(|w| w[0][0] < w[1][0]), "{what}: in order"); + let (of, top, seeded) = sets_of(groups, n, see); + for (g, ids) in groups.iter().enumerate() { + assert!(seeded[g] || ids.len() == 1, "{what}: {ids:?} has no transparency"); + } + for i in 0..n { + for j in 0..n { + if i == j || of[i] == of[j] { + continue; + } + let (gi, gj) = (of[i], of[j]); + if let (Some(a), Some(b)) = (reach[i], reach[j]) { + assert!(!(touch(&a, &b) && (see[i] || see[j])), "{what}: {i} and {j} touch, one see-through"); + if seeded[gi] && touch(&a, &b) { + assert_eq!(i < j, top[gi] < top[gj], "{what}: {i} and {j} change places"); + } + } + let (Some(a), Some(b)) = (painted[i], painted[j]) else { continue }; + if seeded[gi] && fringe > 0.0 && touch(&a.inflate(fringe, fringe), &b) { + assert!(top[gj] > top[gi], "{what}: {j} under the image of {i}'s group"); + } + } + } + } + + /// Boxes of many sizes, some see-through, group as they must, with and without a fringe. + #[test] + fn flatten_groups_keep_stacking_order_and_leave_opaque_art_alone() { + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut next = |m: f64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % 1000) as f64 / 1000.0 * m + }; + for round in 0..60 { + let n = 4 + round; + let painted: Vec> = (0..n) + .map(|i| { + if i % 13 == 5 { + return None; + } + let (x, y) = (next(120.0).round(), next(120.0).round()); + Some(Rect::new(x, y, x + next(25.0).round(), y + next(25.0).round())) + }) + .collect(); + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let see: Vec = (0..n).map(|_| next(1.0) < 0.15).collect(); + for fringe in [0.0, 0.7] { + let groups = flatten_groups(&reach, &painted, &see, fringe).unwrap(); + check(&reach, &painted, &see, fringe, &groups, &format!("round {round}, fringe {fringe}")); + } + } + } + + /// A row of opaque boxes whose reaches touch, with see-through ones at its ends and far from + /// it: only the boxes next to a see-through one join it. + #[test] + fn an_opaque_row_stays_apart_from_the_see_through_boxes_at_its_ends() { + let n = 200; + // 10 pt boxes 1.5 pt apart: their 1 pt reaches overlap. + let painted: Vec> = (0..n).map(|i| Some(Rect::new(11.5 * i as f64, 0.0, 11.5 * i as f64 + 10.0, 10.0))).collect(); + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let mut see = vec![false; n]; + see[0] = true; + see[n - 1] = true; + let groups = flatten_groups(&reach, &painted, &see, 0.0).unwrap(); + check(&reach, &painted, &see, 0.0, &groups, "row"); + assert_eq!(groups[0], vec![0, 1]); + assert_eq!(groups.last(), Some(&vec![n - 2, n - 1])); + assert_eq!(groups.len(), n - 2); + // Without transparency every box is a group of its own. + assert_eq!(flatten_groups(&reach, &painted, &vec![false; n], 0.0).unwrap().len(), n); + } + + /// An opaque box between a member and its group's top that paints over the member joins, and + /// so on along a chain; one under the member stays, but for one within the fringe. + #[test] + fn boxes_the_group_would_cross_join_it() { + // In paint order: D under A, A, B over A, C over B, then the see-through T over A. + let d = Rect::new(40.0, 80.0, 65.0, 120.0); + let a = Rect::new(60.0, 50.0, 105.0, 90.0); + let b = Rect::new(20.0, 20.0, 65.0, 55.0); + let c = Rect::new(0.0, 0.0, 25.0, 25.0); + let t = Rect::new(100.0, 50.0, 140.0, 90.0); + let painted = [Some(d), Some(a), Some(b), Some(c), Some(t)]; + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let see = [false, false, false, false, true]; + let groups = flatten_groups(&reach, &painted, &see, 0.0).unwrap(); + assert_eq!(groups, vec![vec![0], vec![1, 2, 3, 4]]); + // C away from B: it stays. + let far = [Some(d), Some(a), Some(b), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(t)]; + let reach_far: Vec> = far.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_far, &far, &see, 0.0).unwrap(), vec![vec![0], vec![1, 2, 4], vec![3]]); + // B 0.5 pt above A, not overlapping it: their antialiased edges can share pixels, so B joins. + let gap = [Some(d), Some(a), Some(Rect::new(20.0, 20.0, 65.0, 49.5)), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(t)]; + let reach_gap: Vec> = gap.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_gap, &gap, &see, 0.0).unwrap(), vec![vec![0], vec![1, 2, 4], vec![3]]); + // D a little left of A, under it: the fringe takes it in. + let near = [Some(Rect::new(40.0, 80.0, 59.8, 120.0)), Some(a), Some(b), Some(c), Some(t)]; + let reach_near: Vec> = near.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_near, &near, &see, 0.0).unwrap()[0], vec![0]); + assert_eq!(flatten_groups(&reach_near, &near, &see, 0.24).unwrap(), vec![vec![0, 1, 2, 3, 4]]); + } } diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index c1366dba3..ef8425b36 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -1,4 +1,4 @@ -use super::analysis::{Scratch, has_effects, masked, outlined_text, overlapping, reach}; +use super::analysis::{Scratch, finite, flatten_groups, has_effects, masked, outlined_text, overlapping, painted, reach}; use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE}; use super::options::FlattenOptions; use super::{ @@ -115,7 +115,7 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). vectorcraft_doc::cancel::check()?; - let mut queue = overlapping(&reaches)?; + let mut queue = groups(src, roots, &plain, &reaches, &see_through, o)?; queue.reverse(); while let Some(g) = queue.pop() { vectorcraft_doc::cancel::check()?; @@ -196,6 +196,46 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: Ok(out) } +/// `roots` of `src` (`plain`: as plain art; `reaches`: where each can paint, clipped) in the +/// groups that flatten together ([`flatten_groups`]), each in paint order, by their first object. +/// Objects that overlap (as [`overlapping`] groups them) stay in one group when one of them is +/// inside a clip group or clipping layer: [`inside_and_under`] and [`place_of`] then decide where +/// they go. +fn groups( + src: &Document, + roots: &[NodeId], + plain: &[Node], + reaches: &[Option], + see_through: &[bool], + o: &FlattenOptions, +) -> Result>> { + // An image clipped to its regions, opaque over white, reaches a pixel past them; a group's + // image is no larger than the art it overlaps, so its pixels are no wider than this. + let opaque_edge = o.no_transparency.is_some() && o.clip_complex_regions && !o.preserve_alpha; + let ppi = o.line_art_ppi.min(o.gradient_ppi).max(1.0); + let extent = |ids: &mut dyn Iterator| ids.filter_map(|i| reaches[i].filter(finite)).reduce(|a, b| a.union(b)).unwrap_or_default(); + // Where the widest such pixel is wider than the margins of two reaches, objects that close + // overlap too. + let widest = if opaque_edge { 1.0 / raster_scale(extent(&mut (0..reaches.len())), ppi) } else { 0.0 }; + let grow = ((widest - 2.0) / 2.0).max(0.0); + let near: Vec> = reaches.iter().map(|b| b.map(|b| b.inflate(grow, grow))).collect(); + let mut out = vec![]; + for g in overlapping(if grow > 0.0 { &near } else { reaches })? { + let clipped = || clips_count() && g.iter().any(|&i| !clip_ancestors(src, roots[i]).is_empty()); + if g.len() < 2 || !g.iter().any(|&i| see_through[i]) || clipped() { + out.push(g); + continue; + } + let fringe = if opaque_edge { 1.0 / raster_scale(extent(&mut g.iter().copied()), ppi) } else { 0.0 }; + let local = |v: &[Option]| g.iter().map(|&i| v[i]).collect::>(); + let painted: Vec> = g.iter().map(|&i| painted(&plain[i])).collect(); + let see: Vec = g.iter().map(|&i| see_through[i]).collect(); + out.extend(flatten_groups(&local(reaches), &painted, &see, fringe)?.into_iter().map(|l| l.into_iter().map(|k| g[k]).collect::>())); + } + out.sort_by_key(|g| g.first().copied()); + Ok(out) +} + /// `plain` (object `id` of `src` as plain art) inside a clip group for each clip group and /// clipping layer around `id`, innermost first, each clipped by that group's or layer's clipping /// path. `plain` itself when nothing around it clips. With `fills` (clipping paths, and an area), @@ -482,11 +522,7 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool /// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs. /// Fails when the image can't be encoded. fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option, o: &FlattenOptions) -> Result { - let mut scale = ppi / 72.0; - scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); - // No side longer than the renderer draws: the image grows by a pixel on each side and is - // rounded up to whole pixels below. - scale = scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0)); + let scale = raster_scale(rect, ppi); let px = 1.0 / scale; let rect = rect.inflate(px, px); let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c)); @@ -559,6 +595,14 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip }) } +/// Pixels per point of an image of `rect` at `ppi`: lower when the image would be larger than +/// [`MAX_PIXELS`], or have a side longer than the renderer draws (the image grows by a pixel on +/// each side and is rounded up to whole pixels). +fn raster_scale(rect: Rect, ppi: f64) -> f64 { + let scale = (ppi / 72.0).min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); + scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0)) +} + /// `n` at full opacity with Normal blending throughout (objects, fills and strokes). pub(super) fn opaque(n: &mut Node) { n.opacity = 1.0; @@ -877,4 +921,42 @@ mod tests { assert_eq!(flat.parent_of(g), Some(layer)); assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g)); } + + /// In a file without transparency with Clip Complex Regions on, art under a group's opaque + /// image within one of its pixels joins the group, also where a long image is made at a lower + /// resolution and its pixels are wider than at the preset's. + #[test] + fn art_under_the_edge_of_a_long_opaque_image_joins_its_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 31000, "height": 100})).unwrap(); + let rect = |s: &mut Session, x: f64, w: f64| { + let id = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [id]})).unwrap(); + NodeId(id) + }; + // 0.5 pt left of A: wider than a pixel at 300 ppi, narrower than one of a 30,000 pt image. + let d = rect(&mut s, 40.0, 19.5); + let a = rect(&mut s, 60.0, 40.0); + let t = rect(&mut s, 95.0, 30_000.0); + s.execute("transparency.set", &json!({"ids": [t.0], "opacity": 50})).unwrap(); + let o = FlattenOptions { no_transparency: Some(Default::default()), clip_complex_regions: true, ..FlattenOptions::default() }; + let doc = &s.doc().unwrap().doc; + let long = plan(doc, &[d, a, t], &o, false).unwrap(); + assert!(long.kept.is_empty(), "{:?}", long.kept); + assert_eq!(long.flat.iter().map(|f| f.roots.clone()).collect::>(), vec![vec![d, a, t]]); + // Short, the image has 300 ppi pixels: D stays. + let short = rect(&mut s, 95.0, 40.0); + s.execute("transparency.set", &json!({"ids": [short.0], "opacity": 50})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(plan(doc, &[d, a, short], &o, false).unwrap().kept, vec![d]); + // 100,000 pt long, its pixels are over 3 pt wide: art 3 pt away, whose reach doesn't touch + // A's, joins too. + let far = rect(&mut s, 40.0, 17.0); + let longer = rect(&mut s, 95.0, 100_000.0); + s.execute("transparency.set", &json!({"ids": [longer.0], "opacity": 50})).unwrap(); + let doc = &s.doc().unwrap().doc; + let p = plan(doc, &[far, a, longer], &o, false).unwrap(); + assert!(p.kept.is_empty(), "{:?}", p.kept); + assert_eq!(p.flat.iter().map(|f| f.roots.clone()).collect::>(), vec![vec![far, a, longer]]); + } } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 9676c3b82..9003e325a 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial or conic, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial and conic: the vector turns about the center), aspect?: % (radial and conic; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial or conic gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial and conic: the extent ellipse's height / width, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)", has_doc, set_gradient_geom ), @@ -118,7 +118,7 @@ type Parsed = std::result::Result; /// Parse a kind name. fn parse_kind(k: &str) -> Parsed { - GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, conic, freeform)")) + GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform)")) } /// The `method` param of `p`, if given: how the colours between the stops mix. @@ -275,7 +275,7 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { gp.swatch = str_param(g, "swatch").map(str::to_string); let geom = GeomEdit::parse(g)?; let aspect = geom.aspect.map(|a| aspect_for(kind, a)).transpose()?; - if kind == GradientKind::Conic { + if matches!(kind, GradientKind::Conic | GradientKind::Diamond) { no_focal(&geom)?; } if let Some((start, end)) = geom.vector { @@ -315,7 +315,7 @@ fn aspect_for(kind: GradientKind, a: f64) -> Parsed { Ok(a) } -/// An error when `e` gives a focal point: a conic gradient has none (`null` is allowed). +/// An error when `e` gives a focal point: a conic or diamond gradient has none (`null` is allowed). fn no_focal(e: &GeomEdit) -> Parsed<()> { match e.focal { Some(Some(_)) => Err("`focal` applies to radial gradients".into()), @@ -645,7 +645,7 @@ impl GeomEdit { /// the paint's space (text space for type runs) to the document and `bounds` (in that space) fits /// an unplaced gradient, so what the document shows carries over where `e` keeps it. An error /// when `e` gives what the gradient's kind can't take (a negative aspect other than a conic's, or -/// a conic's focal point). +/// a conic's or diamond's focal point). fn vector_paint( cur: &Paint, e: &GeomEdit, @@ -656,7 +656,7 @@ fn vector_paint( let mut gp = gradient_for_edit(cur, model); let kind = gp.gradient.kind; let edit_aspect = e.aspect.map(|a| aspect_for(kind, a)).transpose()?; - if kind == GradientKind::Conic { + if matches!(kind, GradientKind::Conic | GradientKind::Diamond) { no_focal(e)?; } let Some(from_doc) = invert(to_doc) else { return Ok(None) }; diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs index 2f69a9df3..d27809fa7 100644 --- a/crates/engine/src/cmd/gradpresets.rs +++ b/crates/engine/src/cmd/gradpresets.rs @@ -109,6 +109,40 @@ fn unlinked(g: &Gradient) -> Gradient { g } +/// Give each saved preset in `saved` a name no built-in preset or earlier saved one has (any case): +/// a clash becomes the first free " N", N from 2. Returns the renames as (old, new). +pub(crate) fn rename_clashing_presets(saved: &mut [GradientPreset]) -> Vec<(String, String)> { + if saved.is_empty() { + return vec![]; + } + let builtin: std::collections::HashSet = builtin_presets().map(|e| e.name.to_ascii_lowercase()).collect(); + let mut taken: std::collections::HashSet = + builtin.iter().cloned().chain(saved.iter().map(|p| p.name.trim().to_ascii_lowercase())).collect(); + let mut kept = std::collections::HashSet::new(); + // The next suffix to try for each name, so many presets with one name take linear time. + let mut next: std::collections::HashMap = std::collections::HashMap::new(); + let mut renamed = vec![]; + for p in saved { + let key = p.name.trim().to_ascii_lowercase(); + if !builtin.contains(&key) && kept.insert(key.clone()) { + continue; + } + let base = p.name.trim(); + let n = next.entry(key).or_insert(2); + let name = loop { + let candidate = format!("{base} {n}"); + *n += 1; + if !taken.contains(&candidate.to_ascii_lowercase()) { + break candidate; + } + }; + taken.insert(name.to_ascii_lowercase()); + kept.insert(name.to_ascii_lowercase()); + renamed.push((std::mem::replace(&mut p.name, name.clone()), name)); + } + renamed +} + impl Session { /// The Gradient panel's presets: the built-in ones by library, then the saved ones. pub fn gradient_presets(&self) -> Vec { diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 020b483f3..a134eb1c5 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}", + "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, check ), @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -113,7 +113,7 @@ pub fn specs() -> Vec { "Link Info", [], None, - "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size", + "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size; under automation read roots, a link whose file is outside the roots has no link, path or location, and fileSize and modified come only from the file", has_doc, info ), @@ -882,17 +882,35 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio } /// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in -/// the layers), with `refused`: why the path isn't followed, when it isn't. +/// the layers), with `refused`: why the path isn't followed, when it isn't. `path` is left out +/// where [`path_shown`] says so. fn rows<'a>(gs: impl Iterator, doc_path: Option<&str>) -> Vec { gs.filter(|g| !g.ids.is_empty()) .map(|g| { - let mut row = json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) }); + let mut row = json!({ "name": g.link.name(), "ids": ids_json(&g.ids) }); + note_path(&mut row, "path", &g.link.path, doc_path); note_refused(&mut row, &g.link, doc_path); row }) .collect() } +/// May a reply to the caller asking now carry the link path `path` (of the document saved at +/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a +/// relative path taken from the document's folder) lies inside them. +fn path_shown(path: &str, doc_path: Option<&str>) -> bool { + crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok()) +} + +/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`. +fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) { + if path_shown(path, doc_path) { + row[key] = json!(path); + } else if let Some(o) = row.as_object_mut() { + o.remove(key); + } +} + /// `row["refused"]`: why the path of `link` isn't followed, when it isn't ([`refused`]). fn note_refused(row: &mut Value, link: &LinkInfo, doc_path: Option<&str>) { if let Some(r) = refused(link, doc_path) { @@ -1188,10 +1206,12 @@ fn check(s: &mut Session, p: &Value) -> Result { let (status, found) = probe(g, LinkBase::of(st)); missing += usize::from(status == Status::Missing); modified += usize::from(status == Status::Modified); - let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) }); - note_refused(&mut row, &g.link, LinkBase::of(st).doc); + let doc_path = LinkBase::of(st).doc; + let mut row = json!({ "name": g.link.name(), "status": status.as_str(), "ids": ids_json(&g.ids) }); + note_path(&mut row, "path", &g.link.path, doc_path); + note_refused(&mut row, &g.link, doc_path); if let Some(f) = found.filter(|f| f.path != g.link.path) { - row["found"] = json!(f.path); + note_path(&mut row, "found", &f.path, doc_path); } if stale(&st.doc, g) { row["preview"] = json!(true); @@ -1663,14 +1683,15 @@ fn list(s: &mut Session, p: &Value) -> Result { o.insert("linked".into(), json!(link.is_some())); o.insert("status".into(), json!(status)); } + let doc_path = LinkBase::of(st).doc; if let Some(l) = link { - row["path"] = json!(l.path); + note_path(&mut row, "path", &l.path, doc_path); if let Some(page) = l.page { row["page"] = json!(page); } } if let Some(f) = found { - row["found"] = json!(f); + note_path(&mut row, "found", f, doc_path); } if preview { row["preview"] = json!(true); @@ -1811,13 +1832,17 @@ fn placement_json(o: PlacementOptions) -> Value { /// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`. fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { + // Whether the path is shown is judged for the caller asking, before the opener's roots apply. + let shown = path_shown(&l.path, LinkBase::of(st).doc); // A confined caller reads within its own roots; anyone else within what the caller that // opened the document may read (never wider than the caller asking now). let roots = crate::file_access::current().or_else(|| st.opened_by.clone()); - crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, out)); + crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, shown, out)); } -fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { +/// [`file_fields`]; without `shown`, the link's path (`path`, `link`) and folder (`location`) +/// and the size and date the document keeps of its file are left out. +fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, shown: bool, out: &mut Value) { let base = LinkBase::of(st); let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, base).0); let doc_path = base.doc; @@ -1825,13 +1850,20 @@ fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Va let stamp = path.as_deref().and_then(file_stamp); out["status"] = json!(status.as_str()); note_refused(out, l, doc_path); - out["path"] = json!(l.path); + if shown { + out["path"] = json!(l.path); + out["location"] = json!(l.folder()); + } else if let Some(o) = out.as_object_mut() { + for k in ["path", "link", "location"] { + o.remove(k); + } + } out["fileName"] = json!(l.name()); - out["location"] = json!(l.folder()); + let kept = |v: Option| v.filter(|_| shown); for (k, v) in [ ("page", l.page.map(u64::from)), - ("fileSize", stamp.map(|s| s.0).or(l.size)), - ("modified", stamp.and_then(|s| s.1).or(l.modified)), + ("fileSize", stamp.map(|s| s.0).or(kept(l.size))), + ("modified", stamp.and_then(|s| s.1).or(kept(l.modified))), ("created", path.as_deref().and_then(file_created)), ] { if let Some(v) = v { diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 7d7c21227..609936ecf 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -1,5 +1,7 @@ use std::collections::BTreeSet; +use vectorcraft_doc::live::BlendSpineMode; + use super::common::{ DEFAULT_DISTANCE, DEFAULT_STEPS, ease_param, fix_ids, ids_json, is_blend, orientation_param, percent_param, roots_param, selected_of, spacing_param, starts_param, wrap, @@ -61,6 +63,19 @@ fn extend_spine(spec: &mut BlendSpec, old: &[Arc], old_spec: &BlendSpec, k spec.spine = Some(path); } +/// Does `p` set easing (`easingType` or `ramp`)? Easing needs a spine. +fn gives_easing(p: &Value) -> bool { + ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) +} + +/// The Create Spine mode `p` gives as `key`, or `current` when it is omitted or null. +fn spine_mode_param(p: &Value, cmd: &str, key: &str, current: BlendSpineMode) -> Result { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(current), + Some(v) => v.as_str().and_then(BlendSpineMode::from_id).ok_or_else(|| bad(cmd, format!("`{key}` must be one of auto, always, never"))), + } +} + pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result { const C: &str = "object.blend.make"; let roots = roots_param(s, p)?; @@ -69,16 +84,22 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result { } let starts = starts_param(p, &roots)?; let base = first_blend(&s.doc()?.doc, &roots); + let defaults = s.prefs.blend_options.unwrap_or_default(); + // Create Spine applies to a new blend; one taking more keys keeps whether it has a spine. + let create_spine = spine_mode_param(p, C, "createSpine", defaults.create_spine)?; let cur = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation }, - _ => s.prefs.blend_options.unwrap_or_default(), + Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation, ..defaults }, + _ => defaults, }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); - let (ease0, color0) = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), - _ => Default::default(), + let (ease0, color0, no_spine) = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift, spec.no_spine), + _ => (Default::default(), 0.0, !create_spine.creates_spine()), }; + if no_spine && gives_easing(p) { + return Err(bad(C, "the blend has no spine")); + } let ease = ease_param(p, C, ease0)?; let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; let id = s.edit("Make Blend", |d, sel| { @@ -91,7 +112,7 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result { n.knockout = vectorcraft_doc::Knockout::On; n }); - let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() }; + let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, no_spine, ..Default::default() }; if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { extend_spine(&mut spec, old, old_spec, &children); } @@ -346,9 +367,12 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result { // Nothing to set them on: the options new blends start with (easing is per blend). let d = s.prefs.blend_options.unwrap_or_default(); let spacing = spacing_param(p, C, d.spacing)?; - s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); + s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation), ..d }); return Ok(json!({ "defaults": true })); }; + if gives_easing(p) && any_without_spine(s) { + return Err(bad(C, "the blend has no spine")); + } let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); let ease = ease_param(p, C, cur.ease)?; @@ -384,6 +408,7 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result { v["colorShift"] = json!(spec.color_shift); v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); + v["hasSpine"] = json!(!spec.no_spine); if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) && let Some((path, anchors)) = live::blend_spine(children, &spec) && let Some(sp) = path.subpaths.first() @@ -403,6 +428,19 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result { }) } +/// The Blend panel's Create Spine: with `mode`, sets whether new blends get a spine; without, reads +/// it. A tool setting kept with the preferences, not an undo step; blends already made keep theirs. +pub(super) fn blend_create_spine(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.createSpine"; + let mut d = s.prefs.blend_options.unwrap_or_default(); + let mode = spine_mode_param(p, C, "mode", d.create_spine)?; + if mode != d.create_spine { + d.create_spine = mode; + s.prefs.blend_options = Some(d); + } + Ok(json!({ "mode": mode.id() })) +} + pub(super) fn blend_expand(s: &mut Session, _: &Value) -> Result { let blends = selected_of(s, is_blend); let ids = s.edit("Expand Blend", |d, sel| { @@ -507,6 +545,8 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?; d.remove(path)?; if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) { + // A blend made without a spine gets this one. + spec.no_spine = false; spec.spine = Some(spine); spec.key_anchors.clear(); keys_to_spine(children, spec); @@ -517,7 +557,16 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { ok() } +/// Is any selected blend one made without a spine? +fn any_without_spine(s: &Session) -> bool { + let Ok(st) = s.doc() else { return false }; + selected_of(s, is_blend).iter().any(|b| matches!(st.doc.node(*b).map(|n| &n.kind), Some(NodeKind::Blend { spec, .. }) if spec.no_spine)) +} + pub(super) fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { + if any_without_spine(s) { + return Err(bad("object.blend.reverseSpine", "the blend has no spine")); + } edit_blends(s, "Reverse Spine", |children, spec| { if spec.spine.is_some() && pin(children, spec) { // The keys keep their anchors, which now count from the other end. diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index b69b4e869..5480f7ae5 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -1,6 +1,6 @@ use super::blend::{ - blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, blend_reverse_stack, - spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, + blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, + blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, }; use super::common::{has_blend, has_envelope, has_mesh}; use super::envelope::{ @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Blend"], Some("Cmd+Alt+B"), - "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", + "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even), createSpine?: auto|always|never (default: the mode object.blend.createSpine sets; never: a blend without a spine, which takes no easing)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name, transparency and whether it has a spine) instead of nesting → {id}", has_doc, blend_make ), @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Release", ["Object", "Blend"], Some("Cmd+Alt+Shift+B"), - "{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", + "{} release the selected blends: the key objects come back and the spine (if the blend has one) stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", has_blend, blend_release ), @@ -40,6 +40,15 @@ pub fn specs() -> Vec { always, blend_options ), + cmd!( + "object.blend.createSpine", + "Create Spine", + [], + None, + "{mode?: auto|always|never} the Blend panel's Create Spine: whether new blends get a spine (auto and always: the straight lines between the key centers, as before; never: no spine to show, edit, reverse or release, and no easing, until Replace Spine gives one); with no `mode`, reads it; blends already made keep theirs (a tool setting kept with the preferences, not an undo step) → {mode}", + always, + blend_create_spine + ), cmd!( "object.blend.expand", "Expand", @@ -54,7 +63,7 @@ pub fn specs() -> Vec { "Replace Spine", ["Object", "Blend"], None, - "{} use the selected path as the spine of the selected blend (the path is consumed)", + "{} use the selected path as the spine of the selected blend (the path is consumed); a blend made without a spine gets this one", has_blend, blend_replace_spine ), @@ -63,7 +72,7 @@ pub fn specs() -> Vec { "Reverse Spine", ["Object", "Blend"], None, - "{} reverse the order of the key objects along the spine", + "{} reverse the order of the key objects along the spine; an error when a selected blend has no spine", has_blend, blend_reverse_spine ), @@ -72,7 +81,7 @@ pub fn specs() -> Vec { "Blend Info", [], None, - "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", + "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], hasSpine?: false for a blend made without a spine (no `spine` then), spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", always, blend_info ), diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 8ea016db6..1dd154836 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -871,7 +871,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { Ok(out) } -fn has_image(s: &Session) -> std::result::Result<(), String> { +pub(crate) fn has_image(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or("no document open")?; if st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))) { @@ -1306,13 +1306,16 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result { let symbols = d.symbol_boxes(); let boxes: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, d.node(*id)?.geometric_bounds_with(&symbols)?))).collect(); let mut converted = vec![]; + // The highest artboard id the document has, kept up to date as artboards are added. + let mut top = d.artboards.iter().map(|a| a.id).max(); for (id, b) in boxes { if b.width() <= 0.0 || b.height() <= 0.0 { continue; } // Named one past the highest artboard id the document has, whatever id it is given. - let number = d.artboards.iter().map(|a| a.id).max().map_or(1, |m| m.saturating_add(1)); - let aid = d.next_artboard_id(); + let number = top.map_or(1, |m| m.saturating_add(1)); + let aid = d.next_artboard_id_above(top); + top = top.max(Some(aid)); d.artboards.push(vectorcraft_doc::Artboard { id: aid, name: format!("Artboard {number}"), diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index 20dc187ab..bd15d748c 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Artboard, Background, ColorMode, Document, Unit}; use vectorcraft_geom::{Point, Rect}; -use super::{CommandSpec, Result, Session, always, bad, clock_date, cmd, date_param, str_param}; +use super::{CommandSpec, Result, Session, always, bad, clock_date, cmd, date_param, str_param, switch_or}; pub fn specs() -> Vec { vec![ @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "New Document Presets", [], None, - "{category?: \"Recent\"|\"Saved\"|\"Mobile\"|\"Web\"|\"Print\"|\"Film & Video\"|\"Art & Illustration\"|\"Branding\"|\"Social\"} the New Document presets by category (Recent: the last sizes used; Saved: the user's presets) → {categories: [{name, presets: [{name, size: \"1920 × 1080 px\", width: pt, height: pt, units, orientation, artboards, artboardLayout: {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode}]}]}; file.new {preset: name} starts from one", + "{category?: \"Recent\"|\"Saved\"|\"Mobile\"|\"Web\"|\"Print\"|\"Film & Video\"|\"Art & Illustration\"|\"Branding\"|\"Social\"} the New Document presets by category (Recent: the last sizes used; Saved: the user's presets) → {categories: [{name, presets: [{name, size: \"1920 × 1080 px\", width: pt, height: pt, units, orientation, artboards, artboardLayout: {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode, alignNewObjectsToPixelGrid (on in the Mobile and Web presets)}]}]}; file.new {preset: name} starts from one", always, list ), @@ -144,6 +144,10 @@ pub struct DocSettings { #[serde(deserialize_with = "de_ppi")] pub raster_effects_ppi: f64, pub preview_mode: PreviewMode, + /// Align New Objects to Pixel Grid ([`vectorcraft_doc::DocSetup::align_new_objects_to_pixel_grid`]): + /// on in the Mobile and Web presets, off in the others and in a saved or Recent preset from + /// before it. + pub align_new_objects_to_pixel_grid: bool, } impl Default for DocSettings { @@ -163,6 +167,7 @@ impl Default for DocSettings { color_mode: ColorMode::Rgb, raster_effects_ppi: 72.0, preview_mode: PreviewMode::Default, + align_new_objects_to_pixel_grid: false, } } } @@ -211,6 +216,7 @@ impl DocSettings { "colorMode": color_mode_id(self.color_mode), "rasterEffectsPpi": self.raster_effects_ppi, "previewMode": self.preview_mode.id(), + "alignNewObjectsToPixelGrid": self.align_new_objects_to_pixel_grid, }) } @@ -273,6 +279,7 @@ impl DocSettings { if let Some(m) = p.get("previewMode").filter(|v| !v.is_null()) { self.preview_mode = m.as_str().and_then(PreviewMode::parse).ok_or_else(|| bad(cmd, "previewMode must be default, pixel or overprint"))?; } + self.align_new_objects_to_pixel_grid = switch_or(p, "alignNewObjectsToPixelGrid", self.align_new_objects_to_pixel_grid, cmd)?; let extent = self.boards().iter().fold(Rect::ZERO, |a, r| a.union(*r)); if extent.width().max(extent.height()) + self.bleed.iter().fold(0.0f64, |a, b| a.max(*b)) * 2.0 > crate::MAX_COORD { return Err(bad(cmd, "the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size")); @@ -330,6 +337,7 @@ impl DocSettings { d.raster_effects_ppi = self.raster_effects_ppi; d.setup.bleed = self.bleed; d.setup.background = self.background; + d.setup.align_new_objects_to_pixel_grid = self.align_new_objects_to_pixel_grid; d.artboards = self .boards() .into_iter() @@ -543,6 +551,10 @@ pub const CATEGORIES: &[Category] = &[ ), ]; +/// The built-in categories whose presets start with Align New Objects to Pixel Grid on +/// (). +const PIXEL_ALIGNED_CATEGORIES: [&str; 2] = ["Mobile", "Web"]; + /// The category tabs in order: Recent, Saved, then the built-in categories. pub fn category_names() -> impl Iterator { ["Recent", "Saved"].into_iter().chain(CATEGORIES.iter().map(|c| c.0)) @@ -551,7 +563,8 @@ pub fn category_names() -> impl Iterator { /// The built-in presets of one category: screen sizes in pixels, the others in `general` /// (Preferences ▸ Units ▸ General). fn builtin(c: &Category, general: Unit) -> impl Iterator + '_ { - let (_, color_mode, ppi, presets) = *c; + let (category, color_mode, ppi, presets) = *c; + let align = PIXEL_ALIGNED_CATEGORIES.contains(&category); presets.iter().map(move |&(name, w, h, units)| DocSettings { name: name.into(), width: units.to_pt(w), @@ -559,6 +572,7 @@ fn builtin(c: &Category, general: Unit) -> impl Iterator + ' units: if units == Unit::Pixels { units } else { general }, color_mode, raster_effects_ppi: ppi, + align_new_objects_to_pixel_grid: align, ..DocSettings::default() }) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 64157a20f..5eee2d3fc 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -37,7 +37,8 @@ pub(crate) use bounds::{bounds_rewrite, object_bounds_transform}; pub use commands::specs; pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; pub(crate) use pixelgrid::{ - can_align, keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, snap_if_aligned, + align_new_object, can_align, keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, + snap_if_aligned, }; pub(super) use shape::recut; pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects}; diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index eb019a88e..5c66ec844 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Reflect…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical), origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, reflect ), diff --git a/crates/engine/src/cmd/object/pixelgrid.rs b/crates/engine/src/cmd/object/pixelgrid.rs index 6441fda0c..64d47ca0a 100644 --- a/crates/engine/src/cmd/object/pixelgrid.rs +++ b/crates/engine/src/cmd/object/pixelgrid.rs @@ -22,6 +22,16 @@ pub(crate) fn can_align(n: &Node) -> bool { matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) } +/// For an object being drawn into `d`: when the document's Align New Objects to Pixel Grid is on +/// and [`can_align`] accepts `n`, turn `n`'s pixel-aligned property on. The edit that adds it snaps +/// it to the pixel grid ([`refresh`]). Pasted, duplicated, placed and imported objects do not come +/// through here. +pub(crate) fn align_new_object(d: &Document, n: &mut Node) { + if d.setup.align_new_objects_to_pixel_grid && can_align(n) { + n.extras_mut().pixel_aligned = true; + } +} + /// `object.setPixelAligned`: turn the pixel-aligned property of the objects given (or the selected /// objects) on or off. Turning it on snaps them to the pixel grid at once. Every object must be /// one [`can_align`] accepts, else nothing changes. When every object already has the value, the diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 93c4cbf2c..b199f9a35 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector), method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial and conic; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic), dither?: bool (linear, radial and conic fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial or conic `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -641,7 +641,7 @@ fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) { } } -/// Does `p` ask for `span: "selection"` (#2127)? Only a linear, radial or conic gradient the targets +/// Does `p` ask for `span: "selection"` (#2127)? Only a linear, radial, conic or diamond gradient the targets /// would each fit to their own bounds can span them: one with its own vector (`start`/`end`), /// a freeform one or a gradient swatch already has its placement. fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result { @@ -656,7 +656,7 @@ fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result return Err("span: \"selection\" places a `gradient`, not a swatch".into()); } if g.gradient.kind == vectorcraft_color::GradientKind::Freeform || g.freeform.is_some() { - return Err("span: \"selection\" places a linear, radial or conic gradient, not a freeform one".into()); + return Err("span: \"selection\" places a linear, radial, conic or diamond gradient, not a freeform one".into()); } if g.geom.is_some() { return Err( diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index a59e7267e..a6e7f6728 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -5,7 +5,7 @@ //! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with //! `link` ([`super::links`]). //! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options -//! (`dxf`, [`fileio::dxfimport`]). +//! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`). //! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by //! its art (`crop: "bounding"`). //! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by @@ -69,7 +69,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -264,7 +264,7 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result EngineError { @@ -741,17 +741,18 @@ fn install(s: &mut Session, p: &Value) -> Result { } // Installed plug-ins stay installed after a restart (#2541). The module is written first and its // grant only once it is there; when the grant can't be written, the store gets the module it - // held back. A failed write of that module is only logged, and the grant then doesn't cover - // the new module. A stored module that can't be read back isn't replaced by one that needs a - // grant. + // held back. A failed write of that module is only logged: the store then holds the new module, + // which the grant doesn't cover. A write that reports a failure may still have put the old + // module back, so the store is read again to tell which module it holds. A stored module that + // can't be read back isn't replaced by one that needs a grant. let previous = s.stored_plugin_bytes(plugin.id()); let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes); + let holds_new = |s: &Session| s.stored_plugin_bytes(plugin.id()).is_ok_and(|held| held.as_deref() == Some(&bytes[..])); if m.asks_permissions() { let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; - registry::grant(g.clone()); + registry::grant(GrantToken::after_engine_approval(g.clone())); if stored && !s.store_grant(&m.id, Some(g)) { - s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()); - stored = false; + stored = !s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()) && holds_new(s); } } else { // This module takes the id over, so nothing the user approved for the id applies any more. @@ -759,14 +760,16 @@ fn install(s: &mut Session, p: &Value) -> Result { // When the write failed, the old stored module stays in the folder and needs its grant at // the next launch. A grant that can't be removed still approves the old bytes, so the store // gets the module it held back. When that module can't be read or written back, or there - // was none, no module goes with the grant, and the grant stops counting for this session. A - // grants file that is too large or doesn't hold a list approves nothing, and the new module - // stays; one that can't be opened or read may approve the old bytes once it reads again. + // was none, no module goes with the grant, and the grant stops counting for every session in + // the process attached to the folder. A grants file that is too large or doesn't hold a list + // approves nothing, and the new module stays; one that can't be opened or read may approve + // the old bytes once it reads again. + let writes = s.grant_writes(); if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) { let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref())); - stored = !back; + stored = !back && holds_new(s); if !back || matches!(previous, Ok(None)) { - s.ignore_stored_grant(&m.id); + s.ignore_stored_grant(&m.id, writes); } } } @@ -786,7 +789,8 @@ fn install(s: &mut Session, p: &Value) -> Result { if changed { s.store_disabled_plugins(); } - let mut v = describe_installed(®istry::install(plugin)); + let installed = registry::install(plugin).map_err(err)?; + let mut v = describe_installed(&installed); if !unknown.is_empty() { v["unknownCommands"] = json!(unknown); } @@ -885,7 +889,7 @@ fn load_folder(s: &Session, folder: &str) -> Value { None if crate::file_access::current().is_none() => Ok(()), None => Err(format!("automation path rejected: {} isn't valid text", f.display())), }; - match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { + match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_folder_approver()) { Ok(r) => { for id in &r.loaded { s.attach_plugin_storage(id); diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 416a44adb..5c03317fa 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[ "addon.color.strokeToFill", "addon.color.swatchLegend", "addon.contour.set", + "addon.image.removeBackground", "addon.pathtools.addPoints", "addon.pathtools.close", "addon.pathtools.corner", @@ -675,6 +676,7 @@ pub(super) const DENIED: &[&str] = &[ "addon.pen.nextSegment", "effect.refreshDeferred", "graphicStyle.setOptions", + "object.blend.createSpine", "object.blend.options", "object.liquify", "object.pathfinder.options", diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a92b4095a..af09b032d 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -687,6 +687,10 @@ impl Session { for e in self.prefs.keep_folders(&mut p) { log::warn!("{e}"); } + // Saved gradient presets get names no built-in preset or other saved one has. + for (old, new) in super::gradpresets::rename_clashing_presets(&mut p.gradient_presets) { + log::warn!("saved gradient preset {old} is listed as {new}: another preset has its name"); + } // The edit the active tool computes on another thread is applied first (the Preferences // dialog applies its settings here, outside a command). A failure to apply it was the // edit's own, and the preferences apply all the same. @@ -765,7 +769,7 @@ impl Session { pub fn specs() -> Vec { vec![ - cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}", always, get), + cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}; under automation read roots a folder preference outside them is null, and an export preset's folder outside them is left out", always, get), cmd!( query "prefs.set", "Set Preference", @@ -776,7 +780,7 @@ pub fn specs() -> Vec { set ), cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset), - cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list), + cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference; under automation read roots a folder preference's value outside them is null", always, list), cmd!( query "prefs.export", "Export Preferences", @@ -882,8 +886,42 @@ fn import(s: &mut Session, p: &Value) -> Result { Ok(json!({ "imported": values.len() })) } +/// The preferences that name a folder: the [`FOLDER_PREFS`] and the scratch disks. +const PATH_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; + +/// The scratch disk choices that are not folders. +const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; + +/// The preferences as `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show them to the +/// request being served. Under automation read roots, a folder preference ([`PATH_PREFS`]) outside +/// them is `null`, as `ui.inspect` leaves it out, and an export preset's `folder` outside them is +/// left out. An empty value (the default folder) and a scratch +/// disk choice ([`SCRATCH_CHOICES`]) are shown. Without roots the preferences are shown as they are. +fn shown_prefs(prefs: &Prefs) -> Value { + let mut all = prefs.to_json(); + if crate::file_access::current().is_none() { + return all; + } + // The app trims a folder before it uses it. + let hidden = |path: &str| !path.trim().is_empty() && crate::file_access::check_read(path.trim()).is_err(); + for key in PATH_PREFS { + let Some(path) = all[key].as_str() else { continue }; + if hidden(path) && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path.trim())) { + all[key] = Value::Null; + } + } + if let Some(presets) = all.get_mut("screenExportPresets").and_then(Value::as_array_mut) { + for settings in presets.iter_mut().filter_map(|p| p.get_mut("settings").and_then(Value::as_object_mut)) { + if settings.get("folder").and_then(Value::as_str).is_some_and(hidden) { + settings.remove("folder"); + } + } + } + all +} + fn get(s: &mut Session, p: &Value) -> Result { - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); match str_param(p, "key") { Some(k) if spec(k).is_some() || PREF_GROUPS.contains(&k) => Ok(all[k].clone()), Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))), @@ -911,7 +949,7 @@ fn set(s: &mut Session, p: &Value) -> Result { if document_units && values.contains_key("unitsGeneral") && s.active().is_some() { s.set_document_units(s.default_units())?; } - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect())) } @@ -925,11 +963,11 @@ fn reset(s: &mut Session, p: &Value) -> Result { let mut next = s.prefs.clone(); next.reset(cat); s.apply_prefs(next); - Ok(json!({ "prefs": s.prefs.to_json() })) + Ok(json!({ "prefs": shown_prefs(&s.prefs) })) } fn list(s: &mut Session, _: &Value) -> Result { - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); Ok(Value::Array( PREF_SPECS .iter() diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index b75066ec6..3424da3dd 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -17,7 +17,9 @@ //! store can, an announcement that lasts as long as the app ([`RecoveryStore::announce`]: the //! browser's Web Locks). Only areas nobody holds any more (their app is gone) are offered, and //! while their copies are restored or discarded the area is held, so two apps launched together -//! never both take it. +//! never both take it. An area's lock file or folder can be removed while its app runs, so each +//! recovery save and heartbeat checks that the lock still holds the area, and locks it again or +//! takes a new area. //! //! A heartbeat alone can't tell a gone app from one whose timers stopped (a background tab), so //! when unsure an area counts as held, and its app repairs what another app did meanwhile: each @@ -100,6 +102,12 @@ pub trait RecoveryStore: Send + Sync { fn lock(&self, _area: &str) -> std::result::Result { Ok(Lock::Unsupported) } + /// Does `hold`, which [`RecoveryStore::lock`] gave for area `area`, still hold it? A lock on a + /// file or folder that was removed or replaced since holds nothing; when that can't be told now, + /// it does (default: it does). + fn still_held(&self, _area: &str, _hold: &(dyn std::any::Any + Send + Sync)) -> bool { + true + } /// Stores without locks: also hold area `area` in a way other apps can see that lasts exactly as /// long as this app runs, if the store has one (the browser's Web Locks) → it, until dropped. fn announce(&self, _area: &str) -> Option { @@ -416,6 +424,16 @@ impl RecoveryStore for FolderStore { Self::create_area(&dir)?; Self::lock_area(&dir) } + fn still_held(&self, area: &str, hold: &(dyn std::any::Any + Send + Sync)) -> bool { + let (Some(file), Ok(dir)) = (hold.downcast_ref::(), self.area(area)) else { return true }; + let path = dir.join(LOCK_FILE); + // Only a lock file or folder that is gone, or another one in its place, ends the hold. One + // that can't be read now (a network folder that doesn't answer) still counts as held. + match std::fs::symlink_metadata(&path) { + Ok(_) => Self::own_area(&dir).is_ok() && Self::still_named(file, &path), + Err(e) => e.kind() != std::io::ErrorKind::NotFound, + } + } fn remove_area(&self, area: &str) { // Best effort: whatever is left is tidied at a later launch. A folder that isn't empty // (copies came back meanwhile) stays. The caller still holds the lock here. @@ -457,6 +475,10 @@ pub struct Recovery { /// heartbeat it wrote there: that one doesn't make them look running, a newer one does (their /// app was only paused and came back). adopted: BTreeMap, + /// Copies in an area this session had to leave (`keep_hold`), by document uid, with their + /// store: each stays until its document has a copy in the new area, or needs none. They are + /// still this session's: never offered or discarded as a crash's here. + left: BTreeMap, String)>, } impl Recovery { @@ -526,14 +548,81 @@ fn beating(store: &dyn RecoveryStore, area: &str, stale: i64, mine: Option) /// Refresh this session's hold on its area (the app does this every [`HEARTBEAT_EVERY`] seconds, /// so also on its first frame after its timers were paused): its heartbeat in a store without -/// locks, and its copies ([`repair`]) → the documents whose copies went missing (write them now). -/// Best effort: a missed beat only lets another app offer the copies once three intervals have -/// passed. +/// locks, and its copies ([`repair`]) → the documents whose copies went missing (write them now), +/// and those whose only copy is in an area the session left (`keep_hold`). Best effort: a missed +/// beat only lets another app offer the copies once three intervals have passed. pub fn heartbeat(s: &mut Session) -> Vec { + let mut lost = keep_hold(s); if let Some(own) = s.recovery.own.as_ref().filter(|o| o.beats) { let _ = beat(own.store.as_ref(), &own.area); } - repair(s) + // A copy that was being written when the session left its area finished there. A document + // that gets no copy now (a complex one) keeps the copy it has. + lost.extend( + s.recovery + .left + .keys() + .filter(|&&uid| s.documents().iter().any(|d| d.uid == uid && d.recovery.is_none() && skip_reason(s, d).is_none())) + .copied(), + ); + lost.extend(repair(s)); + lost.sort_unstable(); + lost.dedup(); + lost +} + +/// Check that this session's lock still holds its area (stores with locks). The area's lock file, +/// or the whole area, can be removed while the app runs (by the user, a cleaner, another program), +/// and then another app can lock the area and offer or delete this session's copies as a crash's. +/// The area is locked again, made again first if it went (its copies are then written again). +/// When another app holds it now, the session takes a new area, and each copy left in the old one +/// stays until its document has a copy in the new one. → the documents whose copies must be +/// written again. +fn keep_hold(s: &mut Session) -> Vec { + drop_left_done(s); + let Some(own) = &mut s.recovery.own else { return vec![] }; + if own.beats || own.hold.as_deref().is_none_or(|h| own.store.still_held(&own.area, h)) { + return vec![]; + } + let (store, area) = (own.store.clone(), own.area.clone()); + // The lock held is on a file the area no longer has, so it holds nothing: it goes first (a + // removed file this app still has open can keep a new lock file from being made). + own.hold = None; + if let Ok(Lock::Held(h)) = store.lock(&area) { + own.hold = Some(h); + return repair(s); + } + let mut lost = vec![]; + for st in &mut s.docs { + if let Some(c) = st.recovery.take() { + s.recovery.left.insert(st.uid, (store.clone(), c.file)); + lost.push(st.uid); + } + } + s.recovery.own = None; + // Best effort: when no area can be taken now, the next recovery save says why. + let _ = claim(s, &store); + lost +} + +/// The copies left in an area this session left whose documents were saved, reverted or closed +/// since go. +fn drop_left_done(s: &mut Session) { + let done: Vec = s.recovery.left.keys().filter(|&&uid| !s.documents().iter().any(|d| d.uid == uid && d.is_dirty())).copied().collect(); + for uid in done { + drop_left(s, uid); + } +} + +/// Document `uid`'s copy in an area this session left goes. A copy that can't be removed now stays +/// this session's, and a later heartbeat removes it. +fn drop_left(s: &mut Session, uid: u64) { + let Some((store, file)) = s.recovery.left.get(&uid) else { return }; + let doc = store.remove(&format!("{file}{DOC_EXT}")); + let meta = store.remove(&format!("{file}{META_EXT}")); + if doc.is_ok() && meta.is_ok() { + s.recovery.left.remove(&uid); + } } /// Forget this session's copies that are missing from its area, so the next recovery save writes @@ -555,10 +644,13 @@ pub fn repair(s: &mut Session) -> Vec { /// This session's area in `store`, taken now if it has none there yet: a fresh area, held for the /// session's lifetime (locked, or with a heartbeat and an announcement where the store has them). pub fn claim(s: &mut Session, store: &Arc) -> Result { - if let Some(own) = &s.recovery.own { - if own.store.location() == store.location() { + if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) { + // Documents whose copies went are left without one, so the caller writes them again. + keep_hold(s); + if let Some(own) = &s.recovery.own { return Ok(own.area.clone()); } + } else if s.recovery.own.is_some() { // The store moved (the preference names another folder): copies start again there. release(s); } @@ -588,6 +680,11 @@ pub fn release(s: &mut Session) { for st in &mut s.docs { st.recovery = None; } + // Copies left in an area this session had to leave go too. + let left: Vec = s.recovery.left.keys().copied().collect(); + for uid in left { + drop_left(s, uid); + } let Some(Own { store, area, hold, .. }) = s.recovery.own.take() else { return }; let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area); for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) { @@ -750,12 +847,16 @@ pub struct RecoveryJob { pub title: String, meta: Meta, save: SaveJob, + /// The store the copy goes to (the preference can name another folder before it is finished). + store: Arc, } impl RecoveryJob { /// Encode the snapshot and store it with its details and the images the area doesn't have - /// yet → `{file, title, bytes (the document's), imageBytes (images written now)}`. - pub fn write(&self, store: &dyn RecoveryStore) -> std::result::Result { + /// yet, in the store the job was made for → `{file, title, bytes (the document's), imageBytes + /// (images written now)}`. + pub fn write(&self) -> std::result::Result { + let store = self.store.as_ref(); let doc = self.save.doc(); // A linked image keeps its preview, as a save writes it. let linked = doc.linked_only_images(); @@ -808,13 +909,35 @@ impl RecoveryJob { /// Once stored: the document (if still open) remembers the copy, so it isn't written again /// while unchanged and goes when the document is saved or closed. A document saved or closed - /// meanwhile has nothing left to recover: its copy goes at once. + /// meanwhile has nothing left to recover: its copy goes at once. So does a copy written in an + /// area the session left meanwhile (`keep_hold`), and its document gets a new one. pub fn finish(self, s: &mut Session) { let snapshot = self.save.snapshot().clone(); let copy = RecoveryCopy { file: self.file.clone(), doc: snapshot }; + let area = self.file.split_once('/').map(|(a, _)| a); + let current = s.recovery.own.as_ref().is_some_and(|o| o.store.location() == self.store.location() && Some(o.area.as_str()) == area); match s.document_mut(self.uid).filter(|st| st.is_dirty()) { - Some(st) => st.recovery = Some(copy), - None => remove_copy(s, &self.file), + Some(st) if current => { + st.recovery = Some(copy); + drop_left(s, self.uid); + } + // Written in an area left meanwhile: kept, in place of the copy left there before, + // until the document has a copy in the new area. + Some(st) if st.recovery.is_none() => { + let before = s.recovery.left.insert(self.uid, (self.store.clone(), self.file.clone())); + // The copy left there before, unless this one was written over it. Best effort, + // as in `remove_copy`. + if let Some((store, file)) = before.filter(|(old, f)| old.location() != self.store.location() || *f != self.file) { + let _ = store.remove(&format!("{file}{DOC_EXT}")); + let _ = store.remove(&format!("{file}{META_EXT}")); + } + } + // Best effort, as in `remove_copy`. + _ => { + let _ = self.store.remove(&format!("{}{DOC_EXT}", self.file)); + let _ = self.store.remove(&format!("{}{META_EXT}", self.file)); + drop_left(s, self.uid); + } } } } @@ -867,6 +990,11 @@ fn fresh_name(area: &str, stem: &str, taken: &[String]) -> String { /// when it has none and a copy is needed) → (jobs, skipped: `[{title, reason}]` for modified /// documents that get none). pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec, Vec)> { + // A lost lock is taken again first: copies that go with it are written again, even of + // documents unchanged since. + if s.recovery.own.as_ref().is_some_and(|o| o.store.location() == store.location()) { + keep_hold(s); + } // Copies another app removed are written again. repair(s); let skipped: Vec = s @@ -923,7 +1051,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec Result<()> { return Err(EngineError::Other("a document with unsaved changes has no recovery copy".into())); } for j in jobs { - j.write(store.as_ref()).map_err(EngineError::Other)?; + j.write().map_err(EngineError::Other)?; j.finish(s); } Ok(()) @@ -966,6 +1094,8 @@ pub fn forget(s: &mut Session, uid: u64) { if s.script_in_run { return; } + // So does a copy of it left in an area this session left. + drop_left(s, uid); let Some(copy) = s.document_mut(uid).and_then(|st| st.recovery.take()) else { return }; remove_copy(s, ©.file); } @@ -975,12 +1105,16 @@ pub fn forget_all(s: &mut Session) { release(s); } -/// The copies of documents with no unsaved changes left (undone back to the saved state) go. +/// The copies of documents with no unsaved changes left (undone back to the saved state) go, and +/// the copies left in an area this session left of documents closed since. pub fn forget_clean(s: &mut Session) { let uids: Vec = s.documents().iter().filter(|d| !d.is_dirty() && d.recovery.is_some()).map(|d| d.uid).collect(); for uid in uids { forget(s, uid); } + if !s.script_in_run { + drop_left_done(s); + } } fn save(s: &mut Session, _: &Value) -> Result { @@ -988,7 +1122,7 @@ fn save(s: &mut Session, _: &Value) -> Result { let (jobs, skipped) = jobs(s, &store)?; let mut saved = vec![]; for j in jobs { - saved.push(j.write(store.as_ref()).map_err(EngineError::Other)?); + saved.push(j.write().map_err(EngineError::Other)?); j.finish(s); } forget_clean(s); @@ -1057,9 +1191,15 @@ fn open_copies(s: &Session) -> Vec { s.documents().iter().filter_map(|d| d.recovery.as_ref()).map(|c| c.file.clone()).collect() } +/// The copies of documents open now in this session that are in an area it left, in `store`. +fn left_copies(s: &Session, store: &dyn RecoveryStore) -> Vec { + s.recovery.left.values().filter(|(st, _)| st.location() == store.location()).map(|(_, f)| f.clone()).collect() +} + fn list(s: &mut Session, _: &Value) -> Result { let store = need_store(s, "file.recovery.list")?; let open = open_copies(s); + let left = left_copies(s, store.as_ref()); let copies = copies_in(&store.list().map_err(EngineError::Other)?); let mut rows = vec![]; for area in store.areas().map_err(EngineError::Other)? { @@ -1071,7 +1211,7 @@ fn list(s: &mut Session, _: &Value) -> Result { let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title }; rows.push(json!({ "file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved, - "open": who == Owner::Mine && open.contains(f), "running": who == Owner::Running, + "open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running, })); } // A gone app's empty area is tidied away. @@ -1095,7 +1235,14 @@ fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) -> { return Err(bad(cmd, format!("no recovery copy `{f}` (see file.recovery.list)"))); } - let picked = |c: &str| file.is_none_or(|f| f == c); + // A copy this session left in an area it had to leave is still its own. + let left = left_copies(s, store); + if let Some(f) = file + && left.iter().any(|c| c == f) + { + return Err(bad(cmd, format!("`{f}` is the copy of a document open now"))); + } + let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c); let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect(); let mut out = vec![]; for area in areas { diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index f3bb51200..c809d5809 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -404,6 +404,7 @@ fn gradient_key(g: &Gradient) -> Option> { GradientKind::Radial => 1, GradientKind::Freeform => 2, GradientKind::Conic => 3, + GradientKind::Diamond => 4, }; let method = match g.method { GradientMethod::Classic => 0, diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs index 2dda35614..ec458a0c9 100644 --- a/crates/engine/src/cmd/typecmd/block_tests.rs +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -140,6 +140,35 @@ fn block_turns_off_keeping_its_sizes_and_records_nothing_when_nothing_changes() assert_eq!(undo_len(&s), n + 2, "already off: no undo step"); } +#[test] +fn convert_to_point_type_turns_block_off_keeping_its_sizes() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "BIG\na much longer second line"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let sizes = |t: &TextObject| t.runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.leading)).collect::>(); + let fitted = sizes(&text(&s, id)); + let n = undo_len(&s); + s.execute("type.convertToPointType", &json!({})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + let t = text(&s, id); + assert!(matches!(t.kind, TextKind::Point)); + assert!(!t.dynamic_text, "point type has Dynamic Text off"); + assert_eq!(sizes(&t), fitted, "point type keeps the sizes Block set"); + assert!(serde_json::to_value(&t).unwrap().get("dynamicText").is_none(), "saved without dynamicText"); + // Back to area type, the frame is not refitted: Block stays off. + s.execute("type.convertToAreaType", &json!({})).unwrap(); + let t = text(&s, id); + assert!(matches!(t.kind, TextKind::Area { .. })); + assert!(!t.dynamic_text, "Block does not come on again"); + assert_eq!(sizes(&t), fitted); + // Undo gives the Block frame back. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_eq!(sizes(&t), fitted); +} + #[test] fn bad_params_and_other_type_are_refused() { let mut s = session(); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 03681c556..bb0e426c9 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Convert To Point Type", ["Type"], None, - "{ids?|id?} area type → point type; soft line wraps become line breaks, and a frame of threaded text leaves its thread with the text it holds → {ids}", + "{ids?|id?} area type → point type; soft line wraps become line breaks, a frame of threaded text leaves its thread with the text it holds, and Dynamic Text (Block) is turned off, keeping the sizes it set → {ids}", has_selection, to_point ), @@ -69,6 +69,15 @@ pub fn specs() -> Vec { has_dynamic_type, dynamic_text ), + cmd!( + "type.dynamicTextPreset", + "Dynamic Text Presets", + ["Type", "Dynamic Text Presets"], + None, + "{preset: \"circle\"|\"arch\"|\"bow\", position?: \"above\"|\"center\"|\"below\", flip?: bool, ids?|id?} put the selected point type and type on a path on a new path of the preset's shape with Dynamic Text on, so the text is sized to fill it (see type.dynamicText). Circle: a circle as wide as the text, the text running clockwise round all of it from its bottom; Arch: an arc of a circle bulging upward over 120°, as wide as the text; Bow: the same arc bulging downward. The width is, for point type, the length on the page of the longer side of the text's bounds, and for type on a path the longer side of its path's bounds on the page (so the same preset again leaves it as it is); the new path is centered where they were, and the shape is upright on the page when the object is rotated or scaled. position (omitted: above) sets Type on a Path Options › Align to Path: above stands the type on the path (descender), center centers it on the path (center), below hangs it from the path (ascender); flip true then turns the text to the other side of its path, running the other way. Type on a Path Options › effect and spacing stay. Area type in the selection is left as it is; a selection holding type on a path in a thread is refused. When nothing changes, no undo step is recorded → {ids, fitScale (the first object's factor)}", + has_preset_type, + dynamic_text_preset + ), cmd!( "type.fillPlaceholder", "Fill with Placeholder Text", @@ -401,6 +410,9 @@ fn to_point(s: &mut Session, p: &Value) -> Result { Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1, _ => x0, }; + // Point type has no Dynamic Text: Block is turned off, keeping the sizes it set, so + // converting back to area type does not turn it on again. + t.dynamic_text = false; t.kind = TextKind::Point; t.xf *= Affine::translate((ox, baseline)); refresh_bounds(t); @@ -648,6 +660,196 @@ pub(crate) fn bake_dynamic_text(t: &mut TextObject) -> bool { true } +/// The shapes of `type.dynamicTextPreset`. +const PRESETS: [&str; 3] = ["circle", "arch", "bow"]; + +/// Point type and type on a path among `ids` and their descendants: the type +/// `type.dynamicTextPreset` puts on a path. +fn preset_ids(d: &Document, ids: &[NodeId]) -> Vec { + text_ids(d, ids) + .into_iter() + .filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point | TextKind::OnPath { .. }))) + .collect() +} + +/// `type.dynamicTextPreset` is enabled for a selection holding point type or type on a path, and +/// no type on a path in a thread. +fn has_preset_type(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + let d = &st.doc; + let ids = preset_ids(d, &st.selection.objects); + if ids.is_empty() { + return Err("select point type or type on a path".into()); + } + let frames = threaded_ids(d, &ids); + if !frames.is_empty() { + return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); + } + Ok(()) +} + +/// The path of Dynamic Text preset `preset` (one of [`PRESETS`]) `w` wide, centered on `c`, with +/// the start bracket the text starts at. Circle: a circle `w` across, run clockwise from its +/// bottom; Arch and Bow: a 120° arc of a circle whose chord is `w`, bulging up or down, run from +/// left to right. +fn preset_path(preset: &str, c: vectorcraft_geom::Point, w: f64) -> (vectorcraft_geom::PathData, f64) { + let h = w / 2.0; + if preset == "circle" { + // `shapes::ellipse` runs clockwise on screen from its left: its bottom is 3/4 of the way. + return (shapes::ellipse(Rect::new(c.x - h, c.y - h, c.x + h, c.y + h)), 0.75); + } + // A chord of `w` under 120°: radius w/√3, and the arc rises a half radius above its chord. + let r = w / 3f64.sqrt(); + let arch = preset == "arch"; + let cy = if arch { c.y - r / 4.0 + r } else { c.y + r / 4.0 - r }; + let circle = Rect::new(c.x - r, cy - r, c.x + r, cy + r); + // `shapes::ellipse_arc` runs counterclockwise on screen: right to left over the top. + let mut path = if arch { + shapes::ellipse_arc(circle, 30.0, 150.0, shapes::EllipseMode::Arc) + } else { + shapes::ellipse_arc(circle, 210.0, 330.0, shapes::EllipseMode::Arc) + }; + if arch { + path.reverse(); + } + (path, 0.0) +} + +/// `t` (point type or type on a path) on the path of `preset` with Dynamic Text on, Align to Path +/// `align`, and flipped when `flip`. The shape is made upright on the page and mapped back through +/// the object's transform, so a rotated or scaled object still shows a circle, an arch or a bow. +/// Point type: the path is as wide as the longer side of the text's layout bounds is long on the +/// page, centered on them; with no layout area (empty text), four ems of its first character +/// wide, centered on its estimated bounds. Type on a path: as wide as the longer side of its +/// path's bounds on the page, centered on them, so the same preset again gives the same path. +/// When the object's transform can't be undone, or undoing it gives a path that isn't finite, the +/// shape is made in the text's own space. +fn with_preset(t: &TextObject, preset: &str, align: vectorcraft_doc::PathAlign, flip: bool) -> TextObject { + let invertible = t.xf.is_finite() && t.xf.determinant() != 0.0 && t.xf.inverse().is_finite(); + // The transform the shape was made upright through, with the shape. + let ((path, start, w), xf) = invertible + .then(|| preset_shape(t, preset, t.xf).map(|s| (s, t.xf))) + .flatten() + .or_else(|| preset_shape(t, preset, Affine::IDENTITY).map(|s| (s, Affine::IDENTITY))) + .unwrap_or_else(|| { + let (path, start) = preset_path(preset, vectorcraft_geom::Point::ZERO, 48.0); + ((path, start, 48.0), Affine::IDENTITY) + }); + let mut out = t.clone(); + out.kind = TextKind::OnPath { path, start, end: None }; + out.path_align = align; + out.dynamic_text = true; + if flip { + out.flip_on_path(); + } + // The same preset again measures the path it made, which can differ in the last bits: a path + // within a billionth of its width of the old one on the page keeps the old one, so nothing + // changes. + if let (TextKind::OnPath { path: old, .. }, TextKind::OnPath { path: new, .. }) = (&t.kind, &mut out.kind) + && same_path(&old.transformed(xf), &new.transformed(xf), w * 1e-9) + { + *new = old.clone(); + } + refresh_bounds(&mut out); + out +} + +/// The path of `preset` for `t` made upright through transform `xf` (see [`with_preset`]), in the +/// text's space, with its start bracket and its width on the page; None when the path isn't +/// finite. +fn preset_shape(t: &TextObject, preset: &str, xf: Affine) -> Option<(vectorcraft_geom::PathData, f64, f64)> { + // How long `(x, y)` in the text's space is on the page. + let on_page = |x: f64, y: f64| (xf * vectorcraft_geom::Point::new(x, y) - xf * vectorcraft_geom::Point::ZERO).hypot(); + // The center on the page, and the width measured (0 when nothing can be measured). + let (c, longer) = match &t.kind { + TextKind::OnPath { path, .. } => path + .transformed(xf) + .bounds() + .filter(|b| b.is_finite()) + .map_or((xf * vectorcraft_geom::Point::ZERO, 0.0), |b| (b.center(), b.width().max(b.height()))), + _ => { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds; + match Some(lay).filter(|b| b.area() > 0.0 && b.is_finite()) { + Some(b) => (xf * b.center(), on_page(b.width(), 0.0).max(on_page(0.0, b.height()))), + None => (xf * Some(t.estimate_bounds()).filter(|b| b.is_finite()).map_or(vectorcraft_geom::Point::ZERO, |b| b.center()), 0.0), + } + } + }; + // Without a measured width: four ems of the first character, else 48 pt. + let size = t.first_style().size; + let w = if longer.is_finite() && longer > 1e-6 { + longer + } else if size.is_finite() && size > 0.0 && on_page(size * 4.0, 0.0) > 1e-6 { + on_page(size * 4.0, 0.0) + } else { + 48.0 + }; + let (path, start) = preset_path(preset, c, w); + let path = path.transformed(xf.inverse()); + let finite = path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()); + (finite && w.is_finite()).then_some((path, start, w)) +} + +/// Whether paths `a` and `b` have the same subpaths, closed alike, with every anchor and handle +/// within `eps` of each other. +fn same_path(a: &vectorcraft_geom::PathData, b: &vectorcraft_geom::PathData, eps: f64) -> bool { + let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= eps; + a.subpaths.len() == b.subpaths.len() + && a.subpaths.iter().zip(&b.subpaths).all(|(s, t)| { + s.closed == t.closed + && s.anchors.len() == t.anchors.len() + && s.anchors.iter().zip(&t.anchors).all(|(x, y)| x.kind == y.kind && near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out)) + }) +} + +/// Type › Dynamic Text Presets: put the selected point type and type on a path on a new path of +/// the preset's shape with Dynamic Text on, in one undo step, and none when nothing changes. +fn dynamic_text_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.dynamicTextPreset"; + let key = "preset"; + let preset = choice(p, C, key, &PRESETS)?.ok_or_else(|| bad(C, format!("`{key}` must be one of {}", PRESETS.join(", "))))?; + let align = match choice(p, C, "position", &["above", "center", "below"])? { + Some("center") => vectorcraft_doc::PathAlign::Center, + Some("below") => vectorcraft_doc::PathAlign::Ascender, + _ => vectorcraft_doc::PathAlign::Descender, + }; + let flip = switch_or(p, "flip", false, C)?; + let (ids, changed) = { + let d = &s.doc()?.doc; + let ids = preset_ids(d, &targets(s, p)?); + if ids.is_empty() { + return Err(bad(C, "select point type or type on a path")); + } + let frames = threaded_ids(d, &ids); + if !frames.is_empty() { + return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}"))); + } + let changed: Vec<(NodeId, TextObject)> = ids + .iter() + .filter_map(|id| match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some((*id, with_preset(t, preset, align, flip))).filter(|(_, new)| new != &**t), + _ => None, + }) + .collect(); + (ids, changed) + }; + if !changed.is_empty() { + s.edit("Dynamic Text", |d, _| { + for (id, new) in &changed { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + **t = new.clone(); + } + } + Ok(()) + })?; + } + let d = &s.doc()?.doc; + let fit = + ids.first().and_then(|id| path_text(d.node(*id))).map_or(1.0, |t| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale); + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>(), "fitScale": fit})) +} + // ---------- placeholder / insert ---------- /// VectorCraft's own placeholder copy (not the classical Latin text). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 099f99abd..745a6fb62 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -171,10 +171,6 @@ pub struct Session { /// The Scripts folder, whose scripts File › Scripts lists; `None`: no folder /// ([`Session::attach_scripts_dir`]). scripts_dir: Option, - /// The plug-in ids whose grant in the plug-in store should be gone but could not be removed: - /// [`Session::plugin_approver`] and a load of the store do not count those grants, and a successful - /// [`Session::store_grant`] for the id takes it out. - ignored_plugin_grants: std::sync::Mutex>, /// A filter's output that a run made in the background handed in for `plugin.run` to apply /// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it. plugin_output: Option, @@ -282,6 +278,9 @@ pub struct Session { pub(crate) in_tool_actions: bool, /// While [`Session::preview`] runs its command: its edits are the interaction's own. previewing: bool, + /// The images Remove Background decoded during the interaction's preview steps, emptied when + /// it ends ([`cmd::DecodedImages`]). + pub(crate) decoded_images: cmd::DecodedImages, /// Envelope Options with no envelope selected: the options and fidelity new envelopes get /// (`None`: the reference app's defaults, fidelity 50); not saved. pub(crate) envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>, diff --git a/crates/engine/src/script/files.rs b/crates/engine/src/script/files.rs index 7c3b829a8..ec848a973 100644 --- a/crates/engine/src/script/files.rs +++ b/crates/engine/src/script/files.rs @@ -189,11 +189,15 @@ fn home_folder() -> String { } /// The folders `Folder.temp`, `Folder.desktop` and the others name, `null` where there is none. +/// On the web every one is `null`, and no platform folder is looked up. fn locations(home: &str) -> Data { + if cfg!(target_arch = "wasm32") { + return Data::Object(PLACES.iter().map(|k| (k.to_string(), Data::Null)).collect()); + } let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); let join = |a: &str, b: &str| Path::new(a).join(b).to_string_lossy().into_owned(); let exe = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_string_lossy().into_owned())); - let temp = std::env::temp_dir().to_string_lossy().into_owned(); + let temp = temp_folder().to_string_lossy().into_owned(); let (user_data, app_data, common, system, trash) = if WINDOWS { (env("APPDATA"), env("PROGRAMDATA"), env("CommonProgramFiles"), env("SystemRoot"), None) } else if cfg!(target_os = "macos") { @@ -209,7 +213,6 @@ fn locations(home: &str) -> Data { (Some(data.clone()), Some("/usr/share".to_string()), Some("/usr/share".to_string()), Some("/".to_string()), Some(join(&data, "Trash"))) }; let s = |v: Option| v.map_or(Data::Null, Data::String); - let web = cfg!(target_arch = "wasm32"); let places = vec![ ("appData", app_data), ("appPackage", exe.clone()), @@ -222,7 +225,15 @@ fn locations(home: &str) -> Data { ("trash", trash), ("userData", user_data), ]; - Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), if web { Data::Null } else { s(v) })).collect()) + Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), s(v))).collect()) +} + +/// The names [`locations`] gives. +const PLACES: [&str; 10] = ["appData", "appPackage", "commonFiles", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"]; + +/// The folder for temporary files: the platform's, or on the web the root, where nothing is read. +fn temp_folder() -> std::path::PathBuf { + if cfg!(target_arch = "wasm32") { std::path::PathBuf::from("/") } else { std::env::temp_dir() } } /// The start of an absolute path. @@ -349,12 +360,12 @@ impl Pathname { } } -/// `s` with every character but letters, digits and `-_.!~*'()` written as `%xx` escapes of its +/// `s` with every character but letters, digits and `/-_.!~*'()` written as `%xx` escapes of its /// UTF-8 bytes (RFC 2396). fn encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { - if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) { + if b.is_ascii_alphanumeric() || b"/-_.!~*'()".contains(&b) { out.push(b as char); } else { out.push_str(&format!("%{b:02X}")); @@ -842,7 +853,23 @@ fn temp_name(host: &Host, _: &[Data]) -> Result { let mut disk = host.disk.borrow_mut(); disk.temps += 1; let name = format!("vc{:05}.tmp", disk.temps); - Ok(Data::String(std::env::temp_dir().join(name).to_string_lossy().into_owned())) + Ok(Data::String(temp_folder().join(name).to_string_lossy().into_owned())) +} + +fn locations_of(host: &Host, _: &[Data]) -> Result { + Ok(locations(&host.disk.borrow().home)) +} + +fn encode_text(_: &Host, a: &[Data]) -> Result { + Ok(Data::String(encode(&arg_text(a, 0)))) +} + +fn decode_text(_: &Host, a: &[Data]) -> Result { + Ok(Data::String(decode(&arg_text(a, 0)))) +} + +fn encoding_available(_: &Host, a: &[Data]) -> Result { + Ok(Data::Bool(Encoding::parse(&arg_text(a, 0)).is_some())) } /// The folder `Folder.current` starts at in the run `host` serves: the script's own folder. @@ -851,10 +878,6 @@ fn start_folder(host: &Host, _: &[Data]) -> Result { Ok(Data::String(disk.pathname(&disk.start, &disk.start, false).fs())) } -fn locations_of(host: &Host, _: &[Data]) -> Result { - Ok(locations(&host.disk.borrow().home)) -} - /// Adds `File` and `Folder` to the realm, their host functions acting for the run `link` holds. pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunError> { let unchecked = |f: fn(&Host, &[Data]) -> Result| { @@ -871,7 +894,6 @@ pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunEr ("noPicker".into(), value(NO_PICKER)), ("badMode".into(), value(BAD_MODE)), ]); - let pure = |f: fn(&str) -> Data| HostItem::Function(Rc::new(move |a| Ok(f(&arg_text(&a, 0))))); let system = if WINDOWS { "Windows" } else if cfg!(target_os = "macos") { @@ -884,10 +906,10 @@ pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunEr ("system".into(), value(system)), ("lineFeed".into(), value(if WINDOWS { "Windows" } else { "Unix" })), ("current".into(), unchecked(start_folder)), - ("locations".into(), unchecked(locations_of)), - ("encode".into(), pure(|s| Data::String(encode(s)))), - ("decode".into(), pure(|s| Data::String(decode(s)))), - ("encodingAvailable".into(), pure(|s| Data::Bool(Encoding::parse(s).is_some()))), + ("locations".into(), call(link, locations_of)), + ("encode".into(), call(link, encode_text)), + ("decode".into(), call(link, decode_text)), + ("encodingAvailable".into(), call(link, encoding_available)), ("parse".into(), call(link, parse)), ("relative".into(), call(link, relative)), ("tempName".into(), call(link, temp_name)), @@ -955,7 +977,7 @@ mod tests { #[test] fn uri_escapes_are_utf8_both_ways() { assert_eq!(encode("caf\u{e9} 1"), "caf%C3%A9%201"); - assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()%2Fb"); + assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()/b"); assert_eq!(decode("caf%C3%A9%20x"), "caf\u{e9} x"); assert_eq!(decode("100%"), "100%"); assert_eq!(decode("%E9"), "%E9", "a lone Latin-1 escape is not UTF-8"); diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index a8f0c1833..c3b3084e2 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -1787,6 +1787,10 @@ impl Session { if let Some(e) = job.host.dialogs.take_failure() { return Some(self.script_failed(job, &e)); } + // A canceled request ends a sleep or a wait at this pump. + if vectorcraft_doc::cancel::check().is_err() { + self.script_stop.set(); + } if (job.host.waiting.get() || job.host.asleep().is_some() || job.host.dialogs.is_waiting()) && !self.script_stop.is_set() { JOBS.with(|jobs| jobs.borrow_mut().insert(key.id, job)); self.script_key = Some(key); @@ -1798,9 +1802,6 @@ impl Session { let e = RunError { kind: RunErrorKind::Limit(kind), message: format!("limit reached: {kind:?}"), line: 0, column: 0 }; return Some(self.script_failed(job, &e)); } - if vectorcraft_doc::cancel::check().is_err() { - self.script_stop.set(); - } job.host.calls_this_slice.set(0); let outcome = run_slice(self, &mut job); match outcome { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d39979524..2d850fd44 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -87,7 +87,6 @@ impl Session { color_settings_file: None, plugin_dir: None, scripts_dir: None, - ignored_plugin_grants: Default::default(), plugin_output: None, plugin_install_approval: None, plugin_grants_from_requests: false, @@ -115,6 +114,7 @@ impl Session { recovery: Default::default(), in_tool_actions: false, previewing: false, + decoded_images: Default::default(), envelope_defaults: None, liquify_stroke: None, plane_widget_press: false, diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index 1762c9676..c7d22ab6c 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -335,6 +335,7 @@ impl Session { pub fn commit_interaction(&mut self) -> Result<()> { self.clear_deferred_overlay_cache(); + self.decoded_images.empty(); if let Some(paint) = self.active().and_then(|st| st.interaction.as_ref()).and_then(|it| it.preview_paint.clone()) { self.paint = paint; } @@ -414,6 +415,7 @@ impl Session { pub fn cancel_interaction(&mut self) -> Result<()> { self.clear_deferred_overlay_cache(); + self.decoded_images.empty(); self.pending_paint = None; if let Some(paint) = self .active() diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 9a96f9621..4943353ee 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -39,6 +39,7 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::time::SystemTime; use vectorcraft_pdf::{PdfPreset, builtin_preset}; +use vectorcraft_plugins::permissions::GrantToken; use vectorcraft_plugins::safe_mode; use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError}; @@ -88,6 +89,24 @@ static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); /// The state of each plug-in folder's storage, shared by every storage made for the folder until /// the plug-in is removed, which closes it and drops it. static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); +/// The grants of plug-in stores that the sessions of the process don't count, and when each was +/// last written ([`IgnoredGrants`]). Another process reads `grants.json` as it is. +static IGNORED_PLUGIN_GRANTS: Mutex = Mutex::new(IgnoredGrants { ignored: BTreeSet::new(), written: BTreeMap::new(), writes: 0 }); +/// How many of the latest grant writes [`IgnoredGrants`] keeps. +const MAX_GRANT_WRITES_KEPT: usize = 256; + +/// Grants of plug-in stores, by the store's folder ([`plugin_store_key`]) and the plug-in id. +struct IgnoredGrants { + /// The grants that should be gone but could not be removed: no session in the process counts + /// them ([`Session::ignore_stored_grant`]) until one writes the store's grants for the id + /// ([`Session::store_grant`]). + ignored: BTreeSet<(PathBuf, String)>, + /// The latest grant writes of the process, at most [`MAX_GRANT_WRITES_KEPT`], each with the + /// count of writes it made ([`IgnoredGrants::writes`]). + written: BTreeMap<(PathBuf, String), u64>, + /// How many grant writes the sessions of the process have made ([`Session::grant_writes`]). + writes: u64, +} /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -99,6 +118,37 @@ pub(crate) fn file_stem(name: &str) -> String { format!("{}-{hash:016x}", safe.trim()) } +/// The folder `dir` spelled one way for every path to it, so that sessions attached to it by +/// different paths share [`IGNORED_PLUGIN_GRANTS`]: the canonical path when the folder exists, else +/// the canonical path of its nearest folder that exists followed by the rest of the path, with `.` +/// and `..` taken out by name. The path as given when it has no absolute form (the current folder +/// can't be read). +pub(crate) fn plugin_store_key(dir: &Path) -> PathBuf { + if let Ok(path) = std::fs::canonicalize(dir) { + return path; + } + let Ok(absolute) = std::path::absolute(dir) else { return dir.to_path_buf() }; + let mut parts: Vec = Vec::new(); + for part in absolute.components() { + match part { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if matches!(parts.last(), Some(std::path::Component::Normal(_))) { + parts.pop(); + } + } + _ => parts.push(part), + } + } + for kept in (1..=parts.len()).rev() { + let base: PathBuf = parts[..kept].iter().collect(); + if let Ok(found) = std::fs::canonicalize(&base) { + return parts[kept..].iter().fold(found, |path, part| path.join(part)); + } + } + parts.iter().collect() +} + /// The regular files of `dir` with the extension `ext` (any case), by file name, at most /// [`MAX_STORE_FILES`]; none when the folder can't be read. fn list_files(dir: &Path, ext: &str) -> Vec { @@ -190,6 +240,12 @@ fn lock_disabled(_: &Path) -> Option { thread_local! { /// Tests: [`Session::put_back_plugin`] fails on this thread, as on a full volume. pub(crate) static PUT_BACK_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: removing [`GRANTS_FILE`] fails on this thread while the file still reads, as when + /// another program holds it open. + pub(crate) static GRANTS_DELETE_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: [`Session::put_back_plugin`] writes the old module back on this thread and then + /// reports a failure, as when the last flush of a copy fails. + pub(crate) static PUT_BACK_FAILS_AFTER_WRITE: std::cell::Cell = const { std::cell::Cell::new(false) }; /// Tests: moving a plug-in's settings and cache back from `.removed` ([`move_back`]) /// fails on this thread, as when another program holds a file in the folder. pub(crate) static MOVE_BACK_FAILS: std::cell::Cell = const { std::cell::Cell::new(false) }; @@ -201,7 +257,15 @@ thread_local! { /// Remove `dir/file` → whether it is gone; a file that isn't there is what was wanted. fn take(dir: &Path, file: &str) -> bool { let path = dir.join(file); - match std::fs::remove_file(&path) { + #[cfg(test)] + let removed = if file == GRANTS_FILE && GRANTS_DELETE_FAILS.get() { + Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) + } else { + std::fs::remove_file(&path) + }; + #[cfg(not(test))] + let removed = std::fs::remove_file(&path); + match removed { Err(e) if e.kind() != std::io::ErrorKind::NotFound => { log::warn!("can't remove {}: {e}", path.display()); false @@ -340,9 +404,6 @@ impl Session { let disabled = read_capped(&dir.join(DISABLED_FILE), safe_mode::MAX_DISABLED_FILE_BYTES).map(|b| safe_mode::parse_disabled_file(&b)).unwrap_or_default(); safe_mode::set_disabled(disabled); - if self.plugin_dir.as_ref() != Some(&dir) { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).clear(); - } self.plugin_dir = Some(dir); self.load_stored_plugins(); } @@ -353,7 +414,8 @@ impl Session { /// there don't grant that very module ([`grant_covers`]), is skipped with a log record, and /// stays in the folder with its grant. A grant for an id whose stored module lists no /// permissions is forgotten; one that can't be removed from the grants file doesn't count for - /// the rest of the session ([`Session::plugin_approver`]). + /// any session in the process attached to the folder until the file can be written for the id + /// ([`Session::ignore_stored_grant`]). pub fn load_stored_plugins(&mut self) { let Some(dir) = self.plugin_dir.clone() else { return }; if safe_mode::active() { @@ -389,9 +451,11 @@ impl Session { match grants.get(&m.id).filter(|g| grant_covers(g, &plugin)) { Some(g) => { asks_nothing.remove(&m.id); - vectorcraft_plugins::registry::grant(g.clone()); + vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone())); vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id)); - vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); + if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { + log::warn!("skipping the stored plug-in {}: {e}", path.display()); + } } None => { log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display()) @@ -401,7 +465,9 @@ impl Session { Ok(plugin) => { asks_nothing.insert(plugin.id().to_string()); vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id())); - vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); + if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { + log::warn!("skipping the stored plug-in {}: {e}", path.display()); + } } Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()), } @@ -413,8 +479,11 @@ impl Session { Ok(file) => file.names(id), Err(e) => e.unread, }; - if named && !self.store_grant(id, None) && self.holds_plugin_grant(id) { - self.ignore_stored_grant(id); + if named { + let writes = self.grant_writes(); + if !self.store_grant(id, None) && self.holds_plugin_grant(id) { + self.ignore_stored_grant(id, writes); + } } } } @@ -475,12 +544,37 @@ impl Session { } } - /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those this session - /// doesn't count ([`Session::ignore_stored_grant`]). + /// [`Session::plugin_approver`] for a folder load: a module the store's grants approve gets + /// that grant recorded for the process too, since the registry installs only a module that a + /// recorded grant covers ([`vectorcraft_plugins::registry::install`]). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn plugin_folder_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> { + let grants = self.plugin_dir.as_deref().map(|dir| self.counted_grants(dir)); + move |plugin| { + let m = plugin.manifest(); + match &grants { + Some(grants) if m.asks_permissions() => match grants.get(&m.id).filter(|g| grant_covers(g, plugin)) { + Some(g) => { + vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone())); + true + } + None => false, + }, + _ => vectorcraft_plugins::registry::approved(plugin), + } + } + } + + /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those the process + /// doesn't count for that folder ([`Session::ignore_stored_grant`]). The folder's key is worked + /// out on each call, so a path whose link now leads elsewhere is matched with where it leads. fn counted_grants(&self, dir: &Path) -> BTreeMap { - let ignored = self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()); + let key = plugin_store_key(dir); let mut grants = stored_grants(dir); - grants.retain(|id, _| !ignored.contains(id)); + let shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + for (_, id) in shared.ignored.range((key.clone(), String::new())..).take_while(|(folder, _)| *folder == key) { + grants.remove(id); + } grants } @@ -504,16 +598,21 @@ impl Session { /// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a /// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed. → whether the - /// store now holds `previous`. + /// write reported success; a write that reports a failure may still have put `previous` back. pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) -> bool { #[cfg(test)] if PUT_BACK_FAILS.get() { return false; } - match previous { + let back = match previous { Some(bytes) => self.store_plugin(id, bytes), None => self.unstore_plugin(id), + }; + #[cfg(test)] + if PUT_BACK_FAILS_AFTER_WRITE.get() { + return false; } + back } /// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it @@ -528,20 +627,53 @@ impl Session { /// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded; /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now /// holds what was asked (`false` without a folder). When it does, the grant kept for `id` counts - /// again ([`Session::ignore_stored_grant`]). + /// again, for every session in the process attached to the store ([`Session::ignore_stored_grant`]). pub(crate) fn store_grant(&self, id: &str, grant: Option) -> bool { let done = self.write_grant(id, grant); - if done { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).remove(id); + if done && let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { + let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + let entry = (key, id.to_string()); + shared.ignored.remove(&entry); + shared.writes = shared.writes.saturating_add(1); + let writes = shared.writes; + shared.written.insert(entry, writes); + if shared.written.len() > MAX_GRANT_WRITES_KEPT + && let Some(oldest) = shared.written.iter().min_by_key(|(_, at)| **at).map(|(entry, _)| entry.clone()) + { + shared.written.remove(&oldest); + } } done } - /// Stop counting the store's grant for the plug-in `id` until [`Session::store_grant`] next - /// succeeds for it or another store is attached: for a grant that should be gone and could not - /// be removed, which would otherwise approve the bytes it covers. - pub(crate) fn ignore_stored_grant(&self, id: &str) { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).insert(id.to_string()); + /// How many grant writes the sessions of the process have made ([`Session::store_grant`]). + /// Taken before a write that may fail, for [`Session::ignore_stored_grant`]. + pub(crate) fn grant_writes(&self) -> u64 { + IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).writes + } + + /// How many grant writes the process keeps ([`IgnoredGrants::written`]). + #[cfg(test)] + pub(crate) fn grant_writes_kept() -> usize { + IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).written.len() + } + + /// Stop counting the attached store's grant for the plug-in `id`, in every session in the + /// process attached to the same folder, until [`Session::store_grant`] next succeeds for it in + /// one of them: for a grant that should be gone and could not be removed, which would otherwise + /// approve the bytes it covers. `writes` is [`Session::grant_writes`] from before the failed + /// write: when a session has written the store's grants for `id` since, the file holds what that + /// write asked for, and the grant keeps counting. A write no longer among the latest + /// [`MAX_GRANT_WRITES_KEPT`] is not seen, and the grant stops counting. Another process on the + /// folder reads `grants.json` as it is. + pub(crate) fn ignore_stored_grant(&self, id: &str, writes: u64) { + if let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { + let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + let entry = (key, id.to_string()); + if shared.written.get(&entry).is_none_or(|at| *at <= writes) { + shared.ignored.insert(entry); + } + } } fn write_grant(&self, id: &str, grant: Option) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f5bbd5422..abf65d834 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -133,6 +133,7 @@ mod placed_document; mod plugins; mod prefs; mod prefs_file; +mod prefs_roots; mod previewbounds; mod print; mod printadvanced; @@ -161,6 +162,7 @@ mod script_include; mod script_io; mod script_run; mod scripts; +mod select_effects; mod select_ids_first; mod shaper; mod shapetools; diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 059357ddb..4b391c571 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -418,3 +418,179 @@ fn native_blend_easing_values_clamp_and_non_finite_become_default() { assert_eq!((spec.ease.ramp, spec.color_shift), (ramp, shift), "{bad}"); } } + +fn create_spine(s: &mut Session, p: Value) -> Value { + s.execute("object.blend.createSpine", &p).unwrap()["mode"].clone() +} + +/// Two squares 100 pt apart blended with `p`; the blend. +fn make(s: &mut Session, y: f64, p: Value) -> NodeId { + let (a, b) = (rect(s, 0.0, y, 10.0), rect(s, 100.0, y, 10.0)); + let mut p = p; + p["ids"] = json!([a.0, b.0]); + p["steps"] = json!(3); + id_of(&s.execute("object.blend.make", &p).unwrap()) +} + +#[test] +fn create_spine_sets_whether_new_blends_get_a_spine() { + let mut s = session(); + assert_eq!(create_spine(&mut s, json!({})), "auto", "Auto until one is chosen"); + let spined = make(&mut s, 0.0, json!({})); + assert!(!spec(&s, spined).1.no_spine); + let depth = s.doc().unwrap().history.undo.len(); + assert_eq!(create_spine(&mut s, json!({"mode": "never"})), "never"); + assert_eq!(s.doc().unwrap().history.undo.len(), depth, "a tool setting, not an undo step"); + assert_eq!(create_spine(&mut s, json!({})), "never"); + // The Blend Options set with nothing selected keep it, and it keeps them. + s.execute("select.none", &json!({})).unwrap(); + s.execute("object.blend.options", &json!({"steps": 7})).unwrap(); + assert_eq!(create_spine(&mut s, json!({})), "never"); + assert_eq!(s.prefs.blend_options.unwrap().spacing, BlendSpacing::Steps(7)); + // A new blend has no spine; the blend made before keeps its own. + let bare = make(&mut s, 50.0, json!({})); + assert!(spec(&s, bare).1.no_spine); + assert!(!spec(&s, spined).1.no_spine, "blends already made keep their spine"); + sel(&mut s, &[bare]); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!(info["hasSpine"], json!(false)); + assert!(info.get("spine").is_none(), "{info}"); + sel(&mut s, &[spined]); + assert_eq!(s.execute("object.blend.info", &json!({})).unwrap()["hasSpine"], json!(true)); + // `createSpine` on Make chooses for that blend only. + let forced = make(&mut s, 100.0, json!({"createSpine": "always"})); + assert!(!spec(&s, forced).1.no_spine); + assert_eq!(create_spine(&mut s, json!({})), "never"); + assert_eq!(create_spine(&mut s, json!({"mode": "always"})), "always"); + let always = make(&mut s, 150.0, json!({})); + assert!(!spec(&s, always).1.no_spine); + assert!(spec(&s, bare).1.no_spine, "blends already made keep having no spine"); + let none = make(&mut s, 200.0, json!({"createSpine": "never"})); + assert!(spec(&s, none).1.no_spine); + // The setting survives a round trip through the preferences file; Auto is not written. + let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back.blend_options.unwrap().create_spine, vectorcraft_doc::live::BlendSpineMode::Always); + assert_eq!(s.prefs.to_json()["blendOptions"]["createSpine"], json!("always")); + create_spine(&mut s, json!({"mode": "auto"})); + assert!(s.prefs.to_json()["blendOptions"].get("createSpine").is_none()); + let mut odd = s.prefs.to_json(); + odd["blendOptions"]["createSpine"] = json!("sometimes"); + let unknown: Prefs = serde_json::from_value(odd).unwrap(); + assert_eq!(unknown.blend_options.unwrap().create_spine, vectorcraft_doc::live::BlendSpineMode::Auto, "an unknown value reads as Auto"); +} + +#[test] +fn create_spine_refuses_bad_values_and_changes_nothing() { + let mut s = session(); + create_spine(&mut s, json!({"mode": "never"})); + let prefs = s.prefs.blend_options; + for bad in [json!({"mode": "sometimes"}), json!({"mode": 1}), json!({"mode": true}), json!({"mode": ["never"]}), json!({"mode": "Never"})] { + let e = s.execute("object.blend.createSpine", &bad).unwrap_err().to_string(); + assert!(e.contains("`mode` must be one of auto, always, never"), "{bad}: {e}"); + assert_eq!(s.prefs.blend_options, prefs, "{bad}"); + } + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + let depth = s.doc().unwrap().history.undo.len(); + for bad in [json!({"createSpine": "x"}), json!({"createSpine": 0}), json!({"createSpine": "never", "easingType": "easeIn"}), json!({"ramp": 30})] + { + let mut p = bad.clone(); + p["ids"] = json!([a.0, b.0]); + assert!(s.execute("object.blend.make", &p).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), depth, "{bad}: nothing made"); + } + assert!(s.execute("object.blend.createSpine", &json!({"spine": "never"})).is_err(), "an unknown key"); +} + +#[test] +fn a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release() { + let mut s = session(); + let g = make(&mut s, 0.0, json!({"createSpine": "never"})); + sel(&mut s, &[g]); + let before = node(&s, g); + let depth = s.doc().unwrap().history.undo.len(); + // Reverse Spine, easing and the spine point commands refuse it and change nothing. + let refused = [ + ("object.blend.reverseSpine", json!({})), + ("object.blend.options", json!({"easingType": "easeOut"})), + ("object.blend.options", json!({"ramp": 40})), + ("object.blend.spine.addAnchor", json!({"x": 50, "y": 5})), + ("object.blend.spine.moveAnchor", json!({"anchor": 0, "x": 0, "y": 50})), + ]; + for (cmd, p) in refused { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{cmd}: {e}"); + assert_eq!((node(&s, g), s.doc().unwrap().history.undo.len()), (before.clone(), depth), "{cmd}"); + } + // Spacing, Orientation and Shift still apply; Align to Path makes no spine. + s.execute("object.blend.options", &json!({"steps": 2, "orientation": "path", "colorShift": 20})).unwrap(); + let sp = spec(&s, g).1; + assert_eq!((sp.spacing, sp.orientation, sp.color_shift, sp.no_spine), (BlendSpacing::Steps(2), BlendOrientation::AlignToPath, 20.0, true)); + assert!(s.execute("object.blend.info", &json!({})).unwrap().get("spine").is_none()); + // Taking in another key keeps it without a spine, whatever Create Spine says. + let c = rect(&mut s, 200.0, 0.0, 10.0); + sel(&mut s, &[g, c]); + create_spine(&mut s, json!({"mode": "always"})); + let g = id_of(&s.execute("object.blend.make", &json!({})).unwrap()); + assert_eq!(spec(&s, g).0.len(), 3); + assert!(spec(&s, g).1.no_spine && spec(&s, g).1.spine.is_none()); + // Release gives back the keys and no spine path. + sel(&mut s, &[g]); + let r = s.execute("object.blend.release", &json!({})).unwrap(); + assert_eq!((r["ids"].as_array().map(Vec::len), &r["spines"]), (Some(3), &json!([]))); + s.execute("edit.undo", &json!({})).unwrap(); + // Replace Spine gives it one: then it reverses and eases like any other. + let line = id_of(&s.execute("path.create", &json!({"d": "M0 100 L200 150"})).unwrap()); + sel(&mut s, &[g, line]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let sp = spec(&s, g).1; + assert!(!sp.no_spine && sp.spine.is_some()); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!((&info["hasSpine"], &info["spine"]["explicit"]), (&json!(true), &json!(true))); + s.execute("object.blend.reverseSpine", &json!({})).unwrap(); + s.execute("object.blend.options", &json!({"easingType": "easeOut", "ramp": 40})).unwrap(); + assert_eq!(spec(&s, g).1.ease.ramp, 0.4); +} + +#[test] +fn a_blend_without_a_spine_draws_its_steps_where_one_with_a_spine_does() { + let mut s = session(); + for orientation in ["page", "path"] { + let spined = make(&mut s, 0.0, json!({"orientation": orientation})); + let bare = make(&mut s, 0.0, json!({"orientation": orientation, "createSpine": "never"})); + let boxes = |g: NodeId| -> Vec<_> { vectorcraft_doc::live::expand_live(&node(&s, g)).iter().map(|n| n.geometric_bounds()).collect() }; + assert_eq!(boxes(bare), boxes(spined), "{orientation}"); + assert!(boxes(bare).len() >= 3, "the three steps at least"); + } + // Easing stored on a blend without a spine (a hand-edited file) leaves its steps evenly spaced. + let bare = make(&mut s, 300.0, json!({"orientation": "path", "createSpine": "never"})); + let even = vectorcraft_doc::live::expand_live(&node(&s, bare)); + let mut eased = node(&s, bare); + if let NodeKind::Blend { spec, .. } = &mut eased.kind { + spec.ease = vectorcraft_doc::live::BlendEase { kind: vectorcraft_doc::live::BlendEaseType::In, ramp: 1.0 }; + } + let boxes = |v: &[Node]| -> Vec<_> { v.iter().map(|n| n.geometric_bounds()).collect() }; + assert_eq!(boxes(&vectorcraft_doc::live::expand_live(&eased)), boxes(&even)); +} + +#[test] +fn whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one() { + let mut s = session(); + let bare = make(&mut s, 0.0, json!({"createSpine": "never"})); + let spined = make(&mut s, 50.0, json!({})); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(&back.layers, &d.layers); + let saved = |g: NodeId| match &back.node(g).unwrap().kind { + NodeKind::Blend { spec, .. } => spec.no_spine, + _ => panic!("not a blend"), + }; + assert!(saved(bare) && !saved(spined)); + assert_eq!(serde_json::to_value(spec(&s, bare).1).unwrap()["noSpine"], json!(true)); + assert!(serde_json::to_value(spec(&s, spined).1).unwrap().get("noSpine").is_none(), "a spine is not written"); + let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}})).unwrap(); + assert!(!old.no_spine, "a blend saved before Create Spine has a spine"); + for odd in [json!("yes"), json!(1), json!(null)] { + let v: BlendSpec = serde_json::from_value(json!({"noSpine": odd.clone()})).unwrap_or_else(|e| panic!("{odd}: {e}")); + assert!(!v.no_spine, "{odd}"); + } +} diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 3ed51e3c3..0fc2bcb47 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2945,6 +2945,120 @@ fn merged_live_paint_groups_keep_the_bottom_groups_gap_options() { assert_eq!(merged(off, json!({"detect": false})), GapOptions { detect: false, size: GapSize::Custom(12.0), color: blue }); } +#[test] +fn live_paint_bucket_drag_fills_multiple_faces() { + let mut s = session(); + let g = live(&mut s); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0)); + let before = s.doc().unwrap().doc.clone(); + s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); + // Skip the middle face between pointer events, as can happen in a fast drag. + s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap(); + let gn = node(&s, g); + for x in [25.0, 75.0, 125.0] { + let f = vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap(); + assert_eq!(f.appearance.fill_paint(), s.paint.fill, "dragged face at {x}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before, "one Undo restores every face"); + s.pointer(&PointerEvent::new(PointerKind::Move, 125.0, 50.0), v).unwrap(); + assert!(!s.overlays(v).is_empty(), "hover still highlights a face"); + assert_eq!(s.doc().unwrap().doc, before, "hover does not paint"); +} + +#[test] +fn live_paint_bucket_drag_crosses_thin_grid_cells_and_can_cancel() { + for (deferred, cancel) in [(false, false), (false, true), (true, false)] { + let mut s = session(); + let ids = [rect(&mut s, 0.0, 0.0, 150.0, 100.0), line(&mut s, (50.0, 0.0), (50.0, 100.0)), line(&mut s, (50.25, 0.0), (50.25, 100.0))]; + s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.prefs.real_time_drawing = !deferred; + s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 125.0, 50.0), v).unwrap(); + if cancel { + assert!(s.tool_busy()); + assert!(s.tool_claims_key(ToolKey::Escape, v)); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + assert!(!s.tool_busy()); + assert!(!s.tool_claims_key(ToolKey::Escape, v)); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap(); + if !cancel { + let g = s.doc().unwrap().selection.objects[0]; + let gn = node(&s, g); + for x in [25.0, 50.125, 125.0] { + let f = vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap(); + assert_eq!(f.appearance.fill_paint(), s.paint.fill, "deferred={deferred}, x={x}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(s.doc().unwrap().doc == before, "deferred={deferred}, cancel={cancel}: original paths and selection art must be restored"); + } +} + +#[test] +fn live_paint_bucket_long_drag_keeps_late_turns_and_release_as_one_undo() { + for cancel in [false, true] { + let mut s = session(); + let ids = [rect(&mut s, 0.0, 0.0, 150.0, 100.0), line(&mut s, (50.0, 0.0), (50.0, 100.0)), line(&mut s, (100.0, 0.0), (100.0, 100.0))]; + s.execute("select.set", &json!({"ids": ids.map(|id| id.0)})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.prefs.real_time_drawing = false; + s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); + for i in 1..4096 { + s.pointer(&PointerEvent::new(PointerKind::Drag, 25.0 + f64::from(i) / 10000.0, 50.0), v).unwrap(); + } + // Turn around the middle face after the chunk limit, then enter the right face on Up. + for (x, y) in [(25.0, 150.0), (125.0, 150.0)] { + s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap(); + } + if cancel { + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap(); + if !cancel { + let g = s.doc().unwrap().selection.objects[0]; + let gn = node(&s, g); + for x in [25.0, 125.0] { + assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().appearance.fill_paint(), s.paint.fill); + } + assert_eq!(vectorcraft_tools::builder::face_at(&gn, Point::new(75.0, 50.0)).unwrap().appearance.fill_paint(), Paint::solid(Color::WHITE)); + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(s.doc().unwrap().doc == before, "long drag is one cancellable undo step"); + } +} + +#[test] +fn live_paint_bucket_drag_rejects_invalid_points_atomically() { + let mut s = session(); + let g = live(&mut s); + let before = s.doc().unwrap().doc.clone(); + for points in + [json!([]), json!([[26.0, 50.0]]), json!([[25.0, 50.0], [null, 0]]), json!([[25.0, 50.0], [1e30, 0]]), json!(vec![[25.0, 50.0]; 4097])] + { + assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [25.0, 50.0], "points": points})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + } + for continuation in [json!(null), json!([[[26.0, 50.0], [125.0, 50.0]]]), json!([vec![[25.0, 50.0]; 4097]])] { + assert!( + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25.0, 50.0], "points": [[25.0, 50.0]], "continuation": continuation})) + .is_err() + ); + assert_eq!(s.doc().unwrap().doc, before); + } +} + #[test] fn live_paint_bucket_tool_fills_face() { let mut s = session(); diff --git a/crates/engine/src/tests/constrain_tools.rs b/crates/engine/src/tests/constrain_tools.rs index a93965eb0..41acc5417 100644 --- a/crates/engine/src/tests/constrain_tools.rs +++ b/crates/engine/src/tests/constrain_tools.rs @@ -115,6 +115,9 @@ fn constrain_angle_draws_boxes_along_the_turned_axes() { let mut s = new_doc(); set_pref(&mut s, "constrainAngle", json!(90)); for tool in ["rectangle", "ellipse"] { + // The shape drawn before stays selected, and a press on its box's corner handle would + // scale it instead of drawing. + s.execute("select.none", &json!({})).unwrap(); s.select_tool(tool, ViewInfo::default()).unwrap(); // 60 right and 20 down on the page: along axes turned 90°, 20 wide and 60 high. gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 130.0, 110.0), (Drag, 160.0, 120.0), (Up, 160.0, 120.0)]); diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index 3512db9c5..ae1a72fa0 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -234,6 +234,200 @@ fn svg_export_writes_the_drawn_size() { assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}"); } +/// 12 pt point type at (100, 100), selected, with its layout bounds. +fn point(s: &mut Session, content: &str) -> (NodeId, vectorcraft_geom::Rect) { + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": content, "size": 12})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let b = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).bounds; + (id, b) +} + +/// Type on a path's path, start bracket and end bracket. +fn on_path(t: &TextObject) -> (vectorcraft_geom::PathData, f64, Option) { + match &t.kind { + vectorcraft_doc::TextKind::OnPath { path, start, end } => (path.clone(), *start, *end), + k => panic!("not type on a path: {k:?}"), + } +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn presets_put_point_type_on_a_fitted_circle_arch_or_bow() { + let mut s = session(); + let (id, b) = point(&mut s, "Round and round"); + let w = b.width().max(b.height()); + let before = undo_len(&s); + let r = s.execute("type.dynamicTextPreset", &json!({"preset": "circle"})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text"); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!(t.path_align, vectorcraft_doc::PathAlign::Descender, "above by default"); + let (path, start, end) = on_path(&t); + assert!(path.is_closed()); + assert_eq!((start, end), (0.75, None)); + let pb = path.bounds().unwrap(); + assert!(close(pb.width(), w) && close(pb.height(), w), "{pb:?} vs {w}"); + assert!(close(pb.center().x, b.center().x) && close(pb.center().y, b.center().y), "{pb:?} vs {b:?}"); + // The start bracket, 3/4 of the way round, is the circle's bottom. + let bottom = path.subpaths[0].anchors[3].p; + assert!(close(bottom.y, pb.y1) && close(bottom.x, pb.center().x), "{bottom:?}"); + // Round a circle as wide as the text, the text grows to fill it and does not overflow. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.fit_scale > 2.0 && !lay.overflow, "{} {}", lay.fit_scale, lay.overflow); + assert_eq!(r["fitScale"], json!(lay.fit_scale)); + assert_eq!(t.cached_bounds, Some(lay.bounds)); + // Undo gives the point type back. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(text(&s, id).kind, vectorcraft_doc::TextKind::Point) && !text(&s, id).dynamic_text); + + for (preset, up) in [("arch", true), ("bow", false)] { + s.execute("type.dynamicTextPreset", &json!({"preset": preset, "ids": [id.0]})).unwrap(); + let t = text(&s, id); + let (path, start, end) = on_path(&t); + assert!(!path.is_closed() && t.dynamic_text); + assert_eq!((start, end), (0.0, None)); + let a = &path.subpaths[0].anchors; + let (first, middle, last) = (a[0].p, a[1].p, a[a.len() - 1].p); + // Left to right, the chord as wide as the text, the arc rising (or falling) half its radius. + assert!(first.x < last.x && close(last.x - first.x, w) && close(first.y, last.y), "{preset}: {first:?} {last:?}"); + let pb = path.bounds().unwrap(); + assert!(close(pb.height(), w / 3f64.sqrt() / 2.0), "{preset}: {pb:?}"); + assert!(close(pb.center().y, b.center().y), "{preset}: {pb:?} vs {b:?}"); + assert_eq!(middle.y < first.y, up, "{preset}: {middle:?} {first:?}"); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.fit_scale > 1.0 && !lay.overflow, "{preset}: {}", lay.fit_scale); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + +#[test] +fn preset_position_and_flip_set_align_to_path_and_direction() { + let mut s = session(); + let (id, _) = point(&mut s, "Over the hill"); + for (position, align) in [ + ("above", vectorcraft_doc::PathAlign::Descender), + ("center", vectorcraft_doc::PathAlign::Center), + ("below", vectorcraft_doc::PathAlign::Ascender), + ] { + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "position": position})).unwrap(); + assert_eq!(text(&s, id).path_align, align, "{position}"); + } + // Flipped, the arch runs right to left and the text stands on its other side. + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "flip": true})).unwrap(); + let t = text(&s, id); + let (path, start, end) = on_path(&t); + let a = &path.subpaths[0].anchors; + assert!(a[0].p.x > a[a.len() - 1].p.x, "{path:?}"); + assert_eq!((start, end), (0.0, None)); + assert_eq!(t.path_align, vectorcraft_doc::PathAlign::Descender); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // A flipped circle runs the other way round from its bottom. + s.execute("type.dynamicTextPreset", &json!({"preset": "circle", "flip": true})).unwrap(); + let (path, start, end) = on_path(&text(&s, id)); + assert_eq!((start, end), (0.25, None)); + let at = path.subpaths[0].anchors[1].p; + assert!(close(at.y, path.bounds().unwrap().y1), "reversed, the bottom is a quarter of the way: {at:?}"); + // The effect and Spacing of Type on a Path Options stay. + s.execute("type.pathOptions", &json!({"effect": "skew", "spacing": 5})).unwrap(); + s.execute("type.dynamicTextPreset", &json!({"preset": "bow"})).unwrap(); + let t = text(&s, id); + assert_eq!((t.path_effect.id(), t.path_spacing), ("skew", 5.0)); +} + +#[test] +fn presets_fit_type_on_a_path_to_its_path_and_record_nothing_when_nothing_changes() { + let mut s = session(); + let id = on_line(&mut s, "Along the line"); + s.execute("type.dynamicTextPreset", &json!({"preset": "bow"})).unwrap(); + let (path, ..) = on_path(&text(&s, id)); + let a = &path.subpaths[0].anchors; + // The bow is as wide as the 400 pt line, centered where it was. + assert!(close(a[0].p.x, 100.0) && close(a[a.len() - 1].p.x, 500.0), "{a:?}"); + assert!(close(path.bounds().unwrap().center().y, 300.0)); + // The same preset again: nothing changes and no undo step is recorded. + let before = undo_len(&s); + let r = s.execute("type.dynamicTextPreset", &json!({"preset": "bow"})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert_eq!(undo_len(&s), before); + // Another position is a change. + s.execute("type.dynamicTextPreset", &json!({"preset": "bow", "position": "below"})).unwrap(); + assert_eq!(undo_len(&s), before + 1); + // The fit follows later edits to the text. + let f = fit(&s, id); + s.execute("text.setText", &json!({"ids": [id.0], "text": "Along the line and on past it"})).unwrap(); + assert!(fit(&s, id) < f * 0.8, "{} vs {f}", fit(&s, id)); + // On a rotated object the arch still bulges up the page, and on a squashed one the circle is + // round on the page. + let (turned, _) = point(&mut s, "Turned"); + s.execute("object.rotate", &json!({"ids": [turned.0], "angle": 45})).unwrap(); + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "ids": [turned.0]})).unwrap(); + let t = text(&s, turned); + let page = on_path(&t).0.transformed(t.xf); + let a = &page.subpaths[0].anchors; + assert!(close(a[0].p.y, a[2].p.y) && a[1].p.y < a[0].p.y && a[0].p.x < a[2].p.x, "{a:?}"); + // The same preset again leaves the rotated arch as it is. + let before = undo_len(&s); + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "ids": [turned.0]})).unwrap(); + assert_eq!(undo_len(&s), before); + let (squashed, _) = point(&mut s, "Squashed"); + s.execute("object.scale", &json!({"ids": [squashed.0], "sx": 200, "sy": 50})).unwrap(); + s.execute("type.dynamicTextPreset", &json!({"preset": "circle", "ids": [squashed.0]})).unwrap(); + let t = text(&s, squashed); + let pb = on_path(&t).0.transformed(t.xf).bounds().unwrap(); + assert!(close(pb.width(), pb.height()), "{pb:?}"); + // Empty 12 pt point type gets a circle four ems (48 pt) across. + let (empty, _) = point(&mut s, ""); + s.execute("type.dynamicTextPreset", &json!({"preset": "circle"})).unwrap(); + let (path, ..) = on_path(&text(&s, empty)); + let pb = path.bounds().unwrap(); + assert!(close(pb.width(), 48.0) && close(pb.height(), 48.0), "{pb:?}"); +} + +#[test] +fn presets_refuse_bad_params_area_type_and_threads() { + let mut s = session(); + let (id, _) = point(&mut s, "Point"); + let before = undo_len(&s); + for p in [ + json!({}), + json!({"preset": "square"}), + json!({"preset": 1}), + json!({"preset": "arch", "position": "top"}), + json!({"preset": "arch", "position": true}), + json!({"preset": "arch", "flip": "yes"}), + ] { + let e = err(s.execute("type.dynamicTextPreset", &p)); + let key = ["position", "flip"].into_iter().find(|k| p.get(k).is_some()).unwrap_or("preset"); + assert!(e.contains(key), "{p}: {e}"); + } + assert_eq!(undo_len(&s), before); + assert!(matches!(text(&s, id).kind, vectorcraft_doc::TextKind::Point)); + // Area type alone is refused; beside point type it is left as it is. + let area = + s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Area", "area": {"width": 100, "height": 50}})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [area]})).unwrap(); + assert!(err(s.execute("type.dynamicTextPreset", &json!({"preset": "circle"}))).contains("select point type or type on a path")); + s.execute("select.set", &json!({"ids": [area, id.0]})).unwrap(); + let r = s.execute("type.dynamicTextPreset", &json!({"preset": "circle"})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert!(matches!(text(&s, NodeId(area)).kind, vectorcraft_doc::TextKind::Area { .. }) && !text(&s, NodeId(area)).dynamic_text); + // Type on a path in a thread is refused by id. + let threaded = on_line(&mut s, "Threaded story"); + let frame = s.execute("shape.rectangle", &json!({"x": 100, "y": 400, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [threaded.0, frame]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + let before = undo_len(&s); + let e = err(s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "ids": [threaded.0, id.0]}))); + assert!(e.contains("in a thread") && e.contains(&threaded.0.to_string()), "{e}"); + assert_eq!(undo_len(&s), before); +} + /// The id of the first text object in `s`'s document. fn first_text(s: &Session) -> NodeId { let mut found = None; diff --git a/crates/engine/src/tests/eyedropper.rs b/crates/engine/src/tests/eyedropper.rs index f8363fe93..b3a9012e0 100644 --- a/crates/engine/src/tests/eyedropper.rs +++ b/crates/engine/src/tests/eyedropper.rs @@ -215,6 +215,291 @@ fn type_picks_up_font_and_size() { assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, preserved_alignment, "#00ff00".into())); } +/// Sample document-space typography between text objects with different local transforms. +#[test] +fn scaled_type_pick_matches_effective_character_dimensions() { + for source_area in [false, true] { + for target_area in [false, true] { + let mut s = session(); + let create = |s: &mut Session, area: bool, x: f64| { + let mut p = json!({"x": x, "y": 100, "text": "Sample", "size": 12}); + if area { + p["area"] = json!({"width": 160, "height": 80}); + } + id_of(s.execute("text.create", &p).unwrap()) + }; + let src = create(&mut s, source_area, 100.0); + s.execute("object.transform", &json!({"matrix": [3, 0, 0, 2, 0, 0]})).unwrap(); + let dst = create(&mut s, target_area, 300.0); + s.execute("object.transform", &json!({"matrix": [0.5, 0, 0, 1.5, 0, 0]})).unwrap(); + let before = node(&s, dst); + let source = node(&s, src); + let history = undo_len(&s); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let after = node(&s, dst); + let NodeKind::Text(t) = &after.kind else { panic!("not text") }; + let NodeKind::Text(old) = &before.kind else { panic!("not text") }; + let effective = t.effective_char_style(&t.first_style()).unwrap(); + assert!((effective.size - 24.0).abs() < 1e-8, "sampled font size: {}", effective.size); + assert!((effective.h_scale - 150.0).abs() < 1e-8, "sampled horizontal scale: {}", effective.h_scale); + assert_eq!(t.xf, old.xf, "sampling keeps the target's transform"); + assert_eq!(node(&s, src), source); + assert_eq!(undo_len(&s), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, dst), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(node(&s, dst), after); + } + } +} + +/// A text object snapshot, including the local attributes and retained affine transform. +fn sampled_type(s: &Session, id: NodeId) -> vectorcraft_doc::TextObject { + let NodeKind::Text(t) = node(s, id).kind else { panic!("not text") }; + *t +} + +/// Compare the dimensional character attributes that text controls display in document units. +fn assert_sampled_dimensions(actual: &vectorcraft_doc::CharStyle, expected: &vectorcraft_doc::CharStyle) { + assert_eq!(actual.leading.is_some(), expected.leading.is_some()); + for (a, e) in [ + (actual.size, expected.size), + (actual.h_scale, expected.h_scale), + (actual.v_scale, expected.v_scale), + (actual.leading.unwrap_or(0.0), expected.leading.unwrap_or(0.0)), + (actual.baseline_shift, expected.baseline_shift), + (actual.tcy_up_down, expected.tcy_up_down), + (actual.tcy_left_right, expected.tcy_left_right), + ] { + assert!((a - e).abs() < 1e-8, "sampled dimension: {a} != {e}"); + } +} + +/// Rotation, shear, reflection and grouping stay on the objects while typography is copied. +#[test] +fn scaled_type_pick_preserves_affines_and_document_dimensions() { + for grouped in [false, true] { + let mut s = session(); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap()); + s.execute("text.setStyle", &json!({"leading": 18})).unwrap(); + s.execute("text.setFormat", &json!({"hScale": 80, "vScale": 125, "baselineShift": 3, "tcyUpDown": 2, "tcyLeftRight": -1})).unwrap(); + if grouped { + s.execute("object.group", &json!({})).unwrap(); + } + s.execute("object.transform", &json!({"matrix": [0, -3, -2, 1, 0, 0]})).unwrap(); + let src_text = sampled_type(&s, src); + let expected = src_text.effective_char_style(&src_text.first_style()).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Target", "size": 20})).unwrap()); + if grouped { + s.execute("object.group", &json!({})).unwrap(); + } + s.execute("object.transform", &json!({"matrix": [-1, 0.5, 1, 2, 0, 0]})).unwrap(); + let before = sampled_type(&s, dst); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let after = sampled_type(&s, dst); + assert_sampled_dimensions(&after.effective_char_style(&after.first_style()).unwrap(), &expected); + assert_eq!(after.xf, before.xf); + assert_eq!(sampled_type(&s, src), src_text); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let reopened = vectorcraft_format::load(&bytes).unwrap(); + let NodeKind::Text(t) = &reopened.node(dst).unwrap().kind else { panic!("not text") }; + assert_sampled_dimensions(&t.effective_char_style(&t.first_style()).unwrap(), &expected); + } +} + +/// A transformed target compensates only the highlighted characters, retaining the others. +#[test] +fn scaled_type_pick_changes_only_the_highlighted_range() { + let mut s = session(); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap()); + s.execute("object.transform", &json!({"matrix": [3, 0, 0, 2, 0, 0]})).unwrap(); + let source = sampled_type(&s, src); + let expected = source.effective_char_style(&source.first_style()).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Sample", "size": 10})).unwrap()); + s.execute("object.transform", &json!({"matrix": [0.5, 0, 0, 3, 0, 0]})).unwrap(); + let before = sampled_type(&s, dst); + s.select_tool("type", ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 1, "end": 4})); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let history = undo_len(&s); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let after = sampled_type(&s, dst); + assert_eq!(after.runs.len(), 3); + assert_eq!((&*after.runs[0].text, &*after.runs[1].text, &*after.runs[2].text), ("S", "amp", "le")); + assert_eq!(after.runs[0].style, before.first_style()); + assert_eq!(after.runs[2].style, before.first_style()); + assert_sampled_dimensions(&after.effective_char_style(&after.runs[1].style).unwrap(), &expected); + assert_eq!(after.xf, before.xf); + assert_eq!(undo_len(&s), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(sampled_type(&s, dst), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(sampled_type(&s, dst), after); +} + +/// Reject unusable source or target transforms before any part of a multi-object pick changes. +#[test] +fn scaled_type_pick_rejects_unrepresentable_dimensions_atomically() { + for (bad_source, factor) in [(true, 0.0), (false, 0.0), (false, 1e-306), (true, f64::from_bits(1))] { + let mut s = session(); + let size = if bad_source && factor > 0.0 { 0.1 } else { 24.0 }; + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": size, "color": "#00ff00"})).unwrap()); + if bad_source { + s.execute("object.transform", &json!({"matrix": [factor, 0, 0, factor, 0, 0]})).unwrap(); + } + let good = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Good", "size": 10})).unwrap()); + let other = id_of(s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Other", "size": 12})).unwrap()); + if !bad_source { + s.execute("object.transform", &json!({"matrix": [factor, 0, 0, factor, 0, 0]})).unwrap(); + } + s.execute("select.set", &json!({"ids": [good.0, other.0]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let paint = s.paint.clone(); + let sample = s.eyedropper_sample.clone(); + let history = undo_len(&s); + let error = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err(); + assert!(error.to_string().contains("transform"), "{error}"); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.paint, paint); + assert_eq!(s.eyedropper_sample, sample); + assert_eq!(undo_len(&s), history); + // A paint-only pick does not need a usable text transform. + s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"character": false, "paragraph": false}})).unwrap(); + } +} + +/// A failed dimensional pick retains highlighted characters and the previously remembered sample. +#[test] +fn scaled_type_pick_rejection_preserves_the_highlighted_range_and_sample() { + let mut s = session(); + let saved = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Saved", "size": 18})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": saved.0})).unwrap(); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Source", "size": 24, "color": "#00ff00"})).unwrap()); + s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Sample", "size": 10})).unwrap()); + s.select_tool("type", ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 1, "end": 4})); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let range = s.tool_options(); + let paint = s.paint.clone(); + let sample = s.eyedropper_sample.clone(); + let history = undo_len(&s); + assert!(s.execute("appearance.copyFrom", &json!({"source": src.0})).is_err()); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.tool_options(), range); + assert_eq!(s.paint, paint); + assert_eq!(s.eyedropper_sample, sample); + assert_eq!(undo_len(&s), history); + // Alt-click can still apply the last successful sample after the rejected pick. + s.execute("select.none", &json!({})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap(); + assert_eq!(sampled_type(&s, dst).first_style().size, 18.0); +} + +/// A pick that would extend text beyond the canvas keeps the previous sample and defaults. +#[test] +fn scaled_type_pick_canvas_rejection_preserves_sample_and_defaults() { + for mode in ["selection", "ids", "reverse", "range"] { + let mut s = session(); + let saved = id_of(s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Saved", "size": 18, "color": "#0000ff"})).unwrap()); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 96, "color": "#ff0000"})).unwrap()); + let dst = id_of(s.execute("text.create", &json!({"x": MAX_COORD - 100.0, "y": 200, "text": "WWWW", "size": 10})).unwrap()); + s.execute("appearance.copyFrom", &json!({"source": saved.0, "ids": []})).unwrap(); + let pick = match mode { + "ids" | "reverse" => { + s.execute("select.none", &json!({})).unwrap(); + if mode == "ids" { json!({"source": src.0, "ids": [dst.0]}) } else { json!({"source": dst.0, "ids": [src.0], "reverse": true}) } + } + "range" => { + s.select_tool("type", ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 0, "end": 3})); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + json!({"source": src.0}) + } + _ => json!({"source": src.0}), + }; + let doc = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let range = s.tool_options(); + let paint = s.paint.clone(); + let sample = s.eyedropper_sample.clone(); + let history = undo_len(&s); + + let error = s.execute("appearance.copyFrom", &pick).unwrap_err(); + assert!(error.to_string().contains("coordinates out of range"), "{mode}: {error}"); + assert_eq!(s.doc().unwrap().doc, doc, "{mode}"); + assert_eq!(s.doc().unwrap().selection, selection, "{mode}"); + assert_eq!(s.tool_options(), range, "{mode}"); + assert_eq!(s.paint, paint, "{mode}"); + assert_eq!(s.eyedropper_sample, sample, "{mode}"); + assert_eq!(undo_len(&s), history, "{mode}"); + } +} + +/// Changing justification can move explicitly targeted point text outside the canvas even +/// when character copying is disabled. +#[test] +fn paragraph_only_pick_rejects_out_of_bounds_explicit_target() { + let mut s = session(); + let saved = id_of(s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Saved", "color": "#0000ff"})).unwrap()); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "color": "#ff0000"})).unwrap()); + s.execute("text.setStyle", &json!({"justify": "left"})).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 100, "y": 200, "text": "WWWW", "size": 16})).unwrap()); + s.execute("text.setStyle", &json!({"justify": "right"})).unwrap(); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, MAX_COORD - 101.0, 0]})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": saved.0, "ids": []})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let paint = s.paint.clone(); + let sample = s.eyedropper_sample.clone(); + let history = undo_len(&s); + + let pick = json!({"source": src.0, "ids": [dst.0], "apply": {"character": false}}); + let error = s.execute("appearance.copyFrom", &pick).unwrap_err(); + assert!(error.to_string().contains("coordinates out of range"), "{error}"); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.paint, paint); + assert_eq!(s.eyedropper_sample, sample); + assert_eq!(undo_len(&s), history); +} + +/// Disabled character copying and append preserve typography; reverse and cached picks convert it. +#[test] +fn scaled_type_pick_respects_options_and_reverse_sampling() { + let mut s = session(); + let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap()); + s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap(); + let source = sampled_type(&s, src); + let expected = source.effective_char_style(&source.first_style()).unwrap(); + let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Target", "size": 20})).unwrap()); + s.execute("object.scale", &json!({"sx": 50, "sy": 150})).unwrap(); + let before = sampled_type(&s, dst); + for options in [json!({"pickUp": {"character": false}}), json!({"apply": {"character": false}}), json!({"append": true})] { + let mut p = json!({"source": src.0, "ids": [dst.0]}); + p.as_object_mut().unwrap().extend(options.as_object().unwrap().clone()); + s.execute("appearance.copyFrom", &p).unwrap(); + assert_sampled_dimensions(&sampled_type(&s, dst).first_style(), &before.first_style()); + } + s.execute("select.set", &json!({"ids": [src.0]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap(); + let after = sampled_type(&s, dst); + assert_sampled_dimensions(&after.effective_char_style(&after.first_style()).unwrap(), &expected); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap(); + let cached = sampled_type(&s, dst); + assert_sampled_dimensions(&cached.effective_char_style(&cached.first_style()).unwrap(), &expected); +} + #[test] fn alt_click_applies_the_selection_to_the_clicked_object() { let mut s = session(); diff --git a/crates/engine/src/tests/flatten.rs b/crates/engine/src/tests/flatten.rs index 84e4724fe..042f741e0 100644 --- a/crates/engine/src/tests/flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -450,6 +450,54 @@ fn objects_without_transparency_stay_unless_outlined() { } } +/// A row of opaque rectangles 1.5 pt apart (closer than twice the antialiasing margin), with a +/// see-through one touching each end: only the two next to a see-through one are flattened, and +/// the document looks the same. +#[test] +fn opaque_art_that_touches_only_opaque_art_stays() { + let _settings = settings_lock(); + let mut s = session(); + let under = rect(&mut s, 0.0, 50.0, 10.0, 10.0, "#2060c0"); + run(&mut s, "transparency.set", json!({"ids": [under], "opacity": 50})); + let row: Vec = (1..15).map(|i| rect(&mut s, 11.5 * f64::from(i), 50.0, 10.0, 10.0, "#e04010")).collect(); + let over = rect(&mut s, 11.5 * 15.0, 50.0, 10.0, 10.0, "#20a040"); + run(&mut s, "transparency.set", json!({"ids": [over], "opacity": 50, "blend": "Multiply"})); + let before = picture(&s); + run(&mut s, "select.all", json!({})); + let (ids, vector, rasterized) = flatten(&mut s, json!({})); + assert_eq!((vector, rasterized), (4, 0), "two groups of two regions each"); + let (ends, kept) = (row[0], row.last().copied().unwrap()); + assert!(!exists(&s, under) && !exists(&s, over) && !exists(&s, ends) && !exists(&s, kept)); + assert!(row[1..row.len() - 1].iter().all(|id| exists(&s, *id) && ids.contains(id)), "the rest of the row stays"); + assert_eq!(ids.len(), row.len() - 2 + 2); + let diff = changed(&before, &picture(&s)); + assert!(diff == 0.0, "{:.2}% of the pixels changed", diff * 100.0); +} + +/// Opaque art between a see-through object's group and the top of that group, painting over a +/// member, joins the group (along a chain too), so it still paints over it; opaque art under the +/// group stays. +#[test] +fn opaque_art_the_group_would_move_under_joins_it() { + let _settings = settings_lock(); + let mut s = session(); + let d = rect(&mut s, 40.0, 80.0, 25.0, 40.0, "#f0d000"); + let a = rect(&mut s, 60.0, 50.0, 45.0, 40.0, "#e01010"); + let b = rect(&mut s, 20.0, 20.0, 45.0, 35.0, "#10a010"); + let c = rect(&mut s, 0.0, 0.0, 25.0, 25.0, "#1010e0"); + let t = rect(&mut s, 100.0, 50.0, 40.0, 40.0, "#000000"); + run(&mut s, "transparency.set", json!({"ids": [t], "opacity": 50})); + let before = picture(&s); + let (ids, ..) = flatten(&mut s, json!({"ids": [d, a, b, c, t]})); + assert!(exists(&s, d) && ids.contains(&d), "the art under the group stays"); + assert!([a, b, c, t].iter().all(|id| !exists(&s, *id)), "the others are flattened together"); + // Where B paints over A, and C over B, they still do. + for p in [Point::new(62.5, 52.5), Point::new(22.5, 22.5), Point::new(62.5, 85.5), Point::new(102.5, 70.5)] { + assert_eq!(pixel(&picture(&s), p), pixel(&before, p), "at {p:?}"); + } + assert!(changed(&before, &picture(&s)) == 0.0); +} + #[test] fn outlined_type_keeps_its_underline() { let _settings = settings_lock(); diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs index a3f7a2ae6..73e0172da 100644 --- a/crates/engine/src/tests/gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -32,7 +32,7 @@ fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint { #[test] fn new_gradient_defaults_follow_the_cmyk_document_mode() { - for kind in ["linear", "radial", "conic", "freeform"] { + for kind in ["linear", "radial", "conic", "diamond", "freeform"] { let mut s = Session::new(); s.execute("file.new", &json!({"width":200,"height":200,"colorMode":"cmyk"})).unwrap(); let id = rect(&mut s, 10.0, 10.0, 100.0, 100.0); @@ -586,7 +586,7 @@ fn a_conic_gradient_is_set_edited_transformed_and_saved() { assert_eq!(fill_gradient(&s, id).gradient.kind, GradientKind::Conic, "{name}"); } let e = s.execute("paint.editGradient", &json!({"kind": "spiral"})).unwrap_err().to_string(); - assert!(e.contains("(linear, radial, conic, freeform)"), "{e}"); + assert!(e.contains("(linear, radial, conic, diamond, freeform)"), "{e}"); // Placed, an angle turns the vector about the center. s.execute("paint.setGradientGeom", &json!({"start": [200, 150], "end": [260, 150], "aspect": 50})).unwrap(); s.execute("paint.editGradient", &json!({"angle": 90})).unwrap(); @@ -747,3 +747,137 @@ fn making_a_gradient_conic_is_one_undo_step() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(fill_gradient(&s, id), before); } + +/// A diamond gradient through `paint.setFill`, the Gradient panel's edits, transforms, the params +/// agents and swatches read, and the native format. +#[test] +fn a_diamond_gradient_is_set_edited_transformed_and_saved() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond"}})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!(g.gradient.kind, GradientKind::Diamond); + // Unplaced, it runs from the box's center out to a square whose edges pass through the box's + // corners. + let geom = g.resolve(node(&s, id).geometric_bounds().unwrap()); + assert_eq!((geom.start, geom.end), (Point::new(200.0, 150.0), Point::new(350.0, 150.0))); + for (p, t) in [ + (Point::new(300.0, 200.0), 1.0), + (Point::new(100.0, 100.0), 1.0), + (Point::new(250.0, 150.0), 1.0 / 3.0), + (Point::new(200.0, 175.0), 1.0 / 6.0), + ] { + assert!((geom.param_at(GradientKind::Diamond, p) - t).abs() < 1e-9, "{p:?}"); + } + s.execute("paint.editGradient", &json!({"kind": "linear"})).unwrap(); + s.execute("paint.editGradient", &json!({"kind": "DIAMOND"})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.kind, GradientKind::Diamond); + let e = s.execute("paint.editGradient", &json!({"kind": "spiral"})).unwrap_err().to_string(); + assert!(e.contains("(linear, radial, conic, diamond, freeform)"), "{e}"); + // Placed, an angle turns the vector about the center. + s.execute("paint.setGradientGeom", &json!({"start": [200, 150], "end": [260, 150], "aspect": 50})).unwrap(); + s.execute("paint.editGradient", &json!({"angle": 90})).unwrap(); + let g = fill_geom(&s, id); + assert!(g.start.distance(Point::new(200.0, 150.0)) < 1e-9 && g.end.distance(Point::new(200.0, 90.0)) < 1e-9, "{g:?}"); + // A reflection keeps each point's color. + let p = Point::new(230.0, 170.0); + let before = g.param_at(GradientKind::Diamond, p); + s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); + let m = fill_geom(&s, id); + assert!(m.aspect > 0.0, "{m:?}"); + assert!((m.param_at(GradientKind::Diamond, Point::new(170.0, 170.0)) - before).abs() < 1e-9); + // A rotation carries it along. + let before = m.param_at(GradientKind::Diamond, p); + s.execute("object.rotate", &json!({"angle": 30, "origin": [200, 150]})).unwrap(); + let r = vectorcraft_geom::Affine::rotate_about(-30f64.to_radians(), Point::new(200.0, 150.0)); + let after = fill_geom(&s, id).param_at(GradientKind::Diamond, r * p); + assert!((after - before).abs() < 1e-9, "{before} vs {after}"); + // The params give it back, and the native format keeps it. + s.execute("paint.editGradient", &json!({"aspect": 60})).unwrap(); + let want = fill_gradient(&s, id); + let params = vectorcraft_tools::params::gradient_params(&want); + assert_eq!((params["kind"].as_str(), params["aspect"].as_f64()), (Some("diamond"), Some(60.0))); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("paint.setFill", &json!({ "gradient": params })).unwrap(); + let got = fill_gradient(&s, id); + assert_eq!((&got.gradient, got.geom), (&want.gradient, want.geom)); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Paint::Gradient(g) = back.node(id).unwrap().appearance.fill_paint() else { panic!("the gradient is lost") }; + assert_eq!((g.gradient.kind, g.geom), (GradientKind::Diamond, want.geom)); +} + +/// A diamond refuses a negative aspect and a focal point, from every command that gives one, and +/// the gradient stays as it was; a null focal point is allowed. +#[test] +fn a_diamond_refuses_a_negative_aspect_and_a_focal_point() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "aspect": 50}})).unwrap(); + let before = fill_gradient(&s, id); + for p in [ + json!({"gradient": {"kind": "diamond", "aspect": -50}}), + json!({"gradient": {"kind": "diamond", "start": [0, 0], "end": [10, 0], "aspect": -50}}), + ] { + assert!(s.execute("paint.setFill", &p).unwrap_err().to_string().contains("aspect"), "{p}"); + } + assert!(s.execute("paint.editGradient", &json!({"aspect": -50})).is_err()); + assert!(s.execute("paint.setGradientGeom", &json!({"aspect": -50})).is_err()); + assert!(s.execute("paint.editGradient", &json!({"kind": "radial", "aspect": -50})).is_err()); + let e = s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "focal": [210, 150]}})); + assert!(e.unwrap_err().to_string().contains("focal")); + assert!(s.execute("paint.setGradientGeom", &json!({"focal": [210, 150]})).unwrap_err().to_string().contains("focal")); + assert_eq!(fill_gradient(&s, id), before); + s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [200, 150], "end": [260, 150], "focal": null}})).unwrap(); + assert_eq!(fill_gradient(&s, id).geom.unwrap().focal, None); +} + +/// A free distort that is not affine moves a diamond by the warp near its center, as it moves a +/// radial gradient. +#[test] +fn a_warp_moves_a_diamond_by_its_center() { + let warped = |kind: &str| { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": kind, "start": [20, 30], "end": [90, 30], "aspect": 60}})).unwrap(); + s.execute("object.distort", &json!({"corners": [[0, 0], [100, 0], [140, 130], [-20, 100]]})).unwrap(); + fill_geom(&s, id) + }; + let (diamond, radial) = (warped("diamond"), warped("radial")); + assert!(diamond.start.distance(radial.start) < 1e-9, "{diamond:?} vs {radial:?}"); +} + +/// Saved as `.ai`, as PDF with its editing data and as SVG with its editing data, then opened, a +/// turned, squashed diamond comes back as it was. +#[test] +fn a_diamond_survives_saving_as_ai_pdf_and_svg() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "diamond", "start": [180, 140], "end": [240, 110], "aspect": 60}})).unwrap(); + let want = fill_gradient(&s, id); + for format in ["ai", "pdf", "svg"] { + let r = s.execute("file.saveCopy", &json!({"format": format, "preserveEditing": true})).unwrap(); + let mut back = session(); + back.execute("document.open", &json!({"name": format!("t.{format}"), "dataBase64": r["dataBase64"]})).unwrap(); + let mut found = None; + back.doc().unwrap().doc.walk(|n| { + if let (None, Paint::Gradient(g)) = (&found, n.appearance.fill_paint()) { + found = Some(*g.clone()); + } + }); + let got = found.unwrap_or_else(|| panic!("{format}: the gradient is lost")); + assert_eq!((got.gradient.kind, got.geom), (GradientKind::Diamond, want.geom), "{format}"); + } +} + +/// Turning a gradient diamond is one undo step, and undoing it gives the gradient back as it was. +#[test] +fn making_a_gradient_diamond_is_one_undo_step() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap(); + let (before, steps) = (fill_gradient(&s, id), s.doc().unwrap().history.undo.len()); + s.execute("paint.editGradient", &json!({"kind": "diamond", "angle": 45})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_gradient(&s, id), before); +} diff --git a/crates/engine/src/tests/gradpresets.rs b/crates/engine/src/tests/gradpresets.rs index 437f96d26..807094a1b 100644 --- a/crates/engine/src/tests/gradpresets.rs +++ b/crates/engine/src/tests/gradpresets.rs @@ -38,7 +38,10 @@ fn built_in_presets_are_the_gradient_libraries_by_theme_with_unique_names() { // Grouped by library, the gradient libraries first, then the swatch libraries holding gradients. let mut libraries: Vec<&str> = rows.iter().map(|r| r["library"].as_str().unwrap()).collect(); libraries.dedup(); - assert_eq!(libraries, ["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Duotone Gradients", "Radial Glows", "Metallic Gradients"]); + assert_eq!( + libraries, + ["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Organic Fades", "Duotone Gradients", "Radial Glows", "Metallic Gradients"] + ); let mut seen = std::collections::HashSet::new(); assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset"); let vignette = rows.iter().find(|r| r["name"] == "Vignette").unwrap(); @@ -65,6 +68,20 @@ fn applying_a_preset_edits_the_gradient_in_place_as_one_undo_step() { assert_eq!(paint(&s, id, false), before); } +#[test] +fn an_organic_fade_applies_with_its_method_midpoints_and_opacities() { + let (mut s, id) = session_with(json!({"color": "#336699"})); + let mist = s.gradient_preset("Mist").unwrap(); + assert_eq!((mist.library, mist.gradient.method), (Some("Organic Fades"), GradientMethod::Perceptual)); + s.execute("gradient.presets.apply", &json!({"name": "Mist"})).unwrap(); + let g = paint(&s, id, false).gradient; + assert_eq!((g.kind, g.method, &g.stops), (GradientKind::Linear, GradientMethod::Perceptual, &mist.gradient.stops)); + assert_eq!(s.gradient_preset_name(&g).as_deref(), Some("Mist")); + let list = s.execute("gradient.presets.list", &json!({})).unwrap(); + let row = list["presets"].as_array().unwrap().iter().find(|r| r["name"] == "Mist").unwrap().clone(); + assert_eq!(row["gradient"]["method"], "perceptual"); +} + #[test] fn a_preset_applies_to_the_stroke_and_to_a_solid_paint() { let (mut s, id) = session_with(json!({"color": "#336699"})); @@ -334,3 +351,37 @@ fn saved_presets_round_trip_with_the_preferences() { fresh.apply_prefs(back); assert!(fresh.gradient_preset("mine").is_some_and(|e| e.library.is_none())); } + +/// Preferences can hold saved presets named like a built-in preset (saved before it existed) or +/// like each other (edited by hand): applied, each gets a name of its own, any case. +#[test] +fn saved_presets_named_like_a_built_in_or_another_saved_one_get_a_free_name() { + let (mut s, id) = session_with(json!({"gradient": {"kind": "radial"}})); + let mist = s.gradient_preset("Mist").unwrap().gradient; + let mut own = paint(&s, id, false).gradient; + own.dither = true; + let preset = |name: &str| crate::cmd::GradientPreset { name: name.into(), gradient: own.clone() }; + let mut prefs = s.prefs.clone(); + prefs.gradient_presets = + vec![preset("mist"), preset("Mine"), preset("MINE"), preset("Mist 2"), preset("Plain"), preset("PLAIN"), preset("plain")]; + s.apply_prefs(prefs); + let saved: Vec<&str> = s.prefs.gradient_presets.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(saved, ["mist 3", "Mine", "MINE 2", "Mist 2", "Plain", "PLAIN 2", "plain 3"]); + let list = s.execute("gradient.presets.list", &json!({})).unwrap(); + let mut seen = std::collections::HashSet::new(); + assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset"); + // The renamed one applies and deletes; the built-in Mist stays as it was. + s.execute("gradient.presets.apply", &json!({"name": "mist 3"})).unwrap(); + assert_eq!(paint(&s, id, false).gradient, own); + assert_eq!(s.gradient_preset("Mist").unwrap().gradient, mist); + assert_eq!(s.execute("gradient.presets.delete", &json!({"name": "Mist 3"})).unwrap()["deleted"], "mist 3"); + assert!(s.execute("gradient.presets.delete", &json!({"name": "Mist"})).is_err(), "built in"); + assert_eq!(s.prefs.gradient_presets.len(), 6); + // Many presets with one name each get their own number. + let mut prefs = s.prefs.clone(); + prefs.gradient_presets = (0..2000).map(|_| preset("Same")).collect(); + s.apply_prefs(prefs); + let mut seen = std::collections::HashSet::new(); + assert!(s.prefs.gradient_presets.iter().all(|p| seen.insert(p.name.to_lowercase()))); + assert_eq!(s.prefs.gradient_presets.last().map(|p| p.name.as_str()), Some("Same 2000")); +} diff --git a/crates/engine/src/tests/linkspanel.rs b/crates/engine/src/tests/linkspanel.rs index 56784b69d..f029eccd3 100644 --- a/crates/engine/src/tests/linkspanel.rs +++ b/crates/engine/src/tests/linkspanel.rs @@ -296,6 +296,59 @@ fn link_info_stays_within_the_asking_callers_roots() { assert_ne!(i["status"], "ok", "{i}"); } +/// A confined caller's `links.list`, `links.check` and `links.info` replies, and its +/// `document.open` reply, leave out the path and folder of a linked file outside its read roots +/// and keep the row with the file's name. A file found by name inside the roots shows its path. +#[test] +fn link_paths_outside_the_read_roots_are_left_out() { + let away = Folder::new("lrr-away"); + let home = Folder::new("lrr-home"); + let pic = away.file("photo.png"); + let bytes = png(60, 30, RED); + write(&pic, &bytes); + let mut s = session(); + let id = place(&mut s, &pic); + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let ask = |s: &mut Session, cmd: &str, p: Value| crate::file_access::confine(Some(&roots), || run(s, cmd, p)); + let hidden = |r: &Value| assert!(!r.to_string().contains("lrr-away"), "{r}"); + let list = ask(&mut s, "links.list", json!({})); + hidden(&list); + let row = &list["links"][0]; + assert_eq!((row["id"].as_u64(), row["name"].as_str(), row["status"].as_str()), (Some(id.0), Some("photo.png"), Some("missing")), "{list}"); + let check = ask(&mut s, "links.check", json!({})); + hidden(&check); + assert_eq!((check["links"][0]["name"].as_str(), check["missing"].as_u64()), (Some("photo.png"), Some(1)), "{check}"); + let info = ask(&mut s, "links.info", json!({"id": id.0})); + hidden(&info); + assert_eq!( + (info["fileName"].as_str(), info["status"].as_str(), info["linked"].as_bool()), + (Some("photo.png"), Some("missing"), Some(true)), + "{info}" + ); + for k in ["path", "link", "location", "fileSize", "modified", "created"] { + assert!(info.get(k).is_none(), "{k}: {info}"); + } + // Unconfined, the same replies carry the paths. + assert_eq!(run(&mut s, "links.list", json!({}))["links"][0]["path"], json!(pic)); + let i = run(&mut s, "links.info", json!({"id": id.0})); + assert_eq!((&i["path"], &i["link"], i["fileSize"].as_u64()), (&json!(pic), &json!(pic), Some(bytes.len() as u64)), "{i}"); + // Saved inside the roots next to a copy of the file: the copy is found by name and shown. + save(&mut s, &home.file("poster.vectorcraft")); + let copy = home.file("photo.png"); + write(©, &bytes); + let check = ask(&mut s, "links.check", json!({})); + hidden(&check); + assert_eq!((check["links"][0]["status"].as_str(), &check["links"][0]["found"]), (Some("ok"), &json!(copy)), "{check}"); + let list = ask(&mut s, "links.list", json!({})); + hidden(&list); + assert_eq!(list["links"][0]["found"], json!(copy), "{list}"); + // Opened again without the copy: the missing link's row has no path. + std::fs::remove_file(©).unwrap(); + let opened = ask(&mut Session::new(), "document.open", json!({"path": home.file("poster.vectorcraft")})); + hidden(&opened); + assert_eq!(opened["missingLinks"][0]["name"], "photo.png", "{opened}"); +} + /// A 600×300 pt image at the centre of the artboard, its placement options set to `options`, /// relinked to a 300×100 px file → the image's id and session. fn relinked(name: &str, options: Value) -> (Session, NodeId, Folder) { diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index fccbe4a2d..28cb0a159 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1855,6 +1855,38 @@ fn convert_to_artboards_names_by_the_artboards_the_document_has() { assert_eq!(name_after(&|s| _ = s.execute("edit.undo", &json!({})).unwrap()), "Artboard 3"); } +/// Convert to Artboards on several objects gives each new artboard an id above the ids given out +/// before and the deleted artboard's, and names it one past the highest id the artboards have, +/// counting the artboards it has just added. With an artboard whose id is `u32::MAX` (a damaged +/// file), each new artboard takes the smallest free id and the name stays at `u32::MAX`. +#[test] +fn convert_to_artboards_gives_each_object_the_next_id_and_name() { + let convert = |s: &mut Session| { + let ids: Vec = (0..3).map(|i| rect(s, 3000.0 + 300.0 * f64::from(i), 0.0, 200.0, 100.0)).collect(); + sel(s, &ids); + assert_eq!(s.execute("artboard.convertToArtboards", &json!({})).unwrap()["artboards"], 3); + let d = &s.doc().unwrap().doc; + d.artboards.iter().map(|a| (a.id, a.name.clone())).collect::>() + }; + let named = |pairs: &[(u32, &str)]| pairs.iter().map(|&(id, n)| (id, n.to_string())).collect::>(); + + // Artboards 1 and 2 are left after artboard 3 is deleted, so its id stays given out. + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.delete", &json!({"index": 2})).unwrap(); + let ids: Vec = s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect(); + assert_eq!(ids, [1, 2]); + let got = convert(&mut s); + assert_eq!(got, named(&[(1, "Artboard 1"), (2, "Artboard 2"), (4, "Artboard 3"), (5, "Artboard 5"), (6, "Artboard 6")])); + + let mut s = session(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards[0].id = u32::MAX; + let got = convert(&mut s); + let top = format!("Artboard {}", u32::MAX); + assert_eq!(got, named(&[(u32::MAX, "Artboard 1"), (1, &top), (2, &top), (3, &top)])); +} + /// Rearrange All Artboards refuses `columns` that are not a whole number (a fraction, a negative number or a string), /// and the artboards stay where they were; 1.0, as the Rearrange All Artboards dialog sends a count, is one column. #[test] diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 067631eec..4bc58e5f6 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -292,6 +292,57 @@ fn a_saved_preset_with_the_resolution_out_of_range_reads_in_range() { assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 1.0); } +/// Advanced Options › Align New Objects to Pixel Grid starts on in the Mobile and Web presets and +/// off in the others and without a preset; a `file.new` param overrides the preset; the document +/// takes the value; saved presets and Recent keep it, and a saved preset from before it reads off. +#[test] +fn align_new_objects_to_pixel_grid_follows_the_preset() { + let align = |s: &Session| s.doc().unwrap().doc.setup.align_new_objects_to_pixel_grid; + let mut s = Session::new(); + for category in ["Mobile", "Web"] { + assert!(presets(&mut s, category).iter().all(|p| p["alignNewObjectsToPixelGrid"] == true), "{category}"); + } + for category in ["Print", "Film & Video", "Art & Illustration", "Branding", "Social"] { + assert!(presets(&mut s, category).iter().all(|p| p["alignNewObjectsToPixelGrid"] == false), "{category}"); + } + s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap(); + assert!(align(&s)); + assert_eq!(presets(&mut s, "Recent")[0]["alignNewObjectsToPixelGrid"], true); + s.execute("file.new", &json!({"preset": "Phone 390×844"})).unwrap(); + assert!(align(&s)); + s.execute("file.new", &json!({"preset": "Letter"})).unwrap(); + assert!(!align(&s)); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + assert!(!align(&s)); + s.execute("file.new", &json!({"preset": "Web 1920×1080", "alignNewObjectsToPixelGrid": false})).unwrap(); + assert!(!align(&s)); + s.execute("file.new", &json!({"preset": "A4", "alignNewObjectsToPixelGrid": true})).unwrap(); + assert!(align(&s)); + assert!(Session::new().execute("file.new", &json!({"alignNewObjectsToPixelGrid": "on"})).is_err()); + // A Recent entry passed back makes the document it was made as. + let recent = presets(&mut s, "Recent")[0].clone(); + assert_eq!((recent["name"].as_str(), recent["alignNewObjectsToPixelGrid"].as_bool()), (Some("A4"), Some(true))); + s.execute("file.new", &recent).unwrap(); + assert!(align(&s)); + // Saved presets keep it. + s.execute("file.newPresets.save", &json!({"name": "Icons", "width": 64, "height": 64, "alignNewObjectsToPixelGrid": true})).unwrap(); + s.execute("file.newPresets.save", &json!({"name": "Banner", "preset": "Web 1920×1080", "alignNewObjectsToPixelGrid": false})).unwrap(); + let saved: Vec = presets(&mut s, "Saved").iter().map(|p| p["alignNewObjectsToPixelGrid"].clone()).collect(); + assert_eq!(saved, [json!(true), json!(false)]); + s.execute("file.new", &json!({"preset": "Icons"})).unwrap(); + assert!(align(&s)); + s.execute("file.new", &json!({"preset": "Banner"})).unwrap(); + assert!(!align(&s)); + let prefs: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(prefs.new_doc_presets, s.prefs.new_doc_presets); + // A saved preset from before the setting reads off, whatever its name. + let mut old = Session::new(); + old.apply_prefs(serde_json::from_value(json!({"newDocPresets": [{"name": "My Web", "width": 100, "height": 100}]})).unwrap()); + assert_eq!(presets(&mut old, "Saved")[0]["alignNewObjectsToPixelGrid"], false); + old.execute("file.new", &json!({"preset": "My Web"})).unwrap(); + assert!(!align(&old)); +} + #[test] fn rearrange_keeps_its_grid() { let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3})); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index f638355c5..84e555985 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -393,6 +393,77 @@ fn a_mirrored_or_flat_conic_expands_as_the_canvas_draws_it() { } } +/// Three opaque stops running from the center out to the square. +fn diamond_stops() -> Value { + json!({"kind": "diamond", "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ff00ff"}, {"offset": 1, "color": "#004080"}]}) +} + +#[test] +fn a_diamond_gradient_becomes_nested_squares_or_a_mesh_that_paint_it() { + // Objects: nested squares about the center, largest first, together painting what the + // canvas painted. + let mut s = session(); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), diamond_stops(), false); + let before = render(&s); + run(&mut s, "object.expand", json!({"steps": 120})); + let g = selected(&s); + let (_, squares) = clip_parts(&g); + assert_eq!(squares.len(), 120); + let corners = |n: &Node| n.path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect::>(); + let (outer, inner) = (corners(squares[0]), corners(squares[119])); + assert_eq!((outer.len(), inner.len()), (4, 4)); + let reach = |pts: &[vectorcraft_geom::Point]| pts.iter().map(|p| p.distance(vectorcraft_geom::Point::new(100.0, 100.0))).fold(0.0, f64::max); + assert!(reach(&outer) > reach(&inner) * 100.0, "largest first: {outer:?} then {inner:?}"); + let after = render(&s); + assert!(differing(&before, &after, 48) < 200, "{}", differing(&before, &after, 48)); + assert!(mean_diff(&before, &after) < 2.0, "{}", mean_diff(&before, &after)); + // Translucent stops: each square is a ring with the next one cut out, so none doubles up. + let mut s = session(); + let gr = json!({"kind": "diamond", "stops": [{"offset": 0, "color": "#ff0000", "opacity": 0.5}, {"offset": 1, "color": "#0000ff"}]}); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, false); + let before = render(&s); + run(&mut s, "object.expand", json!({"steps": 60})); + let g = selected(&s); + let (_, squares) = clip_parts(&g); + assert!(squares[..59].iter().all(|n| n.path_data().unwrap().subpaths.len() == 2), "rings"); + assert!(mean_diff(&before, &render(&s)) < 4.0, "{}", mean_diff(&before, &render(&s))); + // Mesh: rings of patches about the center. + let mut s = session(); + gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), diamond_stops(), false); + let before = render(&s); + run(&mut s, "object.expand", json!({"gradient": "mesh"})); + let g = selected(&s); + let (_, content) = clip_parts(&g); + let NodeKind::Mesh(m) = &content[0].kind else { panic!("a mesh node: {:?}", content[0].kind) }; + assert!(m.is_valid()); + let after = render(&s); + assert!(differing(&before, &after, 48) < 200, "{}", differing(&before, &after, 48)); + assert!(mean_diff(&before, &after) < 2.5, "{}", mean_diff(&before, &after)); +} + +/// A turned, squashed diamond, and one whose square is flat (as a file can hold it), expand as +/// the canvas draws them. +#[test] +fn a_turned_or_flat_diamond_expands_as_the_canvas_draws_it() { + for aspect in [0.6, 0.0] { + for how in [json!({"steps": 120}), json!({"gradient": "mesh"})] { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), diamond_stops(), false); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let n = d.node_mut(id).unwrap(); + let Paint::Gradient(mut g) = n.appearance.fill_paint().clone() else { panic!("a gradient fill") }; + let (start, end) = (vectorcraft_geom::Point::new(90.0, 110.0), vectorcraft_geom::Point::new(130.0, 80.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect, focal: None }); + n.appearance.set_fill(Paint::Gradient(g)); + let before = render(&s); + run(&mut s, "object.expand", how.clone()); + let after = render(&s); + assert!(differing(&before, &after, 48) < 200, "{aspect} {how}: {}", differing(&before, &after, 48)); + assert!(mean_diff(&before, &after) < 2.5, "{aspect} {how}: {}", mean_diff(&before, &after)); + } + } +} + #[test] fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ diff --git a/crates/engine/src/tests/outlinestroke.rs b/crates/engine/src/tests/outlinestroke.rs index 166c88725..d3394d148 100644 --- a/crates/engine/src/tests/outlinestroke.rs +++ b/crates/engine/src/tests/outlinestroke.rs @@ -73,14 +73,13 @@ fn arrowheads_are_part_of_the_outline() { assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape"); } -/// Outline Stroke of a Lens-profile line covers 7/12 of the full-width rectangle. +/// Outline Stroke of a Lens-profile line covers 2/3 of the full-width rectangle. #[test] -fn a_lens_profile_outlines_to_seven_twelfths_of_the_rectangle() { +fn a_lens_profile_outlines_to_two_thirds_of_the_rectangle() { let mut s = session(); line(&mut s, json!({"weight": 10, "profile": "lens"})); let n = outline(&mut s); - // Each half of the lens swells on a cubic, flat at the middle (#5714). - assert!(near(area(&n), 7.0 / 12.0 * 10.0 * 100.0, 2.0), "{}", area(&n)); + assert!(near(area(&n), 2.0 / 3.0 * 10.0 * 100.0, 2.0), "{}", area(&n)); } #[test] diff --git a/crates/engine/src/tests/pixelalign.rs b/crates/engine/src/tests/pixelalign.rs index 081497d2a..adb8bae15 100644 --- a/crates/engine/src/tests/pixelalign.rs +++ b/crates/engine/src/tests/pixelalign.rs @@ -767,6 +767,131 @@ fn objects_on_path_members_stay_on_the_grid() { assert!(points(&s, a).iter().all(|(_, y)| *y > 105.0), "followed the path: {:?}", points(&s, a)); } +fn align_new(s: &Session) -> bool { + s.doc().unwrap().doc.setup.align_new_objects_to_pixel_grid +} + +/// Align New Objects to Pixel Grid (`document.setup`): while it is on, a rectangle and an open path +/// drawn in the document are pixel-aligned and snapped in the step that makes them, and type is +/// not; art already in the document, duplicates and pastes keep their own setting; turning it off +/// leaves the next drawing as it is drawn, and the object's own setting can be turned off while +/// the document's stays on. +#[test] +fn align_new_objects_makes_new_paths_pixel_aligned() { + let mut s = new_doc(); + assert!(!align_new(&s)); + assert_eq!(s.execute("document.setup", &json!({})).unwrap()["alignNewObjectsToPixelGrid"], false); + let old = path(&mut s, "M10.3 120.6 L40.7 120.6 L40.7 130.3 L10.3 130.3 Z", 2.0); + let old_points = points(&s, old); + let steps = undo_len(&s); + let r = s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": true})).unwrap(); + assert_eq!(r["alignNewObjectsToPixelGrid"], true); + assert!(align_new(&s)); + assert_eq!(undo_len(&s), steps + 1, "one undo step"); + // Art already in the document stays as it was. + assert!(!aligned(&s, old)); + assert_eq!(points(&s, old), old_points); + // A rectangle is drawn pixel-aligned: with the 2 pt stroke `path` left as the current one, its + // edges go to whole points. + let steps = undo_len(&s); + let rect = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, rect)); + assert_eq!(points(&s, rect), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert_eq!(undo_len(&s), steps + 1, "made and snapped in one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(NodeId(rect)).is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(aligned(&s, rect)); + // An open path from the Pen. + let line = s.execute("path.create", &json!({"anchors": [{"x": 50.3, "y": 60.2}, {"x": 90.6, "y": 60.2}]})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, line)); + // Its horizontal segment goes to a whole point. + assert!(points(&s, line).iter().all(|(_, y)| *y == 60.0), "{:?}", points(&s, line)); + // Type cannot be pixel-aligned. + let text = s.execute("text.create", &json!({"x": 10.3, "y": 200.6, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, text)); + // A duplicate and a paste of art that is not pixel-aligned keep it so. + s.execute("select.set", &json!({"ids": [old]})).unwrap(); + s.execute("edit.duplicate", &json!({"dx": 100.3, "dy": 0.4})).unwrap(); + let copy = s.doc().unwrap().selection.objects[0].0; + assert!(copy != old && !aligned(&s, copy)); + s.execute("select.set", &json!({"ids": [old]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let pasted = s.doc().unwrap().selection.objects[0].0; + assert!(pasted != old && pasted != copy && !aligned(&s, pasted)); + // The object's own setting turns off while the document's stays on. + s.execute("object.setPixelAligned", &json!({"ids": [rect], "on": false})).unwrap(); + assert!(!aligned(&s, rect) && align_new(&s)); + // Off: the next rectangle is drawn as it is given, and the first one stays pixel-aligned. + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": false})).unwrap(); + let plain = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, plain)); + let drawn = [(10.3, 20.6), (40.7, 20.6), (40.7, 30.3), (10.3, 30.3)]; + assert!(points(&s, plain).iter().zip(drawn).all(|(a, b)| close(*a, b)), "{:?}", points(&s, plain)); + assert!(aligned(&s, line)); + // A value that is not true or false changes nothing. + let steps = undo_len(&s); + assert!(s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": "yes"})).is_err()); + assert!(!align_new(&s)); + assert_eq!(undo_len(&s), steps); +} + +/// While Align New Objects to Pixel Grid is on, the paths of a new rectangular grid, polar grid +/// and flare are pixel-aligned (the group is not), and a new 3D Box add-on shape is snapped as +/// Align to Pixel Grid snaps the same box drawn without it. +#[test] +fn align_new_objects_reaches_grids_flares_and_add_on_shapes() { + let mut s = new_doc(); + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": true})).unwrap(); + let doc = |s: &Session| s.doc().unwrap().doc.clone(); + let children = |s: &Session, id: u64| -> Vec { doc(s).node(NodeId(id)).unwrap().children().unwrap().iter().map(|c| c.id.0).collect() }; + let grid = s.execute("shape.rectangularGrid", &json!({"x": 10.3, "y": 20.6, "width": 100.4, "height": 50.7})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, grid)); + let lines = children(&s, grid); + assert!(!lines.is_empty() && lines.iter().all(|id| aligned(&s, *id))); + // Under the 1 pt stroke, a divider sits on a half point across its length, and the frame + // has every corner on half points. + let half = |v: f64| ((v - 0.5) - (v - 0.5).round()).abs() < 1e-9; + let snapped = + |p: &[(f64, f64)]| halves(p) || (p.iter().all(|q| q.1 == p[0].1) && half(p[0].1)) || (p.iter().all(|q| q.0 == p[0].0) && half(p[0].0)); + assert!(lines.iter().all(|id| snapped(&points(&s, *id))), "{:?}", lines.iter().map(|id| points(&s, *id)).collect::>()); + let polar = s.execute("shape.polarGrid", &json!({"x": 200.3, "y": 20.6, "width": 80.4, "height": 80.7})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, polar) && children(&s, polar).iter().all(|id| aligned(&s, *id))); + let flare = s.execute("shape.flare", &json!({"cx": 100.3, "cy": 200.6})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, flare) && children(&s, flare).iter().all(|id| aligned(&s, *id))); + // Seen straight on, the box's front face has horizontal and vertical edges to snap. + let params = json!({"x": 20.3, "y": 300.6, "width": 60.4, "height": 40.7, "angleX": 0, "angleY": 0, "angleZ": 0}); + let drawn = s.execute("addon.shapes.box3d", ¶ms).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, drawn)); + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": false})).unwrap(); + let later = s.execute("addon.shapes.box3d", ¶ms).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, later)); + let unaligned = points(&s, later); + s.execute("object.setPixelAligned", &json!({"ids": [later]})).unwrap(); + assert_eq!(points(&s, drawn), points(&s, later)); + assert_ne!(points(&s, drawn), unaligned, "the box has segments to snap"); +} + +/// The document's Align New Objects to Pixel Grid is saved in the native file only when on and +/// read back; a file from before it opens with it off. +#[test] +fn align_new_objects_is_saved_and_read_back() { + let mut s = new_doc(); + let off = serde_json::to_value(&s.doc().unwrap().doc.setup).unwrap(); + assert!(off.get("alignNewObjectsToPixelGrid").is_none(), "{off}"); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(!back.setup.align_new_objects_to_pixel_grid); + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": true})).unwrap(); + let on = serde_json::to_value(&s.doc().unwrap().doc.setup).unwrap(); + assert_eq!(on["alignNewObjectsToPixelGrid"], true); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(back.setup.align_new_objects_to_pixel_grid); + let mut older = on; + older.as_object_mut().unwrap().remove("alignNewObjectsToPixelGrid"); + assert!(!serde_json::from_value::(older).unwrap().align_new_objects_to_pixel_grid); +} + fn selected_ids(s: &Session) -> Vec { let mut ids: Vec = s.doc().unwrap().selection.objects.iter().map(|i| i.0).collect(); ids.sort_unstable(); diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 5871b85a3..e7d3c7cd7 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1060,6 +1060,30 @@ fn link_info_gives_the_artboards_size_not_pixels() { ); } +/// A confined caller's Link Info of a placed document whose file is outside its read roots has no +/// `link`, `path` or `location`, and keeps the file's name. +#[test] +fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() { + let away = Folder::new("placed-lrr-away"); + let home = Folder::new("placed-lrr-home"); + let src = away.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap(); + let i = crate::file_access::confine(Some(&roots), || s.execute("links.info", &json!({"id": id.0}))).unwrap(); + assert!(!i.to_string().contains("placed-lrr-away"), "{i}"); + assert_eq!( + (i["fileName"].as_str(), i["document"].as_bool(), i["status"].as_str()), + (Some("logo.vectorcraft"), Some(true), Some("missing")), + "{i}" + ); + let l = crate::file_access::confine(Some(&roots), || s.execute("links.list", &json!({}))).unwrap(); + assert!(!l.to_string().contains("placed-lrr-away") && l["links"][0]["name"] == "logo.vectorcraft", "{l}"); + let i = s.execute("links.info", &json!({"id": id.0})).unwrap(); + assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}"); +} + /// Save As reads its defaults from the catalog; using them must retain current editable links. #[test] fn native_save_catalog_defaults_keep_placed_documents_editable() { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 99b80ef89..4d225e4bd 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1587,7 +1587,7 @@ fn a_module_without_permissions_ends_the_grant_of_its_id() { /// that holds such a module and a grant for its id has that grant forgotten when it is attached. #[test] fn a_module_without_permissions_ends_the_grant_kept_in_the_store() { - use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex}; + use vectorcraft_plugins::permissions::{Grant, GrantToken, Permission, sha256_hex}; let folder = crate::tests_links::Folder::new("plugin-store-stale-grant"); let id = "test.engine.permissions.storedstale"; let other = "test.engine.permissions.storedother"; @@ -1634,7 +1634,11 @@ fn a_module_without_permissions_ends_the_grant_kept_in_the_store() { let future = json!({"id": id, "hash": "00", "permissions": ["doc.read", "ui.panel"]}); std::fs::write(&grants, serde_json::to_vec(&json!([future, other_entry])).unwrap()).unwrap(); vectorcraft_plugins::registry::remove(id); - vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&approved), permissions: vec![Permission::DocRead] }); + vectorcraft_plugins::registry::grant(GrantToken::after_engine_approval(Grant { + id: id.into(), + hash: sha256_hex(&approved), + permissions: vec![Permission::DocRead], + })); let mut fourth = session(); fourth.attach_plugin_store(folder.0.clone()); assert_eq!(ids(), vec![other.to_string()]); @@ -1816,11 +1820,11 @@ fn a_grant_that_cannot_be_removed_and_has_no_stored_module_stops_counting() { run(&mut s, "plugin.remove", json!({"id": id})); } -/// A grant that a load of the plug-in store should forget and can't stays out of the approvals for -/// the session: both when `grants.json` can't be rewritten and when it can't be read at the load. -/// A grant recorded later counts again, and another store starts with nothing ignored. +/// A grant that a load of the plug-in store should forget and can't stays out of the approvals in +/// the process: both when `grants.json` can't be rewritten and when it can't be read at the load. +/// A grant recorded later counts again, and another store's grant for the id counts. #[test] -fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { +fn a_grant_the_load_cannot_remove_stops_counting_in_the_process() { use vectorcraft_plugins::permissions::sha256_hex; let folder = crate::tests_links::Folder::new("plugin-store-load-forget"); let elsewhere = crate::tests_links::Folder::new("plugin-store-load-forget-other"); @@ -1852,8 +1856,12 @@ fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { next.attach_plugin_store(elsewhere.0.clone()); assert_eq!(run(&mut next, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "that store's grant counts"); + // A load that can rewrite the file forgets the grant, and the store has nothing ignored again. + std::fs::write(&grants, &recorded).unwrap(); + next.attach_plugin_store(folder.0.clone()); + assert!(!grants.exists(), "the load removes the grant"); + // A directory at the file's path gives a read error on every platform. - std::fs::remove_file(&grants).unwrap(); std::fs::create_dir(&grants).unwrap(); vectorcraft_plugins::registry::remove(id); let mut third = session(); @@ -1879,6 +1887,180 @@ fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { run(&mut third, "plugin.remove", json!({"id": id})); } +/// A grant one session stops counting stops counting for every session in the process attached to +/// the same folder, by any path to it: one attached before, one attached after with no stored module +/// to try the removal for, and one that comes back from another folder. A successful write of the +/// grant in any of them makes it count in all of them again. +#[test] +fn an_ignored_stored_grant_is_shared_by_sessions_on_the_same_folder() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-shared-ignore"); + let elsewhere = crate::tests_links::Folder::new("plugin-store-shared-ignore-other"); + let id = "test.engine.permissions.sharedignore"; + let empty = json!({"objects": []}); + let approved = asking(id, r#"["doc.read"]"#, &empty); + let approved_data = vectorcraft_format::base64_encode(&approved); + let plain = asking(id, "null", &empty); + let needs_approval = json!({"dataBase64": approved_data}); + let grants = folder.0.join("grants.json"); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + let recorded = serde_json::to_vec(&json!([entry])).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + let mut first = session(); + first.attach_plugin_store(folder.0.clone()); + let inside = folder.0.join("inside"); + std::fs::create_dir(&inside).unwrap(); + let mut other = session(); + other.attach_plugin_store(inside.join("..")); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + // `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read. + let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes(); + std::fs::write(&grants, &full).unwrap(); + run(&mut first, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain)})); + let file = first.stored_plugin_file(id).unwrap(); + assert!(!std::path::Path::new(&file).exists(), "the store keeps no module"); + assert_eq!(std::fs::read(&grants).unwrap(), full); + + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the other session doesn't count it"); + let refused = other.execute("plugin.install", &json!({"dataBase64": approved_data, "replace": true})).unwrap_err().to_string(); + assert!(refused.contains("doc.read"), "{refused}"); + std::fs::write(&file, &approved).unwrap(); + vectorcraft_plugins::registry::remove(id); + other.load_stored_plugins(); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "the formerly approved module isn't loaded"); + std::fs::remove_file(&file).unwrap(); + + let mut later = session(); + later.attach_plugin_store(folder.0.clone()); + assert_eq!(run(&mut later, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "a session attached later doesn't count it"); + + std::fs::write(elsewhere.0.join("grants.json"), &recorded).unwrap(); + other.attach_plugin_store(elsewhere.0.clone()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "that store's grant counts"); + other.attach_plugin_store(folder.0.clone()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "back on the folder, it doesn't"); + + std::fs::write(&grants, &recorded).unwrap(); + assert!(later.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the file can be written"); + for s in [&mut first, &mut other, &mut later] { + assert_eq!(run(s, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "it counts again"); + } + std::fs::remove_file(&grants).unwrap(); +} + +/// A plug-in folder that doesn't exist yet gets the key it gets once it is made, and every path to a +/// folder gets the same key. +#[test] +fn a_plugin_folder_has_one_key_for_every_path_to_it() { + use crate::store::plugin_store_key; + let folder = crate::tests_links::Folder::new("plugin-store-key"); + let dir = folder.0.join("Plug-ins"); + let before = plugin_store_key(&dir); + assert_eq!(plugin_store_key(&dir.join("sub").join("..")), before); + assert_eq!(plugin_store_key(&folder.0.join(".").join("Plug-ins")), before); + std::fs::create_dir(&dir).unwrap(); + assert_eq!(plugin_store_key(&dir), before, "the key stays when the folder is made"); + assert_eq!(plugin_store_key(&dir.join(".")), before); + #[cfg(windows)] + { + let plain = std::path::absolute(&dir).unwrap(); + let verbatim = std::path::PathBuf::from(format!(r"\\?\{}", plain.display())); + assert_eq!(plugin_store_key(&verbatim), before, "a verbatim path names the same folder"); + } +} + +/// Sessions attached to a plug-in folder before it exists, by paths that only name the same folder +/// once it is made, share the grants the process doesn't count for it. A grant that one session +/// writes while another fails to remove it keeps counting. +#[test] +fn sessions_attached_before_the_folder_exists_share_its_ignored_grants() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-late-folder"); + let id = "test.engine.permissions.latefolder"; + let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let needs_approval = json!({"dataBase64": vectorcraft_format::base64_encode(&approved)}); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + std::fs::create_dir_all(folder.0.join("a").join("b")).unwrap(); + let dir = folder.0.join("a").join("Plug-ins"); + // On disk `link/..` is `a`, so `link/../Plug-ins` is the folder; by name it is a `Plug-ins` beside `a`. + // On Windows the folder is named in other letter case. + #[cfg(unix)] + let alias = { + std::os::unix::fs::symlink(folder.0.join("a").join("b"), folder.0.join("link")).unwrap(); + folder.0.join("link").join("..").join("Plug-ins") + }; + #[cfg(windows)] + let alias = folder.0.join("A").join("PLUG-INS"); + #[cfg(not(any(unix, windows)))] + let alias = dir.clone(); + let mut first = session(); + first.attach_plugin_store(dir.clone()); + let mut other = session(); + other.attach_plugin_store(alias); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("grants.json"), serde_json::to_vec(&json!([entry])).unwrap()).unwrap(); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + first.ignore_stored_grant(id, first.grant_writes()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the other session doesn't count it"); + assert!(other.store_grant(id, Some(serde_json::from_value(entry.clone()).unwrap())), "the file can be written"); + assert_eq!(run(&mut first, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "it counts again"); + + // A write that fails in one session while another writes the grant for the id leaves the grant + // counting. + let before = first.grant_writes(); + assert!(other.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the other session writes the grant"); + first.ignore_stored_grant(id, before); + assert_eq!(run(&mut first, "plugin.inspect", needs_approval)["needsApproval"], false, "the written grant counts"); + std::fs::remove_file(dir.join("grants.json")).unwrap(); +} + +/// Grant writes that change nothing, each for another id, don't grow what the process keeps past its +/// limit. +#[test] +fn the_process_keeps_a_bounded_number_of_grant_writes() { + let folder = crate::tests_links::Folder::new("plugin-store-writes-kept"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + for n in 0..300 { + assert!(s.store_grant(&format!("test.engine.permissions.unknown{n}"), None), "nothing to remove is a success"); + } + assert!(Session::grant_writes_kept() <= 256, "{}", Session::grant_writes_kept()); +} + +/// A session attached by a link to a plug-in folder matches the grants the process doesn't count +/// with the folder the link leads to now, not the one it led to when the session was attached. +#[cfg(unix)] +#[test] +fn a_linked_plugin_folder_uses_the_ignored_grants_of_where_the_link_leads() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-relinked"); + let id = "test.engine.permissions.relinked"; + let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let needs_approval = json!({"dataBase64": vectorcraft_format::base64_encode(&approved)}); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + let recorded = serde_json::to_vec(&json!([entry])).unwrap(); + let (old, new, link) = (folder.0.join("old"), folder.0.join("new"), folder.0.join("Plug-ins")); + for dir in [&old, &new] { + std::fs::create_dir(dir).unwrap(); + std::fs::write(dir.join("grants.json"), &recorded).unwrap(); + } + std::os::unix::fs::symlink(&old, &link).unwrap(); + let mut linked = session(); + linked.attach_plugin_store(link.clone()); + assert_eq!(run(&mut linked, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(&new, &link).unwrap(); + let mut direct = session(); + direct.attach_plugin_store(new.clone()); + direct.ignore_stored_grant(id, direct.grant_writes()); + assert_eq!(run(&mut linked, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the link leads to the ignored grant"); + assert!(linked.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the file can be written"); + assert_eq!(run(&mut direct, "plugin.inspect", needs_approval)["needsApproval"], false, "it counts again"); +} + /// A grants file that can't be opened or read may name the old module once it reads again, so /// neither a module listing no permissions nor `plugin.remove` gets past it. A directory at the /// file's path gives a read error on every platform. @@ -2119,6 +2301,35 @@ fn a_grants_file_that_cannot_be_deleted_keeps_the_stored_module() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// When the last grant can't be removed because `grants.json` can't be deleted, a replacing module +/// that asks for no permissions doesn't stay in the store: the store gets back the module the grant +/// still covers, and the grant is left as it was. +#[test] +fn a_grants_file_whose_deletion_fails_keeps_the_stored_module() { + let folder = crate::tests_links::Folder::new("plugin-store-undeletable-grants"); + let id = "test.engine.permissions.undeletablegrants"; + let empty = json!({"objects": []}); + let approved = asking(id, r#"["doc.read"]"#, &empty); + let approved_data = vectorcraft_format::base64_encode(&approved); + let plain = asking(id, "null", &empty); + let grants = folder.0.join("grants.json"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]})); + let file = s.stored_plugin_file(id).unwrap(); + let recorded = std::fs::read(&grants).unwrap(); + + crate::store::GRANTS_DELETE_FAILS.set(true); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true})); + crate::store::GRANTS_DELETE_FAILS.set(false); + assert_eq!(std::fs::read(&file).unwrap(), approved, "the store gets the module the grant covers back"); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant stays as it was"); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": approved_data}))["needsApproval"], false, "the grant still covers the module"); + + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(!grants.exists(), "a removal that can delete the file forgets the grant"); +} + /// A plug-in folder that leaves a module listing no permissions under an id forgets the process's /// grant for it, unless a later file in the folder leaves a granted module there. A grant kept in /// the plug-in store stays, with the stored module it covers. @@ -2165,7 +2376,7 @@ fn a_plugin_folder_ends_the_process_grant_of_an_id_it_replaces() { /// With a store attached, a matching grant held only by the process doesn't count. #[test] fn a_stored_grant_covers_only_the_stored_module_it_recorded() { - use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex}; + use vectorcraft_plugins::permissions::{Grant, GrantToken, Permission, sha256_hex}; let folder = crate::tests_links::Folder::new("plugin-store-recorded-module"); let id = "test.engine.permissions.storedbytes"; let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); @@ -2210,7 +2421,11 @@ fn a_stored_grant_covers_only_the_stored_module_it_recorded() { assert!(starts()); let mut attached = session(); attached.attach_plugin_store(folder.0.clone()); - vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] }); + vectorcraft_plugins::registry::grant(GrantToken::after_engine_approval(Grant { + id: id.into(), + hash: sha256_hex(&changed), + permissions: vec![Permission::DocRead], + })); let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)})); assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached"); run(&mut attached, "plugin.remove", json!({"id": id})); @@ -2297,6 +2512,194 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { vectorcraft_plugins::registry::revoke(id); } +/// Runs the test `name` of this module again in a process of its own → whether this is that +/// process. The list of disabled plug-ins is kept for the whole process, and the other tests here +/// read it again each time they attach a plug-in store. +fn in_own_process(name: &str) -> bool { + const CHILD: &str = "VECTORCRAFT_TEST_PLUGIN_DISABLED_LIST"; + if std::env::var_os(CHILD).is_some() { + return true; + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &format!("tests::plugins::{name}"), "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "{name} failed in its own process:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + false +} + +/// An update whose grant can't be written, and whose old module can't be put back either, stays in +/// the store: a disabled plug-in is turned on, as for any update the store holds. The old grant +/// doesn't cover the new module, so the next start skips it until it is approved. +#[test] +fn an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on() { + if !in_own_process("an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on") { + return; + } + use vectorcraft_plugins::{permissions::sha256_hex, registry, safe_mode}; + let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-update"); + let id = "test.engine.permissions.putbackupdate"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false})); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + std::fs::remove_file(&grants).unwrap(); + std::fs::create_dir_all(grants.join("block")).unwrap(); + + crate::store::PUT_BACK_FAILS.set(true); + let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true}); + let installed = s.execute("plugin.install", &update); + crate::store::PUT_BACK_FAILS.set(false); + assert_eq!(installed.unwrap()["hash"], sha256_hex(&changed), "the new module runs in this session"); + assert_eq!(registry::get(id).map(|p| p.hash().to_string()), Some(sha256_hex(&changed))); + assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), changed, "the store holds the new module"); + assert!(safe_mode::disabled().is_empty(), "the module the store holds is turned on"); + let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap(); + assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved"); + + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + registry::remove(id); + registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(registry::get(id).is_none(), "the old grant doesn't cover the new module"); + safe_mode::set_disabled(vec![]); +} + +/// An update whose grant can't be written, and whose put-back writes the old module and then +/// reports a failure, leaves the old module in the store: a disabled plug-in stays disabled, and the +/// next start doesn't load it. +#[test] +fn an_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off() { + if !in_own_process("an_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off") { + return; + } + use vectorcraft_plugins::{registry, safe_mode}; + let folder = crate::tests_links::Folder::new("plugin-store-put-back-fails-after-write"); + let id = "test.engine.permissions.putbacklate"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false})); + let disabled = safe_mode::disabled(); + assert!(!disabled.is_empty()); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + std::fs::remove_file(&grants).unwrap(); + std::fs::create_dir_all(grants.join("block")).unwrap(); + + crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(true); + let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true}); + let installed = s.execute("plugin.install", &update); + crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(false); + installed.unwrap(); + assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), first, "the store holds the old module"); + assert_eq!(safe_mode::disabled(), disabled, "the plug-in stays disabled"); + let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap(); + assert!(!safe_mode::parse_disabled_file(&saved).is_empty(), "the disabled list on disk is kept"); + + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + registry::remove(id); + registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(registry::get(id).is_none(), "the disabled old module doesn't load"); + safe_mode::set_disabled(vec![]); +} + +/// A module without permissions whose install can't remove the old grant, and whose put-back writes +/// the old module and then reports a failure, leaves the old module in the store: a disabled plug-in +/// stays disabled. +#[test] +fn a_plain_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off() { + if !in_own_process("a_plain_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off") { + return; + } + use vectorcraft_plugins::safe_mode; + let folder = crate::tests_links::Folder::new("plugin-store-plain-put-back-fails-after-write"); + let id = "test.engine.permissions.plainputbacklate"; + let empty = json!({"objects": []}); + let approved = asking(id, r#"["doc.read"]"#, &empty); + let plain = asking(id, "null", &empty); + let grants = folder.0.join("grants.json"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"]})); + run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false})); + let disabled = safe_mode::disabled(); + assert!(!disabled.is_empty()); + let file = s.stored_plugin_file(id).unwrap(); + // `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read. + let entry = serde_json::from_slice::>(&std::fs::read(&grants).unwrap()).unwrap().remove(0); + let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes(); + std::fs::write(&grants, &full).unwrap(); + + crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(true); + let installed = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true})); + crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(false); + installed.unwrap(); + assert_eq!(std::fs::read(&file).unwrap(), approved, "the store holds the old module"); + assert_eq!(safe_mode::disabled(), disabled, "the plug-in stays disabled"); + let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap(); + assert!(!safe_mode::parse_disabled_file(&saved).is_empty(), "the disabled list on disk is kept"); + + std::fs::remove_file(&grants).unwrap(); + run(&mut s, "plugin.remove", json!({"id": id})); + safe_mode::set_disabled(vec![]); +} + +/// A first install whose grant can't be written, and whose module can't be taken out of the store +/// again, stays in the store: the plug-in's file is turned on. No grant covers the module, so the +/// next start skips it until it is approved. +#[test] +fn a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on() { + if !in_own_process("a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on") { + return; + } + use vectorcraft_plugins::{registry, safe_mode}; + let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-first"); + let id = "test.engine.permissions.putbackfirst"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + let module = s.stored_plugin_file(id).unwrap(); + run(&mut s, "plugin.setEnabled", json!({"file": module, "enabled": false})); + let grants = folder.0.join("grants.json"); + std::fs::create_dir_all(grants.join("block")).unwrap(); + + crate::store::PUT_BACK_FAILS.set(true); + let installed = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + crate::store::PUT_BACK_FAILS.set(false); + installed.unwrap(); + assert!(registry::get(id).is_some(), "the module runs in this session"); + assert_eq!(std::fs::read(&module).unwrap(), first, "the store holds the module"); + assert!(safe_mode::disabled().is_empty(), "the file the store holds is turned on"); + let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap(); + assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved"); + + std::fs::remove_dir_all(&grants).unwrap(); + registry::remove(id); + registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + assert!(registry::get(id).is_none(), "no grant covers the module"); + safe_mode::set_disabled(vec![]); +} + /// A plug-in folder reports a changed module as needing approval and still loads the other files; /// granted on its own, the changed module loads from the folder. A session without a store uses the /// grants held by the process. @@ -2480,8 +2883,15 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() { params.join(",") ); let module = wat::parse_str(tpl::module(&manifest, "unreachable", "", 1)).unwrap(); - // One page of memory: the input and the parameters share 32 KiB. - registry::install(Plugin::load(&module, Limits { max_memory_bytes: 1 << 16, ..Limits::default() }).unwrap()); + // One page of memory: the input and the parameters share 32 KiB. The module lists permissions, + // so its grant is recorded first. + let plugin = Plugin::load(&module, Limits { max_memory_bytes: 1 << 16, ..Limits::default() }).unwrap(); + registry::grant(vectorcraft_plugins::permissions::GrantToken::after_engine_approval(vectorcraft_plugins::permissions::Grant { + id: id.into(), + hash: plugin.hash().to_string(), + permissions: plugin.manifest().permissions(), + })); + registry::install(plugin).unwrap(); let mut s = session(); let small = rect(&mut s, 10.0, "#ff0000"); let long = rect(&mut s, 30.0, "#00ff00"); @@ -2522,7 +2932,7 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() { assert!(e.contains(says), "{says}: {e}"); assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before, "{says}"); } - assert!(registry::remove(id)); + assert!(registry::remove(id) && registry::revoke(id)); } /// What the user granted is kept in the plug-in store with the module: the next session installs @@ -2613,6 +3023,41 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { run(&mut granted, "plugin.remove", json!({"id": id})); } +/// The registry installs a module that lists permissions only under a grant the process holds, so +/// a start and a `plugin.reload` record the store's grant for the module they load. A module in the +/// folder that nobody granted is reported and skipped, and records nothing. +#[test] +fn stored_and_reload_grants_still_install() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + let store = crate::tests_links::Folder::new("plugin-store-grants-reload"); + let id = "test.engine.permissions.reload"; + let lone = "test.engine.permissions.reload.lone"; + let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(store.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + let forget = || { + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + }; + forget(); + let mut next = session(); + next.attach_plugin_store(store.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store"); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&wasm))); + forget(); + let dir = crate::tests_links::Folder::new("plugin-folder-grants-reload"); + std::fs::write(dir.0.join("granted.wasm"), &wasm).unwrap(); + std::fs::write(dir.0.join("lone.wasm"), asking(lone, r#"["doc.read"]"#, &json!({"objects": []}))).unwrap(); + let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([id]), "{r}"); + assert!(vectorcraft_plugins::registry::granted(id).is_some(), "the store's grant is the process's too"); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval") && r["failed"][0]["file"].as_str().unwrap().ends_with("lone.wasm"), "{r}"); + assert!(vectorcraft_plugins::registry::get(lone).is_none() && vectorcraft_plugins::registry::granted(lone).is_none()); + run(&mut next, "plugin.remove", json!({"id": id})); +} + /// A grant covers the module it was given to: another module under the id, listing the same /// permissions, isn't installed from the store at the next start, a folder load reports it as /// needing approval, and `plugin.install` asks for `grant` again, even with `replace: true`. It diff --git a/crates/engine/src/tests/prefs_roots.rs b/crates/engine/src/tests/prefs_roots.rs new file mode 100644 index 000000000..b9818949f --- /dev/null +++ b/crates/engine/src/tests/prefs_roots.rs @@ -0,0 +1,107 @@ +//! The preferences commands under automation roots: a confined request sees a folder preference and +//! an export preset's folder only inside its read roots. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::cmd::fileio::ScreenExportPreset; +use crate::file_access::{AutomationRoots, confine}; +use crate::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn text(p: &Path) -> String { + p.to_string_lossy().to_string() +} + +/// A fresh scratch folder for one test, with the folders `in` and `out`. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vc-prefs-roots-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for sub in ["in", "out"] { + std::fs::create_dir_all(dir.join(sub)).unwrap(); + } + dir +} + +/// The folder preferences that name the folder outside the read root. +const OUTSIDE: [&str; 3] = ["pluginsFolder", "scratchPrimary", "templatesFolder"]; + +/// A session whose folder preferences name `inside` (fonts, and the Data Recovery folder with +/// spaces around it) and `outside` (the [`OUTSIDE`] ones, the Templates folder with spaces around +/// it), with a scratch disk choice and an export preset kept with each folder. +fn session(inside: &str, outside: &str) -> Session { + let mut s = Session::new(); + s.prefs.fonts_folder = inside.to_string(); + s.prefs.recovery_folder = format!(" {inside} "); + s.prefs.plugins_folder = outside.to_string(); + s.prefs.scratch_primary = outside.to_string(); + s.prefs.scratch_secondary = "None".to_string(); + s.prefs.templates_folder = format!(" {outside} "); + for (name, folder) in [("Inside", inside), ("Outside", outside)] { + let settings = json!({"formats": [{"format": "svg"}], "folder": folder}); + s.prefs.screen_export_presets.push(ScreenExportPreset { name: name.into(), settings: settings.as_object().cloned().unwrap_or_default() }); + } + s +} + +/// Does a string anywhere in `v` hold `path`? +fn holds(v: &Value, path: &str) -> bool { + v.to_string().contains(&path.replace('\\', "\\\\")) +} + +#[test] +fn prefs_commands_leave_out_folders_outside_the_read_root() { + let base = folder("get"); + let (inside, outside) = (text(&base.join("in").join("fonts")), text(&base.join("out"))); + let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let mut s = session(&inside, &outside); + + let all = confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({}))); + let prefs = &all["prefs"]; + assert_eq!(prefs["fontsFolder"], json!(inside), "a folder inside the root is shown"); + assert_eq!(prefs["recoveryFolder"], json!(format!(" {inside} ")), "checked without the spaces, shown as kept"); + assert_eq!(prefs["scratchSecondary"], json!("None"), "a scratch disk choice is shown"); + for key in OUTSIDE { + assert_eq!(prefs[key], Value::Null, "{key}: {all}"); + } + let presets = prefs["screenExportPresets"].as_array().unwrap(); + assert_eq!(presets[0]["settings"]["folder"], json!(inside)); + assert_eq!(presets[1]["name"], json!("Outside"), "the preset stays"); + assert!(presets[1]["settings"].get("folder").is_none(), "{all}"); + assert!(!holds(&all, &outside), "{all}"); + + for key in OUTSIDE { + let one = confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({"key": key}))); + assert_eq!(one, Value::Null, "{key}"); + } + assert_eq!(confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({"key": "fontsFolder"}))), json!(inside)); + + let list = confine(roots.as_ref(), || run(&mut s, "prefs.list", json!({}))); + assert!(!holds(&list, &outside), "{list}"); + let row = |key: &str| list.as_array().unwrap().iter().find(|r| r["key"] == key).cloned().unwrap(); + assert_eq!(row("fontsFolder")["value"], json!(inside)); + assert_eq!(row("pluginsFolder")["value"], Value::Null); + assert_eq!(row("pluginsFolder")["kind"], json!("text"), "the row stays"); + + // A request that sets another preference, or resets a category without folders. + let set = confine(roots.as_ref(), || run(&mut s, "prefs.set", json!({"values": {"keyboardIncrement": 2, "pluginsFolder": outside}}))); + assert_eq!(set, json!({"keyboardIncrement": 2.0, "pluginsFolder": null}), "a folder kept as it was is left out too"); + let reset = confine(roots.as_ref(), || run(&mut s, "prefs.reset", json!({"category": "General"}))); + assert!(!holds(&reset, &outside), "{reset}"); + assert_eq!(reset["prefs"]["fontsFolder"], json!(inside)); + + // Unconfined and with only a write root. + let all = run(&mut s, "prefs.get", json!({})); + assert_eq!(all["prefs"]["pluginsFolder"], json!(outside), "unconfined, the folder is shown"); + assert_eq!(all["prefs"]["screenExportPresets"][1]["settings"]["folder"], json!(outside)); + let write_only = AutomationRoots::new(&[], &[&base.join("in")]).unwrap(); + let all = confine(write_only.as_ref(), || run(&mut s, "prefs.get", json!({}))); + assert_eq!(all["prefs"]["fontsFolder"], Value::Null, "without a read root no folder is shown"); + assert_eq!(all["prefs"]["recoveryFolder"], Value::Null); + assert_eq!(s.prefs.plugins_folder, outside, "the preferences are unchanged"); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 4994a9072..f0b6a0708 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -682,6 +682,343 @@ fn a_recovery_save_takes_the_next_area_when_the_first_is_tidied_away() { let _ = std::fs::remove_dir_all(&dir); } +/// A session keeping its copies in folder `dir`, with one modified document whose copy is written. +fn saved_in(dir: &std::path::Path) -> (Session, String) { + let mut s = Session::new(); + s.prefs.recovery_folder = dir.to_string_lossy().to_string(); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + s.execute("file.recovery.save", &json!({})).unwrap(); + let area = s.recovery.own_area().unwrap().to_string(); + (s, area) +} + +/// A new app over folder `dir`, with nothing open. +#[cfg(unix)] +fn other_app(dir: &std::path::Path) -> Session { + let mut s = Session::new(); + s.prefs.recovery_folder = dir.to_string_lossy().to_string(); + s +} + +/// The area's lock file was removed while its app runs: the next recovery save locks the area +/// again, so another app neither offers nor discards its copy. +#[cfg(unix)] +#[test] +fn a_recovery_save_locks_the_area_again_when_its_lock_file_was_removed() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-relock-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(a.recovery.own_area(), Some(area.as_str())); + let mut b = other_app(&dir); + assert!(offered(&mut b).is_empty()); + assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy))); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The whole area was removed while its app runs: the next recovery save makes it again, locked, +/// with the copy written again. +#[cfg(unix)] +#[test] +fn a_recovery_save_makes_the_area_again_when_it_was_removed() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-remade-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_dir_all(dir.join(&area)).unwrap(); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1").as_str(), "{r}"); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy))); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Another app locked the area after its lock file was removed: the next heartbeat takes a new +/// area, and the document is to be copied again there. Its copy in the old area stays until then. +#[cfg(unix)] +#[test] +fn an_area_another_app_locked_meanwhile_is_left_for_a_new_one() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-moved-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let store = FolderStore::new(&dir); + let Ok(Lock::Held(other)) = store.lock(&area) else { panic!("the other app locks the area") }; + let uid = a.doc().unwrap().uid; + assert_eq!(cmd::recovery::heartbeat(&mut a), [uid]); + let mine = a.recovery.own_area().unwrap().to_string(); + assert_ne!(mine, area); + assert!(a.doc().unwrap().recovery.is_none()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file(), "kept until the new copy is written"); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}"); + assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists(), "the copy left there goes"); + assert!(matches!(store.lock(&mine), Ok(Lock::Busy))); + drop(other); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A copy written in the background into the area the session left meanwhile is not the +/// document's copy: it stays only until the document is copied again in the new area. +#[cfg(unix)] +#[test] +fn a_copy_written_into_an_area_left_meanwhile_goes_once_copied_again() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-inflight-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + let store: Arc = Arc::new(FolderStore::new(&dir)); + let (jobs, _) = cmd::recovery::jobs(&mut a, &store).unwrap(); + assert_eq!(jobs.len(), 1); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = store.lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + for j in jobs { + j.write().unwrap(); + j.finish(&mut a); + } + assert!(a.doc().unwrap().recovery.is_none()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + let uid = a.doc().unwrap().uid; + assert_eq!(cmd::recovery::heartbeat(&mut a), [uid], "the next heartbeat has it copied again"); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert_eq!(r["saved"].as_array().unwrap().len(), 1, "{r}"); + assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists()); + assert!(cmd::recovery::heartbeat(&mut a).is_empty()); + drop(other); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Another app locked the area meanwhile and the document is unchanged since its copy: the next +/// recovery save still writes the copy, in the new area. +#[cfg(unix)] +#[test] +fn a_recovery_save_copies_unchanged_documents_into_the_new_area() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-unchanged-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + let mine = a.recovery.own_area().unwrap().to_string(); + assert_ne!(mine, area); + assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}"); + assert!(dir.join(&mine).join("Untitled-1-1.vectorcraft").is_file()); + assert!(!dir.join(&area).join("Untitled-1-1.vectorcraft").exists()); + drop(other); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A copy left in an area the session had to leave goes at once when its document is saved, +/// reverted or closed, and every one goes when the app quits with nothing unsaved. +#[cfg(unix)] +#[test] +fn copies_left_behind_go_when_their_document_is_saved_reverted_or_closed() { + use cmd::recovery::Lock; + let base = std::env::temp_dir().join(format!("vc-recovery-leftgo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir = base.join("recovery"); + let path = base.join("doc.vectorcraft").to_string_lossy().to_string(); + let left = |area: &str| std::fs::read_dir(dir.join(area)).unwrap().flatten().any(|e| e.path().extension().is_some_and(|x| x == "vectorcraft")); + for how in ["save", "revert", "close", "quit"] { + let (mut a, _) = saved_in(&dir); + a.execute("document.save", &json!({"path": path})).unwrap(); + a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + a.execute("file.recovery.save", &json!({})).unwrap(); + let area = a.recovery.own_area().unwrap().to_string(); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + assert!(left(&area), "{how}"); + match how { + "save" => a.execute("document.save", &json!({"path": path})).map(drop).unwrap(), + "revert" => a.execute("file.revert", &json!({})).map(drop).unwrap(), + "close" => a.execute("file.close", &json!({})).map(drop).unwrap(), + _ => cmd::recovery::forget_all(&mut a), + } + assert!(!left(&area), "{how}"); + drop(other); + drop(a); + } + let _ = std::fs::remove_dir_all(&base); +} + +/// The lock file was removed: the next heartbeat locks the area again, before any recovery save. +#[cfg(unix)] +#[test] +fn a_heartbeat_locks_the_area_again_when_its_lock_file_was_removed() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-beatlock-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + assert!(cmd::recovery::heartbeat(&mut a).is_empty(), "the copy is still there"); + assert_eq!(a.recovery.own_area(), Some(area.as_str())); + assert!(matches!(FolderStore::new(&dir).lock(&area), Ok(Lock::Busy))); + assert!(offered(&mut other_app(&dir)).is_empty()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The lock file was removed and can't be made again (the area folder can't be written to now): +/// the session takes a new area and writes its copy there. +#[cfg(unix)] +#[test] +fn an_area_whose_lock_file_cannot_be_made_again_is_left_for_a_new_one() { + use std::os::unix::fs::PermissionsExt as _; + + use cmd::recovery::Lock; + // Permissions don't stop the superuser. + if rustix::process::geteuid().is_root() { + return; + } + let dir = std::env::temp_dir().join(format!("vc-recovery-readonly-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o500)).unwrap(); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + let mine = a.recovery.own_area().unwrap().to_string(); + assert_ne!(mine, area); + assert_eq!(r["saved"][0]["file"], format!("{mine}/Untitled-1-1").as_str(), "{r}"); + assert!(matches!(FolderStore::new(&dir).lock(&mine), Ok(Lock::Busy))); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o700)).unwrap(); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A copy left behind that can't be removed when its document is saved (its area can't be written +/// to now) stays the session's own: it isn't offered here, and a later heartbeat removes it. +#[cfg(unix)] +#[test] +fn a_copy_left_behind_that_cannot_be_removed_yet_stays_the_sessions_own() { + use std::os::unix::fs::PermissionsExt as _; + + use cmd::recovery::Lock; + // Permissions don't stop the superuser. + if rustix::process::geteuid().is_root() { + return; + } + let base = std::env::temp_dir().join(format!("vc-recovery-leftstuck-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let dir = base.join("recovery"); + let path = base.join("doc.vectorcraft").to_string_lossy().to_string(); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o500)).unwrap(); + a.execute("document.save", &json!({"path": path})).unwrap(); + std::fs::set_permissions(dir.join(&area), std::fs::Permissions::from_mode(0o700)).unwrap(); + let copy = dir.join(&area).join("Untitled-1-1.vectorcraft"); + assert!(copy.is_file()); + drop(other); + assert!(offered(&mut a).is_empty()); + cmd::recovery::heartbeat(&mut a); + assert!(!copy.exists()); + drop(a); + let _ = std::fs::remove_dir_all(&base); +} + +/// A copy left in an area the session had to leave is still the session's own after the app that +/// locked that area is gone: the session neither offers nor discards it. +#[cfg(unix)] +#[test] +fn a_copy_left_behind_is_not_offered_by_its_own_app() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-leftmine-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (mut a, area) = saved_in(&dir); + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + cmd::recovery::heartbeat(&mut a); + drop(other); + assert!(offered(&mut a).is_empty()); + let r = a.execute("file.recovery.discard", &json!({})).unwrap(); + assert!(r["discarded"].as_array().unwrap().is_empty(), "{r}"); + assert!(a.execute("file.recovery.discard", &json!({"file": format!("{area}/Untitled-1-1")})).is_err()); + assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file()); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A complex document (Data Recovery off for those) gets no new copy after the session left its +/// area: it keeps the copy it left there, and the heartbeat asks for it only once. +#[cfg(unix)] +#[test] +fn a_complex_documents_copy_left_behind_is_asked_for_once() { + use cmd::recovery::Lock; + let dir = std::env::temp_dir().join(format!("vc-recovery-leftcomplex-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut a = other_app(&dir); + let mut d = vectorcraft_doc::Document::new(1000.0, 1000.0); + let layer = d.layers[0].id; + for i in 0..cmd::recovery::COMPLEX_OBJECTS { + let id = d.alloc_id(); + let r = vectorcraft_geom::Rect::new((i % 200) as f64, (i / 200) as f64, (i % 200) as f64 + 1.0, (i / 200) as f64 + 1.0); + let n = vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art()); + d.insert(Some(layer), i, n).unwrap(); + } + a.add_document(d, None); + a.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + let copy = r["saved"][0]["file"].as_str().unwrap().to_string(); + let area = a.recovery.own_area().unwrap().to_string(); + a.prefs.recovery_off_for_complex = true; + std::fs::remove_file(dir.join(&area).join(".lock")).unwrap(); + let Ok(Lock::Held(other)) = FolderStore::new(&dir).lock(&area) else { panic!("the other app locks the area") }; + let uid = a.doc().unwrap().uid; + assert_eq!(cmd::recovery::heartbeat(&mut a), [uid]); + let r = a.execute("file.recovery.save", &json!({})).unwrap(); + assert!(r["saved"].as_array().unwrap().is_empty(), "{r}"); + assert!(cmd::recovery::heartbeat(&mut a).is_empty(), "asked for once"); + assert!(dir.join(format!("{copy}.vectorcraft")).is_file(), "its copy stays"); + drop(other); + drop(a); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The preference named another folder while a copy was written in the background: the copy goes +/// from the folder it was written to, and the document keeps its copy in the new folder. +#[test] +fn a_copy_finished_after_the_folder_changed_goes_from_its_own_folder() { + let base = std::env::temp_dir().join(format!("vc-recovery-refolder-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (old, new) = (base.join("old"), base.join("new")); + let (mut a, area) = saved_in(&old); + a.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 5, "height": 5})).unwrap(); + let store: Arc = Arc::new(FolderStore::new(&old)); + let (jobs, _) = cmd::recovery::jobs(&mut a, &store).unwrap(); + a.prefs.recovery_folder = new.to_string_lossy().to_string(); + a.execute("file.recovery.save", &json!({})).unwrap(); + let mine = a.doc().unwrap().recovery.as_ref().unwrap().file.clone(); + for j in jobs { + j.write().unwrap(); + j.finish(&mut a); + } + assert!(!old.join(&area).join("Untitled-1-1.vectorcraft").exists()); + assert_eq!(a.doc().unwrap().recovery.as_ref().unwrap().file, mine); + assert!(new.join(format!("{mine}.vectorcraft")).is_file()); + drop(a); + let _ = std::fs::remove_dir_all(&base); +} + #[test] fn saving_reverting_or_closing_removes_the_copy() { let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id())); diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index d92365a35..22a0239fb 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -425,8 +425,16 @@ fn transparency_grid_round_trips_without_dirtying() { let _ = std::fs::remove_dir_all(d); } +/// The `%%BoundingBox` of an EPS file's PostScript. +fn bounding_box(eps: &[u8]) -> [i64; 4] { + let ps = std::str::from_utf8(vectorcraft_eps::sections(eps).unwrap().0).unwrap(); + let line = ps.lines().find_map(|l| l.strip_prefix("%%BoundingBox:")).expect("a bounding box"); + let v: Vec = line.split_whitespace().map(|n| n.parse().unwrap()).collect(); + [v[0], v[1], v[2], v[3]] +} + #[test] -fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() { +fn opened_eps_saves_to_its_own_file_around_its_art_or_on_its_page() { let d = dir("eps-original-format"); let eps_path = path(&d, "Artwork.eps"); // Original, contributor-authored PostScript: the artwork occupies only part of the page. @@ -440,11 +448,16 @@ fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() { assert_eq!((board.width(), board.height()), (200.0, 100.0)); rect(&mut s); let edited_layers = s.doc().unwrap().doc.layers.clone(); - let r = s.execute("document.save", &json!({"options": {"previewFormat": "none", "thumbnails": false}})).unwrap(); + // Without Use Artboards the bounding box fits the art, as Export draws it; the editing data + // keeps the page. + let quiet = json!({"previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"options": quiet})).unwrap(); assert_eq!((r["path"].as_str(), r["format"].as_str()), (Some(eps_path.as_str()), Some("eps"))); let bytes = std::fs::read(&eps_path).unwrap(); assert!(bytes.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0")); - assert!(std::str::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0).unwrap().contains("%%BoundingBox: 0 0 200 100")); + let exported = b64(&s.execute("document.export", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap()); + assert_eq!(bounding_box(&bytes), bounding_box(&exported)); + assert_ne!(bounding_box(&bytes), [0, 0, 200, 100], "the art does not fill the page"); let editing = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(editing.artboards[0].rect, board); assert_eq!(editing.layers, edited_layers); @@ -453,26 +466,101 @@ fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() { assert_eq!(opened["restored"], true); assert_eq!(s.doc().unwrap().doc.layers, edited_layers); assert_eq!(s.doc().unwrap().doc.artboards[0].rect, board); + // Use Artboards keeps the opened file's page. + rect(&mut s); + s.execute("document.save", &json!({"options": {"useArtboards": true, "previewFormat": "none", "thumbnails": false}})).unwrap(); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); + assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); + // Save remembers it. rect(&mut s); s.execute("document.save", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str())); + assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); std::fs::remove_dir_all(d).unwrap(); } #[test] -fn eps_save_keeps_all_editing_artboards_while_printing_one_page() { +fn eps_save_without_use_artboards_fits_the_box_to_art_past_the_artboard() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": -50, "y": 20, "width": 300, "height": 40})).unwrap(); + let quiet = json!({"previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"format": "eps", "options": quiet})).unwrap(); + let [x0, y0, x1, y1] = bounding_box(&b64(&r)); + assert!(x0 <= -50 && x1 >= 250 && y0 >= 0 && y1 <= 100, "{:?}", [x0, y0, x1, y1]); + for (use_artboards, expected) in [(false, [x0, y0, x1, y1]), (true, [0, 0, 200, 100])] { + let options = json!({"useArtboards": use_artboards, "previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"format": "eps", "options": options})).unwrap(); + assert_eq!(bounding_box(&b64(&r)), expected, "useArtboards: {use_artboards}"); + } +} + +#[test] +fn eps_save_keeps_all_editing_artboards_in_the_file_of_one_page() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); rect(&mut s); let r = s.execute("document.save", &json!({"format": "eps", "artboard": 1, "previewFormat": "none", "thumbnails": false})).unwrap(); let bytes = b64(&r); + assert!(r.get("files").is_none(), "{r}"); + let board = s.doc().unwrap().doc.artboards[1].rect; + let [x0, y0, x1, y1] = bounding_box(&bytes); + assert_eq!(((x1 - x0) as f64, (y1 - y0) as f64), (board.width(), board.height())); let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(native.artboards.len(), 2); assert_eq!(native.layers, s.doc().unwrap().doc.layers); - assert!(s.execute("document.save", &json!({"format": "eps", "range": "all"})).is_err(), "EPS is one printed page"); - for options in [json!({"useArtboards": false}), json!({"selectedOnly": true})] { - assert!(s.execute("document.save", &json!({"format": "eps", "options": options})).is_err(), "cropping is Export's job"); + assert!(s.execute("document.save", &json!({"format": "eps", "options": {"selectedOnly": true}})).is_err(), "selected-only is Export's job"); +} + +#[test] +fn eps_save_with_use_artboards_and_a_named_artboard_reports_it_as_the_range() { + let d = dir("eps-named-artboard"); + let eps_path = path(&d, "Art.eps"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let options = json!({"useArtboards": true, "artboard": 1, "previewFormat": "none", "thumbnails": false}); + s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap(); + let o = s.execute("file.formatOptions", &json!({})).unwrap(); + assert_eq!(o["options"]["useArtboards"]["value"], true); + assert_eq!(o["options"]["range"]["value"], "2", "the saved artboard, not All"); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn eps_save_with_use_artboards_writes_the_central_file_and_one_per_artboard() { + let d = dir("eps-artboards"); + let eps_path = path(&d, "Art.eps"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let options = json!({"useArtboards": true, "previewFormat": "none", "thumbnails": false}); + let r = s.execute("document.save", &json!({"path": eps_path, "options": options})).unwrap(); + let files: Vec = r["files"].as_array().expect("files").iter().map(|f| f.as_str().unwrap().to_string()).collect(); + assert_eq!(files.len(), 3, "{r}"); + assert_eq!(files[0], eps_path, "the file the document becomes comes first"); + assert!(files[1..].iter().all(|f| f.starts_with(&path(&d, "Art_")) && f.ends_with(".eps")), "{files:?}"); + // The central file: the visible art and the whole document. + let central = std::fs::read(&eps_path).unwrap(); + let exported = b64(&s.execute("document.export", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap()); + assert_eq!(bounding_box(¢ral), bounding_box(&exported)); + assert_eq!(vectorcraft_format::load(&vectorcraft_eps::native(¢ral).unwrap()).unwrap().artboards.len(), 2); + // Each artboard's file: its artboard as the page, holding that artboard. + let boards = s.doc().unwrap().doc.artboards.clone(); + for (f, board) in files[1..].iter().zip(&boards) { + let bytes = std::fs::read(f).unwrap(); + let [x0, y0, x1, y1] = bounding_box(&bytes); + assert_eq!(((x1 - x0) as f64, (y1 - y0) as f64), (board.rect.width(), board.rect.height()), "{f}"); + let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); + assert_eq!(native.artboards.iter().map(|a| a.rect).collect::>(), [board.rect], "{f}"); } + let st = s.doc().unwrap(); + assert_eq!((st.path.as_deref(), st.is_dirty()), (Some(eps_path.as_str()), false)); + // With no visible art the central file is still written, on the first artboard. + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + std::fs::remove_file(&eps_path).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + assert_eq!(bounding_box(&std::fs::read(&eps_path).unwrap()), [0, 0, 200, 100]); + std::fs::remove_dir_all(d).unwrap(); } #[test] @@ -550,7 +638,7 @@ fn eps_save_bounds_large_page_preview_without_cropping_vector_or_editing_data() s.execute("file.new", &json!({"width": 4000, "height": 4000})).unwrap(); rect(&mut s); // Explicit colour preview remains a colour preview; Save bounds its resolution, not the art. - let r = s.execute("document.save", &json!({"format": "eps", "previewFormat": "tiffColor"})).unwrap(); + let r = s.execute("document.save", &json!({"format": "eps", "useArtboards": true, "previewFormat": "tiffColor"})).unwrap(); let bytes = b64(&r); let (ps, tiff) = vectorcraft_eps::sections(&bytes).unwrap(); let tiff = tiff.unwrap(); diff --git a/crates/engine/src/tests/script_dialogs.rs b/crates/engine/src/tests/script_dialogs.rs index 7572ca52f..f955a6fdb 100644 --- a/crates/engine/src/tests/script_dialogs.rs +++ b/crates/engine/src/tests/script_dialogs.rs @@ -217,6 +217,26 @@ fn dialog_loop_stops_or_exhausts() { assert_eq!(art(&s), before); } +#[test] +fn a_run_waiting_at_a_dialog_stops_at_the_next_slice_once_its_work_is_canceled() { + let mut s = one_doc(); + let before = art(&s); + let token = crate::cancel::CancelToken::new(); + s.script_frames = true; + let source = "vc.run('shape.rectangle', {x: 20, y: 0, width: 5, height: 5}); alert('x'); vc.log('answered');"; + assert_eq!(s.execute("script.run", &json!({"source": source, "name": "d.js"})).unwrap()["running"], true); + assert!(crate::cancel::scope(&token, None, || s.script_pump()).is_none()); + assert!(s.script_dialog().is_some()); + token.cancel(); + let ended = crate::cancel::scope(&token, None, || s.script_pump()); + assert!(ended.expect("the run goes on waiting for an answer").is_err()); + let report = s.script_report().unwrap(); + assert_eq!((report["kind"].as_str(), &report["log"]), (Some("stopped"), &json!([])), "{report}"); + assert_eq!(report["dialogs"][0]["answer"], "stop"); + assert_eq!(s.script_dialog(), None); + assert_eq!(art(&s), before); +} + #[test] fn dialog_text_limits_and_controls() { let mut s = one_doc(); diff --git a/crates/engine/src/tests/script_dollar.rs b/crates/engine/src/tests/script_dollar.rs index d5b962450..57ff9b15c 100644 --- a/crates/engine/src/tests/script_dollar.rs +++ b/crates/engine/src/tests/script_dollar.rs @@ -5,6 +5,7 @@ use std::time::Duration; use serde_json::{Value, json}; +use crate::cancel::{self, CancelToken}; use crate::script::{TEST_ELAPSED, TEST_NO_CLOCK}; use crate::*; @@ -204,6 +205,36 @@ fn a_sleeping_run_stops_when_asked() { TEST_ELAPSED.set(None); } +#[test] +fn a_sleeping_run_stops_at_the_next_slice_once_its_work_is_canceled() { + for (asleep_until, when) in + [(None, "before its first slice"), (Some(Duration::ZERO), "while it sleeps"), (Some(Duration::from_secs(1)), "at its wake time")] + { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let before = (format!("{:?}", s.documents()[0].doc.layers), s.documents()[0].history.undo.len()); + s.script_frames = true; + TEST_ELAPSED.set(Some(Duration::ZERO)); + let source = "vc.run('shape.rectangle', {x: 0, y: 0, width: 5, height: 5}); $.sleep(1000); vc.log('woke'); \ + vc.run('shape.rectangle', {x: 9, y: 0, width: 5, height: 5});"; + s.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + let token = CancelToken::new(); + if let Some(at) = asleep_until { + assert!(cancel::scope(&token, None, || s.script_pump()).is_none(), "{when}"); + assert_eq!(s.script_sleep_left(), Some(Duration::from_secs(1)), "{when}"); + TEST_ELAPSED.set(Some(at)); + } + token.cancel(); + let ended = cancel::scope(&token, None, || s.script_pump()); + TEST_ELAPSED.set(None); + assert!(ended.unwrap_or_else(|| panic!("{when}: the run goes on waiting")).is_err(), "{when}"); + let report = s.script_report().unwrap(); + assert_eq!((report["kind"].as_str(), &report["log"]), (Some("stopped"), &json!([])), "{when}: {report}"); + assert!(!s.script_running(), "{when}"); + assert_eq!((format!("{:?}", s.documents()[0].doc.layers), s.documents()[0].history.undo.len()), before, "{when}"); + } +} + #[test] fn sleep_takes_milliseconds_from_0_to_1000() { let mut s = Session::new(); diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index 4f9f193f5..d8c5d1d2e 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -55,6 +55,42 @@ fn file_constructor_identity_and_folder_path() { let _ = std::fs::remove_dir_all(base); } +/// `exists` is true only when the path names an entry of the object's own kind. +#[test] +fn script_file_exists_checks_the_kind() { + let base = folder("kind"); + write(&base.join("main/data.txt"), b"x"); + std::fs::create_dir_all(base.join("main/sub")).unwrap(); + let log = + logged(&base, "vc.log([new File('sub').exists, new Folder('data.txt').exists, new File('data.txt').exists, new Folder('sub').exists]);"); + assert_eq!(log, json!([[false, false, true, true]])); + let _ = std::fs::remove_dir_all(base); +} + +/// A property a script puts on `Object.prototype` is not read as part of what the app returns. +#[test] +fn script_file_ignores_inherited_result_fields() { + let base = folder("inherited"); + write(&base.join("main/data.txt"), b"data"); + let log = logged( + &base, + "Object.prototype.error = 'inherited'; Object.prototype.exists = true;\n\ + var f = new File('data.txt'); var opened = f.open('r');\n\ + vc.log([f.exists, new File('nope').exists, opened, f.error, f.read()]);", + ); + assert_eq!(log, json!([[true, false, true, "", "data"]])); + let _ = std::fs::remove_dir_all(base); +} + +/// `File.encode`, `File.decode`, `File.isEncodingAvailable` and the `Folder` locations are counted as +/// calls to the app. +#[test] +fn script_file_class_functions_are_counted() { + let source = "File.encode('x'); File.decode('y'); Folder.isEncodingAvailable('UTF-8'); Folder.temp;"; + let r = Session::new().execute("script.run", &json!({"source": source})).unwrap(); + assert_eq!(r["hostCallCounts"]["fileCalls"], 4, "{r}"); +} + #[test] fn file_names_are_uri_encoded_and_current_relative() { let base = folder("names"); @@ -74,7 +110,7 @@ fn file_names_are_uri_encoded_and_current_relative() { assert_eq!( log, json!([ - ["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20%2F", "\u{e9}", "sub/y.txt", true, true], + ["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20/", "\u{e9}", "sub/y.txt", true, true], ["sub", "w", "w"], ["literal%20name.txt", "literal%2520name.txt", "literal%2520name.txt", true, "p%41", "p%41"] ]) diff --git a/crates/engine/src/tests/script_run.rs b/crates/engine/src/tests/script_run.rs index 3359f272f..417798a86 100644 --- a/crates/engine/src/tests/script_run.rs +++ b/crates/engine/src/tests/script_run.rs @@ -845,6 +845,30 @@ fn a_person_can_let_a_growing_run_go_on_or_stop_it() { assert_eq!(state(&s), before); } +#[test] +fn a_run_waiting_to_grow_stops_at_the_next_slice_once_its_work_is_canceled() { + TEST_GROWTH.with(|g| g.set(Some(20))); + let mut s = two_docs(); + let before = state(&s); + s.script_frames = true; + run(&mut s, "for (var i = 0; i < 25; i++) vc.run('shape.rectangle', {x:i,y:1,width:1,height:1});").unwrap(); + let token = crate::cancel::CancelToken::new(); + for _ in 0..1_000 { + if s.script_waiting() { + break; + } + assert!(crate::cancel::scope(&token, None, || s.script_pump()).is_none()); + } + assert!(s.script_waiting()); + token.cancel(); + let ended = crate::cancel::scope(&token, None, || s.script_pump()); + TEST_GROWTH.with(|g| g.set(None)); + assert!(ended.expect("the run goes on waiting for Continue").is_err()); + assert_eq!(s.script_report().unwrap()["kind"], "stopped"); + assert!(!s.script_running()); + assert_eq!(state(&s), before); +} + #[test] fn a_file_saved_before_a_failure_stays_written_and_is_named() { let path = std::env::temp_dir().join(format!("vc-script-save-{}.svg", std::process::id())); diff --git a/crates/engine/src/tests/select_effects.rs b/crates/engine/src/tests/select_effects.rs new file mode 100644 index 000000000..b30d0ef70 --- /dev/null +++ b/crates/engine/src/tests/select_effects.rs @@ -0,0 +1,236 @@ +//! Clicks, hovers and marquees with the selection tools leave out the area that raster effects +//! (drop shadows, glows, blurs) paint beyond an object: there they reach the object under the +//! effect, or nothing. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A rectangle filled with `color`. +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, color: &str) -> NodeId { + let id = id_of(&run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))); + run(s, "select.set", json!({"ids": [id.0]})); + run(s, "paint.setFill", json!({"color": color})); + id +} + +/// A drop shadow on the whole of `id`, `dx` to the right, with no blur. +fn shadow(s: &mut Session, id: NodeId, dx: f64) { + run(s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id.0], "params": {"x": dx, "y": 0, "blur": 0}, "item": null})); +} + +fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y) +} + +fn press(s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64), mods: Mods) -> Vec { + let v = ViewInfo::default(); + s.select_tool(tool, v).unwrap(); + for (k, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + if k == PointerKind::Drag && from == to { + continue; + } + s.pointer(&PointerEvent::new(k, x, y).with_mods(mods), v).unwrap(); + } + s.active().unwrap().selection.objects.clone() +} + +/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards. +fn click(s: &mut Session, x: f64, y: f64) -> Vec { + press(s, "selection", (x, y), (x, y), Mods::default()) +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +const WHITE: [u8; 4] = [255; 4]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// Blue B at x 100 to 160, red A over its left edge's side at x 20 to 80, and A's shadow +/// 40 pt to the right, from x 60 to 120: over the canvas from 80 to 100 and over B from 100 to 120. +fn shadow_over_b(s: &mut Session) -> (NodeId, NodeId) { + let b = rect(s, 100.0, 20.0, 60.0, 60.0, "#0000ff"); + let a = rect(s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + shadow(s, a, 40.0); + (a, b) +} + +#[test] +fn a_click_on_a_drop_shadow_selects_the_object_under_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + // The shadow paints over B and over the canvas. + assert_ne!(pixel(&s, 110, 50), BLUE); + assert_ne!(pixel(&s, 90, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert_eq!(click(&mut s, 110.0, 50.0), [b]); + assert_eq!(click(&mut s, 50.0, 50.0), [a]); +} + +#[test] +fn a_click_on_a_drop_shadow_over_the_canvas_selects_nothing_and_clears_the_selection() { + let mut s = session(); + let (a, _) = shadow_over_b(&mut s); + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert!(click(&mut s, 90.0, 50.0).is_empty()); +} + +#[test] +fn a_click_on_an_outer_glow_or_a_gaussian_blur_outside_the_path_selects_what_is_under_it() { + let mut s = session(); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"effect": "stylize.outerGlow", "ids": [a.0], "params": {"blur": 20, "mode": "normal"}, "item": null})); + assert_ne!(pixel(&s, 88, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 88.0, 50.0).is_empty()); + // A red square whose blur reaches over the canvas and over the left edge of a blue one. + let mut s = session(); + let b = rect(&mut s, 92.0, 20.0, 60.0, 60.0, "#0000ff"); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "ids": [a.0], "params": {"radius": 20}, "item": null})); + assert_ne!(pixel(&s, 98, 50), BLUE); + assert_ne!(pixel(&s, 87, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert_eq!(click(&mut s, 98.0, 50.0), [b]); + // (With B deselected, so that the press does not take its bounding box handle.) + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 87.0, 50.0).is_empty()); +} + +/// The first point of `area` (x0, y0, x1, y1) that the canvas paints, scanning rows. +fn painted_in(s: &Session, (x0, y0, x1, y1): (u32, u32, u32, u32)) -> Option<(f64, f64)> { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let img = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts); + (y0..y1) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .find(|&(x, y)| img.pixel(x, y) != WHITE) + .map(|(x, y)| (f64::from(x) + 0.5, f64::from(y) + 0.5)) +} + +#[test] +fn a_click_on_the_drop_shadow_of_a_group_or_of_point_type_selects_neither() { + let mut s = session(); + let a = rect(&mut s, 20.0, 20.0, 30.0, 30.0, "#ff0000"); + let b = rect(&mut s, 60.0, 20.0, 30.0, 30.0, "#ff0000"); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let g = id_of(&run(&mut s, "object.group", json!({}))); + shadow(&mut s, g, 80.0); + // The group's shadow lies from x 100 to 170. + assert_ne!(pixel(&s, 110, 35), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 110.0, 35.0).is_empty()); + assert_eq!(click(&mut s, 35.0, 35.0), [g]); + // Type 40 pt high at y 150 with its shadow 90 pt to the right. + let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 150, "text": "HH", "size": 40}))); + shadow(&mut s, t, 90.0); + let right = bounds(&s, t).x1; + assert!(right < 100.0, "{right}"); + let at = painted_in(&s, (right.ceil() as u32 + 10, 110, 199, 160)).expect("the type's shadow paints"); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, at.0, at.1).is_empty(), "{at:?}"); +} + +#[test] +fn a_click_on_the_drop_shadow_of_a_symbols_art_does_not_select_the_instance() { + let mut s = session(); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + shadow(&mut s, a, 40.0); + run(&mut s, "select.set", json!({"ids": [a.0]})); + let inst = id_of(&run(&mut s, "symbol.new", json!({"name": "Shadowed"}))); + assert_ne!(pixel(&s, 100, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 100.0, 50.0).is_empty()); + assert_eq!(click(&mut s, 50.0, 50.0), [inst]); +} + +#[test] +fn a_click_on_the_drop_shadow_of_an_image_outside_its_frame_does_not_select_it() { + let mut s = session(); + let mut png = vec![]; + image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let r = run(&mut s, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [65, 65]})); + let im = NodeId(r["ids"][0].as_u64().unwrap()); + // The frame lies from x 50 to 80 and the shadow from 90 to 120. + shadow(&mut s, im, 40.0); + assert_ne!(pixel(&s, 105, 65), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 105.0, 65.0).is_empty()); + assert_eq!(click(&mut s, 65.0, 65.0), [im]); +} + +#[test] +fn shift_click_direct_selection_and_hover_on_a_drop_shadow_pass_through_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + // Shift-click on the shadow over B adds B and keeps A. + run(&mut s, "select.set", json!({"ids": [a.0]})); + let shift = Mods { shift: true, ..Mods::default() }; + let mut sel = press(&mut s, "selection", (110.0, 50.0), (110.0, 50.0), shift); + sel.sort_by_key(|n| n.0); + let mut both = vec![a, b]; + both.sort_by_key(|n| n.0); + assert_eq!(sel, both); + // Direct Selection takes B through the shadow, and nothing over the canvas. + run(&mut s, "select.none", json!({})); + let sel = press(&mut s, "directSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()); + assert!(sel.contains(&b) && !sel.contains(&a), "{sel:?}"); + run(&mut s, "select.none", json!({})); + assert!(press(&mut s, "directSelection", (90.0, 50.0), (90.0, 50.0), Mods::default()).is_empty()); + // With A selected, the pointer over its shadow is the plain arrow, and over A the move pointer. + run(&mut s, "select.set", json!({"ids": [a.0]})); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + assert_eq!(s.cursor(Point::new(50.0, 50.0), Mods::default(), v), Cursor::Move); + assert_eq!(s.cursor(Point::new(110.0, 50.0), Mods::default(), v), Cursor::Arrow); + assert_eq!(s.cursor(Point::new(92.0, 50.0), Mods::default(), v), Cursor::Arrow); +} + +#[test] +fn a_drag_from_the_drop_shadow_of_a_selected_object_does_not_move_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + run(&mut s, "select.set", json!({"ids": [a.0]})); + let before = bounds(&s, a); + // From the shadow over the canvas: a marquee that touches nothing. + assert!(press(&mut s, "selection", (92.0, 50.0), (96.0, 60.0), Mods::default()).is_empty()); + assert_eq!(bounds(&s, a), before); + // From the shadow over B: B moves, A stays. + run(&mut s, "select.set", json!({"ids": [a.0]})); + let b_before = bounds(&s, b); + assert_eq!(press(&mut s, "selection", (110.0, 50.0), (110.0, 60.0), Mods::default()), [b]); + assert_eq!(bounds(&s, a), before); + assert!((bounds(&s, b).y0 - b_before.y0 - 10.0).abs() < 1e-6, "{:?}", bounds(&s, b)); +} + +#[test] +fn a_marquee_that_touches_only_a_drop_shadow_does_not_select_its_object() { + let mut s = session(); + let (_, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + assert!(press(&mut s, "selection", (85.0, 40.0), (95.0, 60.0), Mods::default()).is_empty()); + // From above B, over B and the shadow: B only. + assert_eq!(press(&mut s, "selection", (105.0, 5.0), (115.0, 30.0), Mods::default()), [b]); +} diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index 5124fb450..d1d415590 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -44,6 +44,7 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() assert!(libs.len() >= 8 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "status-effects" && l["name"] == "Status Effects"), "{libs:?}"); + assert!(libs.iter().any(|l| l["id"] == "textures" && l["name"] == "Textures" && l["count"] == 8), "{libs:?}"); for l in libs { let id = l["id"].as_str().unwrap(); let g = run(&mut s, "graphicStyle.library", json!({"library": id})); @@ -64,6 +65,10 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() let status = run(&mut s, "graphicStyle.library", json!({"library": "status-effects"})); let status_names: Vec<&str> = status["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); + let textures = run(&mut s, "graphicStyle.library", json!({"library": "textures"})); + let texture_names: Vec<&str> = textures["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + let expected = ["Fine Grain", "Canvas Weave", "Burlap Sack", "Cracked Glaze", "Mosaic Floor", "Quilted Squares", "Leaded Glass", "Damp Paper"]; + assert_eq!(texture_names, expected); let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]})); assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow"))); @@ -167,6 +172,53 @@ fn add_with_null_ids_applies_to_selection() { assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, Some(style.id)); } +#[test] +fn textures_styles_apply_their_texture_effects_and_keep_their_params_through_save_and_open() { + let mut s = session(); + let (_, lib) = cmd::stylelib::library(&s, "textures").unwrap(); + let mut ids = vec![]; + for (i, g) in lib.styles.iter().enumerate() { + let id = rect(&mut s, (i % 4) as f64 * 50.0, (i / 4) as f64 * 50.0, 40.0, 40.0); + let before = undo_len(&s); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "textures", "name": g.name, "apply": true, "ids": [id.0]})); + assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec![g.name.as_str()], Some(g.name.as_str()))); + assert_eq!(undo_len(&s), before + 1, "{}: added and applied as one step", g.name); + ids.push(id); + } + let effects = |s: &Session| -> Vec> { + ids.iter().map(|id| doc(s).node(*id).unwrap().appearance.effects.iter().map(|e| (e.id.clone(), e.params.clone())).collect()).collect() + }; + let applied = effects(&s); + let effect_ids: Vec> = applied.iter().map(|fx| fx.iter().map(|(id, _)| id.as_str()).collect()).collect(); + assert_eq!( + effect_ids, + [ + ["texture.grain"], + ["texture.texturizer"], + ["texture.texturizer"], + ["texture.craquelure"], + ["texture.mosaicTiles"], + ["texture.patchwork"], + ["texture.stainedGlass"], + ["sketch.waterPaper"], + ] + ); + assert_eq!(applied[1][0].1["texture"], "canvas"); + assert_eq!(applied[3][0].1["crackSpacing"], 12.0); + for (fx, g) in applied.iter().zip(&lib.styles) { + let want: Vec<(String, Value)> = g.appearance.effects.iter().map(|e| (e.id.clone(), e.params.clone())).collect(); + assert_eq!(fx, &want, "{} applies the library's params", g.name); + } + let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "textures.vectorcraft", "dataBase64": b64})); + assert_eq!(effects(&s), applied, "the params survive a save and open"); + for g in &lib.styles { + let saved = doc(&s).graphic_style(&g.name).unwrap(); + assert!(saved.same_look(g), "{} keeps its look", g.name); + assert!(ids.iter().any(|id| doc(&s).node(*id).unwrap().extras().graphic_style == Some(saved.id)), "{} stays linked", g.name); + } +} + /// A document with pattern `name` (a dot) and its swatch. fn add_pattern(s: &mut Session, name: &str, radius: f64) { s.edit("Pattern", |d, _| { diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 3ecb5e68e..55ab34564 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -638,6 +638,39 @@ fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradi assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into())); } +#[test] +fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk() { + let lib = vectorcraft_color::libraries::builtin_library("organic-fades").unwrap(); + for mode in ["rgb", "cmyk"] { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode})); + let list = run(&mut s, "swatch.library.list", json!({})); + let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "organic-fades").unwrap().clone(); + assert_eq!( + (row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()), + (Some("Organic Fades"), Some("gradients"), Some(lib.len() as u64)) + ); + let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + let r = run(&mut s, "swatch.library.add", json!({"library": "organic-fades", "names": ["Petal", "Dew"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Petal", "Dew"]), json!("Petal")), "{mode}"); + for name in ["Petal", "Dew"] { + // The swatch comes as the library has it: stops (colors as given, opacities, midpoints) and method. + let Paint::Gradient(added) = &doc(&s).swatch(name).unwrap().paint else { panic!("{name} is a gradient") }; + let Paint::Gradient(want) = &lib.swatch(name).unwrap().paint else { panic!("{name} is a gradient") }; + assert_eq!(added.gradient, want.gradient, "{mode} {name}"); + assert_eq!(added.gradient.method, vectorcraft_color::GradientMethod::Perceptual); + assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode} {name}: the stops stay RGB"); + assert!(added.gradient.stops.iter().any(|st| st.opacity < 1.0 && (st.midpoint - 0.5).abs() > 0.02), "{mode} {name}"); + } + // The fill takes the fade's method, opacities and midpoints. + let fill = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint(); + let (Paint::Gradient(fill), Paint::Gradient(want)) = (fill, &lib.swatch("Petal").unwrap().paint) else { panic!("a gradient fill") }; + assert_eq!(fill.gradient.method, want.gradient.method, "{mode}"); + let shape = |g: &vectorcraft_color::Gradient| g.stops.iter().map(|st| (st.offset, st.opacity, st.midpoint)).collect::>(); + assert_eq!(shape(&fill.gradient), shape(&want.gradient), "{mode}"); + } +} + #[test] fn pattern_libraries_bring_their_patterns_and_add_each_once() { let mut s = session(); diff --git a/crates/engine/src/tests/turn_direction.rs b/crates/engine/src/tests/turn_direction.rs index f12f0d196..a8961f3c4 100644 --- a/crates/engine/src/tests/turn_direction.rs +++ b/crates/engine/src/tests/turn_direction.rs @@ -114,3 +114,12 @@ fn keys_named(doc: &str) -> Vec { } found.into_iter().map(|(k, _)| k).collect() } + +/// Turn notes the key walk above doesn't reach: a numeric reflection axis and the Scatter brush's +/// `rotation [a, b]` range, whose head isn't a plain key. +#[test] +fn turns_outside_plain_keys_say_which_way() { + let doc = |id: &str| command_specs().iter().find(|c| c.id == id).map(|c| c.params).unwrap_or_default(); + assert!(doc("object.reflect").contains("|deg (the mirror line, counter-clockwise from horizontal"), "object.reflect's numeric axis"); + assert!(doc("brush.options").contains("rotation [a, b] −180..180° (counter-clockwise)"), "the Scatter brush's rotation"); +} diff --git a/crates/engine/src/tests/widthpoints.rs b/crates/engine/src/tests/widthpoints.rs index 4c837c300..6aa324aa9 100644 --- a/crates/engine/src/tests/widthpoints.rs +++ b/crates/engine/src/tests/widthpoints.rs @@ -221,9 +221,9 @@ fn new_width_point_adjust_adjoining_uses_sampled_widths() { let (mut s, id) = line_session(); let original = vec![(0.0, 9.0, 9.0), (0.25, 1.0, 0.0), (0.75, 3.0, 0.0), (1.0, 7.0, 7.0)]; s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": original})).unwrap(); - // At t=0.5 the old left side is where the profile's curve is there (#5714) and the right - // side is 0pt. Indexed editing of that sampled point is the existing convention: scale the - // nonzero side, add from zero. + // At t=0.5 the old left side is where the profile's curve is there and the right side is + // 0pt. Indexed editing of that sampled point is the existing convention: scale the nonzero + // side, add from zero. let sampled = vectorcraft_doc::WidthProfile { points: original }.at(0.5).0; let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": sampled * 5.0, "right": 0}))) } else { None }; set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true})); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 947cf559d..b3779d810 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -674,6 +674,8 @@ fn structured_junk() { ("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})), ("object.blend.options", json!({"ramp": 1e308, "colorShift": -1e308, "easingType": [1]})), ("object.blend.make", json!({"ids": [1, 2], "ramp": f64::NAN, "colorShift": [1]})), + ("object.blend.make", json!({"ids": [1, 2], "createSpine": ["never"], "easingType": "easeIn"})), + ("object.blend.createSpine", json!({"mode": 1e308})), ("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})), ("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})), ("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})), diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 586f177ae..67bb04c33 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1,4 +1,4 @@ -//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input, +//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF, DXF and HP-GL input, //! mutated raster images (Photoshop documents and JPEG XL images among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`), flattener preset //! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may @@ -2935,6 +2935,73 @@ proptest! { } } +// ---------- HP-GL plot files: opened and placed ---------- + +/// A plot file with every kind of instruction the reader plays, printer escapes around it. +const HPGL_SAMPLE: &[u8] = b"\x1b%-12345X@PJL ENTER LANGUAGE=HPGL2\r\n\x1bE\x1b%0BIN;BP1,\"job\";CO\"sample\";NP16;PC9,10,20,30;CR0,100,0,100,0,100;\ +SP1;PW0.5;WU0;LA1,4,2,4,3,7;LT2,5,1;PA0,0;PD1000,0,1000,1000;PU;LT;PR;PD-500,0,0,-500;PA2000,2000;CI300,5;AA2000,2500,120;AR0,100,-90;\ +AT2500,2500,3000,2000;RT100,100,200,0;BZ0,100,100,100,100,0;BR10,10,20,10,30,0;UL3,1,2,1;LT-3,2;EA3000,3000;ER100,-100;FT10,40;RA3500,3500;\ +RR200,200;WG400,30,120;EW-400,0,360;PM0;PD4000,4000,4500,4000,4500,4500;PM1;PU4100,4100;PD4200,4100,4200,4200;PM2;FP1;EP;FT3,10,45;FP;\ +IP0,0,10160,7620;SC0,100,0,100,1,20,80;PU10,10;PD20,20;SC0,10,0,10,2;PD5,5;SC;RO90;PD100,100;RO;IR10,10,90,90;IW0,0,5000,5000;\ +DT*,1;LBlabel*SMx;PA0,0;SM;PE:\xc1<=\xbf\xc0\xc2\xc2;LB\x03PG;\x1b%0A\x1b*r1A\x1b*b4W\x01\x02\x03\x04\x1b*rC\x1b%1BPD1,1;\x1b%-12345X"; + +/// The sample the mutations start from opens as a plot with art. +#[test] +fn the_hpgl_sample_opens() { + let l = vectorcraft_engine::cmd::fileio::load("x.plt", HPGL_SAMPLE).unwrap(); + assert_eq!(l.format.id, "hpgl"); + let mut paths = 0; + l.doc.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }))); + assert!(paths > 15, "{paths} paths, warnings {:?}", l.warnings); +} + +/// A random instruction: a mnemonic the reader plays (or one it doesn't) with hostile numbers. +fn arb_hpgl_instruction() -> impl Strategy { + let op = prop::sample::select(vec![ + "IN", "DF", "PU", "PD", "PA", "PR", "SP", "PW", "WU", "NP", "PC", "CR", "LT", "UL", "LA", "FT", "SC", "IP", "IR", "RO", "CI", "AA", "AR", + "AT", "RT", "BZ", "BR", "EA", "ER", "RA", "RR", "EW", "WG", "PM", "FP", "EP", "LB", "DT", "SM", "CO", "PG", "IW", "PE", "ZZ", + ]); + let number = prop_oneof![ + Just("0".to_string()), + Just("-1".to_string()), + (-3000i32..3000).prop_map(|v| v.to_string()), + Just("99999999999999999999999".to_string()), + Just("0.000001".to_string()), + Just("32767".to_string()), + Just("-32768".to_string()), + Just("360".to_string()), + ]; + (op, prop::collection::vec(number, 0..8)).prop_map(|(o, n)| format!("{o}{};", n.join(","))) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn random_hpgl_instructions_never_panic(instructions in prop::collection::vec(arb_hpgl_instruction(), 0..40)) { + let bytes = format!("IN;SP1;{}", instructions.concat()).into_bytes(); + survive_metafile("HP-GL instructions", "x.plt", &bytes)?; + } + + #[test] + fn mutated_hpgl_files_never_panic(cut in 0usize..1200, edits in prop::collection::vec((0usize..1200, any::()), 0..16)) { + let mut bytes = HPGL_SAMPLE.to_vec(); + let n = bytes.len(); + for &(at, b) in &edits { + bytes[at % n] = b; + } + bytes.truncate(cut.max(4)); + survive_metafile("mutated HP-GL", "x.plt", &bytes)?; + } + + #[test] + fn hpgl_garbage_never_panics(bytes in prop::collection::vec(any::(), 0..400)) { + let mut b = b"IN;SP1;PD".to_vec(); + b.extend(bytes); + survive_metafile("HP-GL garbage", "x.hpgl", &b)?; + } +} + // ---------- print presets files ---------- /// Import print presets from `data`, then lay out (and print, when short) the rich document with diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index b780c115c..f09106230 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -326,7 +326,7 @@ fn an_importer_past_its_budget_opens_nothing() { ]; for (id, limits, _) in &cases { let wasm = wat::parse_str(tpl::module(&tpl::importer(id, r#"["tbg"]"#, "{}"), SPINS, "", 1)).unwrap(); - vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap()); + vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap()).unwrap(); } let mut s = Session::new(); for (id, _, why) in &cases { diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a99646c9c..f2ba516b4 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -7575,3 +7575,45 @@ fn conic_gradients_are_written_as_linear_gradients_with_their_stops() { let Some(Paint::Gradient(swatch)) = back.swatch("Sweep").map(|s| &s.paint) else { panic!("the swatch is lost") }; assert_eq!(swatch.gradient.kind, GradientKind::Linear); } + +/// The data has no diamond gradient: a diamond fill is the radial gradient through its square's +/// corners (centered on it, its vector and the aspect across it), with the same stops, and so is +/// a diamond gradient swatch, with a warning. +#[test] +fn diamond_gradients_are_written_as_radial_gradients_through_their_corners() { + let stops = + vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(0.6, SKY), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient::new(GradientKind::Diamond, stops.clone())); + let (start, end) = (vectorcraft_geom::Point::new(100.0, 50.0), vectorcraft_geom::Point::new(150.0, 50.0)); + // A focal point (a plug-in's or a file's) draws nothing on a diamond and isn't written. + let focal = Some(vectorcraft_geom::Point::new(110.0, 55.0)); + g.geom = Some(vectorcraft_color::GradientGeom { start, end, aspect: 0.5, focal }); + let mut doc = Document::new(200.0, 100.0); + let id = doc.alloc_id(); + let l = doc.layers[0].id; + let fill = Paint::Gradient(Box::new(g.clone())); + doc.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(20.0, 10.0, 180.0, 90.0)), Appearance::basic(fill, Paint::None, 0.0))).unwrap(); + let mut swatches: Vec = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { name: "Gem".into(), paint: Paint::Gradient(Box::new(GradientPaint { geom: None, ..g })), global: false, spot: false }); + doc.swatches = swatches.into(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("diamond gradients are written as radial gradients")), "{:?}", out.warnings); + let back = read_back(&doc); + let mut read = None; + back.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + read = Some(g.clone()); + } + }); + let read = read.expect("a gradient fill"); + assert_eq!(read.gradient.kind, GradientKind::Radial); + assert_eq!( + read.gradient.stops.iter().map(|s| (s.offset, s.color.to_hex())).collect::>(), + stops.iter().map(|s| (s.offset, s.color.to_hex())).collect::>() + ); + let geom = read.geom.unwrap(); + assert!(geom.start.distance(start) < 0.01 && geom.end.distance(end) < 0.01 && (geom.aspect - 0.5).abs() < 0.01, "{geom:?}"); + assert!(geom.focal.is_none_or(|f| f.distance(start) < 0.01), "{geom:?}"); + let Some(Paint::Gradient(swatch)) = back.swatch("Gem").map(|s| &s.paint) else { panic!("the swatch is lost") }; + assert_eq!(swatch.gradient.kind, GradientKind::Radial); +} diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index 892a0d1b6..0dbe24c93 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -45,12 +45,22 @@ pub(super) struct PatternOut { used: bool, } -/// Can `g` be written as a gradient (a linear, radial or conic one with stops, no more than the -/// `.ai` reader reads; a conic is written linear, [`CONIC`])? +pub(in crate::ai_editing) const DIAMOND: &str = + "diamond gradients are written as radial gradients through their corners, with the same stops, in the editing data for other apps"; + +/// Can `g` be written as a gradient (a linear, radial, conic or diamond one with stops, no more +/// than the `.ai` reader reads; a conic is written linear, [`CONIC`], and a diamond radial, +/// [`DIAMOND`])? fn writable_gradient(g: &Gradient) -> bool { g.kind != GradientKind::Freeform && !g.stops.is_empty() && g.stops.len() <= crate::import::MAX_GRADIENT_STOPS } +/// Is a gradient of `kind` written as a radial one: a radial gradient, or a diamond (the ellipse +/// through its square's corners, [`DIAMOND`])? +fn written_radial(kind: GradientKind) -> bool { + matches!(kind, GradientKind::Radial | GradientKind::Diamond) +} + /// The first name of `Gradient`, `Gradient 2`, `Gradient 3`, … numbered `next` or higher for /// which `taken` returns false (`Gradient` is number 1); `next` is set past it. pub(in crate::ai_editing) fn free_gradient_name(next: &mut u64, mut taken: impl FnMut(&str) -> bool) -> String { @@ -128,7 +138,7 @@ impl Writer<'_> { let Paint::Gradient(g) = p else { return p.clone() }; let geom = g.resolve(bounds); let finite = [geom.start.x, geom.start.y, geom.end.x, geom.end.y, geom.aspect].iter().all(|v| v.is_finite()); - let radial = g.gradient.kind == GradientKind::Radial; + let radial = written_radial(g.gradient.kind); if g.gradient.kind == GradientKind::Freeform { self.warn(GRADIENTS); return Paint::solid(average(&g.gradient)); @@ -244,7 +254,7 @@ impl Writer<'_> { self.instance_head(g, clip, path); let m = self.grad_space; let u = geom.end - geom.start; - let radial = g.gradient.kind == GradientKind::Radial; + let radial = written_radial(g.gradient.kind); // The unit vector along the gradient, and across it as long. let frame = |from: f64, to: f64| { let p = geom.start + u * from; @@ -275,10 +285,15 @@ impl Writer<'_> { /// A gradient instance's focal point (`Bh`, radial gradients only) and its `Bg` line, as /// [`Self::instance`] writes them; also what a fill's art style holds after its `Bb`. pub(super) fn instance_head(&mut self, g: &GradientPaint, clip: bool, path: &BezPath) { - let Some(geom) = g.geom else { return }; + let Some(mut geom) = g.geom else { return }; + if g.gradient.kind != GradientKind::Radial { + // Only a radial gradient has a focal point (a plug-in's or a file's other kinds can + // carry one that draws nothing): a diamond's circle stays centered. + geom.focal = None; + } let name = self.define_gradient(&g.gradient, g.swatch.as_deref()); let m = self.grad_space; - let radial = g.gradient.kind == GradientKind::Radial; + let radial = written_radial(g.gradient.kind); let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas); if radial { let c = m * geom.start; @@ -305,8 +320,11 @@ impl Writer<'_> { if d.gradient.kind == GradientKind::Conic { self.warn(CONIC); } + if d.gradient.kind == GradientKind::Diamond { + self.warn(DIAMOND); + } let name = ps::bytes(d.name.as_bytes()); - let radial = d.gradient.kind == GradientKind::Radial; + let radial = written_radial(d.gradient.kind); let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {} {} Bd\n[", u8::from(radial), d.gradient.stops.len()); let mut stops = sorted_stops(&d.gradient); if !radial { @@ -508,7 +526,7 @@ impl Writer<'_> { let swatch = Some(sw.name.as_str()); let name = self.define_gradient(&g.gradient, swatch); s.push_str("Bb\n"); - if g.gradient.kind == GradientKind::Radial { + if written_radial(g.gradient.kind) { s.push_str("0 0 0 0 Bh\n"); } let _ = writeln!(s, "2 {} 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB", ps::bytes(name.as_bytes())); diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 68c761030..e256e305c 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -108,6 +108,7 @@ pub const NAMES: &str = "object names are left out (legacy .ai files name only l const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape"; const FREEFORM: &str = "freeform gradients are written as their average colour"; const CONIC: &str = "conic gradients are written as solid wedges clipped to the shape (legacy .ai files have no conic gradient)"; +const DIAMOND: &str = "diamond gradients are written as four linear gradients clipped to the shape (legacy .ai files have no diamond gradient)"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGES: usize = 360; const STROKES: &str = diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index 60eb9b0c1..5528ad752 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -201,7 +201,9 @@ pub(super) fn segment_point(path: &BezPath, fraction: f64) -> f64 { pub(super) fn direct_fill(p: &Paint) -> bool { match p { Paint::Solid { .. } => true, - Paint::Gradient(g) => !matches!(g.gradient.kind, GradientKind::Freeform | GradientKind::Conic) && !g.gradient.stops.is_empty(), + Paint::Gradient(g) => { + !matches!(g.gradient.kind, GradientKind::Freeform | GradientKind::Conic | GradientKind::Diamond) && !g.gradient.stops.is_empty() + } _ => false, } } diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 8917c9aa7..43b28c934 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -110,6 +110,35 @@ fn conic_gradients_are_written_as_wedges_clipped_to_the_shape() { } } +/// The format has no diamond gradient: a diamond fill, and a diamond stroke (written as its filled +/// outline), are four path objects clipped to the shape, each a linear gradient over a quadrant +/// of the diamond's frame, with a warning; the definition is not named after the swatch. +#[test] +fn diamond_gradients_are_written_as_four_linear_gradients_clipped_to_the_shape() { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient::new(GradientKind::Diamond, stops)); + g.geom = Some(vectorcraft_color::GradientGeom { + start: vectorcraft_geom::Point::new(100.0, 50.0), + end: vectorcraft_geom::Point::new(140.0, 30.0), + aspect: 0.7, + focal: None, + }); + g.swatch = Some("Gem".into()); + let diamond = Paint::Gradient(Box::new(g)); + for (fill, stroke) in [(diamond.clone(), Paint::None), (Paint::None, diamond)] { + let mut doc = Document::new(200.0, 100.0); + let n = shape(&mut doc, shapes::ellipse(Rect::new(20.0, 10.0, 180.0, 90.0)), fill.clone(), stroke.clone()); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, n).unwrap(); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == DIAMOND), "{:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes); + assert_eq!(text.matches("%AI5_BeginGradient: (Gradient)").count(), 1, "one linear definition: {text}"); + assert!(!text.contains("(Gem)"), "not named after the swatch"); + assert_eq!(text.matches(" Bg\n").count(), 4, "a gradient a quadrant"); + } +} + /// `doc` written with options `o`. Every file the tests write is also run as a PostScript program /// ([`prints_as`]) and must draw what `doc` draws. fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput { diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 068f53486..8f8fc4ccf 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -9,7 +9,7 @@ use super::{ ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, }; -use super::{CONIC, CONIC_WEDGES}; +use super::{CONIC, CONIC_WEDGES, DIAMOND}; pub(super) struct Writer<'a> { doc: &'a Document, @@ -429,6 +429,11 @@ impl<'a> Writer<'a> { { return self.conic_object(path, rule, f, g); } + if let Paint::Gradient(g) = &f.paint + && g.gradient.kind == GradientKind::Diamond + { + return self.diamond_object(path, rule, f, g); + } let Paint::Pattern { pattern, xf } = &f.paint else { // A freeform gradient: its average colour. self.warn(FREEFORM); @@ -497,6 +502,41 @@ impl<'a> Writer<'a> { self.line("Q"); } + /// A diamond gradient fill (the format has no diamond gradient) as four linear gradients + /// clipped to `path` (filled by `rule`), one for each quadrant of its frame and equal to the + /// diamond there, each running on under the next + /// ([`vectorcraft_color::GradientGeom::diamond_quadrants`]). A diamond without an extent is its + /// last stop. + fn diamond_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer, g: &GradientPaint) { + let area = path.to_bezpath().bounding_box(); + let Some(quadrants) = g.resolve(area).diamond_quadrants(area) else { + let color = g.gradient.stops.last().map_or(Color::gray(0.5), |s| s.color); + let flat = FillLayer { paint: Paint::solid(color), ..f.clone() }; + return self.path_object(path, rule, Some(&flat), None); + }; + self.warn(DIAMOND); + self.line("q"); + self.fill_rule(rule); + let several = path.subpaths.len() > 1; + if several { + self.line("*u"); + } + for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) { + self.mask(sp); + } + if several { + self.line("*U"); + } + let gradient = Gradient { kind: GradientKind::Linear, ..g.gradient.clone() }; + for (pts, geom) in quadrants { + // Not named after the swatch: the definition is a linear gradient, not the swatch's. + let linear = GradientPaint { gradient: gradient.clone(), geom: Some(geom), swatch: None, ..g.clone() }; + let quadrant = FillLayer { paint: Paint::Gradient(Box::new(linear)), ..f.clone() }; + self.path_object(&PathData::single(SubPath::polyline(&pts, true)), FillRule::NonZero, Some(&quadrant), None); + } + self.line("Q"); + } + /// A path object painted with `fill` (a colour or a linear or radial gradient) and `stroke` /// (a plain line of a colour) over it; several subpaths make a compound path. fn path_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) { diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 3b2a91f0f..96205aa7d 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -43,6 +43,7 @@ const SPOT_GRADIENTS_L2: &str = const SPOT_GRADIENTS_SHARED: &str = "gradients whose spot inks can't share a DeviceN color space are written without the spot inks"; const SPOT_GRADIENTS_PERCEPTUAL: &str = "Perceptual gradients of spot colors are written in process colors"; const CONIC_WEDGES: &str = "conic gradients are written as solid wedges (EPS has no conic shading)"; +const DIAMOND_QUADRANTS: &str = "diamond gradients are written as four linear gradients (EPS has no diamond shading)"; /// Wedges a conic gradient is written as: one a degree. const CONIC_WEDGE_COUNT: usize = 360; /// The most colorants a DeviceN color space may have (PostScript's limit). @@ -934,9 +935,9 @@ impl<'a> Scene<'a> { (space, out) } - /// Paint all of `area` (inside the current clip) with linear, radial or conic gradient `g`, - /// placed on `bounds`: a smooth shading at Level 3, else stepped fills (a conic: wedges at - /// both levels). + /// Paint all of `area` (inside the current clip) with linear, radial, conic or diamond gradient + /// `g`, placed on `bounds`: a smooth shading at Level 3, else stepped fills (a conic: wedges at + /// both levels; a diamond: four linear gradients, [`Self::diamond_quadrants`]). fn gradient(&mut self, g: &GradientPaint, bounds: Rect, area: Rect) { let geom = g.resolve(bounds); let (space, stops) = self.gradient_stops(&g.gradient); @@ -951,6 +952,7 @@ impl<'a> Scene<'a> { let smooth = self.o.level == Level::Three && !self.o.compatible_gradients; match g.gradient.kind { GradientKind::Conic => self.conic_steps(&geom, area, space, &stops), + GradientKind::Diamond => self.diamond_quadrants(&geom, area, space, &stops, smooth), GradientKind::Radial => { let r = geom.length().max(1e-6); let squash = geom.radial_squash(); @@ -999,6 +1001,43 @@ impl<'a> Scene<'a> { } } + /// A diamond gradient over `area` as four linear gradients, each clipped to a quadrant of its + /// frame and equal to the diamond there (PostScript has no diamond shading; + /// [`vectorcraft_color::GradientGeom::diamond_quadrants`]): smooth axial shadings, else + /// stepped fills. Each quadrant but the last runs on under the next (stops are opaque here), + /// so no hairline shows between them. A diamond without an extent is its last stop. + fn diamond_quadrants( + &mut self, + geom: &vectorcraft_color::GradientGeom, + area: Rect, + space: &GradientSpace, + stops: &[(f64, Vec)], + smooth: bool, + ) { + let Some(quadrants) = geom.diamond_quadrants(area) else { + if let Some((_, c)) = stops.last() { + self.set_comps(space, c); + self.rect_fill(area); + } + return; + }; + self.warn(DIAMOND_QUADRANTS); + for (pts, q) in quadrants { + self.line("q"); + for (i, p) in pts.iter().enumerate() { + push_nums(&mut self.body, &[p.x, p.y]); + self.line(if i == 0 { "newpath moveto" } else { "lineto" }); + } + self.line("closepath clip newpath"); + if smooth { + self.shading(2, space, &[q.start.x, q.start.y, q.end.x, q.end.y], stops); + } else { + self.linear_steps(q.start, q.end, area, space, stops); + } + self.line("Q"); + } + } + /// A Level 3 smooth shading of `kind` (2 axial, 3 radial) at `coords`, extended both ways. fn shading(&mut self, kind: u8, space: &GradientSpace, coords: &[f64], stops: &[(f64, Vec)]) { let n = space.n(); diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 96cf75c02..db9ae2837 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -217,6 +217,33 @@ fn conic_gradients_are_solid_wedges_at_both_levels() { } } +/// PostScript has no diamond shading: a diamond is four axial shadings at Level 3 (stepped fills at +/// Level 2), each clipped to a quadrant of its frame, and read back from the PostScript it looks +/// as the canvas paints it, with no hairline between the quadrants. +#[test] +fn diamond_gradients_are_four_linear_gradients_clipped_to_quadrants() { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let mut g = GradientPaint::new(Gradient::new(GradientKind::Diamond, stops)); + g.geom = Some(vectorcraft_color::GradientGeom { start: Point::new(100.0, 70.0), end: Point::new(140.0, 40.0), aspect: 0.6, focal: None }); + let paint = Paint::Gradient(Box::new(g)); + let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 120.0)), paint, Paint::None, 0.0)]); + for (o, shadings) in [(opts(&d), 4), (EpsOptions { level: Level::Two, ..opts(&d) }, 0)] { + let out = export(&d, &o, None, None).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("diamond gradients are written as four linear gradients")), "{:?}", out.warnings); + let page = page_of(&d, &o); + assert_eq!(page.matches("shfill").count(), shadings); + assert_eq!(page.matches("closepath clip newpath").count(), 4, "a clip a quadrant"); + } + let out = export(&d, &opts(&d), None, None).unwrap(); + let back = import_with(&out.bytes, false).unwrap().document; + vectorcraft_testkit::raster::assert_similar( + &vectorcraft_testkit::raster::render_artboard(&d), + &vectorcraft_testkit::raster::render_artboard(&back), + 6.0, + 0.01, + ); +} + #[test] fn strokes_keep_their_caps_joins_and_dashes() { let d = doc_with(|d| { diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs index 5a4363108..8d9ffc06c 100644 --- a/crates/js/src/ast.rs +++ b/crates/js/src/ast.rs @@ -187,7 +187,8 @@ pub enum Expr { qualifier: Option, name: XmlNamePart, }, - /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope. + /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope + /// that holds it, and also an assignment, update or `delete` target. XmlScopeName(ExprId), /// `object..name`. XmlDescendants { diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index f7c21a8d8..301b1235a 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -466,6 +466,7 @@ fn function_ctor(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsRe .chain([parts.len().saturating_sub(1), HEAD.len() + MID.len() + TAIL.len(), body.len()]) .fold(0usize, usize::saturating_add); interp.check_source_units(total).map_err(|e| interp.compile_error_to_abrupt(e))?; + interp.prepare_source(total)?; let mut units = Vec::with_capacity(total); units.extend(HEAD.encode_utf16()); for (i, p) in parts.iter().enumerate() { @@ -554,6 +555,7 @@ fn eval(interp: &mut Interp, _: Value, args: &[Value], _: bool) -> JsResult return Err(interp.type_error(NOT_PLAIN_OBJECT)), _ => None, }; + let fixed = shape.as_ref().and(fixed_state(o)); let Some(shape) = shape else { let what = format!("toSource: a {} cannot be written as source", interp.object(id)?.class.name()); return Err(interp.type_error(&what)); }; + if let Some(what) = fixed { + return Err(interp.type_error(what)); + } self.ancestors.push(id); let r = match shape { Err(length) => self.array(interp, id, length), @@ -347,6 +355,23 @@ const NOT_PLAIN_ARRAY: &str = "toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source"; const NOT_PLAIN_OBJECT: &str = "toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source"; +const NOT_EXTENSIBLE: &str = "toSource: a non-extensible object cannot be written as source"; +const READ_ONLY_LENGTH: &str = "toSource: an array with a read-only length cannot be written as source"; +const FIXED_PROPERTY: &str = "toSource: a property that is not writable or configurable cannot be written as source"; + +/// Why the literal for the array or plain object `o` would not make it again: `o` is not +/// extensible, its `length` is read-only, or one of its data properties is not writable or not +/// configurable. A literal makes each of these open. +fn fixed_state(o: &Object) -> Option<&'static str> { + if !o.extensible { + return Some(NOT_EXTENSIBLE); + } + if matches!(o.class, Class::Array { length_writable: false, .. }) { + return Some(READ_ONLY_LENGTH); + } + let open = WRITABLE | CONFIGURABLE; + o.props.iter().any(|(_, p)| matches!(p, Prop::Data { flags, .. } if flags & open != open)).then_some(FIXED_PROPERTY) +} /// The getter behind `item`; [`ScriptItem::Global`] is a value and has none. pub(crate) fn getter(item: ScriptItem) -> Option<(&'static str, NativeFn)> { diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 3092530d4..6992fd560 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -160,8 +160,11 @@ pub(crate) enum Op { parts: u32, list: bool, }, - /// `name` → the value of the name on the nearest XML value in scope. + /// `name` → the value of the name on the nearest XML value in scope that holds it. XmlScopeGet, + /// `name` → `object name`: the nearest XML value in scope that holds the name, for an + /// assignment, update or `delete`. + XmlScopeRef, /// `object` → the filter's state. XmlFilterStart, /// `state` → `state item`, or jumps with `state` when the items are used up. @@ -170,8 +173,9 @@ pub(crate) enum Op { XmlFilterTest, /// `state` → the XMLList of the items kept. XmlFilterEnd, - /// `iterator name` → `iterator value`: the value under the name a `for each` got. - ForEachValue, + /// `iterator name x₁…xₙ` → `iterator name x₁…xₙ value`: the value under the name a `for each` + /// got, read after the `n` values its target pushed. + ForEachValue(u32), /// `value` → nothing: sets the default XML namespace of the running code. SetDefaultNamespace, } @@ -308,7 +312,8 @@ fn effect(op: Op, finally_tables: &[Vec]) -> Result { Op::XmlDescendants | Op::XmlFilterTest => e(2, 1), Op::XmlEscape(_) | Op::XmlScopeGet | Op::XmlFilterStart | Op::XmlFilterEnd => e(1, 1), Op::XmlLiteral { parts, .. } => e(parts, 1), - Op::ForEachValue => e(2, 2), + Op::XmlScopeRef => e(1, 2), + Op::ForEachValue(n) => e(n + 2, n + 3), Op::SetDefaultNamespace => e(1, 0), }) } diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index 071e99a60..3023e3b12 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -497,16 +497,21 @@ impl<'a> FnCompiler<'a> { let brk = self.new_label(); self.bind(head); self.jump(Op::ForInNext, brk); - if each { - self.emit(Op::ForEachValue); - } match target { ForInTarget::Var(d) => { let n = self.var_name(&d.name.name); + if each { + self.emit(Op::ForEachValue(0)); + } self.emit(Op::SetVar(n)); } + ForInTarget::Expr(e) if each => self.store_value_to(*e)?, ForInTarget::Expr(e) => self.store_key_to(*e)?, } + if each { + // The value; the name under it goes next. + self.emit(Op::Pop); + } self.emit(Op::Pop); self.loop_body(body, labels, brk, head)?; self.jump(Op::Jump, head); @@ -538,6 +543,11 @@ impl<'a> FnCompiler<'a> { self.emit(Op::Rot3); self.emit(Op::SetIndex); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::Rot3); + self.emit(Op::SetIndex); + } _ => { self.expr(target)?; self.emit(Op::Pop); @@ -547,6 +557,46 @@ impl<'a> FnCompiler<'a> { Ok(()) } + /// Assigns the value under the name on top of the stack to a `for each` target expression, + /// leaving the name and then the value on top. The target is evaluated before the value is + /// read (ECMA-357 section 12.3). + fn store_value_to(&mut self, target: ExprId) -> CResult<()> { + let target = self.ast.unparenthesized(target); + self.at(self.ast.expr_span(target).start); + match self.ast.expr(target) { + Some(Expr::Ident(name)) => { + let n = self.var_name(name); + self.emit(Op::ForEachValue(0)); + self.emit(Op::SetVar(n)); + } + Some(Expr::Member { object, property }) => { + self.expr(*object)?; + self.emit(Op::ForEachValue(1)); + let n = self.name_key(key_of(&property.name)); + self.emit(Op::SetMember(n)); + } + Some(Expr::Index { object, index }) => { + self.expr(*object)?; + self.expr(*index)?; + self.emit(Op::ToKey); + self.emit(Op::ForEachValue(2)); + self.emit(Op::SetIndex); + } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::ForEachValue(2)); + self.emit(Op::SetIndex); + } + _ => { + self.expr(target)?; + self.emit(Op::Pop); + self.emit(Op::ForEachValue(0)); + self.throw_invalid_target(); + } + } + Ok(()) + } + fn throw_invalid_target(&mut self) { let s = self.string(&"invalid assignment target".encode_utf16().collect::>()); let depth = self.depth; @@ -854,6 +904,15 @@ impl<'a> FnCompiler<'a> { Ok(()) } + /// Pushes the nearest XML value in scope that holds the E4X name `name` of the expression + /// `id`, then the name, as an assignment, update or `delete` uses them. + fn xml_scope_ref(&mut self, id: ExprId, name: ExprId) -> CResult<()> { + self.expr(name)?; + self.at(self.ast.expr_span(id).start); + self.emit(Op::XmlScopeRef); + Ok(()) + } + fn xml_literal(&mut self, parts: &[XmlPart], list: bool) -> CResult<()> { for part in parts { match part { @@ -1096,6 +1155,10 @@ impl<'a> FnCompiler<'a> { self.expr(*index)?; self.emit(Op::DeleteIndex); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(arg, *name)?; + self.emit(Op::DeleteIndex); + } _ => { self.expr(arg)?; self.emit(Op::Pop); @@ -1202,6 +1265,13 @@ impl<'a> FnCompiler<'a> { self.emit(Op::GetIndex); } } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + if op.is_some() { + self.emit(Op::Dup2); + self.emit(Op::GetIndex); + } + } _ => { self.expr(target)?; if op.is_none() { @@ -1222,7 +1292,7 @@ impl<'a> FnCompiler<'a> { let n = self.name_key(key_of(&property.name)); self.emit(Op::SetMember(n)); } - Some(Expr::Index { .. }) => self.emit(Op::SetIndex), + Some(Expr::Index { .. } | Expr::XmlScopeName(_)) => self.emit(Op::SetIndex), _ => self.throw_invalid_target(), } Ok(()) @@ -1246,6 +1316,10 @@ impl<'a> FnCompiler<'a> { self.expr(*index)?; self.emit(Op::IncIndex { prefix, dec }); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::IncIndex { prefix, dec }); + } _ => { self.expr(target)?; self.emit(Op::ToNumber); diff --git a/crates/js/src/extension_tests.rs b/crates/js/src/extension_tests.rs index 19a20ab5b..8336643e4 100644 --- a/crates/js/src/extension_tests.rs +++ b/crates/js/src/extension_tests.rs @@ -126,6 +126,45 @@ fn to_source_refuses_what_is_not_plain_data() { assert!(thrown(&mut r, "toSource({get g() { throw 'ran'; }})").starts_with("TypeError"), "the getter does not run"); } +#[test] +fn to_source_refuses_flags_and_extensibility_a_literal_does_not_make() { + let mut r = realm(); + let property = "TypeError: toSource: a property that is not writable or configurable cannot be written as source"; + let extensible = "TypeError: toSource: a non-extensible object cannot be written as source"; + let length = "TypeError: toSource: an array with a read-only length cannot be written as source"; + for (source, want) in [ + ("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true, configurable: true}); toSource(o)", property), + ("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true, writable: true}); toSource(o)", property), + ("var a = [1, 2]; Object.defineProperty(a, '1', {writable: false}); toSource(a)", property), + ("var a = [1, 2]; Object.defineProperty(a, '0', {configurable: false}); a.toSource()", property), + ("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true}); toSource({x: [{b: o}]})", property), + ("toSource(Object.preventExtensions({}))", extensible), + ("toSource(Object.preventExtensions([]))", extensible), + ("toSource(Object.seal({a: 1}))", extensible), + ("toSource(Object.freeze({a: 1}))", extensible), + ("toSource(Object.seal([1]))", extensible), + ("toSource(Object.freeze([1]))", extensible), + ("toSource([1, {b: Object.freeze({})}])", extensible), + ("var a = []; Object.defineProperty(a, 'length', {writable: false}); toSource(a)", length), + ("var a = [1, , 3]; Object.defineProperty(a, 'length', {writable: false}); toSource({a: a})", length), + ] { + assert_eq!(thrown(&mut r, source), want, "{source}"); + } + // The refused states are ones the literal would lose: evaluated, it is open again. + let lost = "var o = Object.freeze({a: 1}); var w = eval('({\"a\":1})'); + [Object.isExtensible(o), Object.isExtensible(w), Object.getOwnPropertyDescriptor(w, 'a').writable].join()"; + assert_eq!(text(&mut r, lost), "false,true,true"); +} + +#[test] +fn to_source_writes_properties_defined_with_every_flag_on() { + let mut r = realm(); + let source = "var o = {}; Object.defineProperty(o, 'a', {value: 1, writable: true, enumerable: true, configurable: true}); + var a = [0]; Object.defineProperty(a, '0', {value: 2, writable: true, enumerable: true, configurable: true}); + toSource({o: o, a: a, e: {}, f: [], s: [1, , 3]})"; + assert_eq!(text(&mut r, source), r#"({"o":({"a":1}), "a":[2], "e":({}), "f":[], "s":[1, , 3]})"#); +} + #[test] fn to_source_of_a_string_with_many_escapes_throws_a_range_error_before_the_heap_limit() { let mut r = realm(); diff --git a/crates/js/src/file_classes.rs b/crates/js/src/file_classes.rs index 715834fd5..cabf7c84a 100644 --- a/crates/js/src/file_classes.rs +++ b/crates/js/src/file_classes.rs @@ -70,8 +70,13 @@ fn message(i: &mut Interp, name: &str) -> JsResult { i.get_value(&text, &key(name)) } +/// The field `name` of a record the host gave: its own data property, `undefined` when it has none, +/// so that a property a script put on `Object.prototype` is not read as part of it. fn field(i: &mut Interp, v: &Value, name: &str) -> JsResult { - i.get_value(v, &key(name)) + match v.as_object() { + Some(o) => slot(i, o, name), + None => Ok(Value::Undefined), + } } /// The value of the own data property `name` of `o`, `undefined` when it has none. @@ -310,8 +315,19 @@ fn parent(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult instance(i, proto, up) } +/// `exists`: whether the path names something of the object's own kind, a folder for a `Folder` +/// and anything else for a `File`. fn exists(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { - Ok(Value::Bool(stat_field(i, &this, "exists", "exists", Value::Bool(false))?.truthy())) + let folder = classes(i)?.folder_proto; + let want_dir = match this.as_object() { + Some(o) => i.object(o)?.proto == Some(folder), + None => false, + }; + let Some(r) = stat(i, &this, "exists")? else { return Ok(Value::Bool(false)) }; + i.rooted(std::slice::from_ref(&r), |i| { + let dir = field(i, &r, "dir")?.truthy(); + Ok(Value::Bool(field(i, &r, "exists")?.truthy() && dir == want_dir)) + }) } fn created(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult { @@ -607,7 +623,7 @@ fn get_files(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult self.prepare_source(units.len().saturating_sub(floor)), + Err(_) => Ok(()), + }; + rest.map(|()| units.and_then(|units| self.compile_source_writes(&units, false, writes, crate::bytecode::CodeKind::Eval))) + }); + let code = code.and_then(|c| c); self.source_tag = tag; let r = match code { - Ok(code) => self.run_global(code, true), - Err(e) => Err(self.compile_error_to_abrupt(e)), + Ok(Ok(code)) => self.run_global(code, true), + Ok(Err(e)) => Err(self.compile_error_to_abrupt(e)), + Err(limit) => Err(limit), }; end(); r diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 6fc6c4e04..124fe395f 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -436,6 +436,104 @@ fn a_marked_host_error_that_passes_through_a_finally_block_says_where_it_was_rai assert_eq!(r.error_mark(), Some(7)); } +/// A realm with a 1 MiB heap, the size that the `finally` blocks of these tests collect garbage in. +fn small_heap_realm() -> Realm { + let mut r = Realm::new(); + r.set_limits(Limits { fuel: 200_000_000, heap_bytes: 1 << 20, ..Limits::default() }); + r +} + +/// Begins a run of `source` and advances it one instruction at a time until a `finally` block runs +/// for an exception whose throw site is saved. +fn start_until_a_throw_site_is_saved(r: &mut Realm, source: &str) { + r.start(source).unwrap(); + while r.interp().finally_sites.is_empty() { + assert_eq!(r.advance(1), Ok(Progress::Paused), "{source}"); + } +} + +/// The collections that a `finally` block's allocations cause under a small heap limit keep the +/// site its exception was raised at, and the run's end leaves no saved site behind. +#[test] +fn an_error_that_passes_through_a_finally_block_that_collects_garbage_says_where_it_was_raised() { + let mut r = small_heap_realm(); + let source = "try {\n throw new Error('original');\n} finally {\n for (var i = 0; i < 20000; i++) { var waste = [i, i + 1, i + 2]; }\n}"; + start_until_a_throw_site_is_saved(&mut r, source); + r.collect_garbage(); + let collections = r.interp().heap.collections; + let e = loop { + match r.advance(1_000) { + Ok(Progress::Paused) => assert_eq!(r.interp().finally_sites.len(), 1, "the site stays saved while the block runs"), + Ok(Progress::Done(v)) => panic!("the run ended with {v:?}"), + Err(e) => break e, + } + }; + assert!(r.interp().heap.collections > collections, "the block's allocations collect garbage"); + assert_eq!((e.kind, e.line, e.column, e.message.as_str()), (RunErrorKind::Thrown, 2, 3, "Error: original")); + assert!(r.interp().finally_sites.is_empty()); +} + +/// The code of a saved throw site stays live through a collection: direct `eval` code that +/// threw is not part of the code of any frame still running. +#[test] +fn a_saved_throw_site_keeps_its_code_live() { + let mut r = small_heap_realm(); + let source = "try {\n eval(\"var unused = 'padding padding padding padding';\\nthrow new Error('original');\");\n} finally {\n for (var i = 0; i < 2000; i++) { var waste = [i]; }\n}"; + start_until_a_throw_site_is_saved(&mut r, source); + let with_site = r.collect_garbage(); + let sites = std::mem::take(&mut r.interp().finally_sites); + let without_site = r.collect_garbage(); + r.interp().finally_sites = sites; + assert_eq!(r.collect_garbage(), with_site, "the saved site's code is counted again"); + assert!(with_site > without_site, "{with_site} <= {without_site}"); + let e = finish(&mut r, 1_000).0.unwrap_err(); + assert_eq!((e.kind, e.line, e.message.as_str()), (RunErrorKind::Thrown, 2, "Error: original")); + assert!(r.interp().finally_sites.is_empty()); +} + +/// A run that a host function stops inside a `finally` block leaves no saved throw site behind. +#[test] +fn a_run_stopped_in_a_finally_block_leaves_no_saved_throw_site() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|_| Err(HostError::Abort("stopped".into()))); + r.define_host_object("host", vec![("abort".into(), HostItem::Function(f))]).unwrap(); + let e = r.run("try { throw new Error('original'); } finally { host.abort(); }").unwrap_err(); + assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Limit(LimitKind::Host), "stopped")); + assert!(r.interp().finally_sites.is_empty()); + assert_eq!(r.run("1").unwrap(), JsValue::Number(1.0)); + assert!(r.interp().finally_sites.is_empty()); +} + +/// A `finally` block that `break`, `continue` or `return` leaves drops the saved site of the +/// exception it ran for as it leaves, before the stack grows back over that site's slot. +#[test] +fn leaving_a_finally_block_early_drops_its_saved_throw_site() { + let mut r = realm(1_000_000); + let marked = Rc::new(Cell::new(false)); + let seen = marked.clone(); + let f: HostFunction = Rc::new(move |_| { + seen.set(true); + Ok(Data::Undefined) + }); + r.define_host_object("host", vec![("mark".into(), HostItem::Function(f))]).unwrap(); + for source in [ + "do { try { throw new Error('x'); } finally { break; } } while (false);\nhost.mark(); var a = [1, 2, 3];", + "for (var i = 0; i < 1; i++) { try { throw new Error('x'); } finally { continue; } }\nhost.mark(); var a = [1, 2, 3];", + "function f() { try { throw new Error('x'); } finally { return 1; } }\nf(); host.mark(); var a = [f, f, f];", + ] { + marked.set(false); + let mut saved = false; + r.start(source).unwrap(); + while r.advance(1).unwrap() == Progress::Paused { + saved |= !r.interp().finally_sites.is_empty(); + if marked.get() { + assert!(r.interp().finally_sites.is_empty(), "{source}"); + } + } + assert!(saved && marked.get(), "{source}"); + } +} + #[test] fn advancing_without_a_run_is_an_error() { let mut r = realm(1_000); @@ -540,6 +638,45 @@ fn error_sources_count_from_the_last_script_and_code_from_an_earlier_one_is_unkn assert_eq!(r.error_source(), 0, "a syntax error is in the script"); } +#[test] +fn an_evaluators_source_takes_fuel_before_it_is_parsed() { + // The evaluator gives 131,072 spaces and a number: far more code units than the fuel left. + let ends = Rc::new(Cell::new(0)); + let ended = ends.clone(); + let source: HostFunction = Rc::new(|_| Ok(Data::String(format!("{}7", " ".repeat(1 << 17))))); + let item = HostItem::Evaluator { source, end: Rc::new(move || ended.set(ended.get() + 1)), strict_writes: Rc::new(|| None) }; + let mut r = realm(10_000); + r.define_host_object("host", vec![("evaluate".into(), item)]).unwrap(); + let e = r.run("host.evaluate()").unwrap_err(); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel)); + assert_eq!(ends.get(), 1, "the evaluator's end runs once"); + // This fuel covers a third of the source's bytes but not all of its code units. + r.set_limits(Limits { fuel: 60_000, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()").unwrap_err().kind, RunErrorKind::Limit(LimitKind::Fuel)); + assert_eq!(ends.get(), 2); + r.set_limits(Limits { fuel: 1_000_000, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()"), Ok(JsValue::Number(7.0))); + assert_eq!(ends.get(), 3); +} + +#[test] +fn an_evaluators_whole_source_must_fit_in_the_heap_before_it_is_parsed() { + // A third of the source and the other two thirds each fit in the heap; the whole does not. + // The source does not parse, so a parse would throw a syntax error instead. + let n = (1 << 17) + 1; + let source: HostFunction = Rc::new(move |_| Ok(Data::String(format!("{}(", " ".repeat(n - 1))))); + let item = HostItem::Evaluator { source, end: Rc::new(|| {}), strict_writes: Rc::new(|| None) }; + let mut r = realm(1_000_000); + r.define_host_object("host", vec![("evaluate".into(), item)]).unwrap(); + r.interp().collect_for_room(); + let heap_bytes = r.interp().heap.bytes + crate::heap::string_bytes(n * 5 / 6); + r.set_limits(Limits { fuel: 1_000_000, heap_bytes, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()").unwrap_err().kind, RunErrorKind::Limit(LimitKind::Heap)); + // With room for the whole source, it is parsed. + r.set_limits(Limits { fuel: 1_000_000, ..Limits::default() }); + assert_eq!(r.run("host.evaluate()").unwrap_err().kind, RunErrorKind::Thrown); +} + proptest::proptest! { /// A slice size does not change what a script computes. #[test] diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afb6cde63..f5e2ce2ae 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -9,7 +9,8 @@ //! Before each instruction the interpreter takes one unit of fuel, checks the interrupt flag and, //! when the heap has grown past its threshold, collects garbage. Running out of fuel, the heap //! limit, the frame limit, the re-entry limit and the interrupt flag each end the run with an -//! [`Abrupt::Limit`] that unwinds every frame without running catch or finally blocks. +//! [`Abrupt::Limit`] that unwinds every frame without running catch or finally blocks. Source +//! compiled during a run takes a unit of fuel for each code unit before it is parsed. //! //! Collection runs only between instructions. An operation that may run JavaScript in the middle //! keeps its operands on the operand stack until it is done, so everything it holds is reachable @@ -235,9 +236,9 @@ pub(crate) struct Interp { /// Where the exceptions `finally` blocks are running for were raised, innermost last, each with /// the stack slot that holds the exception while the block runs. The block's end throws the /// exception on from that site. An entry whose slot is at or above the stack's length belongs to - /// a block that was left, and [`Self::drop_left_finally_sites`] removes it. [`Self::unwind`] - /// removes the entries at or above a handler's slot before it adds one, so the slots rise and - /// the list is never longer than the stack. + /// a block that was left, and [`Self::drop_left_finally_sites`] removes it at each `Jump`, each + /// return and each collection. [`Self::unwind`] removes the entries at or above a handler's slot + /// before it adds one, so the slots rise and the list is never longer than the stack. pub(crate) finally_sites: Vec<(ThrowSite, usize)>, /// The mark of the host error the last run ended with. pub(crate) error_mark: Option, @@ -617,6 +618,22 @@ impl Interp { Ok(()) } + /// Prepares for parsing `units` code units of source at run time: takes a unit of fuel for + /// each code unit and checks that the source text fits in the heap, before any of it is + /// flattened, parsed or compiled. Running out of fuel uses up what was left. + pub(crate) fn prepare_source(&mut self, units: usize) -> JsResult<()> { + let fuel = u64::try_from(units).unwrap_or(u64::MAX); + if fuel > self.fuel { + self.fuel = 0; + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + self.fuel -= fuel; + if self.interrupt.load(Ordering::Relaxed) { + return Err(Abrupt::Limit(LimitKind::Interrupted)); + } + self.reserve_heap(string_bytes(units)) + } + /// Removes the [`Self::finally_marks`] entries of `finally` blocks that `break`, `continue` or /// `return` left: those whose stack slot no longer holds their exception. fn drop_left_finally_marks(&mut self) { @@ -885,7 +902,11 @@ impl Interp { Op::New(n) => self.op_new(n), Op::Binary(b) => self.op_binary(b), Op::Neg | Op::Plus | Op::BitNot | Op::Not | Op::Typeof | Op::ToNumber => self.op_unary(op), - Op::Jump(t) => self.jump_to(t), + Op::Jump(t) => { + // `break` and `continue` leave a `finally` block by popping its exception and jumping. + self.drop_left_finally_sites(self.stack.len()); + self.jump_to(t) + } Op::JumpIfFalse(t) | Op::JumpIfTrue(t) => self.op_branch(t, matches!(op, Op::JumpIfTrue(_)), false), Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) => self.op_branch(t, matches!(op, Op::JumpIfTrueKeep(_)), true), Op::Throw => { @@ -922,11 +943,12 @@ impl Interp { Op::XmlEscape(kind) => self.op_xml_escape(kind), Op::XmlLiteral { parts, list } => self.op_xml_literal(parts, list), Op::XmlScopeGet => self.op_xml_scope_get(), + Op::XmlScopeRef => self.op_xml_scope_ref(), Op::XmlFilterStart => self.op_xml_filter_start(), Op::XmlFilterNext(t) => self.op_xml_filter_next(t), Op::XmlFilterTest => self.op_xml_filter_test(), Op::XmlFilterEnd => self.op_xml_filter_end(), - Op::ForEachValue => self.op_for_each_value(), + Op::ForEachValue(n) => self.op_for_each_value(n as usize), Op::SetDefaultNamespace => self.op_set_default_namespace(), Op::Return | Op::ReturnCompletion => internal("return reached the dispatcher"), } @@ -2397,6 +2419,7 @@ impl Interp { }; let strict = self.strict(); self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?; + self.prepare_source(s.len())?; let units = s.flat(); let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; diff --git a/crates/js/src/interp_tests.rs b/crates/js/src/interp_tests.rs index 28157d6ee..93c487b37 100644 --- a/crates/js/src/interp_tests.rs +++ b/crates/js/src/interp_tests.rs @@ -500,6 +500,19 @@ fn declarations_take_fuel() { assert_eq!(limit(&mut realm, &format!("Function('var {vars};')();")), LimitKind::Fuel); } +#[test] +fn source_compiled_during_a_run_takes_fuel_before_it_is_parsed() { + // Doubling a string 17 times makes 131,072 spaces in about a hundred instructions, so only + // the compiled source can use up the fuel. + let build = "var s = ' '; for (var i = 0; i < 17; i++) s += s;"; + let mut realm = small(Limits { fuel: 10_000, ..Limits::default() }); + for call in ["eval(s)", "(0, eval)(s)", "Function(s)", "new Function('a', s)"] { + assert_eq!(limit(&mut realm, &format!("{build} {call};")), LimitKind::Fuel, "{call}"); + } + let mut realm = small(Limits { fuel: 1_000_000, ..Limits::default() }); + assert_eq!(realm.run(&format!("{build} eval(s + '1') + (0, eval)(s + '2') + Function(s + 'return 3')()")), Ok(JsValue::Number(6.0))); +} + #[test] fn a_lower_heap_limit_applies_to_what_is_already_live() { // A heap already past the new limit ends the next run. @@ -823,13 +836,14 @@ fn long_property_names_count_toward_the_heap_after_their_strings_are_collected() // Each name is a 16,385-unit buffer that only the object's key or the function's binding // holds once the string that made it is garbage, so about 32 of them fill the heap well before // the fuel runs out. In the second script the property lookup flattens a long string each - // time, so collections run. + // time, so collections run. Each eval there also takes fuel for its source, so that run has + // more fuel. let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 20_000, ..Limits::default() }); let src = "var s = 'x'; while (s.length < 10000) s += s; var o = {}; for (var i = 0; ; i++) o[s + i] = 1;"; assert_eq!(limit(&mut realm, src), LimitKind::Heap); let src = "var s = 'x'; while (s.length < 10000) s += s; \ (function () { for (var i = 0; ; i++) { eval('var ' + s + i + ' = 1'); ({})[s + i]; } })();"; - let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 20_000, ..Limits::default() }); + let mut realm = small(Limits { heap_bytes: 1 << 20, fuel: 5_000_000, ..Limits::default() }); assert_eq!(limit(&mut realm, src), LimitKind::Heap); } diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index 3c2ab3ed3..fa201c58f 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -873,8 +873,9 @@ impl<'s> Parser<'s> { } /// An assignment's, `++`'s, `--`'s or `for-in`'s target: an identifier (in strict code - /// neither `eval` nor `arguments`), a property, or outside strict mode code a call (assigning - /// to its result fails at run time, as ECMA-262 2015 Annex B and web browsers have it). + /// neither `eval` nor `arguments`), a property, an E4X name standing alone (`@a`, `ns::a`; + /// ECMA-357 section 11.1), or outside strict mode code a call (assigning to its result fails + /// at run time, as ECMA-262 2015 Annex B and web browsers have it). fn check_target(&self, expr: ExprId, message: &str) -> Result<()> { let inner = self.ast.unparenthesized(expr); match self.ast.expr(inner) { @@ -884,7 +885,7 @@ impl<'s> Parser<'s> { } Ok(()) } - Some(Expr::Member { .. } | Expr::Index { .. }) => Ok(()), + Some(Expr::Member { .. } | Expr::Index { .. } | Expr::XmlScopeName(_)) => Ok(()), Some(Expr::Call { .. }) if !self.ctx.strict => Ok(()), _ => Err(self.error_at_offset(self.ast.expr_span(expr).start, message)), } diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index f274c42ea..b17d6a277 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -14,7 +14,8 @@ use crate::{Options, SyntaxError}; /// cannot catch it, and their `finally` blocks do not run. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Limits { - /// Instructions a run may execute. + /// Instructions a run may execute. Source that `eval`, `Function` or a host evaluator + /// compiles during the run also takes one unit for each UTF-16 code unit, before it is parsed. pub fuel: u64, /// Estimated bytes the heap (objects, environments and the strings they hold) may reach /// after a collection. A string longer than a quarter of this many code units cannot be diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 9dc6d71f8..8d83162a5 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -175,6 +175,52 @@ fn is_attribute_run(units: &[u16]) -> bool { true } +/// Reads one pseudo-attribute of an XML declaration at `at`: white space, `name`, `=` with +/// optional white space around it, and a quoted value. Returns the offset after the closing +/// quote and the value. +fn decl_attribute<'a>(body: &'a [u16], at: usize, name: &str) -> Option<(usize, &'a [u16])> { + let start = skip_xml_space(body, at); + let name = parser_units(name); + if start == at || !body.get(start..)?.starts_with(&name) { + return None; + } + let eq = skip_xml_space(body, start + name.len()); + if body.get(eq) != Some(&EQ) { + return None; + } + let open = skip_xml_space(body, eq + 1); + let quote = body.get(open).copied().filter(|&q| q == DQUOTE || q == SQUOTE)?; + let len = body.get(open + 1..)?.iter().position(|&c| c == quote)?; + Some((open + len + 2, body.get(open + 1..open + 1 + len)?)) +} + +/// Whether `body`, the text between ``, is an XML declaration's version, optional +/// encoding, optional standalone flag and optional trailing white space (XML 1.0 productions 23 +/// to 26, 32, 80 and 81). +fn is_xml_decl_body(body: &[u16]) -> bool { + let is_digit = |c: &u16| (u16::from(b'0')..=u16::from(b'9')).contains(c); + let is_letter = |c: &u16| (u16::from(b'A')..=u16::from(b'Z')).contains(c) || (u16::from(b'a')..=u16::from(b'z')).contains(c); + let Some((mut at, version)) = decl_attribute(body, 0, "version") else { return false }; + let digits = version.strip_prefix(parser_units("1.").as_slice()).unwrap_or_default(); + if digits.is_empty() || !digits.iter().all(is_digit) { + return false; + } + if let Some((next, encoding)) = decl_attribute(body, at, "encoding") { + let Some((first, rest)) = encoding.split_first() else { return false }; + if !is_letter(first) || !rest.iter().all(|c| is_letter(c) || is_digit(c) || matches!(*c, 0x2D | 0x2E | 0x5F)) { + return false; + } + at = next; + } + if let Some((next, standalone)) = decl_attribute(body, at, "standalone") { + if standalone != parser_units("yes").as_slice() && standalone != parser_units("no").as_slice() { + return false; + } + at = next; + } + skip_xml_space(body, at) == body.len() +} + /// An XML name without a prefix (ECMA-357 section 13.1.2.1). pub(crate) fn is_ncname(units: &[u16]) -> bool { let Some((&first, rest)) = units.split_first() else { return false }; @@ -2305,8 +2351,30 @@ impl Interp { src.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| from + i) } + /// Takes fuel for `units` code units that the parser scanned, on top of the fuel of the step + /// that scanned them, so a short run costs nothing extra. + fn xml_scan_fuel(&mut self, units: usize) -> JsResult<()> { + self.xml_take_fuel(units / UNITS_PER_FUEL) + } + + /// The offset after the white space at `src[pos]`, taking fuel for its length. + fn skip_space(&mut self, src: &[u16], pos: usize) -> JsResult { + let end = skip_xml_space(src, pos); + self.xml_scan_fuel(end - pos)?; + Ok(end) + } + + /// The offset after the XML name at `src[pos]`, taking fuel for the units it scanned. fn read_name(&mut self, src: &[u16], pos: usize) -> JsResult { - scan_name(src, pos).ok_or_else(|| self.syntax("invalid XML name")) + if let Some(end) = scan_name(src, pos) { + self.xml_scan_fuel(end - pos)?; + return Ok(end); + } + if src.get(pos).copied().is_some_and(is_name_start) { + let scanned = src.get(pos..).unwrap_or_default().iter().skip(1).take_while(|&&c| is_name_part(c)).count(); + self.xml_scan_fuel(scanned + 1)?; + } + Err(self.syntax("invalid XML name")) } fn check_units(&mut self, src: &[u16], from: usize, to: usize) -> JsResult<()> { @@ -2390,9 +2458,7 @@ impl Interp { let mut raw_attrs: Vec<(usize, usize, Vec)> = Vec::new(); let closes = loop { let before = at; - while src.get(at).copied().is_some_and(is_xml_space) { - at += 1; - } + at = self.skip_space(src, at)?; match src.get(at).copied() { None => return Err(self.syntax("unterminated XML tag")), Some(SLASH) if src.get(at + 1) == Some(>) => { @@ -2407,26 +2473,22 @@ impl Interp { Some(_) => { self.use_fuel()?; let end = self.read_name(src, at)?; - let mut p = end; - while src.get(p).copied().is_some_and(is_xml_space) { - p += 1; - } + let p = self.skip_space(src, end)?; if src.get(p) != Some(&EQ) { return Err(self.syntax("expected '=' after an attribute name")); } - p += 1; - while src.get(p).copied().is_some_and(is_xml_space) { - p += 1; - } + let p = self.skip_space(src, p + 1)?; let quote = match src.get(p).copied() { Some(q @ (DQUOTE | SQUOTE)) => q, _ => return Err(self.syntax("expected a quoted attribute value")), }; let value_start = p + 1; let Some(value_end) = Self::find_from(src, value_start, &[quote]) else { + self.xml_text_fuel(src.len().saturating_sub(value_start))?; return Err(self.syntax("unterminated attribute value")); }; if src.get(value_start..value_end).is_some_and(|v| v.contains(<)) { + self.xml_text_fuel(value_end - value_start)?; return Err(self.syntax("'<' cannot appear in an attribute value")); } let mut value = Vec::new(); @@ -2528,10 +2590,7 @@ impl Interp { fn parse_close_tag(&mut self, src: &[u16], pos: usize, open: &mut Vec, names: &mut Vec<(usize, usize)>) -> JsResult { let name_start = pos + 2; let name_end = self.read_name(src, name_start)?; - let mut at = name_end; - while src.get(at).copied().is_some_and(is_xml_space) { - at += 1; - } + let at = self.skip_space(src, name_end)?; if src.get(at) != Some(>) { return Err(self.syntax("expected '>' at the end of a closing tag")); } @@ -2552,14 +2611,17 @@ impl Interp { held.add(self, string_bytes(source.len()).saturating_mul(2))?; let settings = self.xml_settings()?; let mut src = source; - let mut offset_start = 0; - while src.get(offset_start).copied().is_some_and(is_xml_space) { - offset_start += 1; - } + let offset_start = skip_xml_space(src, 0); if src.get(offset_start..).is_some_and(|r| r.starts_with(&parser_units("")) else { + self.xml_text_fuel(src.len())?; return Err(self.syntax("unterminated XML declaration")); }; + self.xml_text_fuel(end + 2)?; + self.check_units(src, offset_start, end)?; + if !is_xml_decl_body(src.get(offset_start + 5..end).unwrap_or_default()) { + return Err(self.syntax("invalid XML declaration")); + } src = src.get(end + 2..).unwrap_or_default(); } let mut pos = 0; @@ -2573,6 +2635,7 @@ impl Interp { let end = Self::find_from(src, pos, &[LT]).unwrap_or(src.len()); let section_end = parser_units("]]>"); if src.get(pos..end).is_some_and(|run| run.windows(3).any(|w| w == section_end)) { + self.xml_text_fuel(end - pos)?; return Err(self.syntax("']]>' cannot appear in XML text")); } self.decode_into(src, pos, end, &mut text, false)?; @@ -2582,6 +2645,7 @@ impl Interp { let rest = src.get(pos..).unwrap_or_default(); if rest.starts_with(&parser_units("")) else { + self.xml_text_fuel(src.len() - pos)?; return Err(self.syntax("unterminated CDATA section")); }; self.xml_text_fuel(end - pos)?; @@ -2593,12 +2657,13 @@ impl Interp { self.flush_text(&mut text, settings.ignore_whitespace, &open, &mut roots)?; if rest.starts_with(&parser_units("