Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee7a7ebfac | ||
|
|
0d4d125f9b | ||
|
|
deaec7fc71 | ||
|
|
a26aa5b203 | ||
|
|
7be80776f2 | ||
|
|
a9c31fcbe7 | ||
|
|
89ff09e9fd | ||
|
|
71efade26b | ||
|
|
6a62dc58ba | ||
|
|
c462057460 | ||
|
|
539dcb84b7 | ||
|
|
d5e21cc735 | ||
|
|
bc688bd1b5 | ||
|
|
11799fa0cc | ||
|
|
b6649a3fad | ||
|
|
49dc3b90be | ||
|
|
c256818665 | ||
|
|
a20e680f62 | ||
|
|
68a14472c6 | ||
|
|
e2227849a1 | ||
|
|
3c096da84d | ||
|
|
47104f5f4c | ||
|
|
ea5a04504a | ||
|
|
d6eb526186 | ||
|
|
ce3683a508 | ||
|
|
333a487dab | ||
|
|
b8f77aefb6 | ||
|
|
40bdfd3aca | ||
|
|
dd595cf2e8 | ||
|
|
614606cc48 | ||
|
|
13f65ce6d4 | ||
|
|
308c096889 | ||
|
|
ba2ee7a174 | ||
|
|
b743d08f11 | ||
|
|
ab2daad763 | ||
|
|
80323a7a4e | ||
|
|
5f59332ec8 | ||
|
|
926cdc80a5 | ||
|
|
b55e3ad70a | ||
|
|
48c96b504d | ||
|
|
2e134e56be | ||
|
|
a01e045905 | ||
|
|
eac383d5af | ||
|
|
94bc59b068 | ||
|
|
c6ac2bf3c5 | ||
|
|
198725ed38 | ||
|
|
7ce27caf88 | ||
|
|
c91ca3634f | ||
|
|
24f7d5fba5 | ||
|
|
cc4992172a | ||
|
|
9dc05ac980 | ||
|
|
339eb78a9b | ||
|
|
e01077a674 | ||
|
|
6313c8cfae | ||
|
|
f9a89a3211 | ||
|
|
fbcadf317c | ||
|
|
64891c1a2e | ||
|
|
c60cb4a9c2 | ||
|
|
83faf568b0 |
No files matched your search
Generated
+25
-25
@@ -4211,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
@@ -4233,7 +4233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4245,7 +4245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4261,7 +4261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
@@ -4277,7 +4277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4289,7 +4289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4303,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4317,7 +4317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4349,7 +4349,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4366,7 +4366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4380,7 +4380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4392,7 +4392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4411,7 +4411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4425,7 +4425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4437,7 +4437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4464,7 +4464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4478,7 +4478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4503,7 +4503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4527,7 +4527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4544,7 +4544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4557,7 +4557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4572,7 +4572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4585,7 +4585,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4613,7 +4613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5687,7 +5687,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+23
-18
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7 and the MCP prompts, resource templates, completions and logging; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, and the 2026-10-07 issue fixes; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -88,34 +88,39 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Automation:**
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **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.
|
||||
- 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, Czech covers every menu label and Japanese the main menus, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference.
|
||||
- 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.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **Tools:**
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options.
|
||||
- **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard.
|
||||
- **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), 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.
|
||||
- **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 every menu label (tested) and Japanese the main menus; 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, upright glyphs centred on the column, 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.
|
||||
- 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.
|
||||
- **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 `<mask>` 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).
|
||||
- **Paint and appearance (M3):**
|
||||
@@ -132,8 +137,8 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing.
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
- 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.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
- 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.
|
||||
- **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`).
|
||||
@@ -149,14 +154,14 @@ 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, 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, 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 `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
|
||||
@@ -184,14 +189,14 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| 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 | 15–20 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth, ruler-drag modifiers (Shift snaps to ticks, Alt swaps orientation) and dragging existing guides | 4–8 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against real files (only reduced repros so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
|
||||
//! vectorcraft-cli info FILE
|
||||
@@ -59,16 +59,17 @@ USAGE:
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step.
|
||||
order, export each FILE in the format its extension picks (see Writable formats). Prints one
|
||||
JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
|
||||
Open IN (any readable format) and export OUT by extension (.svg, .pdf, .png, .jpg, .webp,
|
||||
.vectorcraft). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, the other formats the first. Live effects are kept;
|
||||
--outline-text writes SVG text as paths.
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
Live effects are kept; --outline-text writes SVG text as paths.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts.
|
||||
@@ -82,9 +83,10 @@ USAGE:
|
||||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads.
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
fn usage() -> String {
|
||||
format!("{USAGE}\nReadable formats: .{}\n", vectorcraft_engine::cmd::fileio::OPEN_EXTS.join(", ."))
|
||||
use vectorcraft_engine::cmd::fileio::{OPEN_EXTS, export_extensions};
|
||||
format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n", OPEN_EXTS.join(", ."), export_extensions().join(", ."))
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`vectorcraft mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -19,44 +33,249 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
// Best effort: the connection is dropped either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
},
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
// Best effort: the connection closes either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST: the request line is rejected and the
|
||||
// connection closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused() {
|
||||
// One byte over the limit, then a valid request: neither runs.
|
||||
let mut input = vec![b'x'; MAX_LINE + 1];
|
||||
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
|
||||
let (r, calls) = run(&input);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, _rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
+152
-10
@@ -75,12 +75,16 @@ impl LayerColor {
|
||||
#[serde(tag = "shape", rename_all = "lowercase")]
|
||||
pub enum LiveShape {
|
||||
Rectangle {
|
||||
/// Untransformed width/height.
|
||||
/// Untransformed width/height. Transforms fold their scale into these (see
|
||||
/// [`LiveShape::transform`]), so they are document lengths unless `xf` shears.
|
||||
w: f64,
|
||||
h: f64,
|
||||
/// Corner radii: top-left, top-right, bottom-right, bottom-left.
|
||||
/// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as
|
||||
/// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale.
|
||||
radii: [f64; 4],
|
||||
/// Maps the untransformed shape (origin at its top-left) into the document.
|
||||
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
|
||||
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
|
||||
/// their scale into `w`/`h`) put more in it.
|
||||
xf: Affine,
|
||||
},
|
||||
Ellipse {
|
||||
@@ -129,12 +133,44 @@ impl LiveShape {
|
||||
LiveShape::Line { a, b } => shapes::line(*a, *b),
|
||||
}
|
||||
}
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
|
||||
/// the caller must regenerate the path with [`LiveShape::to_path`].
|
||||
///
|
||||
/// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own
|
||||
/// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps
|
||||
/// only the rotation, reflection and position. The radii scale by the mean scale (the square
|
||||
/// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off.
|
||||
/// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a
|
||||
/// scale in `xf` keep their exact geometry until they're next transformed).
|
||||
pub fn transform(&mut self, a: Affine) -> bool {
|
||||
match self {
|
||||
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => *xf = a * *xf,
|
||||
LiveShape::Rectangle { w, h, radii, xf } => {
|
||||
let m = a * *xf;
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
|
||||
let [m0, m1, m2, m3, m4, m5] = m.as_coeffs();
|
||||
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
|
||||
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
|
||||
let finite = m.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
|
||||
if moves_only || !orthogonal || !finite {
|
||||
*xf = m;
|
||||
return false;
|
||||
}
|
||||
let k = (sx * sy).sqrt();
|
||||
radii.iter_mut().for_each(|r| *r *= k);
|
||||
*w *= sx;
|
||||
*h *= sy;
|
||||
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
|
||||
true
|
||||
}
|
||||
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
|
||||
*xf = a * *xf;
|
||||
false
|
||||
}
|
||||
LiveShape::Line { a: p, b } => {
|
||||
*p = a * *p;
|
||||
*b = a * *b;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -613,11 +649,10 @@ impl Node {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
path.transform(a);
|
||||
if let Some(l) = live {
|
||||
l.transform(a);
|
||||
if sc.keep_corners
|
||||
&& let Some(k) = k
|
||||
&& l.keep_corners(k)
|
||||
{
|
||||
// A rectangle scaled unevenly keeps circular corners: its path is regenerated.
|
||||
let reshaped = l.transform(a);
|
||||
let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k));
|
||||
if reshaped || kept {
|
||||
*path = l.to_path();
|
||||
}
|
||||
}
|
||||
@@ -1135,6 +1170,113 @@ mod tests {
|
||||
assert_eq!(l.label(), "Rectangle");
|
||||
}
|
||||
|
||||
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
|
||||
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], xf };
|
||||
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(live);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
|
||||
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. } = &n.kind else { panic!("not a live rectangle") };
|
||||
(*w, *h, *radii, *xf)
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn near_xf(a: Affine, b: Affine) -> bool {
|
||||
a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y))
|
||||
}
|
||||
|
||||
/// Size and radius of a live rectangle, within rounding.
|
||||
fn assert_live(n: &Node, w: f64, h: f64, r: f64) {
|
||||
let (lw, lh, radii, _) = live_of(n);
|
||||
assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}");
|
||||
}
|
||||
|
||||
/// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation),
|
||||
/// spans `r` along x and y: the corner is a circular arc of radius `r`.
|
||||
fn assert_round_corners(n: &Node, back: Affine, r: f64) {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
|
||||
let a: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect();
|
||||
assert_eq!(a.len(), 8, "a rounded rectangle");
|
||||
// Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs.
|
||||
for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] {
|
||||
let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs());
|
||||
assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}");
|
||||
}
|
||||
}
|
||||
|
||||
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true };
|
||||
|
||||
#[test]
|
||||
fn uneven_scales_keep_live_corners_circular() {
|
||||
// A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291).
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0)));
|
||||
let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0));
|
||||
n.transform(stretch, KEEP_CORNERS);
|
||||
assert_live(&n, 300.0, 100.0, 20.0);
|
||||
assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size");
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}");
|
||||
assert_round_corners(&n, Affine::IDENTITY, 20.0);
|
||||
// Scale Corners on: the radius scales by the mean scale, still circular.
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY);
|
||||
n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default());
|
||||
assert_live(&n, 400.0, 100.0, 40.0);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 40.0);
|
||||
// Squashed below the radius, the short sides are fully round; stretched back, it returns.
|
||||
n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 25.0);
|
||||
n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() {
|
||||
let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians());
|
||||
let mut n = live_rect(100.0, 50.0, 10.0, place);
|
||||
// Stretched along its own width, as a bounding-box drag of a rotated shape does.
|
||||
n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS);
|
||||
assert_live(&n, 200.0, 50.0, 10.0);
|
||||
assert!(near_xf(live_of(&n).3, place));
|
||||
assert_round_corners(&n, place.inverse(), 10.0);
|
||||
// A reflection stays in `xf`; the corners stay as they were.
|
||||
n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS);
|
||||
assert_live(&n, 200.0, 50.0, 10.0);
|
||||
let xf = live_of(&n).3;
|
||||
assert!(near(xf.determinant(), -1.0));
|
||||
assert_round_corners(&n, xf.inverse(), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shears_and_moves_keep_the_old_transform() {
|
||||
// Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before.
|
||||
let rot = Affine::rotate(30f64.to_radians());
|
||||
let mut n = live_rect(100.0, 50.0, 10.0, rot);
|
||||
let before = n.clone();
|
||||
let a = Affine::scale_non_uniform(2.0, 1.0);
|
||||
n.transform(a, Scaling::default());
|
||||
assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot));
|
||||
let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() };
|
||||
assert_eq!(*path, old.transformed(a));
|
||||
// A file saved with an uneven scale in `xf` keeps its exact geometry when moved...
|
||||
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0);
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, legacy);
|
||||
n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS);
|
||||
assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy));
|
||||
// ...and once scaled, has document units and circular corners of its mean radius.
|
||||
n.transform(Affine::scale(2.0), Scaling::default());
|
||||
assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0);
|
||||
assert!(near(live_of(&n).3.determinant(), 1.0));
|
||||
assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_transparency() {
|
||||
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
||||
+89
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
|
||||
@@ -476,6 +476,49 @@ impl TextObject {
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.xf = a * self.xf;
|
||||
}
|
||||
/// Area type's frame (the type area) in document space; None for other type.
|
||||
pub fn area_frame(&self) -> Option<PathData> {
|
||||
match &self.kind {
|
||||
TextKind::Area { frame } => Some(frame.transformed(self.xf)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size
|
||||
/// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for
|
||||
/// other type, a degenerate `xf` or a frame that would not be finite.
|
||||
pub fn transform_area(&mut self, a: Affine) -> bool {
|
||||
let xf = self.xf;
|
||||
self.reshape_area(|frame, to_text| {
|
||||
frame.transform(to_text * a * xf);
|
||||
true
|
||||
})
|
||||
}
|
||||
/// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with
|
||||
/// their handles (a Direct Selection drag of a frame corner or edge). False, changing
|
||||
/// nothing, when an anchor doesn't exist, or as [`Self::transform_area`].
|
||||
pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
|
||||
self.reshape_area(|frame, to_text| {
|
||||
// A distance: only the linear part of the document → text map applies.
|
||||
let local = to_text * d.to_point() - to_text * Point::ZERO;
|
||||
refs.iter().all(|&(si, ai)| frame.anchor_mut(si, ai).map(|a| a.translate(local)).is_some())
|
||||
})
|
||||
}
|
||||
/// Edit a copy of the area frame with `f` (given the document → text space map) and keep it
|
||||
/// when `f` succeeds and the result is finite.
|
||||
fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
|
||||
let det = self.xf.determinant();
|
||||
if !det.is_finite() || det.abs() < 1e-12 {
|
||||
return false;
|
||||
}
|
||||
let to_text = self.xf.inverse();
|
||||
let TextKind::Area { frame } = &mut self.kind else { return false };
|
||||
let mut next = frame.clone();
|
||||
if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
*frame = next;
|
||||
true
|
||||
}
|
||||
/// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so
|
||||
/// `1 / scale` keeps their weight through a transform that scales the type).
|
||||
pub fn scale_char_strokes(&mut self, s: f64) {
|
||||
@@ -505,4 +548,49 @@ mod tests {
|
||||
assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0);
|
||||
assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999);
|
||||
}
|
||||
|
||||
/// 120 × 40 area type at (40, 40), its text drawn at twice its size.
|
||||
fn area_type() -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default());
|
||||
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) };
|
||||
t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0);
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_resizes_its_frame_and_keeps_its_type() {
|
||||
let mut t = area_type();
|
||||
assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0)));
|
||||
// Bottom-right handle to (200, 140), about the top-left corner.
|
||||
let o = Affine::translate((40.0, 40.0));
|
||||
assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse()));
|
||||
assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size");
|
||||
let b = t.bounds().unwrap();
|
||||
assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}");
|
||||
assert_eq!(t.area_frame().unwrap().bounds(), Some(b));
|
||||
// Point type has no area; a degenerate transform or a non-finite frame changes nothing.
|
||||
let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default());
|
||||
assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none());
|
||||
let before = t.clone();
|
||||
assert!(!t.transform_area(Affine::scale(f64::INFINITY)));
|
||||
t.xf = Affine::scale(0.0);
|
||||
assert!(!t.transform_area(Affine::scale(2.0)));
|
||||
t.xf = before.xf;
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_anchors_move_in_document_space() {
|
||||
let mut t = area_type();
|
||||
// The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×.
|
||||
assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0)));
|
||||
let TextKind::Area { frame } = &t.kind else { panic!() };
|
||||
assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0));
|
||||
assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay");
|
||||
// A missing anchor changes nothing.
|
||||
let before = t.clone();
|
||||
assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0)));
|
||||
assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0)));
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use crate::DocState;
|
||||
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
use super::{ARTBOARD_PARAMS, Format, default_name, encode, encode_all, merge, writable, write_encoded, write_or_return};
|
||||
use super::{ARTBOARD_PARAMS, Format, default_name, encode, encode_all, encode_with_warnings, merge, writable, write_encoded, write_or_return};
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
||||
@@ -151,8 +151,10 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expanded = expand_presets(s, C, p)?;
|
||||
let p = &*expanded;
|
||||
let (d, bounds) = selection(s, C)?;
|
||||
let bytes = encode(&d, f.id, &without_artboards(p))?;
|
||||
write_or_return(path, &bytes, json!({ "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
|
||||
// The encoder's warnings (options accepted but not applied, features approximated or left
|
||||
// out), as document.export reports them.
|
||||
let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(p))?;
|
||||
write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings, "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
|
||||
}
|
||||
|
||||
/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Selection…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, format?: png|jpg|webp|svg|svgz|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})",
|
||||
"{path?, format?: png|jpg|webp|svg|svgz|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export",
|
||||
has_selection,
|
||||
export::export_selection
|
||||
),
|
||||
@@ -581,6 +581,18 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"eps",
|
||||
];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
pub fn export_extensions() -> Vec<&'static str> {
|
||||
let mut v: Vec<&'static str> = Vec::new();
|
||||
for e in FORMATS.iter().filter(|f| f.write).filter_map(|f| f.extensions.first()) {
|
||||
if !v.contains(e) {
|
||||
v.push(e);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
||||
|
||||
@@ -150,6 +150,39 @@ fn open_exts_cover_every_readable_format() {
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
}
|
||||
|
||||
/// The extensions usage texts list for export: one per writable format, each picking that format
|
||||
/// (`.png` picks PNG; PNG-8 is asked for by `format`).
|
||||
#[test]
|
||||
fn export_extensions_name_every_writable_format() {
|
||||
let exts = export_extensions();
|
||||
assert_eq!(
|
||||
exts,
|
||||
[
|
||||
"vectorcraft",
|
||||
"svg",
|
||||
"svgz",
|
||||
"pdf",
|
||||
"png",
|
||||
"jpg",
|
||||
"gif",
|
||||
"webp",
|
||||
"tif",
|
||||
"bmp",
|
||||
"vctemplate",
|
||||
"txt",
|
||||
"dxf",
|
||||
"eps",
|
||||
"emf",
|
||||
"wmf",
|
||||
"tga",
|
||||
"psd"
|
||||
]
|
||||
);
|
||||
for e in exts {
|
||||
assert!(format_for_name(&format!("x.{e}")).is_some_and(|f| f.write), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_query_lists_readers_writers_and_options() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transform",
|
||||
[],
|
||||
None,
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences)",
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
|
||||
has_doc,
|
||||
transform
|
||||
),
|
||||
@@ -202,15 +202,18 @@ pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling {
|
||||
Scaling { strokes, effects: strokes.then_some(vectorcraft_render::effects::scale_effect), keep_type_strokes: !strokes, keep_corners: !corners }
|
||||
}
|
||||
|
||||
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`]).
|
||||
/// Records Transform Again.
|
||||
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`];
|
||||
/// `typeAreas`: see [`resize_type_area`]). Records Transform Again.
|
||||
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value) -> Result<Value> {
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let areas = bool_or(p, "typeAreas", false);
|
||||
let sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
||||
for id in &targets {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& !(areas && resize_type_area(n, xf))
|
||||
{
|
||||
n.transform(xf, sc);
|
||||
}
|
||||
}
|
||||
@@ -224,6 +227,17 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Area type resized by its bounding box: `xf` reshapes its frame (the type area) and the text
|
||||
/// reflows at its size. False for anything else (and type in perspective), which transforms as
|
||||
/// usual.
|
||||
fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
|
||||
if n.perspective.is_some() {
|
||||
return false;
|
||||
}
|
||||
let NodeKind::Text(t) = &mut n.kind else { return false };
|
||||
super::typecmd::reshape_area_with(t, |t| t.transform_area(xf))
|
||||
}
|
||||
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
|
||||
@@ -188,7 +188,8 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
|
||||
/// `[[subpath, anchor]…]` anchor references (malformed entries are left out).
|
||||
pub(crate) fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
|
||||
v.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect())
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::PathData;
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{len_param, num_param, shape_node};
|
||||
@@ -48,10 +48,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area Type Options…",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options (none given: query) → the first object's options",
|
||||
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options",
|
||||
has_selection,
|
||||
area_options
|
||||
),
|
||||
cmd!(
|
||||
"text.reshapeArea",
|
||||
"Reshape Type Area",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge)",
|
||||
has_doc,
|
||||
reshape_area
|
||||
),
|
||||
cmd!(
|
||||
"text.fitHeadline",
|
||||
"Fit Headline",
|
||||
@@ -265,25 +274,82 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Reshape area type's frame with `f` ([`TextObject::transform_area`],
|
||||
/// [`TextObject::move_area_anchors`]) and lay its text out again. False, changing nothing, when
|
||||
/// `f` fails or the frame would reach past [`crate::MAX_COORD`] (the text flows over its height).
|
||||
pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObject) -> bool) -> bool {
|
||||
let before = t.kind.clone();
|
||||
let within = |t: &TextObject| match &t.kind {
|
||||
vectorcraft_doc::TextKind::Area { frame } => {
|
||||
frame.bounds().is_some_and(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.abs() <= crate::MAX_COORD))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !(f(t) && within(t)) {
|
||||
t.kind = before;
|
||||
return false;
|
||||
}
|
||||
refresh_bounds(t);
|
||||
true
|
||||
}
|
||||
|
||||
/// Area type's text, if `n` is area type.
|
||||
fn area_text(n: Option<&Node>) -> Option<&TextObject> {
|
||||
match n.map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The width and height of area type's frame in points, along the type's own axes, and the frame's
|
||||
/// bounds in text space.
|
||||
fn area_size(t: &TextObject) -> Option<(f64, f64, vectorcraft_geom::Rect)> {
|
||||
let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { return None };
|
||||
let b = frame.bounds()?;
|
||||
let [a, bb, c, d, _, _] = t.xf.as_coeffs();
|
||||
Some((b.width() * a.hypot(bb), b.height() * c.hypot(d), b))
|
||||
}
|
||||
|
||||
/// Size area type's frame to `w` × `h` points (None: keep that side) from its top-left corner,
|
||||
/// and lay its text out again. False when the frame doesn't change.
|
||||
fn size_area(t: &mut TextObject, w: Option<f64>, h: Option<f64>) -> bool {
|
||||
let Some((cw, ch, b)) = area_size(t) else { return false };
|
||||
let factor = |to: Option<f64>, cur: f64| match to {
|
||||
Some(to) if cur > 1e-9 && (to - cur).abs() > 1e-9 => to / cur,
|
||||
_ => 1.0,
|
||||
};
|
||||
let (sx, sy) = (factor(w, cw), factor(h, ch));
|
||||
if sx == 1.0 && sy == 1.0 {
|
||||
return false;
|
||||
}
|
||||
let o = Affine::translate(b.origin().to_vec2());
|
||||
let a = t.xf * o * Affine::scale_non_uniform(sx, sy) * o.inverse() * t.xf.inverse();
|
||||
reshape_area_with(t, |t| t.transform_area(a))
|
||||
}
|
||||
|
||||
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.areaOptions";
|
||||
let ids: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
text_targets(s, p, C)?
|
||||
.into_iter()
|
||||
.filter(|i| matches!(d.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })))
|
||||
.collect()
|
||||
text_targets(s, p, C)?.into_iter().filter(|i| area_text(d.node(*i)).is_some()).collect()
|
||||
};
|
||||
let first = ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let current = match &s.doc()?.doc.node(*first).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => t.area.clone(),
|
||||
_ => return Err(bad(C, "select area type (text in a frame)")),
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let options = |s: &Session| -> Result<Value> {
|
||||
let t = area_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let (Some(o), Some((w, h, _))) = (v.as_object_mut(), area_size(t)) {
|
||||
o.insert("width".into(), json!(w));
|
||||
o.insert("height".into(), json!(h));
|
||||
}
|
||||
Ok(v)
|
||||
};
|
||||
let mut v = serde_json::to_value(¤t).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut changed = false;
|
||||
let mut v = options(s)?;
|
||||
let size = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x.clamp(1.0, 100_000.0));
|
||||
let (w, h) = (size("width"), size("height"));
|
||||
let mut changed = w.is_some() || h.is_some();
|
||||
if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) {
|
||||
for (k, val) in src {
|
||||
if o.contains_key(k) && k != "ids" {
|
||||
if o.contains_key(k) && !matches!(k.as_str(), "ids" | "width" | "height") {
|
||||
o.insert(k.clone(), val.clone());
|
||||
changed = true;
|
||||
}
|
||||
@@ -298,17 +364,43 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
opts.gutter = opts.gutter.clamp(0.0, 10_000.0);
|
||||
opts.inset = opts.inset.clamp(0.0, 10_000.0);
|
||||
opts.first_baseline_min = opts.first_baseline_min.clamp(0.0, 10_000.0);
|
||||
let out = serde_json::to_value(&opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.edit("Area Type Options", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
t.area = opts.clone();
|
||||
refresh_bounds(t);
|
||||
// A resized frame has laid its text out again; otherwise the new options do here.
|
||||
if !size_area(t, w, h) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
options(s)
|
||||
}
|
||||
|
||||
fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.reshapeArea";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut refs = super::select::parse_refs(p.get("anchors"));
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
if refs.is_empty() {
|
||||
return Err(bad(C, "missing anchors [[subpath, anchor]…]"));
|
||||
}
|
||||
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
|
||||
let node = s.doc()?.doc.node(id);
|
||||
if area_text(node).is_none() || node.is_some_and(|n| n.perspective.is_some()) {
|
||||
return Err(bad(C, "not area type (text in a frame) outside perspective"));
|
||||
}
|
||||
s.edit("Reshape Type Area", |doc, _| {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
if !reshape_area_with(t, |t| t.move_area_anchors(&refs, d)) {
|
||||
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -29,7 +29,21 @@ pub fn node_summary(n: &Node) -> Value {
|
||||
if n.bbox_angle != 0.0 {
|
||||
v["rotation"] = json!(n.bbox_angle);
|
||||
}
|
||||
if !n.is_container() {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
// Type shows its characters' paint: the first run's, as paint.proxies and the Stroke
|
||||
// options read it. Fills and strokes of the type object itself, above or below its
|
||||
// characters, come separately so the two stay apart.
|
||||
let default = vectorcraft_doc::CharStyle::default();
|
||||
let style = t.runs.first().map_or(&default, |r| &r.style);
|
||||
v["fill"] = json!(style.fill.label());
|
||||
v["stroke"] = json!(style.stroke.label());
|
||||
v["strokeWidth"] = json!(if style.stroke.is_none() { 0.0 } else { style.stroke_width });
|
||||
if n.appearance.fill().is_some() || n.appearance.stroke().is_some() {
|
||||
v["objectFill"] = json!(n.appearance.fill_paint().label());
|
||||
v["objectStroke"] = json!(n.appearance.stroke_paint().label());
|
||||
v["objectStrokeWidth"] = json!(n.appearance.stroke_width());
|
||||
}
|
||||
} else if !n.is_container() {
|
||||
v["fill"] = json!(n.appearance.fill_paint().label());
|
||||
v["stroke"] = json!(n.appearance.stroke_paint().label());
|
||||
v["strokeWidth"] = json!(n.appearance.stroke_width());
|
||||
|
||||
@@ -1388,6 +1388,8 @@ mod tests_toolsettings;
|
||||
#[cfg(test)]
|
||||
mod tests_transparencygrid;
|
||||
#[cfg(test)]
|
||||
mod tests_typearea;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
#[cfg(test)]
|
||||
mod tests_webexport;
|
||||
|
||||
@@ -285,6 +285,39 @@ fn inspect_lists_everything() {
|
||||
assert!(s.commands().len() > 100);
|
||||
}
|
||||
|
||||
/// Type reports the paint its characters show, and its own object-level paint apart from it.
|
||||
#[test]
|
||||
fn inspect_reports_the_paint_of_types_characters() {
|
||||
let mut s = session();
|
||||
let id = s.execute("text.create", &json!({"x": 50, "y": 60, "text": "HI", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id]})).unwrap();
|
||||
let node = |s: &mut Session| {
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap()
|
||||
};
|
||||
let t = node(&mut s);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#ff0000"), Some("None"), Some(0.0)), "{t}");
|
||||
assert!(t.get("objectFill").is_none(), "no object-level paint: {t}");
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [id]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 3, "ids": [id]})).unwrap();
|
||||
let t = node(&mut s);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
|
||||
// An object-level fill on the type stays distinguishable from its characters' paint.
|
||||
s.execute("appearance.addFill", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ffff00", "item": 0, "ids": [id]})).unwrap();
|
||||
let t = node(&mut s);
|
||||
assert_eq!(t["objectFill"], "#ffff00", "{t}");
|
||||
assert_eq!(t["fill"], "#0000ff", "{t}");
|
||||
// A path keeps reporting its own appearance.
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r]})).unwrap();
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let rect = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == r).cloned().unwrap();
|
||||
assert_eq!(rect["fill"], "#ff0000");
|
||||
assert!(rect.get("objectFill").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_has_unique_id_and_doc() {
|
||||
let mut ids: Vec<&str> = command_specs().iter().map(|c| c.id).collect();
|
||||
|
||||
@@ -168,6 +168,33 @@ fn export_selection_crops_to_the_selection() {
|
||||
assert_eq!(text.matches("<path").count(), 1, "{text}");
|
||||
}
|
||||
|
||||
/// A selection export reports the encoder's warnings like a whole-document export, and an empty
|
||||
/// list when nothing was approximated.
|
||||
#[test]
|
||||
fn export_selection_reports_the_encoders_warnings() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("transparency.set", &json!({"blend": "Multiply"})).unwrap();
|
||||
let warnings = |v: &serde_json::Value| v["warnings"].as_array().cloned().unwrap_or_else(|| panic!("no warnings key: {v}"));
|
||||
// Control: nothing approximated.
|
||||
let plain = s.execute("document.exportSelection", &json!({"format": "svg"})).unwrap();
|
||||
assert_eq!(plain["format"], "svg");
|
||||
assert!(warnings(&plain).is_empty(), "{plain}");
|
||||
for (format, options, says) in [
|
||||
("webp", json!({"lossless": false}), "lossless"),
|
||||
("svg", json!({"profile": "tiny12"}), "blend"),
|
||||
("pdf", json!({"createLayers": true, "compatibility": "1.4"}), "layers"),
|
||||
] {
|
||||
let mut p = options.clone();
|
||||
p["format"] = json!(format);
|
||||
let sel = s.execute("document.exportSelection", &p).unwrap();
|
||||
let doc = s.execute("document.export", &p).unwrap();
|
||||
let w = warnings(&sel);
|
||||
assert!(w.iter().any(|w| w.as_str().unwrap_or("").to_lowercase().contains(says)), "{format}: {sel}");
|
||||
assert_eq!(w, warnings(&doc), "{format}: the same warnings as a whole-document export");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_opens_as_untitled() {
|
||||
let mut s = session();
|
||||
@@ -206,3 +233,30 @@ fn raster_export_too_large_is_an_error_not_a_crash() {
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"\x89PNG"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Japanese file and folder names save, open, export and title documents as typed: the native
|
||||
/// format, SVG, PNG and PDF, in a folder with a Japanese name too.
|
||||
#[test]
|
||||
fn japanese_file_names_save_open_and_export() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 80, "text": "日本語のテキスト"})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("vc-日本語フォルダ-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("運動会カード(最終版)・2024.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(s.doc().unwrap().title(), "運動会カード(最終版)・2024.vectorcraft", "the tab names the file as typed");
|
||||
s.execute("document.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.path.as_deref(), Some(path.to_string_lossy().as_ref()));
|
||||
let mut found = false;
|
||||
d.doc.walk(|n| found |= matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if t.plain_text() == "日本語のテキスト"));
|
||||
assert!(found, "the Japanese text comes back");
|
||||
for (format, name) in [("svg", "書き出し.svg"), ("png", "書き出し.png"), ("pdf", "書き出し.pdf")] {
|
||||
let out = dir.join(name);
|
||||
s.execute("document.export", &json!({"format": format, "path": out.to_string_lossy()})).unwrap();
|
||||
assert!(std::fs::metadata(&out).is_ok_and(|m| m.len() > 0), "{name}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -153,6 +153,61 @@ fn scale_corners_keeps_or_scales_live_corner_radii() {
|
||||
assert!((n.geometric_bounds().unwrap().width() - 400.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The extent along x and y of each corner arc of an upright live rounded rectangle's path.
|
||||
fn corner_spans(n: &Node) -> Vec<(f64, f64)> {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
|
||||
let a = &path.subpaths[0].anchors;
|
||||
assert_eq!(a.len(), 8, "a rounded rectangle");
|
||||
[(1, 2), (3, 4), (5, 6), (7, 0)].iter().map(|(i, j)| ((a[*j].p.x - a[*i].p.x).abs(), (a[*j].p.y - a[*i].p.y).abs())).collect()
|
||||
}
|
||||
|
||||
/// Every corner is a circular arc of radius `r`.
|
||||
fn round_corners(n: &Node, r: f64) -> bool {
|
||||
corner_spans(n).iter().all(|(x, y)| (x - r).abs() < 1e-9 && (y - r).abs() < 1e-9)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_corners_stay_circular_when_a_rectangle_is_scaled_unevenly() {
|
||||
// #291: a rounded square widened into a long rectangle keeps round corners.
|
||||
let mut s = session();
|
||||
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "radius": 10})));
|
||||
run(&mut s, "object.scale", json!({"sx": 300, "sy": 100, "corners": false}));
|
||||
let n = node(&s, id);
|
||||
assert!((n.geometric_bounds().unwrap().width() - 300.0).abs() < 1e-9);
|
||||
assert!(round_corners(&n, 10.0), "{:?}", corner_spans(&n));
|
||||
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, .. }), .. } = &n.kind else { panic!("not live") };
|
||||
assert!((w - 300.0).abs() < 1e-9 && (h - 100.0).abs() < 1e-9 && (radii[0] - 10.0).abs() < 1e-9, "the panels read document units");
|
||||
// Scale Corners on: the radius scales by the mean scale (√(2 × 0.5) = 1 here, 2 below).
|
||||
run(&mut s, "object.scale", json!({"sx": 200, "sy": 50, "corners": true}));
|
||||
assert!(round_corners(&node(&s, id), 10.0), "{:?}", corner_spans(&node(&s, id)));
|
||||
run(&mut s, "object.scale", json!({"sx": 100, "sy": 400, "corners": true}));
|
||||
assert!(round_corners(&node(&s, id), 20.0), "{:?}", corner_spans(&node(&s, id)));
|
||||
// Undo brings each step back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(round_corners(&node(&s, id), 10.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(round_corners(&node(&s, id), 10.0));
|
||||
assert!((node(&s, id).geometric_bounds().unwrap().width() - 100.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bounding_box_drag_keeps_live_corners_circular() {
|
||||
// The Selection tool's handle drag previews `object.transform` from the shape as it began.
|
||||
let mut s = session();
|
||||
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100, "radius": 25})));
|
||||
s.begin_interaction("Scale").unwrap();
|
||||
for sx in [1.5, 2.5, 4.0] {
|
||||
// Dragging the right-middle handle: scaled about the left edge.
|
||||
s.preview("object.transform", &json!({"matrix": [sx, 0, 0, 1, 50.0 - 50.0 * sx, 0]})).unwrap();
|
||||
}
|
||||
s.commit_interaction().unwrap();
|
||||
let n = node(&s, id);
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 50.0).abs() < 1e-9 && (b.width() - 400.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9, "{b:?}");
|
||||
assert!(round_corners(&n, 25.0), "{:?}", corner_spans(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_journal_records_the_scale_options_used() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -328,6 +328,23 @@ fn select_all_and_cut_like_ui() {
|
||||
assert_eq!(obj(&s, id).plain_text(), "abc defabc def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_dragged_with_the_type_tools_is_the_dragged_rectangle() {
|
||||
for tool in ["type", "verticalType"] {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
for (k, x, y) in [(PointerKind::Down, 100.0, 100.0), (PointerKind::Drag, 300.0, 180.0), (PointerKind::Up, 300.0, 180.0)] {
|
||||
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
let t = obj(&s, s.doc().unwrap().selection.objects[0]);
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("{tool}: not area type") };
|
||||
let b = frame.bounds().unwrap();
|
||||
let (a, z) = (t.xf * Point::new(b.x0, b.y0), t.xf * Point::new(b.x1, b.y1));
|
||||
assert!((a - Point::new(100.0, 100.0)).hypot() < 1e-9 && (z - Point::new(300.0, 180.0)).hypot() < 1e-9, "{tool}: {a:?} {z:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_from_a_circle_keeps_glyphs_inside() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Resizing area type (#252): a bounding-box handle drag and Area Type Options size the type area
|
||||
//! and the text reflows at its size; Direct Selection reshapes the frame by a corner or an edge.
|
||||
//! Point type still scales.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use vectorcraft_text::TextLayout;
|
||||
use vectorcraft_tools::bbox::Handle;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it.";
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// 12 pt area type in a `w` × `h` frame at (`x`, `y`).
|
||||
fn area(s: &mut Session, x: f64, y: f64, w: f64, h: f64, text: &str) -> NodeId {
|
||||
id_of(&s.execute("text.create", &json!({"x": x, "y": y, "size": 12, "area": {"width": w, "height": h}, "text": text})).unwrap())
|
||||
}
|
||||
|
||||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
k => panic!("not text: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame's anchors in text space.
|
||||
fn corners(s: &Session, id: NodeId) -> Vec<Point> {
|
||||
match text(s, id).kind {
|
||||
TextKind::Area { frame } => frame.anchors().map(|(_, _, a)| a.p).collect(),
|
||||
k => panic!("not area type: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(s: &Session, id: NodeId) -> Rect {
|
||||
match text(s, id).kind {
|
||||
TextKind::Area { frame } => frame.bounds().unwrap(),
|
||||
k => panic!("not area type: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(s: &Session, id: NodeId) -> TextLayout {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id))
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn last_step(s: &Session) -> String {
|
||||
s.doc().unwrap().history.undo.last().unwrap().label.clone()
|
||||
}
|
||||
|
||||
/// Press, drag and release `tool` from `from` to `to`.
|
||||
fn drag(s: &mut Session, tool: &str, from: Point, to: Point) {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
for (kind, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
assert!(!s.in_interaction());
|
||||
}
|
||||
|
||||
fn near(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handle_drag_resizes_the_type_area_and_the_text_reflows() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let before = layout(&s, id);
|
||||
assert!(before.overflow, "the story overflows 120 × 40");
|
||||
sel(&mut s, &[id]);
|
||||
let n = undo_len(&s);
|
||||
// The bounding box's bottom-right handle, from (160, 80) to (200, 140): 160 × 100.
|
||||
drag(&mut s, "selection", Point::new(160.0, 80.0), Point::new(200.0, 140.0));
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Resize Type Area");
|
||||
let r = frame(&s, id);
|
||||
assert!(near(r.origin(), Point::ZERO) && (r.width() - 160.0).abs() < 1e-6 && (r.height() - 100.0).abs() < 1e-6, "{r:?}");
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.xf, Affine::translate((40.0, 40.0)), "the type isn't scaled");
|
||||
assert!(t.runs.iter().all(|r| r.style.size == 12.0));
|
||||
let after = layout(&s, id);
|
||||
assert!(after.lines.len() > before.lines.len(), "more lines fit: {} → {}", before.lines.len(), after.lines.len());
|
||||
assert!(after.glyphs.len() > before.glyphs.len());
|
||||
assert!(!after.overflow, "the whole story fits 160 × 100");
|
||||
// The bounding box hugs the new frame.
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().geometric_bounds(), Some(Rect::new(40.0, 40.0, 200.0, 140.0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
assert!(layout(&s, id).overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_still_scales_with_its_handles() {
|
||||
let mut s = session();
|
||||
let id = id_of(&s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "text": "Hello"})).unwrap());
|
||||
sel(&mut s, &[id]);
|
||||
let b = s.transform_box(&s.doc().unwrap().selection.objects).unwrap();
|
||||
let (tl, br) = (b.to_doc() * Handle::TopLeft.pos(b.rect), b.to_doc() * Handle::BottomRight.pos(b.rect));
|
||||
drag(&mut s, "selection", br, tl + (br - tl) * 2.0);
|
||||
assert_eq!(last_step(&s), "Scale");
|
||||
let c = text(&s, id).xf.as_coeffs();
|
||||
assert!((c[0] - 2.0).abs() < 1e-6 && (c[3] - 2.0).abs() < 1e-6, "the type doubles: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_other_objects_the_type_area_resizes_and_the_others_scale() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 40, "y": 100, "width": 120, "height": 20})).unwrap());
|
||||
sel(&mut s, &[id, r]);
|
||||
// The box spans (40, 40)–(160, 120); its right handle doubles the width.
|
||||
drag(&mut s, "selection", Point::new(160.0, 80.0), Point::new(280.0, 80.0));
|
||||
assert_eq!(last_step(&s), "Scale");
|
||||
assert_eq!(s.doc().unwrap().doc.node(r).unwrap().geometric_bounds(), Some(Rect::new(40.0, 100.0, 280.0, 120.0)));
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 240.0, 40.0));
|
||||
assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_type_area_resizes_along_its_own_axes() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
sel(&mut s, &[id]);
|
||||
s.execute("object.rotate", &json!({"angle": 30, "absolute": true})).unwrap();
|
||||
let xf = text(&s, id).xf;
|
||||
let b = s.transform_box(&[id]).unwrap();
|
||||
let right = b.to_doc() * Handle::Right.pos(b.rect);
|
||||
let along = (b.to_doc() * Handle::Right.pos(b.rect)) - (b.to_doc() * Handle::Left.pos(b.rect));
|
||||
drag(&mut s, "selection", right, right + along * 0.5);
|
||||
assert_eq!(text(&s, id).xf, xf, "the type keeps its size and angle");
|
||||
let r = frame(&s, id);
|
||||
assert!((r.width() - 180.0).abs() < 1e-6 && (r.height() - 40.0).abs() < 1e-6, "still a rectangle, 180 × 40: {r:?}");
|
||||
assert_eq!(corners(&s, id).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_drags_a_frame_corner_or_edge() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let glyphs = layout(&s, id).glyphs.len();
|
||||
let n = undo_len(&s);
|
||||
// The bottom-right corner, not selected yet: the corner moves, the others stay.
|
||||
drag(&mut s, "directSelection", Point::new(160.0, 80.0), Point::new(200.0, 140.0));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Reshape Type Area");
|
||||
let c = corners(&s, id);
|
||||
assert!(near(c[0], Point::ZERO) && near(c[1], Point::new(120.0, 0.0)) && near(c[3], Point::new(0.0, 40.0)), "{c:?}");
|
||||
assert!(near(c[2], Point::new(160.0, 100.0)), "{c:?}");
|
||||
assert!(layout(&s, id).glyphs.len() > glyphs, "more text fits the larger area");
|
||||
assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
// The right edge: both its ends move, and the area stays a rectangle.
|
||||
drag(&mut s, "directSelection", Point::new(160.0, 60.0), Point::new(180.0, 60.0));
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 140.0, 40.0));
|
||||
assert_eq!(text(&s, id).runs[0].style.size, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_frame_reflows_its_thread() {
|
||||
let mut s = session();
|
||||
let a = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let b = area(&mut s, 300.0, 40.0, 120.0, 200.0, "");
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let rest = text(&s, b).plain_text().len();
|
||||
assert!(rest > 0, "the overflow flows on");
|
||||
// Bounding-box resize of the first frame to 160 × 100, as the Selection tool sends it.
|
||||
let m = Affine::translate((40.0, 40.0)) * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * Affine::translate((-40.0, -40.0));
|
||||
let c = m.as_coeffs();
|
||||
s.execute("object.transform", &json!({"ids": [a.0], "matrix": c, "typeAreas": true})).unwrap();
|
||||
assert!(text(&s, b).plain_text().len() < rest, "the second frame holds less");
|
||||
assert_eq!(text(&s, a).plain_text().len() + text(&s, b).plain_text().len(), STORY.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_options_size_the_type_area() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
sel(&mut s, &[id]);
|
||||
let q = s.execute("text.areaOptions", &json!({})).unwrap();
|
||||
assert_eq!((q["width"].as_f64(), q["height"].as_f64()), (Some(120.0), Some(40.0)));
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("text.areaOptions", &json!({"width": 160, "height": 100})).unwrap();
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(160.0), Some(100.0)));
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 160.0, 100.0));
|
||||
assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0)));
|
||||
assert!(!layout(&s, id).overflow);
|
||||
// The dialog's OK sends the size back unchanged with the other options: only those change.
|
||||
s.execute("text.areaOptions", &json!({"width": 160, "height": 100, "inset": 4})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 160.0, 100.0));
|
||||
assert_eq!(undo_len(&s), n + 2);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
// Type scaled 200%: sizes are in points on the page, along the type's axes.
|
||||
s.execute("object.scale", &json!({"sx": 200, "origin": [40, 40]})).unwrap();
|
||||
assert_eq!(s.execute("text.areaOptions", &json!({})).unwrap()["width"].as_f64(), Some(240.0));
|
||||
s.execute("text.areaOptions", &json!({"width": 300})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 150.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_area_takes_untrusted_params() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let point = id_of(&s.execute("text.create", &json!({"x": 40, "y": 300, "text": "Point"})).unwrap());
|
||||
let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 50, "height": 50})).unwrap());
|
||||
let n = undo_len(&s);
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"anchors": [[0, 2]], "dx": 1, "dy": 1}),
|
||||
json!({"id": point.0, "anchors": [[0, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": rect.0, "anchors": [[0, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": 99_999, "anchors": [[0, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [["a", 1], [0], "x"], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 9]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 1], [7, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dx": "far", "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dx": 0, "dy": 1e300}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dx": -1e7, "dy": 0}),
|
||||
] {
|
||||
assert!(s.execute("text.reshapeArea", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), n, "nothing changed");
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
// Listed twice, an anchor still moves once.
|
||||
s.execute("text.reshapeArea", &json!({"id": id.0, "anchors": [[0, 2], [0, 2]], "dx": 10, "dy": 5})).unwrap();
|
||||
assert_eq!(corners(&s, id)[2], Point::new(130.0, 45.0));
|
||||
assert_eq!(last_step(&s), "Reshape Type Area");
|
||||
// A frame collapsed to nothing lays out (everything overflows) without trouble.
|
||||
for m in [[0.0, 0.0, 0.0, 1.0, 40.0, 0.0], [1.0, 0.0, 0.0, 0.0, 0.0, 40.0], [0.0; 6]] {
|
||||
s.execute("object.transform", &json!({"ids": [id.0], "matrix": m, "typeAreas": true})).unwrap();
|
||||
assert!(layout(&s, id).overflow);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_area_is_a_registered_command() {
|
||||
let spec = cmd::find_command("text.reshapeArea").unwrap();
|
||||
assert_eq!(spec.label, "Reshape Type Area");
|
||||
assert!(spec.params.contains("anchors") && spec.params.contains("dx"));
|
||||
assert!(cmd::find_command("object.transform").unwrap().params.contains("typeAreas"));
|
||||
assert!(session().commands().iter().any(|c| c.id == "text.reshapeArea" && c.enabled));
|
||||
}
|
||||
@@ -148,6 +148,27 @@ fn free_transform_distort_mode_via_pointer() {
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_cmd_held_once_a_corner_drag_started_distorts_in_one_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("freeTransform", v).unwrap();
|
||||
let before = undo_len(&s);
|
||||
let cmd = Mods { cmd: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 210.0, 210.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(cmd), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0).with_mods(cmd), v).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let pts: Vec<Point> = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
|
||||
for p in [Point::new(250.0, 260.0), Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(100.0, 200.0)] {
|
||||
assert!(pts.iter().any(|q| q.distance(p) < 1e-6), "{p:?} not in {pts:?}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Distort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_side_handle_scales() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -910,6 +910,9 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
|
||||
"stopped", "begin", "end", "dict", "array", "string", "get", "put", "getinterval", "putinterval", "aload", "astore", "forall",
|
||||
"[", "]", "<<", ">>", "{", "}", "matrix", "currentmatrix", "setmatrix", "transform", "itransform", "invertmatrix", "pathbbox",
|
||||
"clippath", "initclip", "currentpoint", "eexec", "cleartomark", "mark", "counttomark", "showpage", "gstate", "setgstate",
|
||||
"clipsave", "cliprestore", "languagelevel", "cvx", "findresource", "defineresource", "undefineresource", "resourcestatus",
|
||||
"resourceforall", "/Category", "/Generic", "/ProcSet", "(*)", "(G?n*\\\\*?)", "{pop}", "8 string",
|
||||
"(*) {pop} 8 string /Category resourceforall", "/Generic /Category findresource dup length dict copy /Category defineresource",
|
||||
"/ASCII85Decode", "/ASCIIHexDecode", "/FlateDecode", "/RunLengthDecode", "/LZWDecode", "/DCTDecode", "/SubFileDecode",
|
||||
"/DeviceRGB", "/DeviceCMYK", "/DeviceGray", "/Pattern", "/x", "x", "/Helvetica", "(abc)", "<ff00>", "<~87cURD]i,\"Ebo80~>",
|
||||
"true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 <ff000000ff00>]",
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_geom::Affine;
|
||||
use super::graphics::{Space, process};
|
||||
use super::interp::{Interp, matrix_of};
|
||||
use super::lex::{find, hex_decode};
|
||||
use super::obj::{DictRef, Key, Obj, Op, PsError, Res, ps_err};
|
||||
use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
|
||||
use crate::ps;
|
||||
|
||||
/// Most bytes one filter or image reads.
|
||||
@@ -223,20 +223,15 @@ impl Interp<'_> {
|
||||
ReadHexString | ReadString => {
|
||||
let s = self.pop_str()?;
|
||||
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
|
||||
let n = s.borrow().len();
|
||||
let n = s.len();
|
||||
let got = if op == ReadString { self.read(&f, n)? } else { self.read_hex(&f, n)? };
|
||||
let full = got.len() == n;
|
||||
self.alloc(got.len())?;
|
||||
if let Some(d) = s.borrow_mut().get_mut(..got.len()) {
|
||||
d.copy_from_slice(&got);
|
||||
}
|
||||
self.push(Obj::string(got))?;
|
||||
self.push(Obj::Bool(full))?;
|
||||
self.fill(s, &got)?;
|
||||
self.push(Obj::Bool(got.len() == n))?;
|
||||
}
|
||||
ReadLine => {
|
||||
let s = self.pop_str()?;
|
||||
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
|
||||
let n = s.borrow().len();
|
||||
let n = s.len();
|
||||
let mut line = vec![];
|
||||
let mut ended = false;
|
||||
while line.len() < n {
|
||||
@@ -252,8 +247,7 @@ impl Interp<'_> {
|
||||
Some(b) => line.push(*b),
|
||||
}
|
||||
}
|
||||
self.alloc(line.len())?;
|
||||
self.push(Obj::string(line))?;
|
||||
self.fill(s, &line)?;
|
||||
self.push(Obj::Bool(ended))?;
|
||||
}
|
||||
FlushFile | CloseFile => {
|
||||
@@ -307,12 +301,20 @@ impl Interp<'_> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read bytes `got` into string `s` and push the part of `s` they fill.
|
||||
fn fill(&mut self, s: Shared<u8>, got: &[u8]) -> Res {
|
||||
if !s.write(0, got) {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
self.push_interval(Obj::Str(s), 0, got.len())
|
||||
}
|
||||
|
||||
/// `filter`: a file that decodes its source. Over the program's own data it decodes when
|
||||
/// first read (the data follows the operator that reads it); over other data, at once.
|
||||
fn filter(&mut self) -> Res {
|
||||
let name = self.pop()?.text().ok_or(PsError::Ps("typecheck", "filter".into()))?;
|
||||
let sub = if &*name == "SubFileDecode" {
|
||||
let eod = self.pop_str()?.borrow().clone();
|
||||
let eod = self.pop_str()?.to_vec();
|
||||
let count = self.pop_count()?;
|
||||
(count, eod)
|
||||
} else {
|
||||
@@ -687,8 +689,8 @@ struct Pixels {
|
||||
fn source(o: &Obj) -> Res<Source> {
|
||||
Ok(match o {
|
||||
Obj::File(f) => Source::File(f.clone()),
|
||||
Obj::Str(s) => Source::Str(s.borrow().clone()),
|
||||
p @ Obj::Array { .. } => Source::Proc(Obj::Array { items: p.items().cloned().unwrap_or_default(), exec: true }),
|
||||
Obj::Str(s) => Source::Str(s.to_vec()),
|
||||
Obj::Array { items, .. } => Source::Proc(Obj::Array { items: items.clone(), exec: true }),
|
||||
_ => return ps_err("typecheck", "DataSource"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ pub(crate) struct GState {
|
||||
pub cur: Option<Point>,
|
||||
pub start: Option<Point>,
|
||||
pub clips: Vec<Arc<Clip>>,
|
||||
/// The clips `clipsave` saved, for `cliprestore`.
|
||||
pub saved_clips: Vec<Rc<[Arc<Clip>]>>,
|
||||
pub overprint: bool,
|
||||
pub font: Option<DictRef>,
|
||||
}
|
||||
@@ -128,6 +130,7 @@ impl Default for GState {
|
||||
cur: None,
|
||||
start: None,
|
||||
clips: vec![],
|
||||
saved_clips: vec![],
|
||||
overprint: false,
|
||||
font: None,
|
||||
}
|
||||
@@ -489,7 +492,7 @@ impl Interp<'_> {
|
||||
let (family, items) = match o {
|
||||
Obj::Name(n) | Obj::Exec(n) => (n.clone(), vec![]),
|
||||
Obj::Array { items, .. } => {
|
||||
let v = items.borrow().clone();
|
||||
let v = items.to_vec();
|
||||
let n = v.first().and_then(Obj::text).ok_or(PsError::Ps("typecheck", "setcolorspace".into()))?;
|
||||
(n, v)
|
||||
}
|
||||
@@ -702,7 +705,7 @@ impl Interp<'_> {
|
||||
let d = match f {
|
||||
Obj::Dict(d) => d.clone(),
|
||||
Obj::Array { items, .. } => {
|
||||
let fs = items.borrow().clone();
|
||||
let fs = items.to_vec();
|
||||
let mut out = vec![];
|
||||
for g in &fs {
|
||||
out.extend(self.eval(g, t, depth + 1)?.first().copied());
|
||||
@@ -730,7 +733,7 @@ impl Interp<'_> {
|
||||
Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect())
|
||||
}
|
||||
Some(3.0) => {
|
||||
let fs = get("Functions").and_then(|o| o.items().map(|i| i.borrow().clone())).unwrap_or_default();
|
||||
let fs = get("Functions").and_then(|o| o.items().map(|i| i.to_vec())).unwrap_or_default();
|
||||
let bounds = nums(get("Bounds"));
|
||||
let encode = nums(get("Encode"));
|
||||
let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0));
|
||||
@@ -1095,6 +1098,19 @@ impl Interp<'_> {
|
||||
self.g.start = self.g.cur;
|
||||
}
|
||||
InitClip => self.g.clips.clear(),
|
||||
ClipSave => {
|
||||
if self.g.saved_clips.len() >= MAX_GSAVE {
|
||||
return Err(PsError::Limit("too many saved clips"));
|
||||
}
|
||||
self.alloc(std::mem::size_of_val(self.g.clips.as_slice()))?;
|
||||
let clips = self.g.clips.as_slice().into();
|
||||
self.g.saved_clips.push(clips);
|
||||
}
|
||||
ClipRestore => {
|
||||
if let Some(clips) = self.g.saved_clips.pop() {
|
||||
self.g.clips = clips.to_vec();
|
||||
}
|
||||
}
|
||||
Clip | EoClip => {
|
||||
let bp = self.g.path.clone();
|
||||
self.clip_with(bp, if op == EoClip { FillRule::EvenOdd } else { FillRule::NonZero });
|
||||
@@ -1118,7 +1134,7 @@ impl Interp<'_> {
|
||||
}
|
||||
RectStroke => {
|
||||
let m = match self.stack.last() {
|
||||
Some(Obj::Array { items, .. }) if items.borrow().len() == 6 => Some(self.pop_matrix()?),
|
||||
Some(Obj::Array { items, .. }) if items.len() == 6 => Some(self.pop_matrix()?),
|
||||
_ => None,
|
||||
};
|
||||
let mut bp = self.rects()?;
|
||||
@@ -1157,7 +1173,7 @@ fn linear_points(f: &Obj) -> Option<Vec<f64>> {
|
||||
match num("FunctionType")? {
|
||||
2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]),
|
||||
3.0 => {
|
||||
let fs = d.get(&Key::name("Functions"))?.items()?.borrow().clone();
|
||||
let fs = d.get(&Key::name("Functions"))?.items()?.to_vec();
|
||||
if !fs.iter().all(|g| linear_points(g).is_some_and(|p| p.is_empty())) {
|
||||
return None;
|
||||
}
|
||||
|
||||
+139
-96
@@ -11,7 +11,7 @@ use vectorcraft_geom::Affine;
|
||||
|
||||
use super::graphics::{GState, Out};
|
||||
use super::lex::Lexer;
|
||||
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err};
|
||||
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
|
||||
|
||||
/// Most operations one file may run.
|
||||
const MAX_OPS: u64 = 50_000_000;
|
||||
@@ -61,6 +61,12 @@ fn encoding() -> Obj {
|
||||
Obj::array(vec![Obj::name(".notdef"); 256])
|
||||
}
|
||||
|
||||
/// The resource categories there are to begin with (instances of `Category`).
|
||||
const CATEGORIES: &str = "Category Generic Font CIDFont CMap FontSet Encoding Form Pattern ProcSet ColorSpace Halftone \r
|
||||
ColorRendering IdiomSet InkParams TrapParams OutputDevice ControlLanguage Localization PDL HWOptions \r
|
||||
Filter ColorSpaceFamily Emulator IODevice ColorRenderingType FMapType FontType FormType HalftoneType \r
|
||||
ImageType PatternType FunctionType ShadingType";
|
||||
|
||||
impl<'a> Interp<'a> {
|
||||
pub fn new(src: &'a [u8], g: GState, out: Out) -> Self {
|
||||
let system = new_dict();
|
||||
@@ -72,7 +78,15 @@ impl<'a> Interp<'a> {
|
||||
for name in ["StandardEncoding", "ISOLatin1Encoding"] {
|
||||
d.insert(Key::name(name), encoding());
|
||||
}
|
||||
// An integer, as interpreters have it (`systemdict /languagelevel get 2 ge`): run, the
|
||||
// name pushes it all the same.
|
||||
d.insert(Key::name("languagelevel"), Obj::Int(3));
|
||||
}
|
||||
// Each category's implementation dictionary (`/Generic /Category findresource`).
|
||||
let categories: Dict =
|
||||
CATEGORIES.split_whitespace().map(|c| (Key::name(c), Obj::dict(Dict::from([(Key::name("Category"), Obj::name(c))])))).collect();
|
||||
let resources = new_dict();
|
||||
resources.borrow_mut().insert(Key::name("Category"), Obj::dict(categories));
|
||||
let fonts = new_dict();
|
||||
system.borrow_mut().insert(Key::name("FontDirectory"), Obj::Dict(fonts.clone()));
|
||||
system.borrow_mut().insert(Key::name("GlobalFontDirectory"), Obj::Dict(fonts.clone()));
|
||||
@@ -87,7 +101,7 @@ impl<'a> Interp<'a> {
|
||||
status: new_dict(),
|
||||
error: new_dict(),
|
||||
fonts,
|
||||
resources: new_dict(),
|
||||
resources,
|
||||
depth: 0,
|
||||
ops: 0,
|
||||
allocated: 0,
|
||||
@@ -175,7 +189,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pop_str(&mut self) -> Res<Rc<RefCell<Vec<u8>>>> {
|
||||
pub fn pop_str(&mut self) -> Res<Shared<u8>> {
|
||||
match self.pop()? {
|
||||
Obj::Str(s) => Ok(s),
|
||||
_ => ps_err("typecheck", "a string"),
|
||||
@@ -183,7 +197,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
|
||||
/// An array or procedure.
|
||||
pub fn pop_array(&mut self) -> Res<Rc<RefCell<Vec<Obj>>>> {
|
||||
pub fn pop_array(&mut self) -> Res<Shared<Obj>> {
|
||||
match self.pop()? {
|
||||
Obj::Array { items, .. } => Ok(items),
|
||||
_ => ps_err("typecheck", "an array"),
|
||||
@@ -197,15 +211,9 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
|
||||
/// Store `m` into the matrix operand `items` and push it back.
|
||||
pub fn put_matrix(&mut self, items: Rc<RefCell<Vec<Obj>>>, m: Affine) -> Res {
|
||||
{
|
||||
let mut v = items.borrow_mut();
|
||||
if v.len() != 6 {
|
||||
return ps_err("rangecheck", "a matrix");
|
||||
}
|
||||
for (slot, c) in v.iter_mut().zip(m.as_coeffs()) {
|
||||
*slot = Obj::Real(c);
|
||||
}
|
||||
pub fn put_matrix(&mut self, items: Shared<Obj>, m: Affine) -> Res {
|
||||
if items.len() != 6 || !items.write(0, &m.as_coeffs().map(Obj::Real)) {
|
||||
return ps_err("rangecheck", "a matrix");
|
||||
}
|
||||
self.push(Obj::Array { items, exec: false })
|
||||
}
|
||||
@@ -276,24 +284,24 @@ impl<'a> Interp<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_proc(&mut self, items: &Rc<RefCell<Vec<Obj>>>) -> Res {
|
||||
fn run_proc(&mut self, items: &Shared<Obj>) -> Res {
|
||||
self.enter()?;
|
||||
let r = self.run_items(items);
|
||||
self.depth -= 1;
|
||||
r
|
||||
}
|
||||
|
||||
fn run_items(&mut self, items: &Rc<RefCell<Vec<Obj>>>) -> Res {
|
||||
fn run_items(&mut self, items: &Shared<Obj>) -> Res {
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let Some(o) = items.borrow().get(i).cloned() else { return Ok(()) };
|
||||
let Some(o) = items.get(i) else { return Ok(()) };
|
||||
i += 1;
|
||||
self.exec_token(o)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `proc` as a loop body: `Ok(false)` when it ran `exit`.
|
||||
fn body(&mut self, proc: &Obj) -> Res<bool> {
|
||||
pub fn body(&mut self, proc: &Obj) -> Res<bool> {
|
||||
self.tick()?;
|
||||
match self.call(proc.clone()) {
|
||||
Ok(()) => Ok(true),
|
||||
@@ -303,7 +311,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
|
||||
/// Run a procedure operand, executable or not (`exec`, `if`, loops).
|
||||
fn pop_proc(&mut self) -> Res<Obj> {
|
||||
pub fn pop_proc(&mut self) -> Res<Obj> {
|
||||
match self.pop()? {
|
||||
Obj::Array { items, .. } => Ok(Obj::Array { items, exec: true }),
|
||||
o @ (Obj::Op(_) | Obj::Exec(_)) => Ok(o),
|
||||
@@ -480,7 +488,7 @@ impl<'a> Interp<'a> {
|
||||
Gt | Ge | Lt | Le => {
|
||||
let (b, a) = (self.pop()?, self.pop()?);
|
||||
let ord = match (&a, &b) {
|
||||
(Obj::Str(x), Obj::Str(y)) => x.borrow().as_slice().partial_cmp(y.borrow().as_slice()),
|
||||
(Obj::Str(x), Obj::Str(y)) => x.borrow().partial_cmp(&*y.borrow()),
|
||||
_ => a.as_num().zip(b.as_num()).and_then(|(x, y)| x.partial_cmp(&y)),
|
||||
}
|
||||
.ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
@@ -646,14 +654,10 @@ impl<'a> Interp<'a> {
|
||||
} else {
|
||||
show_text(&self.pop()?)
|
||||
};
|
||||
let n = text.len();
|
||||
if n > s.borrow().len() {
|
||||
if !s.write(0, text.as_bytes()) {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
if let Some(d) = s.borrow_mut().get_mut(..n) {
|
||||
d.copy_from_slice(text.as_bytes());
|
||||
}
|
||||
self.push_interval(Obj::Str(s), 0, n)?;
|
||||
self.push_interval(Obj::Str(s), 0, text.len())?;
|
||||
}
|
||||
ReadOnly | ExecuteOnly | NoAccess | SetPacking | SetGlobal | SetObjectFormat => {
|
||||
if matches!(op, SetPacking | SetGlobal | SetObjectFormat) {
|
||||
@@ -683,8 +687,8 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
Length => {
|
||||
let n = match self.pop()? {
|
||||
Obj::Array { items, .. } => items.borrow().len(),
|
||||
Obj::Str(s) => s.borrow().len(),
|
||||
Obj::Array { items, .. } => items.len(),
|
||||
Obj::Str(s) => s.len(),
|
||||
Obj::Dict(d) => d.borrow().len(),
|
||||
Obj::Name(n) | Obj::Exec(n) => n.len(),
|
||||
_ => return ps_err("typecheck", ""),
|
||||
@@ -699,14 +703,8 @@ impl<'a> Interp<'a> {
|
||||
Get => {
|
||||
let k = self.pop()?;
|
||||
let o = match self.pop()? {
|
||||
Obj::Array { items, .. } => {
|
||||
let i = index(&k)?;
|
||||
items.borrow().get(i).cloned().ok_or(PsError::Ps("rangecheck", String::new()))?
|
||||
}
|
||||
Obj::Str(s) => {
|
||||
let i = index(&k)?;
|
||||
Obj::Int(i64::from(*s.borrow().get(i).ok_or(PsError::Ps("rangecheck", String::new()))?))
|
||||
}
|
||||
Obj::Array { items, .. } => items.get(index(&k)?).ok_or(PsError::Ps("rangecheck", String::new()))?,
|
||||
Obj::Str(s) => Obj::Int(i64::from(s.get(index(&k)?).ok_or(PsError::Ps("rangecheck", String::new()))?)),
|
||||
Obj::Dict(d) => {
|
||||
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
let v = d.borrow().get(&key).cloned();
|
||||
@@ -718,21 +716,21 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
Put => {
|
||||
let (v, k, c) = (self.pop()?, self.pop()?, self.pop()?);
|
||||
match c {
|
||||
Obj::Array { items, .. } => {
|
||||
let i = index(&k)?;
|
||||
*items.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = v;
|
||||
}
|
||||
let written = match c {
|
||||
Obj::Array { items, .. } => items.write(index(&k)?, std::slice::from_ref(&v)),
|
||||
Obj::Str(s) => {
|
||||
let i = index(&k)?;
|
||||
let b = v.as_num().ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
*s.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = b as i64 as u8;
|
||||
s.write(index(&k)?, &[b as i64 as u8])
|
||||
}
|
||||
Obj::Dict(d) => {
|
||||
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
insert(&d, key, v)?;
|
||||
true
|
||||
}
|
||||
_ => return ps_err("typecheck", ""),
|
||||
};
|
||||
if !written {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
}
|
||||
GetInterval => {
|
||||
@@ -744,41 +742,35 @@ impl<'a> Interp<'a> {
|
||||
PutInterval => {
|
||||
let src = self.pop()?;
|
||||
let at = self.pop_count()?;
|
||||
match (self.pop()?, src) {
|
||||
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => {
|
||||
let from = from.borrow().clone();
|
||||
let mut v = items.borrow_mut();
|
||||
let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?;
|
||||
d.clone_from_slice(&from);
|
||||
}
|
||||
(Obj::Str(s), Obj::Str(from)) => {
|
||||
let from = from.borrow().clone();
|
||||
let mut v = s.borrow_mut();
|
||||
let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?;
|
||||
d.copy_from_slice(&from);
|
||||
}
|
||||
let written = match (self.pop()?, src) {
|
||||
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => items.write(at, &from.to_vec()),
|
||||
(Obj::Str(s), Obj::Str(from)) => s.write(at, &from.to_vec()),
|
||||
_ => return ps_err("typecheck", ""),
|
||||
};
|
||||
if !written {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
}
|
||||
Aload => {
|
||||
let items = self.pop_array()?;
|
||||
let all = items.borrow().clone();
|
||||
for o in all {
|
||||
for o in items.to_vec() {
|
||||
self.push(o)?;
|
||||
}
|
||||
self.push(Obj::Array { items, exec: false })?;
|
||||
}
|
||||
Astore => {
|
||||
let items = self.pop_array()?;
|
||||
let n = items.borrow().len();
|
||||
let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("stackunderflow", String::new()))?;
|
||||
*items.borrow_mut() = self.stack.split_off(at);
|
||||
let at = self.stack.len().checked_sub(items.len()).ok_or(PsError::Ps("stackunderflow", String::new()))?;
|
||||
if !items.write(0, self.stack.get(at..).unwrap_or_default()) {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
self.stack.truncate(at);
|
||||
self.push(Obj::Array { items, exec: false })?;
|
||||
}
|
||||
Search | AnchorSearch => {
|
||||
let seek = self.pop_str()?.borrow().clone();
|
||||
let seek = self.pop_str()?.to_vec();
|
||||
let s = self.pop_str()?;
|
||||
let text = s.borrow().clone();
|
||||
let text = s.to_vec();
|
||||
let at = if op == Search { super::lex::find(&text, &seek) } else { text.starts_with(&seek).then_some(0) };
|
||||
match at {
|
||||
Some(i) => {
|
||||
@@ -888,7 +880,6 @@ impl<'a> Interp<'a> {
|
||||
self.push(Obj::Int(v))?;
|
||||
}
|
||||
}
|
||||
LanguageLevel => self.push(Obj::Int(3))?,
|
||||
Version => self.push(Obj::string(b"3010".to_vec()))?,
|
||||
Product => self.push(Obj::string(b"VectorCraft".to_vec()))?,
|
||||
RealTime | UserTime => self.push(Obj::Int(0))?,
|
||||
@@ -922,19 +913,18 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
}
|
||||
Obj::Array { items, exec } => {
|
||||
let from = self.pop_array()?.borrow().clone();
|
||||
let n = from.len();
|
||||
{
|
||||
let mut v = items.borrow_mut();
|
||||
v.get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.clone_from_slice(&from);
|
||||
let from = self.pop_array()?.to_vec();
|
||||
if !items.write(0, &from) {
|
||||
return ps_err("rangecheck", "copy");
|
||||
}
|
||||
self.push_interval(Obj::Array { items, exec }, 0, n)?;
|
||||
self.push_interval(Obj::Array { items, exec }, 0, from.len())?;
|
||||
}
|
||||
Obj::Str(s) => {
|
||||
let from = self.pop_str()?.borrow().clone();
|
||||
let n = from.len();
|
||||
s.borrow_mut().get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.copy_from_slice(&from);
|
||||
self.push_interval(Obj::Str(s), 0, n)?;
|
||||
let from = self.pop_str()?.to_vec();
|
||||
if !s.write(0, &from) {
|
||||
return ps_err("rangecheck", "copy");
|
||||
}
|
||||
self.push_interval(Obj::Str(s), 0, from.len())?;
|
||||
}
|
||||
Obj::Dict(d) => {
|
||||
let from = self.pop_dict()?.borrow().clone();
|
||||
@@ -946,16 +936,12 @@ impl<'a> Interp<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `n` items of an array or string from `at` (a copy: shared storage isn't modelled).
|
||||
fn push_interval(&mut self, o: Obj, at: usize, n: usize) -> Res {
|
||||
let end = at.checked_add(n).ok_or(PsError::Ps("rangecheck", String::new()))?;
|
||||
self.alloc(n * if matches!(o, Obj::Str(_)) { 1 } else { std::mem::size_of::<Obj>() })?;
|
||||
/// `n` items of an array or string from `at`, sharing its storage.
|
||||
pub fn push_interval(&mut self, o: Obj, at: usize, n: usize) -> Res {
|
||||
let range = || PsError::Ps("rangecheck", String::new());
|
||||
let r = match o {
|
||||
Obj::Array { items, exec } => {
|
||||
let v = items.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec();
|
||||
Obj::Array { items: Rc::new(RefCell::new(v)), exec }
|
||||
}
|
||||
Obj::Str(s) => Obj::string(s.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec()),
|
||||
Obj::Array { items, exec } => Obj::Array { items: items.sub(at, n).ok_or_else(range)?, exec },
|
||||
Obj::Str(s) => Obj::Str(s.sub(at, n).ok_or_else(range)?),
|
||||
_ => return ps_err("typecheck", "getinterval"),
|
||||
};
|
||||
self.push(r)
|
||||
@@ -966,7 +952,7 @@ impl<'a> Interp<'a> {
|
||||
match self.pop()? {
|
||||
Obj::Array { items, .. } => {
|
||||
let mut i = 0;
|
||||
while let Some(o) = items.borrow().get(i).cloned() {
|
||||
while let Some(o) = items.get(i) {
|
||||
i += 1;
|
||||
self.push(o)?;
|
||||
if !self.body(&p)? {
|
||||
@@ -975,8 +961,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
}
|
||||
Obj::Str(s) => {
|
||||
let bytes = s.borrow().clone();
|
||||
for b in bytes {
|
||||
for b in s.to_vec() {
|
||||
self.push(Obj::Int(i64::from(b)))?;
|
||||
if !self.body(&p)? {
|
||||
break;
|
||||
@@ -1000,22 +985,19 @@ impl<'a> Interp<'a> {
|
||||
|
||||
/// Replace the names in a procedure that stand for operators by the operators, down through
|
||||
/// nested procedures.
|
||||
fn bind(&mut self, items: &Rc<RefCell<Vec<Obj>>>, depth: u32) {
|
||||
fn bind(&mut self, items: &Shared<Obj>, depth: u32) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
let n = items.borrow().len();
|
||||
for i in 0..n {
|
||||
let o = items.borrow().get(i).cloned();
|
||||
match o {
|
||||
for i in 0..items.len() {
|
||||
match items.get(i) {
|
||||
Some(Obj::Exec(name)) => {
|
||||
if let Some(Obj::Op(op)) = self.lookup(&name)
|
||||
&& let Some(slot) = items.borrow_mut().get_mut(i)
|
||||
{
|
||||
*slot = Obj::Op(op);
|
||||
if let Some(o @ Obj::Op(_)) = self.lookup(&name) {
|
||||
// `i` is in range and nothing is reading the procedure: the write can't fail.
|
||||
items.write(i, &[o]);
|
||||
}
|
||||
}
|
||||
Some(Obj::Array { items: inner, exec: true }) if !Rc::ptr_eq(&inner, items) => self.bind(&inner, depth + 1),
|
||||
Some(Obj::Array { items: inner, exec: true }) if !inner.shares(items) => self.bind(&inner, depth + 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1032,6 +1014,67 @@ impl<'a> Interp<'a> {
|
||||
_ => ps_err("typecheck", ""),
|
||||
})
|
||||
}
|
||||
|
||||
/// The instances of `category`: `FontDirectory` for fonts.
|
||||
pub fn instances(&mut self, category: &Obj) -> Res<DictRef> {
|
||||
if category.text().as_deref() == Some("Font") { Ok(self.fonts.clone()) } else { self.category(category) }
|
||||
}
|
||||
|
||||
/// `template proc scratch category resourceforall`: `proc` run on each instance name that
|
||||
/// matches `template`, copied into `scratch` (in name order).
|
||||
pub fn resource_for_all(&mut self) -> Res {
|
||||
let category = self.pop()?;
|
||||
let scratch = self.pop_str()?;
|
||||
let p = self.pop_proc()?;
|
||||
let template = self.pop_str()?.to_vec();
|
||||
let mut names: Vec<Vec<u8>> = self.instances(&category)?.borrow().keys().map(|k| show_text(&k.obj()).into_bytes()).collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
if !self.matches(&template, &name)? {
|
||||
continue;
|
||||
}
|
||||
if !scratch.write(0, &name) {
|
||||
return ps_err("rangecheck", "resourceforall");
|
||||
}
|
||||
self.push_interval(Obj::Str(scratch.clone()), 0, name.len())?;
|
||||
if !self.body(&p)? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does `name` match a `resourceforall` template: `*` any run of bytes, `?` any one, `\` the
|
||||
/// next one as it is? Each step counts as an operation.
|
||||
fn matches(&mut self, template: &[u8], name: &[u8]) -> Res<bool> {
|
||||
let (mut t, mut n) = (0, 0);
|
||||
// Where the last `*` was followed (template, name): a mismatch after it retries from there.
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
loop {
|
||||
self.tick()?;
|
||||
if n == name.len() {
|
||||
return Ok(template.get(t..).unwrap_or_default().iter().all(|b| *b == b'*'));
|
||||
}
|
||||
let (want, width) = match template.get(t) {
|
||||
Some(b'*') => {
|
||||
t += 1;
|
||||
star = Some((t, n));
|
||||
continue;
|
||||
}
|
||||
Some(b'?') => (name.get(n).copied(), 1),
|
||||
Some(b'\\') => (template.get(t + 1).copied(), 2),
|
||||
c => (c.copied(), 1),
|
||||
};
|
||||
if want.is_some() && want == name.get(n).copied() {
|
||||
(t, n) = (t + width, n + 1);
|
||||
continue;
|
||||
}
|
||||
// The last `*` takes one more byte, else there is no match.
|
||||
let Some((st, sn)) = star else { return Ok(false) };
|
||||
star = Some((st, sn + 1));
|
||||
(t, n) = (st, sn + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `v` under `k` in `d`, within [`MAX_ALLOC`] entries.
|
||||
@@ -1057,7 +1100,7 @@ fn equal(a: &Obj, b: &Obj) -> bool {
|
||||
match (a, b) {
|
||||
(Obj::Null, Obj::Null) | (Obj::Mark, Obj::Mark) => true,
|
||||
(Obj::Bool(x), Obj::Bool(y)) => x == y,
|
||||
(Obj::Array { items: x, .. }, Obj::Array { items: y, .. }) => Rc::ptr_eq(x, y),
|
||||
(Obj::Array { items: x, .. }, Obj::Array { items: y, .. }) => x.same(y),
|
||||
(Obj::Dict(x), Obj::Dict(y)) => Rc::ptr_eq(x, y),
|
||||
(Obj::Op(x), Obj::Op(y)) => x == y,
|
||||
_ => match (a.as_num(), b.as_num()) {
|
||||
|
||||
@@ -192,7 +192,10 @@ fn finish(mut out: Out) -> Imported {
|
||||
|
||||
/// A document of `frame`'s size showing TIFF preview `tiff` stretched over it.
|
||||
fn preview(tiff: &[u8], frame: Rect) -> Option<Document> {
|
||||
let img = image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).ok()?.to_rgba8();
|
||||
let img = match image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff) {
|
||||
Ok(img) => img.to_rgba8(),
|
||||
Err(_) => crate::tiff::palette_rgba(tiff)?,
|
||||
};
|
||||
let (w, h) = img.dimensions();
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The PostScript objects the interpreter works with, its operators and its errors.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Ref, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -31,10 +31,10 @@ pub(crate) enum Obj {
|
||||
Name(Rc<str>),
|
||||
/// An executable name: looked up and run.
|
||||
Exec(Rc<str>),
|
||||
Str(Rc<RefCell<Vec<u8>>>),
|
||||
Str(Shared<u8>),
|
||||
/// An array; executable ones are procedures.
|
||||
Array {
|
||||
items: Rc<RefCell<Vec<Obj>>>,
|
||||
items: Shared<Obj>,
|
||||
exec: bool,
|
||||
},
|
||||
Dict(DictRef),
|
||||
@@ -53,15 +53,15 @@ impl Obj {
|
||||
}
|
||||
|
||||
pub fn string(bytes: Vec<u8>) -> Self {
|
||||
Self::Str(Rc::new(RefCell::new(bytes)))
|
||||
Self::Str(Shared::new(bytes))
|
||||
}
|
||||
|
||||
pub fn array(items: Vec<Obj>) -> Self {
|
||||
Self::Array { items: Rc::new(RefCell::new(items)), exec: false }
|
||||
Self::Array { items: Shared::new(items), exec: false }
|
||||
}
|
||||
|
||||
pub fn proc(items: Vec<Obj>) -> Self {
|
||||
Self::Array { items: Rc::new(RefCell::new(items)), exec: true }
|
||||
Self::Array { items: Shared::new(items), exec: true }
|
||||
}
|
||||
|
||||
pub fn dict(d: Dict) -> Self {
|
||||
@@ -98,7 +98,7 @@ impl Obj {
|
||||
}
|
||||
|
||||
/// The items of an array or procedure.
|
||||
pub fn items(&self) -> Option<&Rc<RefCell<Vec<Obj>>>> {
|
||||
pub fn items(&self) -> Option<&Shared<Obj>> {
|
||||
match self {
|
||||
Self::Array { items, .. } => Some(items),
|
||||
_ => None,
|
||||
@@ -125,6 +125,71 @@ impl Obj {
|
||||
}
|
||||
}
|
||||
|
||||
/// The values of a string or an array: a run of storage that other strings or arrays may share
|
||||
/// (`getinterval` makes a new object over part of the same values, as PostScript does).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Shared<T> {
|
||||
buf: Rc<RefCell<Vec<T>>>,
|
||||
at: usize,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<T: Clone> Shared<T> {
|
||||
pub fn new(v: Vec<T>) -> Self {
|
||||
Self { len: v.len(), buf: Rc::new(RefCell::new(v)), at: 0 }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// The values. Only [`Self::write`] borrows the storage mutably, for the copy alone, so this
|
||||
/// never meets it.
|
||||
pub fn borrow(&self) -> Ref<'_, [T]> {
|
||||
let (at, end) = (self.at, self.at.saturating_add(self.len));
|
||||
Ref::map(self.buf.borrow(), |v| v.get(at..end).unwrap_or(&[]))
|
||||
}
|
||||
|
||||
pub fn get(&self, i: usize) -> Option<T> {
|
||||
self.borrow().get(i).cloned()
|
||||
}
|
||||
|
||||
pub fn to_vec(&self) -> Vec<T> {
|
||||
self.borrow().to_vec()
|
||||
}
|
||||
|
||||
/// `n` values from `at`, sharing the storage (`getinterval`); `None` past the end.
|
||||
pub fn sub(&self, at: usize, n: usize) -> Option<Self> {
|
||||
(at.checked_add(n)? <= self.len).then(|| Self { buf: self.buf.clone(), at: self.at + at, len: n })
|
||||
}
|
||||
|
||||
/// Write `vals` from `at`: false when they don't fit (or `vals` is borrowed from the storage).
|
||||
pub fn write(&self, at: usize, vals: &[T]) -> bool {
|
||||
if at.saturating_add(vals.len()) > self.len {
|
||||
return false;
|
||||
}
|
||||
let start = self.at + at;
|
||||
let Ok(mut v) = self.buf.try_borrow_mut() else { return false };
|
||||
match v.get_mut(start..start + vals.len()) {
|
||||
Some(d) => {
|
||||
d.clone_from_slice(vals);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same object (`eq` on arrays): the same values of the same storage.
|
||||
pub fn same(&self, o: &Self) -> bool {
|
||||
Rc::ptr_eq(&self.buf, &o.buf) && self.at == o.at && self.len == o.len
|
||||
}
|
||||
|
||||
/// Do both see the same storage?
|
||||
pub fn shares(&self, o: &Self) -> bool {
|
||||
Rc::ptr_eq(&self.buf, &o.buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Key {
|
||||
pub fn name(s: &str) -> Self {
|
||||
Self::Name(Rc::from(s))
|
||||
@@ -209,7 +274,7 @@ ops! {
|
||||
ErrorDict = "errordict", DollarError = "$error", Bind = "bind",
|
||||
// Virtual memory and the environment.
|
||||
Save = "save", Restore = "restore", SetGlobal = "setglobal", CurrentGlobal = "currentglobal", VmStatus = "vmstatus",
|
||||
LanguageLevel = "languagelevel", Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime",
|
||||
Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime",
|
||||
Print = "print", EqPrint = "=", EqEqPrint = "==", Pstack = "pstack", Stack = "stack", Flush = "flush",
|
||||
// Graphics state.
|
||||
Gsave = "gsave", Grestore = "grestore", GrestoreAll = "grestoreall", InitGraphics = "initgraphics",
|
||||
@@ -242,8 +307,8 @@ ops! {
|
||||
RLineTo = "rlineto", CurveTo = "curveto", RCurveTo = "rcurveto", Arc = "arc", Arcn = "arcn", Arct = "arct",
|
||||
Arcto = "arcto", ClosePath = "closepath", FlattenPath = "flattenpath", ReversePath = "reversepath",
|
||||
PathBBox = "pathbbox", ClipPath = "clippath", InitClip = "initclip", Clip = "clip", EoClip = "eoclip",
|
||||
RectClip = "rectclip", Fill = "fill", EoFill = "eofill", Stroke = "stroke", RectFill = "rectfill",
|
||||
RectStroke = "rectstroke", ShFill = "shfill",
|
||||
RectClip = "rectclip", ClipSave = "clipsave", ClipRestore = "cliprestore", Fill = "fill", EoFill = "eofill",
|
||||
Stroke = "stroke", RectFill = "rectfill", RectStroke = "rectstroke", ShFill = "shfill",
|
||||
// Images.
|
||||
Image = "image", ImageMask = "imagemask", ColorImage = "colorimage",
|
||||
// Files.
|
||||
@@ -252,7 +317,8 @@ ops! {
|
||||
// Fonts and type.
|
||||
FindFont = "findfont", ScaleFont = "scalefont", MakeFont = "makefont", SetFont = "setfont", SelectFont = "selectfont",
|
||||
CurrentFont = "currentfont", DefineFont = "definefont", UndefineFont = "undefinefont", FindResource = "findresource",
|
||||
DefineResource = "defineresource", ResourceStatus = "resourcestatus", Show = "show", AShow = "ashow",
|
||||
DefineResource = "defineresource", UndefineResource = "undefineresource", ResourceStatus = "resourcestatus",
|
||||
ResourceForAll = "resourceforall", Show = "show", AShow = "ashow",
|
||||
WidthShow = "widthshow", AWidthShow = "awidthshow", XShow = "xshow", YShow = "yshow", XYShow = "xyshow",
|
||||
KShow = "kshow", GlyphShow = "glyphshow", StringWidth = "stringwidth", CharPath = "charpath",
|
||||
SetCacheDevice = "setcachedevice", SetCharWidth = "setcharwidth",
|
||||
|
||||
@@ -452,3 +452,234 @@ fn device_settings_are_accepted_and_change_nothing() {
|
||||
assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Check that `body` draws one square, blue: it sets blue when what it checks holds, else red.
|
||||
fn blue(body: &str) {
|
||||
let r = read(&format!("{body} 10 10 80 80 rectfill"));
|
||||
let o = objects(&r.document);
|
||||
assert_eq!(o.len(), 1, "{body}: {:?}", r.warnings);
|
||||
assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0))), "{body}");
|
||||
assert!(r.warnings.is_empty(), "{body}: {:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Check that PostScript `cond` leaves `true`.
|
||||
fn check(cond: &str) {
|
||||
blue(&format!("{cond} {{ 0 0 1 setrgbcolor }} {{ 1 0 0 setrgbcolor }} ifelse"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_generic_category_defines_new_categories() {
|
||||
// #234: the reference app's prolog.
|
||||
blue("/Generic /Category findresource pop 0 0 1 setrgbcolor");
|
||||
blue(
|
||||
"[/CSA /Gradient /Procedure] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall 0 0 1 setrgbcolor",
|
||||
);
|
||||
let define = "[/CSA /Gradient] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall \
|
||||
/G1 << /Kind 7 >> /Gradient defineresource pop";
|
||||
check(&format!("{define} /G1 /Gradient findresource /Kind get 7 eq"));
|
||||
check(&format!("{define} /Gradient /Category resourcestatus {{ pop pop true }} {{ false }} ifelse"));
|
||||
check(&format!("{define} /G1 /Gradient resourcestatus {{ pop pop true }} {{ false }} ifelse"));
|
||||
check(&format!("{define} /G1 /Gradient undefineresource /G1 /Gradient resourcestatus not"));
|
||||
check(&format!("{define} {{ /G2 /Gradient findresource }} stopped"));
|
||||
// Unknown categories aren't instances of Category; a category is a dictionary.
|
||||
check("/Nonsense /Category resourcestatus not");
|
||||
check("{ /Nonsense /Category findresource } stopped");
|
||||
check("{ /X 5 /Category defineresource } stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipsave_and_cliprestore_bring_the_clip_back() {
|
||||
// #234.
|
||||
blue("clipsave 0 0 50 50 rectclip cliprestore 0 0 1 setrgbcolor");
|
||||
// The fill after cliprestore covers the whole square, unclipped.
|
||||
let r = read("clipsave 0 0 50 50 rectclip 1 0 0 setrgbcolor 0 0 10 10 rectfill cliprestore 0 0 1 setrgbcolor 10 10 80 80 rectfill");
|
||||
let o = objects(&r.document);
|
||||
assert_eq!(o.len(), 2, "{:?}", r.warnings);
|
||||
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", o[0].kind);
|
||||
assert!(matches!(o[1].kind, NodeKind::Path { clipping: false, .. }), "{:?}", o[1].kind);
|
||||
assert!(near(bounds(&o[1]), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(&o[1]));
|
||||
// Nested, leaving the rest of the graphics state alone.
|
||||
let r = read(
|
||||
"0 0 60 60 rectclip clipsave 0 0 20 20 rectclip clipsave 0 0 5 5 rectclip cliprestore 0 1 0 setrgbcolor cliprestore \
|
||||
10 10 80 80 rectfill",
|
||||
);
|
||||
let NodeKind::Group { children, clip: true } = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
|
||||
assert!(near(bounds(&children[0]), Rect::new(0.0, 40.0, 60.0, 100.0)), "{:?}", bounds(&children[0]));
|
||||
assert_eq!(fill(&children[1]), Some(&Paint::solid(Color::rgb(0.0, 1.0, 0.0))));
|
||||
// grestore undoes the clipsaves after its gsave; cliprestore with nothing saved does nothing.
|
||||
let o = objects(&read("cliprestore gsave clipsave 0 0 50 50 rectclip grestore cliprestore 10 10 80 80 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Path { .. }), "{:?}", o[0].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn languagelevel_is_an_integer_and_runs() {
|
||||
// #234.
|
||||
blue(
|
||||
"/Level2? systemdict /languagelevel known dup { pop systemdict /languagelevel get 2 ge } if def \
|
||||
Level2? { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse",
|
||||
);
|
||||
check("languagelevel 3 eq");
|
||||
check("{ languagelevel } bind exec 3 eq");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_share_their_arrays_and_strings() {
|
||||
// #234: astore into a subarray stores into the array.
|
||||
blue("/a 4 array def 1 2 a 0 2 getinterval astore pop a 0 get 1 eq { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse");
|
||||
// put, putinterval and copy into an interval; an interval of an interval.
|
||||
check("/a [0 0 0 0 0] def a 1 3 getinterval 1 2 getinterval 0 7 put a 2 get 7 eq");
|
||||
check("/a [0 0 0 0] def a 2 2 getinterval 0 [8 9] putinterval a 3 get 9 eq");
|
||||
check("/a [0 0 0 0] def [5 6] a 1 2 getinterval copy pop a 2 get 6 eq");
|
||||
check("/s (abcd) def s 1 2 getinterval 0 (XY) putinterval s (aXYd) eq");
|
||||
check("/s (abcd) def s 2 1 getinterval 0 90 put s (abZd) eq");
|
||||
check("/s 8 string def 42 s 2 6 getinterval cvs pop s 2 2 getinterval (42) eq");
|
||||
// The same interval is the same object; another one is not.
|
||||
check("/a 4 array def a 0 2 getinterval a 0 2 getinterval eq");
|
||||
check("/a 4 array def a 0 2 getinterval a 0 3 getinterval ne a 0 2 getinterval a 1 2 getinterval ne and");
|
||||
// Reads and writes stay inside the interval.
|
||||
check("/a [1 2 3 4] def { a 1 2 getinterval 2 get } stopped { a 1 2 getinterval 2 0 put } stopped and");
|
||||
check("/a [1 2 3 4] def a 1 2 getinterval length 2 eq a 1 2 getinterval aload pop 3 eq exch 2 eq and and");
|
||||
check("/n 0 def [1 2 3 4] 1 2 getinterval { n add /n exch def } forall n 5 eq");
|
||||
// A procedure from an interval runs only its part.
|
||||
check("{ 1 2 3 } 1 2 getinterval cvx exec add 5 eq");
|
||||
// The prolog's stack save: the operands into part of one array, and back.
|
||||
check(
|
||||
"/stk 10 array def /lev 3 array def 1 2 3 count dup lev exch 0 exch put stk exch 0 exch getinterval astore pop \
|
||||
clear stk 0 lev 0 get getinterval aload pop add add 6 eq",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resourceforall_enumerates_matching_names() {
|
||||
// #234.
|
||||
blue("(*) { pop } 128 string /Category resourceforall 0 0 1 setrgbcolor");
|
||||
let count = |template: &str| format!("/n 0 def ({template}) {{ pop /n n 1 add def }} 128 string /ProcSet resourceforall n");
|
||||
let define = "[/Foo /Fop /Bar /F*x] { 1 dict /ProcSet defineresource pop } forall";
|
||||
check(&format!("{define} {} 4 eq", count("*")));
|
||||
check(&format!("{define} {} 2 eq", count("Fo?")));
|
||||
check(&format!("{define} {} 3 eq", count("F*")));
|
||||
check(&format!("{define} {} 1 eq", count("F\\\\*x")));
|
||||
check(&format!("{define} {} 0 eq", count("Q*")));
|
||||
check(&format!("{define} {} 1 eq", count("*a*")));
|
||||
// Each name is copied into the scratch string; exit stops.
|
||||
check(&format!("{define} /last () def (B*) {{ dup length string copy /last exch def }} 8 string /ProcSet resourceforall last (Bar) eq"));
|
||||
check(&format!("{define} /n 0 def (*) {{ pop /n n 1 add def exit }} 8 string /ProcSet resourceforall n 1 eq"));
|
||||
// A scratch string too small for the names, as the prolog probes it.
|
||||
check(&format!("{define} {{ (*) {{ pop }} 2 string /ProcSet resourceforall }} stopped"));
|
||||
check("/n 0 def (Gen*) { pop /n n 1 add def } 16 string /Category resourceforall n 1 eq");
|
||||
}
|
||||
|
||||
/// A `w` × `h` 8-bit palette TIFF with an alpha sample (`ExtraSamples` [1]), uncompressed in
|
||||
/// strips of `per` rows, in either byte order, as the reference app writes its EPS previews:
|
||||
/// pixel (x, y) is entry `(x + y) % 3` of a red, green, blue map, at alpha `12 x`.
|
||||
fn palette_tiff(w: u16, h: u16, per: u16, big: bool) -> Vec<u8> {
|
||||
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
// Shorts in an entry, left-justified.
|
||||
let shorts = |v: &[u16]| -> [u8; 4] {
|
||||
let mut f = [0; 4];
|
||||
for (i, s) in v.iter().enumerate() {
|
||||
f[2 * i..2 * i + 2].copy_from_slice(&u16b(*s));
|
||||
}
|
||||
f
|
||||
};
|
||||
let row = u32::from(w) * 2;
|
||||
let mut out = vec![];
|
||||
out.extend(if big { *b"MM\0*" } else { *b"II*\0" });
|
||||
out.extend([0; 4]);
|
||||
let data_at = out.len() as u32;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
out.extend([((x + y) % 3) as u8, (x * 12) as u8]);
|
||||
}
|
||||
}
|
||||
let map_at = out.len() as u32;
|
||||
for (c, full) in [0xFFFF, 0x8000, 0xFFFF].into_iter().enumerate() {
|
||||
for i in 0..256 {
|
||||
out.extend(u16b(if i == c { full } else { 0 }));
|
||||
}
|
||||
}
|
||||
let strips: Vec<u16> = (0..h.div_ceil(per)).collect();
|
||||
let offsets_at = out.len() as u32;
|
||||
for k in &strips {
|
||||
out.extend(u32b(data_at + u32::from(k * per) * row));
|
||||
}
|
||||
let counts_at = out.len() as u32;
|
||||
for k in &strips {
|
||||
out.extend(u32b(u32::from(per.min(h - k * per)) * row));
|
||||
}
|
||||
let ifd = out.len() as u32;
|
||||
out[4..8].copy_from_slice(&u32b(ifd));
|
||||
let n = strips.len() as u32;
|
||||
let entries: [(u16, u16, u32, [u8; 4]); 12] = [
|
||||
(256, 3, 1, shorts(&[w])),
|
||||
(257, 3, 1, shorts(&[h])),
|
||||
(258, 3, 2, shorts(&[8, 8])),
|
||||
(259, 3, 1, shorts(&[1])),
|
||||
(262, 3, 1, shorts(&[3])),
|
||||
(273, 4, n, u32b(if n == 1 { data_at } else { offsets_at })),
|
||||
(277, 3, 1, shorts(&[2])),
|
||||
(278, 3, 1, shorts(&[per])),
|
||||
(279, 4, n, u32b(if n == 1 { u32::from(h) * row } else { counts_at })),
|
||||
(284, 3, 1, shorts(&[1])),
|
||||
(320, 3, 768, u32b(map_at)),
|
||||
(338, 3, 1, shorts(&[1])),
|
||||
];
|
||||
out.extend(u16b(entries.len() as u16));
|
||||
for (tag, kind, count, field) in entries {
|
||||
out.extend(u16b(tag));
|
||||
out.extend(u16b(kind));
|
||||
out.extend(u32b(count));
|
||||
out.extend(field);
|
||||
}
|
||||
out.extend([0; 4]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_palette_preview_with_alpha_is_placed() {
|
||||
// #234: the program fails, so the preview is placed.
|
||||
let ps = eps("0 0 1 setrgbcolor 10 10 80 80 rectfill frobnicate");
|
||||
for (big, per) in [(false, 5), (true, 5), (false, 10), (true, 3)] {
|
||||
let tiff = palette_tiff(20, 10, per, big);
|
||||
let r = import(&crate::dos_eps(&ps, &tiff).unwrap()).unwrap();
|
||||
assert!(r.preview, "{big} {per}");
|
||||
assert!(r.warnings[0].contains("preview"), "{:?}", r.warnings);
|
||||
let o = objects(&r.document);
|
||||
let NodeKind::Image(im) = &o[0].kind else { panic!("{:?}", o[0].kind) };
|
||||
assert_eq!((im.width, im.height), (20, 10));
|
||||
assert!(near(bounds(&o[0]), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
for (x, y) in [(0, 0), (1, 0), (2, 0), (19, 9), (7, 4), (3, 6)] {
|
||||
let [r, g, b] = [[255, 0, 0], [0, 128, 0], [0, 0, 255]][((x + y) % 3) as usize];
|
||||
assert_eq!(png.get_pixel(x, y).0, [r, g, b, (x * 12) as u8], "({x}, {y}) {big} {per}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_palette_preview_is_refused_without_panicking() {
|
||||
let tiff = palette_tiff(20, 10, 5, false);
|
||||
assert!(crate::tiff::palette_rgba(&tiff).is_some());
|
||||
// Cut anywhere before the last entry's value (the directory is at the end): nothing.
|
||||
for cut in 0..tiff.len() - 6 {
|
||||
assert!(crate::tiff::palette_rgba(&tiff[..cut]).is_none(), "{cut}");
|
||||
}
|
||||
// Any byte changed: a picture or nothing.
|
||||
for big in [false, true] {
|
||||
let tiff = palette_tiff(20, 10, 5, big);
|
||||
for at in 0..tiff.len() {
|
||||
for v in [0, 1, 2, 0x7f, 0x80, 0xff] {
|
||||
let mut t = tiff.clone();
|
||||
t[at] = v;
|
||||
let _ = crate::tiff::palette_rgba(&t);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A damaged preview of an unreadable program: the hard error.
|
||||
let ps = eps("frobnicate");
|
||||
for t in [&tiff[..tiff.len() / 2], b"II*\0garbage", &[0; 64]] {
|
||||
let e = import(&crate::dos_eps(&ps, t).unwrap()).unwrap_err();
|
||||
assert!(e.contains("can't be read"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -110,38 +110,46 @@ impl Interp<'_> {
|
||||
let category = self.pop()?;
|
||||
let v = self.pop()?;
|
||||
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "defineresource".into()))?;
|
||||
let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? };
|
||||
dict.borrow_mut().insert(k, v.clone());
|
||||
// A category is defined by its implementation dictionary.
|
||||
if category.text().as_deref() == Some("Category") && !matches!(v, Obj::Dict(_)) {
|
||||
return ps_err("typecheck", "defineresource");
|
||||
}
|
||||
self.instances(&category)?.borrow_mut().insert(k, v.clone());
|
||||
self.push(v)?;
|
||||
}
|
||||
UndefineResource => {
|
||||
let category = self.pop()?;
|
||||
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "undefineresource".into()))?;
|
||||
self.instances(&category)?.borrow_mut().remove(&k);
|
||||
}
|
||||
ResourceStatus => {
|
||||
let category = self.pop()?;
|
||||
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "resourcestatus".into()))?;
|
||||
let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? };
|
||||
let known = dict.borrow().contains_key(&k);
|
||||
let known = self.instances(&category)?.borrow().contains_key(&k);
|
||||
if known {
|
||||
self.push(Obj::Int(1))?;
|
||||
self.push(Obj::Int(0))?;
|
||||
}
|
||||
self.push(Obj::Bool(known))?;
|
||||
}
|
||||
ResourceForAll => self.resource_for_all()?,
|
||||
Show => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.show(&s)?;
|
||||
}
|
||||
AShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.nums::<2>()?;
|
||||
self.show(&s)?;
|
||||
}
|
||||
WidthShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.pop()?;
|
||||
self.nums::<2>()?;
|
||||
self.show(&s)?;
|
||||
}
|
||||
AWidthShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.nums::<2>()?;
|
||||
self.pop()?;
|
||||
self.nums::<2>()?;
|
||||
@@ -149,11 +157,11 @@ impl Interp<'_> {
|
||||
}
|
||||
XShow | YShow | XYShow => {
|
||||
self.pop()?;
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.show(&s)?;
|
||||
}
|
||||
KShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.pop()?;
|
||||
self.show(&s)?;
|
||||
}
|
||||
@@ -163,14 +171,14 @@ impl Interp<'_> {
|
||||
self.show(ch.encode_utf8(&mut [0; 4]).as_bytes())?;
|
||||
}
|
||||
StringWidth => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
let (_, _, v) = self.set_type(&s, Point::ZERO)?;
|
||||
self.push_num(v.x)?;
|
||||
self.push_num(v.y)?;
|
||||
}
|
||||
CharPath => {
|
||||
self.pop_bool()?;
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.char_path(&s)?;
|
||||
}
|
||||
SetCacheDevice => {
|
||||
|
||||
+110
-1
@@ -1,5 +1,6 @@
|
||||
//! The TIFF preview (little-endian, PackBits strips, written by the raster exports' TIFF writer):
|
||||
//! 1-bit black and white, or 8-bit RGB with or without an alpha channel. Also the PNG thumbnail.
|
||||
//! 1-bit black and white, or 8-bit RGB with or without an alpha channel. Also the PNG thumbnail,
|
||||
//! and a reader for the palette previews other apps write that the `image` crate can't decode.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -50,3 +51,111 @@ pub(crate) fn png(img: &Raster) -> Result<Vec<u8>, String> {
|
||||
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Most pixels a palette preview may have.
|
||||
const MAX_PIXELS: usize = 1 << 26;
|
||||
/// Most values a TIFF field may have.
|
||||
const MAX_VALUES: usize = 1 << 20;
|
||||
|
||||
/// A TIFF's bytes in its byte order.
|
||||
struct Tiff<'a> {
|
||||
b: &'a [u8],
|
||||
big: bool,
|
||||
}
|
||||
|
||||
impl Tiff<'_> {
|
||||
fn u16(&self, at: usize) -> Option<u16> {
|
||||
let b: [u8; 2] = self.b.get(at..at.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(if self.big { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) })
|
||||
}
|
||||
|
||||
fn u32(&self, at: usize) -> Option<u32> {
|
||||
let b: [u8; 4] = self.b.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if self.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) })
|
||||
}
|
||||
|
||||
/// The values of the directory entry at `at` (bytes, shorts or longs; in the entry when they
|
||||
/// fit in four bytes).
|
||||
fn values(&self, at: usize) -> Option<Vec<u32>> {
|
||||
let (kind, n) = (self.u16(at.checked_add(2)?)?, self.u32(at.checked_add(4)?)? as usize);
|
||||
let size = match kind {
|
||||
1 => 1,
|
||||
3 => 2,
|
||||
4 => 4,
|
||||
_ => return None,
|
||||
};
|
||||
if n > MAX_VALUES {
|
||||
return None;
|
||||
}
|
||||
let field = at.checked_add(8)?;
|
||||
let start = if n * size <= 4 { field } else { self.u32(field)? as usize };
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let at = start.checked_add(i * size)?;
|
||||
match size {
|
||||
1 => self.b.get(at).map(|v| u32::from(*v)),
|
||||
2 => self.u16(at).map(u32::from),
|
||||
_ => self.u32(at),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// An 8-bit palette TIFF, with or without an alpha sample (`ExtraSamples`), uncompressed and
|
||||
/// interleaved, as RGBA: the layout of other apps' EPS previews. `None` for anything else.
|
||||
pub(crate) fn palette_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
let big = match bytes.get(..4)? {
|
||||
b"II*\0" => false,
|
||||
b"MM\0*" => true,
|
||||
_ => return None,
|
||||
};
|
||||
let t = Tiff { b: bytes, big };
|
||||
let ifd = t.u32(4)? as usize;
|
||||
let entries = usize::from(t.u16(ifd)?);
|
||||
let mut fields = std::collections::HashMap::new();
|
||||
for i in 0..entries {
|
||||
let at = ifd.checked_add(2 + i * 12)?;
|
||||
fields.insert(t.u16(at)?, at);
|
||||
}
|
||||
let get = |tag: u16| fields.get(&tag).and_then(|at| t.values(*at));
|
||||
let one = |tag: u16| get(tag).and_then(|v| v.first().copied());
|
||||
let (w, h) = (one(256)? as usize, one(257)? as usize);
|
||||
let samples = one(277).unwrap_or(1) as usize;
|
||||
let alpha = samples == 2 && matches!(get(338)?.as_slice(), [0..=2]);
|
||||
let plain = one(259).unwrap_or(1) == 1 && one(262)? == 3 && one(284).unwrap_or(1) == 1;
|
||||
if !plain || !(samples == 1 || alpha) || get(258)?.iter().any(|b| *b != 8) || w == 0 || h == 0 || w.checked_mul(h)? > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let map = get(320)?;
|
||||
if map.len() != 3 * 256 {
|
||||
return None;
|
||||
}
|
||||
// Strips of `per` rows each, in order.
|
||||
let (offsets, counts) = (get(273)?, get(279)?);
|
||||
let per = (one(278).unwrap_or(u32::MAX) as usize).min(h);
|
||||
let row = w * samples;
|
||||
// The strips are in the file: so is all that's allocated.
|
||||
if per == 0 || offsets.len() != counts.len() || offsets.len() < h.div_ceil(per) || row * h > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let mut data = Vec::with_capacity(row * h);
|
||||
for (k, (at, n)) in offsets.iter().zip(&counts).take(h.div_ceil(per)).enumerate() {
|
||||
let want = per.min(h - k * per) * row;
|
||||
if (*n as usize) < want {
|
||||
return None;
|
||||
}
|
||||
let at = *at as usize;
|
||||
data.extend_from_slice(bytes.get(at..at.checked_add(want)?)?);
|
||||
}
|
||||
// Map entries are 16-bit.
|
||||
let channel = |c: usize, i: usize| map.get(c * 256 + i).map_or(0, |v| ((u64::from(*v).min(65_535) * 255 + 32_767) / 65_535) as u8);
|
||||
let rgba = data
|
||||
.chunks_exact(samples)
|
||||
.flat_map(|px| {
|
||||
let i = usize::from(px.first().copied().unwrap_or(0));
|
||||
[channel(0, i), channel(1, i), channel(2, i), px.get(1).copied().unwrap_or(255)]
|
||||
})
|
||||
.collect();
|
||||
image::RgbaImage::from_raw(w as u32, h as u32, rgba)
|
||||
}
|
||||
@@ -284,6 +284,36 @@ fn headless_path_gesture_undo() {
|
||||
assert_eq!(&std::fs::read(tmp("x.png")).unwrap()[..4], b"\x89PNG");
|
||||
}
|
||||
|
||||
/// inspect_document reads back the paint type's characters show, as add_text and set_paint give it.
|
||||
#[test]
|
||||
fn inspect_document_reports_the_paint_of_type() {
|
||||
let mut s = server();
|
||||
let r = call(&mut s, 2, "add_text", json!({"text": "HI", "x": 50, "y": 60, "size": 24, "color": "#ff0000"}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
|
||||
let node = |s: &mut Server, n: u64| {
|
||||
let v: Value = serde_json::from_str(&text_of(&call(s, n, "inspect_document", json!({})))).unwrap();
|
||||
v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).cloned().unwrap()
|
||||
};
|
||||
let t = node(&mut s, 3);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str()), (Some("#ff0000"), Some("None")), "{t}");
|
||||
let r = call(&mut s, 4, "set_paint", json!({"fill": "#0000ff", "stroke": "#00ff00", "strokeWidth": 3, "ids": [id]}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let t = node(&mut s, 5);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
|
||||
}
|
||||
|
||||
/// save_file says it saves in the document's own format or the one the path's extension picks.
|
||||
#[test]
|
||||
fn save_file_describes_the_formats_it_writes() {
|
||||
let tools = tool_definitions();
|
||||
let d = tools.iter().find(|t| t["name"] == "save_file").and_then(|t| t["description"].as_str()).unwrap();
|
||||
for ext in [".vectorcraft", ".vctemplate", ".pdf", ".svg", ".svgz", ".ai"] {
|
||||
assert!(d.contains(ext), "{ext}: {d}");
|
||||
}
|
||||
assert!(d.contains("its own format"), "{d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_tool_results_not_crashes() {
|
||||
let mut s = server();
|
||||
|
||||
+13
-4
@@ -2,7 +2,7 @@
|
||||
//! [`Backend`].
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS};
|
||||
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format};
|
||||
|
||||
use crate::backend::Backend;
|
||||
|
||||
@@ -57,6 +57,12 @@ fn obj(props: Value, required: &[&str]) -> Value {
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The extension of each format Save writes, in [`SAVE_FORMATS`] order.
|
||||
fn save_extensions() -> Vec<&'static str> {
|
||||
SAVE_FORMATS.iter().filter_map(|id| format(id)?.extensions.first().copied()).collect()
|
||||
}
|
||||
|
||||
fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
@@ -259,14 +265,17 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"save_file",
|
||||
"Save file",
|
||||
"Save the active document in the native .vectorcraft format.",
|
||||
obj(json!({"path": string("Destination (default: the document's current path)")}), &[]),
|
||||
&format!(
|
||||
"Save the active document with the engine's document.save: by default to its own file in its own format (native .vectorcraft unless it was opened from or saved as SVG, PDF or a restorable .ai). With `path`, the extension picks the format: .{}. Other formats are exports (see export). The reply's `format` says what was written and `warnings` what that format loses.",
|
||||
save_extensions().join(", .")
|
||||
),
|
||||
obj(json!({"path": string("Destination; its extension picks the format (default: the document's own file)")}), &[]),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8/tiff/bmp/tga/psd (one rendered artboard; png8 is an indexed .png; psd keeps the layers), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
|
||||
"Export the active document with the engine's document.export (the same bytes in the app and headless), in any format of the `format` enum: vectors (svg/svgz with the artboard as viewBox, eps, dxf, emf, wmf), pdf (one page per artboard: all, or `artboard` / `range`), rasters (one rendered artboard: png, jpg, webp, gif, png8 as an indexed .png, tiff, bmp, tga, psd with its layers), txt (the stories, back to front), vectorcraft or template (a native copy or template; the document keeps its path). run_command document.formats lists each format's options. `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
|
||||
obj(
|
||||
json!({
|
||||
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
|
||||
|
||||
@@ -111,7 +111,16 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts);
|
||||
b.begin_page(scan);
|
||||
interpret_page(page, &mut ctx, &mut b);
|
||||
let parts = b.end_page();
|
||||
let mut parts = b.end_page();
|
||||
// A file with several artboards writes, on each page, the art of its neighbours that
|
||||
// reaches into the page's box: art lying wholly outside this page is theirs (each page
|
||||
// draws it shifted by its own artboard spacing), so keep it only where it belongs.
|
||||
if picked.len() > 1 {
|
||||
for (_, art) in &mut parts {
|
||||
art.retain(|n| n.visual_bounds().is_none_or(|r| r.x0 <= ab.x1 && r.x1 >= ab.x0 && r.y0 <= ab.y1 && r.y1 >= ab.y0));
|
||||
}
|
||||
parts.retain(|(_, art)| !art.is_empty());
|
||||
}
|
||||
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
|
||||
placeholder &= crate::pages::has_private_data(page) && only_text(&children);
|
||||
let mut right = ab.x1;
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
//! fifth of the size reads as a space). Fonts are named from the file's base font name (its
|
||||
//! subset prefix dropped, the family matched against the fonts available); a font that isn't
|
||||
//! available keeps its name, and the text shows in the fallback font until it is.
|
||||
//!
|
||||
//! Glyphs each turned a little further along a curve (type set on a path: apps write each glyph
|
||||
//! with its own placement) make one type-on-a-path object, its path through the glyphs' baseline.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, Point, Vec2};
|
||||
use kurbo::{Affine, BezPath, Point, Vec2};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, TextObject, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, TextKind, TextObject, TextRun};
|
||||
|
||||
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -93,12 +96,19 @@ pub(crate) struct TextLine {
|
||||
next: Point,
|
||||
/// The last glyph's origin.
|
||||
last: Point,
|
||||
/// The last glyph's baseline direction.
|
||||
last_dir: Vec2,
|
||||
/// Each glyph's baseline origin, then where the last one ends.
|
||||
baseline: Vec<Point>,
|
||||
runs: Vec<(Look, String)>,
|
||||
}
|
||||
|
||||
/// The most a glyph on a curve turns from the one before it (about 20°).
|
||||
const CURVE_TURN_COS: f64 = 0.94;
|
||||
|
||||
impl TextLine {
|
||||
pub fn new(at: Placement, opacity: f32) -> Self {
|
||||
Self { at, opacity, next: at.origin, last: at.origin, runs: vec![] }
|
||||
Self { at, opacity, next: at.origin, last: at.origin, last_dir: at.dir, baseline: vec![], runs: vec![] }
|
||||
}
|
||||
|
||||
/// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if
|
||||
@@ -106,13 +116,20 @@ impl TextLine {
|
||||
pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool {
|
||||
if let Some((last, run)) = self.runs.last_mut() {
|
||||
let size = self.at.size.max(at.size);
|
||||
let gap = (at.origin - self.next).dot(self.at.dir);
|
||||
let gap = (at.origin - self.next).dot(self.last_dir);
|
||||
let on_line = opacity == self.opacity
|
||||
&& at.dir.dot(self.at.dir) > 0.9995
|
||||
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
|
||||
&& gap > -size * 0.3
|
||||
&& gap < size * 3.0;
|
||||
if !on_line {
|
||||
// Or on a curve: turned a little from the glyph before it, and starting about where
|
||||
// that one ends.
|
||||
let on_curve = !on_line
|
||||
&& opacity == self.opacity
|
||||
&& at.dir.dot(self.last_dir) > CURVE_TURN_COS
|
||||
&& at.dir.dot(self.last_dir) < 0.99999
|
||||
&& (at.origin - self.next).hypot() < size * 0.6;
|
||||
if !on_line && !on_curve {
|
||||
return false;
|
||||
}
|
||||
// A gap wider than a fifth of an em reads as a space.
|
||||
@@ -127,11 +144,42 @@ impl TextLine {
|
||||
} else {
|
||||
self.runs.push((look.clone(), text.to_string()));
|
||||
}
|
||||
self.next = at.origin + self.at.dir * advance;
|
||||
self.next = at.origin + at.dir * advance;
|
||||
self.last = at.origin;
|
||||
self.last_dir = at.dir;
|
||||
self.baseline.push(at.origin);
|
||||
true
|
||||
}
|
||||
|
||||
/// Has the baseline turned (more than about 3° from the first glyph to the last)?
|
||||
fn curved(&self) -> bool {
|
||||
self.last_dir.dot(self.at.dir) < 0.9986
|
||||
}
|
||||
|
||||
/// A smooth path through the glyphs' baseline origins and the end of the last glyph, an em
|
||||
/// further (Catmull-Rom through the points, as cubic Béziers).
|
||||
fn baseline_path(&self) -> Option<BezPath> {
|
||||
let mut pts = self.baseline.clone();
|
||||
// On past the end of the last glyph by an em, so rounding in the spacing doesn't push it
|
||||
// off the end of the path (where type on a path stops).
|
||||
pts.push(self.next);
|
||||
pts.push(self.next + self.last_dir * self.at.size);
|
||||
let first = *pts.first()?;
|
||||
// Finite placements far out can still overflow once an em is added: no path then.
|
||||
if pts.len() < 3 || !pts.iter().all(|p| p.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(first);
|
||||
for (i, seg) in pts.windows(2).enumerate() {
|
||||
let &[p1, p2] = seg else { continue };
|
||||
let p0 = *pts.get(i.wrapping_sub(1)).unwrap_or(&p1);
|
||||
let p3 = *pts.get(i + 2).unwrap_or(&p2);
|
||||
bp.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2);
|
||||
}
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
/// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill
|
||||
/// and stroke rendering): the run is stroked. `false`: it isn't that glyph.
|
||||
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool {
|
||||
@@ -144,6 +192,8 @@ impl TextLine {
|
||||
|
||||
/// The point type object and its opacity.
|
||||
pub fn finish(mut self) -> Option<(TextObject, f32)> {
|
||||
// Turned along a curve: type on a path through the glyphs.
|
||||
let path = if self.curved() { self.baseline_path() } else { None };
|
||||
if let Some((_, last)) = self.runs.last_mut() {
|
||||
last.truncate(last.trim_end().len());
|
||||
}
|
||||
@@ -156,6 +206,10 @@ impl TextLine {
|
||||
let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style);
|
||||
t.runs.extend(runs);
|
||||
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(self.at.dir.atan2());
|
||||
if let Some(path) = path {
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0 };
|
||||
t.xf = Affine::IDENTITY;
|
||||
}
|
||||
Some((t, self.opacity))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,3 +359,34 @@ fn appearance_items_are_untouched_for_plain_art() {
|
||||
assert!(nodes.iter().all(|n| n.mask.is_none() && !matches!(n.kind, NodeKind::Group { .. })));
|
||||
assert!(matches!(nodes.iter().find(|n| n.appearance.stroke().is_some()).unwrap().appearance.items[0], AppearanceItem::Stroke(_)));
|
||||
}
|
||||
|
||||
/// Type set on a path (each glyph placed and turned along a curve, as apps write it) comes back
|
||||
/// as type on a path through the glyphs, not as a straight line; straight type stays point type.
|
||||
#[test]
|
||||
fn glyphs_turned_along_a_curve_become_type_on_a_path() {
|
||||
// "ARCHING" along an arc of radius 100 around (150, 0), each glyph where the one before
|
||||
// it ends (Helvetica's advances), clockwise from 135° (PDF y up: the arc bulges upwards).
|
||||
let mut content = String::from("BT /F1 18 Tf ");
|
||||
let mut a = 135f64.to_radians();
|
||||
for (c, adv) in "ARCHING".chars().zip([667.0, 722.0, 722.0, 722.0, 278.0, 722.0, 778.0]) {
|
||||
let (x, y) = (150.0 + 100.0 * a.cos(), 100.0 * a.sin());
|
||||
// The baseline's direction is the tangent, clockwise along the arc.
|
||||
let (dx, dy) = (a.sin(), -a.cos());
|
||||
content.push_str(&format!("{dx:.5} {dy:.5} {:.5} {dx:.5} {x:.3} {y:.3} Tm ({c}) Tj ", -dy));
|
||||
a -= adv / 1000.0 * 18.0 / 100.0;
|
||||
}
|
||||
content.push_str("ET");
|
||||
let t = texts(&open(&one_page(&content, HELVETICA, &[])));
|
||||
assert_eq!(t.len(), 1, "one text object: {t:?}");
|
||||
let text: String = t[0].runs.iter().map(|r| r.text.as_str()).collect();
|
||||
assert_eq!(text, "ARCHING");
|
||||
let vectorcraft_doc::TextKind::OnPath { path, .. } = &t[0].kind else { panic!("type on a path: {:?}", t[0].kind) };
|
||||
let b = path.bounds().unwrap();
|
||||
assert!(b.height() > 15.0, "the path curves: {b:?}");
|
||||
// Every glyph fits on the path, the last one too.
|
||||
assert_eq!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]).glyphs.len(), 7);
|
||||
// Straight type with a slight rotation stays one point type object.
|
||||
let straight = texts(&open(&one_page("BT /F1 18 Tf 0.98481 0.17365 -0.17365 0.98481 20 40 Tm (Straight) Tj ET", HELVETICA, &[])));
|
||||
assert_eq!(straight.len(), 1);
|
||||
assert!(matches!(straight[0].kind, vectorcraft_doc::TextKind::Point), "{:?}", straight[0].kind);
|
||||
}
|
||||
@@ -106,6 +106,19 @@ fn bounding_boxes_of_several_pages_do_not_overlap() {
|
||||
assert!(b.x0 >= a.x1 + 36.0, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_of_neighbouring_artboards_off_a_page_is_left_to_its_own_page() {
|
||||
// Page 2 also draws page 1's square, off to its left (outside its box), as files with several
|
||||
// artboards are written: only page 1 keeps it, so it doesn't come back twice.
|
||||
let pages = [PdfPage::new(100.0, 100.0, "0 g 10 10 20 20 re f"), PdfPage::new(100.0, 100.0, "0 g -90 10 20 20 re f 1 0 0 rg 50 10 20 20 re f")];
|
||||
let d = open(&pdf(&pages, None), ImportOptions::default());
|
||||
let count = |i: usize| d.layers[i].children().map_or(0, |c| c.len());
|
||||
assert_eq!((count(0), count(1)), (1, 1));
|
||||
// A single page keeps its art off the page (the pasteboard).
|
||||
let d = open(&pdf(&pages[1..], None), ImportOptions::default());
|
||||
assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encrypted_pdf_opens_with_its_password_only() {
|
||||
let bytes = pdf(&[PdfPage::new(200.0, 100.0, "1 0 0 rg 10 10 50 50 re f")], Some("secret"));
|
||||
|
||||
+22
-12
@@ -40,13 +40,10 @@ impl Ctx<'_> {
|
||||
tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size);
|
||||
// An upright glyph advances at least one em down the column (the vertical advance of
|
||||
// CJK fonts), centred in it: a narrow mark like § must not overlap its neighbours.
|
||||
for g in v.iter_mut().filter(|g| stands_upright(g)) {
|
||||
let natural = g.face.advance(g.gid) * g.sx;
|
||||
let em = g.face.units_per_em() * g.sx;
|
||||
if natural < em && g.adv > 0.0 {
|
||||
g.dx += (em - natural) * 0.5;
|
||||
g.adv += em - natural;
|
||||
}
|
||||
for g in v.iter_mut().filter(|g| g.adv > 0.0 && stands_upright(g)) {
|
||||
let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx;
|
||||
g.dx += extra * 0.5;
|
||||
g.adv += extra;
|
||||
}
|
||||
}
|
||||
v
|
||||
@@ -68,9 +65,12 @@ impl Ctx<'_> {
|
||||
let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0));
|
||||
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m;
|
||||
} else if self.vertical && stands_upright(g) {
|
||||
// Turned about the centre of its em box, which the column's centre line runs through.
|
||||
// Turned about the centre of its em box, which the column's centre line runs through:
|
||||
// the middle of its own cell, not of its advance (tracking and justification add space
|
||||
// after the cell, and must not push the glyph off the centre line).
|
||||
let em = self.style_at(g.byte).size;
|
||||
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + advance * 0.5, origin.y - EM_CENTER * em)) * m;
|
||||
let cell = if g.adv > 0.0 { upright_cell(g) } else { advance };
|
||||
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - EM_CENTER * em)) * m;
|
||||
}
|
||||
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
|
||||
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
|
||||
@@ -214,6 +214,12 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The length an upright glyph takes down the column before tracking and justification: its
|
||||
/// advance, at least one em.
|
||||
fn upright_cell(g: &SGlyph) -> f64 {
|
||||
(g.face.advance(g.gid) * g.sx).max(g.face.units_per_em() * g.sx)
|
||||
}
|
||||
|
||||
/// Height of the centre of the ideographic em box above the baseline, in ems (the em box runs
|
||||
/// from 0.12 em below the baseline to 0.88 em above it).
|
||||
const EM_CENTER: f64 = 0.38;
|
||||
@@ -829,8 +835,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
// Composed lines may shrink word spaces (never below zero).
|
||||
per_space =
|
||||
((width - w) / spaces as f64).max(-sg[i..trimmed].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min));
|
||||
} else if para.justify == Justify::JustifyAll && trimmed - i > 1 && width > w {
|
||||
per_gap = (width - w) / (trimmed - i - 1) as f64;
|
||||
} else if para.justify == Justify::JustifyAll && width > w {
|
||||
let gaps = sg.get(i + 1..trimmed).map_or(0, |s| s.iter().filter(|g| !g.continues_tcy()).count());
|
||||
if gaps > 0 {
|
||||
per_gap = (width - w) / gaps as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
let start_x = if justify {
|
||||
@@ -861,7 +870,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
} else if j < trimmed {
|
||||
if g.is_space() {
|
||||
adv += per_space;
|
||||
} else if j + 1 < trimmed {
|
||||
} else if j + 1 < trimmed && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
|
||||
// Between glyphs, never inside a tate-chu-yoko block (one cell).
|
||||
adv += per_gap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ impl SGlyph {
|
||||
pub fn is_letter(&self) -> bool {
|
||||
self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’')
|
||||
}
|
||||
/// Vertical type: a tate-chu-yoko glyph after its block's first, sharing that glyph's cell.
|
||||
pub fn continues_tcy(&self) -> bool {
|
||||
self.tcy.is_some_and(|t| t.pen > 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const SOFT_HYPHEN: char = '\u{00AD}';
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
//! Vertical type (bundled fonts only: `§` and `×` stand upright like CJK, letters lie on their side).
|
||||
//! Vertical type (bundled fonts: `§` and `×` stand upright like CJK, letters lie on their side;
|
||||
//! Japanese text only with craft-fonts' faces, skipped without them).
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
use vectorcraft_doc::{CharStyle, TextKind};
|
||||
use vectorcraft_doc::{CharStyle, Justify, TextKind};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
fn vertical(text: &str) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||||
vertical_styled(text, CharStyle { size: 20.0, ..CharStyle::default() })
|
||||
}
|
||||
|
||||
fn vertical_styled(text: &str, style: CharStyle) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.vertical = true;
|
||||
t
|
||||
@@ -128,3 +133,131 @@ fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_brackets_off_line_end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each glyph's cell down the (single) column: from its origin over its advance. A glyph that
|
||||
/// doesn't advance (the rest of a tate-chu-yoko block) shares the cell of the glyph before it.
|
||||
fn column_cells(l: &TextLayout) -> Vec<(f64, f64)> {
|
||||
let mut cells: Vec<(f64, f64)> = Vec::with_capacity(l.glyphs.len());
|
||||
for g in &l.glyphs {
|
||||
let cell = match cells.last() {
|
||||
Some(&prev) if g.advance == 0.0 => prev,
|
||||
_ => (g.origin.y, g.origin.y + g.advance),
|
||||
};
|
||||
cells.push(cell);
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// One column, cells one after the other, each glyph's ink inside its own cell (within `tol`)
|
||||
/// and no two glyphs' ink overlapping.
|
||||
fn assert_own_cells(l: &TextLayout, text: &str, tol: f64) {
|
||||
let label = |i: usize| &text[l.glyphs[i].byte..l.glyphs[i].byte + l.glyphs[i].len];
|
||||
let cells = column_cells(l);
|
||||
for (i, g) in l.glyphs.iter().enumerate() {
|
||||
assert!((g.origin.x - l.glyphs[0].origin.x).abs() < 1e-9, "{:?} leaves the column: {g:?}", label(i));
|
||||
if let Some(next) = l.glyphs.get(i + 1) {
|
||||
assert!((next.origin.y - (g.origin.y + g.advance)).abs() < 1e-9, "{:?} starts where {:?} ends", label(i + 1), label(i));
|
||||
}
|
||||
if g.outline.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (r, (top, bottom)) = (ink(l, i), cells[i]);
|
||||
assert!(r.y0 >= top - tol && r.y1 <= bottom + tol, "{:?}: ink {r:?} outside its cell {top}..{bottom}", label(i));
|
||||
for j in (0..i).filter(|&j| !l.glyphs[j].outline.elements().is_empty()) {
|
||||
let o = ink(l, j);
|
||||
let (ox, oy) = (r.x1.min(o.x1) - r.x0.max(o.x0), r.y1.min(o.y1) - r.y0.max(o.y0));
|
||||
assert!(ox <= tol || oy <= tol, "{:?} {r:?} overlaps {:?} {o:?}", label(i), label(j));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the glyph of `needle`'s first byte in `text`.
|
||||
fn glyph_of(l: &TextLayout, text: &str, needle: &str) -> usize {
|
||||
let b = text.find(needle).unwrap();
|
||||
l.glyphs.iter().position(|g| g.byte == b).unwrap()
|
||||
}
|
||||
|
||||
/// Issue #260 with bundled glyphs (`§` stands in for the upright CJK characters).
|
||||
#[test]
|
||||
fn numbers_keep_their_own_cells_down_the_column() {
|
||||
let text = "§§ §§§ 10§22§§ 1§ 100§ 2026§";
|
||||
let l = layout(FontDb::global(), &vertical(text));
|
||||
assert_own_cells(&l, text, 0.25);
|
||||
// Tate-chu-yoko: one em of the column, centred on the column's centre line (x = 0), the next
|
||||
// character right after it.
|
||||
for block in ["10§", "22§", "100§"] {
|
||||
let first = glyph_of(&l, text, block);
|
||||
let digits = block.len() - '§'.len_utf8();
|
||||
let after = first + digits;
|
||||
let g = &l.glyphs[first];
|
||||
assert!((g.advance - 20.0).abs() < 1e-9, "{block}: {g:?}");
|
||||
assert!(l.glyphs[first + 1..after].iter().all(|d| d.advance == 0.0), "{block}");
|
||||
assert!((l.glyphs[after].origin.y - (g.origin.y + 20.0)).abs() < 1e-9, "{block}: the next character follows the block's em");
|
||||
let inks = (first..after).map(|i| ink(&l, i)).reduce(|a, b| a.union(b)).unwrap();
|
||||
assert!(inks.center().x.abs() < 1.0, "{block} centred on the column: {inks:?}");
|
||||
assert!((inks.center().y - (g.origin.y + 10.0)).abs() < 1.5, "{block} centred in its em: {inks:?} {g:?}");
|
||||
}
|
||||
// A single digit and longer numbers lie on their side, advancing by their own length.
|
||||
let one = glyph_of(&l, text, "1§");
|
||||
let r = ink(&l, one);
|
||||
assert!(r.width() > r.height() && l.glyphs[one].advance < 20.0, "1 on its side: {r:?}");
|
||||
let year = glyph_of(&l, text, "2026");
|
||||
let len: f64 = l.glyphs[year..year + 4].iter().map(|g| g.advance).sum();
|
||||
assert!(len > 30.0 && (l.glyphs[year + 4].origin.y - l.glyphs[year].origin.y - len).abs() < 1e-9, "2026 takes its length: {len}");
|
||||
// Half-width spaces take their own (narrow) width.
|
||||
for (i, g) in l.glyphs.iter().enumerate().filter(|(_, g)| text[g.byte..].starts_with(' ')) {
|
||||
assert!(g.advance > 2.0 && g.advance < 10.0, "space {i}: {g:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justified_columns_keep_tate_chu_yoko_blocks_whole_and_marks_centred() {
|
||||
let text = "§10§§";
|
||||
let mut t = vertical(text);
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 40.0, 200.0).to_path(0.1)) };
|
||||
t.para.justify = Justify::JustifyAll;
|
||||
let l = layout(FontDb::global(), &t);
|
||||
assert_eq!(l.lines.len(), 1);
|
||||
// 200 pt of column, four 20 pt cells: three gaps of 40 pt between them, none inside "10".
|
||||
let (one, zero) = (ink(&l, 1), ink(&l, 2));
|
||||
assert!((one.center().y - zero.center().y).abs() < 0.5 && zero.x0 > one.x1, "10 side by side: {one:?} {zero:?}");
|
||||
let marks: Vec<Rect> = [0, 3, 4].iter().map(|&i| ink(&l, i)).collect();
|
||||
assert!((marks[1].center().y - marks[0].center().y - 120.0).abs() < 1e-6, "{marks:?}");
|
||||
assert!((marks[2].center().y - marks[1].center().y - 60.0).abs() < 1e-6, "{marks:?}");
|
||||
assert!(marks.iter().all(|m| (m.center().x - marks[0].center().x).abs() < 1e-6), "upright marks stay on the centre line: {marks:?}");
|
||||
assert!(marks[2].y1 <= 200.0 + 1e-6, "inside the frame: {marks:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_spaces_upright_marks_without_moving_them_off_the_centre_line() {
|
||||
let plain = layout(FontDb::global(), &vertical("§§"));
|
||||
let tracked = layout(FontDb::global(), &vertical_styled("§§", CharStyle { size: 20.0, tracking: 200.0, ..CharStyle::default() }));
|
||||
for i in 0..2 {
|
||||
assert!((ink(&tracked, i).center().x - ink(&plain, i).center().x).abs() < 1e-6, "{:?} {:?}", ink(&tracked, i), ink(&plain, i));
|
||||
}
|
||||
let step = ink(&tracked, 1).center().y - ink(&tracked, 0).center().y;
|
||||
assert!((step - 24.0).abs() < 1e-6, "one em plus 200/1000 em of tracking: {step}");
|
||||
}
|
||||
|
||||
/// Issue #260 as reported, with a Japanese face of craft-fonts (whose own digits are proportional).
|
||||
#[test]
|
||||
fn japanese_numbers_keep_their_own_cells_down_the_column() {
|
||||
let Some(face) = crate::craft_fonts::japanese_ui_fonts(false).into_iter().next() else {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
|
||||
return;
|
||||
};
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let text = "はただ商店 秋のセール 10月22日まで";
|
||||
let t = vertical_styled(text, CharStyle { size: 28.0, font_family: face.family.into(), font_style: face.style.into(), ..CharStyle::default() });
|
||||
let l = layout(&db, &t);
|
||||
assert!(l.glyphs.iter().filter(|g| !text[g.byte..].starts_with(' ')).all(|g| g.gid != 0), "real glyphs, no tofu");
|
||||
assert_own_cells(&l, text, 0.5);
|
||||
for block in ["10月", "22日"] {
|
||||
let first = glyph_of(&l, text, block);
|
||||
let g = &l.glyphs[first];
|
||||
assert!((g.advance - 28.0).abs() < 1e-9 && l.glyphs[first + 1].advance == 0.0, "{block}: one em");
|
||||
assert!((l.glyphs[first + 2].origin.y - (g.origin.y + 28.0)).abs() < 1e-9, "{block}: the next character follows the em");
|
||||
let inks = ink(&l, first).union(ink(&l, first + 1));
|
||||
assert!(inks.center().x.abs() < 2.8, "{block} centred on the column: {inks:?}");
|
||||
}
|
||||
}
|
||||
@@ -93,14 +93,15 @@ impl CornerDrag {
|
||||
Some(Self { widgets, corner, start: p, began: false, radius: widgets.radii[corner], at: p })
|
||||
}
|
||||
|
||||
/// The start radius plus the pointer's travel along the corner's inward diagonal (in the
|
||||
/// shape's own units), from square to fully round.
|
||||
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
|
||||
/// corner's inward diagonal (in the shape's own units), from square to fully round.
|
||||
fn radius_at(&self, p: Point) -> f64 {
|
||||
let w = &self.widgets;
|
||||
let inv = w.xf.inverse();
|
||||
let d = inv * p - inv * self.start;
|
||||
let (_, (sx, sy)) = CORNERS[self.corner];
|
||||
(w.radii[self.corner] + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, w.w.min(w.h) / 2.0)
|
||||
let max = (w.w.abs().min(w.h.abs()) / 2.0).max(0.0);
|
||||
(w.radii[self.corner].min(max) + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, max)
|
||||
}
|
||||
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
|
||||
+133
-30
@@ -7,16 +7,20 @@
|
||||
//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its
|
||||
//! points (a key object on a point moves with it) and, once a point is clicked, its handles; and
|
||||
//! the points of selected gradient meshes and mesh envelopes and their handles ([`MeshEdit`]).
|
||||
//! Dragging a corner or an edge of area type's frame reshapes the type area (`text.reshapeArea`):
|
||||
//! the text reflows at its size.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
use vectorcraft_doc::{AnchorRef, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, Point, Rect};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{CornerDrag, over_widget};
|
||||
use crate::meshedit::MeshEdit;
|
||||
use crate::select::matrix_json;
|
||||
use crate::select::{is_area_type, matrix_json};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -58,6 +62,13 @@ enum State {
|
||||
},
|
||||
/// Dragging a mesh point or handle ([`MeshEdit`]).
|
||||
Mesh,
|
||||
/// Dragging anchors of area type's frame (a corner, or the two ends of an edge).
|
||||
TypeArea {
|
||||
id: NodeId,
|
||||
anchors: Vec<AnchorRef>,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct DirectSelectionTool {
|
||||
@@ -115,33 +126,45 @@ fn spine_anchor(cx: &ToolContext, (id, ai): (NodeId, usize)) -> Option<vectorcra
|
||||
}
|
||||
}
|
||||
|
||||
/// Anchor (or handle) of any selected/visible path under `p`.
|
||||
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64, selected_only: bool) -> Option<(NodeId, usize, usize)> {
|
||||
let ids: Vec<NodeId> = if selected_only {
|
||||
cx.selection.objects.clone()
|
||||
} else {
|
||||
let mut v = vec![];
|
||||
cx.doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) {
|
||||
v.push(n.id)
|
||||
}
|
||||
});
|
||||
v.reverse();
|
||||
v
|
||||
};
|
||||
for id in ids {
|
||||
if !cx.doc.is_editable(id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) {
|
||||
for (si, ai, a) in pd.anchors() {
|
||||
if a.p.distance(p) <= tol {
|
||||
return Some((id, si, ai));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The anchors Direct Selection edits on `n`, in document space: a path's, or area type's frame.
|
||||
fn editable_path(n: &Node) -> Option<Cow<'_, PathData>> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => Some(Cow::Borrowed(path)),
|
||||
NodeKind::Text(t) => t.area_frame().map(Cow::Owned),
|
||||
_ => None,
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The editable objects `f` accepts, topmost first.
|
||||
fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
cx.doc.walk(|n| {
|
||||
if f(n) {
|
||||
v.push(n.id)
|
||||
}
|
||||
});
|
||||
v.reverse();
|
||||
v.retain(|id| cx.doc.is_editable(*id));
|
||||
v
|
||||
}
|
||||
|
||||
/// Anchor of any visible path or area type frame under `p`, topmost first.
|
||||
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
|
||||
let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
|
||||
owners.into_iter().find_map(|id| {
|
||||
let pd = cx.doc.node(id).and_then(editable_path)?;
|
||||
pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, _)| (id, si, ai))
|
||||
})
|
||||
}
|
||||
|
||||
/// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors).
|
||||
fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<AnchorRef>)> {
|
||||
anchor_owners(cx, is_area_type).into_iter().find_map(|id| {
|
||||
let frame = cx.doc.node(id).and_then(editable_path)?;
|
||||
let (si, seg, _, _, d) = frame.nearest(p)?;
|
||||
let n = frame.subpaths.get(si)?.anchors.len();
|
||||
(d <= tol && n > 0).then(|| (id, vec![(si, seg % n), (si, (seg + 1) % n)]))
|
||||
})
|
||||
}
|
||||
|
||||
/// Direction handle of a partially selected anchor under `p`: (id, si, ai, is_out).
|
||||
@@ -223,7 +246,10 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::SpinePoint { id, anchor, from, start: p, began: false };
|
||||
return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
|
||||
}
|
||||
if let Some((id, si, ai)) = hit_anchor(cx, p, tol, false) {
|
||||
if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
|
||||
if cx.doc.node(id).is_some_and(is_area_type) {
|
||||
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
|
||||
}
|
||||
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
|
||||
self.state = State::MoveAnchors { start: p, began: false };
|
||||
if ev.mods.shift {
|
||||
@@ -234,6 +260,9 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
|
||||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||||
}
|
||||
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||||
// Clicking a segment/fill selects the whole leaf path (all anchors).
|
||||
self.state = State::MoveObject { start: p, began: false };
|
||||
@@ -277,6 +306,19 @@ impl Tool for DirectSelectionTool {
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::TypeArea { id, anchors, start, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Reshape Type Area".into()));
|
||||
}
|
||||
let d = move_delta(start, p, ev.mods.shift);
|
||||
out.push(Action::Preview("text.reshapeArea".into(), json!({"id": id.0, "anchors": anchors_json(&anchors), "dx": d.x, "dy": d.y})));
|
||||
self.state = State::TypeArea { id, anchors, start, began: true };
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
|
||||
vec![Action::Preview(
|
||||
"path.setHandle".into(),
|
||||
@@ -318,7 +360,10 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. }) => {
|
||||
(
|
||||
PointerKind::Up,
|
||||
State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. } | State::TypeArea { began, .. },
|
||||
) => {
|
||||
self.state = State::Idle;
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
@@ -356,6 +401,9 @@ impl Tool for DirectSelectionTool {
|
||||
_ => {
|
||||
let mut out = self.spine_overlays(cx);
|
||||
out.extend(self.mesh.overlays(cx));
|
||||
if !self.group {
|
||||
out.extend(frame_overlays(cx));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -368,7 +416,31 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame anchors of the selected area type, which Direct Selection drags.
|
||||
fn frame_overlays(cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
for id in &cx.selection.objects {
|
||||
let Some(frame) = cx.doc.node(*id).filter(|n| is_area_type(n)).and_then(editable_path) else { continue };
|
||||
let color = cx.doc.layer_color(*id);
|
||||
out.extend(frame.anchors().map(|(_, _, a)| Overlay::Anchor { p: a.p, color, filled: false, size: 5.0 }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
/// Press on area type's frame anchors (a corner, or an edge's two ends): select the text and
|
||||
/// get ready to drag them.
|
||||
fn press_type_area(&mut self, cx: &ToolContext, id: NodeId, anchors: Vec<AnchorRef>, p: Point, shift: bool) -> Vec<Action> {
|
||||
self.state = State::TypeArea { id, anchors, start: p, began: false };
|
||||
if cx.selection.contains(id) {
|
||||
vec![]
|
||||
} else if shift {
|
||||
vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))]
|
||||
} else {
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]
|
||||
}
|
||||
}
|
||||
|
||||
/// The clicked spine point, filled, with its handles.
|
||||
fn spine_overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) else { return vec![] };
|
||||
@@ -435,6 +507,37 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_frame_corner_or_edge_reshapes_area_type() {
|
||||
let (d, text) = doc_with_area_type();
|
||||
let p = paint();
|
||||
let s = Selection::default();
|
||||
let cx1 = cx(&d, &s, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
// The bottom-right corner of the 120 × 40 frame at (300, 300) selects the text...
|
||||
assert_eq!(
|
||||
t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 421.0, 339.0)),
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))]
|
||||
);
|
||||
// ...and drags that corner.
|
||||
let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 461.0, 399.0));
|
||||
assert_eq!(a[0], Action::Begin("Reshape Type Area".into()));
|
||||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 40.0, "dy": 60.0})));
|
||||
assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit]);
|
||||
// The top edge drags both its ends; a click alone changes nothing.
|
||||
let mut s = Selection::default();
|
||||
s.add(text);
|
||||
let cx2 = cx(&d, &s, &p);
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 350.0, 301.0)).is_empty());
|
||||
let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 350.0, 281.0));
|
||||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 0], [0, 1]], "dx": 0.0, "dy": -20.0})));
|
||||
t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 350.0, 281.0));
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)).is_empty());
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 300.0, 340.0)).is_empty());
|
||||
// The selected frame's corners show.
|
||||
assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_anchor_selects_it() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -477,6 +477,17 @@ pub(crate) mod testutil {
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// [`doc_with_rect`] plus 120 × 40 area type at (300, 300).
|
||||
pub fn doc_with_area_type() -> (Document, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut t = vectorcraft_doc::TextObject::point(Point::new(300.0, 300.0), "Some words", Default::default());
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 40.0)) };
|
||||
d.insert(Some(l), 1, Node::new(id, vectorcraft_doc::NodeKind::Text(Box::new(t)))).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
pub fn paint() -> PaintDefaults {
|
||||
PaintDefaults::default()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
||||
//! drag a live rectangle's corner widget to round its corners, double-click to enter isolation mode.
|
||||
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
|
||||
//! along the objects' axes.
|
||||
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
|
||||
//! scaling its type: the text reflows at its size.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{hit_test, marquee};
|
||||
@@ -28,6 +29,8 @@ enum State {
|
||||
Scaling {
|
||||
handle: Handle,
|
||||
bx: OrientedBox,
|
||||
/// Area type is selected: its frame resizes (`typeAreas`), its type keeps its size.
|
||||
areas: bool,
|
||||
},
|
||||
Rotating {
|
||||
center: Point,
|
||||
@@ -129,8 +132,12 @@ impl Tool for SelectionTool {
|
||||
{
|
||||
match box_hit(cx, &bx, p) {
|
||||
Some(BoxHit::Handle(handle)) => {
|
||||
self.state = State::Scaling { handle, bx };
|
||||
return vec![Action::Begin("Scale".into())];
|
||||
let is_area = |id: &NodeId| cx.doc.node(*id).is_some_and(is_area_type);
|
||||
let areas = cx.selection.objects.iter().any(is_area);
|
||||
// Only area type: the step resizes type areas; anything else scales.
|
||||
let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" };
|
||||
self.state = State::Scaling { handle, bx, areas };
|
||||
return vec![Action::Begin(label.into())];
|
||||
}
|
||||
Some(BoxHit::Rotate) => {
|
||||
self.state = State::Rotating { center: bx.center(), start: p };
|
||||
@@ -196,12 +203,16 @@ impl Tool for SelectionTool {
|
||||
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::Scaling { handle, bx }) => {
|
||||
(PointerKind::Drag, State::Scaling { handle, bx, areas }) => {
|
||||
// Scale in the box's own frame: along the objects' axes when it is rotated.
|
||||
let a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
|
||||
let nr = a.transform_rect_bbox(bx.rect);
|
||||
self.measure = Some((p, cx.size_label(nr.width(), nr.height())));
|
||||
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }))]
|
||||
let mut params = json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false });
|
||||
if areas {
|
||||
params["typeAreas"] = json!(true);
|
||||
}
|
||||
vec![Action::Preview("object.transform".into(), params)]
|
||||
}
|
||||
(PointerKind::Drag, State::Rotating { center, start, .. }) => {
|
||||
let (a, deg) = rotate_for_drag(center, start, p, m.shift);
|
||||
@@ -271,7 +282,7 @@ impl Tool for SelectionTool {
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
match self.state {
|
||||
State::Rotating { .. } => return Cursor::Rotate,
|
||||
State::Scaling { handle, bx } => return handle_cursor(handle, bx.angle),
|
||||
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
|
||||
State::Moving { began: true, .. } => return Cursor::Arrow,
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
_ => {}
|
||||
@@ -298,6 +309,12 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `n` area type (text in a frame) whose frame the tools reshape? Type in perspective isn't:
|
||||
/// it transforms whole.
|
||||
pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool {
|
||||
n.perspective.is_none() && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }))
|
||||
}
|
||||
|
||||
/// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the
|
||||
/// handle that sits where `h` appears on screen, in 45° steps.
|
||||
fn handle_cursor(h: Handle, angle: f64) -> Cursor {
|
||||
@@ -420,6 +437,36 @@ mod tests {
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_drag_on_area_type_resizes_its_frame() {
|
||||
let (d, text) = doc_with_area_type();
|
||||
let p = paint();
|
||||
let mut s = Selection::default();
|
||||
s.add(text);
|
||||
let cx1 = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
// Bottom-right handle of the 120 × 40 frame at (300, 300).
|
||||
assert_eq!(t.pointer(&cx1, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Resize Type Area".into())]);
|
||||
let a = t.pointer(&cx1, &ev(PointerKind::Drag, 460.0, 400.0));
|
||||
assert!(matches!(&a[0], Action::Preview(c, v) if c == "object.transform" && v["typeAreas"] == true), "{a:?}");
|
||||
assert_eq!(t.pointer(&cx1, &ev(PointerKind::Up, 460.0, 400.0)), vec![Action::Commit]);
|
||||
// With another object the step is a scale, still resizing the type area.
|
||||
let rect = d.layers[0].children().unwrap()[0].id;
|
||||
s.add(rect);
|
||||
let cx2 = cx(&d, &s, &p);
|
||||
assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Scale".into())]);
|
||||
let a = t.pointer(&cx2, &ev(PointerKind::Drag, 460.0, 400.0));
|
||||
assert!(matches!(&a[0], Action::Preview(_, v) if v["typeAreas"] == true), "{a:?}");
|
||||
t.pointer(&cx2, &ev(PointerKind::Up, 460.0, 400.0));
|
||||
// Without area type the drag scales as before.
|
||||
let mut s = Selection::default();
|
||||
s.add(rect);
|
||||
let cx3 = cx(&d, &s, &p);
|
||||
t.pointer(&cx3, &ev(PointerKind::Down, 200.0, 200.0));
|
||||
let a = t.pointer(&cx3, &ev(PointerKind::Drag, 300.0, 300.0));
|
||||
assert!(matches!(&a[0], Action::Preview(_, v) if v.get("typeAreas").is_none()), "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turned_box_handles_turn_and_scale_along_the_object() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
|
||||
+23
-10
@@ -235,19 +235,26 @@ impl TypeTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_up(&mut self, cx: &ToolContext, start: Point) -> Vec<Action> {
|
||||
/// Start editing the type under `p` with the caret there → the actions (ending the previous
|
||||
/// edit, selecting the type); None when no type is under `p`.
|
||||
fn edit_at(&mut self, cx: &ToolContext, p: Point) -> Option<Vec<Action>> {
|
||||
let h = hit_test(cx.doc, p, cx.hit_options())?;
|
||||
let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind) else { return None };
|
||||
let lay = self.layout(t);
|
||||
let byte = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * p);
|
||||
let mut out = self.finish(cx);
|
||||
self.start_editing(h.leaf, byte);
|
||||
self.clicks = (Some(p), 1);
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]})));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn on_up(&mut self, cx: &ToolContext, start: Point) -> Vec<Action> {
|
||||
// Click into existing text: place the caret.
|
||||
if let Some(h) = hit_test(cx.doc, start, cx.hit_options())
|
||||
&& let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind)
|
||||
{
|
||||
let lay = self.layout(t);
|
||||
let byte = vectorcraft_text::hit_byte(&lay, t.xf.inverse() * start);
|
||||
self.start_editing(h.leaf, byte);
|
||||
self.clicks = (Some(start), 1);
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]})));
|
||||
if let Some(out) = self.edit_at(cx, start) {
|
||||
return out;
|
||||
}
|
||||
let mut out = self.finish(cx);
|
||||
let drag = self.drag.take();
|
||||
// Area Type / Type on a Path: click a path.
|
||||
if self.mode != Mode::Type && drag.is_none() {
|
||||
@@ -264,7 +271,7 @@ impl TypeTool {
|
||||
}
|
||||
let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
|
||||
let mut params = match area {
|
||||
Some(r) => json!({"x": r.x0, "y": r.y0 + 12.0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
|
||||
Some(r) => json!({"x": r.x0, "y": r.y0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
|
||||
None => json!({"x": start.x, "y": start.y, "text": ""}),
|
||||
};
|
||||
params["vertical"] = json!(self.vertical);
|
||||
@@ -357,6 +364,12 @@ impl Tool for TypeTool {
|
||||
return out;
|
||||
}
|
||||
self.clicks = (None, 0);
|
||||
// A press on other type edits it from there, so a drag selects its text at once,
|
||||
// as it does in the type being edited.
|
||||
if let Some(out) = self.edit_at(cx, ev.pos) {
|
||||
self.selecting = true;
|
||||
return out;
|
||||
}
|
||||
self.press = Some(ev.pos);
|
||||
self.drag = None;
|
||||
vec![]
|
||||
|
||||
@@ -176,6 +176,33 @@ fn drag_selects_and_overlay_highlights() {
|
||||
assert_eq!((opts["start"].as_u64(), opts["end"].as_u64()), (Some(7), Some(9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_across_type_not_being_edited_selects_its_text() {
|
||||
let (d, id) = doc_with_text("Select me please");
|
||||
let mut tool = TypeTool::new("type");
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &sel, &p);
|
||||
let t = TypeTool::text(&c, id).unwrap().clone();
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), &t);
|
||||
let pt = |b: usize| {
|
||||
let (a, bb) = vectorcraft_text::caret_position(&lay, b);
|
||||
t.xf * a.midpoint(bb)
|
||||
};
|
||||
let (a, b) = (pt(7), pt(9));
|
||||
// The press starts editing the type and the drag selects, in one gesture.
|
||||
let out = tool.pointer(&c, &PointerEvent::new(PointerKind::Down, a.x + 0.1, a.y));
|
||||
assert!(out.contains(&Action::Exec("select.set".into(), json!({"ids": [id.0]}))));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, b.x + 0.1, b.y));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, b.x + 0.1, b.y));
|
||||
assert_eq!(tool.editing, Some(id));
|
||||
assert_eq!(tool.sel(), (7, 9));
|
||||
// A plain click still just places the caret.
|
||||
let mut tool = TypeTool::new("type");
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, a.x + 0.1, a.y));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, a.x + 0.1, a.y));
|
||||
assert_eq!((tool.editing, tool.sel()), (Some(id), (7, 7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_and_styled_paste() {
|
||||
let (d, _, mut tool) = editing("abc");
|
||||
|
||||
@@ -44,6 +44,27 @@ enum Op {
|
||||
Corners(Handle, DistortMode),
|
||||
}
|
||||
|
||||
impl Op {
|
||||
/// The bounding-box handle being dragged, if any.
|
||||
fn handle(self) -> Option<Handle> {
|
||||
match self {
|
||||
Op::Scale(h) | Op::Shear(h) | Op::Corners(h, _) => Some(h),
|
||||
Op::Move | Op::Rotate => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The undo step's name.
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Op::Move => "Move",
|
||||
Op::Scale(_) => "Scale",
|
||||
Op::Shear(_) => "Shear",
|
||||
Op::Rotate => "Rotate",
|
||||
Op::Corners(..) => "Distort",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Drag {
|
||||
op: Op,
|
||||
@@ -125,22 +146,28 @@ pub fn distort_quad(r: Rect, h: Handle, d: Vec2, mode: DistortMode) -> [Point; 4
|
||||
}
|
||||
|
||||
impl FreeTransformTool {
|
||||
/// What dragging handle `h` with modifiers `m` does: Cmd on a corner distorts it freely,
|
||||
/// Cmd+Alt+Shift in perspective, Cmd on a side shears; otherwise the tool's mode.
|
||||
fn handle_op(&self, h: Handle, m: Mods) -> Op {
|
||||
let mode = if h.is_corner() && m.cmd && m.alt && m.shift {
|
||||
DistortMode::Perspective
|
||||
} else if h.is_corner() && m.cmd {
|
||||
DistortMode::Distort
|
||||
} else {
|
||||
self.mode
|
||||
};
|
||||
match mode {
|
||||
DistortMode::Free if !h.is_corner() && m.cmd => Op::Shear(h),
|
||||
DistortMode::Free => Op::Scale(h),
|
||||
DistortMode::Perspective if !h.is_corner() => Op::Scale(h),
|
||||
mode => Op::Corners(h, mode),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify(&self, cx: &ToolContext, r: Rect, p: Point, m: Mods) -> Option<Op> {
|
||||
let tol = cx.tol(5.0);
|
||||
if let Some(h) = hit_handle(r, p, tol) {
|
||||
let mode = if h.is_corner() && m.cmd && m.alt && m.shift {
|
||||
DistortMode::Perspective
|
||||
} else if h.is_corner() && m.cmd {
|
||||
DistortMode::Distort
|
||||
} else {
|
||||
self.mode
|
||||
};
|
||||
return Some(match mode {
|
||||
DistortMode::Free if !h.is_corner() && m.cmd => Op::Shear(h),
|
||||
DistortMode::Free => Op::Scale(h),
|
||||
DistortMode::Perspective if !h.is_corner() => Op::Scale(h),
|
||||
mode => Op::Corners(h, mode),
|
||||
});
|
||||
return Some(self.handle_op(h, m));
|
||||
}
|
||||
if self.mode == DistortMode::Free && in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() {
|
||||
return Some(Op::Rotate);
|
||||
@@ -205,19 +232,24 @@ impl Tool for FreeTransformTool {
|
||||
PointerKind::Drag => {
|
||||
let Some(mut d) = self.drag else { return vec![] };
|
||||
let mut out = vec![];
|
||||
// A handle drag follows the modifiers held now: the gesture is to start dragging
|
||||
// and then hold Cmd (a Cmd press on the canvas is the temporary Selection tool).
|
||||
if let Some(h) = d.op.handle() {
|
||||
let op = self.handle_op(h, ev.mods);
|
||||
if d.began && op.label() != d.op.label() {
|
||||
// Another operation: it gets an undo step of its own.
|
||||
out.push(Action::Cancel);
|
||||
out.push(Action::Begin(op.label().into()));
|
||||
}
|
||||
d.op = op;
|
||||
}
|
||||
if !d.began {
|
||||
if p.distance(d.start) < cx.tol(2.0) {
|
||||
self.drag = Some(d);
|
||||
return out;
|
||||
}
|
||||
d.began = true;
|
||||
let label = match d.op {
|
||||
Op::Move => "Move",
|
||||
Op::Scale(_) => "Scale",
|
||||
Op::Shear(_) => "Shear",
|
||||
Op::Rotate => "Rotate",
|
||||
Op::Corners(..) => "Distort",
|
||||
};
|
||||
out.push(Action::Begin(label.into()));
|
||||
out.push(Action::Begin(d.op.label().into()));
|
||||
}
|
||||
self.drag = Some(d);
|
||||
out.push(self.preview(cx, d, p, ev.mods));
|
||||
@@ -353,6 +385,37 @@ mod tests {
|
||||
assert!((m * Point::new(100.0, 100.0)).distance(Point::new(120.0, 100.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_pressed_during_a_handle_drag_change_what_it_does() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = FreeTransformTool::default();
|
||||
let cmd = Mods { cmd: true, ..Default::default() };
|
||||
let perspective = Mods { cmd: true, alt: true, shift: true, ..Default::default() };
|
||||
// Start dragging the bottom-right corner, then hold Cmd: Free Distort, a step of its own.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 210.0, 210.0));
|
||||
assert_eq!(a[0], Action::Begin("Scale".into()));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0).with_mods(cmd));
|
||||
let corners = json!({"corners": [[100.0, 100.0], [200.0, 100.0], [220.0, 230.0], [100.0, 200.0]], "from": [100.0, 100.0, 200.0, 200.0]});
|
||||
assert_eq!(a, vec![Action::Cancel, Action::Begin("Distort".into()), Action::Preview("object.distort".into(), corners)]);
|
||||
// Cmd+Alt+Shift: Perspective Distort, still the same step.
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0).with_mods(perspective));
|
||||
assert!(matches!(&a[..], [Action::Preview(c, _)] if c == "object.distort"), "{a:?}");
|
||||
// Letting go of the keys scales again.
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0));
|
||||
assert_eq!(a[..2], [Action::Cancel, Action::Begin("Scale".into())]);
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 220.0, 230.0)), vec![Action::Commit]);
|
||||
// Cmd held during a side handle's drag shears.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 155.0, 100.0));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(cmd));
|
||||
assert_eq!(a[..2], [Action::Cancel, Action::Begin("Shear".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_modes_emit_corners() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
+260
-18
@@ -92,6 +92,11 @@ fn temp_tool_id() -> egui::Id {
|
||||
egui::Id::new("canvas-temp-tool")
|
||||
}
|
||||
|
||||
/// Selection, Direct Selection and Group Selection: the tools Cmd switches to for a drag.
|
||||
pub(crate) fn is_selection_tool(id: &str) -> bool {
|
||||
matches!(id, "selection" | "directSelection" | "groupSelection")
|
||||
}
|
||||
|
||||
/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch
|
||||
/// input, else the last one seen since the press (`pressed`: a new press, whose mouse has none
|
||||
/// until a pen reports it). A mouse presses fully (1).
|
||||
@@ -120,12 +125,14 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) {
|
||||
}
|
||||
return;
|
||||
};
|
||||
let current = app.view().map_or(0, |v| v.artboard);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let target = match how {
|
||||
"view.fitAll" => {
|
||||
st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect))
|
||||
}
|
||||
_ => st.doc.artboards.first().map(|a| a.rect),
|
||||
// The navigator's artboard (the first if it has gone).
|
||||
_ => st.doc.artboards.get(current).or(st.doc.artboards.first()).map(|a| a.rect),
|
||||
};
|
||||
let Some(target) = target else { return };
|
||||
let Some(v) = app.view_mut() else { return };
|
||||
@@ -342,6 +349,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
|
||||
if app.ui.view.rulers && app.ui.screen_mode < 3 {
|
||||
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t);
|
||||
ruler_guides(app, ui, full, rect, &xf, &t);
|
||||
}
|
||||
if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 {
|
||||
task_bar(app, ui, &xf);
|
||||
@@ -351,12 +359,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let space = ui.input(|i| i.key_down(egui::Key::Space));
|
||||
let panning = matches!(ui.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Pan { .. }));
|
||||
let zooming = if space { m.command } else { app.session.tool_id() == "zoom" };
|
||||
let cur = if panning {
|
||||
egui::CursorIcon::Grabbing
|
||||
} else if zooming {
|
||||
if m.alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn }
|
||||
} else if space || app.session.tool_id() == "hand" {
|
||||
egui::CursorIcon::Grab
|
||||
} else if app.session.tool_id() == "zoom" {
|
||||
if m.alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn }
|
||||
} else if let Some(p) = app.hover_doc {
|
||||
let c = app.session.cursor(p, mods(m, space), view_info);
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor")));
|
||||
@@ -442,7 +451,10 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
}
|
||||
|
||||
let tool = app.session.tool_id();
|
||||
let pan_mode = space || tool == "hand";
|
||||
// Held, Space is the Hand tool for the moment, and Cmd+Space the Zoom tool (with Alt, zooming
|
||||
// out), whatever the tool.
|
||||
let zoom_mode = if space { m.command } else { tool == "zoom" };
|
||||
let pan_mode = if space { !m.command } else { tool == "hand" };
|
||||
let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. }));
|
||||
// egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a
|
||||
// press on the canvas itself reaches the tools, else it would land at the canvas's centre.
|
||||
@@ -453,19 +465,20 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
{
|
||||
ui.ctx().memory_mut(|mem| mem.stop_text_input());
|
||||
app.ui.flyout = None;
|
||||
let d = if pan_mode {
|
||||
Drag::Pan { start: p, center: v.center, middle: false }
|
||||
} else if tool == "zoom" {
|
||||
let d = if zoom_mode {
|
||||
Drag::ZoomBox { start: p }
|
||||
} else if pan_mode {
|
||||
Drag::Pan { start: p, center: v.center, middle: false }
|
||||
} else if tool == "rotateView" {
|
||||
let c = rect.center();
|
||||
Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation }
|
||||
} else {
|
||||
let selection_family = matches!(tool, "selection" | "directSelection" | "groupSelection");
|
||||
let mut kind = Drag::Tool;
|
||||
if m.command && !selection_family {
|
||||
// Cmd with another tool: drag with the selection tool used last.
|
||||
if m.command && !is_selection_tool(tool) {
|
||||
ui.data_mut(|d| d.insert_temp(temp_tool_id(), tool.to_string()));
|
||||
app.select_tool("selection");
|
||||
let last = app.ui.last_selection_tool.clone();
|
||||
app.select_tool(if is_selection_tool(&last) { &last } else { "selection" });
|
||||
kind = Drag::TempSelect;
|
||||
}
|
||||
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) };
|
||||
@@ -563,7 +576,15 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
&& let Some(p) = hover
|
||||
{
|
||||
let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
|
||||
let before = app.session.tool_id().to_string();
|
||||
dispatch(app, &ev, view);
|
||||
// A double-click on type switched a selection tool to the Type tool: the caret goes where
|
||||
// the type was clicked, so typing edits it at once.
|
||||
if before != "type" && app.session.tool_id() == "type" {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
dispatch(app, &PointerEvent { kind, ..ev }, view);
|
||||
}
|
||||
}
|
||||
}
|
||||
if drag.is_some() || pointer.is_moving() {
|
||||
ui.ctx().request_repaint();
|
||||
@@ -680,11 +701,39 @@ pub(crate) fn ruler_label(v: f64, step: f64) -> String {
|
||||
}
|
||||
|
||||
/// The rulers, numbered in `unit` (the General unit).
|
||||
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit, t: &Tokens) {
|
||||
let p = ui.painter();
|
||||
/// The top ruler, the left ruler and the box where they meet.
|
||||
fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
|
||||
let top = egui::Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER));
|
||||
let left = egui::Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom()));
|
||||
let corner = egui::Rect::from_min_size(full.min, vec2(RULER, RULER));
|
||||
[top, left, egui::Rect::from_min_size(full.min, vec2(RULER, RULER))]
|
||||
}
|
||||
|
||||
/// A drag from a ruler onto the canvas makes a guide where the button is released: a horizontal
|
||||
/// one from the top ruler, a vertical one from the left ruler. Released anywhere else, it makes
|
||||
/// none.
|
||||
fn ruler_guides(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf, t: &Tokens) {
|
||||
let [top, left, _] = ruler_rects(full);
|
||||
for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] {
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::drag());
|
||||
let Some(p) = resp.interact_pointer_pos().filter(|p| canvas.contains(*p)) else { continue };
|
||||
if resp.drag_stopped() {
|
||||
let d = xf.to_doc(p);
|
||||
// The new guide shows even if guides were hidden.
|
||||
app.ui.view.guides = true;
|
||||
if let Err(e) = app.run("guide.add", json!({ "vertical": vertical, "pos": if vertical { d.x } else { d.y } })) {
|
||||
app.status(e);
|
||||
}
|
||||
} else if resp.dragged() {
|
||||
let line =
|
||||
if vertical { [pos2(p.x, canvas.top()), pos2(p.x, canvas.bottom())] } else { [pos2(canvas.left(), p.y), pos2(canvas.right(), p.y)] };
|
||||
ui.painter_at(canvas).line_segment(line, Stroke::new(1.0, t.guide));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit, t: &Tokens) {
|
||||
let p = ui.painter();
|
||||
let [top, left, corner] = ruler_rects(full);
|
||||
for r in [top, left, corner] {
|
||||
p.rect_filled(r, 0.0, t.ruler);
|
||||
}
|
||||
@@ -798,11 +847,18 @@ fn c32(rgb: [u8; 3]) -> Color32 {
|
||||
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
|
||||
/// Outline of a node for highlighting (paths, compound children, text/image bounds).
|
||||
/// Outline of a node for highlighting (paths, compound children, area type frames, other
|
||||
/// text/image bounds).
|
||||
fn node_outline(n: &Node) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
walk_drawn(n, &mut |c| match &c.kind {
|
||||
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
|
||||
// Area type shows its frame: the type area Direct Selection reshapes.
|
||||
NodeKind::Text(t) if c.perspective.is_none() && matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => {
|
||||
if let Some(frame) = t.area_frame() {
|
||||
bp.extend(frame.to_bezpath());
|
||||
}
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
|
||||
if let Some(b) = c.geometric_bounds() {
|
||||
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
|
||||
@@ -870,6 +926,14 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
|
||||
return;
|
||||
}
|
||||
let Some(d) = resp.dnd_release_payload::<widgets::PanelDrag>() else { return };
|
||||
// A symbol from the Symbols panel: an instance centred where it is dropped, on art or not.
|
||||
if let widgets::PanelDrag::Symbol(name) = &*d {
|
||||
let at = xf.to_doc(pos);
|
||||
if let Err(e) = app.run("symbol.place", json!({"name": name, "x": at.x, "y": at.y})) {
|
||||
app.status(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return };
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let (cmd, params) = match &*d {
|
||||
@@ -894,8 +958,9 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
|
||||
let add = ui.input(|i| i.modifiers.alt);
|
||||
("graphicStyle.apply", json!({"name": name, "ids": [hit.top_object(st.isolation).0], "add": add}))
|
||||
}
|
||||
// Art dragged back onto the canvas: its move was already dropped.
|
||||
widgets::PanelDrag::Art(_) => return,
|
||||
// Art dragged back onto the canvas: its move was already dropped. (A symbol was placed
|
||||
// above.)
|
||||
widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) => return,
|
||||
};
|
||||
if let Err(e) = app.run(cmd, params) {
|
||||
app.status(e);
|
||||
@@ -1131,8 +1196,10 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
{
|
||||
anchor_square(p, xf.to_screen(b.center()), color, true, 4.0);
|
||||
}
|
||||
// Text: baseline marker.
|
||||
if let NodeKind::Text(tx) = &n.kind {
|
||||
// Text: baseline marker (area type shows its frame instead, which may not be a rectangle).
|
||||
if let NodeKind::Text(tx) = &n.kind
|
||||
&& !matches!(tx.kind, vectorcraft_doc::TextKind::Area { .. })
|
||||
{
|
||||
let o = xf.to_screen(tx.xf * Point::ZERO);
|
||||
let b = n.geometric_bounds().unwrap_or_default();
|
||||
let e = xf.to_screen(Point::new(b.x1, (tx.xf * Point::ZERO).y));
|
||||
@@ -1475,6 +1542,181 @@ mod tests {
|
||||
assert_eq!(selected(&app), vec![vectorcraft_doc::NodeId(id)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.ui.view.rulers = true;
|
||||
app.ui.view.guides = false;
|
||||
// A drawing tool: the drag must not draw.
|
||||
app.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let rect = app.canvas_rect.unwrap();
|
||||
let xf = Xf::new(rect, app.view().unwrap());
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||||
frame(app, &ctx, vec![button(from, true)]);
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||||
frame(app, &ctx, vec![button(to, false)]);
|
||||
};
|
||||
let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||||
// From the top ruler: a horizontal guide where the button was released.
|
||||
let to = pos2(rect.center().x, rect.top() + 120.0);
|
||||
drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), to);
|
||||
assert_eq!(guides(&app), [(false, xf.to_doc(to).y)]);
|
||||
assert!(app.ui.view.guides, "the new guide shows");
|
||||
// From the left ruler: a vertical one.
|
||||
let to2 = pos2(rect.left() + 200.0, rect.center().y);
|
||||
drag(&mut app, pos2(rect.left() - RULER / 2.0, rect.center().y), to2);
|
||||
assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(to2).x)]);
|
||||
// Released back on the ruler: no guide.
|
||||
drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), pos2(rect.center().x + 40.0, rect.top() - 4.0));
|
||||
assert_eq!(guides(&app).len(), 2);
|
||||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the tool drew nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_with_another_tool_drags_with_the_selection_tool_used_last() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let cmd_frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let events = std::iter::once(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)).chain(events).collect();
|
||||
let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let button =
|
||||
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::COMMAND };
|
||||
// A Cmd drag from `a` to `b` with the Pen tool; → the tool that did it.
|
||||
let cmd_drag = |app: &mut VectorcraftApp, a: Pos2, b: Pos2| {
|
||||
app.select_tool("pen");
|
||||
cmd_frame(app, vec![egui::Event::PointerMoved(a), button(a, true)]);
|
||||
let used = app.session.tool_id().to_string();
|
||||
cmd_frame(app, vec![egui::Event::PointerMoved(b)]);
|
||||
cmd_frame(app, vec![button(b, false)]);
|
||||
assert_eq!(app.session.tool_id(), "pen", "back to the Pen tool");
|
||||
used
|
||||
};
|
||||
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only.
|
||||
app.select_tool("directSelection");
|
||||
let corner = xf.to_screen(Point::new(200.0, 150.0));
|
||||
assert_eq!(cmd_drag(&mut app, corner, corner + vec2(20.0, 20.0)), "directSelection");
|
||||
let b = bounds(&app);
|
||||
assert_eq!((b.x0, b.y0), (100.0, 100.0));
|
||||
assert!((b.x1 - xf.to_doc(corner + vec2(20.0, 20.0)).x).abs() < 1e-6, "{b:?}");
|
||||
// Group Selection, then Selection: each is the one used.
|
||||
app.select_tool("groupSelection");
|
||||
let inside = xf.to_screen(Point::new(130.0, 120.0));
|
||||
assert_eq!(cmd_drag(&mut app, inside, inside), "groupSelection");
|
||||
app.select_tool("selection");
|
||||
assert_eq!(cmd_drag(&mut app, inside, inside), "selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_space_zooms_and_space_pans_whatever_the_tool() {
|
||||
use egui::{Event, Modifiers};
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.select_tool("rectangle");
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let at = app.canvas_rect.unwrap().center();
|
||||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||||
// A drag from `at` to `to` with Space and `held` down.
|
||||
let drag = |app: &mut VectorcraftApp, held: Modifiers, to: Pos2| {
|
||||
frame(app, &ctx, vec![Event::ModifiersChanged(held), space(true, held), Event::PointerMoved(at)]);
|
||||
frame(app, &ctx, vec![button(at, true, held)]);
|
||||
frame(app, &ctx, vec![Event::PointerMoved(to)]);
|
||||
frame(app, &ctx, vec![button(to, false, held)]);
|
||||
frame(app, &ctx, vec![space(false, held), Event::ModifiersChanged(Modifiers::NONE)]);
|
||||
};
|
||||
let z0 = app.view().unwrap().zoom;
|
||||
drag(&mut app, Modifiers::COMMAND, at);
|
||||
let z1 = app.view().unwrap().zoom;
|
||||
assert_eq!(z1, crate::state::next_zoom(z0, true), "Cmd+Space click zooms in");
|
||||
drag(&mut app, Modifiers::COMMAND | Modifiers::ALT, at);
|
||||
assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(z1, false), "Cmd+Alt+Space click zooms out");
|
||||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing");
|
||||
// Space alone pans, with the Zoom tool too.
|
||||
app.select_tool("zoom");
|
||||
let before = *app.view().unwrap();
|
||||
drag(&mut app, Modifiers::NONE, at + vec2(30.0, 0.0));
|
||||
let after = *app.view().unwrap();
|
||||
assert_eq!(after.zoom, before.zoom);
|
||||
assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_type_with_the_selection_tool_puts_the_caret_there() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
app.select_tool("selection");
|
||||
let ctx = egui::Context::default();
|
||||
let timed = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let mut out =
|
||||
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
timed(&mut app, 0.0, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let text = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
// Double-click the middle of the text.
|
||||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
let at = xf.to_screen(b.center());
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]);
|
||||
timed(&mut app, 1.1, vec![]);
|
||||
// The Type tool edits the text, with the caret inside it, where it was clicked: typing goes
|
||||
// into the text, not to tool shortcuts (X would swap fill and stroke).
|
||||
assert_eq!(app.session.tool_id(), "type");
|
||||
assert!(app.session.tool_wants_text());
|
||||
let o = app.session.tool_options();
|
||||
assert_eq!(o["editing"], json!(id));
|
||||
let caret = o["caret"].as_u64().unwrap();
|
||||
assert!((1..11).contains(&caret), "caret {caret}");
|
||||
app.session.tool_text("X", app.view_info()).unwrap();
|
||||
let t = text(&app);
|
||||
assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_dropped_on_the_canvas_is_placed_there() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap();
|
||||
let name = app.session.execute("symbol.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
// Released over empty canvas: an instance centred there.
|
||||
let at = xf.to_screen(Point::new(250.0, 180.0));
|
||||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Symbol(name.clone()));
|
||||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||||
let st = app.session.active().unwrap();
|
||||
let placed = st.selection.objects.first().copied().unwrap();
|
||||
let n = st.doc.node(placed).unwrap();
|
||||
assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name));
|
||||
let c = n.geometric_bounds().unwrap().center();
|
||||
assert!((c - xf.to_doc(at)).hypot() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_drag_pans_the_view_with_any_tool() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -372,13 +372,25 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
},
|
||||
);
|
||||
ui.separator();
|
||||
// The artboard navigator: first, previous, the current artboard's number, next, last.
|
||||
let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0);
|
||||
for icon in ["chevrons-left", "chevron-left"] {
|
||||
widgets::icon_button(ui, icon, "", false, 18.0);
|
||||
let cur = app.view().map_or(0, |v| v.artboard).min(nab.saturating_sub(1));
|
||||
let mut go = None;
|
||||
for (icon, to) in [("chevrons-left", "first"), ("chevron-left", "previous")] {
|
||||
if widgets::icon_button_enabled(ui, icon, "", false, cur > 0, 18.0).clicked() {
|
||||
go = Some(to);
|
||||
}
|
||||
}
|
||||
ui.label(egui::RichText::new(if nab > 0 { "1" } else { "–" }).size(11.5));
|
||||
for icon in ["chevron-right", "chevrons-right"] {
|
||||
widgets::icon_button(ui, icon, "", false, 18.0);
|
||||
ui.label(egui::RichText::new(if nab > 0 { (cur + 1).to_string() } else { "–".into() }).size(11.5));
|
||||
for (icon, to) in [("chevron-right", "next"), ("chevrons-right", "last")] {
|
||||
if widgets::icon_button_enabled(ui, icon, "", false, cur + 1 < nab, 18.0).clicked() {
|
||||
go = Some(to);
|
||||
}
|
||||
}
|
||||
if let Some(to) = go
|
||||
&& let Err(e) = app.run("view.goToArtboard", json!({ "index": to }))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
ui.separator();
|
||||
let tool = vectorcraft_tools::tool_info(app.session.tool_id())
|
||||
@@ -667,6 +679,62 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One headless frame of the status bar; returns the artboard navigator's buttons, left to
|
||||
/// right: first, previous, next, last.
|
||||
fn navigator(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let size = egui::vec2(18.0, 18.0);
|
||||
let mut r: Vec<egui::Rect> =
|
||||
ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect());
|
||||
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_artboard_navigator_goes_to_artboards_and_fitting_shows_the_current_one() {
|
||||
use vectorcraft_geom::Point;
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
// Three 200 × 100 artboards in a row, centred at x = 100, 320 and 540.
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
app.run("artboard.new", json!({})).unwrap();
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 260.0)));
|
||||
let ctx = egui::Context::default();
|
||||
let buttons = navigator(&mut app, &ctx, vec![]);
|
||||
assert_eq!(buttons.len(), 4);
|
||||
let click = |app: &mut crate::VectorcraftApp, r: egui::Rect| {
|
||||
let b = |pressed| egui::Event::PointerButton {
|
||||
pos: r.center(),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
navigator(app, &ctx, vec![egui::Event::PointerMoved(r.center())]);
|
||||
navigator(app, &ctx, vec![b(true)]);
|
||||
navigator(app, &ctx, vec![b(false)]);
|
||||
};
|
||||
let at = |app: &crate::VectorcraftApp| (app.view().unwrap().artboard, app.view().unwrap().center);
|
||||
click(&mut app, buttons[2]);
|
||||
assert_eq!(at(&app), (1, Point::new(320.0, 50.0)), "next");
|
||||
click(&mut app, buttons[3]);
|
||||
assert_eq!(at(&app), (2, Point::new(540.0, 50.0)), "last");
|
||||
click(&mut app, buttons[1]);
|
||||
assert_eq!(at(&app), (1, Point::new(320.0, 50.0)), "previous");
|
||||
// Fit Artboard in Window and Actual Size show the navigator's artboard.
|
||||
app.run("view.setZoom", json!({"zoom": 300, "center": [0, 0]})).unwrap();
|
||||
app.run("view.fitArtboard", json!({})).unwrap();
|
||||
assert_eq!(at(&app), (1, Point::new(320.0, 50.0)));
|
||||
app.run("view.actualSize", json!({})).unwrap();
|
||||
assert_eq!((at(&app).1, app.view().unwrap().zoom), (Point::new(320.0, 50.0), 1.0));
|
||||
click(&mut app, buttons[0]);
|
||||
assert_eq!(at(&app), (0, Point::new(100.0, 50.0)), "first");
|
||||
// The command also takes an index, clamped to the last artboard.
|
||||
assert_eq!(app.run("view.goToArtboard", json!({"index": 9})), Ok(json!({"index": 2})));
|
||||
assert!(app.run("view.goToArtboard", json!({"index": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tab_title_names_the_opacity_mask_while_it_is_edited() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -26,7 +26,7 @@ fn lengths(command: &str) -> &'static [&'static str] {
|
||||
"artboard.rearrange" => &["spacing"],
|
||||
"perspective.grid.set" => &["cell", "distance"],
|
||||
"object.repeat.options" => &["radius", "hSpacing", "vSpacing"],
|
||||
"text.areaOptions" => &["gutter", "inset", "firstBaselineMin"],
|
||||
"text.areaOptions" => &["width", "height", "gutter", "inset", "firstBaselineMin"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +274,21 @@ fn a_form_dialog_is_as_wide_as_its_fields_not_the_window() {
|
||||
assert!(narrow.width() <= 360.0, "{:.0} wide in a 360-point window", narrow.width());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_parameter_dialogs_are_compact() {
|
||||
// #292: Object › Path › Simplify (and its neighbours) spanned the whole window.
|
||||
for id in ["object.path.simplify", "object.path.offsetPath", "object.move", "object.rotate", "object.path.splitIntoGrid", "path.average"] {
|
||||
let mut app = app();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
crate::menus::invoke(&mut app, id, json!({}));
|
||||
assert!(app.ui.dialog.is_some(), "{id} opened no dialog");
|
||||
let wide = dialog_rect(&mut app, 1600.0);
|
||||
eprintln!("{id}: {:.0} × {:.0} in a 1600-point window", wide.width(), wide.height());
|
||||
assert!(wide.width() < 420.0, "{id} is {:.0} wide in a 1600-point window", wide.width());
|
||||
}
|
||||
}
|
||||
|
||||
/// A list mixing built-in labels with names translates only the built-in entries: names that
|
||||
/// happen to be catalog keys ("Black", "Regular") are shown as they are.
|
||||
#[test]
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
# common Czech desktop-software conventions (commands in the infinitive, menus as nouns); no other
|
||||
# application's Czech localisation was consulted or copied.
|
||||
#
|
||||
# Coverage: every menu label (tested: `czech_translates_every_menu_label`); panels and dialogs fall
|
||||
# back to English. Left in English on purpose (`CZECH_KEEP_AS_IS` in the tests): the product name,
|
||||
# Coverage: every menu label (tested: `menu_catalogs_translate_every_menu_label`); panels and dialogs fall
|
||||
# back to English. Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name,
|
||||
# language names, OpenType, the built-in workspace names and the bracketed perspective grid preset
|
||||
# names.
|
||||
About VectorCraft O aplikaci VectorCraft
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
+558
-29
@@ -1,42 +1,571 @@
|
||||
# Japanese (ja) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
|
||||
# (empty context = a plain UI string; `@id` = a command id; `@plural` = "one|other" forms).
|
||||
#
|
||||
# Coverage: the main menus. Untranslated text falls back to English, so rows can be added one at a
|
||||
# time; set `complete_menus` in i18n/mod.rs once every menu string and `tl!` literal has a row.
|
||||
# Every row's source must be a string the UI shows (menu label or `tl!` literal): tested.
|
||||
Copy コピー
|
||||
Color カラー
|
||||
Cut 切り取り
|
||||
Deselect 選択を解除
|
||||
Edit 編集
|
||||
Effect 効果
|
||||
Export 書き出し
|
||||
Export As… 形式を指定して書き出し…
|
||||
File ファイル
|
||||
Help ヘルプ
|
||||
History 履歴
|
||||
Horizontal 横書き
|
||||
# Clean-room: menu and feature names follow the names Japanese users of vector illustration
|
||||
# software know, written from the meaning of VectorCraft's English labels; no other application's
|
||||
# Japanese localisation was copied. Toggles read as their action (「…を表示」「…を隠す」).
|
||||
#
|
||||
# Coverage: every menu label (tested: `menu_catalogs_translate_every_menu_label`); panels and
|
||||
# dialogs fall back to English. Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the
|
||||
# product name, language names, OpenType, the built-in workspace names and the bracketed perspective
|
||||
# grid preset names. Set `complete_menus` in i18n/mod.rs once every `tl!` literal has a row too.
|
||||
About VectorCraft VectorCraft について
|
||||
Join Our Discord Discord に参加
|
||||
Settings… 環境設定…
|
||||
Language 表示言語
|
||||
Layers レイヤー
|
||||
UI Brightness UI の明るさ
|
||||
Dark 暗い
|
||||
Medium Dark やや暗い
|
||||
Medium Light やや明るい
|
||||
Light 明るい
|
||||
Quit VectorCraft VectorCraft を終了
|
||||
File ファイル
|
||||
New… 新規…
|
||||
Object オブジェクト
|
||||
New from Template… テンプレートから新規…
|
||||
Open… 開く…
|
||||
Paste 貼り付け
|
||||
Preferences… 環境設定…
|
||||
Print… 印刷…
|
||||
Properties プロパティ
|
||||
Redo やり直し
|
||||
Revert 保存済みの状態に戻す
|
||||
Open Recent Files 最近使用したファイルを開く
|
||||
Clear Recent Files 最近使用したファイルを消去
|
||||
Show in Folder フォルダーに表示
|
||||
Close 閉じる
|
||||
Close All すべてを閉じる
|
||||
Save 保存
|
||||
Save As… 別名で保存…
|
||||
Select 選択
|
||||
Settings… 環境設定…
|
||||
Tools ツール
|
||||
Type 書式
|
||||
Type Orientation 組み方向
|
||||
Save a Copy… コピーを保存…
|
||||
Save as Template… テンプレートとして保存…
|
||||
Save as PDF… PDF として保存…
|
||||
Save for Office Documents… Office 用に保存…
|
||||
Revert 保存済みの状態に戻す
|
||||
Place… 配置…
|
||||
Export 書き出し
|
||||
Export for Screens… スクリーン用に書き出し…
|
||||
Export As… 形式を指定して書き出し…
|
||||
Export As SVG… SVG として書き出し…
|
||||
Export As PNG… PNG として書き出し…
|
||||
Save for Web (Legacy)… Web 用に保存…
|
||||
Export Selection… 選択範囲を書き出し…
|
||||
Package… パッケージ…
|
||||
Scripts スクリプト
|
||||
Other Script… その他のスクリプト…
|
||||
Document Setup… ドキュメント設定…
|
||||
Document Color Mode ドキュメントのカラーモード
|
||||
CMYK Color CMYK カラー
|
||||
RGB Color RGB カラー
|
||||
File Info… ファイル情報…
|
||||
Print… 印刷…
|
||||
Edit 編集
|
||||
Undo 取り消し
|
||||
Redo やり直し
|
||||
Cut 切り取り
|
||||
Copy コピー
|
||||
Paste 貼り付け
|
||||
Paste in Front 前面へペースト
|
||||
Paste in Back 背面へペースト
|
||||
Paste in Place 同じ位置にペースト
|
||||
Paste on All Artboards すべてのアートボードにペースト
|
||||
Paste without Formatting 書式なしでペースト
|
||||
Clear 消去
|
||||
Find and Replace… 検索と置換…
|
||||
Find Next 次を検索
|
||||
Spelling スペルチェック
|
||||
Auto Spell Check 自動スペルチェック
|
||||
Check Spelling… スペルチェック…
|
||||
Edit Custom Dictionary… カスタム辞書を編集…
|
||||
Edit Colors カラーを編集
|
||||
Recolor Artwork… オブジェクトを再配色…
|
||||
Recolor with Preset プリセットで再配色
|
||||
1 Color Job… 1 色ジョブ…
|
||||
2 Color Job… 2 色ジョブ…
|
||||
3 Color Job… 3 色ジョブ…
|
||||
Color Library… カラーライブラリ…
|
||||
Adjust Color Balance… カラーバランス調整…
|
||||
Blend Front to Back 前後にブレンド
|
||||
Blend Horizontally 左右にブレンド
|
||||
Blend Vertically 上下にブレンド
|
||||
Convert to CMYK CMYK に変換
|
||||
Convert to Grayscale グレースケールに変換
|
||||
Convert to RGB RGB に変換
|
||||
Invert Colors カラー反転
|
||||
Overprint Black… オーバープリントブラック…
|
||||
Saturate… 彩度調整…
|
||||
Edit Original オリジナルを編集
|
||||
Transparency Flattener Presets… 透明の分割・統合プリセット…
|
||||
Print Presets… プリントプリセット…
|
||||
PDF Presets… PDF プリセット…
|
||||
Perspective Grid Presets… 遠近グリッドプリセット…
|
||||
Color Settings… カラー設定…
|
||||
Assign Profile… プロファイルの指定…
|
||||
Keyboard Shortcuts… キーボードショートカット…
|
||||
Preferences… 環境設定…
|
||||
Object オブジェクト
|
||||
Transform 変形
|
||||
Transform Again 変形の繰り返し
|
||||
Move… 移動…
|
||||
Rotate… 回転…
|
||||
Reflect… リフレクト…
|
||||
Scale… 拡大・縮小…
|
||||
Shear… シアー…
|
||||
Transform Each… 個別に変形…
|
||||
Reset Bounding Box バウンディングボックスのリセット
|
||||
Arrange 重ね順
|
||||
Bring to Front 最前面へ
|
||||
Bring Forward 前面へ
|
||||
Send Backward 背面へ
|
||||
Send to Back 最背面へ
|
||||
Send to Current Layer 現在のレイヤーへ送る
|
||||
Align 整列
|
||||
Horizontal Align Left 水平方向左に整列
|
||||
Horizontal Align Center 水平方向中央に整列
|
||||
Horizontal Align Right 水平方向右に整列
|
||||
Vertical Align Top 垂直方向上に整列
|
||||
Vertical Align Center 垂直方向中央に整列
|
||||
Vertical Align Bottom 垂直方向下に整列
|
||||
Group グループ
|
||||
Ungroup グループ解除
|
||||
Lock ロック
|
||||
Selection 選択
|
||||
All Artwork Above 前面のすべてのアートワーク
|
||||
Other Layers その他のレイヤー
|
||||
Unlock All すべてをロック解除
|
||||
Hide 隠す
|
||||
Show All すべてを表示
|
||||
Expand… 分割・拡張…
|
||||
Expand Appearance アピアランスを分割
|
||||
Crop Image 画像の切り抜き
|
||||
Rasterize… ラスタライズ…
|
||||
Create Gradient Mesh… グラデーションメッシュを作成…
|
||||
Create Object Mosaic… モザイクオブジェクトを作成…
|
||||
Vector Halftone… ベクターハーフトーン…
|
||||
Create Trim Marks トリムマークを作成
|
||||
Flatten Transparency… 透明部分を分割・統合…
|
||||
Make Pixel Perfect ピクセルグリッドに最適化
|
||||
Slice スライス
|
||||
Make 作成
|
||||
Release 解除
|
||||
Create from Guides ガイドから作成
|
||||
Create from Selection 選択範囲から作成
|
||||
Duplicate Slice スライスを複製
|
||||
Combine Slices スライスを結合
|
||||
Divide Slices… スライスを分割…
|
||||
Delete All すべてを削除
|
||||
Slice Options… スライスオプション…
|
||||
Clip to Artboard アートボードサイズでクリップ
|
||||
Path パス
|
||||
Join 連結
|
||||
Average… 平均…
|
||||
Outline Stroke パスのアウトライン
|
||||
Offset Path… パスのオフセット…
|
||||
Reverse Path Direction パスの方向反転
|
||||
Simplify… 単純化…
|
||||
Add Anchor Points アンカーポイントの追加
|
||||
Remove Anchor Points アンカーポイントの削除
|
||||
Divide Objects Below 背面のオブジェクトで型抜き
|
||||
Split Into Grid… グリッドに分割…
|
||||
Clean Up… 余分なポイントを削除…
|
||||
Shape シェイプ
|
||||
Convert to Shape シェイプに変換
|
||||
Expand Shape シェイプを拡張
|
||||
Pattern パターン
|
||||
Edit Pattern パターンを編集
|
||||
Tile Edge Color… タイルの境界線のカラー…
|
||||
Repeat リピート
|
||||
Radial ラジアル
|
||||
Grid グリッド
|
||||
Mirror ミラー
|
||||
Options… オプション…
|
||||
Blend ブレンド
|
||||
Blend Options… ブレンドオプション…
|
||||
Expand 拡張
|
||||
Replace Spine ブレンド軸を置き換え
|
||||
Reverse Spine ブレンド軸を反転
|
||||
Reverse Front to Back 前後を反転
|
||||
Envelope Distort エンベロープ
|
||||
Make with Warp… ワープで作成…
|
||||
Reset with Warp… ワープでリセット…
|
||||
Make with Mesh… メッシュで作成…
|
||||
Reset with Mesh… メッシュでリセット…
|
||||
Make with Top Object 最前面のオブジェクトで作成
|
||||
Envelope Options… エンベロープオプション…
|
||||
Edit Contents 内容を編集
|
||||
Perspective 遠近
|
||||
Attach to Active Plane 現在の面に配置
|
||||
Release with Perspective 遠近を解除
|
||||
Move Plane to Match Object オブジェクトに合わせて面を移動
|
||||
Edit Text テキストを編集
|
||||
Live Paint ライブペイント
|
||||
Merge 結合
|
||||
Gap Options… 間隔オプション…
|
||||
Image Trace 画像トレース
|
||||
Make… 作成…
|
||||
Make and Expand… 作成して拡張…
|
||||
Text Wrap テキストの回り込み
|
||||
Text Wrap Options… テキストの回り込みオプション…
|
||||
Clipping Mask クリッピングマスク
|
||||
Edit Clipping Path クリッピングパスを編集
|
||||
Compound Path 複合パス
|
||||
Artboards アートボード
|
||||
Convert to Artboards アートボードに変換
|
||||
Rearrange All Artboards… すべてのアートボードを再配置…
|
||||
Fit to Artwork Bounds アートワーク境界に合わせる
|
||||
Fit to Selected Art 選択オブジェクトに合わせる
|
||||
Graph グラフ
|
||||
Type… 種類…
|
||||
Data… データ…
|
||||
Design… デザイン…
|
||||
Column… 棒グラフ…
|
||||
Marker… マーカー…
|
||||
Collect for Export 書き出し用に追加
|
||||
As Single Asset 単一アセットとして
|
||||
As Multiple Assets 複数アセットとして
|
||||
Plug-ins プラグイン
|
||||
No filter plug-ins installed フィルタープラグインはインストールされていません
|
||||
Install Plug-in… プラグインをインストール…
|
||||
Reload Plug-ins プラグインを再読み込み
|
||||
Type 書式
|
||||
Font フォント
|
||||
Recent Fonts 最近使用したフォント
|
||||
Size サイズ
|
||||
Glyphs 字形
|
||||
Insert Special Character 特殊文字を挿入
|
||||
Bullet 箇条書き記号
|
||||
Copyright Symbol 著作権記号
|
||||
Ellipsis 省略記号
|
||||
Paragraph Symbol 段落記号
|
||||
Registered Trademark Symbol 登録商標記号
|
||||
Section Symbol セクション記号
|
||||
Trademark Symbol 商標記号
|
||||
Em Dash EM ダッシュ
|
||||
En Dash EN ダッシュ
|
||||
Discretionary Hyphen 任意ハイフン
|
||||
Nonbreaking Hyphen 分散禁止ハイフン
|
||||
Double Left Quotation Marks 左二重引用符
|
||||
Double Right Quotation Marks 右二重引用符
|
||||
Single Left Quotation Mark 左一重引用符
|
||||
Single Right Quotation Mark 右一重引用符
|
||||
Insert Whitespace Character 空白文字を挿入
|
||||
Em Space EM スペース
|
||||
En Space EN スペース
|
||||
Hair Space 極細スペース
|
||||
Sixth Space 1/6 スペース
|
||||
Thin Space 細いスペース
|
||||
Nonbreaking Space 改行なしスペース
|
||||
Figure Space 数字スペース
|
||||
Punctuation Space 句読点スペース
|
||||
Third Space 1/3 スペース
|
||||
Quarter Space 1/4 スペース
|
||||
Insert Break Character 分割文字を挿入
|
||||
Forced Line Break 強制改行
|
||||
Tab タブ
|
||||
Convert To Area Type エリア内文字に切り換え
|
||||
Convert To Point Type ポイント文字に切り換え
|
||||
Area Type Options… エリア内文字オプション…
|
||||
Type on a Path パス上文字オプション
|
||||
Rainbow 虹
|
||||
Skew 歪み
|
||||
3D Ribbon 3D リボン
|
||||
Stair Step 階段
|
||||
Gravity 引力
|
||||
Type on a Path Options… パス上文字オプション…
|
||||
Threaded Text スレッドテキストオプション
|
||||
Create 作成
|
||||
Release Selection 選択部分をリリース
|
||||
Remove Threading スレッドを解除
|
||||
Fit Headline ヘッドラインを合わせる
|
||||
Find Font… フォントの検索と置換…
|
||||
Change Case 大文字と小文字の変更
|
||||
UPPERCASE すべて大文字
|
||||
lowercase すべて小文字
|
||||
Title Case 単語の先頭のみ大文字
|
||||
Sentence case 文頭のみ大文字
|
||||
Smart Punctuation… 欧文用字形の自動置換…
|
||||
Create Outlines アウトラインを作成
|
||||
Optical Margin Alignment 最適なマージン揃え
|
||||
Fill with Placeholder Text サンプルテキストの割り付け
|
||||
Show Hidden Characters 制御文字を表示
|
||||
Type Orientation 組み方向
|
||||
Horizontal 横書き
|
||||
Vertical 縦書き
|
||||
Select 選択
|
||||
All すべてを選択
|
||||
All on Active Artboard 作業アートボードのすべてを選択
|
||||
Deselect 選択を解除
|
||||
Reselect 再選択
|
||||
Inverse 選択範囲を反転
|
||||
Next Object Above 前面のオブジェクト
|
||||
Next Object Below 背面のオブジェクト
|
||||
Same 共通
|
||||
Appearance アピアランス
|
||||
Appearance Attribute アピアランス属性
|
||||
Blending Mode 描画モード
|
||||
Fill & Stroke 塗りと線
|
||||
Fill Color カラー (塗り)
|
||||
Opacity 不透明度
|
||||
Stroke Color カラー (線)
|
||||
Stroke Weight 線幅
|
||||
Graphic Style グラフィックスタイル
|
||||
Symbol Instance シンボルインスタンス
|
||||
Font Family フォントファミリー
|
||||
Font Family & Style フォントファミリー (スタイル)
|
||||
Font Family, Style & Size フォントファミリー (スタイルとサイズ)
|
||||
Font Size フォントサイズ
|
||||
Text Fill Color テキストカラー (塗り)
|
||||
Text Stroke Color テキストカラー (線)
|
||||
All on Same Layers 同じレイヤー上のすべて
|
||||
Direction Handles 方向線ハンドル
|
||||
Brush Strokes ブラシストローク
|
||||
Bristle Brush Strokes 絵筆ブラシストローク
|
||||
Clipping Masks クリッピングマスク
|
||||
Slices スライス
|
||||
Stray Points 余分なポイント
|
||||
Open Paths オープンパス
|
||||
All Text Objects すべてのテキストオブジェクト
|
||||
Point Text Objects ポイント文字オブジェクト
|
||||
Area Text Objects エリア内文字オブジェクト
|
||||
Start Global Edit グローバル編集を開始
|
||||
Save Selection… 選択範囲を保存…
|
||||
Edit Selection… 選択範囲を編集…
|
||||
Effect 効果
|
||||
Apply Last Effect 前回の効果を適用
|
||||
Last Effect… 前回の効果…
|
||||
Document Raster Effects Settings… ドキュメントのラスタライズ効果設定…
|
||||
Vector Effects ベクター効果
|
||||
3D and Materials 3D とマテリアル
|
||||
Extrude & Bevel… 押し出しとベベル…
|
||||
Revolve… 回転体…
|
||||
Inflate… 膨張…
|
||||
Materials… マテリアル…
|
||||
Color Adjustments 色調補正
|
||||
Brightness/Contrast… 明るさ・コントラスト…
|
||||
Curves… トーンカーブ…
|
||||
Hue/Saturation… 色相・彩度…
|
||||
Levels… レベル補正…
|
||||
Shift to Color… カラーに変更…
|
||||
Temperature/Tint… 色温度・色かぶり…
|
||||
Rectangle… 長方形…
|
||||
Rounded Rectangle… 角丸長方形…
|
||||
Ellipse… 楕円形…
|
||||
Crop Marks トリムマーク
|
||||
Distort & Transform パスの変形
|
||||
Free Distort… パスの自由変形…
|
||||
Pucker & Bloat… パンク・膨張…
|
||||
Roughen… ラフ…
|
||||
Transform… 変形…
|
||||
Tweak… ランダム・ひねり…
|
||||
Twist… 旋回…
|
||||
Zig Zag… ジグザグ…
|
||||
Pathfinder パスファインダー
|
||||
Add 追加
|
||||
Intersect 交差
|
||||
Exclude 中マド
|
||||
Subtract 前面オブジェクトで型抜き
|
||||
Minus Back 背面オブジェクトで型抜き
|
||||
Divide 分割
|
||||
Trim 刈り込み
|
||||
Crop 切り抜き
|
||||
Outline アウトライン
|
||||
Stylize スタイライズ
|
||||
Round Corners… 角を丸くする…
|
||||
Scribble… 落書き…
|
||||
Drop Shadow… ドロップシャドウ…
|
||||
Inner Glow… 光彩 (内側)…
|
||||
Outer Glow… 光彩 (外側)…
|
||||
Feather… ぼかし…
|
||||
SVG Filters SVG フィルター
|
||||
Apply SVG Filter… SVG フィルターを適用…
|
||||
Warp ワープ
|
||||
Arc… 円弧…
|
||||
Arc Lower… 円弧 (下弦)…
|
||||
Arc Upper… 円弧 (上弦)…
|
||||
Arch… アーチ…
|
||||
Bulge… でこぼこ…
|
||||
Shell Lower… 貝殻 (下向き)…
|
||||
Shell Upper… 貝殻 (上向き)…
|
||||
Flag… 旗…
|
||||
Wave… 波形…
|
||||
Fish… 魚形…
|
||||
Rise… 上昇…
|
||||
Fisheye… 魚眼レンズ…
|
||||
Squeeze… 絞り込み…
|
||||
Raster Effects ラスター効果
|
||||
Blur ぼかし
|
||||
Gaussian Blur… ぼかし (ガウス)…
|
||||
View 表示
|
||||
Window ウィンドウ
|
||||
Overprint Preview オーバープリントプレビュー
|
||||
Pixel Preview ピクセルプレビュー
|
||||
Trim View トリミング表示
|
||||
Presentation Mode プレゼンテーションモード
|
||||
Screen Mode スクリーンモード
|
||||
Normal Screen Mode 標準スクリーンモード
|
||||
Full Screen Mode with Menu Bar メニュー付きフルスクリーンモード
|
||||
Full Screen Mode フルスクリーンモード
|
||||
Proof Setup 校正設定
|
||||
Working CMYK 作業用 CMYK
|
||||
Legacy Macintosh RGB (Gamma 1.8) 従来の Macintosh RGB(ガンマ 1.8)
|
||||
Internet Standard RGB (sRGB) インターネット標準 RGB(sRGB)
|
||||
Monitor RGB モニター RGB
|
||||
Color blindness – Protanopia-type P 型(1 型)色覚
|
||||
Color blindness – Deuteranopia-type D 型(2 型)色覚
|
||||
Customize… カスタマイズ…
|
||||
Proof Colors 色の校正
|
||||
Zoom In ズームイン
|
||||
Zoom Out ズームアウト
|
||||
Fit Artboard in Window アートボードを全体表示
|
||||
Fit All in Window すべてのアートボードを全体表示
|
||||
Actual Size 100% 表示
|
||||
Reset Rotate View ビューを回転の初期化
|
||||
Hide Edges エッジを隠す
|
||||
Hide Artboards アートボードを隠す
|
||||
Show Print Tiling プリント分割を表示
|
||||
Hide Slices スライスを隠す
|
||||
Lock Slices スライスをロック
|
||||
Rulers 定規
|
||||
Show Rulers 定規を表示
|
||||
Change to Global Rulers グローバル定規に変更
|
||||
Show Video Rulers ビデオ定規を表示
|
||||
Hide Bounding Box バウンディングボックスを隠す
|
||||
Show Transparency Grid 透明グリッドを表示
|
||||
Hide Text Threads テキストのスレッドを隠す
|
||||
Hide Gradient Annotator グラデーションガイドを隠す
|
||||
Hide Corner Widget コーナーウィジェットを隠す
|
||||
Guides ガイド
|
||||
Hide Guides ガイドを隠す
|
||||
Lock Guides ガイドをロック
|
||||
Make Guides ガイドを作成
|
||||
Release Guides ガイドを解除
|
||||
Clear Guides ガイドを消去
|
||||
Smart Guides スマートガイド
|
||||
Perspective Grid 遠近グリッド
|
||||
Show Grid グリッドを表示
|
||||
Snap to Grid グリッドにスナップ
|
||||
Lock Grid グリッドをロック
|
||||
Lock Station Point 視点をロック
|
||||
Define Grid… グリッドを定義…
|
||||
One Point Perspective 1 点遠近法
|
||||
Two Point Perspective 2 点遠近法
|
||||
Three Point Perspective 3 点遠近法
|
||||
Save Grid as Preset… グリッドをプリセットとして保存…
|
||||
Snap to Pixel ピクセルにスナップ
|
||||
Snap to Point ポイントにスナップ
|
||||
Snap to Glyph グリフにスナップ
|
||||
New View… 新規表示…
|
||||
Edit Views… 表示の編集…
|
||||
Saved View 1 保存した表示 1
|
||||
Saved View 2 保存した表示 2
|
||||
Saved View 3 保存した表示 3
|
||||
Saved View 4 保存した表示 4
|
||||
Saved View 5 保存した表示 5
|
||||
Saved View 6 保存した表示 6
|
||||
Saved View 7 保存した表示 7
|
||||
Saved View 8 保存した表示 8
|
||||
Saved View 9 保存した表示 9
|
||||
Saved View 10 保存した表示 10
|
||||
Window ウィンドウ
|
||||
New Window 新規ウィンドウ
|
||||
Cascade 重ねて表示
|
||||
Tile 並べて表示
|
||||
Float in Window ウィンドウを分離
|
||||
Consolidate All Windows すべてのウィンドウを統合
|
||||
Workspace ワークスペース
|
||||
Reset Essentials 初期設定をリセット
|
||||
New Workspace… 新規ワークスペース…
|
||||
Manage Workspaces… ワークスペースを管理…
|
||||
Control コントロール
|
||||
Contextual Task Bar コンテキストタスクバー
|
||||
Tools ツール
|
||||
Toolbars ツールバー
|
||||
Advanced 詳細
|
||||
Single / Double Column 1 列/2 列
|
||||
Actions アクション
|
||||
Asset Export アセットの書き出し
|
||||
Attributes 属性
|
||||
Brushes ブラシ
|
||||
Color カラー
|
||||
Color Guide カラーガイド
|
||||
Color Themes カラーテーマ
|
||||
CSS Properties CSS プロパティ
|
||||
Document Info ドキュメント情報
|
||||
Flattener Preview 分割・統合プレビュー
|
||||
Gradient グラデーション
|
||||
Graphic Styles グラフィックスタイル
|
||||
History 履歴
|
||||
Info 情報
|
||||
Layers レイヤー
|
||||
Libraries ライブラリ
|
||||
Links リンク
|
||||
Magic Wand 自動選択
|
||||
Navigator ナビゲーター
|
||||
Pattern Options パターンオプション
|
||||
Properties プロパティ
|
||||
Separations Preview 分版プレビュー
|
||||
Stroke 線
|
||||
SVG Interactivity SVG インタラクティビティ
|
||||
Swatches スウォッチ
|
||||
Symbols シンボル
|
||||
Transparency 透明
|
||||
Character 文字
|
||||
Character Styles 文字スタイル
|
||||
Paragraph 段落
|
||||
Paragraph Styles 段落スタイル
|
||||
Tabs タブ
|
||||
Variables 変数
|
||||
Brush Libraries ブラシライブラリ
|
||||
Built-in Libraries 内蔵ライブラリ
|
||||
User Defined ユーザー定義
|
||||
Other Library… その他のライブラリ…
|
||||
Graphic Style Libraries グラフィックスタイルライブラリ
|
||||
Shadows and Glows シャドウと光彩
|
||||
Outlines and Rules アウトラインと罫線
|
||||
Hand-Drawn 手描き風
|
||||
Gradient Finishes グラデーション仕上げ
|
||||
Shape Effects シェイプ効果
|
||||
Blends and Transparency ブレンドと透明
|
||||
Save Graphic Style Library… グラフィックスタイルライブラリを保存…
|
||||
Swatch Libraries スウォッチライブラリ
|
||||
Default Swatches 初期設定スウォッチ
|
||||
Web Safe 216 Web セーフ 216
|
||||
Grays and Neutrals グレーとニュートラル
|
||||
Earth Tones アーストーン
|
||||
Skin Tone Ramps 肌色のグラデーション
|
||||
Pastels パステル
|
||||
Brights ビビッド
|
||||
Metallic Gradients メタリックグラデーション
|
||||
Perceptual Scales 知覚的カラースケール
|
||||
Harmony Sets ハーモニーセット
|
||||
Gradients グラデーション
|
||||
Spectrum Gradients スペクトルグラデーション
|
||||
Sky Gradients 空のグラデーション
|
||||
Neutral Gradients ニュートラルグラデーション
|
||||
Duotone Gradients ダブルトーングラデーション
|
||||
Radial Glows 円形グロー
|
||||
Save Swatch Library… スウォッチライブラリを保存…
|
||||
Symbol Libraries シンボルライブラリ
|
||||
Help ヘルプ
|
||||
ArtCraft Website ArtCraft の Web サイト
|
||||
VectorCraft on getartcraft.com getartcraft.com の VectorCraft
|
||||
VectorCraft on GitHub GitHub の VectorCraft
|
||||
Search Commands… コマンドを検索…
|
||||
VectorCraft Help… VectorCraft ヘルプ…
|
||||
Preview プレビュー
|
||||
Show Edges エッジを表示
|
||||
Show Corner Widget コーナーウィジェットを表示
|
||||
Show Text Threads テキストのスレッドを表示
|
||||
Hide Hidden Characters 制御文字を隠す
|
||||
Show Gradient Annotator グラデーションガイドを表示
|
||||
Show Artboards アートボードを表示
|
||||
Hide Rulers 定規を隠す
|
||||
Show Bounding Box バウンディングボックスを表示
|
||||
Hide Transparency Grid 透明グリッドを隠す
|
||||
Show Guides ガイドを表示
|
||||
Hide Grid グリッドを隠す
|
||||
Unlock Guides ガイドをロック解除
|
||||
Show Slices スライスを表示
|
||||
Hide Print Tiling プリント分割を隠す
|
||||
Unlock Grid グリッドをロック解除
|
||||
Edit Envelope エンベロープを編集
|
||||
Automatic 自動
|
||||
Isolate Selected Group 選択したグループを分離
|
||||
Exit Isolation Mode 分離モードを終了
|
||||
Make Clipping Mask クリッピングマスクを作成
|
||||
Release Clipping Mask クリッピングマスクを解除
|
||||
Make Compound Path 複合パスを作成
|
||||
Release Compound Path 複合パスを解除
|
||||
Select All すべてを選択
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -62,10 +62,10 @@ fn plural_czech(n: u64) -> usize {
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 4] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Japanese: the main menus so far (untranslated text stays English); `complete_menus` once the
|
||||
// catalog covers every menu string and `tl!` literal.
|
||||
// Japanese: every menu label (`menu_catalogs_translate_every_menu_label`); panels and dialogs
|
||||
// not yet. `complete_menus` once the catalog covers every menu string and `tl!` literal.
|
||||
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Czech: every menu label (`czech_translates_every_menu_label`); panels and dialogs not yet.
|
||||
// Czech: every menu label (`menu_catalogs_translate_every_menu_label`); panels and dialogs not yet.
|
||||
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_czech, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Traditional Chinese in the vocabulary used in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*`
|
||||
// locales all resolve here (see `candidates`).
|
||||
|
||||
@@ -322,10 +322,10 @@ fn cs() -> Lang {
|
||||
Lang::from_code("cs").expect("cs registered")
|
||||
}
|
||||
|
||||
/// Menu labels Czech shows as they are: the product name, a format name, the built-in workspace
|
||||
/// Menu labels the menu-complete catalogs (Czech, Japanese) show as they are: the product name, a format name, the built-in workspace
|
||||
/// names and the perspective grid presets (names, shown untranslated wherever else they appear).
|
||||
/// Each language's own name in the Language menu is left alone too.
|
||||
const CZECH_KEEP_AS_IS: &[&str] = &[
|
||||
const MENU_KEEP_AS_IS: &[&str] = &[
|
||||
"VectorCraft",
|
||||
"OpenType",
|
||||
"Essentials",
|
||||
@@ -412,9 +412,10 @@ fn toggled_labels() -> Vec<String> {
|
||||
labels
|
||||
}
|
||||
|
||||
/// Czech covers every menu label, the Show/Hide pairs included (panels and dialogs not yet).
|
||||
/// Czech and Japanese cover every menu label, the Show/Hide pairs and the canvas context menu
|
||||
/// included (panels and dialogs not yet).
|
||||
#[test]
|
||||
fn czech_translates_every_menu_label() {
|
||||
fn menu_catalogs_translate_every_menu_label() {
|
||||
let labels = menu_labels();
|
||||
// What the menus show (recent files and fonts, user presets and libraries show their names).
|
||||
let shown = crate::menus::menu_strings();
|
||||
@@ -422,20 +423,24 @@ fn czech_translates_every_menu_label() {
|
||||
// Font names and sizes, and installed plug-ins' own names, are not interface text.
|
||||
let interface = |(label, id, p): &(&str, &str, serde_json::Value)| {
|
||||
let plugin = *id == "plugin.dialog" || p.get("effect").and_then(serde_json::Value::as_str).is_some_and(|e| e.starts_with("plugin."));
|
||||
*id != "text.setStyle" && !plugin && shown.contains(*label) && !CZECH_KEEP_AS_IS.contains(label) && !language_name(label)
|
||||
*id != "text.setStyle" && !plugin && shown.contains(*label) && !MENU_KEEP_AS_IS.contains(label) && !language_name(label)
|
||||
};
|
||||
let mut missing: Vec<String> = labels.iter().filter(|l| interface(l)).map(|(l, ..)| l.to_string()).collect();
|
||||
missing.extend(toggled_labels());
|
||||
missing.extend(crate::menus::CONTEXT_LABELS.iter().map(|l| l.to_string()));
|
||||
missing.retain(|l| !has(cs(), l));
|
||||
missing.sort();
|
||||
missing.dedup();
|
||||
assert!(missing.is_empty(), "untranslated Czech menu labels: {missing:?}");
|
||||
for keep in CZECH_KEEP_AS_IS {
|
||||
assert!(labels.iter().any(|(l, ..)| l == keep), "`{keep}` isn't a menu label");
|
||||
assert!(!has(cs(), keep), "`{keep}` is both kept and translated");
|
||||
let mut all: Vec<String> = labels.iter().filter(|l| interface(l)).map(|(l, ..)| l.to_string()).collect();
|
||||
all.extend(toggled_labels());
|
||||
all.extend(crate::menus::CONTEXT_LABELS.iter().map(|l| l.to_string()));
|
||||
all.sort();
|
||||
all.dedup();
|
||||
for code in ["cs", "ja"] {
|
||||
let lang = Lang::from_code(code).expect("registered");
|
||||
let missing: Vec<&String> = all.iter().filter(|l| !has(lang, l)).collect();
|
||||
assert!(missing.is_empty(), "{code}: untranslated menu labels: {missing:?}");
|
||||
for keep in MENU_KEEP_AS_IS {
|
||||
assert!(labels.iter().any(|(l, ..)| l == keep), "`{keep}` isn't a menu label");
|
||||
assert!(!has(lang, keep), "{code}: `{keep}` is both kept and translated");
|
||||
}
|
||||
}
|
||||
assert_eq!(tr(cs(), "File"), "Soubor");
|
||||
assert_eq!(tr(Lang::from_code("ja").expect("ja"), "File"), "ファイル");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -988,6 +988,7 @@
|
||||
Global Process Color, CMYK 整體印刷色,CMYK
|
||||
glyph {id} 字符 {id}
|
||||
Glyphs 字符
|
||||
Go to Artboard 前往工作區域
|
||||
Go To Link 前往連結
|
||||
GPL palettes keep solid colours only, as RGB. GPL 調色盤僅保留純色,並以 RGB 儲存。
|
||||
GPU Performance GPU 效能
|
||||
@@ -2022,6 +2023,7 @@
|
||||
Reshape 改變外框
|
||||
Reshape Segment 改變線段外框
|
||||
Reshape Tool 改變外框工具
|
||||
Reshape Type Area 改變區域文字外框
|
||||
Resolution: 解析度:
|
||||
Responsive 回應式
|
||||
Restore 還原
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -513,6 +513,9 @@ impl VectorcraftApp {
|
||||
{
|
||||
*slot = id.to_string();
|
||||
}
|
||||
if canvas::is_selection_tool(id) {
|
||||
self.ui.last_selection_tool = id.to_string();
|
||||
}
|
||||
toolbar::remember(self, id);
|
||||
self.ui.flyout = None;
|
||||
}
|
||||
|
||||
+162
-2
@@ -147,6 +147,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("view.fitAll", "Fit All in Window", "Cmd+Alt+0", "{}"),
|
||||
("view.actualSize", "Actual Size", "Cmd+1", "{}"),
|
||||
("view.setZoom", "Set Zoom", "", "{zoom: percent, center?: [x,y]}"),
|
||||
(
|
||||
"view.goToArtboard",
|
||||
"Go to Artboard",
|
||||
"",
|
||||
"{index: 0-based number | \"first\" | \"previous\" | \"next\" | \"last\"} make that artboard the status bar navigator's (the one Fit Artboard in Window and Actual Size show) and fit it in the window → {index}",
|
||||
),
|
||||
("view.edges", "Hide Edges", "Cmd+H", "{}"),
|
||||
("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"),
|
||||
("view.rulers", "Show Rulers", "Cmd+R", "{}"),
|
||||
@@ -239,7 +245,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"tool.options",
|
||||
"Tool Options…",
|
||||
"",
|
||||
"{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values})",
|
||||
"{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values})",
|
||||
),
|
||||
(
|
||||
"ui.colorGuideOptions",
|
||||
@@ -859,6 +865,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
crate::canvas::fit(app, id);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.goToArtboard" => go_to_artboard(app, p),
|
||||
"view.presentation" => {
|
||||
app.ui.screen_mode = if app.ui.screen_mode == 3 { 0 } else { 3 };
|
||||
Ok(json!(app.ui.screen_mode))
|
||||
@@ -1118,6 +1125,31 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// `view.goToArtboard`: make artboard `index` (a number, or first / previous / next / last from
|
||||
/// the current one) the navigator's, and fit it in the window.
|
||||
fn go_to_artboard(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let n = app.session.active().map(|d| d.doc.artboards.len()).ok_or("no document open")?;
|
||||
let last = n.checked_sub(1).ok_or("the document has no artboards")?;
|
||||
let current = app.view().map_or(0, |v| v.artboard).min(last);
|
||||
let to = match p.get("index") {
|
||||
Some(Value::String(s)) => match s.as_str() {
|
||||
"first" => 0,
|
||||
"previous" => current.saturating_sub(1),
|
||||
"next" => current + 1,
|
||||
"last" => last,
|
||||
other => return Err(format!("index must be a number or first, previous, next or last, not `{other}`")),
|
||||
},
|
||||
Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("index must be a number or first, previous, next or last")?,
|
||||
None => return Err("missing index".into()),
|
||||
}
|
||||
.min(last);
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.artboard = to;
|
||||
}
|
||||
crate::canvas::fit(app, "view.fitArtboard");
|
||||
Ok(json!({ "index": to }))
|
||||
}
|
||||
|
||||
/// Checked state for toggle items.
|
||||
pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
||||
if id == "app.language" {
|
||||
@@ -2311,14 +2343,16 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
let tree = menu_tree();
|
||||
let end = egui::MenuBar::new()
|
||||
.ui(ui, |ui| {
|
||||
let mut titles = Vec::with_capacity(tree.len());
|
||||
for (i, (title, items)) in tree.iter().enumerate() {
|
||||
let text = if i == 0 {
|
||||
egui::RichText::new(tl!(title)).font(theme::semibold(13.0)).color(t.text)
|
||||
} else {
|
||||
egui::RichText::new(tl!(title)).size(13.0).color(t.text)
|
||||
};
|
||||
ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked));
|
||||
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
|
||||
}
|
||||
switch_on_hover(ui.ctx(), &titles);
|
||||
ui.cursor().min.x
|
||||
})
|
||||
.inner;
|
||||
@@ -2328,6 +2362,39 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
|
||||
end
|
||||
}
|
||||
|
||||
/// Whether the pointer at `p` is really over the menu title `title`: inside it, and with no
|
||||
/// popup above it (a tall menu that egui moves up can cover the bar; hovering that menu must
|
||||
/// not switch to the title under it).
|
||||
fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::Pos2) -> bool {
|
||||
title.interact_rect.contains(p) && ctx.layer_id_at(p) == Some(title.layer_id)
|
||||
}
|
||||
|
||||
/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another
|
||||
/// title opens that menu instead (egui alone needs a click on each title).
|
||||
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) {
|
||||
let ids: Vec<egui::Id> = titles.iter().map(egui::Popup::default_response_id).collect();
|
||||
let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else {
|
||||
return;
|
||||
};
|
||||
// `Response::hovered` is false while a menu's popup is open, so hit-test the titles here.
|
||||
let Some(p) = ctx.pointer_hover_pos() else {
|
||||
return;
|
||||
};
|
||||
// Only a moving pointer switches: one resting on a title leaves the open menu alone.
|
||||
if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) {
|
||||
return;
|
||||
}
|
||||
let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) else {
|
||||
return;
|
||||
};
|
||||
if i != open
|
||||
&& let Some(id) = ids.get(i)
|
||||
{
|
||||
egui::Popup::open_id(ctx, *id);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt;
|
||||
/// it scrolls when it is taller than the window.
|
||||
fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) {
|
||||
@@ -2922,6 +2989,99 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One headless frame of the in-window menu bar; returns its titles (left to right) as
|
||||
/// (rect, id of the title's popup).
|
||||
fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(egui::Rect, egui::Id)> {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
|
||||
let mut layer = None;
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
|
||||
layer = Some(ui.layer_id());
|
||||
menu_bar(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let layer = layer.unwrap();
|
||||
let mut titles: Vec<(egui::Rect, egui::Id)> = ctx.viewport(|vp| {
|
||||
vp.prev_pass
|
||||
.widgets
|
||||
.get_layer(layer)
|
||||
.filter(|w| w.sense.senses_click() && w.rect.top() < 30.0)
|
||||
// `egui::Popup::default_response_id` of the title's response.
|
||||
.map(|w| (w.rect, w.id.with("popup")))
|
||||
.collect()
|
||||
});
|
||||
titles.sort_by(|a, b| a.0.left().total_cmp(&b.0.left()));
|
||||
titles
|
||||
}
|
||||
|
||||
fn open_titles(ctx: &egui::Context, titles: &[(egui::Rect, egui::Id)]) -> Vec<usize> {
|
||||
(0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hovering_another_title_switches_the_open_menu() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
bar_frame(&mut app, &ctx, vec![]);
|
||||
let titles = bar_frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(titles.len(), menu_tree().len());
|
||||
// 0 is the app menu, then File, Edit, Object.
|
||||
let (file, edit, object) = (titles[1].0.center(), titles[2].0.center(), titles[3].0.center());
|
||||
let frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut t = bar_frame(app, &ctx, events);
|
||||
for _ in 0..2 {
|
||||
t = bar_frame(app, &ctx, vec![]);
|
||||
}
|
||||
t
|
||||
};
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
|
||||
// Nothing open: hovering a title opens nothing.
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
|
||||
assert!(open_titles(&ctx, &t).is_empty());
|
||||
|
||||
// Click File, then move onto Edit and on to Object: each opens in turn, alone.
|
||||
frames(&mut app, vec![egui::Event::PointerMoved(file)]);
|
||||
frames(&mut app, vec![button(file, true)]);
|
||||
let t = frames(&mut app, vec![button(file, false)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![1], "a click opens File");
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Edit opens it and closes File");
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![3], "hovering Object opens it and closes Edit");
|
||||
|
||||
// A pointer resting on a title doesn't switch: File opened another way (the keyboard)
|
||||
// stays open while the pointer stays still over Object.
|
||||
egui::Popup::open_id(&ctx, t[1].1);
|
||||
let t = frames(&mut app, vec![]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![1], "a still pointer leaves the open menu alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_popup_covering_a_title_does_not_switch_menus() {
|
||||
let ctx = egui::Context::default();
|
||||
let p = egui::pos2(90.0, 12.0);
|
||||
let mut reaches = (true, false);
|
||||
// The popup becomes hit-testable once egui has laid it out: draw a few frames.
|
||||
for _ in 0..3 {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::new("covered"), egui::Sense::click());
|
||||
let q = p + egui::vec2(0.0, 100.0);
|
||||
let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::new("free"), egui::Sense::click());
|
||||
egui::Area::new(egui::Id::new("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| {
|
||||
ui.allocate_space(egui::vec2(20.0, 20.0));
|
||||
});
|
||||
reaches = (pointer_reaches_title(&ctx, &covered, p), pointer_reaches_title(&ctx, &free, q));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
assert_eq!(reaches, (false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_in_menus_are_not_interface_labels() {
|
||||
for (id, label) in [
|
||||
|
||||
@@ -42,11 +42,13 @@ struct Entry {
|
||||
haystack: String,
|
||||
}
|
||||
|
||||
/// The palette's entries in `lang`. They are built when the language or the installed plug-ins
|
||||
/// change, not on every frame while a query is typed.
|
||||
/// The palette's entries in `lang`. They are built when the language, the installed plug-ins or
|
||||
/// the keyboard shortcuts (shown beside each entry) change, not on every frame while a query is
|
||||
/// typed.
|
||||
fn entries(ctx: &egui::Context, lang: crate::i18n::Lang) -> Arc<Vec<Entry>> {
|
||||
type Cached = ((&'static str, u64), Arc<Vec<Entry>>);
|
||||
let key = (lang.code(), menus::plugin_revision());
|
||||
type Cached = ((&'static str, u64, u64), Arc<Vec<Entry>>);
|
||||
let shortcuts = crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let key = (lang.code(), menus::plugin_revision(), shortcuts);
|
||||
let id = egui::Id::new("palette-entries");
|
||||
if let Some((k, v)) = ctx.data(|d| d.get_temp::<Cached>(id))
|
||||
&& k == key
|
||||
@@ -123,6 +125,9 @@ mod tests {
|
||||
assert!(Arc::ptr_eq(&en, &entries(&ctx, Lang::EN)), "kept while the language stays");
|
||||
let zh_entries = entries(&ctx, zh);
|
||||
assert!(!Arc::ptr_eq(&en, &zh_entries), "rebuilt for another language");
|
||||
// Editing a shortcut rebuilds them, so the palette shows the new one.
|
||||
crate::shortcut_editor::GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(!Arc::ptr_eq(&zh_entries, &entries(&ctx, zh)), "rebuilt after a shortcut edit");
|
||||
let group = zh_entries.iter().find(|e| e.id == "object.group").unwrap();
|
||||
assert!(group.shown.ends_with(crate::i18n::tr(zh, "Group")), "{}", group.shown);
|
||||
// A query matches the English label, the shown label or the id.
|
||||
|
||||
@@ -351,7 +351,7 @@ fn zone_input(ui: &Ui, zone: &Response, d: &Document, ev: &mut Events) {
|
||||
PanelDrag::GraphicStyle(n) => (Some(n), None),
|
||||
PanelDrag::Art(ids) => (None, ids.first().copied()),
|
||||
PanelDrag::Appearance(id) => (None, Some(*id)),
|
||||
PanelDrag::Paint { .. } => (None, None),
|
||||
PanelDrag::Paint { .. } | PanelDrag::Symbol(_) => (None, None),
|
||||
};
|
||||
if style.is_none() && source.is_none() {
|
||||
return;
|
||||
|
||||
@@ -571,6 +571,10 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) {
|
||||
None => return,
|
||||
},
|
||||
PanelDrag::GraphicStyle(name) => return pointer_chip(ctx, |ui, r| super::graphic_styles::paint_style(app, ui, r, name)),
|
||||
PanelDrag::Symbol(name) => match app.session.active() {
|
||||
Some(st) => return pointer_chip(ctx, |ui, r| super::symbols::chip(ui, &st.doc, r, name)),
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
pointer_chip(ctx, |ui, r| {
|
||||
if registration {
|
||||
|
||||
@@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use super::{pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
use crate::widgets::{self, PanelDrag, menu_item};
|
||||
|
||||
/// A thumbnail of symbol `name` (an instance at its natural size), cached by the art's identity.
|
||||
fn thumb(ui: &Ui, doc: &Document, name: &str, size: f32) -> Option<egui::TextureHandle> {
|
||||
@@ -50,6 +50,24 @@ fn thumb(ui: &Ui, doc: &Document, name: &str, size: f32) -> Option<egui::Texture
|
||||
Some(tex)
|
||||
}
|
||||
|
||||
/// Symbol `name`'s thumbnail on white in `r` (the panel's tiles and the chip a dragged symbol
|
||||
/// shows at the pointer).
|
||||
pub(crate) fn chip(ui: &Ui, doc: &Document, r: egui::Rect, name: &str) {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
|
||||
if let Some(tex) = thumb(ui, doc, name, r.width()) {
|
||||
ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
/// Art dragged off the canvas over the symbol list: outline it; dropped, the art becomes a new
|
||||
/// symbol (`ids`).
|
||||
fn art_drop(ui: &Ui, zone: &egui::Response) -> Option<Vec<u64>> {
|
||||
let drag = zone.dnd_hover_payload::<PanelDrag>()?;
|
||||
let PanelDrag::Art(ids) = &*drag else { return None };
|
||||
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
|
||||
zone.dnd_release_payload::<PanelDrag>().map(|_| ids.iter().map(|id| id.0).collect())
|
||||
}
|
||||
|
||||
fn current(app: &mut VectorcraftApp) -> Option<String> {
|
||||
app.run("symbol.list", json!({})).ok().and_then(|v| v["current"].as_str().map(str::to_string))
|
||||
}
|
||||
@@ -73,28 +91,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let list: bool = pstate(ui.ctx(), "sym-list");
|
||||
let sel = current(app);
|
||||
let mut clicked: Option<(String, bool)> = None;
|
||||
widgets::list_box(ui, |ui| {
|
||||
let list_rect = widgets::list_box(ui, |ui| {
|
||||
ui.set_min_height(110.0);
|
||||
ui.set_width(ui.available_width());
|
||||
if names.is_empty() {
|
||||
super::empty_state(ui, "spray-can", tl!("No symbols in this document"), tl!("Select art and click New Symbol to make one."));
|
||||
return;
|
||||
return ui.min_rect();
|
||||
}
|
||||
let paint_thumb = |ui: &Ui, r: egui::Rect, n: &str| {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
|
||||
if let Some(tex) = thumb(ui, &doc, n, r.width()) {
|
||||
ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
}
|
||||
};
|
||||
if list {
|
||||
for n in &names {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag());
|
||||
// Dragged onto the canvas, the symbol is placed there.
|
||||
widgets::drag_source(ui, &resp, || PanelDrag::Symbol(n.clone()));
|
||||
if sel.as_deref() == Some(n.as_str()) {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
paint_thumb(ui, egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(22.0, 22.0)), n);
|
||||
chip(ui, &doc, egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(22.0, 22.0)), n);
|
||||
ui.painter().text(r.left_center() + vec2(32.0, 0.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(12.5), t.text);
|
||||
if resp.double_clicked() {
|
||||
clicked = Some((n.clone(), true));
|
||||
@@ -105,8 +119,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
} else {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for n in &names {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::click());
|
||||
paint_thumb(ui, r.shrink(2.0), n);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::click_and_drag());
|
||||
widgets::drag_source(ui, &resp, || PanelDrag::Symbol(n.clone()));
|
||||
chip(ui, &doc, r.shrink(2.0), n);
|
||||
let on = sel.as_deref() == Some(n.as_str());
|
||||
ui.painter().rect_stroke(
|
||||
r,
|
||||
@@ -123,7 +138,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.min_rect()
|
||||
});
|
||||
let zone = ui.interact(list_rect, ui.id().with("symbols-drop"), Sense::hover());
|
||||
if let Some(ids) = art_drop(ui, &zone)
|
||||
&& let Err(e) = app.run("symbol.new", json!({ "ids": ids }))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
if let Some((n, place)) = clicked {
|
||||
app.run("symbol.setCurrent", json!({"name": n})).ok();
|
||||
if place {
|
||||
@@ -182,3 +204,54 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
set_pstate(ui.ctx(), "sym-list", true);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{Pos2, Rect, pos2};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// One headless frame of the panel, 236 pt wide as in the dock.
|
||||
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) {
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 400.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn button(at: Pos2, pressed: bool) -> egui::Event {
|
||||
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
fn symbols(app: &mut VectorcraftApp) -> Vec<String> {
|
||||
let list = app.run("symbol.list", json!({})).unwrap();
|
||||
list["symbols"].as_array().unwrap().iter().map(|s| s["name"].as_str().unwrap_or_default().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_dropped_on_the_panel_becomes_a_symbol_and_a_symbol_drags_out() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
// Selected, as art dragged off the canvas is.
|
||||
let ctx = egui::Context::default();
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
// Art dragged off the canvas and released over the list.
|
||||
let over = pos2(100.0, 50.0);
|
||||
egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(id)]));
|
||||
frame(&ctx, &mut app, vec![egui::Event::PointerMoved(over), button(over, false)]);
|
||||
let names = symbols(&mut app);
|
||||
assert_eq!(names.len(), 1, "{names:?}");
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
let first = doc.layers[0].children().unwrap().first().unwrap();
|
||||
assert!(matches!(first.kind, NodeKind::SymbolInstance { .. }), "the art became an instance");
|
||||
// Dragging the symbol's tile out of the panel carries the symbol.
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
let tile = pos2(26.0, 26.0);
|
||||
frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile), button(tile, true)]);
|
||||
frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile + vec2(40.0, 40.0))]);
|
||||
frame(&ctx, &mut app, vec![egui::Event::PointerMoved(tile + vec2(80.0, 80.0))]);
|
||||
assert_eq!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).as_deref(), Some(&PanelDrag::Symbol(names[0].clone())));
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,12 @@ pub fn format(sc: &KeyboardShortcut) -> String {
|
||||
}
|
||||
let k = sc.logical_key;
|
||||
let name = match k {
|
||||
// A shifted punctuation key is recorded as the key, not the character it types.
|
||||
Key::CloseCurlyBracket if m.shift => "]",
|
||||
Key::OpenCurlyBracket if m.shift => "[",
|
||||
Key::Questionmark if m.shift => "/",
|
||||
Key::Colon if m.shift => ";",
|
||||
Key::Pipe if m.shift => "\\",
|
||||
Key::CloseBracket => "]",
|
||||
Key::OpenBracket => "[",
|
||||
Key::Semicolon => ";",
|
||||
|
||||
@@ -75,7 +75,22 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json
|
||||
/// is ignored). `native`: the system menu already handles the chord as written, so only that
|
||||
/// alias is left to match here.
|
||||
pub(crate) fn consume(i: &mut egui::InputState, sc: &KeyboardShortcut, native: bool) -> bool {
|
||||
(!native && i.consume_shortcut(sc)) || (sc.logical_key == Key::Equals && i.consume_key(sc.modifiers, Key::Plus))
|
||||
(!native && i.consume_shortcut(sc))
|
||||
|| (sc.logical_key == Key::Equals && i.consume_key(sc.modifiers, Key::Plus))
|
||||
|| (sc.modifiers.shift && shifted(sc.logical_key).is_some_and(|k| i.consume_key(sc.modifiers, k)))
|
||||
}
|
||||
|
||||
/// The key a shifted punctuation key arrives as: egui reports the character typed, so Cmd+Shift+[
|
||||
/// comes in as Cmd+Shift+`{` (US-style layouts).
|
||||
fn shifted(k: Key) -> Option<Key> {
|
||||
Some(match k {
|
||||
Key::OpenBracket => Key::OpenCurlyBracket,
|
||||
Key::CloseBracket => Key::CloseCurlyBracket,
|
||||
Key::Slash => Key::Questionmark,
|
||||
Key::Semicolon => Key::Colon,
|
||||
Key::Backslash => Key::Pipe,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.)
|
||||
@@ -371,12 +386,16 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
let upper = text.to_uppercase();
|
||||
let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() };
|
||||
// A shifted punctuation character can also be written with its Shift (the Curvature tool's
|
||||
// Shift+~ arrives as `~`).
|
||||
let shifted = m.shift.then(|| format!("Shift+{text}"));
|
||||
let lookup = |find: fn(&str) -> Option<&'static str>| find(&key).or_else(|| find(&text)).or_else(|| shifted.as_deref().and_then(find));
|
||||
// Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default).
|
||||
if let Some(id) = crate::shortcut_editor::command_for_key(&key).or_else(|| crate::shortcut_editor::command_for_key(&text)) {
|
||||
if let Some(id) = lookup(crate::shortcut_editor::command_for_key) {
|
||||
let _ = app.run(id, json!({}));
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = crate::shortcut_editor::tool_for_key(&key).or_else(|| crate::shortcut_editor::tool_for_key(&text)) {
|
||||
if let Some(t) = lookup(crate::shortcut_editor::tool_for_key) {
|
||||
app.select_tool(t);
|
||||
}
|
||||
}
|
||||
@@ -442,6 +461,36 @@ mod tests {
|
||||
assert_eq!(fill(&app).color().unwrap().to_hex(), "#336699", "`,` applies the last colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifted_punctuation_shortcuts_work_as_typed() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let a = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||||
let b = app.session.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||||
app.session.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
let order =
|
||||
|app: &VectorcraftApp| -> Vec<u64> { app.session.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id.0).collect() };
|
||||
let (a, b) = (a.as_u64().unwrap(), b.as_u64().unwrap());
|
||||
assert_eq!(order(&app), [a, b]);
|
||||
let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT;
|
||||
// Recorded in the Keyboard Shortcuts editor, they read as the keys (and so conflict).
|
||||
assert_eq!(crate::shortcut_editor::chord_from_event(Key::CloseCurlyBracket, cmd_shift).as_deref(), Some("Cmd+Shift+]"));
|
||||
assert_eq!(crate::shortcut_editor::chord_from_event(Key::Questionmark, cmd_shift).as_deref(), Some("Cmd+Shift+/"));
|
||||
// Cmd+Shift+] arrives as Cmd+Shift+`}`, Cmd+Shift+[ as Cmd+Shift+`{`.
|
||||
frame(&mut app, vec![press(Key::CloseCurlyBracket, cmd_shift)]);
|
||||
assert_eq!(order(&app), [b, a], "Bring to Front");
|
||||
frame(&mut app, vec![press(Key::OpenCurlyBracket, cmd_shift)]);
|
||||
assert_eq!(order(&app), [a, b], "Send to Back");
|
||||
// Cmd+Shift+/ arrives as Cmd+Shift+`?`: Search Commands.
|
||||
frame(&mut app, vec![press(Key::Questionmark, cmd_shift)]);
|
||||
assert!(app.ui.palette_open, "Search Commands");
|
||||
app.ui.palette_open = false;
|
||||
// The Curvature tool's Shift+~ arrives as the text `~` with Shift.
|
||||
frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), egui::Event::Text("~".into())]);
|
||||
assert_eq!(app.session.tool_id(), "curvature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_typed_any_way_zooms_in() {
|
||||
assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key");
|
||||
|
||||
@@ -17,11 +17,15 @@ pub struct View {
|
||||
pub fitted: bool,
|
||||
/// View rotation in degrees (Rotate View tool).
|
||||
pub rotation: f64,
|
||||
/// The artboard the status bar's navigator is on (an index): Fit Artboard in Window and Actual
|
||||
/// Size show it.
|
||||
#[serde(default)]
|
||||
pub artboard: usize,
|
||||
}
|
||||
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
Self { zoom: 1.0, center: Point::new(306.0, 396.0), fitted: false, rotation: 0.0 }
|
||||
Self { zoom: 1.0, center: Point::new(306.0, 396.0), fitted: false, rotation: 0.0, artboard: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +38,7 @@ impl View {
|
||||
center: v.center,
|
||||
fitted: true,
|
||||
rotation: if v.rotation.is_finite() { v.rotation } else { 0.0 },
|
||||
artboard: 0,
|
||||
},
|
||||
_ => Self::default(),
|
||||
}
|
||||
@@ -235,6 +240,9 @@ pub struct UiState {
|
||||
pub flyout: Option<usize>,
|
||||
/// Last tool shown for each toolbar group (flyout selection sticks).
|
||||
pub group_tool: Vec<String>,
|
||||
/// The selection tool used last (Selection, Direct Selection or Group Selection): a Cmd press
|
||||
/// with any other tool drags with it.
|
||||
pub last_selection_tool: String,
|
||||
pub status: String,
|
||||
pub palette_open: bool,
|
||||
pub palette_query: String,
|
||||
@@ -363,6 +371,7 @@ impl Default for UiState {
|
||||
dialog: None,
|
||||
flyout: None,
|
||||
group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(),
|
||||
last_selection_tool: "selection".into(),
|
||||
status: String::new(),
|
||||
palette_open: false,
|
||||
palette_query: String::new(),
|
||||
|
||||
@@ -208,9 +208,21 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
/// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are
|
||||
/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog, a Liquify tool's its Tool
|
||||
/// Options dialog, the Blend tool's Blend
|
||||
/// Options; the Print Tiling tool's resets the print tiling.
|
||||
/// Options; the Print Tiling tool's resets the print tiling. As in the reference app, the Hand
|
||||
/// tool's fits the artboard in the window, the Zoom tool's shows it at 100%, and the Rotate, Scale,
|
||||
/// Reflect and Shear tools' are their Object › Transform dialogs.
|
||||
pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::Value, String> {
|
||||
match tool {
|
||||
"hand" => app.run("view.fitArtboard", json!({})),
|
||||
"zoom" => app.run("view.actualSize", json!({})),
|
||||
"rotate" | "scale" | "reflect" | "shear" => {
|
||||
let id = format!("object.{tool}");
|
||||
if let Some(c) = vectorcraft_engine::find_command(&id) {
|
||||
(c.enabled)(&app.session)?;
|
||||
}
|
||||
crate::menus::invoke(app, &id, json!({}));
|
||||
Ok(json!({ "dialog": tool }))
|
||||
}
|
||||
"gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })),
|
||||
"gradient" => app.run("window.panel", json!({ "panel": "gradient" })),
|
||||
"eyedropper" => {
|
||||
@@ -486,7 +498,35 @@ pub(crate) mod tests {
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let o = app.session.prefs.eyedropper;
|
||||
assert!(o.pick_up.appearance.transparency && !o.apply.appearance.transparency && o.apply.appearance.fill.color);
|
||||
assert!(app.run("tool.options", json!({"tool": "zoom"})).is_err());
|
||||
assert!(app.run("tool.options", json!({"tool": "lasso"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_the_hand_zoom_and_transform_tools() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(660.0, 460.0)));
|
||||
app.run("view.setZoom", json!({"zoom": 333, "center": [10, 10]})).unwrap();
|
||||
// Hand: the artboard fits the window. Zoom: 100%.
|
||||
app.run("tool.options", json!({"tool": "hand"})).unwrap();
|
||||
let v = *app.view().unwrap();
|
||||
assert_eq!((v.center.x, v.center.y, v.zoom), (150.0, 100.0, 2.0));
|
||||
app.run("tool.options", json!({"tool": "zoom"})).unwrap();
|
||||
assert_eq!(app.view().unwrap().zoom, 1.0);
|
||||
// The transform tools open their dialogs, like Object › Transform: not with nothing selected.
|
||||
assert_eq!(app.run("tool.options", json!({"tool": "rotate"})), Err("nothing selected".into()));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
for tool in ["rotate", "scale", "reflect", "shear"] {
|
||||
app.run("tool.options", json!({"tool": tool})).unwrap();
|
||||
assert_eq!(app.ui.dialog.take().map(|d| d.kind), Some(tool.to_string()));
|
||||
}
|
||||
// OK in Rotate turns the selection about its centre.
|
||||
app.run("tool.options", json!({"tool": "rotate"})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(90));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 20.0).abs() < 1e-9 && (b.y0 - 0.0).abs() < 1e-9 && (b.width() - 20.0).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1140,8 +1140,12 @@ pub enum PanelDrag {
|
||||
/// `ids`); the panel moves it to where it is dropped.
|
||||
GraphicStyle(String),
|
||||
/// The selected art, dragged off the canvas with the Selection tool: the Graphic Styles panel
|
||||
/// makes a style of the first object (`graphicStyle.new`).
|
||||
/// makes a style of the first object (`graphicStyle.new`), the Symbols panel a symbol of it all
|
||||
/// (`symbol.new`).
|
||||
Art(Vec<vectorcraft_doc::NodeId>),
|
||||
/// A Symbols panel symbol: the canvas places an instance of it centred where it is dropped
|
||||
/// (`symbol.place`).
|
||||
Symbol(String),
|
||||
}
|
||||
|
||||
/// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
`vectorcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). One JSON request per line:
|
||||
`{"id": 1, "method": "ui.inspect", "params": {}}` → `{"id": 1, "ok": true, "result": {...}}` or `{"id":1,"ok":false,"error":"..."}`.
|
||||
|
||||
**Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params`
|
||||
are optional; blank lines are skipped). Anything else gets one error reply
|
||||
(`{"ok": false, "error": "… closing the connection"}`) and the server **closes the connection**, so
|
||||
nothing sent after it on that connection runs. That covers text that isn't JSON, a JSON array or number,
|
||||
an object without `method`, invalid UTF-8, and a line longer than 4 MiB. An HTTP request (for example a
|
||||
web page's cross-origin `fetch` to `127.0.0.1:<port>`) therefore can't smuggle a command in its body:
|
||||
its request line is rejected first. At most 16 connections are served at once; further ones get an error
|
||||
line and are closed. Clients that get an error reply should reconnect. The port has no authentication,
|
||||
so only enable it while you use it. Transport: `apps/vectorcraft/src/control_server.rs`.
|
||||
|
||||
| Method | Params | |
|
||||
|---|---|---|
|
||||
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
|
||||
@@ -77,7 +87,9 @@ point (`paint.freeform.selectPoint`); its fields are `color` (hex), `opacity` an
|
||||
Tool options: double-clicking a tool button runs `tool.options {tool}`. For `gradient` it opens the Gradient panel;
|
||||
for `eyedropper` it opens Eyedropper Options, an `eyedropperOptions` dialog (fields `sampleSize` 1/3/5, `pickUp` and
|
||||
`apply`, the attribute trees of `eyedropper.setOptions`) whose `ui.dialog.confirm` runs `eyedropper.setOptions` (what
|
||||
`appearance.copyFrom` copies). Gradient tool handles snap to
|
||||
`appearance.copyFrom` copies). For `hand` it fits the artboard in the window (`view.fitArtboard`) and for `zoom` it
|
||||
shows 100% (`view.actualSize`). For `rotate`, `scale`, `reflect` and `shear` it opens the same dialog as Object ›
|
||||
Transform (dialog kind = the tool id), or fails with `nothing selected`. Gradient tool handles snap to
|
||||
anchors, edges and smart guides; Shift constrains them to 45° steps from the `constrainAngle` preference.
|
||||
|
||||
Effect dialogs: `engine.execute {command: "effect.dialog", params: {effect, index?, item?}}` opens the `effect` dialog
|
||||
|
||||
+2
-2
@@ -66,8 +66,8 @@ text at render time from one catalog per language (`i18n/<code>.tsv`; the format
|
||||
always use the English ids and labels, so agents and scripts never see translated text.
|
||||
|
||||
Languages shipped: English (`en`, the source), Traditional Chinese (`zh-hant`, complete, in the vocabulary used
|
||||
in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve to it), Czech (`cs`, every menu
|
||||
label) and Japanese (`ja`, the main menus so far). Untranslated text falls back to English until its rows are
|
||||
in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve to it), Czech (`cs`) and Japanese (`ja`), every menu
|
||||
label for both. Untranslated text falls back to English until its rows are
|
||||
added. Simplified Chinese locales
|
||||
(`zh-CN`, `zh-SG`, `zh-Hans`) fall back to English until a `zh-hans` catalog is registered: the resolver already
|
||||
tells the two scripts apart, so the Traditional catalog is never shown to a Simplified locale.
|
||||
|
||||
+30
-13
@@ -147,7 +147,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
|---|---|---|
|
||||
| `list_commands` | `{filter?, enabledOnly?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement. |
|
||||
| `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. |
|
||||
| `inspect_document` | `{}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. |
|
||||
| `inspect_document` | `{}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. |
|
||||
| `inspect_ui` | `{}` | UI state. Remote mode only. |
|
||||
| `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … |
|
||||
| `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. |
|
||||
@@ -159,9 +159,9 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |
|
||||
| `open_panel` | `{panel}` | Remote only. |
|
||||
| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). |
|
||||
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. |
|
||||
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. |
|
||||
| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). |
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
|
||||
| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). |
|
||||
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
|
||||
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
|
||||
@@ -755,8 +755,9 @@ fills and strokes become objects among its members, and its members are expanded
|
||||
|
||||
A colour linked to a global or spot swatch has a tint (the reference app's T slider): `paint.setFill {swatch,
|
||||
tint?: 0..100}` (and `paint.setStroke`) applies the swatch at that percentage, linked, so `swatch.edit` recolours it
|
||||
at its own tint. `paint.proxies` and `document.inspect` show the paint as `{type: "solid", color, swatch, tint}`
|
||||
(`tint` 0..1, left out at 100 %). `swatch.new {tint?}` with the current fill (or `swatch`) saves a tint swatch,
|
||||
at its own tint. `paint.proxies` shows the paint as `{type: "solid", color, swatch, tint}` (`tint` 0..1, left out at
|
||||
100 %); `document.inspect` reports it as a label, the swatch name, the tint below 100 % and the colour it gives
|
||||
(`"G1 40% (#ff9999)"`, `"G1 (#ff0000)"`). `swatch.new {tint?}` with the current fill (or `swatch`) saves a tint swatch,
|
||||
"Name 40%": `swatch.list` reports it with `tintOf` and `tint`, applying it links to its base at that tint, and it
|
||||
follows edits of its base. A spot tint prints that percentage of its plate (Separations Preview, PDF Separation
|
||||
value). `edit.colors.adjustBalance {tint: -100..100}` (Global mode) shifts the tints of the selection's linked
|
||||
@@ -1679,14 +1680,16 @@ PostScript is read instead and the first warning says why.
|
||||
|
||||
Other files are run through a small PostScript interpreter (Level 3, first page only): paths, fills and strokes with
|
||||
their width, caps, joins, miter limit and dashes (a fill and a stroke of the same path become one object), grey, RGB,
|
||||
CMYK, indexed and spot colours (a Separation ink becomes a spot swatch, painted at its tint), clips (clipping groups),
|
||||
`gsave`/`grestore`, `save`/`restore`, transforms, procedures with `bind def`, loops and dictionaries, axial and radial
|
||||
shadings and shading patterns (gradients), images and image masks (data in the file through ASCII85, hex, run-length,
|
||||
Flate, LZW or DCT filters, or from procedures), and type as point type in the font the file names (embedded font
|
||||
programs are skipped). The artboard is the `%%HiResBoundingBox` (else `%%BoundingBox`; a letter page without one).
|
||||
A program the interpreter can't run (an operator it doesn't know, an error, a runaway loop) or that draws nothing comes
|
||||
in as its TIFF preview with a warning; without a preview, the art drawn up to the error is kept with a warning, and a
|
||||
file with none is refused with a message saying why.
|
||||
CMYK, indexed and spot colours (a Separation ink becomes a spot swatch, painted at its tint), clips (clipping groups;
|
||||
`clipsave`/`cliprestore`), `gsave`/`grestore`, `save`/`restore`, transforms, procedures with `bind def`, loops,
|
||||
dictionaries, arrays and strings whose intervals share storage, resources (categories made from `Generic`,
|
||||
`resourceforall`), axial and radial shadings and shading patterns (gradients), images and image masks (data in the
|
||||
file through ASCII85, hex, run-length, Flate, LZW or DCT filters, or from procedures), and type as point type in the
|
||||
font the file names (embedded font programs are skipped). The artboard is the `%%HiResBoundingBox` (else
|
||||
`%%BoundingBox`; a letter page without one). A program the interpreter can't run (an operator it doesn't know, an
|
||||
error, a runaway loop) or that draws nothing comes in as its TIFF preview (palette previews with an alpha channel too)
|
||||
with a warning; without a preview, the art drawn up to the error is kept with a warning, and a file with none is
|
||||
refused with a message saying why.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/logo.eps"}}}
|
||||
@@ -1787,6 +1790,20 @@ its undo history are as if the click never happened. Commands on the edited text
|
||||
{"name":"run_command","arguments":{"command":"text.discardEmpty","params":{"id":42}}}
|
||||
```
|
||||
|
||||
## Resizing area type
|
||||
|
||||
As in the reference app, dragging a bounding-box handle of area type resizes its type area instead of scaling the
|
||||
type: the Selection tool sends `object.transform {matrix, typeAreas: true}`, which reshapes the frame of each area
|
||||
type object it transforms (other objects, type inside groups and point type transform as usual) and reflows the
|
||||
text at its size, through its thread too. `text.reshapeArea {id, anchors: [[subpath, anchor]…], dx, dy}` moves
|
||||
frame anchors with their handles (Direct Selection dragging a corner, or an edge's two ends), and
|
||||
`text.areaOptions {width?, height?}` sizes the area from its top-left corner (the query returns both). Each is one
|
||||
undo step. Object › Transform › Scale, the Scale tool and the Transform panel still scale the type.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}}
|
||||
```
|
||||
|
||||
## Constrain proportions
|
||||
|
||||
The link between W and H in the Transform panel, the Properties panel and the Control bar is the
|
||||
|
||||
Reference in new issue
Block a user