mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-16 20:26:55 -04:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7255ac394d | ||
|
|
c2711df30b | ||
|
|
f86618777e | ||
|
|
14d8efcabf | ||
|
|
8a19490ea0 | ||
|
|
0e6ca8f88d | ||
|
|
e8255f2bd5 | ||
|
|
dd1a32bb8e | ||
|
|
257c8ec435 | ||
|
|
ad8458832d | ||
|
|
a01b0dfc7a | ||
|
|
ddd1c8fade | ||
|
|
eefe849e14 | ||
|
|
00344f9b68 | ||
|
|
b8a61e90ce | ||
|
|
f2b35fc039 | ||
|
|
133ad87541 | ||
|
|
f8a7695c3a | ||
|
|
66cb7848f3 | ||
|
|
ee16d10fd4 | ||
|
|
82af7a3108 | ||
|
|
46e4262376 | ||
|
|
c16804237e | ||
|
|
4b9f9b9bc2 | ||
|
|
008b5e6d6c | ||
|
|
640a46a76f | ||
|
|
2d12b39261 | ||
|
|
d53074487c |
No files matched your search
@@ -200,7 +200,7 @@ and masks, text and images, with what didn't come in (effects, adjustments, brus
|
||||
import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
|
||||
doesn't write Affinity files. [Scope and limits](crates/affinity/README.md).
|
||||
The interface
|
||||
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian and Russian (and Czech and Brazilian Portuguese in the menus).
|
||||
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus).
|
||||
The scores are
|
||||
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
|
||||
|
||||
|
||||
+9
-9
@@ -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-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -107,7 +107,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
|
||||
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
|
||||
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian and Russian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), 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).
|
||||
- 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), Simplified Chinese (mainland), Japanese, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), 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. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
|
||||
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
|
||||
@@ -134,7 +134,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **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 (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **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, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
|
||||
@@ -145,8 +145,8 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
|
||||
- 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) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). 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. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
|
||||
- **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 and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian and Russian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian and Russian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. 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 applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; 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.
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). 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. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724).
|
||||
- **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 and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. 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 applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; 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.
|
||||
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
|
||||
- **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 (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (`object.cropImage {trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
@@ -165,7 +165,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **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 (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; 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, an `.ai`'s white page under its layers left out rather than coming in as art, 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). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
- 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 a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
- 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 a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
- Affinity documents in (AF.1–AF.3): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. 22 public CC0/MIT/Apache-2.0 files, pinned and sha256-verified (`cargo xtask corpus --affinity`), must render within a few levels of the thumbnails Affinity saved in them; [scope, provenance and limits](crates/affinity/README.md).
|
||||
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
|
||||
- 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; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
|
||||
@@ -184,7 +184,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.3 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| 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 gap detection, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| 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 |
|
||||
@@ -231,7 +231,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| 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 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 20 Preferences options stored but not yet acted on (#394: Spacing Guides, Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation, Appearance of Black, devices…) | 14–22 |
|
||||
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 5–8 |
|
||||
| Libraries, Links, Package | 2 | 80% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles are done (#745); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
|
||||
@@ -250,7 +250,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form
|
||||
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
|
||||
|
||||
## Out of scope (by design or by law)
|
||||
- **Native `.ai` private data, beyond its structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type that shows comes from the page, which draws it, into its text object's place (no group around it); type that doesn't (hidden layers and objects, art off the page) is made from the file's text document (characters, font, size, colour, alignment and where it is, for point type), and area type and type on a path in it are left out with a note. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike); the page is used when they differ by more than 5%. A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
- **Native `.ai` private data, beyond its structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type that shows comes from the page, which draws it, into its text object's place (no group around it); type that doesn't (hidden layers and objects, art off the page) is made from the file's text document (characters, font, size, colour, alignment and where it is, for point type), and area type and type on a path in it are left out with a note. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type made from the text document and other type left out with a note. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike); the page is used when they differ by more than 5%. A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
|
||||
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
|
||||
|
||||
|
||||
@@ -489,6 +489,12 @@ fn main() -> std::process::ExitCode {
|
||||
app.session.swatch_libraries.set_user_dir(swatches);
|
||||
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
|
||||
app.session.style_libraries.set_user_dir(styles);
|
||||
// So do the Libraries panel's libraries; runs without preferences (agents' test
|
||||
// runs) keep theirs for the session only, never touching the user's.
|
||||
if prefs_enabled() {
|
||||
let libraries = prefs_path().and_then(|p| Some(p.parent()?.join("Libraries").to_string_lossy().to_string()));
|
||||
app.session.libraries.set_dir(libraries);
|
||||
}
|
||||
// Data Recovery copies live next to the preferences too (none for runs without
|
||||
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
|
||||
|
||||
@@ -35,3 +35,8 @@ sync_github_name = true
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.AutoHenry]
|
||||
real_name = "Hal Goff"
|
||||
display_name = "Hal Goff, HG Studio"
|
||||
sync_github_name = false
|
||||
+10
-2
@@ -96,7 +96,7 @@ pub use vectorcraft_geom as geom;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Swatch, SwatchGroup};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum DocError {
|
||||
@@ -741,7 +741,8 @@ impl Document {
|
||||
}
|
||||
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
|
||||
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
|
||||
self.next_id = self.next_id.max(max + 1);
|
||||
// An id of u64::MAX (a damaged file) can't overflow: ids after it are reused at worst.
|
||||
self.next_id = self.next_id.max(max.saturating_add(1));
|
||||
}
|
||||
|
||||
/// Find a node anywhere in the tree.
|
||||
@@ -1006,6 +1007,13 @@ impl Document {
|
||||
*g = g.moved(d);
|
||||
}
|
||||
}
|
||||
/// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork
|
||||
/// with Artboard).
|
||||
pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) {
|
||||
for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) {
|
||||
g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y };
|
||||
}
|
||||
}
|
||||
/// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their
|
||||
/// artboard).
|
||||
pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) {
|
||||
|
||||
@@ -154,11 +154,18 @@ impl Clipboard {
|
||||
}
|
||||
vectorcraft_brush::store(&mut d, &lib);
|
||||
}
|
||||
let mut layer = Node::layer(NodeId(u64::MAX), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
|
||||
// The layer's id is above every copied object's, so the document saves and reopens as
|
||||
// a native file.
|
||||
let mut top = 0;
|
||||
for n in &self.nodes {
|
||||
n.walk(&mut |c| top = top.max(c.id.0));
|
||||
}
|
||||
let mut layer = Node::layer(NodeId(top.saturating_add(1)), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = self.nodes.iter().cloned().map(Arc::new).collect();
|
||||
}
|
||||
d.layers = vec![Arc::new(layer)];
|
||||
d.fix_next_id();
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +287,8 @@ enum PasteMode {
|
||||
Back,
|
||||
InPlace,
|
||||
AllArtboards,
|
||||
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
|
||||
Library,
|
||||
}
|
||||
|
||||
impl PasteMode {
|
||||
@@ -297,6 +299,7 @@ impl PasteMode {
|
||||
PasteMode::InPlace => "Paste in Place",
|
||||
PasteMode::AllArtboards => "Paste on All Artboards",
|
||||
PasteMode::Offset => "Paste",
|
||||
PasteMode::Library => "Place from Library",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,6 +313,14 @@ fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
|
||||
r
|
||||
}
|
||||
|
||||
/// Place the objects of `clip` (a library graphic) into the active document centred on `center`,
|
||||
/// as one undo step: their resources come along as a paste's do, a swatch whose name the document
|
||||
/// gives another colour merged into the document's.
|
||||
pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_geom::Point) -> Result<Value> {
|
||||
let choices = SwatchChoices::parse(PasteMode::Library.label(), &json!({}))?;
|
||||
paste_clip(s, &json!({ "center": [center.x, center.y] }), PasteMode::Library, clip, &choices)
|
||||
}
|
||||
|
||||
/// Paste in Place, in Front or in Back onto artboard `artboard` (the active one, #693): where the
|
||||
/// objects were on the artboard they were copied from, on that one; without it (or a copy from no
|
||||
/// artboard), where they were.
|
||||
@@ -348,7 +359,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
};
|
||||
let placements: Vec<Affine> = match (mode, board) {
|
||||
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
||||
(PasteMode::Offset, None) => vec![match point_param(p, "center") {
|
||||
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
|
||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
|
||||
}],
|
||||
|
||||
@@ -150,6 +150,20 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
|
||||
(doc, warnings)
|
||||
}
|
||||
|
||||
/// A `.ai` saved without PDF compatibility (its PDF part is a placeholder page) from the editor's
|
||||
/// own copy of its art alone: `None` when the file has none (or pages are picked, or layers are
|
||||
/// off), an error when it can't be read.
|
||||
fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(Document, Vec<String>)>> {
|
||||
if opts.pages.is_some() || !opts.layers {
|
||||
return None;
|
||||
}
|
||||
let private = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref())?;
|
||||
Some(
|
||||
vectorcraft_eps::ai_alone(&private)
|
||||
.map_err(|why| err(format!("this file was saved without PDF compatibility, and its editing data can't be read ({why})"))),
|
||||
)
|
||||
}
|
||||
|
||||
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
|
||||
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
|
||||
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
|
||||
@@ -188,9 +202,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
|
||||
.flatten()
|
||||
.map(|e| (e.intact, move || Some(e.data)));
|
||||
restore_or_import(editing, || {
|
||||
let (doc, warnings) = super::pdfimport::import(bytes, opts)?;
|
||||
Ok(through_editing_data(bytes, opts, doc, warnings))
|
||||
restore_or_import(editing, || match super::pdfimport::import(bytes, opts) {
|
||||
Ok((doc, warnings)) => Ok(through_editing_data(bytes, opts, doc, warnings)),
|
||||
// Saved without its PDF part: the editing copy is all the file has.
|
||||
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
|
||||
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
})?
|
||||
}
|
||||
"dxf" => {
|
||||
|
||||
@@ -54,6 +54,7 @@ pub(crate) use load::native_file;
|
||||
pub(crate) use load::source;
|
||||
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
|
||||
pub use load::{losses_summary, overwrite_losses};
|
||||
pub(crate) use native::preview_png;
|
||||
pub use native::with_compression_pref;
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
|
||||
@@ -186,7 +186,7 @@ pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
|
||||
|
||||
/// The first artboard (else the art) as a PNG fitted into [`PREVIEW_MAX`] pixels (`None`: nothing
|
||||
/// to show).
|
||||
fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
|
||||
pub(crate) fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
|
||||
let Some(r) = doc.artboards.first().map(|a| a.rect).or_else(|| vectorcraft_render::encode::art_bounds(doc)) else { return Ok(None) };
|
||||
let scale = f64::from(PREVIEW_MAX) / r.width().max(r.height());
|
||||
if vectorcraft_render::raster_size(r, scale).is_err() {
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export PDF",
|
||||
[],
|
||||
None,
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options ac
Line truncated
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null k
Line truncated
|
||||
has_doc,
|
||||
export_pdf
|
||||
),
|
||||
|
||||
@@ -91,3 +91,20 @@ fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
|
||||
let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("symbols") && r["warnings"].to_string().contains("PDF part"), "{r}");
|
||||
}
|
||||
|
||||
/// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has.
|
||||
const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET";
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER), json!({})).unwrap();
|
||||
assert_eq!(r["format"], json!("ai"), "{r}");
|
||||
check_sample(&s);
|
||||
// Its editing data damaged: it says so (the placeholder isn't the art).
|
||||
let e = open(&mut s, "damaged.ai", &ai::ai(b"%AI12_CompressedData not zlib", PLACEHOLDER), json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
|
||||
// Without editing data, the placeholder still isn't opened as the art.
|
||||
let e = open(&mut s, "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
@@ -10,8 +10,8 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Artboard Options…",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index, name?, x?, y?, width?, height?}",
|
||||
"{index, name?, x?, y?, width?, height?, scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale)} → null, or with scaleArt {scaled: the art scaled}",
|
||||
has_doc,
|
||||
artboard_set
|
||||
),
|
||||
@@ -752,21 +752,62 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let now = rect_from(p, was);
|
||||
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
|
||||
// Scale Artwork with Artboard: resized, it takes the art fully inside it (as Move Artwork with
|
||||
// Artboard gathers it) and its guides from the old rectangle onto the new one.
|
||||
let scale_art = bool_or(p, "scaleArt", false);
|
||||
let fit = if scale_art && resized { rect_map(was, now) } else { None };
|
||||
let art = if fit.is_some() {
|
||||
// Locked and hidden art too? Noted in the journal, so a replay scales the same objects
|
||||
// whatever the preference is then.
|
||||
let all = bool_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard);
|
||||
s.note_journal("lockedAndHidden", json!(all));
|
||||
s.doc()?.doc.art_on_artboard(was, all)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let mut sc = match fit {
|
||||
Some(xf) if Scaling::factor(xf).is_some() => super::object::scaling(s, p),
|
||||
_ => Scaling::default(),
|
||||
};
|
||||
sc.patterns = super::object::transform_patterns(s, p, &art)?;
|
||||
s.edit("Artboard Options", |d, _| {
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let was = a.rect;
|
||||
a.rect = rect_from(p, a.rect);
|
||||
a.rect = now;
|
||||
if let Some(n) = str_param(p, "name") {
|
||||
a.name = n.to_string();
|
||||
}
|
||||
// Moved (not resized), it takes its guides along.
|
||||
let (id, now) = (a.id, a.rect);
|
||||
if (now.width() - was.width()).abs() <= 1e-6 && (now.height() - was.height()).abs() <= 1e-6 {
|
||||
let id = a.id;
|
||||
if let Some(xf) = fit {
|
||||
d.map_artboard_guides(id, xf);
|
||||
for id in &art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(xf, sc);
|
||||
}
|
||||
}
|
||||
} else if !resized {
|
||||
// Moved (not resized), it takes its guides along.
|
||||
d.move_artboard_guides(id, now.origin() - was.origin());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
if !scale_art {
|
||||
return ok();
|
||||
}
|
||||
Ok(json!({ "scaled": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// The map taking artboard rectangle `from` onto `to`, each side scaled by its own ratio. None
|
||||
/// when `from` has no area or the map isn't finite (a size out of range).
|
||||
fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
|
||||
if from.width() <= 1e-9 || from.height() <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let (sx, sy) = (to.width() / from.width(), to.height() / from.height());
|
||||
let xf = Affine::translate(to.origin().to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-from.origin().to_vec2());
|
||||
xf.is_finite().then_some(xf)
|
||||
}
|
||||
|
||||
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
//! Libraries (Window › Libraries): local libraries of graphics, colours and text styles that every
|
||||
//! document can use. A library is one `.vclibrary` file (JSON) in the library folder next to the
|
||||
//! preferences, which the desktop app sets; without a folder (the web, headless sessions) libraries
|
||||
//! live for the session only. A graphic is kept as a native document with a PNG thumbnail: adding
|
||||
//! one copies the selection as Copy does, and placing it pastes a copy as Paste does (its images,
|
||||
//! symbols, patterns and swatches come along; a swatch name the document gives another colour is
|
||||
//! merged into the document's).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::TextStyleDef;
|
||||
|
||||
use super::*;
|
||||
use crate::Clipboard;
|
||||
|
||||
/// The extension of a library file.
|
||||
pub const LIBRARY_EXT: &str = "vclibrary";
|
||||
/// Bytes a library file may have when read (graphics with large images included).
|
||||
const MAX_FILE: u64 = 512 << 20;
|
||||
/// Items of one kind a library may hold.
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
|
||||
/// A library: its name and its items, newest last.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Library {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub colors: Vec<LibraryColor>,
|
||||
#[serde(default)]
|
||||
pub char_styles: Vec<TextStyleDef>,
|
||||
#[serde(default)]
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
#[serde(default)]
|
||||
pub graphics: Vec<LibraryGraphic>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LibraryColor {
|
||||
pub name: String,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
/// A graphic: the art as a native document (its own resources included) and its thumbnail.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryGraphic {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Size of the art (points).
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
/// The native document, base64.
|
||||
pub data: String,
|
||||
/// A PNG of the art, at most 256 pixels a side, base64 (empty when it draws nothing).
|
||||
#[serde(default)]
|
||||
pub thumbnail: String,
|
||||
}
|
||||
|
||||
/// The libraries of a session ([`Session::libraries`]).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Libraries {
|
||||
/// The library folder: none in the web app and headless sessions.
|
||||
dir: Option<String>,
|
||||
/// (id: the file's stem, library), in name order.
|
||||
libs: Vec<(String, Library)>,
|
||||
/// The library the panel shows, which commands without a `library` act on.
|
||||
current: Option<String>,
|
||||
}
|
||||
|
||||
impl Libraries {
|
||||
pub fn dir(&self) -> Option<&str> {
|
||||
self.dir.as_deref()
|
||||
}
|
||||
|
||||
/// Set the library folder and read its libraries (unreadable files are skipped).
|
||||
pub fn set_dir(&mut self, dir: Option<String>) {
|
||||
self.dir = dir;
|
||||
self.libs.clear();
|
||||
if let Some(dir) = self.dir.clone() {
|
||||
for path in library_paths(&dir) {
|
||||
let id = stem_of(&path);
|
||||
let lib = super::fileio::file_stamp(&path)
|
||||
.filter(|(len, _)| *len <= MAX_FILE)
|
||||
.and_then(|_| super::fileio::read_file(&path).ok())
|
||||
.and_then(|b| serde_json::from_slice::<Library>(&b).ok());
|
||||
if let Some(mut lib) = lib {
|
||||
lib.cap();
|
||||
self.libs.push((id, lib));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sort();
|
||||
}
|
||||
|
||||
fn sort(&mut self) {
|
||||
self.libs.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()).then(a.0.cmp(&b.0)));
|
||||
}
|
||||
|
||||
/// The libraries, as (id, library), in name order.
|
||||
pub fn all(&self) -> &[(String, Library)] {
|
||||
&self.libs
|
||||
}
|
||||
|
||||
/// The library the panel shows: the one chosen last, else the first.
|
||||
pub fn current(&self) -> Option<&str> {
|
||||
self.current.as_deref().filter(|c| self.get(c).is_some()).or_else(|| self.libs.first().map(|(i, _)| i.as_str()))
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Library> {
|
||||
self.libs.iter().find(|(i, _)| i == id).map(|(_, l)| l)
|
||||
}
|
||||
|
||||
/// Library `key` by id, else by name (ignoring case).
|
||||
fn find(&self, key: &str) -> Option<String> {
|
||||
self.libs.iter().find(|(i, _)| i == key).or_else(|| self.libs.iter().find(|(_, l)| l.name.eq_ignore_ascii_case(key))).map(|(i, _)| i.clone())
|
||||
}
|
||||
|
||||
/// A new empty library called `name` → its id.
|
||||
fn create(&mut self, name: &str) -> Result<String> {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
let base = base.trim();
|
||||
let base = if base.is_empty() { "Library" } else { base };
|
||||
let taken = |id: &str| self.libs.iter().any(|(i, _)| i.eq_ignore_ascii_case(id)) || self.file_exists(id);
|
||||
let id = std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|id| !taken(id)).unwrap_or_default();
|
||||
self.libs.push((id.clone(), Library { name: name.to_string(), ..Default::default() }));
|
||||
if let Err(e) = self.save(&id) {
|
||||
self.libs.retain(|(i, _)| i != &id);
|
||||
return Err(e);
|
||||
}
|
||||
self.sort();
|
||||
self.current = Some(id.clone());
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn file_exists(&self, id: &str) -> bool {
|
||||
self.path(id).is_some_and(|p| std::path::Path::new(&p).exists())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn file_exists(&self, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn path(&self, id: &str) -> Option<String> {
|
||||
let dir = self.dir.as_deref()?;
|
||||
Some(std::path::Path::new(dir).join(format!("{id}.{LIBRARY_EXT}")).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Write library `id` to its file (nothing to do without a folder).
|
||||
fn save(&self, id: &str) -> Result<()> {
|
||||
let (Some(path), Some(lib)) = (self.path(id), self.get(id)) else { return Ok(()) };
|
||||
if let Some(dir) = self.dir.as_deref() {
|
||||
super::fileio::create_dir(dir)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec(lib).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
super::fileio::write_file(&path, &bytes)
|
||||
}
|
||||
|
||||
/// Change library `id` with `f` and save it; on a failed save the change is undone.
|
||||
fn change<T>(&mut self, id: &str, f: impl FnOnce(&mut Library) -> Result<T>) -> Result<T> {
|
||||
let i = self.libs.iter().position(|(i, _)| i == id).ok_or_else(|| EngineError::Other(format!("no library `{id}`")))?;
|
||||
let before = self.libs.get(i).map(|(_, l)| l.clone()).unwrap_or_default();
|
||||
let r = match self.libs.get_mut(i) {
|
||||
Some((_, lib)) => f(lib)?,
|
||||
None => return Err(EngineError::Other(format!("no library `{id}`"))),
|
||||
};
|
||||
if let Err(e) = self.save(id) {
|
||||
if let Some((_, lib)) = self.libs.get_mut(i) {
|
||||
*lib = before;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
self.sort();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn delete(&mut self, id: &str) -> Result<()> {
|
||||
if let Some(path) = self.path(id) {
|
||||
remove_file(&path)?;
|
||||
}
|
||||
self.libs.retain(|(i, _)| i != id);
|
||||
if self.current.as_deref() == Some(id) {
|
||||
self.current = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// At most [`MAX_ITEMS`] of each kind (a hand-edited file may hold more).
|
||||
fn cap(&mut self) {
|
||||
self.colors.truncate(MAX_ITEMS);
|
||||
self.char_styles.truncate(MAX_ITEMS);
|
||||
self.para_styles.truncate(MAX_ITEMS);
|
||||
self.graphics.truncate(MAX_ITEMS);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn library_paths(dir: &str) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
let mut v: Vec<String> = rd
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x.to_string_lossy().eq_ignore_ascii_case(LIBRARY_EXT)))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn library_paths(_: &str) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn remove_file(path: &str) -> Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(EngineError::Other(format!("{path}: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn remove_file(_: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stem_of(path: &str) -> String {
|
||||
std::path::Path::new(path).file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// The kinds of library item.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Graphic,
|
||||
FillColor,
|
||||
StrokeColor,
|
||||
CharStyle,
|
||||
ParaStyle,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn of(p: &Value, cmd: &str) -> Result<Self> {
|
||||
Ok(match str_param(p, "kind") {
|
||||
Some("graphic") => Kind::Graphic,
|
||||
Some("fillColor") => Kind::FillColor,
|
||||
Some("strokeColor") => Kind::StrokeColor,
|
||||
Some("color") => Kind::FillColor,
|
||||
Some("charStyle") => Kind::CharStyle,
|
||||
Some("paraStyle") => Kind::ParaStyle,
|
||||
_ => return Err(bad(cmd, "kind must be graphic, fillColor, strokeColor, charStyle or paraStyle")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "library.list",
|
||||
"Libraries",
|
||||
[],
|
||||
None,
|
||||
"{} → {libraries: [{id, name, graphics, colors, charStyles, paraStyles} (counts)], current: the current library's id or null, folder: the library folder, or null when libraries last for the session only (the web, headless)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
query "library.get",
|
||||
"Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}]}",
|
||||
always,
|
||||
get
|
||||
),
|
||||
cmd!(
|
||||
"library.setCurrent",
|
||||
"Show Library",
|
||||
[],
|
||||
None,
|
||||
"{library: id or name} the library the Libraries panel shows, which library commands without a `library` act on → {id}",
|
||||
always,
|
||||
set_current
|
||||
),
|
||||
cmd!(
|
||||
"library.create",
|
||||
"Create New Library",
|
||||
[],
|
||||
None,
|
||||
"{name?: \"My Library\"} a new empty library (its file in the library folder), made the current one → {id, name}",
|
||||
always,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"library.rename",
|
||||
"Rename Library",
|
||||
[],
|
||||
None,
|
||||
"{library?, name} rename a library (default: the current one) → {id, name}",
|
||||
always,
|
||||
rename
|
||||
),
|
||||
cmd!(
|
||||
"library.delete",
|
||||
"Delete Library",
|
||||
[],
|
||||
None,
|
||||
"{library?} delete a library (default: the current one) and its file; not undoable → null",
|
||||
always,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"library.add",
|
||||
"Add to Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|charStyle|paraStyle, ids?: objects (default: the selection), name?} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). An item the library already has (same kind and contents) isn't added again → {library, kind, name, id?: graphic id, existing: bool}",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
cmd!(
|
||||
"library.use",
|
||||
"Use Library Item",
|
||||
[],
|
||||
None,
|
||||
"{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colours; default: the kind's)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colours: paint the selection (and the defaults) as paint.setFill/paint.setStroke; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}",
|
||||
has_doc,
|
||||
use_item
|
||||
),
|
||||
cmd!("library.removeItem", "Delete Library Item", [], None, "{library?, kind, item: name (graphic: id or name)} → null", always, remove_item),
|
||||
]
|
||||
}
|
||||
|
||||
/// The library `p` names (`library`: id or name), else the current one.
|
||||
fn library_id(s: &Session, p: &Value, cmd: &str) -> Result<String> {
|
||||
match str_param(p, "library") {
|
||||
Some(key) => s.libraries.find(key).ok_or_else(|| bad(cmd, format!("no library `{key}`"))),
|
||||
None => s.libraries.current().map(str::to_string).ok_or_else(|| bad(cmd, "there is no library: create one first")),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.setCurrent";
|
||||
let key = str_param(p, "library").ok_or_else(|| bad(C, "missing `library`"))?;
|
||||
let id = s.libraries.find(key).ok_or_else(|| bad(C, format!("no library `{key}`")))?;
|
||||
s.libraries.current = Some(id.clone());
|
||||
Ok(json!({ "id": id }))
|
||||
}
|
||||
|
||||
fn counts(lib: &Library) -> Value {
|
||||
json!({
|
||||
"graphics": lib.graphics.len(),
|
||||
"colors": lib.colors.len(),
|
||||
"charStyles": lib.char_styles.len(),
|
||||
"paraStyles": lib.para_styles.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let libraries: Vec<Value> = s
|
||||
.libraries
|
||||
.all()
|
||||
.iter()
|
||||
.map(|(id, lib)| {
|
||||
let mut v = json!({ "id": id, "name": lib.name });
|
||||
if let (Some(o), Value::Object(c)) = (v.as_object_mut(), counts(lib)) {
|
||||
o.extend(c);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "libraries": libraries, "current": s.libraries.current(), "folder": s.libraries.dir() }))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = library_id(s, p, "library.get")?;
|
||||
let lib = s.libraries.get(&id).ok_or_else(|| bad("library.get", format!("no library `{id}`")))?;
|
||||
Ok(json!({
|
||||
"id": id,
|
||||
"name": lib.name,
|
||||
"graphics": lib.graphics.iter().map(|g| json!({"id": g.id, "name": g.name, "width": g.width, "height": g.height, "thumbnail": g.thumbnail})).collect::<Vec<_>>(),
|
||||
"colors": lib.colors.iter().map(|c| json!({"name": c.name, "hex": c.color.to_hex(), "color": c.color})).collect::<Vec<_>>(),
|
||||
"charStyles": lib.char_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
|
||||
"paraStyles": lib.para_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("My Library").to_string();
|
||||
let id = s.libraries.create(&name)?;
|
||||
Ok(json!({ "id": id, "name": name }))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.rename";
|
||||
let id = library_id(s, p, C)?;
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
let n = name.clone();
|
||||
s.libraries.change(&id, |l| {
|
||||
l.name = n;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id, "name": name }))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = library_id(s, p, "library.delete")?;
|
||||
s.libraries.delete(&id)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The library `p` names, else the current one, else a new "My Library".
|
||||
fn target_library(s: &mut Session, p: &Value, cmd: &str) -> Result<String> {
|
||||
if p.get("library").is_some_and(|v| !v.is_null()) || s.libraries.current().is_some() {
|
||||
return library_id(s, p, cmd);
|
||||
}
|
||||
s.libraries.create("My Library")
|
||||
}
|
||||
|
||||
/// `base`, or `base 2`, `base 3`… the first that `taken` doesn't hold.
|
||||
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.add";
|
||||
let kind = Kind::of(p, C)?;
|
||||
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
// Read what is added before a library is made for it, so a failure makes none.
|
||||
let item = match kind {
|
||||
Kind::Graphic => Item::Graphic(graphic_of(s, p, C)?),
|
||||
Kind::FillColor | Kind::StrokeColor => Item::Color(color_of(s, p, kind == Kind::StrokeColor, C)?),
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let para = kind == Kind::ParaStyle;
|
||||
let attrs = super::textstyles::selected_style_attrs(s, para)?.ok_or_else(|| bad(C, "select some type first"))?;
|
||||
Item::Style(para, attrs)
|
||||
}
|
||||
};
|
||||
let id = target_library(s, p, C)?;
|
||||
let kind_name = str_param(p, "kind").unwrap_or_default().to_string();
|
||||
let r = s.libraries.change(&id, |lib| {
|
||||
let full = |n: usize| if n >= MAX_ITEMS { Err(bad(C, format!("the library holds {MAX_ITEMS} items of this kind already"))) } else { Ok(()) };
|
||||
Ok(match item {
|
||||
Item::Graphic(mut g) => {
|
||||
full(lib.graphics.len())?;
|
||||
if let Some(old) = lib.graphics.iter().find(|o| o.data == g.data) {
|
||||
return Ok(json!({ "name": old.name, "id": old.id, "existing": true }));
|
||||
}
|
||||
let base = given.clone().unwrap_or_else(|| g.name.clone());
|
||||
g.name = unique(&base, |n| lib.graphics.iter().any(|o| o.name == n));
|
||||
g.id = unique("graphic", |n| lib.graphics.iter().any(|o| o.id == n)).replace(' ', "-");
|
||||
let v = json!({ "name": g.name, "id": g.id, "existing": false });
|
||||
lib.graphics.push(g);
|
||||
v
|
||||
}
|
||||
Item::Color((name, color)) => {
|
||||
full(lib.colors.len())?;
|
||||
if let Some(old) = lib.colors.iter().find(|o| o.color == color) {
|
||||
return Ok(json!({ "name": old.name, "existing": true }));
|
||||
}
|
||||
let name = unique(&given.clone().unwrap_or(name), |n| lib.colors.iter().any(|o| o.name == n));
|
||||
lib.colors.push(LibraryColor { name: name.clone(), color });
|
||||
json!({ "name": name, "existing": false })
|
||||
}
|
||||
Item::Style(para, attrs) => {
|
||||
let list = if para { &mut lib.para_styles } else { &mut lib.char_styles };
|
||||
full(list.len())?;
|
||||
if let Some(old) = list.iter().find(|o| o.attrs == attrs) {
|
||||
return Ok(json!({ "name": old.name, "existing": true }));
|
||||
}
|
||||
let base = given.clone().unwrap_or_else(|| style_name(&attrs, para));
|
||||
let name = unique(&base, |n| list.iter().any(|o| o.name == n));
|
||||
list.push(TextStyleDef { name: name.clone(), attrs });
|
||||
json!({ "name": name, "existing": false })
|
||||
}
|
||||
})
|
||||
})?;
|
||||
let mut r = r;
|
||||
r["library"] = json!(id);
|
||||
r["kind"] = json!(kind_name);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
enum Item {
|
||||
Graphic(LibraryGraphic),
|
||||
Color((String, Color)),
|
||||
Style(bool, Map<String, Value>),
|
||||
}
|
||||
|
||||
/// The selected objects (or `ids`) as a library graphic (its id and name set by the caller).
|
||||
fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result<LibraryGraphic> {
|
||||
let st = s.doc()?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => st.selection.in_paint_order(&st.doc),
|
||||
};
|
||||
let roots = super::edit::roots_of(&st.doc, ids);
|
||||
if roots.is_empty() {
|
||||
return Err(bad(cmd, "select the art to add first"));
|
||||
}
|
||||
let clip = Clipboard::copy(st, &roots);
|
||||
let bounds = clip.bounds().ok_or_else(|| bad(cmd, "the selection draws nothing"))?;
|
||||
let mut doc = clip.to_document();
|
||||
let board = bounds.inflate(1.0, 1.0);
|
||||
// One artboard round the art: what the thumbnail shows.
|
||||
doc.artboards.truncate(1);
|
||||
match doc.artboards.first_mut() {
|
||||
Some(a) => a.rect = board,
|
||||
None => return Err(bad(cmd, "the graphic's document has no artboard")),
|
||||
}
|
||||
let name = match roots.as_slice() {
|
||||
[one] => st.doc.node(*one).map(|n| n.display_name()).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let name = if name.trim().is_empty() || name.starts_with('<') { "Graphic".to_string() } else { name };
|
||||
let thumbnail = super::fileio::preview_png(&doc)?.map(|b| vectorcraft_format::base64_encode(&b)).unwrap_or_default();
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&doc, false));
|
||||
Ok(LibraryGraphic { id: String::new(), name, width: bounds.width(), height: bounds.height(), data, thumbnail })
|
||||
}
|
||||
|
||||
/// The first selected object's solid fill (`stroke`: stroke) colour, else the default one, with a
|
||||
/// name: its swatch's, else its hex.
|
||||
fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<(String, Color)> {
|
||||
// A colour given (a swatch dropped on the panel).
|
||||
if let Some(c) = p.get("color").filter(|v| !v.is_null()) {
|
||||
let color = color_value(c).ok_or_else(|| bad(cmd, "bad colour"))?;
|
||||
return Ok((color.to_hex(), color));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => st.selection.objects.clone(),
|
||||
};
|
||||
let from_art = ids.iter().filter_map(|id| st.doc.node(*id)).find_map(|n| {
|
||||
let paint = if stroke { n.appearance.stroke().map(|x| &x.paint) } else { n.appearance.fill().map(|f| &f.paint) };
|
||||
paint.cloned()
|
||||
});
|
||||
let paint = match from_art {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let d = &s.paint;
|
||||
if stroke { d.stroke.clone() } else { d.fill.clone() }
|
||||
}
|
||||
};
|
||||
match paint {
|
||||
Paint::Solid { color, swatch, .. } => {
|
||||
let name = swatch.filter(|n| !n.is_empty()).unwrap_or_else(|| color.to_hex());
|
||||
Ok((name, color))
|
||||
}
|
||||
Paint::None => Err(bad(cmd, if stroke { "the selection has no stroke colour" } else { "the selection has no fill colour" })),
|
||||
_ => Err(bad(cmd, "only solid colours can be added (not gradients or patterns)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A character style's name from its font, style and size ("Source Sans 3 Bold 24 pt"); a
|
||||
/// paragraph style's from its alignment and size too, else "Paragraph Style".
|
||||
fn style_name(attrs: &Map<String, Value>, para: bool) -> String {
|
||||
let s = |k: &str| attrs.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let size = attrs.get("size").and_then(Value::as_f64).map(|v| format!("{} pt", (v * 100.0).round() / 100.0));
|
||||
let words: Vec<String> = [s("font_family"), s("font_style")].into_iter().chain(size).filter(|w| !w.is_empty()).collect();
|
||||
match (para, words.is_empty()) {
|
||||
(false, false) => words.join(" "),
|
||||
(false, true) => "Character Style".into(),
|
||||
(true, _) => "Paragraph Style".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The library item `p` names: (library id, kind).
|
||||
fn item_param<'a>(s: &Session, p: &'a Value, cmd: &str) -> Result<(String, Kind, &'a str)> {
|
||||
let id = library_id(s, p, cmd)?;
|
||||
let kind = Kind::of(p, cmd)?;
|
||||
let item = str_param(p, "item").ok_or_else(|| bad(cmd, "missing `item`"))?;
|
||||
Ok((id, kind, item))
|
||||
}
|
||||
|
||||
fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.use";
|
||||
// A replay (the journal) carries what the item was, so it doesn't depend on the library.
|
||||
let recorded = p.get("recorded").cloned();
|
||||
let (kind, data) = match recorded {
|
||||
Some(r) => (Kind::of(p, C)?, r),
|
||||
None => {
|
||||
let (id, kind, item) = item_param(s, p, C)?;
|
||||
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
|
||||
let missing = || bad(C, format!("the library has no item `{item}`"));
|
||||
let data = match kind {
|
||||
Kind::Graphic => {
|
||||
let g =
|
||||
lib.graphics.iter().find(|g| g.id == item).or_else(|| lib.graphics.iter().find(|g| g.name == item)).ok_or_else(missing)?;
|
||||
json!({ "data": g.data })
|
||||
}
|
||||
Kind::FillColor | Kind::StrokeColor => {
|
||||
let c = lib.colors.iter().find(|c| c.name == item).ok_or_else(missing)?;
|
||||
json!({ "color": c.color })
|
||||
}
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let list = if kind == Kind::ParaStyle { &lib.para_styles } else { &lib.char_styles };
|
||||
let t = list.iter().find(|t| t.name == item).ok_or_else(missing)?;
|
||||
json!({ "name": t.name, "attrs": t.attrs })
|
||||
}
|
||||
};
|
||||
s.note_journal("recorded", data.clone());
|
||||
(kind, data)
|
||||
}
|
||||
};
|
||||
match kind {
|
||||
Kind::Graphic => {
|
||||
let bytes = data
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(vectorcraft_format::base64_decode)
|
||||
.ok_or_else(|| bad(C, "the graphic's data is damaged"))?;
|
||||
let doc = vectorcraft_format::load(&bytes).map_err(|e| bad(C, format!("the graphic can't be read: {e}")))?;
|
||||
let clip = Clipboard::from_document(&doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(C, "the graphic is empty"));
|
||||
}
|
||||
let center = match point_param(p, "center") {
|
||||
Some(c) => c,
|
||||
None => s.doc()?.doc.artboards.first().map(|a| a.rect.center()).unwrap_or_default(),
|
||||
};
|
||||
let mut r = super::edit::place_clip(s, &clip, center)?;
|
||||
r["kind"] = json!("graphic");
|
||||
Ok(r)
|
||||
}
|
||||
Kind::FillColor | Kind::StrokeColor => {
|
||||
let stroke = match str_param(p, "to") {
|
||||
Some("fill") => false,
|
||||
Some("stroke") => true,
|
||||
Some(_) => return Err(bad(C, "to must be fill or stroke")),
|
||||
None => kind == Kind::StrokeColor,
|
||||
};
|
||||
let color = data.get("color").cloned().ok_or_else(|| bad(C, "no colour"))?;
|
||||
s.execute(if stroke { "paint.setStroke" } else { "paint.setFill" }, &json!({ "color": color }))?;
|
||||
Ok(json!({ "kind": if stroke { "strokeColor" } else { "fillColor" } }))
|
||||
}
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let para = kind == Kind::ParaStyle;
|
||||
let name = data.get("name").and_then(Value::as_str).unwrap_or("Style").to_string();
|
||||
let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let doc = &s.doc()?.doc;
|
||||
// The document's style of that name when it is the same; else a new one.
|
||||
let same = super::textstyles::style_attrs(doc, para, &name).is_some_and(|a| a == attrs);
|
||||
let style = if same { name } else { unique(&name, |n| super::textstyles::style_attrs(doc, para, n).is_some()) };
|
||||
let prefix = if para { "paraStyle" } else { "charStyle" };
|
||||
in_one_step(s, |s| {
|
||||
if !same {
|
||||
s.execute(&format!("{prefix}.new"), &json!({ "name": style, "attrs": attrs }))?;
|
||||
}
|
||||
s.execute(&format!("{prefix}.apply"), &json!({ "name": style }))
|
||||
})?;
|
||||
Ok(json!({ "kind": if para { "paraStyle" } else { "charStyle" }, "style": style }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`'s commands as one undo step (undone together when it fails), unless an undo group is
|
||||
/// already open (its own step then holds them).
|
||||
fn in_one_step<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
|
||||
let open = s.active().is_some_and(|st| st.undo_group.is_some());
|
||||
if !open {
|
||||
s.begin_undo_group();
|
||||
}
|
||||
let r = f(s);
|
||||
if !open {
|
||||
s.end_undo_group(r.is_err());
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.removeItem";
|
||||
let (id, kind, item) = item_param(s, p, C)?;
|
||||
let item = item.to_string();
|
||||
s.libraries.change(&id, |lib| {
|
||||
let before = (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len());
|
||||
match kind {
|
||||
Kind::Graphic => {
|
||||
let at = lib.graphics.iter().position(|g| g.id == item).or_else(|| lib.graphics.iter().position(|g| g.name == item));
|
||||
if let Some(i) = at {
|
||||
lib.graphics.remove(i);
|
||||
}
|
||||
}
|
||||
Kind::FillColor | Kind::StrokeColor => lib.colors.retain(|c| c.name != item),
|
||||
Kind::CharStyle => lib.char_styles.retain(|t| t.name != item),
|
||||
Kind::ParaStyle => lib.para_styles.retain(|t| t.name != item),
|
||||
}
|
||||
if before == (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()) {
|
||||
return Err(bad(C, format!("the library has no item `{item}`")));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub mod help;
|
||||
pub(crate) mod inline;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
pub mod library;
|
||||
pub mod links;
|
||||
mod live;
|
||||
pub(crate) mod maskedit;
|
||||
@@ -245,6 +246,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(freeform::specs());
|
||||
v.extend(flatten::specs());
|
||||
v.extend(stylelib::specs());
|
||||
v.extend(library::specs());
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v.extend(newart::specs());
|
||||
|
||||
@@ -241,6 +241,17 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
|
||||
}))
|
||||
}
|
||||
|
||||
/// The selected text's character (`para`: paragraph) attributes, every one of them: what a
|
||||
/// library keeps of a text style. `None` without text selected.
|
||||
pub(crate) fn selected_style_attrs(s: &Session, para: bool) -> Result<Option<Map<String, Value>>> {
|
||||
selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char })
|
||||
}
|
||||
|
||||
/// Whether the document has a character (`para`: paragraph) style `name`, and its attributes.
|
||||
pub(crate) fn style_attrs(d: &Document, para: bool, name: &str) -> Option<Map<String, Value>> {
|
||||
if para { Kind::Para } else { Kind::Char }.attrs(d, name)
|
||||
}
|
||||
|
||||
fn name_param<'a>(p: &'a Value, kind: Kind, verb: &str) -> Result<&'a str> {
|
||||
str_param(p, "name").ok_or_else(|| bad(&kind.cmd(verb), "missing `name`"))
|
||||
}
|
||||
|
||||
@@ -829,6 +829,8 @@ pub struct Session {
|
||||
pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
|
||||
/// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved.
|
||||
pub style_libraries: cmd::stylelib::Libraries,
|
||||
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
|
||||
pub libraries: cmd::library::Libraries,
|
||||
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
|
||||
pub recent_urls: Vec<String>,
|
||||
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
|
||||
@@ -899,6 +901,7 @@ impl Session {
|
||||
swatch_libraries: Default::default(),
|
||||
freeform_point: None,
|
||||
style_libraries: Default::default(),
|
||||
libraries: Default::default(),
|
||||
recent_urls: vec![],
|
||||
ui_language: None,
|
||||
journal_note: Default::default(),
|
||||
@@ -1475,6 +1478,8 @@ mod tests_layerclip;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_library;
|
||||
#[cfg(test)]
|
||||
mod tests_linked_stops;
|
||||
#[cfg(test)]
|
||||
mod tests_links;
|
||||
|
||||
@@ -209,3 +209,26 @@ fn rotated_box_rect_matches_page_bounds_when_upright() {
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(10.0, 20.0, 40.0, 60.0)));
|
||||
}
|
||||
|
||||
/// The Selection tool says when a drag moves, scales or rotates the selection (the canvas hides the
|
||||
/// bounding box meanwhile, #712), and not on a press that hasn't moved or a marquee.
|
||||
#[test]
|
||||
fn the_selection_tool_reports_a_transforming_drag() {
|
||||
let mut s = session();
|
||||
let id = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y).with_mods(Mods::default());
|
||||
s.pointer(&ev(PointerKind::Down, 150.0, 125.0), v).unwrap();
|
||||
assert!(!s.tool_transforming(), "a press that hasn't moved");
|
||||
s.pointer(&ev(PointerKind::Drag, 190.0, 160.0), v).unwrap();
|
||||
assert!(s.tool_transforming(), "moving");
|
||||
s.pointer(&ev(PointerKind::Up, 190.0, 160.0), v).unwrap();
|
||||
assert!(!s.tool_transforming());
|
||||
// A marquee on empty canvas isn't a transform.
|
||||
s.pointer(&ev(PointerKind::Down, 500.0, 400.0), v).unwrap();
|
||||
s.pointer(&ev(PointerKind::Drag, 550.0, 450.0), v).unwrap();
|
||||
assert!(s.tool_busy() && !s.tool_transforming());
|
||||
s.pointer(&ev(PointerKind::Up, 550.0, 450.0), v).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! The Libraries panel's libraries (#745): making, renaming and deleting them, adding graphics,
|
||||
//! colours and text styles from the selection, using them in another document, and keeping them
|
||||
//! in files across sessions.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn objects(s: &Session) -> usize {
|
||||
s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len())
|
||||
}
|
||||
|
||||
fn hex_of(s: &Session, id: &Value, stroke: bool) -> String {
|
||||
let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone();
|
||||
let paint = if stroke { n.appearance.stroke().unwrap().paint.clone() } else { n.appearance.fill().unwrap().paint.clone() };
|
||||
paint.color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libraries_are_made_renamed_and_deleted() {
|
||||
let mut s = session();
|
||||
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"], json!([]));
|
||||
let id = run(&mut s, "library.create", json!({"name": "Brand"}))["id"].as_str().unwrap().to_string();
|
||||
let other = run(&mut s, "library.create", json!({"name": "Brand"}))["id"].as_str().unwrap().to_string();
|
||||
assert_ne!(id, other, "two libraries of one name are two files");
|
||||
run(&mut s, "library.rename", json!({"library": other, "name": "Client"}));
|
||||
let list = run(&mut s, "library.list", json!({}));
|
||||
let names: Vec<&str> = list["libraries"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Brand", "Client"]);
|
||||
assert!(list["folder"].is_null(), "no folder: libraries last for the session");
|
||||
run(&mut s, "library.delete", json!({"library": "client"}));
|
||||
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"].as_array().unwrap().len(), 1);
|
||||
assert!(s.execute("library.get", &json!({"library": "Client"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_graphic_goes_into_a_library_and_comes_out_in_another_document() {
|
||||
let mut s = session();
|
||||
let id = run(&mut s, "shape.ellipse", json!({"x": 10, "y": 10, "width": 40, "height": 30}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#cc3300", "ids": [id]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id]}));
|
||||
// With no library yet, adding makes "My Library".
|
||||
let added = run(&mut s, "library.add", json!({"kind": "graphic"}));
|
||||
assert_eq!(added["existing"], false);
|
||||
let lib = added["library"].as_str().unwrap().to_string();
|
||||
let item = added["id"].as_str().unwrap().to_string();
|
||||
let got = run(&mut s, "library.get", json!({"library": lib}));
|
||||
assert_eq!(got["name"], "My Library");
|
||||
let g = &got["graphics"][0];
|
||||
// Its size as it draws: the 1 pt stroke included.
|
||||
assert!((g["width"].as_f64().unwrap() - 41.0).abs() < 1e-6 && (g["height"].as_f64().unwrap() - 31.0).abs() < 1e-6, "{g}");
|
||||
assert!(!g["thumbnail"].as_str().unwrap().is_empty(), "a thumbnail of the art");
|
||||
// The same art again is the item already there.
|
||||
assert_eq!(run(&mut s, "library.add", json!({"kind": "graphic"}))["existing"], true);
|
||||
// Placed in another document, centred where asked, selected, one undo step.
|
||||
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
|
||||
let placed = run(&mut s, "library.use", json!({"library": lib, "kind": "graphic", "item": item, "center": [150, 100]}));
|
||||
let new_id = placed["ids"][0].clone();
|
||||
let st = s.doc().unwrap();
|
||||
let b = st.doc.node(NodeId(new_id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap();
|
||||
assert!((b.center().x - 150.0).abs() < 1e-6 && (b.center().y - 100.0).abs() < 1e-6, "{b:?}");
|
||||
assert_eq!(st.selection.objects, vec![NodeId(new_id.as_u64().unwrap())]);
|
||||
assert_eq!(hex_of(&s, &new_id, false), "#cc3300");
|
||||
assert_eq!(objects(&s), 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(objects(&s), 0);
|
||||
// A graphic is removed by id.
|
||||
run(&mut s, "library.removeItem", json!({"library": lib, "kind": "graphic", "item": item}));
|
||||
assert!(s.execute("library.use", &json!({"library": lib, "kind": "graphic", "item": item})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_come_from_the_selection_and_paint_it() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
let a = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#123456", "ids": [a]}));
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#abcdef", "ids": [a]}));
|
||||
run(&mut s, "select.set", json!({"ids": [a]}));
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["name"], "#123456");
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "strokeColor"}))["name"], "#abcdef");
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["existing"], true);
|
||||
let b = run(&mut s, "shape.rectangle", json!({"x": 50, "y": 0, "width": 20, "height": 20}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [b]}));
|
||||
run(&mut s, "library.use", json!({"library": lib, "kind": "fillColor", "item": "#123456"}));
|
||||
run(&mut s, "library.use", json!({"library": lib, "kind": "fillColor", "item": "#123456", "to": "stroke"}));
|
||||
assert_eq!(hex_of(&s, &b, false), "#123456");
|
||||
assert_eq!(hex_of(&s, &b, true), "#123456");
|
||||
assert!(s.execute("library.use", &json!({"library": lib, "kind": "fillColor", "item": "#nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_styles_come_from_the_selected_type_and_apply_in_one_step() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
assert!(s.execute("library.add", &json!({"library": lib, "kind": "charStyle"})).is_err(), "no type selected");
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Heading", "size": 31}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let name = run(&mut s, "library.add", json!({"library": lib, "kind": "charStyle"}))["name"].as_str().unwrap().to_string();
|
||||
assert!(name.ends_with("31 pt"), "{name}");
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "paraStyle"}));
|
||||
// Another document: the style is added and applied, and one undo takes both back.
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
let u = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [u]}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": name}));
|
||||
assert_eq!(r["style"], name);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let size = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(u.as_u64().unwrap())).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => t.runs[0].style.size,
|
||||
_ => 0.0,
|
||||
};
|
||||
assert_eq!(size(&s), 31.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(size(&s), 9.0);
|
||||
assert!(s.doc().unwrap().doc.char_styles.iter().all(|c| c.name != name), "the style went with the undo");
|
||||
// A style of that name with other attributes in the document: the library's comes in numbered.
|
||||
run(&mut s, "charStyle.new", json!({"name": name, "attrs": {"size": 5}}));
|
||||
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": name}));
|
||||
assert_eq!(r["style"], format!("{name} 2"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn libraries_are_kept_in_files_across_sessions() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-libraries-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let folder = dir.to_string_lossy().to_string();
|
||||
let mut s = session();
|
||||
s.libraries.set_dir(Some(folder.clone()));
|
||||
let lib = run(&mut s, "library.create", json!({"name": "Kept"}))["id"].as_str().unwrap().to_string();
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [id]}));
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "graphic", "name": "Square"}));
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}));
|
||||
let mut t = session();
|
||||
t.libraries.set_dir(Some(folder.clone()));
|
||||
let got = run(&mut t, "library.get", json!({"library": "Kept"}));
|
||||
assert_eq!(got["graphics"][0]["name"], "Square");
|
||||
assert_eq!(got["colors"].as_array().unwrap().len(), 1);
|
||||
run(&mut t, "library.delete", json!({"library": "Kept"}));
|
||||
let mut u = session();
|
||||
u.libraries.set_dir(Some(folder));
|
||||
assert_eq!(run(&mut u, "library.list", json!({}))["libraries"], json!([]), "deleting removes the file");
|
||||
// A damaged file is skipped.
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("Bad.vclibrary"), b"{not json").unwrap();
|
||||
let mut v = session();
|
||||
v.libraries.set_dir(Some(dir.to_string_lossy().to_string()));
|
||||
assert_eq!(run(&mut v, "library.list", json!({}))["libraries"], json!([]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -316,6 +316,106 @@ fn artboard_move_copy_reports_the_copies_and_follows_move_art() {
|
||||
assert_eq!(ids.len(), 3, "every artboard has an id of its own");
|
||||
}
|
||||
|
||||
fn stroke_width(s: &Session, id: NodeId) -> f64 {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().width
|
||||
}
|
||||
|
||||
fn guide_positions(s: &Session) -> Vec<f64> {
|
||||
s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect()
|
||||
}
|
||||
|
||||
/// Scale Artwork with Artboard (#602): `artboard.setProps {scaleArt}` takes the art fully inside the
|
||||
/// artboard and its guides from the old rectangle onto the new one, each side by its own ratio, in
|
||||
/// one undo step; art partly outside and canvas guides stay.
|
||||
#[test]
|
||||
fn artboard_set_props_scales_the_art_and_guides_with_the_artboard() {
|
||||
let mut s = session();
|
||||
let inside = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
|
||||
let partly = rect(&mut s, 700.0, 500.0, 200.0, 200.0);
|
||||
for (vertical, pos, artboard) in [(true, 400.0, json!(0)), (false, 300.0, json!(0)), (true, 400.0, json!(null))] {
|
||||
s.execute("guide.add", &json!({"vertical": vertical, "pos": pos, "artboard": artboard})).unwrap();
|
||||
}
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
// Half the size: half the art, half the stroke (Scale Strokes & Effects).
|
||||
let out = s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true, "strokes": true})).unwrap();
|
||||
assert_eq!(out["scaled"], json!([inside.0]));
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 400.0, 300.0));
|
||||
assert_eq!(bounds(&s, inside), Rect::new(50.0, 50.0, 150.0, 100.0));
|
||||
assert_eq!(stroke_width(&s, inside), 2.0);
|
||||
assert_eq!(bounds(&s, partly), Rect::new(700.0, 500.0, 900.0, 700.0), "art partly outside stays");
|
||||
assert_eq!(guide_positions(&s), vec![200.0, 150.0, 400.0], "the artboard's guides scale, the canvas guide stays");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc().unwrap().doc, *before, "one undo step");
|
||||
// Moved and stretched (left edge to -100, twice as wide), strokes kept: the art follows.
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "x": -100, "width": 1600, "scaleArt": true, "strokes": false})).unwrap();
|
||||
assert_eq!(bounds(&s, inside), Rect::new(100.0, 100.0, 500.0, 200.0));
|
||||
assert_eq!(stroke_width(&s, inside), 4.0);
|
||||
assert_eq!(guide_positions(&s), vec![700.0, 300.0, 400.0]);
|
||||
}
|
||||
|
||||
/// Without `scaleArt` a resize leaves the art and guides alone (and returns nothing, as before);
|
||||
/// with it, a pure move scales nothing (guides move along, as they always have).
|
||||
#[test]
|
||||
fn artboard_set_props_scales_art_only_when_asked_and_resized() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 400, "artboard": 0})).unwrap();
|
||||
assert_eq!(s.execute("artboard.setProps", &json!({"index": 0, "width": 400})).unwrap(), Value::Null);
|
||||
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
|
||||
assert_eq!(guide_positions(&s), vec![400.0]);
|
||||
let out = s.execute("artboard.setProps", &json!({"index": 0, "x": 50, "scaleArt": true})).unwrap();
|
||||
assert_eq!(out["scaled"], json!([]));
|
||||
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
|
||||
assert_eq!(guide_positions(&s), vec![450.0]);
|
||||
assert!(s.execute("artboard.setProps", &json!({"index": 9, "width": 10, "scaleArt": true})).is_err());
|
||||
}
|
||||
|
||||
/// Locked and hidden art scales only with Move Locked and Hidden Artwork with Artboard, as it
|
||||
/// moves only with it.
|
||||
#[test]
|
||||
fn scale_artwork_with_artboard_leaves_locked_art_unless_the_preference_says() {
|
||||
let mut s = session();
|
||||
let locked = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
let scaled = |s: &mut Session| {
|
||||
let out = s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
out["scaled"].clone()
|
||||
};
|
||||
assert_eq!(scaled(&mut s), json!([]));
|
||||
s.execute("prefs.set", &json!({"key": "moveLockedWithArtboard", "value": true})).unwrap();
|
||||
assert_eq!(scaled(&mut s), json!([locked.0]));
|
||||
// The choice is journaled: replayed with the preference off, the same art scales.
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true})).unwrap();
|
||||
assert_eq!(s.journal.last().unwrap().1["lockedAndHidden"], true);
|
||||
let (id, p) = s.journal.last().unwrap().clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "moveLockedWithArtboard", "value": false})).unwrap();
|
||||
assert_eq!(s.execute(&id, &p).unwrap()["scaled"], json!([locked.0]));
|
||||
}
|
||||
|
||||
/// The Artboard tool with its `scaleArt` option on resizes proportionally and the art scales with
|
||||
/// the artboard, in one undo step.
|
||||
#[test]
|
||||
fn artboard_tool_scales_art_with_the_artboard() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
s.set_tool_options(Some("artboard"), json!({"scaleArt": true}).as_object().unwrap());
|
||||
// The right handle 100 pt out: 9/8 as wide, and as tall about the middle of the left side.
|
||||
gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default());
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, -37.5, 900.0, 637.5));
|
||||
assert_eq!(bounds(&s, r), Rect::new(112.5, 75.0, 337.5, 187.5));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc().unwrap().doc, *before, "one undo step");
|
||||
// Off again (the option is remembered like Move Artwork with Artboard): the art stays.
|
||||
s.set_tool_options(Some("artboard"), json!({"scaleArt": false}).as_object().unwrap());
|
||||
gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default());
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 900.0, 600.0));
|
||||
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_wand_selects_same_fill() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -358,6 +358,11 @@ impl Session {
|
||||
self.tool.busy()
|
||||
}
|
||||
|
||||
/// Is the active tool moving, scaling or rotating the selection with a drag?
|
||||
pub fn tool_transforming(&self) -> bool {
|
||||
self.tool.transforming()
|
||||
}
|
||||
|
||||
/// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)?
|
||||
pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool {
|
||||
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
|
||||
|
||||
@@ -272,6 +272,8 @@ fn structured_junk() {
|
||||
("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})),
|
||||
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})),
|
||||
("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})),
|
||||
("artboard.setProps", json!({"index": 0, "width": 1e300, "height": 1e-300, "scaleArt": true, "strokes": true, "corners": true})),
|
||||
("artboard.setProps", json!({"index": 0, "x": -1e308, "width": 0, "height": 1, "scaleArt": true, "patterns": true})),
|
||||
("artboard.new", json!({"width": 0, "height": -1})),
|
||||
("artboard.delete", json!({"index": 0})),
|
||||
("artboard.move", json!({"index": 0, "dx": 1e308, "dy": 0, "moveArt": true})),
|
||||
|
||||
@@ -61,7 +61,7 @@ use graphics::{GState, Out};
|
||||
use interp::{Fault, Interp};
|
||||
use obj::PsError;
|
||||
|
||||
pub use native::{is_loss, layered_ai};
|
||||
pub use native::{ai_alone, is_loss, layered_ai};
|
||||
pub use text::family_style;
|
||||
|
||||
/// The page of a PostScript file without a bounding box (US Letter).
|
||||
|
||||
@@ -31,6 +31,8 @@ use super::ate::{Story, Texts};
|
||||
|
||||
/// The notes that say the import left out something the file has (see [`is_loss`]).
|
||||
const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out";
|
||||
/// The same for a file without a page to take shown type from.
|
||||
const TYPE_LEFT_OUT: &str = "text objects of kinds VectorCraft doesn't read from the file's text document yet are left out";
|
||||
const ART_LEFT_OUT: &str = "hidden layers have art this can't read";
|
||||
const LAYERS_UNREAD: &str = "the file's layers weren't read";
|
||||
|
||||
@@ -38,6 +40,7 @@ const LAYERS_UNREAD: &str = "the file's layers weren't read";
|
||||
/// layers that could not be read)? Writing the document over the file would lose it for good.
|
||||
pub fn is_loss(note: &str) -> bool {
|
||||
note.ends_with(TEXT_LEFT_OUT)
|
||||
|| note.ends_with(TYPE_LEFT_OUT)
|
||||
|| [ART_LEFT_OUT, LAYERS_UNREAD, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n))
|
||||
}
|
||||
|
||||
@@ -512,7 +515,7 @@ fn plan(
|
||||
};
|
||||
}
|
||||
}
|
||||
if spare.len() != loose.len() {
|
||||
if !spare.is_empty() && spare.len() != loose.len() {
|
||||
warn.push("the text is on the layers in the order the page paints it, so a text object may be in another group than it was".into());
|
||||
}
|
||||
}
|
||||
@@ -600,31 +603,36 @@ struct Frame<'a> {
|
||||
}
|
||||
|
||||
/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the
|
||||
/// file read as its page, put in its text objects → the document, its notes and where the page is
|
||||
/// in it.
|
||||
fn build(data: &[u8], visible: &Document, page: Page) -> Result<(Document, Vec<String>, Rect), String> {
|
||||
/// file read as its page, put in its text objects → the document and its notes. Without a page
|
||||
/// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and
|
||||
/// nothing is compared.
|
||||
fn build(data: &[u8], visible: Option<&Document>, page: Page) -> Result<(Document, Vec<String>), String> {
|
||||
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data)?;
|
||||
let art_box = match page {
|
||||
Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?,
|
||||
Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?,
|
||||
};
|
||||
let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?;
|
||||
if (art_box.width() - page_rect.width()).abs() > 0.5 || (art_box.height() - page_rect.height()).abs() > 0.5 {
|
||||
return Err("its editing data is for a different page".into());
|
||||
}
|
||||
// The page's area in the layers' document, and the page's type moved there.
|
||||
let rect = to_doc.transform_rect_bbox(art_box);
|
||||
let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0));
|
||||
let (mut shown, mut hidden) = (vec![], vec![]);
|
||||
for l in &visible.layers {
|
||||
if let Some(c) = l.children() {
|
||||
text_objects(c, l.visible, &mut shown, &mut hidden);
|
||||
let mut frame = None;
|
||||
if let Some(visible) = visible {
|
||||
let art_box = match page {
|
||||
Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?,
|
||||
Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?,
|
||||
};
|
||||
let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?;
|
||||
if (art_box.width() - page_rect.width()).abs() > 0.5 || (art_box.height() - page_rect.height()).abs() > 0.5 {
|
||||
return Err("its editing data is for a different page".into());
|
||||
}
|
||||
}
|
||||
for object in shown.iter_mut().chain(hidden.iter_mut()) {
|
||||
for n in object.iter_mut() {
|
||||
Arc::make_mut(n).transform(shift, false);
|
||||
// The page's area in the layers' document, and the page's type moved there.
|
||||
let rect = to_doc.transform_rect_bbox(art_box);
|
||||
let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0));
|
||||
for l in &visible.layers {
|
||||
if let Some(c) = l.children() {
|
||||
text_objects(c, l.visible, &mut shown, &mut hidden);
|
||||
}
|
||||
}
|
||||
for object in shown.iter_mut().chain(hidden.iter_mut()) {
|
||||
for n in object.iter_mut() {
|
||||
Arc::make_mut(n).transform(shift, false);
|
||||
}
|
||||
}
|
||||
frame = Some(Frame { page: visible, page_rect, rect });
|
||||
}
|
||||
let mut infos = vec![];
|
||||
collect_slots(&doc.layers, true, &mut infos);
|
||||
@@ -656,18 +664,22 @@ fn build(data: &[u8], visible: &Document, page: Page) -> Result<(Document, Vec<S
|
||||
fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty);
|
||||
doc.layers = layers;
|
||||
if empty > 0 {
|
||||
notes.push(format!("{empty} {TEXT_LEFT_OUT}"));
|
||||
notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT }));
|
||||
}
|
||||
if !hidden_unread.is_empty() {
|
||||
notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread)));
|
||||
}
|
||||
let frame = Frame { page: visible, page_rect, rect };
|
||||
let unseen = prune_unseen(&frame, &mut doc);
|
||||
if unseen > 0 {
|
||||
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
|
||||
match frame {
|
||||
Some(frame) => {
|
||||
let unseen = prune_unseen(&frame, &mut doc);
|
||||
if unseen > 0 {
|
||||
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
|
||||
}
|
||||
notes.extend(compare(&frame, &doc)?);
|
||||
}
|
||||
None => unmark(&mut doc.layers, true, &mut vec![]),
|
||||
}
|
||||
notes.extend(compare(&frame, &doc)?);
|
||||
Ok((doc, notes, rect))
|
||||
Ok((doc, notes))
|
||||
}
|
||||
|
||||
/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's
|
||||
@@ -765,9 +777,9 @@ fn compare(frame: &Frame<'_>, layered: &Document) -> Result<Option<String>, Stri
|
||||
/// has one that can't be used, `visible` with a warning that says why.
|
||||
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
let Some(data) = ai::eps_data(ps) else { return visible };
|
||||
let result = data.and_then(|data| build(&data, &visible.document, Page::Art));
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art));
|
||||
match result {
|
||||
Ok((document, mut warnings, _)) => {
|
||||
Ok((document, mut warnings)) => {
|
||||
for w in &visible.warnings {
|
||||
if !warnings.contains(w) {
|
||||
warnings.push(w.clone());
|
||||
@@ -792,8 +804,8 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>) -> (
|
||||
if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") {
|
||||
return (visible, warnings);
|
||||
}
|
||||
match build(&data, &visible, Page::Artboard) {
|
||||
Ok((done, notes, _)) => {
|
||||
match build(&data, Some(&visible), Page::Artboard) {
|
||||
Ok((done, notes)) => {
|
||||
let mut all = warnings;
|
||||
all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::<Vec<_>>());
|
||||
(done, all)
|
||||
@@ -806,6 +818,13 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>) -> (
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy
|
||||
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
|
||||
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
|
||||
let data = ai::decode_private(private)?;
|
||||
build(&data, None, Page::Artboard)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -521,3 +521,24 @@ fn a_dictionary_left_open_at_the_end_of_a_layer_is_a_damaged_file() {
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| crate::is_loss(w)), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
|
||||
// Point type from the text document, shown or not; a story it can't read is left out, with a
|
||||
// note that is a loss.
|
||||
let mut doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Area\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]);
|
||||
// Story 1 in frame 1, a frame that says more than a matrix.
|
||||
let at = doc.rfind("/1 << /0 [ << /0 0 >> ]").unwrap();
|
||||
doc.replace_range(at..at + 23, "/1 << /0 [ << /0 1 >> ]");
|
||||
let at = doc.rfind("/2 << /2 [ 1 0 0 1 0 0 ] >>").unwrap();
|
||||
doc.replace_range(at..at + 27, "/2 << /2 [ 1 0 0 1 0 0 ] /7 1 >>");
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Words", true, &(text_object(0) + &text_object(1)))), &doc);
|
||||
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
|
||||
assert_eq!(names(&d), ["Back", "Words"], "{notes:?}");
|
||||
let texts: Vec<String> = text_of(&d.layers[1]).iter().map(|t| t.plain_text()).collect();
|
||||
assert_eq!(texts, ["Hi there"], "{notes:?}");
|
||||
let left = notes.iter().find(|n| n.contains("left out")).unwrap();
|
||||
assert!(left.starts_with("1 ") && crate::is_loss(left), "{notes:?}");
|
||||
assert!(!notes.iter().any(|n| n.contains("order the page paints")), "{notes:?}");
|
||||
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
|
||||
}
|
||||
@@ -34,7 +34,7 @@ mod ps;
|
||||
mod scene;
|
||||
mod tiff;
|
||||
|
||||
pub use import::{Imported, family_style, import, is_loss, layered_ai};
|
||||
pub use import::{Imported, ai_alone, family_style, import, is_loss, layered_ai};
|
||||
pub use print::{PrintJob, PrintPage, print};
|
||||
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
@@ -270,10 +270,12 @@ impl Writer {
|
||||
// The drawing space the page shows (the marks' area).
|
||||
let shown = sheet.view.inverse().transform_rect_bbox(paper);
|
||||
let knockout = std::mem::replace(&mut ex.knockout, false);
|
||||
// Marks keep their inks: they aren't converted to the destination.
|
||||
// Marks keep their inks: they aren't converted to the destination. The page
|
||||
// information is drawn as glyph shapes, like the other marks, not as text.
|
||||
let convert = std::mem::replace(&mut ex.convert, false);
|
||||
let outline = std::mem::replace(&mut ex.outline_text, true);
|
||||
ex.node(&mut s, art, sheet.window.map_or(shown, |r| r.intersect(shown)), true);
|
||||
(ex.knockout, ex.convert) = (knockout, convert);
|
||||
(ex.knockout, ex.convert, ex.outline_text) = (knockout, convert, outline);
|
||||
}
|
||||
if window.is_some() {
|
||||
s.pop();
|
||||
|
||||
@@ -24,6 +24,9 @@ pub(crate) struct Placement {
|
||||
pub size: f64,
|
||||
/// Width of the em ÷ its height, in percent.
|
||||
pub h_scale: f64,
|
||||
/// How far the em leans along the baseline per unit of its height (a text matrix with a
|
||||
/// shear: faux italic, or type slanted as a whole); 0 when it stands square on the baseline.
|
||||
pub slant: f64,
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
@@ -33,14 +36,23 @@ impl Placement {
|
||||
let origin = m * Point::ORIGIN;
|
||||
let ex = m * Point::new(1000.0, 0.0) - origin;
|
||||
let ey = m * Point::new(0.0, 1000.0) - origin;
|
||||
let (w, size) = (ex.hypot(), ey.hypot());
|
||||
let w = ex.hypot();
|
||||
// Upright in y-down document space: x to the right of up.
|
||||
let upright = ex.cross(ey) < 0.0;
|
||||
(origin.is_finite() && w.is_finite() && size.is_finite() && w > 0.01 && size > 0.01 && size < 1e5 && upright).then(|| Self {
|
||||
if !(w.is_finite() && w > 0.01) {
|
||||
return None;
|
||||
}
|
||||
let dir = ex / w;
|
||||
// The em's height is across the baseline; what's left of `ey` along it is a slant.
|
||||
let up = Vec2::new(dir.y, -dir.x);
|
||||
let size = ey.dot(up);
|
||||
let slant = ey.dot(dir) / size;
|
||||
(origin.is_finite() && size.is_finite() && slant.is_finite() && size > 0.01 && size < 1e5 && upright).then(|| Self {
|
||||
origin,
|
||||
dir: ex / w,
|
||||
dir,
|
||||
size,
|
||||
h_scale: w / size * 100.0,
|
||||
slant,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,6 +223,7 @@ impl TextLine {
|
||||
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.slant - self.at.slant).abs() < 1e-3
|
||||
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
|
||||
&& gap > -size * 0.3
|
||||
&& gap < size * 3.0;
|
||||
@@ -218,6 +231,7 @@ impl TextLine {
|
||||
// that one ends.
|
||||
let on_curve = !on_line
|
||||
&& opacity == self.opacity
|
||||
&& (at.slant - self.at.slant).abs() < 1e-3
|
||||
&& 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;
|
||||
@@ -309,7 +323,8 @@ impl TextLine {
|
||||
to_logical(&mut t);
|
||||
}
|
||||
let angle = self.at.dir.atan2();
|
||||
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle);
|
||||
// The glyphs' tops lean along the baseline (+x), up being -y in the type's own space.
|
||||
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle) * Affine::skew(-self.at.slant, 0.0);
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
if let Some(path) = path {
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||||
|
||||
@@ -373,7 +373,7 @@ pub struct AdvancedSettings {
|
||||
|
||||
impl Default for AdvancedSettings {
|
||||
fn default() -> Self {
|
||||
Self { font_subset_percent: 100.0, outline_text: true, overprint: Overprint::Preserve }
|
||||
Self { font_subset_percent: 100.0, outline_text: false, overprint: Overprint::Preserve }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -342,9 +342,13 @@ fn text_exported_as_outlines() {
|
||||
let mut d = doc(300.0, 100.0);
|
||||
let t = TextObject::point(Point::new(10.0, 50.0), "Hello", CharStyle { size: 24.0, ..CharStyle::default() });
|
||||
add(&mut d, Node::new(NodeId(0), NodeKind::Text(Box::new(t))));
|
||||
let s = uncompressed(&d);
|
||||
let mut outlined = PdfOptions::uncompressed();
|
||||
outlined.settings.advanced.outline_text = true;
|
||||
let s = String::from_utf8_lossy(&export(&d, &outlined).unwrap()).into_owned();
|
||||
assert!(!s.contains("/Font"), "no fonts when outlining text");
|
||||
let r = import_with_report(&export(&d, &PdfOptions::default()).unwrap(), &ImportOptions::default()).unwrap();
|
||||
// Real text in an embedded font by default, as a PDF from Illustrator has it (#733).
|
||||
assert!(uncompressed(&d).contains("/FontFile"), "a font embedded by default");
|
||||
let r = import_with_report(&export(&d, &outlined).unwrap(), &ImportOptions::default()).unwrap();
|
||||
let l = leaves(&r.document);
|
||||
assert!(!l.is_empty());
|
||||
assert!(l.iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||||
|
||||
@@ -331,9 +331,11 @@ fn pdf_a_overprint_mode_is_0() {
|
||||
#[test]
|
||||
fn overprinting_art_imports_as_it_was_drawn() {
|
||||
let d = overprinting(RED);
|
||||
let plain = leaves(&import(&export(&d, json!({"advanced": {"overprint": "discard"}})).bytes).unwrap());
|
||||
// What is drawn (a form's bounds may come back as an unpainted clipping path).
|
||||
let painted = |bytes: &[u8]| leaves(&import(bytes).unwrap()).into_iter().filter(|l| l.2.is_some()).collect::<Vec<_>>();
|
||||
let plain = painted(&export(&d, json!({"advanced": {"overprint": "discard"}})).bytes);
|
||||
for v in [json!({}), json!({"createLayers": true})] {
|
||||
assert_eq!(leaves(&import(&export(&d, v.clone()).bytes).unwrap()), plain, "{v}: the marks draw nothing");
|
||||
assert_eq!(painted(&export(&d, v.clone()).bytes), plain, "{v}: the marks draw nothing");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
|
||||
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
|
||||
stroke: None,
|
||||
};
|
||||
let at = |x: f64, y: f64| Placement { origin: Point::new(x, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0 };
|
||||
let at = |x: f64, y: f64| Placement { origin: Point::new(x, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };
|
||||
let mut line = TextLine::new(at(100.0, 100.0), 1.0);
|
||||
assert!(line.push_upright(&look, at(100.0, 100.0), 1.0, Upright { top: Point::new(110.0, 82.0) }, "§"));
|
||||
assert!(!line.push(&look, at(130.0, 100.0), 1.0, 10.0, "a"), "a horizontal glyph starts another line");
|
||||
@@ -288,7 +288,7 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
|
||||
stroke: None,
|
||||
};
|
||||
// Glyphs set down a column 1.5 em apart (tracking 500): no spaces come in between them.
|
||||
let at = |y: f64| Placement { origin: Point::new(100.0, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0 };
|
||||
let at = |y: f64| Placement { origin: Point::new(100.0, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };
|
||||
let mut line = TextLine::new(at(100.0), 1.0);
|
||||
for (i, c) in ["§", "§", "§"].iter().enumerate() {
|
||||
let y = 100.0 + 30.0 * i as f64;
|
||||
|
||||
@@ -274,3 +274,19 @@ fn an_embedded_fonts_own_cmap_names_its_glyphs() {
|
||||
}
|
||||
assert!(crate::import::font_chars(b"not a font").is_none());
|
||||
}
|
||||
|
||||
/// Type whose text matrix shears its glyphs (slanted as a whole, as Illustrator writes type
|
||||
/// turned and skewed) reopens as type that still leans: drawn, it covers what its outlines do.
|
||||
#[test]
|
||||
fn slanted_type_reopens_as_type_that_still_leans() {
|
||||
for (turn, lean) in [(0.0, 0.25), (-0.2, 0.25), (0.3, -0.15)] {
|
||||
let mut t = TextObject::point(Point::new(80.0, 150.0), "LEAN", style(60.0));
|
||||
t.xf *= Affine::rotate(turn) * Affine::skew(lean, 0.0);
|
||||
let d = doc(vec![t]);
|
||||
let reopened = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
||||
assert_eq!(texts(&reopened), ["LEAN"], "{turn} {lean}: reopened as type");
|
||||
let (a, b) = (ink(&import_as(&pdf(&reopened, true).bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
|
||||
let off = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(off < 0.5, "{turn} {lean}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ fn options_not_applied_yet_come_back_as_warnings() {
|
||||
json!({"output": {"outputIntent": vectorcraft_color::cms::GENERIC_CMYK, "outputCondition": "Press", "registry": "r", "trapped": true}}),
|
||||
json!({"output": {"outputConditionId": "CGATS TR 001"}}),
|
||||
json!({"advanced": {"outlineText": false}}),
|
||||
json!({"advanced": {"fontSubsetPercent": 35}}),
|
||||
json!({"advanced": {"outlineText": true, "fontSubsetPercent": 35}}),
|
||||
json!({"bleed": {"useDocument": true, "top": 9}}),
|
||||
json!({"bleed": {"top": 9}, "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}),
|
||||
json!({"includeNonPrinting": true}),
|
||||
|
||||
@@ -85,7 +85,8 @@ pub struct RenderOptions {
|
||||
pub overprint_preview: bool,
|
||||
/// Trim View: clip the artwork to the artboards (nothing on the pasteboard is drawn).
|
||||
pub trim: bool,
|
||||
/// Leave template layers out (exports and thumbnails: templates are guides, not artwork).
|
||||
/// Leave template layers and guides out (exports and thumbnails: they're aids to drawing, not
|
||||
/// artwork).
|
||||
pub skip_templates: bool,
|
||||
/// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge
|
||||
/// Color), RGB.
|
||||
@@ -725,9 +726,9 @@ impl Renderer {
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Whether `a` is left out of this frame: hidden, a skipped template, or culled.
|
||||
/// Whether `a` is left out of this frame: hidden, a skipped template or guide, or culled.
|
||||
fn skipped(&mut self, f: &Frame, a: &Arc<Node>) -> bool {
|
||||
let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. });
|
||||
let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. });
|
||||
if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -66,3 +66,22 @@ fn dim_images_fades_only_the_images() {
|
||||
assert_eq!(img.pixel(25, 25), [255, 0, 0, 255], "vector art stays");
|
||||
assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed");
|
||||
}
|
||||
|
||||
/// A guide path draws on screen only: exports leave it out, as they leave templates out (and as
|
||||
/// the SVG, PSD and PDF exports and printing already do).
|
||||
#[test]
|
||||
fn guides_draw_on_screen_only() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut g = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 70.0, 90.0, 90.0)), Appearance::default());
|
||||
if let NodeKind::Path { guide, .. } = &mut g.kind {
|
||||
*guide = true;
|
||||
}
|
||||
d.insert(Some(l), 0, g).unwrap();
|
||||
let drawn = |export: bool| {
|
||||
let r = render(&d, export);
|
||||
(60..100).flat_map(|y| (60..100).map(move |x| (x, y))).filter(|&(x, y)| r.pixel(x, y) != [255, 255, 255, 255]).count()
|
||||
};
|
||||
assert!(drawn(false) > 0, "on screen the guide shows");
|
||||
assert_eq!(drawn(true), 0, "exports leave it out");
|
||||
}
|
||||
@@ -517,6 +517,11 @@ pub trait Tool: Send {
|
||||
fn busy(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Is a drag moving, scaling or rotating the selection (the canvas then hides the bounding box,
|
||||
/// so only the art is seen going with the pointer)?
|
||||
fn transforming(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool
|
||||
/// with a stop selected takes Delete and the arrows)?
|
||||
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
|
||||
|
||||
@@ -163,6 +163,10 @@ impl Tool for SelectionTool {
|
||||
!matches!(self.state, State::Idle) || self.guide.busy()
|
||||
}
|
||||
|
||||
fn transforming(&self) -> bool {
|
||||
matches!(self.state, State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. })
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
|
||||
@@ -34,7 +34,7 @@ const ROWS: &[Row] = &[
|
||||
(&["polygon"], None, &["sides"]),
|
||||
(&["star"], None, &["points"]),
|
||||
(&["freeTransform"], None, &["constrain"]),
|
||||
(&["artboard"], None, &["moveArt"]),
|
||||
(&["artboard"], None, &["moveArt", "scaleArt"]),
|
||||
];
|
||||
|
||||
/// The stores `tool`'s persistent options live in, each with the option keys it holds.
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
//! Click an artboard to make it active (dashed bounds, 8 handles and its name). Drag inside moves
|
||||
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
|
||||
//! leaves the artboard where it was), drag a handle resizes it
|
||||
//! (Shift proportional, Alt from centre), drag on the pasteboard draws a new artboard, Delete removes
|
||||
//! (Shift proportional, Alt from centre; with the `scaleArt` option on, Scale Artwork with Artboard,
|
||||
//! always proportional, and the art fully inside scales with it), drag on the pasteboard draws a new artboard, Delete removes
|
||||
//! the active one (Copy, Cut and Paste take it with its art: `artboard.copy`) and Escape returns to
|
||||
//! the Selection tool. Moving and resizing snap like drawing
|
||||
//! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and
|
||||
//! objects (never to the dragged artboard or the art moving with it); the artboard's own bleed edges
|
||||
//! objects (never to the dragged artboard or the art moving or scaling with it); the artboard's own bleed edges
|
||||
//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::{BLUE, polygon, rect_corners};
|
||||
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
|
||||
@@ -49,6 +50,9 @@ pub struct ArtboardTool {
|
||||
pub active: usize,
|
||||
/// Move artwork with the artboard.
|
||||
pub move_art: bool,
|
||||
/// Scale artwork with the artboard: a resize takes the art fully inside it along, and keeps the
|
||||
/// artboard's proportions.
|
||||
pub scale_art: bool,
|
||||
drag: Option<Drag>,
|
||||
preview: Option<Rect>,
|
||||
/// Smart Guide targets, gathered when a move or resize starts.
|
||||
@@ -63,7 +67,17 @@ pub struct ArtboardTool {
|
||||
|
||||
impl Default for ArtboardTool {
|
||||
fn default() -> Self {
|
||||
Self { active: 0, move_art: true, drag: None, preview: None, targets: None, copy_targets: None, guides: vec![], draw: DrawSnap::default() }
|
||||
Self {
|
||||
active: 0,
|
||||
move_art: true,
|
||||
scale_art: false,
|
||||
drag: None,
|
||||
preview: None,
|
||||
targets: None,
|
||||
copy_targets: None,
|
||||
guides: vec![],
|
||||
draw: DrawSnap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +154,20 @@ impl ArtboardTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// An artboard's shortest side (as `artboard.setProps` keeps it).
|
||||
const MIN_SIDE: f64 = 1.0;
|
||||
|
||||
/// `r` grown about `anchor` until both sides are at least [`MIN_SIDE`], by one factor, so a
|
||||
/// proportional resize keeps its proportions where the command would lengthen only the short side.
|
||||
fn keep_min_side(r: Rect, anchor: Point) -> Rect {
|
||||
let short = r.width().min(r.height());
|
||||
if short >= MIN_SIDE || short <= 0.0 || !short.is_finite() {
|
||||
return r;
|
||||
}
|
||||
let k = MIN_SIDE / short;
|
||||
(Affine::translate(anchor.to_vec2()) * Affine::scale(k) * Affine::translate(-anchor.to_vec2())).transform_rect_bbox(r)
|
||||
}
|
||||
|
||||
/// Where the bleed edge lies beyond a handle, along the axes the handle moves.
|
||||
fn bleed_offset(cx: &ToolContext, rect: Rect, handle: Handle) -> Vec2 {
|
||||
let d = handle.pos(cx.doc.setup.bleed_rect(rect)) - handle.pos(rect);
|
||||
@@ -206,13 +234,21 @@ impl Tool for ArtboardTool {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
out.push(Action::Begin("Resize Artboard".into()));
|
||||
self.begin_snapping(cx, index, false);
|
||||
self.begin_snapping(cx, index, self.scale_art);
|
||||
}
|
||||
self.drag = Some(Drag::Resize { index, handle, rect, grab, began: true });
|
||||
let h = self.snap_handle(cx, p + grab, bleed_offset(cx, rect, handle));
|
||||
let nr = scale_for_drag(rect, handle, h, m.shift, m.alt).transform_rect_bbox(rect);
|
||||
let proportional = m.shift || self.scale_art;
|
||||
let mut nr = scale_for_drag(rect, handle, h, proportional, m.alt).transform_rect_bbox(rect);
|
||||
if proportional {
|
||||
nr = keep_min_side(nr, if m.alt { rect.center() } else { handle.opposite().pos(rect) });
|
||||
}
|
||||
self.preview = Some(nr);
|
||||
out.push(Action::Preview("artboard.setProps".into(), rect_json(index, nr)));
|
||||
let mut v = rect_json(index, nr);
|
||||
if self.scale_art {
|
||||
v["scaleArt"] = json!(true);
|
||||
}
|
||||
out.push(Action::Preview("artboard.setProps".into(), v));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, Some(Drag::Create { start, .. })) => {
|
||||
@@ -338,13 +374,14 @@ impl Tool for ArtboardTool {
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
json!({ "active": self.active, "moveArt": self.move_art })
|
||||
json!({ "active": self.active, "moveArt": self.move_art, "scaleArt": self.scale_art })
|
||||
}
|
||||
|
||||
fn set_option(&mut self, key: &str, value: &Value) {
|
||||
match key {
|
||||
"active" => self.active = value.as_u64().unwrap_or(0) as usize,
|
||||
"moveArt" => self.move_art = value.as_bool().unwrap_or(true),
|
||||
"scaleArt" => self.scale_art = value.as_bool().unwrap_or(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -483,6 +520,49 @@ mod tests {
|
||||
assert_eq!(v["width"].as_f64(), Some(210.0));
|
||||
}
|
||||
|
||||
/// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's
|
||||
/// proportions, and never snaps to the art scaling with it.
|
||||
#[test]
|
||||
fn scale_art_resizes_proportionally_without_snapping_to_its_art() {
|
||||
let mut d = two_boards();
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let art = vectorcraft_geom::shapes::rectangle(Rect::new(700.0, 50.0, 710.0, 60.0));
|
||||
d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
// Off: the right handle 3 pt past the art's right edge (710) lands on it; the height stays.
|
||||
let mut t = ArtboardTool { active: 1, ..Default::default() };
|
||||
t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0));
|
||||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0)));
|
||||
assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v.get("scaleArt")), (Some(110.0), Some(200.0), None));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 713.0, 100.0));
|
||||
// On: the art scales along, so it's no target, and the height follows the width.
|
||||
t.set_option("scaleArt", &json!(true));
|
||||
assert_eq!(t.options()["scaleArt"], true);
|
||||
t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0));
|
||||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0)));
|
||||
assert_eq!((v["width"].as_f64(), v["scaleArt"].as_bool()), (Some(113.0), Some(true)));
|
||||
assert!((v["height"].as_f64().unwrap() - 113.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// A proportional resize never asks for a side under 1 pt, which the command would lengthen
|
||||
/// alone (scaling the art unevenly): both sides grow by one factor instead.
|
||||
#[test]
|
||||
fn a_proportional_resize_keeps_its_proportions_at_the_smallest_size() {
|
||||
let mut d = two_boards();
|
||||
d.artboards[1].rect = Rect::new(600.0, 0.0, 800.0, 20.0);
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let mut c = cx(&d, &s, &p);
|
||||
c.smart_guides = false;
|
||||
let mut t = ArtboardTool { active: 1, scale_art: true, ..Default::default() };
|
||||
// The right handle to 2.5 % of the width: 5 × 0.5, grown to 10 × 1 about the left middle.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 800.0, 10.0));
|
||||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 605.0, 10.0)));
|
||||
let got = ["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap());
|
||||
assert!(got.iter().zip([600.0, 9.5, 10.0, 1.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bleed_edges_snap_to_bleed_edges() {
|
||||
let mut d = two_boards();
|
||||
|
||||
@@ -732,11 +732,13 @@ fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) {
|
||||
vm.center += before - Xf::new(rect, vm).to_doc(p);
|
||||
}
|
||||
|
||||
/// A dashed marquee around screen rectangle `r`.
|
||||
/// A marquee around screen rectangle `r`: dark dashes over a light line, so it shows on a white
|
||||
/// artboard and on the grey pasteboard alike (#725).
|
||||
fn marquee(p: &egui::Painter, r: egui::Rect) {
|
||||
let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
|
||||
p.add(Shape::line(pts.to_vec(), Stroke::new(1.0, Color32::from_white_alpha(230))));
|
||||
for w in pts.windows(2) {
|
||||
p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(90)), 3.0, 3.0));
|
||||
p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(40)), 3.0, 3.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1293,6 +1295,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 Libraries panel graphic: a copy centred where it is dropped.
|
||||
if let widgets::PanelDrag::LibraryGraphic { library, item } = &*d {
|
||||
let at = xf.to_doc(pos);
|
||||
if let Err(e) = app.run("library.use", json!({"library": library, "kind": "graphic", "item": item, "center": [at.x, at.y]})) {
|
||||
app.status(e);
|
||||
}
|
||||
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);
|
||||
@@ -1331,7 +1341,7 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
|
||||
}
|
||||
// Art dragged back onto the canvas: its move was already dropped. (A symbol was placed
|
||||
// above.)
|
||||
widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) => return,
|
||||
widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) | widgets::PanelDrag::LibraryGraphic { .. } => return,
|
||||
};
|
||||
if let Err(e) = app.run(cmd, params) {
|
||||
app.status(e);
|
||||
@@ -1539,8 +1549,10 @@ fn print_tiling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
const PRINT_TILING: Color32 = Color32::from_gray(96);
|
||||
|
||||
fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
// The Selection tool's bounding box (rotated with the objects after a rotation).
|
||||
// The Selection tool's bounding box (rotated with the objects after a rotation). It hides while
|
||||
// a drag moves or resizes the selection, so only the art is seen going with the pointer (#712).
|
||||
let show_box = app.session.tool_id() == "selection"
|
||||
&& !app.session.tool_transforming()
|
||||
&& app.ui.view.bounding_box
|
||||
&& app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
|
||||
let bbox = if show_box { app.selection_box() } else { None };
|
||||
|
||||
@@ -323,26 +323,55 @@ fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool) -> Stri
|
||||
/// Linux and the web).
|
||||
const CLOSE_BEFORE_TITLE: bool = cfg!(target_os = "macos");
|
||||
|
||||
/// The first of tabs `widths` wide that the strip shows in `room`: the first one, or a later one so
|
||||
/// that the `active` tab fits.
|
||||
fn first_tab_shown(widths: &[f32], active: Option<usize>, room: f32) -> usize {
|
||||
let Some(a) = active else { return 0 };
|
||||
let mut first = 0;
|
||||
while first < a && widths.get(first..=a).map_or(0.0, |w| w.iter().sum::<f32>()) > room {
|
||||
first += 1;
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
/// Document tab strip: "Name* @ 66.67% (RGB/Preview)". User Interface › Large Tabs makes the tabs
|
||||
/// taller, with larger titles.
|
||||
/// taller, with larger titles. Tabs that don't fit are reached from a » button at the strip's
|
||||
/// right end, which lists every open document, and the active tab is always in view (#746).
|
||||
pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
/// The width of the » button.
|
||||
const MORE: f32 = 30.0;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (height, title_size) = if app.session.prefs.large_tabs { (44.0, 14.0) } else { (35.0, 12.5) };
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
|
||||
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
|
||||
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
|
||||
// On the Home screen no tab is the current one.
|
||||
let active = app.session.active_index().filter(|_| app.ui.home.is_none());
|
||||
let tabs: Vec<(String, std::sync::Arc<egui::Galley>)> = app
|
||||
.session
|
||||
.documents()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| {
|
||||
let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0);
|
||||
let title = tab_title(d, zoom, app.ui.view.outline);
|
||||
let color = if Some(i) == active { t.text_strong } else { t.text_dim };
|
||||
(title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color))
|
||||
})
|
||||
.collect();
|
||||
let widths: Vec<f32> = tabs.iter().map(|(_, g)| g.size().x + 50.0).collect();
|
||||
let overflow = widths.iter().sum::<f32>() > strip.width();
|
||||
let room = strip.width() - if overflow { MORE } else { 0.0 };
|
||||
let first = if overflow { first_tab_shown(&widths, active, room) } else { 0 };
|
||||
let mut x = strip.left();
|
||||
let mut activate = None;
|
||||
let mut close = None;
|
||||
let active = app.session.active_index();
|
||||
for (i, d) in app.session.documents().iter().enumerate() {
|
||||
let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0);
|
||||
let title = tab_title(d, zoom, app.ui.view.outline);
|
||||
// On the Home screen no tab is the current one.
|
||||
let is_active = Some(i) == active && app.ui.home.is_none();
|
||||
let galley = ui.painter().layout_no_wrap(title, theme::semibold(title_size), if is_active { t.text_strong } else { t.text_dim });
|
||||
let w = galley.size().x + 50.0;
|
||||
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(w, strip.height() - 1.0));
|
||||
for (i, ((_, galley), w)) in tabs.iter().zip(&widths).enumerate().skip(first) {
|
||||
if x + w > strip.left() + room && Some(i) != active {
|
||||
break;
|
||||
}
|
||||
let is_active = Some(i) == active;
|
||||
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(*w, strip.height() - 1.0));
|
||||
let resp = ui.interact(r, ui.id().with(("tab", i)), Sense::click());
|
||||
if is_active {
|
||||
ui.painter().rect_filled(r, 0.0, t.panel);
|
||||
@@ -356,7 +385,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let xr = egui::Rect::from_center_size(egui::pos2(x_center, r.center().y), vec2(12.0, 12.0));
|
||||
let xresp = ui.interact(xr.expand(3.0), ui.id().with(("tabx", i)), Sense::click());
|
||||
icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text });
|
||||
ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text);
|
||||
if xresp.clicked() {
|
||||
close = Some(i);
|
||||
} else if resp.clicked() {
|
||||
@@ -364,6 +393,22 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
x += w;
|
||||
}
|
||||
// The » button: every open document, the active one checked.
|
||||
if overflow {
|
||||
let more = egui::Rect::from_min_max(egui::pos2(strip.right() - MORE, strip.top()), egui::pos2(strip.right(), strip.bottom() - 1.0));
|
||||
let resp = ui.interact(more, ui.id().with("tabs-more"), Sense::click());
|
||||
ui.painter().rect_filled(more, 0.0, if resp.hovered() { t.hover.gamma_multiply(0.4) } else { t.tab_strip });
|
||||
icons::paint(ui, "chevrons-right", egui::Rect::from_center_size(more.center(), vec2(14.0, 14.0)), t.text);
|
||||
let resp = resp.on_hover_text(tl!("Show all open documents"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for (i, (title, _)) in tabs.iter().enumerate() {
|
||||
if widgets::menu_item_name(ui, title, true, Some(i) == active) {
|
||||
activate = Some(i);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(i) = close {
|
||||
if let Err(e) = crate::unsaved::close(app, i) {
|
||||
app.status(e);
|
||||
@@ -764,6 +809,16 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// With more tabs than fit, the strip starts late enough to show the active one (#746).
|
||||
#[test]
|
||||
fn the_active_tab_stays_in_view() {
|
||||
let widths = [100.0; 10];
|
||||
assert_eq!(super::first_tab_shown(&widths, Some(2), 350.0), 0, "it fits from the start");
|
||||
assert_eq!(super::first_tab_shown(&widths, Some(9), 350.0), 7, "the last three tabs shown");
|
||||
assert_eq!(super::first_tab_shown(&widths, None, 350.0), 0);
|
||||
assert_eq!(super::first_tab_shown(&widths, Some(4), 50.0), 4, "a tab wider than the room still shows");
|
||||
}
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ pub fn float(ui: &mut UiState, ids: &[&str], active: &str, pos: Pos2) {
|
||||
}
|
||||
let panels: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
|
||||
let active = panels.iter().position(|p| p == active).unwrap_or(0);
|
||||
ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y] });
|
||||
ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y], width: None });
|
||||
// The tabbed group shows another of its tabs when the one it showed floats.
|
||||
if let Some(tab) = shown_tab(ui) {
|
||||
ui.dock_tab = tab;
|
||||
@@ -312,6 +312,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let ids = ids_of(g);
|
||||
let Some(&first) = ids.first() else { continue };
|
||||
let active = ids.get(g.active).copied().unwrap_or(first);
|
||||
let given = g.width.map(|w| w.min(screen.width() - 16.0));
|
||||
let area = area_id(first);
|
||||
let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(active), 200.0), |r| r.size());
|
||||
let pos = clamp(g.pos, size, screen);
|
||||
@@ -333,7 +334,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
})
|
||||
.collect();
|
||||
let tabs_width: f32 = tabs.iter().map(|g| g.size().x + 24.0).sum();
|
||||
let width = ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max);
|
||||
let width = ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max).max(given.unwrap_or(0.0));
|
||||
ui.set_width(width);
|
||||
// Title bar: a grip that moves the group, and the × that docks it.
|
||||
let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover());
|
||||
@@ -763,7 +764,7 @@ mod tests {
|
||||
}))
|
||||
.unwrap();
|
||||
let edited = edited.sanitized();
|
||||
assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0] }]);
|
||||
assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0], width: None }]);
|
||||
h.settle();
|
||||
let g = h.group("layers");
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
|
||||
|
||||
@@ -285,6 +285,8 @@
|
||||
Fill with Placeholder Text Vyplnit zástupným textem
|
||||
Insert Inline Symbol Vložit symbol do textu
|
||||
Show Hidden Characters Zobrazit skryté znaky
|
||||
Bold Tučné
|
||||
Italic Kurzíva
|
||||
Hide Hidden Characters Skrýt skryté znaky
|
||||
Type Orientation Orientace textu
|
||||
Horizontal Vodorovně
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -339,6 +339,8 @@
|
||||
Clean Up… Limpiar…
|
||||
Clear Borrar
|
||||
Clear All Tabs Borrar todas las tabulaciones
|
||||
Above Text Sobre el texto
|
||||
Position Panel Above Text Colocar el panel sobre el texto
|
||||
Clear Appearance Borrar apariencia
|
||||
Clear Guides Borrar guías
|
||||
Clear Overrides Borrar modificaciones
|
||||
@@ -1989,6 +1991,7 @@
|
||||
Arrange by Row Organizar en una fila
|
||||
Arrange by Column Organizar en una columna
|
||||
Move Artwork with Artboard Mover la ilustración con la mesa de trabajo
|
||||
Scale Artwork with Artboard Escalar la ilustración con la mesa de trabajo
|
||||
Artboards: {n} Mesas de trabajo: {n}
|
||||
Rearrange All Artboards… Reorganizar todas las mesas de trabajo…
|
||||
Recall Selection Recuperar selección
|
||||
@@ -2444,6 +2447,8 @@
|
||||
Show Guides Mostrar guías
|
||||
Show handles when multiple anchors are selected Mostrar manejadores al seleccionar varias anclas
|
||||
Show Hidden Characters Mostrar caracteres ocultos
|
||||
Bold Negrita
|
||||
Italic Cursiva
|
||||
Show Images in Outline Mode Mostrar imágenes en modo de contorno
|
||||
Show in Folder Mostrar en carpeta
|
||||
Show Indic Options Mostrar opciones índicas
|
||||
@@ -2643,6 +2648,25 @@
|
||||
Swatch Tiles Mosaicos de muestras
|
||||
Swatches Muestras
|
||||
Switch workspace Cambiar espacio de trabajo
|
||||
Add Content Añadir contenido
|
||||
Click to apply to the selected type Haz clic para aplicarlo al texto seleccionado
|
||||
Create New Library Crear nueva biblioteca
|
||||
Create New Library… Crear nueva biblioteca…
|
||||
Rename Library… Cambiar nombre de la biblioteca…
|
||||
Delete the library “{name}”? ¿Eliminar la biblioteca “{name}”?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. Arrastra aquí ilustraciones o muestras, o usa + para añadir los colores y estilos de texto de la selección.
|
||||
Its graphics, colors and text styles are deleted from this machine. Sus gráficos, colores y estilos de texto se eliminan de este equipo.
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Guarda aquí gráficos, colores y estilos de texto para usarlos en cualquier documento. Las bibliotecas se guardan en este equipo; no hace falta una cuenta.
|
||||
This library is empty Esta biblioteca está vacía
|
||||
Add to Library Añadir a la biblioteca
|
||||
Delete Library Eliminar biblioteca
|
||||
Delete Library Item Eliminar elemento de la biblioteca
|
||||
Library Biblioteca
|
||||
Rename Library Cambiar nombre de la biblioteca
|
||||
Show Library Mostrar biblioteca
|
||||
Use Library Item Usar elemento de la biblioteca
|
||||
Graphic Gráfico
|
||||
Show all open documents Mostrar todos los documentos abiertos
|
||||
Symbol Instance Instancia de símbolo
|
||||
Symbol Libraries Bibliotecas de símbolos
|
||||
Symbol Libraries (on the roadmap) Bibliotecas de símbolos (en la hoja de ruta)
|
||||
@@ -3124,6 +3148,9 @@ axis Both Ambos
|
||||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
@msg no library `{id}` no hay ninguna biblioteca `{id}`
|
||||
@msg select some type first selecciona primero algún texto
|
||||
@msg {_1} has no {_2} style {_1} no tiene el estilo {_2}
|
||||
@msg a layer couldn't be read no se pudo leer una capa
|
||||
@msg it has more than one artboard tiene más de una mesa de trabajo
|
||||
@msg it has no page no tiene página
|
||||
@@ -3676,6 +3703,7 @@ axis Both Ambos
|
||||
@msg an image VectorCraft can't read was left out se omitió una imagen que VectorCraft no puede leer
|
||||
@msg an image whose data is cut short was left out se omitió una imagen con datos incompletos
|
||||
@msg its editing data doesn't say where its art is sus datos de edición no dicen dónde está su ilustración
|
||||
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) este archivo se guardó sin compatibilidad con PDF y sus datos de edición no se pueden leer ({why})
|
||||
|
||||
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
|
||||
Services Servicios
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -340,6 +340,8 @@
|
||||
Clean Up… Nettoyer…
|
||||
Clear Effacer
|
||||
Clear All Tabs Effacer toutes les tabulations
|
||||
Above Text Au-dessus du texte
|
||||
Position Panel Above Text Placer le panneau au-dessus du texte
|
||||
Clear Appearance Effacer l’aspect
|
||||
Clear Guides Effacer les repères
|
||||
Clear Overrides Effacer les remplacements
|
||||
@@ -1986,6 +1988,7 @@
|
||||
Arrange by Row Disposer en ligne
|
||||
Arrange by Column Disposer en colonne
|
||||
Move Artwork with Artboard Déplacer l’illustration avec le plan de travail
|
||||
Scale Artwork with Artboard Mettre à l’échelle l’illustration avec le plan de travail
|
||||
Artboards: {n} Plans de travail : {n}
|
||||
Rearrange All Artboards… Réorganiser tous les plans de travail…
|
||||
Recall Selection Rappeler la sélection
|
||||
@@ -2441,6 +2444,8 @@
|
||||
Show Guides Afficher les repères
|
||||
Show handles when multiple anchors are selected Afficher les poignées lorsque plusieurs points d’ancrage sont sélectionnés
|
||||
Show Hidden Characters Afficher les caractères masqués
|
||||
Bold Gras
|
||||
Italic Italique
|
||||
Show Images in Outline Mode Afficher les images en mode Tracés
|
||||
Show in Folder Afficher dans le dossier
|
||||
Show Indic Options Afficher les options indiennes
|
||||
@@ -2640,6 +2645,25 @@
|
||||
Swatch Tiles Carreaux de nuances
|
||||
Swatches Nuancier
|
||||
Switch workspace Changer d’espace de travail
|
||||
Add Content Ajouter du contenu
|
||||
Click to apply to the selected type Cliquez pour l’appliquer au texte sélectionné
|
||||
Create New Library Créer une bibliothèque
|
||||
Create New Library… Créer une bibliothèque…
|
||||
Rename Library… Renommer la bibliothèque…
|
||||
Delete the library “{name}”? Supprimer la bibliothèque « {name} » ?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. Faites glisser ici des illustrations ou des nuances, ou utilisez + pour ajouter les couleurs et les styles de texte de la sélection.
|
||||
Its graphics, colors and text styles are deleted from this machine. Ses graphismes, couleurs et styles de texte sont supprimés de cet ordinateur.
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Conservez ici des graphismes, des couleurs et des styles de texte pour les utiliser dans n’importe quel document. Les bibliothèques sont stockées sur cet ordinateur, sans compte.
|
||||
This library is empty Cette bibliothèque est vide
|
||||
Add to Library Ajouter à la bibliothèque
|
||||
Delete Library Supprimer la bibliothèque
|
||||
Delete Library Item Supprimer l’élément de la bibliothèque
|
||||
Library Bibliothèque
|
||||
Rename Library Renommer la bibliothèque
|
||||
Show Library Afficher la bibliothèque
|
||||
Use Library Item Utiliser l’élément de la bibliothèque
|
||||
Graphic Graphisme
|
||||
Show all open documents Afficher tous les documents ouverts
|
||||
Symbol Instance Occurrence de symbole
|
||||
Symbol Libraries Bibliothèques de symboles
|
||||
Symbol Libraries (on the roadmap) Bibliothèques de symboles (prévu dans la feuille de route)
|
||||
@@ -3121,6 +3145,9 @@ axis Both Les deux
|
||||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
@msg no library `{id}` aucune bibliothèque `{id}`
|
||||
@msg select some type first sélectionnez d’abord du texte
|
||||
@msg {_1} has no {_2} style {_1} n’a pas de style {_2}
|
||||
@msg a layer couldn't be read un calque n’a pas pu être lu
|
||||
@msg it has more than one artboard il a plus d'un plan de travail
|
||||
@msg it has no page il n’a pas de page
|
||||
@@ -3673,6 +3700,7 @@ axis Both Les deux
|
||||
@msg an image VectorCraft can't read was left out une image que VectorCraft ne peut pas lire a été ignorée
|
||||
@msg an image whose data is cut short was left out une image aux données tronquées a été ignorée
|
||||
@msg its editing data doesn't say where its art is ses données d’édition ne disent pas où se trouve son illustration
|
||||
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) ce fichier a été enregistré sans compatibilité PDF et ses données d’édition sont illisibles ({why})
|
||||
|
||||
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
|
||||
Services Services
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -339,6 +339,8 @@
|
||||
Clean Up… Pulisci…
|
||||
Clear Cancella
|
||||
Clear All Tabs Cancella tutte le tabulazioni
|
||||
Above Text Sopra il testo
|
||||
Position Panel Above Text Posiziona il pannello sopra il testo
|
||||
Clear Appearance Cancella aspetto
|
||||
Clear Guides Cancella guide
|
||||
Clear Overrides Cancella sostituzioni
|
||||
@@ -1989,6 +1991,7 @@
|
||||
Arrange by Row Disponi in una riga
|
||||
Arrange by Column Disponi in una colonna
|
||||
Move Artwork with Artboard Sposta la grafica con la tavola da disegno
|
||||
Scale Artwork with Artboard Scala la grafica con la tavola da disegno
|
||||
Artboards: {n} Tavole da disegno: {n}
|
||||
Rearrange All Artboards… Ridisponi tutte le tavole da disegno…
|
||||
Recall Selection Richiama selezione
|
||||
@@ -2444,6 +2447,8 @@
|
||||
Show Guides Mostra guide
|
||||
Show handles when multiple anchors are selected Mostra le maniglie quando sono selezionati più punti di ancoraggio
|
||||
Show Hidden Characters Mostra caratteri nascosti
|
||||
Bold Grassetto
|
||||
Italic Corsivo
|
||||
Show Images in Outline Mode Mostra immagini in modalità Contorno
|
||||
Show in Folder Mostra nella cartella
|
||||
Show Indic Options Mostra opzioni per lingue indiane
|
||||
@@ -2643,6 +2648,25 @@
|
||||
Swatch Tiles Riquadri campioni
|
||||
Swatches Campioni
|
||||
Switch workspace Cambia area di lavoro
|
||||
Add Content Aggiungi contenuto
|
||||
Click to apply to the selected type Fai clic per applicarlo al testo selezionato
|
||||
Create New Library Crea nuova libreria
|
||||
Create New Library… Crea nuova libreria…
|
||||
Rename Library… Rinomina libreria…
|
||||
Delete the library “{name}”? Eliminare la libreria “{name}”?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. Trascina qui grafica o campioni, oppure usa + per aggiungere i colori e gli stili di testo della selezione.
|
||||
Its graphics, colors and text styles are deleted from this machine. Grafiche, colori e stili di testo vengono eliminati da questo computer.
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Conserva qui grafiche, colori e stili di testo per usarli in qualsiasi documento. Le librerie restano su questo computer: non serve un account.
|
||||
This library is empty Questa libreria è vuota
|
||||
Add to Library Aggiungi alla libreria
|
||||
Delete Library Elimina libreria
|
||||
Delete Library Item Elimina elemento della libreria
|
||||
Library Libreria
|
||||
Rename Library Rinomina libreria
|
||||
Show Library Mostra libreria
|
||||
Use Library Item Usa elemento della libreria
|
||||
Graphic Grafica
|
||||
Show all open documents Mostra tutti i documenti aperti
|
||||
Symbol Instance Istanza di simbolo
|
||||
Symbol Libraries Librerie simboli
|
||||
Symbol Libraries (on the roadmap) Librerie simboli (in programma)
|
||||
@@ -3124,6 +3148,9 @@ axis Both Entrambi
|
||||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
@msg no library `{id}` nessuna libreria `{id}`
|
||||
@msg select some type first seleziona prima del testo
|
||||
@msg {_1} has no {_2} style {_1} non ha lo stile {_2}
|
||||
@msg a layer couldn't be read non è stato possibile leggere un livello
|
||||
@msg it has more than one artboard ha più di una tavola da disegno
|
||||
@msg it has no page non ha pagine
|
||||
@@ -3676,6 +3703,7 @@ axis Both Entrambi
|
||||
@msg an image VectorCraft can't read was left out un'immagine che VectorCraft non può leggere è stata omessa
|
||||
@msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa
|
||||
@msg its editing data doesn't say where its art is i suoi dati di modifica non dicono dove si trova la grafica
|
||||
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) questo file è stato salvato senza compatibilità PDF e i suoi dati di modifica non si possono leggere ({why})
|
||||
|
||||
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
|
||||
Services Servizi
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -326,6 +326,8 @@
|
||||
Fill with Placeholder Text サンプルテキストの割り付け
|
||||
Insert Inline Symbol インラインシンボルを挿入
|
||||
Show Hidden Characters 制御文字を表示
|
||||
Bold 太字
|
||||
Italic 斜体
|
||||
Type Orientation 組み方向
|
||||
Horizontal 横書き
|
||||
Vertical 縦書き
|
||||
@@ -882,6 +884,8 @@
|
||||
Choose… 選択…
|
||||
Circle 円
|
||||
Clear All Tabs すべてのタブを消去
|
||||
Above Text テキストの上
|
||||
Position Panel Above Text テキストの上にパネルを配置
|
||||
Clear Appearance アピアランスを消去
|
||||
Clear Overrides オーバーライドを消去
|
||||
Click クリック
|
||||
@@ -2002,6 +2006,7 @@
|
||||
Arrange by Row 1 行に配置
|
||||
Arrange by Column 1 列に配置
|
||||
Move Artwork with Artboard アートボードと一緒にアートワークを移動
|
||||
Scale Artwork with Artboard アートボードと一緒にアートワークを拡大・縮小
|
||||
Artboards: {n} アートボード: {n}
|
||||
Recall Selection 選択範囲を呼び出し
|
||||
Recent Colors 最近使用したカラー
|
||||
@@ -2417,6 +2422,25 @@
|
||||
Swatch Options… スウォッチオプション…
|
||||
Swatch Tiles スウォッチタイル
|
||||
Switch workspace ワークスペースを切り替え
|
||||
Add Content コンテンツを追加
|
||||
Click to apply to the selected type クリックして選択したテキストに適用
|
||||
Create New Library 新規ライブラリを作成
|
||||
Create New Library… 新規ライブラリを作成…
|
||||
Rename Library… ライブラリ名を変更…
|
||||
Delete the library “{name}”? ライブラリ「{name}」を削除しますか?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. ここにアートやスウォッチをドラッグするか、+ で選択範囲のカラーやテキストスタイルを追加します。
|
||||
Its graphics, colors and text styles are deleted from this machine. グラフィック、カラー、テキストスタイルはこのコンピューターから削除されます。
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. グラフィック、カラー、テキストスタイルをここに保存すると、どのドキュメントでも使えます。ライブラリはこのコンピューターに保存され、アカウントは不要です。
|
||||
This library is empty このライブラリは空です
|
||||
Add to Library ライブラリに追加
|
||||
Delete Library ライブラリを削除
|
||||
Delete Library Item ライブラリ項目を削除
|
||||
Library ライブラリ
|
||||
Rename Library ライブラリ名を変更
|
||||
Show Library ライブラリを表示
|
||||
Use Library Item ライブラリ項目を使用
|
||||
Graphic グラフィック
|
||||
Show all open documents 開いているすべてのドキュメントを表示
|
||||
Symbol Libraries (on the roadmap) シンボルライブラリ (今後対応予定)
|
||||
Symbol Screener Tool シンボルスクリーナーツール
|
||||
Symbol Scruncher Tool シンボルスクランチャーツール
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -68,8 +68,9 @@ fn plural_french(n: u64) -> usize {
|
||||
usize::from(n > 1)
|
||||
}
|
||||
|
||||
/// Russian: 1 (but not 11) is `one`, 2–4 (but not 12–14) `few`, everything else `many`.
|
||||
fn plural_russian(n: u64) -> usize {
|
||||
/// Russian and Ukrainian integer counts: 1 (but not 11) is `one`, 2–4 (but not 12–14)
|
||||
/// `few`, everything else `many`.
|
||||
fn plural_east_slavic(n: u64) -> usize {
|
||||
match n % 100 {
|
||||
11..=14 => 2,
|
||||
_ => match n % 10 {
|
||||
@@ -81,7 +82,7 @@ fn plural_russian(n: u64) -> usize {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 10] = [
|
||||
pub static LANGUAGES: [LangInfo; 11] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
// Japanese: the whole interface (every menu string and `tl!` literal), keeping the product,
|
||||
// workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
|
||||
@@ -114,7 +115,22 @@ pub static LANGUAGES: [LangInfo; 10] = [
|
||||
// Russian: the whole interface and the status and error messages, keeping the same names in
|
||||
// English as Spanish; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` …) resolves here.
|
||||
LangInfo {
|
||||
code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new()
|
||||
code: "ru",
|
||||
name: "Русский",
|
||||
source: include_str!("ru.tsv"),
|
||||
plural: plural_east_slavic,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
// Ukrainian: the whole interface and the status and error messages, with one/few/many
|
||||
// plural forms; every `uk-*` locale (including `uk-UA`) resolves here.
|
||||
LangInfo {
|
||||
code: "uk",
|
||||
name: "Українська",
|
||||
source: include_str!("uk.tsv"),
|
||||
plural: plural_east_slavic,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
// Brazilian Portuguese: every menu label (`menu_catalogs_translate_every_menu_label`), every
|
||||
// `tl!` literal and the plural messages; left English on purpose are the `MENU_KEEP_AS_IS`
|
||||
|
||||
@@ -1760,6 +1760,7 @@
|
||||
Arrange by Row Organizar em Linha
|
||||
Arrange by Column Organizar em Coluna
|
||||
Move Artwork with Artboard Mover Arte com a Mesa de Trabalho
|
||||
Scale Artwork with Artboard Dimensionar Arte com a Mesa de Trabalho
|
||||
Artboards: {n} Mesas de Trabalho: {n}
|
||||
Rearrange All Artboards… Reorganizar Todas as Mesas de Trabalho…
|
||||
Recall Selection Relembrar Seleção
|
||||
@@ -2134,6 +2135,8 @@
|
||||
Show Grid (⌘') Mostrar Grade (⌘')
|
||||
Show Guides Mostrar Guias
|
||||
Show Hidden Characters Mostrar Caracteres Ocultos
|
||||
Bold Negrito
|
||||
Italic Itálico
|
||||
Show Images in Outline Mode Mostrar Imagens no Modo de Contorno
|
||||
Show Indic Options Mostrar Opções do Índico
|
||||
Show Link Info Mostrar Informações do Link
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -337,6 +337,8 @@
|
||||
Clean Up… Очистить…
|
||||
Clear Очистить
|
||||
Clear All Tabs Удалить все табуляции
|
||||
Above Text Над текстом
|
||||
Position Panel Above Text Расположить панель над текстом
|
||||
Clear Appearance Очистить оформление
|
||||
Clear Guides Удалить направляющие
|
||||
Clear Overrides Очистить переопределения
|
||||
@@ -1985,6 +1987,7 @@
|
||||
Arrange by Row Расположить в строку
|
||||
Arrange by Column Расположить в столбец
|
||||
Move Artwork with Artboard Перемещать иллюстрацию вместе с монтажной областью
|
||||
Scale Artwork with Artboard Масштабировать иллюстрацию вместе с монтажной областью
|
||||
Artboards: {n} Монтажные области: {n}
|
||||
Rearrange All Artboards… Переставить все монтажные области…
|
||||
Recall Selection Восстановить выделение
|
||||
@@ -2440,6 +2443,8 @@
|
||||
Show Guides Показать направляющие
|
||||
Show handles when multiple anchors are selected Показывать маркеры при выделении нескольких опорных точек
|
||||
Show Hidden Characters Показать скрытые символы
|
||||
Bold Полужирный
|
||||
Italic Курсив
|
||||
Show Images in Outline Mode Показывать изображения в режиме контуров
|
||||
Show in Folder Показать в папке
|
||||
Show Indic Options Показать параметры индийских языков
|
||||
@@ -2639,6 +2644,25 @@
|
||||
Swatch Tiles Плитки образцов
|
||||
Swatches Образцы
|
||||
Switch workspace Переключить рабочую среду
|
||||
Add Content Добавить содержимое
|
||||
Click to apply to the selected type Щёлкните, чтобы применить к выделенному тексту
|
||||
Create New Library Создать библиотеку
|
||||
Create New Library… Создать библиотеку…
|
||||
Rename Library… Переименовать библиотеку…
|
||||
Delete the library “{name}”? Удалить библиотеку «{name}»?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. Перетащите сюда графику или образцы или нажмите +, чтобы добавить цвета и стили текста выделенного.
|
||||
Its graphics, colors and text styles are deleted from this machine. Её графика, цвета и стили текста будут удалены с этого компьютера.
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Храните здесь графику, цвета и стили текста, чтобы использовать их в любом документе. Библиотеки хранятся на этом компьютере — учётная запись не нужна.
|
||||
This library is empty Эта библиотека пуста
|
||||
Add to Library Добавить в библиотеку
|
||||
Delete Library Удалить библиотеку
|
||||
Delete Library Item Удалить элемент библиотеки
|
||||
Library Библиотека
|
||||
Rename Library Переименовать библиотеку
|
||||
Show Library Показать библиотеку
|
||||
Use Library Item Использовать элемент библиотеки
|
||||
Graphic Графика
|
||||
Show all open documents Показать все открытые документы
|
||||
Symbol Instance Экземпляр символа
|
||||
Symbol Libraries Библиотеки символов
|
||||
Symbol Libraries (on the roadmap) Библиотеки символов (в плане развития)
|
||||
@@ -3120,6 +3144,9 @@ axis Both Оба
|
||||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
@msg no library `{id}` нет библиотеки `{id}`
|
||||
@msg select some type first сначала выделите текст
|
||||
@msg {_1} has no {_2} style у {_1} нет начертания {_2}
|
||||
@msg a layer couldn't be read не удалось прочитать слой
|
||||
@msg it has more than one artboard в нём несколько монтажных областей
|
||||
@msg it has no page в нём нет страницы
|
||||
@@ -3672,6 +3699,7 @@ axis Both Оба
|
||||
@msg an image VectorCraft can't read was left out изображение, которое VectorCraft не может прочитать, пропущено
|
||||
@msg an image whose data is cut short was left out изображение с обрезанными данными пропущено
|
||||
@msg its editing data doesn't say where its art is в его данных редактирования не указано, где находится графика
|
||||
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) этот файл сохранён без совместимости с PDF, и его данные редактирования не удаётся прочитать ({why})
|
||||
|
||||
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
|
||||
Services Службы
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -19,6 +19,9 @@ fn tags_map_to_languages() {
|
||||
for tag in ["fr", "fr_FR.UTF-8", "fr-BE", "fr_CA", "fr-CH", "fr-LU"] {
|
||||
assert_eq!(lang_from_tag(tag), Lang::from_code("fr"), "{tag}");
|
||||
}
|
||||
for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK-ua", "uk_UA.UTF-8@euro"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(uk()), "{tag}");
|
||||
}
|
||||
// Traditional Chinese: by region, by script, and with a region after the script.
|
||||
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH()));
|
||||
assert_eq!(lang_from_tag("zh-TW"), Some(ZH()));
|
||||
@@ -64,6 +67,7 @@ fn preferences_resolve_with_fallback() {
|
||||
assert_eq!(Lang::from_pref("zh-hant"), ZH());
|
||||
assert_eq!(Lang::from_pref("ZH-Hant"), ZH());
|
||||
assert_eq!(Lang::from_pref("en"), Lang::EN);
|
||||
assert_eq!(Lang::from_pref("UK"), uk());
|
||||
// `auto` and unknown codes follow the system (English under test).
|
||||
assert_eq!(Lang::from_pref("auto"), Lang::EN);
|
||||
assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN);
|
||||
@@ -419,7 +423,7 @@ fn cs() -> Lang {
|
||||
}
|
||||
|
||||
/// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English.
|
||||
const KEEPS_MENU_NAMES: [&str; 7] = ["cs", "es", "fr", "it", "ja", "pt-br", "ru"];
|
||||
const KEEPS_MENU_NAMES: [&str; 8] = ["cs", "es", "fr", "it", "ja", "pt-br", "ru", "uk"];
|
||||
|
||||
/// Menu labels the menu-complete catalogs (Czech, Spanish, Italian, Japanese, Brazilian Portuguese) 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).
|
||||
@@ -568,6 +572,10 @@ fn ru() -> Lang {
|
||||
Lang::from_code("ru").expect("ru registered")
|
||||
}
|
||||
|
||||
fn uk() -> Lang {
|
||||
Lang::from_code("uk").expect("uk registered")
|
||||
}
|
||||
|
||||
/// Spanish uses the vector-illustration vocabulary its users know, has two plural forms like
|
||||
/// English, and reads the same in the menus and in the panels.
|
||||
#[test]
|
||||
@@ -655,6 +663,76 @@ fn russian_reads_as_russian() {
|
||||
assert_eq!(trn(ru(), 22, "{n} Layer", "{n} Layers"), "22 слоя");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ukrainian_reads_as_ukrainian() {
|
||||
for (en, want) in [
|
||||
("Artboard Tool", "Інструмент «Монтажна область»"),
|
||||
("Swatches", "Зразки"),
|
||||
("Pathfinder", "Обробка контурів"),
|
||||
("Stroke", "Обведення"),
|
||||
("Fill", "Заливка"),
|
||||
("Direct Selection Tool", "Інструмент «Пряме виділення»"),
|
||||
("Save As…", "Зберегти як…"),
|
||||
("Undo", "Скасувати"),
|
||||
] {
|
||||
assert_eq!(tr(uk(), en), want);
|
||||
}
|
||||
assert_eq!(tr_ctx(uk(), "axis", "Both"), "Обидві");
|
||||
for (n, word) in [
|
||||
(0, "шарів"),
|
||||
(1, "шар"),
|
||||
(2, "шари"),
|
||||
(5, "шарів"),
|
||||
(11, "шарів"),
|
||||
(21, "шар"),
|
||||
(22, "шари"),
|
||||
(111, "шарів"),
|
||||
(112, "шарів"),
|
||||
(121, "шар"),
|
||||
(124, "шари"),
|
||||
] {
|
||||
assert_eq!(trn(uk(), n, "{n} Layer", "{n} Layers"), format!("{n} {word}"));
|
||||
}
|
||||
let nested = "command `object.group` is not available right now: nothing selected";
|
||||
assert_eq!(message(uk(), nested), "команда `object.group` зараз недоступна: нічого не виділено");
|
||||
}
|
||||
|
||||
/// Every Ukrainian plural has all three forms; exercise different endings through real lookups.
|
||||
#[test]
|
||||
fn ukrainian_plurals_have_three_forms() {
|
||||
let (entries, _) = parse_entries(uk().0.source);
|
||||
let mut count = 0;
|
||||
for (ctx, src, translated) in entries {
|
||||
if ctx != "@plural" {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
let (one, other) = src.split_once('|').expect("one|other source");
|
||||
let forms: Vec<_> = translated.split('|').collect();
|
||||
assert_eq!(forms.len(), 3, "{src}");
|
||||
for (n, form) in [
|
||||
(1, 0),
|
||||
(2, 1),
|
||||
(4, 1),
|
||||
(0, 2),
|
||||
(5, 2),
|
||||
(11, 2),
|
||||
(12, 2),
|
||||
(14, 2),
|
||||
(21, 0),
|
||||
(24, 1),
|
||||
(111, 2),
|
||||
(114, 2),
|
||||
(121, 0),
|
||||
(122, 1),
|
||||
(u64::MAX, 2),
|
||||
] {
|
||||
assert_eq!(trn(uk(), n, one, other), forms[form].replace("{n}", &n.to_string()), "{src}: {n}");
|
||||
}
|
||||
}
|
||||
assert!(count >= 13, "Ukrainian plural rows missing");
|
||||
}
|
||||
|
||||
/// Catalogs written with spaces between words keep a fragment's leading and trailing spaces: the
|
||||
/// hint bar and a few labels are joined from pieces (" to finish", "Press ").
|
||||
#[test]
|
||||
@@ -675,7 +753,7 @@ fn czech_plurals_have_three_forms() {
|
||||
|
||||
#[test]
|
||||
fn russian_plurals_have_three_forms() {
|
||||
let forms: Vec<usize> = [0, 1, 2, 4, 5, 11, 21, 22, 25, 111, 121].into_iter().map(plural_russian).collect();
|
||||
let forms: Vec<usize> = [0, 1, 2, 4, 5, 11, 21, 22, 25, 111, 121].into_iter().map(plural_east_slavic).collect();
|
||||
assert_eq!(forms, [2, 0, 1, 1, 2, 2, 0, 1, 2, 2, 0]);
|
||||
}
|
||||
|
||||
@@ -702,10 +780,10 @@ fn latin_script_glyphs_are_available_without_system_fonts() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Russian letters (and the punctuation the catalog uses) come from each family's own first font, not
|
||||
/// Russian and Ukrainian letters (and their punctuation) come from each family's own first font, not
|
||||
/// from a fallback further down the stack.
|
||||
#[test]
|
||||
fn russian_glyphs_are_available_without_system_fonts() {
|
||||
fn cyrillic_glyphs_are_available_without_system_fonts() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
@@ -716,7 +794,7 @@ fn russian_glyphs_are_available_without_system_fonts() {
|
||||
let first = first.unwrap();
|
||||
let mut font = fonts.fonts.font(&family);
|
||||
let chars = font.characters();
|
||||
for ch in "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ«»„“…–’".chars()
|
||||
for ch in "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯґєіїҐЄІЇ«»„“…–’".chars()
|
||||
{
|
||||
assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}");
|
||||
}
|
||||
@@ -794,7 +872,7 @@ fn complete_languages_translate_every_message() {
|
||||
}
|
||||
|
||||
/// Languages whose catalogs cover every status and error message.
|
||||
const COMPLETE_MESSAGES: &[&str] = &["es", "fr", "it", "ru"];
|
||||
const COMPLETE_MESSAGES: &[&str] = &["es", "fr", "it", "ru", "uk"];
|
||||
|
||||
/// Crates whose error and status messages reach the status bar.
|
||||
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"];
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -325,6 +325,8 @@
|
||||
Clean Up… 清理…
|
||||
Clear 清除
|
||||
Clear All Tabs 清除全部制表位
|
||||
Above Text 文本上方
|
||||
Position Panel Above Text 将面板置于文本上方
|
||||
Clear Appearance 清除外观
|
||||
Clear Guides 清除参考线
|
||||
Clear Overrides 清除覆盖
|
||||
@@ -1943,6 +1945,7 @@
|
||||
Arrange by Row 排成一行
|
||||
Arrange by Column 排成一列
|
||||
Move Artwork with Artboard 随画板移动图稿
|
||||
Scale Artwork with Artboard 随画板缩放图稿
|
||||
Artboards: {n} 画板:{n}
|
||||
Rearrange All Artboards… 重新排列所有画板…
|
||||
Recall Selection 召回选区
|
||||
@@ -2362,6 +2365,8 @@
|
||||
Show Guides 显示参考线
|
||||
Show handles when multiple anchors are selected 选择多个锚点时显示手柄
|
||||
Show Hidden Characters 显示隐藏字符
|
||||
Bold 粗体
|
||||
Italic 斜体
|
||||
Show Images in Outline Mode 以轮廓模式显示图像
|
||||
Show in Folder 在文件夹中显示
|
||||
Show Indic Options 显示印度文字选项
|
||||
@@ -2560,6 +2565,25 @@
|
||||
Swatch Tiles 色板拼贴
|
||||
Swatches 色板
|
||||
Switch workspace 切换工作区
|
||||
Add Content 添加内容
|
||||
Click to apply to the selected type 单击以应用到所选文字
|
||||
Create New Library 创建新库
|
||||
Create New Library… 创建新库…
|
||||
Rename Library… 重命名库…
|
||||
Delete the library “{name}”? 要删除库“{name}”吗?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. 将图稿或色板拖到此处,或使用 + 添加所选对象的颜色和文字样式。
|
||||
Its graphics, colors and text styles are deleted from this machine. 其图形、颜色和文字样式将从此计算机中删除。
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. 在此保存图形、颜色和文字样式,即可在任何文档中使用。库存储在此计算机上,无需帐户。
|
||||
This library is empty 此库为空
|
||||
Add to Library 添加到库
|
||||
Delete Library 删除库
|
||||
Delete Library Item 删除库项目
|
||||
Library 库
|
||||
Rename Library 重命名库
|
||||
Show Library 显示库
|
||||
Use Library Item 使用库项目
|
||||
Graphic 图形
|
||||
Show all open documents 显示所有打开的文档
|
||||
Symbol Instance 符号实例
|
||||
Symbol Libraries 符号库
|
||||
Symbol Libraries (on the roadmap) 符号库 (已列入路线图)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -337,6 +337,8 @@
|
||||
Clean Up… 清除…
|
||||
Clear 清除
|
||||
Clear All Tabs 清除所有定位點
|
||||
Above Text 文字上方
|
||||
Position Panel Above Text 將面板置於文字上方
|
||||
Clear Appearance 清除外觀
|
||||
Clear Guides 清除參考線
|
||||
Clear Overrides 清除覆寫
|
||||
@@ -1992,6 +1994,7 @@
|
||||
Arrange by Row 排成一列
|
||||
Arrange by Column 排成一欄
|
||||
Move Artwork with Artboard 隨工作區域移動圖稿
|
||||
Scale Artwork with Artboard 隨工作區域縮放圖稿
|
||||
Artboards: {n} 工作區域:{n}
|
||||
Rearrange All Artboards… 重新排列所有工作區域…
|
||||
Recall Selection 叫出選取範圍
|
||||
@@ -2447,6 +2450,8 @@
|
||||
Show Guides 顯示參考線
|
||||
Show handles when multiple anchors are selected 選取多個錨點時顯示控制把手
|
||||
Show Hidden Characters 顯示隱藏字元
|
||||
Bold 粗體
|
||||
Italic 斜體
|
||||
Show Images in Outline Mode 在外框模式中顯示影像
|
||||
Show in Folder 在資料夾中顯示
|
||||
Show Indic Options 顯示印度文字選項
|
||||
@@ -2646,6 +2651,25 @@
|
||||
Swatch Tiles 色票拼貼
|
||||
Swatches 色票
|
||||
Switch workspace 切換工作區
|
||||
Add Content 新增內容
|
||||
Click to apply to the selected type 按一下以套用到所選文字
|
||||
Create New Library 建立新資料庫
|
||||
Create New Library… 建立新資料庫…
|
||||
Rename Library… 重新命名資料庫…
|
||||
Delete the library “{name}”? 要刪除資料庫「{name}」嗎?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. 將圖稿或色票拖到此處,或使用 + 加入所選物件的顏色和文字樣式。
|
||||
Its graphics, colors and text styles are deleted from this machine. 其圖形、顏色和文字樣式將從此電腦中刪除。
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. 在此保存圖形、顏色和文字樣式,即可在任何文件中使用。資料庫儲存在此電腦上,無需帳號。
|
||||
This library is empty 此資料庫是空的
|
||||
Add to Library 加入資料庫
|
||||
Delete Library 刪除資料庫
|
||||
Delete Library Item 刪除資料庫項目
|
||||
Library 資料庫
|
||||
Rename Library 重新命名資料庫
|
||||
Show Library 顯示資料庫
|
||||
Use Library Item 使用資料庫項目
|
||||
Graphic 圖形
|
||||
Show all open documents 顯示所有開啟的文件
|
||||
Symbol Instance 符號範例
|
||||
Symbol Libraries 符號資料庫
|
||||
Symbol Libraries (on the roadmap) 符號資料庫 (已列入路線圖)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -11,6 +11,7 @@ use vectorcraft_engine::cmd::fileio::SaveMode;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::io;
|
||||
use crate::panels::character::Face;
|
||||
use crate::state::{DockTab, next_zoom};
|
||||
use crate::theme::{self, Brightness, Tokens};
|
||||
use crate::widgets;
|
||||
@@ -175,6 +176,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"),
|
||||
("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"),
|
||||
("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"),
|
||||
(
|
||||
"type.bold",
|
||||
"Bold",
|
||||
"",
|
||||
"{} the selected type (or the Type tool's selection) in its family's Bold face, or back to the regular face when it is bold; keeps italics. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+B",
|
||||
),
|
||||
(
|
||||
"type.italic",
|
||||
"Italic",
|
||||
"",
|
||||
"{} the selected type (or the Type tool's selection) in its family's Italic (or Oblique) face, or back upright when it is italic; keeps the weight. A family without that face is left as it is, with a message. While the Type tool edits text, Cmd+Shift+I",
|
||||
),
|
||||
("effect.last", "Last Effect…", "Cmd+Alt+Shift+E", "{} open the dialog of the last effect applied"),
|
||||
("view.zoomIn", "Zoom In", "Cmd+=", "{}"),
|
||||
("view.zoomOut", "Zoom Out", "Cmd+-", "{}"),
|
||||
@@ -930,6 +943,8 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
"view.snapToPixel" => flag(&mut app.ui.view.snap_to_pixel),
|
||||
"view.textThreads" => flag(&mut app.ui.view.text_threads),
|
||||
"type.hiddenCharacters" => flag(&mut app.ui.view.hidden_chars),
|
||||
"type.bold" => crate::panels::character::toggle_face(app, Face::Bold),
|
||||
"type.italic" => crate::panels::character::toggle_face(app, Face::Italic),
|
||||
"view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator),
|
||||
"effect.last" => match app.last_effect.clone() {
|
||||
Some((e, params)) => {
|
||||
@@ -1801,6 +1816,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
match id {
|
||||
// Save is off for a clean document that already has its own file.
|
||||
"file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()),
|
||||
"type.bold" | "type.italic" => crate::panels::character::text_style(app).is_some(),
|
||||
"file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()),
|
||||
"file.place"
|
||||
| "file.export.svg"
|
||||
@@ -2220,6 +2236,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
sub("Font", font_items()),
|
||||
sub("Recent Fonts", RECENT_FONT_IDS.iter().map(|id| c("Recent Font", id)).collect()),
|
||||
sub("Size", TYPE_SIZES.iter().map(|(l, n)| cp(l, "text.setStyle", json!({ "size": n }))).collect()),
|
||||
c("Bold", "type.bold"),
|
||||
c("Italic", "type.italic"),
|
||||
Sep,
|
||||
panel("Glyphs", "glyphs"),
|
||||
sub("Insert Special Character", insert_items(INSERT_SPECIAL)),
|
||||
|
||||
@@ -45,10 +45,14 @@ struct Entry {
|
||||
/// 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.
|
||||
/// What the entries depend on: the language, the installed plug-ins and the shortcuts.
|
||||
fn cache_key(lang: crate::i18n::Lang) -> (&'static str, u64, u64) {
|
||||
(lang.code(), menus::plugin_revision(), crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn entries(ctx: &egui::Context, lang: crate::i18n::Lang) -> Arc<Vec<Entry>> {
|
||||
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 key = cache_key(lang);
|
||||
let id = egui::Id::new("palette-entries");
|
||||
if let Some((k, v)) = ctx.data(|d| d.get_temp::<Cached>(id))
|
||||
&& k == key
|
||||
@@ -121,8 +125,16 @@ mod tests {
|
||||
fn entries_are_built_once_per_language_and_match_english_shown_text_and_ids() {
|
||||
let ctx = egui::Context::default();
|
||||
let zh = Lang::from_code("zh-hant").unwrap();
|
||||
let en = entries(&ctx, Lang::EN);
|
||||
assert!(Arc::ptr_eq(&en, &entries(&ctx, Lang::EN)), "kept while the language stays");
|
||||
// Tests running alongside edit shortcuts and install plug-ins, which rebuilds the entries:
|
||||
// look twice until nothing changed in between.
|
||||
let (en, again) = (0..100)
|
||||
.find_map(|_| {
|
||||
let key = cache_key(Lang::EN);
|
||||
let pair = (entries(&ctx, Lang::EN), entries(&ctx, Lang::EN));
|
||||
(cache_key(Lang::EN) == key).then_some(pair)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(Arc::ptr_eq(&en, &again), "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.
|
||||
|
||||
@@ -12,6 +12,24 @@ use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
||||
/// The Artboard tool's Move Artwork with Artboard and Scale Artwork with Artboard options (#602) as
|
||||
/// check boxes: the Control bar and Properties show them while the tool is in use.
|
||||
pub(crate) fn art_options(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let opts = app.session.tool_options();
|
||||
for (key, label, default) in [("moveArt", tl!("Move Artwork with Artboard"), true), ("scaleArt", tl!("Scale Artwork with Artboard"), false)] {
|
||||
let on = opts[key].as_bool().unwrap_or(default);
|
||||
if widgets::check(ui, label, on, true) {
|
||||
app.run("tool.setOption", json!({ "key": key, "value": !on })).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether artboards resized while the Artboard tool is in use take their art along (its Scale
|
||||
/// Artwork with Artboard option): the panels' sizes pass it to `artboard.setProps` as `scaleArt`.
|
||||
pub(crate) fn scale_art(app: &VectorcraftApp) -> bool {
|
||||
app.session.tool_id() == "artboard" && app.session.tool_options()["scaleArt"].as_bool().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The active artboard (of `n`).
|
||||
pub(crate) fn selected(app: &VectorcraftApp, n: usize) -> usize {
|
||||
app.view().map_or(0, |v| v.artboard).min(n.saturating_sub(1))
|
||||
|
||||
@@ -111,6 +111,90 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
|
||||
range_style(app, p);
|
||||
}
|
||||
|
||||
/// The faces Type › Bold and Type › Italic switch to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Face {
|
||||
Bold,
|
||||
Italic,
|
||||
}
|
||||
|
||||
/// Words of a style name that set its weight: a style without any is the family's regular weight.
|
||||
const WEIGHTS: [&str; 16] = [
|
||||
"thin",
|
||||
"hairline",
|
||||
"extralight",
|
||||
"ultralight",
|
||||
"light",
|
||||
"book",
|
||||
"medium",
|
||||
"semibold",
|
||||
"demibold",
|
||||
"demi",
|
||||
"bold",
|
||||
"extrabold",
|
||||
"ultrabold",
|
||||
"heavy",
|
||||
"black",
|
||||
"ultra",
|
||||
];
|
||||
|
||||
/// Words that name the regular weight, upright: not part of what Bold and Italic keep.
|
||||
const PLAIN: [&str; 5] = ["regular", "normal", "roman", "plain", "upright"];
|
||||
|
||||
/// A style name's (bold, regular weight, italic, the other words): `Bold Italic` is (true, false,
|
||||
/// true, []), `Narrow Italic` (false, true, true, ["narrow"]), `SemiBold` (false, false, false, []).
|
||||
fn face_of(style: &str) -> (bool, bool, bool, Vec<String>) {
|
||||
let words: Vec<String> = style.split([' ', '-', '_']).filter(|w| !w.is_empty()).map(str::to_ascii_lowercase).collect();
|
||||
let italic = words.iter().any(|w| w == "italic" || w == "oblique");
|
||||
let bold = words.iter().any(|w| w == "bold");
|
||||
let regular = !words.iter().any(|w| WEIGHTS.contains(&w.as_str()));
|
||||
let mut rest: Vec<String> =
|
||||
words.into_iter().filter(|w| !WEIGHTS.contains(&w.as_str()) && !PLAIN.contains(&w.as_str()) && w != "italic" && w != "oblique").collect();
|
||||
rest.sort();
|
||||
(bold, regular, italic, rest)
|
||||
}
|
||||
|
||||
/// The style of `styles` that is bold (else of regular weight) and italic (else upright), with the
|
||||
/// same other words as style `like` (a Narrow face stays Narrow, a plain one plain).
|
||||
fn pick_face<'a>(styles: &'a [String], bold: bool, italic: bool, like: &str) -> Option<&'a str> {
|
||||
let (.., keep) = face_of(like);
|
||||
styles
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|s| {
|
||||
let (b, r, i, rest) = face_of(s);
|
||||
i == italic && (if bold { b } else { r }) && rest == keep
|
||||
})
|
||||
// `Regular` before `Regular Text`-like doubles: the plainest name is the family's own face.
|
||||
.min_by_key(|s| s.len())
|
||||
}
|
||||
|
||||
/// Type › Bold and Type › Italic (Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text, as in
|
||||
/// page layout apps): the family's own Bold or Italic face for the text the panels act on, or back
|
||||
/// to its regular weight (upright) when it already is bold (italic). No fake bold or slant: a family
|
||||
/// without that face is left as it is, and the message says so (#724).
|
||||
pub(crate) fn toggle_face(app: &mut VectorcraftApp, face: Face) -> Result<Value, String> {
|
||||
let (s, _) = text_style(app).ok_or("select some type first")?;
|
||||
let styles = vectorcraft_text::FontDb::global().styles(&s.font_family);
|
||||
let (bold, _, italic, _) = face_of(&s.font_style);
|
||||
let (want_bold, want_italic) = match face {
|
||||
Face::Bold => (!bold, italic),
|
||||
Face::Italic => (bold, !italic),
|
||||
};
|
||||
let Some(name) = pick_face(&styles, want_bold, want_italic, &s.font_style) else {
|
||||
let wanted = match (want_bold, want_italic) {
|
||||
(true, true) => "Bold Italic",
|
||||
(true, false) => "Bold",
|
||||
(false, true) => "Italic",
|
||||
(false, false) => "Regular",
|
||||
};
|
||||
return Err(format!("{} has no {} style", s.font_family, wanted));
|
||||
};
|
||||
let name = name.to_string();
|
||||
style(app, json!({ "style": name }));
|
||||
Ok(json!({ "style": name }))
|
||||
}
|
||||
|
||||
/// `text.setRangeStyle` with `p` on the range the Type tool has selected (the whole text when
|
||||
/// nothing is selected). Does nothing unless the Type tool edits text.
|
||||
pub(crate) fn range_style(app: &mut VectorcraftApp, p: Value) {
|
||||
@@ -213,6 +297,7 @@ enum TextInput {
|
||||
Copy,
|
||||
Cut,
|
||||
Paste(Option<String>),
|
||||
Face(Face),
|
||||
}
|
||||
|
||||
/// Route editing keys (with modifiers), clipboard events and Cmd+C/X/V to the Type tool (Cmd+A is
|
||||
@@ -254,6 +339,13 @@ pub(crate) fn route_type_input(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Type › Bold and Italic, as page layout apps have them while text is edited.
|
||||
if m.command && m.shift && !m.alt && matches!(key, Key::B | Key::I) {
|
||||
if *pressed {
|
||||
todo.push(TextInput::Face(if *key == Key::B { Face::Bold } else { Face::Italic }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if m.command && !m.shift && !m.alt && matches!(key, Key::C | Key::X | Key::V) {
|
||||
if *pressed {
|
||||
todo.push(match key {
|
||||
@@ -281,6 +373,11 @@ pub(crate) fn route_type_input(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
TextInput::Paste(s) => text_paste(app, s),
|
||||
TextInput::Face(f) => {
|
||||
if let Err(e) = toggle_face(app, f) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -549,6 +646,33 @@ mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// Bold and Italic pick the family's own faces by name, and toggle back (#724).
|
||||
#[test]
|
||||
fn bold_and_italic_pick_the_familys_own_faces() {
|
||||
let styles: Vec<String> =
|
||||
["Light", "Narrow", "Regular", "Italic", "Narrow Italic", "SemiBold", "Bold", "Bold Condensed", "Narrow Bold", "Bold Italic", "Black"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert_eq!(pick_face(&styles, true, false, "Regular"), Some("Bold"), "not Bold Condensed, Narrow Bold or SemiBold");
|
||||
assert_eq!(pick_face(&styles, true, true, "Italic"), Some("Bold Italic"));
|
||||
assert_eq!(pick_face(&styles, false, true, "Regular"), Some("Italic"));
|
||||
assert_eq!(pick_face(&styles, false, false, "Italic"), Some("Regular"), "upright again, not Narrow");
|
||||
assert_eq!(pick_face(&styles, true, false, "Narrow"), Some("Narrow Bold"), "a Narrow face stays Narrow");
|
||||
assert_eq!(pick_face(&styles, false, true, "Narrow"), Some("Narrow Italic"));
|
||||
let oblique: Vec<String> = ["Book", "Oblique", "Bold-Oblique"].iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(pick_face(&oblique, true, true, "Oblique"), Some("Bold-Oblique"));
|
||||
assert_eq!(pick_face(&oblique, false, false, "Oblique"), None, "Book has a weight word: no regular face");
|
||||
assert_eq!(face_of("Bold Italic"), (true, false, true, vec![]));
|
||||
assert_eq!(face_of("SemiBold"), (false, false, false, vec![]));
|
||||
assert_eq!(face_of("Narrow Italic"), (false, true, true, vec!["narrow".to_string()]));
|
||||
// No type selected: a message, nothing changed.
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
assert!(toggle_face(&mut app, Face::Bold).is_err());
|
||||
assert!(!crate::menus::enabled(&app, "type.bold"));
|
||||
}
|
||||
|
||||
/// Character Alignment is in the panel menu with the East Asian options only (as Mojikumi Set
|
||||
/// and Top-to-Top Leading are in the Paragraph panel).
|
||||
#[test]
|
||||
|
||||
@@ -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 { .. } | PanelDrag::Symbol(_) | PanelDrag::Brush { .. } => (None, None),
|
||||
PanelDrag::Paint { .. } | PanelDrag::Symbol(_) | PanelDrag::Brush { .. } | PanelDrag::LibraryGraphic { .. } => (None, None),
|
||||
};
|
||||
if style.is_none() && source.is_none() {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Libraries panel (Window › Libraries, #745): the current library's colours, character and
|
||||
//! paragraph styles and graphics, from the engine's `library.*` commands. Art dragged off the canvas
|
||||
//! onto the panel, and swatches dragged in, are added to it; the + button adds the selection's
|
||||
//! graphic, fill or stroke colour or text styles. A colour click paints the selection through the
|
||||
//! active Fill/Stroke proxy, a style click applies it to the selected type, and a graphic is
|
||||
//! dragged onto the canvas (or double-clicked) to place a copy. Libraries are kept on this machine.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_engine::cmd::library::Library;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, PanelDrag, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
||||
/// The size of a graphic's tile.
|
||||
const TILE: f32 = 56.0;
|
||||
/// The size of a colour chip.
|
||||
const CHIP: f32 = 22.0;
|
||||
|
||||
/// The current library: its id and a copy of it (the panel acts on it after reading).
|
||||
fn current(app: &VectorcraftApp) -> Option<(String, Library)> {
|
||||
let libs = &app.session.libraries;
|
||||
let id = libs.current()?;
|
||||
Some((id.to_string(), libs.get(id)?.clone()))
|
||||
}
|
||||
|
||||
/// Run `cmd`, reporting a failure in the status bar.
|
||||
fn run(app: &mut VectorcraftApp, cmd: &str, p: Value) {
|
||||
if let Err(e) = app.run(cmd, p) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// The name dialog for a new library, or for renaming the current one.
|
||||
fn ask_name(app: &mut VectorcraftApp, rename: Option<&str>) {
|
||||
let (cmd, label, name) = match rename {
|
||||
Some(n) => ("library.rename", "Rename Library", n.to_string()),
|
||||
None => ("library.create", "Create New Library", "My Library".to_string()),
|
||||
};
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("command", json!({ "__command": cmd, "__label": label, "name": name })));
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some((lib_id, lib)) = current(app) else {
|
||||
ui.add_space(16.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
icons::icon(ui, "library", 40.0, t.text_dim);
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(tl!("Local Libraries")).size(14.0).color(t.text));
|
||||
widgets::dim_label(ui, tl!("Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required."));
|
||||
ui.add_space(8.0);
|
||||
if ui.button(tl!("Create New Library")).clicked() {
|
||||
ask_name(app, None);
|
||||
}
|
||||
});
|
||||
return;
|
||||
};
|
||||
// The library picker.
|
||||
let libs: Vec<(String, String)> = app.session.libraries.all().iter().map(|(i, l)| (i.clone(), l.name.clone())).collect();
|
||||
let names: Vec<&str> = libs.iter().map(|(_, n)| n.as_str()).collect();
|
||||
if let Some(i) = widgets::dropdown_names(ui, "lib-picker", &lib.name, &names, ui.available_width())
|
||||
&& let Some((id, _)) = libs.get(i)
|
||||
{
|
||||
run(app, "library.setCurrent", json!({ "library": id }));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let empty = lib.colors.is_empty() && lib.char_styles.is_empty() && lib.para_styles.is_empty() && lib.graphics.is_empty();
|
||||
let mut act: Option<(&'static str, Value)> = None;
|
||||
let fill_active = app.session.fill_active;
|
||||
let list_rect = widgets::list_box(ui, |ui| {
|
||||
ui.set_min_height(140.0);
|
||||
ui.set_width(ui.available_width());
|
||||
if empty {
|
||||
super::empty_state(
|
||||
ui,
|
||||
"library",
|
||||
tl!("This library is empty"),
|
||||
tl!("Drag art or swatches here, or use + to add the selection's colors and text styles."),
|
||||
);
|
||||
return ui.min_rect();
|
||||
}
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::same(6)).show(ui, |ui| {
|
||||
if !lib.colors.is_empty() {
|
||||
heading(ui, tl!("Colors"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
for c in &lib.colors {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(CHIP, CHIP), Sense::click_and_drag());
|
||||
let paint = Paint::solid(c.color);
|
||||
widgets::swatch_tile(ui, r, &paint, false, false);
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
widgets::drag_source(ui, &resp, || PanelDrag::Paint {
|
||||
paint: paint.clone(),
|
||||
params: json!({ "color": c.color }),
|
||||
rows: None,
|
||||
});
|
||||
let resp = resp.on_hover_text(&c.name);
|
||||
if resp.clicked() {
|
||||
act = Some((
|
||||
"library.use",
|
||||
json!({ "kind": "fillColor", "item": c.name, "to": if fill_active { "fill" } else { "stroke" } }),
|
||||
));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Delete", true, false) {
|
||||
act = Some(("library.removeItem", json!({ "kind": "fillColor", "item": c.name })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
for (para, list, title) in [(false, &lib.char_styles, tl!("Character Styles")), (true, &lib.para_styles, tl!("Paragraph Styles"))] {
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
heading(ui, title);
|
||||
let kind = if para { "paraStyle" } else { "charStyle" };
|
||||
for st in list {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
icons::paint(
|
||||
ui,
|
||||
if para { "pilcrow" } else { "type" },
|
||||
Rect::from_center_size(r.left_center() + vec2(10.0, 0.0), vec2(14.0, 14.0)),
|
||||
t.icon,
|
||||
);
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(24.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&st.name,
|
||||
egui::FontId::proportional(12.5),
|
||||
t.text,
|
||||
);
|
||||
let resp = resp.on_hover_text(tl!("Click to apply to the selected type"));
|
||||
if resp.clicked() {
|
||||
act = Some(("library.use", json!({ "kind": kind, "item": st.name })));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Delete", true, false) {
|
||||
act = Some(("library.removeItem", json!({ "kind": kind, "item": st.name })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if !lib.graphics.is_empty() {
|
||||
heading(ui, tl!("Graphics"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
for g in &lib.graphics {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(TILE, TILE), Sense::click_and_drag());
|
||||
tile(ui, r, &lib_id, g);
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border }), StrokeKind::Inside);
|
||||
// Dragged onto the canvas, a copy is placed where it is dropped.
|
||||
widgets::drag_source(ui, &resp, || PanelDrag::LibraryGraphic { library: lib_id.clone(), item: g.id.clone() });
|
||||
let resp = resp.on_hover_text(&g.name);
|
||||
if resp.double_clicked() {
|
||||
act = Some(("library.use", json!({ "kind": "graphic", "item": g.id })));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Place", true, false) {
|
||||
act = Some(("library.use", json!({ "kind": "graphic", "item": g.id })));
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Delete", true, false) {
|
||||
act = Some(("library.removeItem", json!({ "kind": "graphic", "item": g.id })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.min_rect()
|
||||
});
|
||||
// Art dragged off the canvas, or a swatch, dropped on the list is added to the library.
|
||||
let zone = ui.interact(list_rect, ui.id().with("libraries-drop"), Sense::hover());
|
||||
if let Some(ids) = widgets::art_drop(ui, &zone) {
|
||||
act = Some(("library.add", json!({ "kind": "graphic", "ids": ids })));
|
||||
}
|
||||
if let Some(drag) = zone.dnd_hover_payload::<PanelDrag>()
|
||||
&& let PanelDrag::Paint { paint: Paint::Solid { color, .. }, .. } = &*drag
|
||||
{
|
||||
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
|
||||
let color = *color;
|
||||
if zone.dnd_release_payload::<PanelDrag>().is_some() {
|
||||
act = Some(("library.add", json!({ "kind": "fillColor", "color": color })));
|
||||
}
|
||||
}
|
||||
if let Some((cmd, mut p)) = act {
|
||||
p["library"] = json!(lib_id);
|
||||
if cmd == "library.use" && p["kind"] == "graphic" {
|
||||
// Placed at the centre of the view.
|
||||
if let Some(v) = app.view() {
|
||||
p["center"] = json!([v.center.x, v.center.y]);
|
||||
}
|
||||
}
|
||||
run(app, cmd, p);
|
||||
}
|
||||
let has_doc = app.session.active().is_some();
|
||||
widgets::bottom_bar(ui, |ui| {
|
||||
let add = widgets::icon_button_enabled(ui, "plus", tl!("Add Content"), false, has_doc, 24.0);
|
||||
egui::Popup::menu(&add).show(|ui| {
|
||||
for (label, kind) in [
|
||||
("Graphic", "graphic"),
|
||||
("Fill Color", "fillColor"),
|
||||
("Stroke Color", "strokeColor"),
|
||||
("Character Style", "charStyle"),
|
||||
("Paragraph Style", "paraStyle"),
|
||||
] {
|
||||
if menu_item(ui, label, true, false) {
|
||||
run(app, "library.add", json!({ "library": lib_id, "kind": kind }));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A section heading in the list.
|
||||
fn heading(ui: &mut Ui, text: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(text).size(11.5).color(t.text_dim));
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
|
||||
/// Graphic `g`'s thumbnail on white in `r`.
|
||||
fn tile(ui: &Ui, r: Rect, library: &str, g: &vectorcraft_engine::cmd::library::LibraryGraphic) {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
|
||||
if let Some(tex) = thumbnail(ui.ctx(), library, g) {
|
||||
let size = tex.size_vec2();
|
||||
let k = ((r.width() - 6.0) / size.x).min((r.height() - 6.0) / size.y).min(1.0);
|
||||
let fit = Rect::from_center_size(r.center(), size * k);
|
||||
ui.painter().image(tex.id(), fit, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture of graphic `g`'s PNG thumbnail, decoded once.
|
||||
fn thumbnail(ctx: &egui::Context, library: &str, g: &vectorcraft_engine::cmd::library::LibraryGraphic) -> Option<egui::TextureHandle> {
|
||||
thread_local! {
|
||||
static CACHE: crate::graphics::TexCache<HashMap<(String, String, usize), Option<egui::TextureHandle>>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
let key = (library.to_string(), g.id.clone(), g.thumbnail.len());
|
||||
if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
|
||||
return t;
|
||||
}
|
||||
let tex = vectorcraft_format::base64_decode(&g.thumbnail)
|
||||
.and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok())
|
||||
.map(|img| {
|
||||
let rgba = img.to_rgba8();
|
||||
let image = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
|
||||
ctx.load_texture(format!("library-{library}-{}", g.id), image, egui::TextureOptions::LINEAR)
|
||||
});
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.len() > 512 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, tex.clone());
|
||||
});
|
||||
tex
|
||||
}
|
||||
|
||||
/// Graphic `item` of `library`'s thumbnail in `r` (the chip a dragged graphic shows at the pointer).
|
||||
pub(crate) fn chip(app: &VectorcraftApp, ui: &Ui, r: Rect, library: &str, item: &str) {
|
||||
if let Some(g) = app.session.libraries.get(library).and_then(|l| l.graphics.iter().find(|g| g.id == item)) {
|
||||
tile(ui, r, library, g);
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel's ≡ menu: Create New Library, Rename Library…, Delete Library.
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let cur = current(app);
|
||||
if menu_item(ui, "Create New Library…", true, false) {
|
||||
ask_name(app, None);
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Rename Library…", cur.is_some(), false)
|
||||
&& let Some((_, lib)) = &cur
|
||||
{
|
||||
ask_name(app, Some(&lib.name));
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Delete Library", cur.is_some(), false)
|
||||
&& let Some((id, lib)) = &cur
|
||||
{
|
||||
let message = crate::i18n::fmt(tl!("Delete the library “{name}”?"), &[("name", &lib.name)]);
|
||||
crate::dialogs::confirm::ask(
|
||||
app,
|
||||
&message,
|
||||
tl!("Its graphics, colors and text styles are deleted from this machine."),
|
||||
"library.delete",
|
||||
json!({ "library": id }),
|
||||
);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
show(app, ui);
|
||||
menu(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The panel draws with no library, an empty one and one holding every kind of item (#745).
|
||||
#[test]
|
||||
fn the_panel_draws_its_libraries() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
frame(&mut app);
|
||||
app.run("library.create", json!({"name": "Brand"})).unwrap();
|
||||
frame(&mut app);
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run("library.add", json!({"kind": "graphic"})).unwrap();
|
||||
app.run("library.add", json!({"kind": "fillColor"})).unwrap();
|
||||
let t = app.run("text.create", json!({"x": 10, "y": 80, "text": "Type"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [t]})).unwrap();
|
||||
app.run("library.add", json!({"kind": "charStyle"})).unwrap();
|
||||
app.run("library.add", json!({"kind": "paraStyle"})).unwrap();
|
||||
frame(&mut app);
|
||||
let lib = app.session.libraries.get("Brand").unwrap();
|
||||
assert_eq!((lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()), (1, 1, 1, 1));
|
||||
// The thumbnail decodes.
|
||||
let ctx = egui::Context::default();
|
||||
assert!(thumbnail(&ctx, "Brand", &lib.graphics[0]).is_some());
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ pub mod history;
|
||||
pub mod image_trace;
|
||||
pub mod info;
|
||||
pub mod layers;
|
||||
pub mod libraries;
|
||||
pub mod library_panel;
|
||||
pub mod links;
|
||||
pub mod magic_wand;
|
||||
@@ -169,6 +170,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool
|
||||
"paraStyles" => text_styles::menu(app, ui, text_styles::Kind::Para),
|
||||
"magicWand" => magic_wand::menu(app, ui),
|
||||
"tabs" => tabs::menu(app, ui),
|
||||
"libraries" => libraries::menu(app, ui),
|
||||
flattener_preview::ID => flattener_preview::menu(app, ui),
|
||||
"attributes" => attributes::menu(app, ui),
|
||||
"colorThemes" => color_themes::menu(app, ui),
|
||||
@@ -208,18 +210,8 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn libraries(_app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(20.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
icons::icon(ui, "library", 40.0, t.text_dim);
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(tl!("Local Libraries")).size(14.0).color(t.text));
|
||||
dim_label(
|
||||
ui,
|
||||
"Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required.",
|
||||
);
|
||||
});
|
||||
pub fn libraries(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
libraries::show(app, ui);
|
||||
}
|
||||
|
||||
// ---------- shared helpers ----------
|
||||
|
||||
@@ -198,12 +198,14 @@ fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
}
|
||||
|
||||
/// Edit Artboards (the Artboard tool): the active artboard's name, position and size, its preset and
|
||||
/// orientation (#671), New Artboard and Delete Artboard, and Quick Actions: Rearrange All (#681) and
|
||||
/// orientation (#671) (with Scale Artwork with Artboard on, a new size scales its art, #602), Move
|
||||
/// and Scale Artwork with Artboard, New Artboard and Delete Artboard, and Quick Actions: Rearrange All (#681) and
|
||||
/// Exit back to the Selection tool (#530).
|
||||
fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let units = app.session.general_unit();
|
||||
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
|
||||
let i = super::artboards::selected(app, n);
|
||||
let scale_art = super::artboards::scale_art(app);
|
||||
if let Some(ab) = app.session.active().and_then(|d| d.doc.artboards.get(i)).cloned() {
|
||||
// Its name as it is (names are never translated).
|
||||
ui.label(egui::RichText::new(&ab.name).size(13.0).color(Tokens::get(ui.ctx()).text));
|
||||
@@ -216,13 +218,15 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
for (label, key, v) in [(l1, k1, v1), (l2, k2, v2)] {
|
||||
dim_label(ui, label);
|
||||
if let Some(v) = widgets::num_field(ui, ("ab", key, row), Some(v), units, fw) {
|
||||
app.run("artboard.setProps", json!({"index": i, key: v})).ok();
|
||||
app.run("artboard.setProps", json!({"index": i, key: v, "scaleArt": scale_art})).ok();
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
artboard_size_row(app, ui, i, ab.rect.width(), ab.rect.height());
|
||||
ui.add_space(2.0);
|
||||
super::artboards::art_options(app, ui);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let w = (ui.available_width() - 6.0) / 2.0;
|
||||
@@ -296,7 +300,8 @@ fn artboard_size_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize, w: f64, h:
|
||||
}
|
||||
});
|
||||
if let Some((width, height)) = size {
|
||||
app.run("artboard.setProps", json!({"index": i, "width": width, "height": height})).ok();
|
||||
let scale_art = super::artboards::scale_art(app);
|
||||
app.run("artboard.setProps", json!({"index": i, "width": width, "height": height, "scaleArt": scale_art})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,4 +680,43 @@ mod tests {
|
||||
app.run("artboard.setProps", json!({"index": 0, "width": 333})).unwrap();
|
||||
assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Custom"));
|
||||
}
|
||||
|
||||
/// #602: Edit Artboards shows Move and Scale Artwork with Artboard; with Scale on, a new size
|
||||
/// (here the other orientation) takes the art on the artboard along.
|
||||
#[test]
|
||||
fn edit_artboards_scales_the_art_with_scale_artwork_with_artboard() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
let r = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
app.select_tool("artboard");
|
||||
let ctx = egui::Context::default();
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
assert!(texts.iter().any(|(t, _)| t == "Move Artwork with Artboard"), "{texts:?}");
|
||||
assert_eq!(app.session.tool_options()["scaleArt"], false);
|
||||
click(&mut app, &ctx, &texts, "Scale Artwork with Artboard");
|
||||
assert_eq!(app.session.tool_options()["scaleArt"], true);
|
||||
// Portrait: 100 × 200, the art scaled by ½ across and 2 down.
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap();
|
||||
let portrait = ctx.viewport(|vp| {
|
||||
let mut r: Vec<Rect> = vp
|
||||
.prev_pass
|
||||
.widgets
|
||||
.layers()
|
||||
.flat_map(|(_, w)| w.iter())
|
||||
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
|
||||
.map(|w| w.rect)
|
||||
.collect();
|
||||
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
|
||||
r[r.len() - 2].center()
|
||||
});
|
||||
let press = |pressed| Event::PointerButton { pos: portrait, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(portrait), press(true)]);
|
||||
frame(&mut app, &ctx, vec![press(false)]);
|
||||
let d = &app.session.active().unwrap().doc;
|
||||
let ab = d.artboards[0].rect;
|
||||
assert_eq!((ab.width(), ab.height()), (100.0, 200.0));
|
||||
let b = d.node(vectorcraft_doc::NodeId(r)).unwrap().geometric_bounds().unwrap();
|
||||
assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 40.0, 20.0, 80.0));
|
||||
}
|
||||
}
|
||||
@@ -581,6 +581,7 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) {
|
||||
None => return,
|
||||
},
|
||||
PanelDrag::Brush { def, .. } => return pointer_chip(ctx, |ui, r| super::brushes::chip(ui, r, def)),
|
||||
PanelDrag::LibraryGraphic { library, item } => return pointer_chip(ctx, |ui, r| super::libraries::chip(app, ui, r, library, item)),
|
||||
};
|
||||
pointer_chip(ctx, |ui, r| {
|
||||
if registration {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use egui::{Sense, Stroke, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, TabAlign, TabStop};
|
||||
use vectorcraft_doc::{Node, NodeKind, TabAlign, TabStop, TextKind};
|
||||
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
@@ -26,9 +26,13 @@ fn align_id(a: TabAlign) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The stops of the caret's paragraph while the Type tool edits text (else the first selected text
|
||||
/// object's first paragraph) and the ruler span in points (the frame width for area type).
|
||||
fn current(app: &VectorcraftApp) -> Option<(Vec<TabStop>, f64)> {
|
||||
/// The panel body's margin and the ruler's own inset: the ruler's 0 is this far right of the panel.
|
||||
const RULER_LEFT: f32 = 10.0 + 4.0;
|
||||
|
||||
/// The text object, the stops of the caret's paragraph while the Type tool edits text (else the
|
||||
/// first selected text object's first paragraph) and the ruler span in points (the frame width for
|
||||
/// area type).
|
||||
fn current(app: &VectorcraftApp) -> Option<(Node, Vec<TabStop>, f64)> {
|
||||
let editing = super::character::text_editing(app);
|
||||
let n = match editing {
|
||||
Some((id, _, _)) => app.session.active()?.doc.node(id)?.clone(),
|
||||
@@ -37,10 +41,39 @@ fn current(app: &VectorcraftApp) -> Option<(Vec<TabStop>, f64)> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let para = editing.map_or(0, |(_, a, _)| t.paragraphs_in(a, a).start);
|
||||
let span = match &t.kind {
|
||||
vectorcraft_doc::TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()),
|
||||
TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()),
|
||||
_ => 360.0,
|
||||
};
|
||||
Some((t.para_at(para).tabs.clone(), span.max(72.0)))
|
||||
let tabs = t.para_at(para).tabs.clone();
|
||||
Some((n, tabs, span.max(72.0)))
|
||||
}
|
||||
|
||||
/// Position Panel Above Text: float the panel just above text `n`, as wide as its ruler needs to
|
||||
/// put 0 on the text's left edge and its marks at the canvas zoom, so each stop sits over the
|
||||
/// column it sets (#729). `span`: the ruler's length in the text's points.
|
||||
fn above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, span: f64) -> Option<()> {
|
||||
let xf = crate::canvas::Xf::new(app.canvas_rect?, app.view()?);
|
||||
let on_screen = xf.rect_to_screen(n.geometric_bounds()?);
|
||||
// Screen points per point of the text: across the frame for area type (so a scaled frame
|
||||
// lines up too), the zoom for point type.
|
||||
let per = match &n.kind {
|
||||
NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => on_screen.width() / span as f32,
|
||||
_ => xf.zoom as f32,
|
||||
};
|
||||
if !per.is_finite() || per <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let width = span as f32 * per + RULER_LEFT * 2.0;
|
||||
let height = ctx.memory(|m| m.area_rect(crate::floating::area_id("tabs"))).map_or(240.0, |r| r.height());
|
||||
let pos = egui::pos2(on_screen.left() - RULER_LEFT, on_screen.top() - height - 6.0);
|
||||
if crate::floating::group_of(&app.ui, "tabs").is_none() {
|
||||
crate::floating::float(&mut app.ui, &["tabs"], "tabs", pos);
|
||||
}
|
||||
let gi = crate::floating::group_of(&app.ui, "tabs")?;
|
||||
let g = app.ui.floating_panels.get_mut(gi)?;
|
||||
g.pos = [pos.x, pos.y];
|
||||
g.width = Some(width);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn stops_json(stops: &[TabStop]) -> Value {
|
||||
@@ -63,7 +96,7 @@ fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) {
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some((mut stops, span)) = current(app) else {
|
||||
let Some((node, mut stops, span)) = current(app) else {
|
||||
super::empty_state(ui, "pilcrow", tl!("No text selected"), tl!("Select a text object to set its tab stops."));
|
||||
return;
|
||||
};
|
||||
@@ -89,6 +122,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(tl!("Above Text")).on_hover_text(tl!("Position Panel Above Text")).clicked() {
|
||||
above_text(app, ui.ctx(), &node, span);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("tabs-grid").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| {
|
||||
@@ -209,7 +247,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some((mut stops, _)) = current(app) else {
|
||||
let Some((_, mut stops, _)) = current(app) else {
|
||||
ui.add_enabled(false, egui::Button::new(tl!("Select text")).frame(false));
|
||||
return;
|
||||
};
|
||||
@@ -259,11 +297,39 @@ mod tests {
|
||||
frame(&mut app);
|
||||
app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}, {"position": 120, "align": "decimal"}]})).unwrap();
|
||||
frame(&mut app);
|
||||
let (stops, span) = current(&app).unwrap();
|
||||
let (_, stops, span) = current(&app).unwrap();
|
||||
assert_eq!(stops.len(), 2);
|
||||
assert_eq!(span, 300.0);
|
||||
// The stops survive a JSON round trip through the panel's encoding.
|
||||
let back: Vec<serde_json::Value> = stops_json(&stops).as_array().unwrap().clone();
|
||||
assert_eq!(back[1]["align"], json!("decimal"));
|
||||
}
|
||||
|
||||
/// Position Panel Above Text floats the panel over the text, its ruler's 0 on the frame's left
|
||||
/// edge and a stop's mark over the column it sets, at any zoom (#729).
|
||||
#[test]
|
||||
fn above_text_puts_the_ruler_over_the_text_at_the_zoom() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
|
||||
app.session.execute("text.create", &json!({"x": 40, "y": 200, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap();
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), vec2(1000.0, 700.0)));
|
||||
for zoom in [1.0, 2.0] {
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.zoom = zoom;
|
||||
}
|
||||
let (node, _, span) = current(&app).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
above_text(&mut app, &ctx, &node, span).unwrap();
|
||||
let g = &app.ui.floating_panels[crate::floating::group_of(&app.ui, "tabs").unwrap()];
|
||||
let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let frame = xf.rect_to_screen(node.geometric_bounds().unwrap());
|
||||
assert!((g.pos[0] + RULER_LEFT - frame.left()).abs() < 0.01, "0 on the frame's left edge");
|
||||
assert!(g.pos[1] < frame.top(), "above the text");
|
||||
// The ruler (the panel less its margins) spans the frame at this zoom.
|
||||
let ruler = g.width.unwrap() - RULER_LEFT * 2.0;
|
||||
assert!((ruler - frame.width()).abs() < 0.01, "{ruler} {}", frame.width());
|
||||
assert!((ruler - 300.0 * zoom as f32).abs() < 0.01);
|
||||
}
|
||||
assert_eq!(app.ui.floating_panels.len(), 1, "placed again, not floated twice");
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,8 @@ fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect)
|
||||
let rp = vectorcraft_geom::reference_point(r, refi);
|
||||
let set = |app: &mut VectorcraftApp, x, y, w, h| {
|
||||
let n = artboard_rect(r, refi, x, y, w, h, link);
|
||||
let p = json!({"index": index, "x": n.x0, "y": n.y0, "width": n.width(), "height": n.height()});
|
||||
let scale_art = crate::panels::artboards::scale_art(app);
|
||||
let p = json!({"index": index, "x": n.x0, "y": n.y0, "width": n.width(), "height": n.height(), "scaleArt": scale_art});
|
||||
if let Err(e) = app.run("artboard.setProps", p) {
|
||||
app.status(e);
|
||||
}
|
||||
|
||||
@@ -466,6 +466,31 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// Cmd+Shift+B while the Type tool edits text is Type › Bold, not Hide Bounding Box (#724).
|
||||
#[test]
|
||||
fn cmd_shift_b_while_typing_is_bold_not_the_bounding_box() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
app.select_tool("type");
|
||||
let view = app.view_info();
|
||||
for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] {
|
||||
app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, 50.0, 50.0), view).unwrap();
|
||||
}
|
||||
frame(&mut app, vec![egui::Event::Text("Bold".into())]);
|
||||
assert!(app.session.tool_wants_text());
|
||||
let chord =
|
||||
egui::Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND | Modifiers::SHIFT };
|
||||
frame(&mut app, vec![chord.clone()]);
|
||||
assert!(app.ui.view.bounding_box, "the bounding box stays");
|
||||
// The chord reached Bold: the text is in a bold face, or its family has none and says so.
|
||||
let face = crate::panels::character::text_style(&app).map(|(s, _)| s.font_style).unwrap_or_default();
|
||||
assert!(face.contains("Bold") || app.ui.status.contains("has no Bold style"), "{face:?} {:?}", app.ui.status);
|
||||
// Out of the text, the chord hides the bounding box as before.
|
||||
app.select_tool("selection");
|
||||
frame(&mut app, vec![chord]);
|
||||
assert!(!app.ui.view.bounding_box);
|
||||
}
|
||||
|
||||
/// Tab shows and hides the panels, and in the Type tool it types a tab, without moving the
|
||||
/// keyboard focus on to a field: the keys after it (a tool letter, the next letter typed) still
|
||||
/// work.
|
||||
|
||||
@@ -425,6 +425,9 @@ pub struct FloatingPanels {
|
||||
pub active: usize,
|
||||
/// Top-left corner in screen points.
|
||||
pub pos: [f32; 2],
|
||||
/// A width the group was given (the Tabs panel sized over its text), else its panels' own.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub width: Option<f32>,
|
||||
}
|
||||
|
||||
impl FloatingPanels {
|
||||
|
||||
@@ -338,7 +338,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
|
||||
}
|
||||
|
||||
/// The active tool's options in the Control bar: Mirror & Cut's axis and the side it keeps, Puppet
|
||||
/// Warp's mesh and pins (set through `tool.setOption`).
|
||||
/// Warp's mesh and pins, the Artboard tool's Move and Scale Artwork with Artboard (set through
|
||||
/// `tool.setOption`).
|
||||
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
/// (value, label) of each choice.
|
||||
type Choices = &'static [(&'static str, &'static str)];
|
||||
@@ -349,6 +350,11 @@ pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if app.session.tool_id() == "puppetWarp" {
|
||||
return puppet_warp_options(app, ui);
|
||||
}
|
||||
if app.session.tool_id() == "artboard" {
|
||||
crate::panels::artboards::art_options(app, ui);
|
||||
ui.separator();
|
||||
return;
|
||||
}
|
||||
if app.session.tool_id() != "mirrorCut" {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1463,6 +1463,9 @@ pub enum PanelDrag {
|
||||
/// A Brushes panel brush (`def`: its definition, for the chip at the pointer): the path it is
|
||||
/// dropped on takes it (`brush.apply`).
|
||||
Brush { name: String, def: serde_json::Value },
|
||||
/// A Libraries panel graphic (`item`: its id in `library`): the canvas places a copy centred
|
||||
/// where it is dropped (`library.use`).
|
||||
LibraryGraphic { library: String, item: String },
|
||||
}
|
||||
|
||||
/// A panel list `zone` that takes art dragged off the canvas: outlined while art is held over it;
|
||||
|
||||
+5
-4
@@ -138,7 +138,8 @@ so the two scripts never mix), Japanese (`ja`, complete), Spanish (`es`, complet
|
||||
international Spanish; every `es-*` locale such as `es-ES`, `es-MX`, `es-AR` or `es-419` resolves to it), French (`fr`,
|
||||
complete; `fr-FR`, `fr-BE`, `fr-CA`, `fr-CH` and every other `fr-*` locale resolve to it), Italian (`it`,
|
||||
complete; `it-IT`, `it-CH` and every other `it-*` locale resolve to it), Russian (`ru`, complete; every `ru-*`
|
||||
locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Czech (`cs`, every menu label) and Brazilian Portuguese
|
||||
locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Ukrainian (`uk`, complete; `uk-UA`,
|
||||
`uk_UA.UTF-8` and every other `uk-*` locale resolve to it; integer plurals use one/few/many), Czech (`cs`, every menu label) and Brazilian Portuguese
|
||||
(`pt-br`, every menu label and every `tl!` literal). Untranslated text falls back to English until its rows are added.
|
||||
|
||||
- `tl!("…")` translates a literal into the language the UI is drawn in; `i18n::t(s)` is the same for a
|
||||
@@ -166,17 +167,17 @@ locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Czech (`cs`, every m
|
||||
control channel, MCP and tests read them) and are translated only where the status bar draws them
|
||||
(`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as
|
||||
`Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the
|
||||
placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish, French, Italian and Russian cover every
|
||||
placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish, French, Italian, Russian and Ukrainian cover every
|
||||
message literal the test scan finds (`complete_languages_translate_every_message`, languages listed in
|
||||
`COMPLETE_MESSAGES`): a new `Err("…")`, `Other(…)`, `#[error(…)]` or `status(…)` message needs an `es.tsv`,
|
||||
a `fr.tsv`, an `it.tsv` and a `ru.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui
|
||||
a `fr.tsv`, an `it.tsv`, a `ru.tsv` and a `uk.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui
|
||||
complete_languages_translate_every_message` lists them all). Other languages show messages in English
|
||||
until they add `@msg` rows.
|
||||
- Not translated on purpose: names that are user data (layers, swatches, fonts, documents), the tab
|
||||
title's colour mode, command ids and parameter names inside messages. Not done yet: locale-aware number
|
||||
and date formats, right-to-left layout. Chinese and Japanese UI text
|
||||
is drawn with craft-fonts' BIZ UDPGothic when the build embeds it (see Fonts above), else with an installed
|
||||
system font; the glyph test checks Latin-script catalogs always and the CJK ones only with craft-fonts.
|
||||
system font; the glyph test checks Latin and Cyrillic catalogs always and the CJK ones only with craft-fonts.
|
||||
A Traditional Chinese UI font is still to be added to craft-fonts for the web build.
|
||||
|
||||
## Vendor names gate
|
||||
|
||||
+45
-6
@@ -436,7 +436,9 @@ CMYK too.
|
||||
```
|
||||
|
||||
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS
|
||||
reader (see EPS and PostScript import); an `.ai` whose PDF part is only a placeholder page says it can't be opened.
|
||||
reader (see EPS and PostScript import). An `.ai` saved without PDF compatibility (its PDF part is only a placeholder
|
||||
page) opens from its editing data alone: its type is made from the file's text document where it can be (point type), and
|
||||
what can't be is left out with a warning; without editing data it says it can't be opened.
|
||||
|
||||
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
|
||||
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,
|
||||
@@ -627,7 +629,7 @@ repeated names, and ids the document doesn't have are dropped.
|
||||
Ruler guides are numbered in the order they were made. `guide.add {vertical, pos, artboard?}` makes one (the x of a
|
||||
vertical guide, the y of a horizontal one, in points) → `{index}`. With `artboard` (an artboard's index) it is an
|
||||
artboard guide: it runs across that artboard only, moves with it (`artboard.move`, a pure move in
|
||||
`artboard.setProps`, `artboard.rearrange`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is
|
||||
`artboard.setProps`, `artboard.rearrange`), scales with it (`artboard.setProps {scaleArt}`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is
|
||||
deleted with it (`artboard.delete`); without, a canvas guide runs across the whole canvas. `guide.list` →
|
||||
`[{index, vertical, pos, selected, artboard?}…]` (`artboard` only for artboard guides).
|
||||
`guide.select {indexes: [index…], toggle?}` selects guides on their own (the art is deselected; `toggle` adds or
|
||||
@@ -807,6 +809,31 @@ with); without `path` it returns `{data}`, and `user: true` saves into the user
|
||||
(category `user`, User Defined). `graphicStyle.loadLibrary {path? | data? | dataBase64?, name?}` loads a `.vcstyles`
|
||||
file, or another document's graphic styles, as a library to add from.
|
||||
|
||||
## Libraries
|
||||
|
||||
The Libraries panel's libraries hold graphics, colours and character and paragraph styles that any document can use.
|
||||
They are kept on this machine: each is a `.vclibrary` file (JSON) in the `Libraries` folder next to the preferences
|
||||
(none in the web app and headless sessions, where they last for the session). `library.list` lists them (`id`, `name`,
|
||||
the count of each kind, `current`, `folder`); `library.get {library?}` returns one's items (graphics with `id`, `name`,
|
||||
size and a PNG `thumbnail` in base64; colours with `name`, `hex` and `color`; styles with `name` and `attrs`).
|
||||
`library.create {name?}`, `library.rename {library?, name}`, `library.delete {library?}` and
|
||||
`library.setCurrent {library}` manage them; `library` is an id or a name, and without it the current library (the one
|
||||
the panel shows) is meant.
|
||||
|
||||
`library.add {library?, kind, ids?, name?, color?}` adds from the selection: `graphic` (a copy of the objects with the
|
||||
images, symbols, patterns, swatches, styles and brushes they use, as Copy takes them), `fillColor` / `strokeColor` (the
|
||||
first selected object's solid colour, else the default one, or `color`), `charStyle` / `paraStyle` (the selected
|
||||
text's attributes). An item the library already has is reported as `existing`. `library.use {library?, kind, item,
|
||||
center?, to?}` uses one: a graphic is placed centred on `center` (pasted with its resources; a swatch name the document
|
||||
gives another colour merges into the document's), a colour paints the selection's fill or stroke, and a style is added
|
||||
to the document (numbered when its name is taken by other attributes) and applied to the selected text, each as one
|
||||
undo step. `library.removeItem {library?, kind, item}` removes one.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"library.add","params":{"kind":"graphic","name":"Logo"}}}
|
||||
{"name":"run_command","arguments":{"command":"library.use","params":{"kind":"graphic","item":"Logo","center":[300,200]}}}
|
||||
```
|
||||
|
||||
## Strokes on type
|
||||
|
||||
`stroke.set` without an `item` gives type its characters' stroke: weight, cap, join, miter limit and the dash
|
||||
@@ -1182,9 +1209,9 @@ for its colour mode). `profiles` (`all`, `destination`, or `taggedSource` for a
|
||||
`edit.assignProfile`) writes colours in ICC-based spaces with their profiles embedded: CMYK with the destination's or
|
||||
the document's CMYK profile, RGB as sRGB, grey with the sRGB tone curve. `outputIntent` embeds a profile as the file's
|
||||
`/GTS_PDFX` output intent with `outputCondition`, `outputConditionId` and `registry`, and `trapped` sets `/Trapped`
|
||||
(PDF/A files keep their own output intent). `advanced: {outlineText: false}` writes type as selectable, searchable
|
||||
text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding stay outlines, with a
|
||||
warning.
|
||||
(PDF/A files keep their own output intent). Type is written as selectable, searchable text in embedded subset fonts
|
||||
with a ToUnicode map (fonts whose licence forbids embedding stay outlines, with a warning); `advanced: {outlineText:
|
||||
true}` writes every glyph as an outline instead.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/press.pdf","output":{"conversion":"preserveNumbers","destination":"VectorCraft Generic CMYK (SWOP-like)","profiles":"destination","outputIntent":"VectorCraft Generic CMYK (SWOP-like)","trapped":true},"advanced":{"outlineText":false}}}}
|
||||
@@ -1266,7 +1293,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
|
||||
point a selection is dragged by, a drawn point and a transform tool's reference point land on an anchor or a ruler
|
||||
guide that near.
|
||||
- `moveLockedWithArtboard`: `artboard.move {moveArt: true}`, the Artboard tool and `artboard.rearrange` move locked and
|
||||
hidden art with the artboard too; off (the default) it stays where it is.
|
||||
hidden art with the artboard too, and `artboard.setProps {scaleArt: true}` scales it too; off (the default) it stays
|
||||
where it is.
|
||||
- `penRubberBand`, `curvatureRubberBand` (on by default): off, the Pen and Curvature tools draw no preview segment to
|
||||
the pointer.
|
||||
- `showHandlesMultipleAnchors` (on by default): off, Direct Selection (and the Anchor Point tool, and the Pen with Alt)
|
||||
@@ -1549,6 +1577,17 @@ pastes only the art), in this document or another; the result's `artboard` is th
|
||||
does the same in one step → `{index, ids}`, and `artboard.move {copy: true, moveArt: true}` is the Artboard tool's
|
||||
Alt-drag.
|
||||
|
||||
Scale Artwork with Artboard (#602): `artboard.setProps {index, x?, y?, width?, height?, scaleArt: true}` resizes an
|
||||
artboard and scales the art fully inside it, and its artboard guides, from the old rectangle onto the new one, each side
|
||||
by its own ratio (keep the proportions for an even scale), in one undo step → `{scaled: [id…]}`. Locked and hidden art
|
||||
scales only with `lockedAndHidden: true` (default: prefs `moveLockedWithArtboard`); strokes, effects and corners scale
|
||||
as with `object.scale` (`strokes?`, `corners?`, default: Scale Strokes & Effects and Scale Corners), pattern tiles with
|
||||
`patterns?` (default: Transform Pattern Tiles). A move that keeps the size scales nothing (art moves with
|
||||
`artboard.move {moveArt}`). The Artboard tool's `scaleArt` option (`tool.setOption {tool: "artboard", key: "scaleArt",
|
||||
value: true}`; the Control bar's and Properties' Scale Artwork with Artboard check box, beside Move Artwork with
|
||||
Artboard) makes its handle drags proportional and passes `scaleArt`, as do Properties' and the Transform panel's
|
||||
artboard size fields while the tool is in use.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"artboard.copy","params":{"index":0}}}
|
||||
{"name":"run_command","arguments":{"command":"edit.paste","params":{}}}
|
||||
|
||||
Reference in new issue
Block a user