diff --git a/Cargo.lock b/Cargo.lock index c441473d..afcd8a54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4553,6 +4553,7 @@ dependencies = [ "subsetter", "vectorcraft-doc", "vectorcraft-geom", + "write-fonts", ] [[package]] diff --git a/README.md b/README.md index 846a20c2..d1d9bbb6 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,10 @@ Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/cr build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path). Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input). +On laptops with two graphics processors, VectorCraft uses the power-saving (integrated) one by default. +To use the discrete one, choose **Preferences › Performance › Graphics Processor › High Performance** +and restart, or start the app with `WGPU_POWER_PREF=high` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)). + ### Use it from Claude Code and other agents Register the MCP server with Claude Code: diff --git a/ROADMAP.md b/ROADMAP.md index d7cbce6c..e7729195 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,7 +42,7 @@ since 2026-10-01. | **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | -| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; Windows/Linux packaging and accessibility are pending | +| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; hybrid-graphics laptops render on the power-saving GPU by default (#306); Windows/Linux packaging and accessibility are pending | | **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel | ### Where we're lacking (in priority order) @@ -95,6 +95,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. + - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, and Czech and Japanese cover every menu label, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. @@ -119,7 +120,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). -- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. +- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. - **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). @@ -141,6 +142,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). - Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. +- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. - **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`). ## Milestones and estimates @@ -154,7 +156,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | @@ -190,7 +192,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | | Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 | -| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 | +| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | | Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 | | Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 | diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index a1e7bcf1..15b755fa 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -241,6 +241,27 @@ fn app_icon() -> egui::IconData { eframe::icon_data::from_png_bytes(png).unwrap_or_default() } +/// The power preference wgpu picks the window's graphics adapter with (#306): the +/// `WGPU_POWER_PREF` environment variable (`low`, `high`, `none`) when set, otherwise Preferences › +/// Performance › Graphics Processor (`gpuPreference`). Power saving unless the preference asks for +/// high performance: the canvas is rasterized on the CPU and the GPU only composites it, which the +/// integrated GPU of a hybrid-graphics laptop does easily, while presenting frames rendered on the +/// discrete GPU through the integrated one made the window flicker on some laptops. With a single +/// GPU both preferences pick it. +fn power_preference(pref: Option<&str>, env: Option) -> eframe::wgpu::PowerPreference { + use eframe::wgpu::PowerPreference; + match (env, pref) { + (Some(p), _) => p, + (None, Some("highPerformance")) => PowerPreference::HighPerformance, + (None, _) => PowerPreference::LowPower, + } +} + +/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports. +fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String { + format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type) +} + /// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`). /// macOS keeps its traffic lights over the integrated title strip. const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); @@ -262,7 +283,9 @@ fn main() -> eframe::Result { } let saved = read_prefs(); let saved_window = saved.as_ref().and_then(|ui| ui.window); - let options = eframe::NativeOptions { + let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str); + let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env()); + let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("VectorCraft") .with_inner_size(window::DEFAULT_SIZE) @@ -276,6 +299,9 @@ fn main() -> eframe::Result { .with_app_id("ai.storyteller.vectorcraft"), ..Default::default() }; + if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + create.power_preference = power; + } eframe::run_native( "VectorCraft", options, @@ -297,6 +323,11 @@ fn main() -> eframe::Result { let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string())); app.session.recovery.set_default_folder(recovery); } + if let Some(rs) = &cc.wgpu_render_state { + let summary = adapter_summary(&rs.adapter.get_info()); + log::info!("vectorcraft: rendering with {summary} (power preference {power:?})"); + app.graphics_adapter = Some(summary); + } app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = CUSTOM_TITLEBAR; if let Some(port) = control_port { @@ -316,3 +347,35 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod tests { + use super::*; + use eframe::wgpu::PowerPreference; + + #[test] + fn power_saving_unless_the_preference_or_the_environment_says_otherwise() { + assert_eq!(power_preference(None, None), PowerPreference::LowPower); + assert_eq!(power_preference(Some("powerSaving"), None), PowerPreference::LowPower); + assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance); + // A value this version doesn't know (a newer or damaged preference file) is the default. + assert_eq!(power_preference(Some("turbo"), None), PowerPreference::LowPower); + assert_eq!(power_preference(Some(""), None), PowerPreference::LowPower); + // WGPU_POWER_PREF wins over the preference, either way. + assert_eq!(power_preference(Some("powerSaving"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance); + assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower); + assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None); + } + + /// The preference the engine saves is the one the app reads back before the window opens. + #[test] + fn the_saved_engine_preference_is_found() { + let mut prefs = vectorcraft_engine::Prefs::default(); + let saved = prefs.to_json(); + assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("powerSaving")); + prefs.gpu_preference = "highPerformance".into(); + let saved = prefs.to_json(); + let pref = saved.get("gpuPreference").and_then(serde_json::Value::as_str); + assert_eq!(power_preference(pref, None), PowerPreference::HighPerformance); + } +} diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index cba10e7d..63dbd8e4 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -82,6 +82,8 @@ pub const UNITS: &[(&str, &str)] = &[ ("feet", "Feet"), ]; const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")]; +/// Performance › Graphics Processor (`gpuPreference`), read by the desktop app at startup. +pub const GPU_PREFERENCES: &[(&str, &str)] = &[("powerSaving", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")]; const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")]; const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")]; @@ -265,6 +267,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ // Performance p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool), p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool), + p!("gpuPreference", "Performance", "GPU Performance", "Graphics Processor", choice(GPU_PREFERENCES)), p!("historyStates", "Performance", "", "History States", int(5, 1000)), p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool), p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d908071d..cf9ae1bc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -397,6 +397,12 @@ pub struct Prefs { // Performance pub gpu_performance: bool, pub animated_zoom: bool, + /// Which graphics processor the desktop app asks for at startup (it takes effect after a + /// restart): `powerSaving` (the integrated GPU on hybrid-graphics laptops) or `highPerformance` + /// (the discrete one). The canvas is rasterized on the CPU and only composited on the GPU, so + /// the integrated GPU is plenty; presenting from the discrete GPU through the integrated one + /// made some hybrid laptops flicker (#306). Single-GPU machines are unaffected. + pub gpu_preference: String, pub history_states: u32, pub real_time_drawing: bool, /// Rasterizer worker threads; -1 = automatic. @@ -598,6 +604,7 @@ impl Default for Prefs { interface_language: s("auto"), gpu_performance: true, animated_zoom: true, + gpu_preference: s("powerSaving"), history_states: 500, real_time_drawing: true, render_threads: -1, diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 155a962d..07e45cc2 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -122,3 +122,33 @@ fn prefs_serde_round_trip_and_tolerates_missing_fields() { assert_eq!(partial.keyboard_increment, 4.0); assert_eq!(partial.corner_radius, 12.0); } + +/// Performance › Graphics Processor (#306): power saving by default, set by value or label, kept +/// through a save/load round trip, and a bad value is an error that changes nothing. +#[test] +fn gpu_preference_defaults_to_power_saving_and_validates() { + let mut s = Session::new(); + assert_eq!(s.prefs.gpu_preference, "powerSaving"); + assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("powerSaving")); + s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap(); + assert_eq!(s.prefs.gpu_preference, "highPerformance"); + s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "Power Saving (integrated)"})).unwrap(); + assert_eq!(s.prefs.gpu_preference, "powerSaving"); + for bad in [json!("turbo"), json!(""), json!(1), json!(null), json!(["highPerformance"])] { + assert!(s.execute("prefs.set", &json!({"key": "gpuPreference", "value": bad})).is_err(), "{bad}"); + assert_eq!(s.prefs.gpu_preference, "powerSaving"); + } + let p = Prefs { gpu_preference: "highPerformance".into(), ..Default::default() }; + let back: Prefs = serde_json::from_value(p.to_json()).unwrap(); + assert_eq!(back.gpu_preference, "highPerformance"); + // Preference files written before the preference existed get the default. + let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap(); + assert_eq!(old.gpu_preference, "powerSaving"); + let l = s.execute("prefs.list", &json!({})).unwrap(); + let row = l.as_array().unwrap().iter().find(|e| e["key"] == "gpuPreference").unwrap().clone(); + assert_eq!(row["category"], "Performance"); + assert_eq!(row["options"], json!(["powerSaving", "highPerformance"])); + s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap(); + s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap(); + assert_eq!(s.prefs.gpu_preference, "powerSaving"); +} diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index f4fd2da9..6031b5b6 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -32,4 +32,6 @@ sha2 = { version = "0.10", default-features = false } [dev-dependencies] vectorcraft-testkit = { workspace = true } +# A variable font built in code (no font file is committed). +vectorcraft-text = { workspace = true, features = ["test-fonts"] } serde_json = { workspace = true } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index c8b7c0ce..3e08f203 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1444,7 +1444,11 @@ impl Exporter<'_> { let name = format!("{} {}", face.family, face.style); let font = match face.embedding() { Embedding::Subset => { - let font = krilla::text::Font::new(face.file_data().to_vec().into(), face.face_index()).map(|f| (f, face.units_per_em())); + // A named instance of a variable font is embedded instanced (its own outlines and + // widths, not the default instance's). + let coords: Vec<(krilla::text::Tag, f32)> = face.variations().iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect(); + let font = + krilla::text::Font::new_variable(face.file_data().to_vec().into(), face.face_index(), &coords).map(|f| (f, face.units_per_em())); if font.is_none() { self.warn(format!("the font “{name}” can't be embedded: its text is exported as outlines")); } diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs index 5cdcdca8..72761baa 100644 --- a/crates/pdf/src/tests_realtext.rs +++ b/crates/pdf/src/tests_realtext.rs @@ -110,6 +110,28 @@ fn real_text_lands_within_half_a_point_of_the_outlines() { } } +/// A named instance of a variable font is real text in that instance: its embedded subset draws +/// the instance's glyphs where the outlines would be, not the default instance's (#296). +#[test] +fn variable_font_instances_are_real_text_in_their_own_glyphs() { + use vectorcraft_text::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY, variable_font}; + FontDb::global().add_font(variable_font().unwrap()); + let mut widths = vec![]; + for s in ["Regular", "Bold"] { + let st = CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: s.into(), ..style(48.0) }; + let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]); + let r = pdf(&d, false); + assert!(r.warnings.is_empty(), "{s}: {:?}", r.warnings); + assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), [VARIABLE_CHARS], "{s}: real text"); + let (real, outlined) = (ink(&import_as(&r.bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines))); + let off = + [real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(off < 0.5, "{s}: {real:?} vs {outlined:?}"); + widths.push(real.width()); + } + assert!(widths[1] > widths[0] * 1.2, "Bold is wider: {widths:?}"); +} + #[test] fn gradients_on_real_text_stay_where_they_were() { let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 2910a9d0..1ac02e46 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -30,6 +30,8 @@ svgtypes = "0.16" [dev-dependencies] # Reading embedded font subsets back. skrifa = { workspace = true } +# A variable font built in code (no font file is committed). +vectorcraft-text = { workspace = true, features = ["test-fonts"] } resvg = { version = "0.45", default-features = false } vectorcraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/svg/tests/encoding_profile_fonts.rs b/crates/svg/tests/encoding_profile_fonts.rs index 4987dd60..2e09a970 100644 --- a/crates/svg/tests/encoding_profile_fonts.rs +++ b/crates/svg/tests/encoding_profile_fonts.rs @@ -216,6 +216,31 @@ fn embedded_fonts_hold_only_the_glyphs_used() { assert!(!export_full(&d, &ExportOptions { embed_fonts: true, outline_text: true, ..Default::default() }, None).svg.contains("@font-face")); } +/// A named instance of a variable font embeds as a static font of that instance: the variable +/// file would show its default instance in browsers (#296). +#[test] +fn variable_font_instances_embed_as_themselves() { + use vectorcraft_text::test_fonts::{VARIABLE_FAMILY, variable_font}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(variable_font().unwrap()); + let mut d = Document::new(300.0, 200.0); + add(&mut d, text(Point::new(20.0, 60.0), &[("ll", style(VARIABLE_FAMILY, "Regular")), ("ll", style(VARIABLE_FAMILY, "Bold"))])); + let o = export_full(&d, &ExportOptions { embed_fonts: true, ..Default::default() }, None); + let fonts = embedded(&o.svg); + assert_eq!(fonts.iter().map(|f| f.1).collect::>(), [400, 700], "{:?}", o.warnings); + let mut advances = vec![]; + for ((_, w, data), style) in fonts.iter().zip(["Regular", "Bold"]) { + let f = skrifa::FontRef::new(data).unwrap(); + assert!(f.fvar().is_err() && f.gvar().is_err(), "{w}: a static font"); + let gid = f.cmap().unwrap().map_codepoint('l').unwrap(); + let adv = f.hmtx().unwrap().advance(gid).unwrap(); + let face = db.face(VARIABLE_FAMILY, style).unwrap(); + assert_eq!(f64::from(adv), face.advance(face.glyph_for('l')).round(), "{style}"); + advances.push(adv); + } + assert!(advances[1] > advances[0], "{advances:?}"); +} + /// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original /// name) with OS/2 `fsType` set to `fs_type`. fn licensed_font(family: &str, fs_type: u16) -> Vec { diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index ca0e0424..4085773e 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -15,5 +15,16 @@ kurbo = { workspace = true } skrifa = { workspace = true } harfrust = { workspace = true } log = { workspace = true } -# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer. -subsetter = { version = "0.2", default-features = false } +# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer +# (which turns on `variable-fonts` too: named instances of variable fonts are instanced). +subsetter = { version = "0.2", default-features = false, features = ["variable-fonts"] } +# Builds a small variable font for tests (`test_fonts`; no font file is committed). MIT OR +# Apache-2.0, already in the build through subsetter. +write-fonts = { version = "0.48", optional = true } + +[features] +# `vectorcraft_text::test_fonts` for other crates' tests (dev-dependencies only). +test-fonts = ["dep:write-fonts"] + +[dev-dependencies] +write-fonts = "0.48" diff --git a/crates/text/src/embed.rs b/crates/text/src/embed.rs index 50288912..6615d10c 100644 --- a/crates/text/src/embed.rs +++ b/crates/text/src/embed.rs @@ -60,19 +60,29 @@ impl FontFace { /// The face as a font file to embed: the glyphs of `chars` alone when its licence allows /// subsetting, else the whole face. Ligatures, kerning and other layout features are left out - /// of subsets (viewers draw each character's own glyph). `None` when the licence forbids - /// embedding or the font can't be read. + /// of subsets (viewers draw each character's own glyph). A named instance of a variable font + /// ([`FontFace::variations`]) is embedded as a static font of that instance (the variable file + /// would show its default instance), CFF2 outlines becoming TrueType ones. `None` when the + /// licence forbids embedding or the font can't be read. pub fn embed(&self, chars: &[char]) -> Option { let font = self.skrifa()?; let cff = [b"CFF ", b"CFF2"].iter().any(|t| font.table_data(Tag::new(t)).is_some()); let whole = || Some(EmbeddedFont { data: sfnt(font.table_directory.sfnt_version(), tables(&font))?, cff, subset: false }); + let instance = !self.variations().is_empty(); match self.embedding() { Embedding::Forbidden => None, + // Every glyph of the instance, keeping their ids. + Embedding::Whole if instance => { + let all: Vec = font.charmap().mappings().filter_map(|(c, _)| char::from_u32(c)).collect(); + subset(self, &font, &all, true).map(|(data, cff)| EmbeddedFont { data, cff, subset: false }) + } Embedding::Whole => whole(), - Embedding::Subset => match subset(self, &font, chars) { - Some(data) => Some(EmbeddedFont { data, cff, subset: true }), - // Outlines the subsetter can't read (CFF2): what the licence allows anyway. - None => whole(), + Embedding::Subset => match subset(self, &font, chars, false) { + Some((data, cff)) => Some(EmbeddedFont { data, cff, subset: true }), + // Outlines the subsetter can't read: what the licence allows anyway (a named + // instance can't be embedded whole, which would show the default instance). + None if !instance => whole(), + None => None, }, } } @@ -83,10 +93,17 @@ fn tables<'a>(font: &skrifa::FontRef<'a>) -> Vec<(Tag, Cow<'a, [u8]>)> { font.table_directory.table_records().iter().filter_map(|r| font.table_data(r.tag()).map(|d| (r.tag(), Cow::Borrowed(d.as_bytes())))).collect() } -/// `face` reduced to the glyphs of `chars`, with a `cmap` mapping them and the face's `OS/2`. -fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char]) -> Option> { +/// `face` reduced to the glyphs of `chars` (every glyph, keeping their ids, when `all`), with a +/// `cmap` mapping them and the face's `OS/2`; a named instance instanced. Returns the font and +/// whether its outlines are CFF ones. +fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char], all: bool) -> Option<(Vec, bool)> { let charmap = font.charmap(); let mut remapper = subsetter::GlyphRemapper::new(); + if all { + for gid in 0..font.maxp().ok()?.num_glyphs() { + remapper.remap(gid); + } + } let mut map: Vec<(u32, u16)> = chars .iter() .filter_map(|c| { @@ -96,14 +113,20 @@ fn subset(face: &FontFace, font: &skrifa::FontRef<'_>, chars: &[char]) -> Option .collect(); map.sort_unstable(); map.dedup_by_key(|m| m.0); - let data = subsetter::subset(face.data(), face.index(), &remapper).ok()?; + let data = if face.variations().is_empty() { + subsetter::subset(face.data(), face.index(), &remapper).ok()? + } else { + let coords: Vec<(subsetter::Tag, f32)> = face.variations().iter().map(|(t, v)| (subsetter::Tag::new(t), *v)).collect(); + subsetter::subset_with_variations(face.data(), face.index(), &coords, &remapper).ok()? + }; let sub = skrifa::FontRef::new(&data).ok()?; + let cff = [b"CFF ", b"CFF2"].iter().any(|t| sub.table_data(Tag::new(t)).is_some()); let mut t: Vec<(Tag, Cow<'_, [u8]>)> = tables(&sub).into_iter().filter(|(tag, _)| *tag != CMAP && *tag != OS2).collect(); t.push((CMAP, Cow::Owned(cmap(&map)?))); if let Some(os2) = font.table_data(OS2) { t.push((OS2, Cow::Owned(os2_for(os2.as_bytes(), &map)))); } - sfnt(sub.table_directory.sfnt_version(), t) + Some((sfnt(sub.table_directory.sfnt_version(), t)?, cff)) } /// The face's `OS/2` table with its first and last character fitted to the subset's. diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 70891154..1046dec6 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -7,11 +7,11 @@ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use kurbo::BezPath; -use skrifa::instance::{LocationRef, Size}; +use skrifa::instance::{Location, LocationRef, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; use skrifa::raw::FileRef; use skrifa::string::StringId; -use skrifa::{GlyphId, MetadataProvider}; +use skrifa::{GlyphId, MetadataProvider, Tag}; /// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue). pub const FALLBACK_FAMILY: &str = "Source Sans 3"; @@ -28,6 +28,14 @@ static BUNDLED: &[&[u8]] = &[ include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"), ]; +/// An axis setting of a variable font: the axis tag (`wght`) and its value in user units (700). +pub type Variation = ([u8; 4], f32); + +/// Caps on what a (possibly damaged) variable font can make the database read: axes per font and +/// named instances listed per face. +const MAX_AXES: usize = 64; +const MAX_INSTANCES: usize = 512; + enum FontBytes { Static(&'static [u8]), Owned(Arc>), @@ -60,6 +68,13 @@ pub struct FontFace { pub(crate) cap_height: f64, pub(crate) x_height: f64, pub(crate) shaper: harfrust::ShaperData, + /// The axis settings of the variable font's named instance this face is (user units: `wght` + /// 700); empty for a static face or a variable font's default instance. + variations: Vec, + /// [`Self::variations`] in the font's normalized design space (outlines and metrics). + location: Location, + /// [`Self::variations`] for the shaper (advances, kerning, feature variations). + pub(crate) instance: Option, } impl std::fmt::Debug for FontFace { @@ -85,6 +100,17 @@ impl FontFace { pub(crate) fn index(&self) -> u32 { self.index } + /// Where in a variable font's design space the face's outlines and metrics are taken (the + /// default location for a static face). + pub(crate) fn location(&self) -> LocationRef<'_> { + LocationRef::from(&self.location) + } + /// The axis settings of the variable font's named instance this face is (`wght` 700 for + /// Bold), in user units; empty for a static face and a variable font's default instance. + /// The face's file holds the whole variable font: exporters that embed it pass these on. + pub fn variations(&self) -> &[Variation] { + &self.variations + } /// Unique id of this face within the process. pub fn id(&self) -> u32 { self.id @@ -112,7 +138,7 @@ impl FontFace { /// Advance width of glyph `gid` in font units. pub fn advance(&self, gid: u32) -> f64 { self.skrifa() - .and_then(|f| f.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid))) + .and_then(|f| f.glyph_metrics(Size::unscaled(), self.location()).advance_width(GlyphId::new(gid))) .map(|a| a as f64) .unwrap_or(self.upem * 0.5) } @@ -147,8 +173,18 @@ impl FontFace { #[derive(Debug, Default)] struct CatalogFamily { name: String, - /// (style, file) of each face. - faces: Vec<(String, PathBuf)>, + faces: Vec, +} + +/// One style of an installed family: a face, or a named instance of a variable font. +#[derive(Debug)] +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +struct CatalogFace { + style: String, + path: PathBuf, + /// Weight and italic as the style lists them (by its name, or a named instance's axes). + weight: f32, + italic: bool, } /// The installed fonts by ASCII-lowercased family name (lookups ignore ASCII case, as @@ -344,26 +380,103 @@ fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { Some((family, style)) } -/// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`. -fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { +/// One style a font file offers: one of its faces, or a named instance of a variable font +/// (Figtree's `Figtree[wght].ttf` holds Light, the default, and Regular to Black as instances). +#[derive(Clone, Debug)] +pub(crate) struct FaceStyle { + family: String, + style: String, + keys: FaceKeys, + /// A named instance's axis settings (user units); empty for the face itself. + variations: Vec, + /// A named instance's weight (its `wght` setting); `None` for the face itself (its OS/2 table + /// says) or an instance of a font without a weight axis. + weight: Option, + /// A named instance is italic (by its `ital` or `slnt` setting, or its name). + italic: bool, +} + +/// The styles of a face: the face itself, then its named instances when it is a variable font. +pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec { + let Some((family, style)) = face_names(f) else { return vec![] }; + let mut keys = FaceKeys::of(f, &family, &style); + let instances = named_instances(f, &family, &style, &mut keys); + let face = FaceStyle { family, style, keys, variations: vec![], weight: None, italic: false }; + std::iter::once(face).chain(instances).collect() +} + +/// The named instances of a variable font face (its `fvar` table) as styles of the face's family, +/// named by their subfamily names, without the default instance (the face itself) and without +/// names already taken. The default instance's name, when it isn't the face's style name (a face +/// `Regular` whose default instance is `Book`), names the face too (`keys`). Damaged tables give +/// fewer instances or none; the counts read are capped. +fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mut FaceKeys) -> Vec { + let axes = f.axes(); + if axes.is_empty() || axes.len() > MAX_AXES { + return vec![]; + } + let tags: Vec = axes.iter().map(|a| a.tag()).collect(); + let defaults: Vec = axes.iter().map(|a| a.default_value()).collect(); + // Instances without a PostScript name of their own are named `-