Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 2c1a1526df Fix clippy on main: Create Outlines passes the text, not a reference to it
needless_borrow from #850, which a stale lint result let through.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 06:27:19 +07:00
BFlat 5b27fdc321 Merge pull request #845 from dotCooCoo/feat/copy-to-user-defined
Swatches: swatch.library.copyToUser copies a loaded library into User Defined
2026-10-10 06:21:36 +07:00
BFlat 338ed529e4 Merge pull request #850 from storytold/fix/847-underline-strikethrough
Fix #847: draw Underline and Strikethrough
2026-10-10 06:17:48 +07:00
BFlat b2b3b4fe6a Merge pull request #848 from storytold/fix/hyphen-test-race
Hyphenation exceptions: unit tests read only their thread's list
2026-10-10 06:17:39 +07:00
bflatasticandClaude Opus 5.5 72c9cec0e0 Fix #847: draw Underline and Strikethrough
The styles were stored on the text and written to SVG's text-decoration,
but the type was drawn without the lines. vectorcraft_text::decorations
now gives each underlined or struck-through stretch of a run on a line
its bar, placed and sized from the font's own underline and strikeout
metrics (with fallbacks for fonts that have none). The canvas, Create
Outlines, PDF export (outlined and real text) and SVG's outlined text
draw them in the run's paint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 06:03:04 +07:00
bflatasticandClaude Opus 5.5 4181755db6 Hyphenation exceptions: unit tests read only their thread's list
hyphenation_exceptions_override_the_pattern failed when the round-trip
test set the process-wide list at the same moment: words missing from the
thread's override fell through to the shared store.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 06:02:48 +07:00
BFlat c93d4ad995 Merge pull request #810 from Messhias/feat/394-hyphenation-exceptions
Part of #394: Hyphenation › Exceptions reach the hyphenator
2026-10-10 05:46:03 +07:00
dotCooCoo 975d4975ee Swatches: swatch.library.copyToUser copies a loaded library into User Defined
swatch.library.copyToUser {library} copies a library loaded from
another folder into the user library folder of the desktop app. A
.vcswatches, .gpl or .ase file is copied as it is; the swatches of a
document and a library loaded from the data or dataBase64 parameter are
written as a .vcswatches file. The copy gets a name that no file in the
folder has, and when a file with the same extension and bytes is
already there, that file is used. The library is then listed as User
Defined only. Until the app quits, commands given its loaded id use the
copy, unless another library is loaded under that id. Without a user
library folder (the web and headless sessions) the command is disabled.

A file with the same extension and bytes as a User Defined library
opens as that User Defined library. The unchanged original of a copied
.ase file is one such file. Graphic style libraries open the same way.
2026-10-09 13:32:59 -07:00
BFlat 4cf912fa94 Merge pull request #820 from storytold/fix/812-ellipse-pie
Part of #812: an ellipse's Pie Start and End Angle and Invert Pie
2026-10-10 03:00:32 +07:00
bflatasticandClaude Opus 5.5 78f0297127 Part of #812: an ellipse's Pie Start and End Angle and Invert Pie
An ellipse kept pie angles in its model, but nothing drew or set them. Now
the Transform and Properties panels show Ellipse Properties with Pie Start
Angle, Pie End Angle and Invert Pie, as in Illustrator, and
object.setLiveShape takes {pieStart, pieEnd, invertPie}. The pie runs
counterclockwise on screen from the start angle (0 at 3 o'clock) to the
end angle and closes through the centre; 0 to 360 is the whole ellipse,
an end of 0 means 360, and Invert Pie swaps the angles to show the other
part. One undo step each; a bad angle is an error. The labels are
translated in every complete language. The on-canvas pie widget is still
to come.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 03:00:15 +07:00
BFlat d857d2c0b7 Merge pull request #814 from jmverges/font-versions
Fix #813: type can name an installed version of its font
2026-10-10 02:44:09 +07:00
BFlat 9df597a46d Merge pull request #816 from storytold/fix/807-all-tools-drawer
Fix #807: All Tools shows every tool with its icon, under headings
2026-10-10 02:33:18 +07:00
BFlat 4c175ed099 Merge pull request #815 from storytold/fix/808-slim-toolbar-scrollbar
Fix #808: the toolbar's scroll bar stays a slim line
2026-10-10 02:33:10 +07:00
bflatasticandClaude Opus 5.5 a262ee5c9e Fix #807: All Tools shows every tool with its icon, under headings
The toolbar's All Tools drawer listed the tools' names as plain buttons.
It now shows each tool with its icon and name in a grid, under the
toolbar's group headings (Select, Shapes, Draw, Modify, Type, Navigate,
Color) and headings for the tools only the Advanced toolbar shows
(Perspective, Symbols, Graph, Artboards, Slices). The active tool is
highlighted, a tool's tooltip gives its name and shortcut, and picking one
selects it and closes the drawer. The headings use labels translated
already.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 02:32:52 +07:00
bflatasticandClaude Opus 5.5 986b83280f Fix #808: the toolbar's scroll bar stays a slim line
The toolbar and the dock's icon column scroll when the window is too
short for them. Their scroll bar was egui's floating one, which widens to
10 pt while scrolled or dragged and covered the group labels and the
icons' right edge. It now stays at most 4 pt wide (2 pt at rest), at the
column's edge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 02:32:27 +07:00
Fabio William ConceiçãoandCursor 445bceb681 Part of #394: look up hyphenation exceptions under the lock; drop ~ syntax.
Co-authored-by: Cursor <[email protected]>
2026-10-09 20:26:56 +01:00
Fabio William ConceiçãoandCursor 4f9b292213 Part of #394: headless tests for Hyphenation › Exceptions UI and layout.
Co-authored-by: Cursor <[email protected]>
2026-10-09 20:26:56 +01:00
Fabio William Conceição c9ba5db4fb Part of #394: Preferences › Hyphenation › Exceptions reach the hyphenator.
Comma-separated words never break, or break only at marked hyphens; prefs.set and restore push the list so type reflows.
2026-10-09 20:26:56 +01:00
Juan Manuel Verges SolanasandClaude Opus 5.5 562e09707a Fix #813: type can name an installed version of its font
A font library can hold two versions of one family and style (a new
Montserrat-Bold.ttf beside an old montserrat.ttf). The font database kept
only the first one loaded, so type set in the other couldn't be matched
when a PDF or .ai was opened: it came in as outlines. Now every version
loads (the first stays the one family and style resolve to), PDF import
tries the other versions when the glyphs differ, and type set in a version
the family and style alone wouldn't pick names it in CharStyle::font_version
(left out when None, so documents without it are unchanged). Layout,
shaping, the substituted-glyph highlight, Find Font, Package and Document
Info follow it; changing the font clears it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 19:26:51 +00:00
BFlat b43e633329 Merge pull request #754 from hamada147/feature/support-arabic-language
feat(text): load craft-fonts' Arabic families, fetch them on web
2026-10-10 02:08:29 +07:00
bflatastic 8c1be41e08 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	ROADMAP.md
2026-10-10 01:56:46 +07:00
BFlat cbacb47fdb Merge pull request #770 from dotCooCoo/feat/swatch-libraries
Swatches: Save Swatch Library writes swatch exchange (.ase) files
2026-10-10 01:53:32 +07:00
BFlat 116573ba86 Merge pull request #782 from dotCooCoo/feat/package-font-resolution
Package: fonts found by any of their names are copied, and skipped fonts are named
2026-10-10 01:43:01 +07:00
BFlat 783540a34a Merge pull request #781 from dotCooCoo/feat/macos-registered-fonts
Part of #579: macOS fonts registered from other folders are listed too
2026-10-10 01:42:54 +07:00
BFlat 35a6015d88 Merge pull request #803 from haranishi/i18n-japanese-messages
i18n: Japanese translations for error and status-bar messages
2026-10-10 01:15:02 +07:00
BFlat 71ea5c4215 Merge pull request #802 from dmatviichuk/i18n/ukrainian-dictionary-review
i18n: correct Ukrainian wording and spelling
2026-10-10 01:14:55 +07:00
BFlat 3e1d9b7bbb Merge pull request #795 from DWAA1660/fix/selection-validation-and-clear
Validate selection IDs and clear exact compound members
2026-10-10 01:05:58 +07:00
Ikki NishiharaandClaude Sonnet 5.5 cee9feea2e i18n: Japanese translations for error and status-bar messages
Japanese now translates the status-bar and error messages (`@msg`
templates) like Spanish, French, Italian, Russian and Ukrainian do:
556 rows at the end of ja.tsv, the 498 message literals the test scan
finds plus the 58 that the other catalogs also carry (web start-up,
relinking, export notes, PostScript read errors).

`ja` joins COMPLETE_MESSAGES, so complete_languages_translate_every_message
keeps it complete. The registry comment, docs/development.md and the
ROADMAP line now list Japanese with the other five. The messages
themselves stay English for agents and MCP.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 02:53:14 +09:00
BFlat d522c1d7be Merge pull request #800 from storytold/fix/793-artboard-options-and-layouts
Part of #793: Artboard Options lists Name, Width, Height; one-key shortcuts follow the key on any layout
2026-10-10 00:52:17 +07:00
Dmytro MatviichukandCodex 01f329aa99 i18n: correct Ukrainian wording and spelling (#747)
Co-authored-by: Codex <[email protected]>
2026-10-09 19:51:43 +02:00
bflatasticandClaude Opus 5.5 f300321e28 Part of #793: Artboard Options lists Name, Width, Height; one-key shortcuts follow the key on any layout
- Artboard Options listed its fields by key (Height, Name, Width); it now
  lists Name, then Width and Height.
- One-key tool and command shortcuts (V, P, X, D…) matched the character
  typed, so with a layout without Latin letters (Persian, Arabic, Russian,
  Greek…) they did nothing. When the text typed holds no ASCII character,
  the key's position stands in: the V key selects the Selection tool
  whatever it types. Latin layouts (AZERTY…) keep the letter typed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 00:51:18 +07:00
BFlat 0b14b45610 Merge pull request #796 from storytold/fix/flaky-native-menu-test
Native menu test: judge idle frames while the global generations hold still
2026-10-10 00:36:35 +07:00
DWAA1660 636d9b24c8 Fix #786: clear exact compound members and explicit targets 2026-10-09 11:33:23 -06:00
DWAA1660 04439bb01d Fix #784: validate selection IDs and report the resulting selection 2026-10-09 11:19:08 -06:00
bflatasticandClaude Opus 5.5 9de418a4a4 ROADMAP: the Arabic families come with a craft-fonts checkout that has them
craft-fonts main holds only Noto Sans Arabic so far (the other eleven are in
storytold/craft-fonts#11), and releases pin an older craft-fonts commit, so
say which builds list them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 23:16:41 +07:00
dotCooCoo 75f2145b92 Package: fonts found by any of their names are copied, and skipped fonts are named
File › Package and Document Info › Font Details looked fonts up with
FontDb::face and compared the face's family with the name the document
stores. A font a document named by another of its names (a PostScript
or localized name, as SVG and PDF files do) was reported as not
available and left out of the package, although the canvas drew it and
exports embedded it.

- Package and Font Details resolve fonts with FontDb::resolve, as
  text.fonts and the renderer do. A style the family lacks is still
  copied as its closest style; the report now names the style it
  copies, and Font Details lists the font as substituted, with that
  style's file and permission.
- FontFace::embeddable reads fsType as exports do (Embedding), so fonts
  that allow bitmap embedding only (bit 9), or that set the restricted
  bit together with reserved bit 0 (0x0003), are no longer copied.
  Document Info's permission labels follow the same rule: when a font
  sets more than one usage bit, the label names the most permissive
  one.
- The Package dialog and its status line name the fonts it didn't copy.
2026-10-09 08:35:16 -07:00
dotCooCoo 4b00c2d968 Part of #579: macOS fonts registered from other folders are listed too
The font scan read only the macOS font folders and the Additional
Fonts Folder, so fonts that apps and font managers register with
CoreText from their own folders were missing from the font menus, and
exports set such type in the fallback font. CoreText's font manager
lists them with the installed fonts.

- `mac_fonts.rs` lists the files of the fonts the font manager has
  available (`CTFontManagerCopyAvailableFontURLs`, through
  objc2-core-text), keeping only file URLs as `open_documents.rs` does
  (#433). The desktop app and `vectorcraft-cli` install it as they
  install the DirectWrite lister on Windows, and the scan reads the
  files outside the font folders in place.
- The scan leaves out faces with no outlines the font engine draws (no
  glyf, CFF, CFF2 or VARC table), on every platform. CoreText lists
  Apple's PingFang UI interface font (macOS 27), whose outlines are in
  an hvgl table; if the scan cataloged it, PingFang SC would count as
  installed, and Chinese characters in fallback text, Japanese kanji
  included, would draw blank.
- Tests: the list holds the installed fonts by full path, a font
  registered from another folder is listed while it is registered, and
  a face without outlines the engine draws isn't cataloged.
2026-10-09 08:16:33 -07:00
dotCooCoo a61d058f1b Swatches: Save Swatch Library writes swatch exchange (.ase) files
The Save Swatch Library dialog lists Swatch Exchange (.ase) as a
format, and swatch.library.save takes format: "ase" or a path ending
in .ase. Each solid color keeps its model (RGB, CMYK, Lab or Gray) and
its type (spot, global or process), and color groups stay groups.
Gradients and patterns are left out, and a tint swatch is written as
the color it shows. Without a path or user: true, the command returns
the file as dataBase64.

A swatch exchange library opens under the name of its file. When Save
Swatch Library or Save Graphic Style Library saves to a file, the save
panel suggests the Name from the dialog as the file name, as a save to
User Defined does, and the web app downloads the file under that name.
A name made only of dots and spaces, such as "...", gives the file name
Library (Library.vcswatches) in place of a hidden file.

palette_io::write returns the file's bytes. A property test writes
generated libraries and reads them back, and an import fuzz test loads
mutated copies of a file the writer wrote.
2026-10-09 08:15:09 -07:00
Ahmed Moussa 806ba127e7 Merge branch 'main' into feature/support-arabic-language 2026-10-09 17:19:05 +03:00
Ahmed Moussa 3e20c2cf29 build(web): copy web fonts with cargo xtask, not sh
Replace the Trunk post_build hook packaging/web/copy-fonts.sh with `cargo xtask web-fonts` (xtask/src/web_fonts.rs), so the web build only needs trunk, the wasm32 target and cargo, with no POSIX shell, and works the same on Windows.

The new task reuses crates/text/build/web_fonts.rs for list parsing and URLs, so build.rs and the copier can't disagree on fonts/<sha16>/<file>. It still does nothing without CRAFT_FONTS_DIR, checks each file's SHA-256 against the manifest, and copies the licence next to each face. A listed face that an older craft-fonts checkout lacks is now skipped with a warning, as build.rs does, instead of failing the build. A relative CRAFT_FONTS_DIR is resolved from the workspace root.

Update the references in Trunk.toml, fonts.rs, build.rs, craft_fonts.rs and web-fonts.txt, and document the web build's requirements in docs/development.md. Add tests for the copy, a hash mismatch and the no-op case.

Signed-off-by: Ahmed Moussa <[email protected]>
2026-10-09 17:18:54 +03:00
Ahmed Moussa 3c85f3f9e3 feat(text): load craft-fonts' Arabic families, fetch them on web
Desktop builds made with CRAFT_FONTS_DIR now load craft-fonts' 12 Arabic families into the document font database. Noto Sans Arabic comes first and is the fallback for Arabic text. Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar can be picked by name from the font menus.

The web build doesn't embed these fonts because they would make the wasm too large. It fetches them from beside the wasm instead:
- crates/text/web-fonts.txt lists each face as startup or background.
- build.rs turns that list into vectorcraft_text::WEB_FONTS, with each face's URL (fonts/<sha16>/<file>) and SRI hash taken from the craft-fonts manifest.
- packaging/web/copy-fonts.sh, a Trunk post_build hook, copies the font files and their OFL licences there.
- apps/vectorcraft-web/src/fonts.rs fetches Noto Sans Arabic before the app starts, so Arabic text never shows as boxes. It loads the other faces in the background and logs `web font …` for any that fail.

Because fonts can now arrive after startup, the Type tool's layout cache also checks the font database's generation, so text lays out again when new fonts arrive.

_headers and .htaccess serve fonts/* as font/ttf and cache it as immutable. packaging/web/README.md and docs/development.md explain the fonts folder, and ROADMAP.md lists the Arabic typefaces under Type. Also adds Ahmed Moussa to contributors/people.toml.

Signed-off-by: Ahmed Moussa <[email protected]>
2026-10-09 16:09:34 +03:00
100 changed files with 3695 additions and 301 deletions

No files matched your search

Generated
+14
View File
@@ -2553,6 +2553,15 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -4323,6 +4332,8 @@ dependencies = [
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"rfd",
"serde_json",
@@ -4379,6 +4390,8 @@ name = "vectorcraft-cli"
version = "0.7.0"
dependencies = [
"log",
"objc2-core-foundation",
"objc2-core-text",
"serde_json",
"vectorcraft-color",
"vectorcraft-doc",
@@ -4756,6 +4769,7 @@ dependencies = [
"rfd",
"vectorcraft-engine",
"vectorcraft-format",
"vectorcraft-text",
"vectorcraft-ui-egui",
"wasm-bindgen",
"wasm-bindgen-futures",
+9 -7
View File
@@ -114,7 +114,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). About 20 stored options still do nothing yet (the rest of #394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size at 100% Zoom, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
- Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). About 19 stored options still do nothing yet (the rest of #394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size at 100% Zoom, Identify Objects By, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Appearance of Black, Touch Workspace).
- Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423).
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
@@ -125,9 +125,11 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Tools:**
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
@@ -145,13 +147,13 @@ 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). 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.
- **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. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). 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 Line truncated
- **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, Japanese, 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 (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{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).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl` or CSS, and loaded from `.vcswatches`, `.gpl` and swatch exchange (`.ase`) files (RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
@@ -161,7 +163,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579).
- **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.
@@ -230,7 +232,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| 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 |
| 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 19 Preferences options stored but not yet acted on (#394: Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 |
| 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 |
+6
View File
@@ -31,6 +31,12 @@ log = { workspace = true }
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
# CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font
# managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT.
[target.'cfg(target_os = "macos")'.dependencies]
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
serde_json = { workspace = true }
+8 -2
View File
@@ -9,8 +9,8 @@
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000]
//! ```
// Denied, not forbidden: the DirectWrite font lister shared with the desktop app (Windows) allows
// it for its COM calls, and nothing else may.
// Denied, not forbidden: the font listers shared with the desktop app allow it for their
// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may.
#![deny(unsafe_code)]
use std::io::Write;
@@ -36,6 +36,10 @@ macro_rules! out {
}};
}
/// The desktop app's CoreText font lister, shared: exports and MCP find the same fonts.
#[cfg(target_os = "macos")]
#[path = "../../vectorcraft/src/mac_fonts.rs"]
mod mac_fonts;
mod perf;
/// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts.
#[cfg(all(windows, not(target_vendor = "win7")))]
@@ -104,6 +108,8 @@ fn main() -> ExitCode {
// Before any font lookup: the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
system_fonts::install();
#[cfg(target_os = "macos")]
mac_fonts::install();
let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
Some("mcp") => mcp(&args[1..]),
+2 -1
View File
@@ -17,6 +17,7 @@ path = "src/main.rs"
vectorcraft-engine = { workspace = true }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-format = { workspace = true }
vectorcraft-text = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -27,4 +28,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
+8
View File
@@ -8,3 +8,11 @@ filehash = true
[serve]
addresses = ["127.0.0.1"]
port = 8766
# Copy the craft-fonts faces web builds fetch (crates/text/web-fonts.txt) into the site's fonts/
# folder; a no-op without CRAFT_FONTS_DIR. Runs for `trunk serve` and `trunk build`, through cargo so
# it needs no shell (xtask/src/web_fonts.rs).
[[hooks]]
stage = "post_build"
command = "cargo"
command_arguments = ["xtask", "web-fonts"]
+109
View File
@@ -0,0 +1,109 @@
//! Craft-fonts faces served beside the wasm (`fonts/<sha16>/…`, copied by
//! `cargo xtask web-fonts`) instead of embedded: [`load_startup_fonts`] fetches the `startup`
//! ones before the app starts, [`spawn_background_fonts`] the rest once it runs. The browser checks
//! each file against its SHA-256 (Subresource Integrity). A face that fails is logged and skipped:
//! the app runs without it. With `WEB_FONTS` empty (built without craft-fonts) both do nothing.
use std::cell::Cell;
use std::rc::Rc;
use vectorcraft_text::{FontDb, WEB_FONTS, WebFont};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::closure::Closure;
use wasm_bindgen_futures::JsFuture;
/// Per startup face: the page shows its loading text meanwhile.
const STARTUP_TIMEOUT_MS: i32 = 20_000;
/// Per background face.
const BACKGROUND_TIMEOUT_MS: i32 = 120_000;
/// Background fetches at once, leaving the connection to the app's own requests.
const BACKGROUND_PARALLEL: usize = 4;
thread_local! {
/// The background fetch runs once per page, though a graphics restart creates the app again.
static BACKGROUND_STARTED: Cell<bool> = const { Cell::new(false) };
}
/// Fetch and register the `startup` faces (one after the other). Returns how many loaded.
pub async fn load_startup_fonts() -> usize {
let mut loaded = 0;
for f in WEB_FONTS.iter().filter(|f| f.startup) {
if load(f, STARTUP_TIMEOUT_MS).await {
loaded += 1;
}
}
loaded
}
/// Fetch and register the `background` faces, [`BACKGROUND_PARALLEL`] at a time, asking `ctx` for
/// a frame after each so the font menus and the canvas pick it up. Only the first call does
/// anything.
pub fn spawn_background_fonts(ctx: egui::Context) {
if BACKGROUND_STARTED.with(|s| s.replace(true)) {
return;
}
let queue: Rc<Vec<&'static WebFont>> = Rc::new(WEB_FONTS.iter().filter(|f| !f.startup).collect());
let next = Rc::new(Cell::new(0usize));
for _ in 0..BACKGROUND_PARALLEL.min(queue.len()) {
let (queue, next, ctx) = (queue.clone(), next.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
loop {
let i = next.get();
next.set(i + 1);
let Some(f) = queue.get(i) else { break };
if load(f, BACKGROUND_TIMEOUT_MS).await {
ctx.request_repaint();
}
}
});
}
}
/// Fetch one face and add it to the font database. False (and a console warning) on failure.
async fn load(f: &WebFont, timeout_ms: i32) -> bool {
let bytes = match fetch(f.url, f.integrity, timeout_ms).await {
Ok(bytes) => bytes,
Err(e) => {
log::warn!("web font {} {}: {e}", f.family, f.style);
return false;
}
};
if FontDb::global().add_font(bytes) == 0 {
log::warn!("web font {} {}: not a font, or already loaded", f.family, f.style);
return false;
}
true
}
/// GET `url` (relative to the page) with Subresource Integrity `integrity`, aborted after
/// `timeout_ms`.
async fn fetch(url: &str, integrity: &str, timeout_ms: i32) -> Result<Vec<u8>, String> {
let window = web_sys::window().ok_or("no window")?;
let abort = web_sys::AbortController::new().map_err(js)?;
let init = web_sys::RequestInit::new();
init.set_method("GET");
init.set_integrity(integrity);
init.set_signal(Some(&abort.signal()));
let on_timeout = {
let abort = abort.clone();
Closure::<dyn FnMut()>::new(move || abort.abort())
};
let timer = window.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), timeout_ms).map_err(js)?;
let result = async {
let response: web_sys::Response = JsFuture::from(window.fetch_with_str_and_init(url, &init)).await.map_err(js)?.dyn_into().map_err(js)?;
if !response.ok() {
return Err(format!("HTTP {} for {url}", response.status()));
}
let buffer = JsFuture::from(response.array_buffer().map_err(js)?).await.map_err(js)?;
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
}
.await;
window.clear_timeout_with_handle(timer);
// Kept alive until the timer can no longer fire.
drop(on_timeout);
result
}
fn js(e: wasm_bindgen::JsValue) -> String {
format!("{e:?}")
}
+2
View File
@@ -14,6 +14,8 @@
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
//! language code) stands in for the browser's languages when the interface language is Automatic.
#[cfg(target_arch = "wasm32")]
mod fonts;
#[cfg(target_arch = "wasm32")]
mod locks;
#[cfg(target_arch = "wasm32")]
+4
View File
@@ -64,6 +64,9 @@ pub fn start() {
wasm_bindgen_futures::spawn_local(async move {
// Before the first frame, which looks for copies a crash left behind.
let locks = WebLocks::start(RECOVERY_PREFIX).await;
// Fallback fonts the first frame needs (Noto Sans Arabic), fetched while the loading text
// shows; the rest load in the background once the app runs.
crate::fonts::load_startup_fonts().await;
let host = Host {
runner: eframe::WebRunner::new(),
inbox: Arc::default(),
@@ -99,6 +102,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
web_options(),
Box::new(move |cc| {
let ctx = &cc.egui_ctx;
crate::fonts::spawn_background_fonts(ctx.clone());
let loss = GraphicsLoss::default();
watch_canvas(&page, &loss, ctx);
if let Some(rs) = &cc.wgpu_render_state {
+5
View File
@@ -51,6 +51,11 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std",
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
# CoreText's font manager (src/mac_fonts.rs): fonts that apps and font managers register from their
# own folders (#579). Zlib OR Apache-2.0 OR MIT; objc2-core-foundation already comes with
# objc2-foundation.
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
[build-dependencies]
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
+84
View File
@@ -0,0 +1,84 @@
//! macOS: the font files CoreText's font manager lists, scanned with the font folders
//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports,
//! MCP) both install it; the CLI shares this file.
//!
//! Apps and font managers can register fonts with the font manager from files in any folder,
//! outside the font folders
//! (<https://developer.apple.com/documentation/coretext/ctfontmanagerregisterfontsforurl(_:_:_:)>).
//! The font manager's list of available fonts holds them
//! (<https://developer.apple.com/documentation/coretext/ctfontmanagercopyavailablefonturls()>), so
//! VectorCraft lists them as it does on Windows (`system_fonts.rs`), reading the files in place
//! (#579).
//!
//! CoreText is a C API with no safe binding, hence the scoped `unsafe_code` allowance.
#![allow(unsafe_code)]
use std::collections::BTreeSet;
use objc2_core_foundation::{CFType, CFURL};
use objc2_core_text::CTFontManagerCopyAvailableFontURLs;
/// The most fonts read from the list (a system has a few thousand faces).
const MAX_FONTS: usize = 1 << 16;
/// Have the font scans read the font files CoreText lists.
pub fn install() {
vectorcraft_text::set_platform_font_files(font_files);
}
/// The files of the fonts available to the app as CoreText lists them now (fonts registered since
/// the last call included).
fn font_files() -> Vec<String> {
// SAFETY: a CoreText call without arguments; it returns a new array.
let list = unsafe { CTFontManagerCopyAvailableFontURLs() };
// SAFETY: the array holds CF objects (CFURLs, as CoreText documents); each one is checked to be
// a CFURL before it is read.
let fonts = unsafe { list.cast_unchecked::<CFType>() };
// Faces of one file (a collection's) name it once.
let files: BTreeSet<String> = fonts
.iter()
.take(MAX_FONTS)
.filter_map(|f| f.downcast::<CFURL>().ok())
// Only file URLs, as in `open_documents.rs` (#433).
.filter(|u| u.scheme().is_some_and(|s| s.to_string().eq_ignore_ascii_case("file")))
.filter_map(|u| u.to_file_path()?.into_os_string().into_string().ok())
.collect();
files.into_iter().collect()
}
#[cfg(test)]
mod tests {
use objc2_core_foundation::CFURL;
use objc2_core_text::{CTFontManagerRegisterFontsForURL, CTFontManagerScope, CTFontManagerUnregisterFontsForURL};
/// CoreText lists the installed fonts, by their files' full paths.
#[test]
fn coretext_lists_the_installed_font_files() {
let files = super::font_files();
assert!(files.iter().all(|f| std::path::Path::new(f).is_absolute()), "{:?}", files.first());
assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts/")), "{} files, {:?}", files.len(), files.first());
}
/// A font registered with CoreText from another folder, as apps and font managers register
/// fonts, is listed while it is registered.
#[test]
fn fonts_registered_from_other_folders_are_listed() {
let dir = std::env::temp_dir().join(format!("vectorcraft-mac-fonts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// A space in the name: the URL's escapes are decoded. The bundled serif isn't a font macOS
// installs.
let file = dir.join("Registered Font.ttf");
std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSerif4-Regular.ttf"), &file).unwrap();
let want = std::fs::canonicalize(&file).unwrap();
let listed = || super::font_files().iter().any(|f| f.ends_with("/Registered Font.ttf") && std::fs::canonicalize(f).is_ok_and(|p| p == want));
assert!(!listed());
let url = CFURL::from_file_path(&file).unwrap();
// SAFETY: a file URL, a scope constant and no error out-parameter.
assert!(unsafe { CTFontManagerRegisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) });
let registered = listed();
// SAFETY: as above.
unsafe { CTFontManagerUnregisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) };
std::fs::remove_dir_all(&dir).unwrap();
assert!(registered);
}
}
+4
View File
@@ -23,6 +23,8 @@ mod control_server;
mod gpu;
mod logging;
#[cfg(target_os = "macos")]
mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod open_documents;
@@ -393,6 +395,8 @@ fn main() -> std::process::ExitCode {
// Before the first font scan (the app's start): the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
system_fonts::install();
#[cfg(target_os = "macos")]
mac_fonts::install();
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
+5
View File
@@ -40,3 +40,8 @@ sync_github_name = true
real_name = "Hal Goff"
display_name = "Hal Goff, HG Studio"
sync_github_name = false
[people.hamada147]
real_name = "Ahmed Moussa"
display_name = "Ahmed Moussa"
sync_github_name = true
+312 -12
View File
@@ -1,7 +1,7 @@
//! Swatch library files: the native `.vcswatches` JSON (colour models, global, spot, gradients
//! and colour groups exactly), `.gpl` palettes (8-bit RGB; colour groups as `# Group:` comment
//! headers) and CSS custom properties (written only). Swatch exchange files (`.ase`) are read
//! only ([`read_bytes`]).
//! headers), swatch exchange `.ase` files (binary: solid colors in their own model, global, spot
//! and process colors, color groups) and CSS custom properties (written only).
use std::collections::{HashMap, HashSet};
@@ -16,18 +16,22 @@ pub enum PaletteFormat {
Native,
/// `.gpl`: a plain-text RGB palette many paint and design tools read.
Gpl,
/// `.ase`: a swatch exchange file (binary): solid colors in their own model, global, spot or
/// process, and color groups.
Ase,
/// `.css`: custom properties on `:root` (colours and gradients; written only).
Css,
}
impl PaletteFormat {
pub const ALL: [PaletteFormat; 3] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Css];
pub const ALL: [PaletteFormat; 4] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Ase, PaletteFormat::Css];
/// The format's id, which is also its extension.
pub fn id(self) -> &'static str {
match self {
PaletteFormat::Native => "vcswatches",
PaletteFormat::Gpl => "gpl",
PaletteFormat::Ase => "ase",
PaletteFormat::Css => "css",
}
}
@@ -35,6 +39,7 @@ impl PaletteFormat {
match self {
PaletteFormat::Native => "VectorCraft Swatches (.vcswatches)",
PaletteFormat::Gpl => "GPL Palette (.gpl)",
PaletteFormat::Ase => "Swatch Exchange (.ase)",
PaletteFormat::Css => "CSS Custom Properties (.css)",
}
}
@@ -47,6 +52,10 @@ impl PaletteFormat {
pub fn readable(self) -> bool {
self != PaletteFormat::Css
}
/// Are files of this format binary rather than text?
pub fn binary(self) -> bool {
self == PaletteFormat::Ase
}
}
/// The native file: a header around the library.
@@ -71,11 +80,13 @@ const ASE_MAX_ENTRIES: usize = 100_000;
const ASE_MAX_NAME: usize = 1024;
const ASE_CUT_SHORT: &str = "the swatch exchange file is cut short";
/// Write `lib` in `format`. Pattern swatches (their tiles live in a document) and None are left
/// out; `.gpl` keeps solid colours only, as 8-bit RGB.
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String {
/// Write `lib` in `format` → the file's bytes. Pattern swatches (their tiles live in a document)
/// and None are left out; `.gpl` keeps solid colours only, as 8-bit RGB, and `.ase` keeps solid
/// colors in their own model. Writing `.ase` fails for a library with more colors and groups than
/// [`read_bytes`] reads from one file.
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> Result<Vec<u8>, String> {
let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_));
match format {
let text = match format {
PaletteFormat::Native => {
let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::<Vec<_>>();
let library = SwatchLibrary {
@@ -87,8 +98,10 @@ pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String {
serde_json::to_string_pretty(&file).unwrap_or_default()
}
PaletteFormat::Gpl => write_gpl(lib),
PaletteFormat::Ase => return write_ase(lib),
PaletteFormat::Css => write_css(lib, keep),
}
};
Ok(text.into_bytes())
}
/// One line of text: no line breaks or tabs.
@@ -184,6 +197,102 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
out
}
/// `.ase`, as [`read_ase`] reads it: version 1.0, the ungrouped colors, then each color group as a
/// group start (its name), its colors and a group end, also for a group without colors. A color is
/// written in its own model: RGB, CMYK and gray components from 0 to 1 (gray as a level, 1 is
/// white), Lab lightness as a fraction of 100 and a and b from −128 to 127. Components are clamped
/// to those ranges, and a component that isn't a finite number is written as 0. The color type is
/// 1 for a spot color, 0 for another global color and 2 for a process color.
///
/// A swatch exchange file holds solid colors only: gradients, patterns and None are left out, and
/// a tint swatch is written as the color it shows, a process color without its link to its base.
/// The file has no library name ([`read_ase`] names a library after its file). More than
/// [`ASE_MAX_ENTRIES`] colors and groups is an error.
fn write_ase(lib: &SwatchLibrary) -> Result<Vec<u8>, String> {
let solid = |list: &[Swatch]| list.iter().filter(|w| w.paint.color().is_some()).count();
let entries = solid(&lib.swatches) + lib.groups.iter().map(|g| 1 + solid(&g.swatches)).sum::<usize>();
if entries > ASE_MAX_ENTRIES {
return Err(format!(
"VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries}"
));
}
let mut body = vec![];
let mut blocks = put_ase_colors(&mut body, &lib.swatches)?;
for g in &lib.groups {
let mut name = vec![];
put_ase_name(&mut name, &g.name)?;
put_ase_block(&mut body, ASE_GROUP_START, &name)?;
blocks += 2 + put_ase_colors(&mut body, &g.swatches)?;
put_ase_block(&mut body, ASE_GROUP_END, &[])?;
}
let blocks = u32::try_from(blocks).map_err(|_| "too many swatch exchange blocks".to_string())?;
let mut out = ASE_SIGNATURE.to_vec();
out.extend(1u16.to_be_bytes());
out.extend(0u16.to_be_bytes());
out.extend(blocks.to_be_bytes());
out.extend(body);
Ok(out)
}
/// The color blocks of the solid colors in `list` → how many were written.
fn put_ase_colors(out: &mut Vec<u8>, list: &[Swatch]) -> Result<usize, String> {
let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
let ab = |v: f32| if v.is_finite() { v.clamp(-128.0, 127.0) } else { 0.0 };
let mut n = 0;
for (w, color) in list.iter().filter_map(|w| Some((w, w.paint.color()?))) {
let mut body = vec![];
put_ase_name(&mut body, &w.name)?;
let (model, values): (&[u8; 4], Vec<f32>) = match color {
Color::Rgb { r, g, b } => (b"RGB ", vec![unit(r), unit(g), unit(b)]),
Color::Cmyk { c, m, y, k } => (b"CMYK", vec![unit(c), unit(m), unit(y), unit(k)]),
Color::Lab { l, a, b } => (b"LAB ", vec![unit(l / 100.0), ab(a), ab(b)]),
Color::Gray { k } => (b"Gray", vec![1.0 - unit(k)]),
};
body.extend(model);
body.extend(values.iter().flat_map(|v| v.to_be_bytes()));
let kind: u16 = if w.spot {
1
} else if w.global {
0
} else {
2
};
body.extend(kind.to_be_bytes());
put_ase_block(out, ASE_COLOR, &body)?;
n += 1;
}
Ok(n)
}
/// A block: its type, the length of its body and the body.
fn put_ase_block(out: &mut Vec<u8>, kind: u16, body: &[u8]) -> Result<(), String> {
let len = u32::try_from(body.len()).map_err(|_| "a swatch exchange block is too long".to_string())?;
out.extend(kind.to_be_bytes());
out.extend(len.to_be_bytes());
out.extend_from_slice(body);
Ok(())
}
/// A name as `.ase` stores it: a `u16` count of UTF-16 code units, the terminating zero included,
/// then the units. NUL characters are left out, and a name longer than [`ASE_MAX_NAME`] code units
/// is cut before the character that would pass that limit.
fn put_ase_name(out: &mut Vec<u8>, name: &str) -> Result<(), String> {
let mut units: Vec<u16> = vec![];
for c in name.chars().filter(|&c| c != '\0') {
let mut buf = [0; 2];
let encoded = c.encode_utf16(&mut buf);
if units.len() + encoded.len() > ASE_MAX_NAME {
break;
}
units.extend_from_slice(encoded);
}
units.push(0);
let count = u16::try_from(units.len()).map_err(|_| "a swatch exchange name is too long".to_string())?;
out.extend(count.to_be_bytes());
out.extend(units.iter().flat_map(|u| u.to_be_bytes()));
Ok(())
}
/// Read a `.vcswatches` or `.gpl` library (detected from its content). `name` names it when the
/// file doesn't.
pub fn read(text: &str, name: &str) -> Result<SwatchLibrary, String> {
@@ -442,6 +551,7 @@ impl UniqueNames {
mod tests {
use super::*;
use crate::{Gradient, GradientPaint, GradientStop};
use proptest::prelude::*;
fn sample() -> SwatchLibrary {
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient {
@@ -468,10 +578,15 @@ mod tests {
}
}
/// `lib` written as text format `f`.
fn written(lib: &SwatchLibrary, f: PaletteFormat) -> String {
String::from_utf8(write(lib, f).unwrap()).unwrap()
}
#[test]
fn native_round_trip_keeps_cmyk_spot_and_groups() {
let lib = sample();
let text = write(&lib, PaletteFormat::Native);
let text = written(&lib, PaletteFormat::Native);
assert!(sniff(&text));
let back = read(&text, "fallback").unwrap();
let mut expected = lib.clone();
@@ -484,7 +599,7 @@ mod tests {
#[test]
fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() {
let text = write(&sample(), PaletteFormat::Gpl);
let text = written(&sample(), PaletteFormat::Gpl);
assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n"));
assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}");
assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours");
@@ -514,7 +629,7 @@ mod tests {
assert_eq!(css_property("#FF00CC"), "--\\#ff00cc");
assert_eq!(css_property("Café 50%"), "--café-50\\%");
assert_eq!(css_property(" "), "--swatch");
let text = write(&sample(), PaletteFormat::Css);
let text = written(&sample(), PaletteFormat::Css);
assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}");
assert!(text.contains(" --ink: #"), "{text}");
assert!(text.contains("; /* spot */"));
@@ -528,7 +643,7 @@ mod tests {
swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(),
groups: vec![],
};
let text = write(&lib, PaletteFormat::Css);
let text = written(&lib, PaletteFormat::Css);
assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;"));
assert!(read(&text, "x").is_err(), "CSS is written only");
}
@@ -645,4 +760,189 @@ mod tests {
assert!(read_bytes(&ase(&groups), "x").unwrap_err().contains("more than"));
assert_eq!(read_bytes(&ase(&groups[1..]), "x").unwrap().groups.len(), ASE_MAX_ENTRIES, "up to the limit");
}
#[test]
fn ase_writes_each_model_kind_and_group() {
let sw = |name: &str, color, global, spot| Swatch { name: name.into(), paint: Paint::solid(color), global, spot };
let tint = Swatch {
name: "Ink 40%".into(),
paint: Paint::Solid { color: Color::cmyk(0.4, 0.2, 0.0, 0.08), swatch: Some("Ink".into()), tint: 0.4 },
global: false,
spot: false,
};
// `sample()` holds the spot color Ink, the gradient Fade, the pattern Tiles and the group Neutrals.
let mut lib = sample();
lib.swatches.insert(0, sw("Sky", Color::rgb(0.0, 0.5, 1.0), true, false));
lib.swatches.push(tint);
lib.groups[0].swatches = vec![sw("Mist", Color::gray(0.25), false, false), sw("Clay", Color::lab(50.0, 20.0, -30.0), false, false)];
lib.groups.push(SwatchGroup { name: "Empty".into(), swatches: vec![] });
let bytes = write(&lib, PaletteFormat::Ase).unwrap();
let expected = ase(&[
ase_color("Sky", b"RGB ", &[0.0, 0.5, 1.0], 0),
ase_color("Ink", b"CMYK", &[1.0, 0.5, 0.0, 0.2], 1),
ase_color("Ink 40%", b"CMYK", &[0.4, 0.2, 0.0, 0.08], 2),
(ASE_GROUP_START, ase_name("Neutrals")),
ase_color("Mist", b"Gray", &[0.75], 2),
ase_color("Clay", b"LAB ", &[0.5, 20.0, -30.0], 2),
(ASE_GROUP_END, vec![]),
(ASE_GROUP_START, ase_name("Empty")),
(ASE_GROUP_END, vec![]),
]);
assert_eq!(bytes, expected, "no gradient or pattern; the tint as the process color it shows");
let back = read_bytes(&bytes, "Brand").unwrap();
assert_eq!((back.len(), back.groups.len()), (5, 2), "the empty group stays");
assert_eq!(PaletteFormat::parse(".ASE"), Some(PaletteFormat::Ase));
assert!(PaletteFormat::Ase.readable() && PaletteFormat::Ase.binary() && !PaletteFormat::Gpl.binary());
}
#[test]
fn ase_names_lose_nul_and_stop_at_the_reader_limit() {
let names = ["Re\0d".to_string(), "é".repeat(1030), format!("a{}", "🎨".repeat(600))];
let swatches = names.iter().map(|n| Swatch { name: n.clone(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).collect();
let lib = SwatchLibrary { name: "x".into(), swatches, groups: vec![] };
let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap();
let got: Vec<&str> = back.swatches.iter().map(|w| w.name.as_str()).collect();
// At most 1024 UTF-16 code units, cut between characters: "a" and 511 two-unit emoji.
assert_eq!(got, ["Red".to_string(), "é".repeat(1024), format!("a{}", "🎨".repeat(511))]);
}
#[test]
fn ase_clamps_components_and_writes_non_finite_ones_as_zero() {
let sw = |name: &str, color| Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false };
let swatches = vec![
sw("Lab", Color::lab(100.5, 300.0, -300.0)),
sw("Rgb", Color::rgb(2.0, -1.0, f32::NAN)),
sw("Cmyk", Color::cmyk(1.5, 0.0, 0.0, f32::INFINITY)),
sw("Gray", Color::gray(1.5)),
];
let bytes = write(&SwatchLibrary { name: "x".into(), swatches, groups: vec![] }, PaletteFormat::Ase).unwrap();
let expected = ase(&[
ase_color("Lab", b"LAB ", &[1.0, 127.0, -128.0], 2),
ase_color("Rgb", b"RGB ", &[1.0, 0.0, 0.0], 2),
ase_color("Cmyk", b"CMYK", &[1.0, 0.0, 0.0, 0.0], 2),
ase_color("Gray", b"Gray", &[0.0], 2),
]);
assert_eq!(bytes, expected, "clamped to the ranges .ase holds, a component that isn't finite as 0");
// Lightness 100.5 is written as 1, which reads back as L* 100.
let back = read_bytes(&bytes, "x").unwrap();
assert_eq!(back.swatches[0].paint.color(), Some(Color::lab(100.0, 127.0, -128.0)));
}
#[test]
fn ase_write_refuses_more_entries_than_the_reader_takes() {
let gray = |i: usize| Swatch { name: format!("G{i}"), paint: Paint::solid(Color::gray(0.5)), global: false, spot: false };
// The limit counts colors and groups: a group and `ASE_MAX_ENTRIES - 1` colors reach it.
let mut lib = SwatchLibrary {
name: "x".into(),
swatches: (1..ASE_MAX_ENTRIES).map(gray).collect(),
groups: vec![SwatchGroup { name: "Group".into(), swatches: vec![] }],
};
let back = read_bytes(&write(&lib, PaletteFormat::Ase).unwrap(), "x").unwrap();
assert_eq!((back.len(), back.groups.len()), (ASE_MAX_ENTRIES - 1, 1));
lib.swatches.push(gray(0));
let e = write(&lib, PaletteFormat::Ase).unwrap_err();
assert!(e.contains("reads at most 100000 colors and groups from a swatch exchange file, and this library has 100001"), "{e}");
// A gradient is left out and is not counted.
lib.swatches.last_mut().unwrap().paint = sample().swatches[1].paint.clone();
assert!(write(&lib, PaletteFormat::Ase).is_ok());
}
/// A color in one of the four models, in the ranges `.ase` holds.
fn arb_color() -> impl Strategy<Value = Color> {
let unit = || 0.0f32..=1.0;
prop_oneof![
(unit(), unit(), unit()).prop_map(|(r, g, b)| Color::rgb(r, g, b)),
(unit(), unit(), unit(), unit()).prop_map(|(c, m, y, k)| Color::cmyk(c, m, y, k)),
unit().prop_map(Color::gray),
(0.0f32..=100.0, -128.0f32..=127.0, -128.0f32..=127.0).prop_map(|(l, a, b)| Color::lab(l, a, b)),
]
}
/// A swatch's paint with its global and spot flags: a solid color with any flags, a tint swatch
/// (both flags off) or a gradient.
fn arb_paint() -> impl Strategy<Value = (Paint, bool, bool)> {
prop_oneof![
4 => (arb_color(), any::<bool>(), any::<bool>()).prop_map(|(c, global, spot)| (Paint::solid(c), global, spot)),
1 => arb_color().prop_map(|c| (Paint::Solid { color: c, swatch: Some("Base".into()), tint: 0.4 }, false, false)),
1 => Just((sample().swatches[1].paint.clone(), false, false)),
]
}
/// Ungrouped swatches and color groups. A name is a number and up to 12 characters of any script
/// but NUL, trimmed, so swatches and groups have unique names, as in a document.
fn arb_library() -> impl Strategy<Value = SwatchLibrary> {
let swatches = || prop::collection::vec((arb_paint(), "[^\\x00]{0,12}"), 0..12);
(swatches(), prop::collection::vec(("[^\\x00]{0,12}", swatches()), 0..4)).prop_map(|(loose, groups)| {
let mut n = 0;
let mut name = |prefix: &str, base: &str| {
n += 1;
format!("{prefix}{n} {base}").trim().to_string()
};
let mut lib = SwatchLibrary { name: "Fuzz".into(), ..Default::default() };
for ((paint, global, spot), base) in loose {
lib.swatches.push(Swatch { name: name("", &base), paint, global, spot });
}
for (base, list) in groups {
let mut g = SwatchGroup { name: name("G", &base), swatches: vec![] };
for ((paint, global, spot), base) in list {
g.swatches.push(Swatch { name: name("", &base), paint, global, spot });
}
lib.groups.push(g);
}
lib
})
}
/// Are `got` and `want` in the same model, with every component within 1e-4? Lab lightness and
/// gray read back within `f32` rounding.
fn close(got: Color, want: Color) -> bool {
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
match (got, want) {
(Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => near(r, r2) && near(g, g2) && near(b, b2),
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => near(c, c2) && near(m, m2) && near(y, y2) && near(k, k2),
(Color::Gray { k }, Color::Gray { k: k2 }) => near(k, k2),
(Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => near(l, l2) && near(a, a2) && near(b, b2),
_ => false,
}
}
/// The swatches of `list` as an `.ase` file reads them back: solid colors only, unlinked, spot
/// colors global.
fn ase_expected(list: &[Swatch]) -> Vec<Swatch> {
list.iter()
.filter_map(|w| Some(Swatch { name: w.name.clone(), paint: Paint::solid(w.paint.color()?), global: w.global || w.spot, spot: w.spot }))
.collect()
}
/// Do swatches `got` have the names, flags and unlinked colors (within 1e-4) of `want`?
fn same_swatches(got: &[Swatch], want: &[Swatch]) -> bool {
got.len() == want.len()
&& got.iter().zip(want).all(|(g, w)| {
let color = match (&g.paint, w.paint.color()) {
(Paint::Solid { color, swatch: None, .. }, Some(c)) => close(*color, c),
_ => false,
};
g.name == w.name && g.global == w.global && g.spot == w.spot && color
})
}
proptest! {
#![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })]
/// A written `.ase` file reads back as the library's solid colors in their own model, with
/// their names, kinds and color groups.
#[test]
fn ase_round_trip_keeps_models_kinds_and_groups(lib in arb_library()) {
let bytes = write(&lib, PaletteFormat::Ase).unwrap();
prop_assert!(sniff_bytes(&bytes));
let back = read_bytes(&bytes, &lib.name).unwrap();
prop_assert_eq!(&back.name, &lib.name);
prop_assert!(same_swatches(&back.swatches, &ase_expected(&lib.swatches)), "{:?}", back.swatches);
prop_assert_eq!(back.groups.len(), lib.groups.len());
for (g, want) in back.groups.iter().zip(&lib.groups) {
prop_assert_eq!(&g.name, &want.name);
prop_assert!(same_swatches(&g.swatches, &ase_expected(&want.swatches)), "{}: {:?}", g.name, g.swatches);
}
}
}
}
+1 -1
View File
@@ -146,7 +146,7 @@ impl LiveShape {
LiveShape::Rectangle { w, h, radii, kinds, xf } => {
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf)
}
LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf),
LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf),
LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0,
LiveShape::Line { a, b } => shapes::line(*a, *b),
LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0,
+6
View File
@@ -27,6 +27,11 @@ pub struct CharStyle {
pub font_family: String,
#[serde(default = "regular")]
pub font_style: String,
/// Which installed version of the font (its version string) when several versions of the
/// family and style are installed and the text was set in one the family and style alone
/// wouldn't pick (an imported file's type matched to it); None = the one they pick.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_version: Option<String>,
/// Size in points.
pub size: f64,
/// Leading in points; None = Auto (120% of size).
@@ -175,6 +180,7 @@ impl Default for CharStyle {
Self {
font_family: "Source Sans 3".into(),
font_style: "Regular".into(),
font_version: None,
size: 12.0,
leading: None,
tracking: 0.0,
+37 -16
View File
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_text::embed::Embedding;
use super::*;
@@ -77,13 +78,17 @@ pub struct Section {
pub rows: Vec<(String, String)>,
}
/// What a font's `fsType` lets a copy of it do.
/// What a font's `fsType` lets a copy of it do, read as exports read it ([`Embedding`]): the
/// most permissive usage bit wins, and bitmap-only fonts may not be embedded.
pub fn embedding_label(fs_type: u16) -> &'static str {
match fs_type & 0x000f {
0x0002 => "embedding not allowed",
0x0004 => "embedding for preview and print",
0x0008 => "embedding for editing",
_ => "embedding allowed",
if Embedding::from_fs_type(fs_type) == Embedding::Forbidden {
"embedding not allowed"
} else if fs_type & 0x0008 != 0 {
"embedding for editing"
} else if fs_type & 0x0004 != 0 {
"embedding for preview and print"
} else {
"embedding allowed"
}
}
@@ -92,7 +97,7 @@ type ImageRow = (String, u32, u32, Option<String>);
/// The Document Info categories of `d` (only `category` when given) from `info` (what
/// `document.info` reports), its fonts `(family, style)`, image objects and used patterns.
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
let names = |k: &str| -> Vec<(String, String)> {
info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect()
};
@@ -130,22 +135,32 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag
"patterns" => names("patternNames"),
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
"symbols" => names("symbols"),
"fonts" => fonts.iter().map(|(f, s)| (format!("{f} {s}"), String::new())).collect(),
"fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(),
"fontDetails" => {
let db = vectorcraft_text::FontDb::global();
fonts
.iter()
.map(|(family, style)| {
let detail = match db.face(family, style) {
Some(f) if f.family.eq_ignore_ascii_case(family) => {
.map(|used| {
let (family, style, version) = used;
// Found by any of its names (in the version the type names); a style the
// family lacks shows in another.
let resolved =
db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m));
let detail = match resolved {
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
let file =
f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned());
format!("{file}; {}", embedding_label(f.fs_type()))
let found = format!("{file}; {}", embedding_label(f.fs_type()));
if m == vectorcraft_text::FontMatch::Style {
format!("substituted: shown in {} {} ({found})", f.family, f.style)
} else {
found
}
}
Some(f) => format!("missing: shown in {} {}", f.family, f.style),
Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style),
None => "missing".into(),
};
(format!("{family} {style}"), detail)
(super::fonts::font_label(used), detail)
})
.collect()
}
@@ -232,7 +247,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
NodeKind::Compound { .. } => Some("compoundPaths"),
NodeKind::Text(t) => {
for run in &t.runs {
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone()));
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone(), run.style.font_version.clone()));
}
Some("textObjects")
}
@@ -289,7 +304,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
}
});
}
let font_names: Vec<String> = fonts.iter().map(|(f, s)| format!("{f} {s}")).collect();
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
let mut out = json!({
"document": document_summary(d),
"objects": counts,
@@ -396,6 +411,12 @@ mod tests {
assert_eq!(embedding_label(0x0002), "embedding not allowed");
assert_eq!(embedding_label(0x0004 | 0x0100), "embedding for preview and print");
assert_eq!(embedding_label(0x0008), "embedding for editing");
// As exports read it: bitmap embedding only, the restricted bit with reserved bit 0, the
// most permissive of several usage bits.
assert_eq!(embedding_label(0x0200), "embedding not allowed");
assert_eq!(embedding_label(0x0003), "embedding not allowed");
assert_eq!(embedding_label(0x0006), "embedding for preview and print");
assert_eq!(embedding_label(0x000c), "embedding for editing");
}
#[test]
+22 -12
View File
@@ -1,6 +1,6 @@
//! File (document-level) and Edit commands, plus document queries.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use serde_json::{Value, json};
@@ -250,29 +250,39 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
if s.tool_id() == "artboard" {
return super::panelcmds::artboard_copy(s, &json!({}), true);
}
// Copy promotes a compound member to its compound. Cut must remove the same objects it
// copied, while Clear on its own deletes exactly the selected members.
let ids = selected_roots(s)?;
copy(s, p)?;
clear(s, &json!({}))
if s.doc()?.selection.anchors.is_empty() { clear(s, &json!({ "ids": ids })) } else { clear(s, &json!({})) }
}
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
// Direct-selected anchors: delete those anchors instead of whole objects.
if ids_param(p, "ids").is_none() && !s.doc()?.selection.anchors.is_empty() {
if explicit.is_none() && !s.doc()?.selection.anchors.is_empty() {
return super::path::delete_anchors(s, p);
}
// Selected ruler guides (selected on their own).
if ids_param(p, "ids").is_none() && s.doc()?.selection.is_empty() {
if explicit.is_none() && s.doc()?.selection.is_empty() {
return super::docmenu::guide_remove(s, &json!({}));
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let st = s.doc()?;
let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc));
// Keep the selected ids themselves, without promoting compound members. Delete a selected
// ancestor just once; its selected descendants leave with it. Layers themselves are kept.
let targets: BTreeSet<_> = targets.into_iter().filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer())).collect();
let ids: Vec<_> = targets
.iter()
.copied()
.filter(|id| !st.doc.ancestry(*id).unwrap_or_default().iter().rev().skip(1).any(|ancestor| targets.contains(ancestor)))
.collect();
if ids.is_empty() {
return ok();
}
s.edit("Clear", |d, sel| {
for id in &ids {
if d.node(*id).is_some_and(|n| n.is_layer()) {
continue;
}
let _ = d.remove(*id);
d.remove(*id)?;
}
sel.clear();
Ok(())
+21
View File
@@ -725,6 +725,22 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
/// Write `bytes` to a new file at `path`. A file already at `path` is an error and is never
/// replaced. When the write fails part way, the file it started is removed.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
use std::io::Write as _;
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?;
let written = f.write_all(bytes).and_then(|()| f.sync_all());
drop(f);
written.map_err(|e| {
// Best effort: the partly written file is all there is to clean up.
let _ = std::fs::remove_file(path);
err(e)
})
}
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
/// keeps one); `None` when there is no file at `path`.
#[cfg(not(target_arch = "wasm32"))]
@@ -786,6 +802,11 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> {
Err(no_fs(path))
}
/// File-name parts for artboards `boards` of `doc`: the artboard's name with unsafe characters as
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
/// case: `Icon` and `icon` are one file on most desktop file systems).
+12
View File
@@ -396,3 +396,15 @@ fn oversized_raster_exports_are_refused() {
assert!(e.contains("too large"), "{e}");
assert!(s.execute("document.export", &json!({"format": "png", "scale": 40})).is_err(), "80000 px a side");
}
#[test]
fn new_files_never_replace_a_file() {
let d = tmp_dir("new-file");
let path = d.join("Brand.ase").to_string_lossy().to_string();
let _ = std::fs::remove_file(&path);
write_new_file(&path, b"first").unwrap();
let e = write_new_file(&path, b"second").unwrap_err().to_string();
assert!(e.starts_with(&format!("{path}: ")), "{e}");
assert_eq!(std::fs::read(&path).unwrap(), b"first");
let _ = std::fs::remove_dir_all(d);
}
+31 -13
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fonts in Document",
[],
None,
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
has_doc,
fonts
),
@@ -85,12 +85,23 @@ fn scope(s: &Session, selection_only: bool) -> Result<Vec<NodeId>> {
Ok(v)
}
/// The fonts (family, style) of the type in the layers and symbols of `d`.
pub(super) fn used_fonts(d: &Document) -> BTreeSet<(String, String)> {
/// A font as type names it: family, style and, when it names one, the installed version.
pub(super) type UsedFont = (String, String, Option<String>);
/// How a font is named in reports: family and style, then the version the type names, if any.
pub(super) fn font_label((family, style, version): &UsedFont) -> String {
match version {
Some(v) => format!("{family} {style} ({v})"),
None => format!("{family} {style}"),
}
}
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
let mut fonts = BTreeSet::new();
let mut add = |n: &Node| {
if let NodeKind::Text(t) = &n.kind {
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone())));
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())));
}
};
d.walk(&mut add);
@@ -106,10 +117,10 @@ pub(super) fn substitution_warning(d: &Document) -> Option<String> {
let db = vectorcraft_text::FontDb::global();
let mut missing: Vec<String> = used_fonts(d)
.into_iter()
.filter(|(family, style)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
.map(|(family, _)| family)
.filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
.map(|(family, _, _)| family)
.collect();
// Sorted by family: its styles are neighbours.
// Sorted by family: its styles and versions are neighbours.
missing.dedup();
(!missing.is_empty()).then(|| {
format!(
@@ -131,10 +142,10 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
let d = &s.doc()?.doc;
let db = vectorcraft_text::FontDb::global();
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>, String)> = BTreeMap::new();
let mut found: BTreeMap<UsedFont, (usize, Vec<NodeId>, String)> = BTreeMap::new();
for id in ids {
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default();
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())).or_default();
e.0 += 1;
e.2.push_str(&r.text);
if !e.1.contains(&id) {
@@ -144,20 +155,25 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
}
let list: Vec<Value> = found
.into_iter()
.map(|((family, style), (runs, objects, chars))| {
let resolved = db.resolve(&family, &style);
.map(|((family, style, version), (runs, objects, chars))| {
// The face the type is set in: the version it names when that one is installed.
let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
let mut lacking: Vec<char> = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
lacking.sort_unstable();
lacking.dedup();
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
json!({
let mut row = json!({
"family": family, "style": style, "runs": runs, "objects": objects.len(),
"missing": status == vectorcraft_text::FontMatch::Missing,
"status": status.as_str(),
"resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })),
"missingGlyphs": lacking.len(),
})
});
if let Some(v) = version {
row["version"] = json!(v);
}
row
})
.collect();
Ok(json!(list))
@@ -198,6 +214,8 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
.unwrap_or_else(|| r.style.font_style.clone());
r.style.font_family = tf.clone();
r.style.font_style = style;
// Another font: none of the old one's versions.
r.style.font_version = None;
changed = true;
n += 1;
}
+15
View File
@@ -304,6 +304,21 @@ pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<NodeId>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect())
}
/// Validate all explicit targets before a command changes the document or selection.
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result<Vec<NodeId>> {
let values =
p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?;
values.iter().map(|value| checked_id(s, value, command)).collect()
}
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<NodeId> {
let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?;
if s.doc()?.doc.node(id).is_none() {
return Err(bad(command, format!("no such object id {value}")));
}
Ok(id)
}
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
let a = p.get(key)?.as_array()?;
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
+27 -1
View File
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
has_selection,
set_live_shape
),
@@ -1095,6 +1095,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
let sides = p.get("sides").and_then(Value::as_u64);
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let angle = |k: &str| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|a| a.is_finite())
.map(|a| Some(a.rem_euclid(360.0)))
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be an angle in degrees, not {v}"))),
}
};
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -1102,6 +1115,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
l.set_sides(n);
*path = l.to_path();
}
if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut()
&& (pie_start.is_some() || pie_end.is_some() || invert)
{
if let LiveShape::Ellipse { pie, .. } = l {
// An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°).
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
*pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
if invert {
*pie = (pie.1.rem_euclid(360.0), end_of(pie.0));
}
}
*path = l.to_path();
}
if radius.is_none() && kind.is_none() {
continue;
}
+20 -9
View File
@@ -32,7 +32,7 @@ type Entry = (String, Vec<u8>);
/// `name`, else the first free `stem 2.ext`, `stem 3.ext`… (compared without case, as most
/// desktop file systems do); the name is taken.
fn free_name(name: &str, taken: &mut HashSet<String>) -> String {
pub(crate) fn free_name(name: &str, taken: &mut HashSet<String>) -> String {
let p = Path::new(name);
let stem = p.file_stem().map_or_else(|| name.to_string(), |s| s.to_string_lossy().into_owned());
let ext = p.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
@@ -107,10 +107,21 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
let db = vectorcraft_text::FontDb::global();
let mut files = HashSet::new();
lines.push("FONTS".to_string());
for (family, style) in super::fonts::used_fonts(&st.doc) {
let font = format!("{family} {style}");
let reason = match db.face(&family, &style) {
Some(f) if f.family.eq_ignore_ascii_case(&family) && f.embeddable() => {
for used in super::fonts::used_fonts(&st.doc) {
let font = super::fonts::font_label(&used);
let (family, style, version) = used;
// Found by any of its names, as the canvas draws it (in the version the type names).
let resolved = db
.resolve(&family, &style)
.filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing)
.map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
// A style the family lacks is shown in its closest style, whose file is copied.
let shown = match &resolved {
Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style),
_ => String::new(),
};
let reason = match resolved {
Some((f, _)) if f.embeddable() => {
let data = f.file_data();
let file = f.path().and_then(Path::file_name).map_or_else(
|| format!("{}-{}.{}", f.family, f.style, font_ext(data)).replace(|c: char| !(c.is_alphanumeric() || "-_.".contains(c)), ""),
@@ -121,13 +132,13 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
entries.push((format!("Fonts/{file}"), data.to_vec()));
fonts += 1;
}
lines.push(format!("{font} → Fonts/{file}"));
lines.push(format!("{font}{shown} → Fonts/{file}"));
continue;
}
Some(f) if f.family.eq_ignore_ascii_case(&family) => "its licence doesn't allow embedding",
_ => "not available on this computer",
Some(_) => "its licence doesn't allow embedding",
None => "not available on this computer",
};
lines.push(format!("{font}: {reason}, not copied"));
lines.push(format!("{font}{shown}: {reason}, not copied"));
skipped.push(json!({ "font": font, "reason": reason }));
}
lines.push(String::new());
+9
View File
@@ -475,6 +475,7 @@ impl Session {
let history_changed = p.history_states != self.prefs.history_states;
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder;
let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions;
self.prefs = p;
if fonts_folder_changed {
let folder = self.prefs.fonts_folder.trim();
@@ -486,6 +487,10 @@ impl Session {
let _ = super::fonts::rescan(self, &serde_json::Value::Null);
}
}
if hyphen_exceptions_changed {
// Preferences › Hyphenation › Exceptions (#394): the layout reads the process-wide list.
vectorcraft_text::set_hyphenation_exceptions(&self.prefs.hyphenation_exceptions);
}
vectorcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok());
for st in &mut self.docs {
if history_changed {
@@ -503,6 +508,10 @@ impl Session {
d.grid.subdivisions = self.prefs.grid_subdivisions;
st.revision += 1;
}
// Hyphenation exceptions change every text object's line breaks.
if hyphen_exceptions_changed {
st.revision += 1;
}
}
// Plug-ins in a newly set Additional Plug-ins Folder are installed.
super::plugin::sync_prefs(self);
+36 -10
View File
@@ -25,14 +25,30 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse),
cmd!("select.nextAbove", "Next Object Above", ["Select"], Some("Cmd+Alt+]"), "{}", has_selection, |s, _| step(s, 1)),
cmd!("select.nextBelow", "Next Object Below", ["Select"], Some("Cmd+Alt+["), "{}", has_selection, |s, _| step(s, -1)),
cmd!("select.set", "Select Objects", [], None, "{ids: [id…]}", has_doc, set),
cmd!("select.add", "Add to Selection", [], None, "{ids: [id…]}", has_doc, add),
cmd!(
"select.set",
"Select Objects",
[],
None,
"{ids: [id…]} all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
has_doc,
set
),
cmd!(
"select.add",
"Add to Selection",
[],
None,
"{ids: [id…]} all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
has_doc,
add
),
cmd!(
"select.toggle",
"Toggle Selection",
[],
None,
"{id} or {ids: [id…]}: each selected one leaves the selection, the others join it",
"{id} or {ids: [id…]}: each selected one leaves the selection, the others join it; all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
has_doc,
toggle
),
@@ -225,29 +241,39 @@ fn step(s: &mut Session, dir: i64) -> Result<Value> {
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").ok_or_else(|| bad("select.set", "missing ids"))?;
s.select(|d, sel| sel.set(ids.iter().copied().filter(|i| d.node(*i).is_some())))?;
ok()
let ids = checked_ids_param(s, p, "ids", "select.set")?;
s.select(|_, sel| sel.set(ids.iter().copied()))?;
selection_result(s)
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").ok_or_else(|| bad("select.add", "missing ids"))?;
let ids = checked_ids_param(s, p, "ids", "select.add")?;
s.select(|_, sel| {
for i in ids {
sel.add(i);
}
})?;
ok()
selection_result(s)
}
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|id| vec![id])).ok_or_else(|| bad("select.toggle", "missing id or ids"))?;
let ids = if p.get("ids").is_some() {
checked_ids_param(s, p, "ids", "select.toggle")?
} else {
let value = p.get("id").ok_or_else(|| bad("select.toggle", "missing id or ids"))?;
vec![checked_id(s, value, "select.toggle")?]
};
s.select(|_, sel| {
for id in ids {
sel.toggle(id);
}
})?;
ok()
selection_result(s)
}
fn selection_result(s: &Session) -> Result<Value> {
let selection = &s.doc()?.selection;
Ok(json!({ "count": selection.len(), "ids": selection.objects }))
}
fn key(s: &mut Session, p: &Value) -> Result<Value> {
+3 -3
View File
@@ -15,7 +15,7 @@ use vectorcraft_doc::{Document, GraphicStyle, PatternDef, StyleLibrary};
use super::menucmds::squash;
use super::swatch::str_list;
use super::swatchlib::{LibraryFile, LibraryInfo, stem};
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Other Library…",
["Window", "Graphic Style Libraries"],
None,
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits) → {library: id, name, count}",
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcstyles library, or the graphic styles of any document VectorCraft opens (see document.formats), for the library panel (Window → Graphic Style Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}",
always,
load
),
@@ -211,7 +211,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
let lib = StyleLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?;
let mut out = json!({ "count": lib.len() });
s.style_libraries.write(p, &lib.name, STYLES_EXT, style_libs::write(&lib), &mut out, C)?;
s.style_libraries.write(p, &lib.name, STYLES_EXT, FileData::Text(style_libs::write(&lib)), &mut out, C)?;
Ok(out)
}
+146 -21
View File
@@ -3,7 +3,9 @@
//! [`vectorcraft_color::libraries`]; User Defined ones are the library files in the user library
//! folder ([`Libraries`]), and Other Library… loads more from files. `swatch.library.add` copies
//! swatches into the document; `swatch.library.save` writes the document's ([`palette_io`]).
//! `swatch.library.copyToUser` copies a loaded library into the user library folder.
use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
@@ -12,8 +14,9 @@ use vectorcraft_color::palette_io::{self, PaletteFormat};
use vectorcraft_color::recolor::Palette;
use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches};
use super::fileio::{create_dir, read_file, write_file};
use super::fileio::{create_dir, extension, read_file, write_file, write_new_file};
use super::menucmds::squash;
use super::package::free_name;
use super::swatch::{map_default_paints, str_list, swatch_json};
use super::*;
@@ -60,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Swatch Library…",
["Window", "Swatches"],
None,
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}",
"{path?, format?: \"vcswatches\" (lossless JSON: colour models, global, spot, gradients, groups) | \"gpl\" (8-bit RGB palette; groups as `# Group:` comments) | \"ase\" (swatch exchange, binary: solid colors in their own model (RGB, CMYK, Lab, Gray) as global, spot or process colors, and color groups; gradients are left out, and a tint swatch is saved as the color it shows) | \"css\" (custom properties on :root) (default: the path's extension, else vcswatches), names?: [swatch or colour group names] (default: all; None and patterns are never saved), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's swatches as a library → {path, format, count, library?: id when saved to the user folder}; without path or user → {data: the file's text, format, count}, or {dataBase64, format, count} for ase",
has_doc,
save
),
@@ -69,10 +72,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Other Library…",
["Window", "Swatch Libraries"],
None,
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits) → {library: id, name, count}",
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load a .vcswatches, .gpl or .ase (swatch exchange) library, or the swatches of any document VectorCraft opens (see document.formats), for the library panel (Window → Swatch Libraries lists it until the app quits; a file of the user library folder, or a file with the same extension and bytes as one there, opens as that User Defined library) → {library: id, name, count}",
always,
load
),
cmd!(
"swatch.library.copyToUser",
"Copy to User Defined",
["Window", "Swatch Libraries"],
None,
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}",
has_user_folder,
copy_to_user
),
]
}
@@ -85,6 +97,9 @@ pub struct Libraries<L = SwatchLibrary> {
/// there are no User Defined libraries.
user_dir: Option<String>,
extra: Vec<Extra<L>>,
/// Loaded libraries copied into the user library folder ([`Libraries::copy_to_user`]): the
/// loaded id and the User Defined id [`Libraries::get`] finds for it.
copied: Vec<(String, String)>,
}
#[derive(Clone, Debug)]
@@ -117,6 +132,14 @@ impl LibraryFile for SwatchLibrary {
/// The extensions of library files [`palette_io::read_bytes`] reads.
pub const LIBRARY_EXTS: &[&str] = &["vcswatches", "gpl", "ase"];
/// A library file the save commands write ([`Libraries::write`]).
pub(crate) enum FileData {
/// Text (`.vcswatches`, `.gpl`, CSS, `.vcstyles`), returned as `data`.
Text(String),
/// Bytes (`.ase`), returned as `dataBase64`.
Binary(Vec<u8>),
}
impl<L: LibraryFile> Libraries<L> {
pub fn user_dir(&self) -> Option<&str> {
self.user_dir.as_deref()
@@ -145,9 +168,11 @@ impl<L: LibraryFile> Libraries<L> {
self.extra.iter().map(|e| &e.info)
}
/// The User Defined or loaded library `id`.
/// The User Defined or loaded library `id`. The id of a loaded library copied into the user
/// library folder finds the User Defined copy.
pub fn get(&self, id: &str) -> Option<(LibraryInfo, Arc<L>)> {
self.extra.iter().find(|e| e.info.id == id).map(|e| (e.info.clone(), e.lib.clone()))
let find = |id: &str| self.extra.iter().find(|e| e.info.id == id).map(|e| (e.info.clone(), e.lib.clone()));
find(id).or_else(|| self.copied.iter().find(|(loaded, _)| loaded == id).and_then(|(_, user)| find(user)))
}
/// The file library `id` was read from.
@@ -155,36 +180,50 @@ impl<L: LibraryFile> Libraries<L> {
self.extra.iter().find(|e| e.info.id == id).and_then(|e| e.path.as_deref())
}
/// Write library file `text` as the save commands do: into the user library folder as
/// The User Defined library whose file has the extension of file name `file` and holds `bytes`.
fn user_file(&self, file: &str, bytes: &[u8]) -> Option<&Extra<L>> {
let ext = extension(file);
self.extra
.iter()
.find(|e| e.info.category == "user" && e.path.as_deref().is_some_and(|p| extension(p) == ext && read_file(p).is_ok_and(|b| b == bytes)))
}
/// Write library file `data` as the save commands do: into the user library folder as
/// `name`.`ext` with `user: true` (→ `path`, and `library`: its id), to `path`, else back as
/// `data`; the results go into `out`.
pub(crate) fn write(&mut self, p: &Value, name: &str, ext: &str, text: String, out: &mut Value, cmd: &str) -> Result<()> {
/// `data` (text) or `dataBase64` (bytes); the results go into `out`.
pub(crate) fn write(&mut self, p: &Value, name: &str, ext: &str, data: FileData, out: &mut Value, cmd: &str) -> Result<()> {
let bytes = match &data {
FileData::Text(text) => text.as_bytes(),
FileData::Binary(bytes) => bytes.as_slice(),
};
if bool_or(p, "user", false) {
let dir = self
.user_dir()
.ok_or_else(|| bad(cmd, "no user library folder here (save with a path, or without one to get the data)"))?
.to_string();
// A file name from the library's name, without characters file systems reject.
let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect();
let file = format!("{}.{ext}", base.trim_matches(['.', ' ']));
let file = library_file_name(name, ext);
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
create_dir(&dir)?;
write_file(&path, text.as_bytes())?;
write_file(&path, bytes)?;
self.rescan();
out["path"] = json!(path);
out["library"] = json!(format!("user/{file}"));
} else if let Some(path) = str_param(p, "path") {
write_file(path, text.as_bytes())?;
write_file(path, bytes)?;
out["path"] = json!(path);
} else {
out["data"] = json!(text);
match data {
FileData::Text(text) => out["data"] = json!(text),
FileData::Binary(bytes) => out["dataBase64"] = json!(vectorcraft_format::base64_encode(&bytes)),
}
}
Ok(())
}
/// Load a library from `p` (`path`, `data` or `dataBase64`; `name`: the file's name) as the
/// load commands do, `parse(bytes, file name)` reading it. A file of the user library folder is
/// its User Defined library; others are listed as loaded until the app quits.
/// load commands do, `parse(bytes, file name)` reading it. A file of the user library folder,
/// or a file with the same extension and bytes as one there, is that User Defined library;
/// others are listed as loaded until the app quits.
pub(crate) fn load(&mut self, p: &Value, cmd: &str, parse: impl FnOnce(&[u8], &str) -> Result<L>) -> Result<(LibraryInfo, Arc<L>)> {
let path = str_param(p, "path");
let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) {
@@ -196,7 +235,9 @@ impl<L: LibraryFile> Libraries<L> {
let file = str_param(p, "name").map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into());
let lib = parse(&bytes, &file)?;
self.rescan();
if let Some(e) = path.and_then(|p| self.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p))) {
let user =
|p: &str| self.extra.iter().find(|e| e.info.category == "user" && e.path.as_deref() == Some(p)).or_else(|| self.user_file(p, &bytes));
if let Some(e) = path.and_then(user) {
return Ok((e.info.clone(), e.lib.clone()));
}
let info = LibraryInfo { id: format!("loaded/{}", path.unwrap_or(&file)), name: lib.name().to_string(), category: "loaded" };
@@ -205,6 +246,66 @@ impl<L: LibraryFile> Libraries<L> {
self.extra.push(Extra { info: info.clone(), path: path.map(str::to_string), lib: lib.clone() });
Ok((info, lib))
}
/// Copy loaded library `id` into the user library folder, where it is listed as User Defined in
/// place of the loaded entry; `id` then finds the copy ([`Libraries::get`]). A library read
/// from a library file (an extension of `L::EXTS`) is copied as that file's bytes under the
/// file's name; any other (the swatches of a document, a library loaded from data) is written
/// by `encode` as a `.{ext}` file named after the library. A name the folder already has,
/// compared without case, gets a number ("Brand 2.ase"), and no file is replaced. When a file
/// of the folder with the same extension holds the same bytes, its library is used and nothing
/// is written. → the User Defined library, and whether a file was written.
pub(crate) fn copy_to_user(
&mut self,
id: &str,
ext: &str,
encode: impl FnOnce(&L) -> Result<Vec<u8>>,
cmd: &str,
) -> Result<(LibraryInfo, Arc<L>, bool)> {
let dir = self.user_dir().ok_or_else(|| bad(cmd, "no user library folder here"))?.to_string();
let (source, lib) = self
.extra
.iter()
.find(|e| e.info.id == id && e.info.category == "loaded")
.map(|e| (e.path.clone(), e.lib.clone()))
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
Some(p) => (file_name(&p), read_file(&p)?),
None => (library_file_name(lib.name(), ext), encode(&lib)?),
};
self.rescan();
let (user_id, written) = match self.user_file(&file, &bytes).map(|e| e.info.id.clone()) {
Some(same) => (same, None),
None => {
create_dir(&dir)?;
let mut taken: HashSet<String> = library_files(&dir, L::EXTS).iter().map(|p| file_name(p).to_lowercase()).collect();
let file = free_name(&file, &mut taken);
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().to_string();
write_new_file(&path, &bytes)?;
self.rescan();
(format!("user/{file}"), Some(path))
}
};
let Some((info, lib)) = self.get(&user_id) else {
if let Some(path) = &written {
// Best effort: the copy that can't be read is all there is to clean up.
let _ = std::fs::remove_file(path);
}
return Err(bad(cmd, format!("the copy `{user_id}` can't be read as a library")));
};
self.extra.retain(|x| x.info.id != id);
self.copied.retain(|(loaded, _)| loaded != id);
self.copied.push((id.to_string(), info.id.clone()));
Ok((info, lib, written.is_some()))
}
}
/// The file name of library `name` with extension `ext`: characters file systems reject become
/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`.
pub fn library_file_name(name: &str, ext: &str) -> String {
let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect();
let base = base.trim_matches(['.', ' ']);
format!("{}.{ext}", if base.is_empty() { "Library" } else { base })
}
/// The library files with extensions `exts` in folder `dir`, sorted by name.
@@ -343,15 +444,16 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.library.save";
let path = str_param(p, "path");
let format = match str_param(p, "format").or_else(|| path.map(|p| p.rsplit_once('.').map_or("", |(_, e)| e))).filter(|f| !f.is_empty()) {
Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl or css)")))?,
Some(f) => PaletteFormat::parse(f).ok_or_else(|| bad(C, format!("unknown format `{f}` (vcswatches, gpl, ase or css)")))?,
None => PaletteFormat::Native,
};
let st = s.doc()?;
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
let lib = document_library(&st.doc, &str_list(p, "names"), name, C)?;
let (count, text) = (lib.len(), palette_io::write(&lib, format));
let mut out = json!({"format": format.id(), "count": count});
s.swatch_libraries.write(p, &lib.name, format.id(), text, &mut out, C)?;
let bytes = palette_io::write(&lib, format).map_err(|e| bad(C, e))?;
let data = if format.binary() { FileData::Binary(bytes) } else { FileData::Text(String::from_utf8_lossy(&bytes).into_owned()) };
let mut out = json!({"format": format.id(), "count": lib.len()});
s.swatch_libraries.write(p, &lib.name, format.id(), data, &mut out, C)?;
Ok(out)
}
@@ -383,6 +485,29 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
)
}
/// There is a user library folder to copy libraries into (the desktop app sets one).
fn has_user_folder(s: &Session) -> std::result::Result<(), String> {
match s.swatch_libraries.user_dir() {
Some(_) => Ok(()),
None => Err("no user library folder here".into()),
}
}
fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.library.copyToUser";
let (info, lib) = library_param(s, p, C)?;
let (info, lib, copied) = match info.category {
"loaded" => {
let native = |lib: &SwatchLibrary| palette_io::write(lib, PaletteFormat::Native).map_err(|e| bad(C, e));
s.swatch_libraries.copy_to_user(&info.id, PaletteFormat::Native.id(), native, C)?
}
"user" => (info, lib, false),
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
};
let path = s.swatch_libraries.path(&info.id);
Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
}
/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`:
/// ungrouped): a group's name brings the group, a swatch's name the swatch alone; no names, all.
fn picked<'a>(lib: &'a SwatchLibrary, names: &[String], cmd: &str) -> Result<Vec<(Option<&'a str>, &'a Swatch)>> {
+7 -1
View File
@@ -316,6 +316,10 @@ impl CharChange {
if let Some(v) = &self.style {
st.font_style = v.clone();
}
// Another font: none of the old one's versions.
if self.font.is_some() || self.style.is_some() {
st.font_version = None;
}
if let Some(v) = self.size {
st.size = v.clamp(0.1, 1296.0);
}
@@ -388,7 +392,9 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
let db = vectorcraft_text::FontDb::global();
let mut faces = std::collections::HashMap::new();
let mut covers = |st: &CharStyle, c: char| {
let face = faces.entry((st.font_family.clone(), st.font_style.clone())).or_insert_with(|| db.face(&st.font_family, &st.font_style));
let face = faces
.entry((st.font_family.clone(), st.font_style.clone(), st.font_version.clone()))
.or_insert_with(|| db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()));
face.as_ref().map(|f| f.covers(c))
};
// Byte ranges that keep a font (family, style).
+11
View File
@@ -251,6 +251,13 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
node.appearance = st.appearance();
children.push(Arc::new(node));
}
// Underline and strikethrough bars become paths of their run's paint too (#847).
for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) {
let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style());
let mut node = shape_node(d, PathData::from_bezpath(&bar).transformed(t.xf), None);
node.appearance = st.appearance();
children.push(Arc::new(node));
}
let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?;
d.remove(tid)?;
if children.is_empty() {
@@ -416,6 +423,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &style {
st.font_style = f.clone();
}
// Another font: none of the old one's versions.
if font.is_some() || style.is_some() {
st.font_version = None;
}
if let Some(v) = size {
st.size = v.clamp(0.1, 1296.0);
}
+4 -1
View File
@@ -1055,7 +1055,10 @@ impl Session {
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if let Err(why) = (spec.enabled)(self) {
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
// document. The command validates those targets before making any edit.
let enabled = if id == "edit.clear" && params.get("ids").is_some() { cmd::has_doc(self) } else { (spec.enabled)(self) };
if let Err(why) = enabled {
return Err(EngineError::Disabled(id.to_string(), why));
}
// Only top-level commands are journaled (commands that call other commands would otherwise
+28
View File
@@ -279,3 +279,31 @@ fn a_pen_path_rounds_its_corners_but_not_its_ends() {
run(&mut s, json!({"id": e.0, "radius": 8}));
assert_eq!(node_path(&s, e), before);
}
/// #812: an ellipse's Pie Start and End Angle cut it as a pie; Invert Pie shows the other part;
/// one undo step each; a bad angle is an error.
#[test]
fn an_ellipse_takes_pie_angles_and_inverts() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let id = NodeId(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap());
let pie = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Path { live: Some(LiveShape::Ellipse { pie, .. }), path, .. } => (*pie, path.bounds().unwrap()),
k => panic!("{k:?}"),
};
s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap();
let (p, b) = pie(&s);
assert_eq!(p, (0.0, 90.0));
assert!((b.x0 - 100.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "the quarter up and right of the centre: {b:?}");
s.execute("object.setLiveShape", &json!({"invertPie": true})).unwrap();
let (p, b) = pie(&s);
assert_eq!(p, (90.0, 360.0));
assert!(b.x0.abs() < 1e-6 && (b.y1 - 100.0).abs() < 1e-6, "the other three quarters: {b:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pie(&s).0, (0.0, 90.0));
// 0 to 360 (or an end of 0) is the whole ellipse again.
s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 0})).unwrap();
assert_eq!(pie(&s).0, (0.0, 360.0));
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchor_count(), 4);
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
}
+30
View File
@@ -157,6 +157,36 @@ fn a_missing_link_is_reported_not_copied() {
assert!(report.contains("blue.png: not found"), "{report}");
}
#[test]
fn a_font_named_by_another_of_its_names_is_copied() {
let dir = Folder::new("package-alias");
let mut s = poster(&dir);
// The bundled Semibold by its PostScript name, as an SVG or a PDF can name it.
run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Alias", "font": "SourceSans3-Semibold"}));
let r = run(&mut s, "file.package", json!({}));
assert_eq!((r["fonts"].as_u64(), r["skippedFonts"].clone()), (Some(2), json!([])), "{}", r["skippedFonts"]);
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
let report = String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap();
assert!(report.contains("SourceSans3-Semibold Regular → Fonts/"), "{report}");
let details = report.lines().find(|l| l.starts_with("SourceSans3-Semibold Regular: ")).unwrap_or_default();
assert!(details.contains("embedding allowed") && !details.contains("missing"), "{report}");
}
#[test]
fn a_substituted_style_names_the_style_shown() {
let dir = Folder::new("package-style");
let mut s = poster(&dir);
// No Source Sans 3 has a Black Wide, bundled or installed: its closest style stands in, and
// that style's file is copied.
run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Heavy", "font": "Source Sans 3", "style": "Black Wide"}));
let r = run(&mut s, "file.package", json!({}));
assert_eq!(r["skippedFonts"], json!([]));
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
let report = String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap();
assert!(report.contains("Source Sans 3 Black Wide (shown in Source Sans 3 "), "{report}");
assert!(report.contains("Source Sans 3 Black Wide: substituted: shown in Source Sans 3 "), "{report}");
}
#[test]
fn the_document_info_report_has_every_category() {
let dir = Folder::new("docinfo-report");
+24
View File
@@ -670,3 +670,27 @@ world", "size": 20}),
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(dst).unwrap().kind else { panic!("not type") };
assert_eq!((t.first_style().size, t.first_style().fill.color().map(|c| c.to_hex())), (20.0, Some("#00ff00".into())));
}
/// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator
/// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`.
#[test]
fn hyphenation_exceptions_preference_reaches_the_hyphenator() {
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
vectorcraft_text::set_hyphenation_exceptions("");
}
}
let _clear = Clear;
let mut s = Session::new();
set_pref(&mut s, "hyphenationExceptions", json!(""));
assert!(vectorcraft_text::hyphenation_exceptions().is_empty());
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let before = s.doc().unwrap().revision;
set_pref(&mut s, "hyphenationExceptions", json!("typography, hap-pen"));
assert_eq!(s.prefs.hyphenation_exceptions, "typography, hap-pen");
assert_eq!(vectorcraft_text::hyphenation_exceptions(), "typography, hap-pen");
assert!(s.doc().unwrap().revision > before, "type reflows");
set_pref(&mut s, "hyphenationExceptions", json!(""));
assert!(vectorcraft_text::hyphenation_exceptions().is_empty());
}
+8 -1
View File
@@ -192,8 +192,15 @@ fn user_libraries_and_other_documents_styles() {
assert_eq!(user.len(), 1);
assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("My: Looks"), Some(doc(&s).graphic_styles.len() as u64)));
assert_eq!(list["userFolder"].as_str(), Some(dir.to_string_lossy().as_ref()));
// Opening a file of the user folder gives its User Defined library.
// Opening a file of the user folder gives its User Defined library, and so does a file with the
// same bytes in another folder.
assert_eq!(run(&mut s, "graphicStyle.loadLibrary", json!({ "path": path }))["library"], "user/My- Looks.vcstyles");
let elsewhere = dir.with_file_name(format!("vc-stylelib-elsewhere-{}", std::process::id()));
std::fs::create_dir_all(&elsewhere).unwrap();
let copy = elsewhere.join("Looks.vcstyles");
std::fs::copy(&path, &copy).unwrap();
assert_eq!(run(&mut s, "graphicStyle.loadLibrary", json!({ "path": copy.to_string_lossy() }))["library"], "user/My- Looks.vcstyles");
let _ = std::fs::remove_dir_all(elsewhere);
let _ = std::fs::remove_dir_all(dir);
// Another document's graphic styles load as a library.
+137
View File
@@ -182,6 +182,10 @@ fn libraries_saved_in_the_user_folder_are_user_defined() {
let css = dir.join("out.css").to_string_lossy().to_string();
run(&mut s, "swatch.library.save", json!({ "path": css }));
assert!(std::fs::read_to_string(&css).unwrap().contains(" --white: #ffffff;"));
// A name with nothing a file name can keep is saved as Library.vcswatches, and User Defined lists it.
let r = run(&mut s, "swatch.library.save", json!({"user": true, "name": "..."}));
assert_eq!(r["library"], "user/Library.vcswatches");
assert_eq!(cmd::swatchlib::library(&s, "user/Library.vcswatches").map(|(info, _)| info.name), Some("...".into()));
let _ = std::fs::remove_dir_all(dir);
}
@@ -235,6 +239,139 @@ fn swatch_exchange_files_load_add_and_list_as_user_libraries() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn swatch_exchange_files_save_with_models_kinds_and_groups() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 1.0, "m": 0.5, "y": 0.0, "k": 0.2}, "spot": true}));
run(&mut s, "swatch.new", json!({"name": "Clay", "color": {"l": 50, "a": 20, "b": -30}}));
run(&mut s, "swatch.newGroup", json!({"name": "Brand", "colors": ["#123456", "#abcdef"]}));
let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "names": ["Ink", "Clay", "Brand", "Grays", "Sunset"], "name": "Brand Kit"}));
assert_eq!(r["format"], "ase");
assert!(r.get("data").is_none(), "a binary file comes back as dataBase64");
let b64 = r["dataBase64"].as_str().unwrap().to_string();
assert!(vectorcraft_format::base64_decode(&b64).unwrap().starts_with(b"ASEF"));
let r = run(&mut s, "swatch.library.load", json!({"dataBase64": b64, "name": "Brand Kit.ase"}));
let (_, lib) = cmd::swatchlib::library(&s, r["library"].as_str().unwrap()).unwrap();
assert_eq!(lib.name, "Brand Kit", "named after the file");
let ink = lib.swatch("Ink").unwrap();
assert!(ink.spot && ink.global);
assert_eq!(ink.paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2)));
assert_eq!(lib.swatch("Clay").unwrap().paint.color(), Some(Color::lab(50.0, 20.0, -30.0)));
assert!(lib.swatch("Sunset").is_none(), "a gradient is left out");
let groups: Vec<(&str, usize)> = lib.groups.iter().map(|g| (g.name.as_str(), g.swatches.len())).collect();
assert_eq!(groups, [("Grays", 9), ("Brand", 2)], "in document order");
// Gray colors come back within float rounding.
let grays = &doc(&s).swatch_groups.iter().find(|g| g.name == "Grays").unwrap().swatches;
for (w, want) in lib.groups[0].swatches.iter().zip(grays) {
let (Some(Color::Gray { k }), Some(Color::Gray { k: want_k })) = (w.paint.color(), want.paint.color()) else {
panic!("{} isn't gray", w.name)
};
assert!(w.name == want.name && (k - want_k).abs() < 1e-4, "{}: {k} for {want_k}", w.name);
}
// Into the user library folder, and to a path ending in .ase.
let dir = temp_dir("ase-save");
s.swatch_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let r = run(&mut s, "swatch.library.save", json!({"format": "ase", "user": true, "name": "Brand Kit"}));
assert_eq!(r["library"], "user/Brand Kit.ase");
assert!(std::fs::read(r["path"].as_str().unwrap()).unwrap().starts_with(b"ASEF"));
assert!(cmd::swatchlib::library(&s, "user/Brand Kit.ase").is_some(), "the user folder lists it");
let path = dir.join("out.ase").to_string_lossy().to_string();
assert_eq!(run(&mut s, "swatch.library.save", json!({ "path": path }))["format"], "ase");
assert!(std::fs::read(&path).unwrap().starts_with(b"ASEF"));
let e = s.execute("swatch.library.save", &json!({"format": "aco"})).unwrap_err().to_string();
assert!(e.contains("vcswatches, gpl, ase or css"), "{e}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn loaded_libraries_copy_into_the_user_folder_without_replacing_files() {
use vectorcraft_testkit::ase::{Block, ase, sample};
let dir = temp_dir("copy");
let (user, elsewhere) = (dir.join("user"), dir.join("elsewhere"));
std::fs::create_dir_all(&user).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
let source = elsewhere.join("Brand.ase");
std::fs::write(&source, sample()).unwrap();
let source = source.to_string_lossy().to_string();
// Another library whose file name differs only in case.
let other = ase(&[Block::Color("Only", b"RGB ", &[1.0, 0.0, 0.0], 2)]);
std::fs::write(user.join("brand.ase"), &other).unwrap();
let mut s = session();
s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string()));
let loaded = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
assert_eq!(loaded, format!("loaded/{source}"));
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
// A swatch exchange library is named after its file.
assert_eq!(
(r["library"].as_str(), r["name"].as_str(), r["count"].as_u64(), r["copied"].as_bool()),
(Some("user/Brand 2.ase"), Some("Brand 2"), Some(4), Some(true))
);
assert_eq!(r["path"], user.join("Brand 2.ase").to_string_lossy().to_string());
assert_eq!(std::fs::read(user.join("Brand 2.ase")).unwrap(), sample(), "the file as it is");
assert_eq!(std::fs::read(user.join("brand.ase")).unwrap(), other, "never replaced");
let listed = |s: &mut Session| -> Vec<String> {
let list = run(s, "swatch.library.list", json!({}));
let libs = list["libraries"].as_array().unwrap().iter().filter(|l| matches!(l["category"].as_str(), Some("user" | "loaded")));
libs.map(|l| l["id"].as_str().unwrap().to_string()).collect()
};
assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"], "listed as User Defined only");
// The loaded id finds the copy, so choices made with it (Limit to Library) keep their colors.
let found = cmd::swatchlib::library(&s, loaded.as_str().unwrap()).map(|(info, _)| info.id);
assert_eq!(found.as_deref(), Some("user/Brand 2.ase"));
assert!(cmd::swatchlib::limit_palette(&s, loaded.as_str().unwrap()).is_some());
// The same file again opens the copy, which is listed once; copying it writes nothing.
let again = run(&mut s, "swatch.library.load", json!({ "path": source }))["library"].clone();
assert_eq!(again, "user/Brand 2.ase");
assert_eq!(listed(&mut s), ["user/Brand 2.ase", "user/brand.ase"]);
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": again }));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Brand 2.ase"), Some(false)));
assert_eq!(std::fs::read_dir(&user).unwrap().count(), 2);
// A document's swatches and a library loaded from data are written as .vcswatches.
run(&mut s, "swatch.new", json!({"name": "Signal", "color": "#ff3300"}));
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
let poster = elsewhere.join("Poster.vectorcraft");
std::fs::write(&poster, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap();
let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone();
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(true)));
assert!(cmd::swatchlib::library(&s, "user/Poster.vcswatches").unwrap().1.swatch("Signal").is_some());
// Copied again, the document's swatches are written as the same bytes: the copy there is used.
let loaded = run(&mut s, "swatch.library.load", json!({ "path": poster.to_string_lossy() }))["library"].clone();
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/Poster.vcswatches"), Some(false)));
let kit = vectorcraft_format::base64_encode(&sample());
let loaded = run(&mut s, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone();
assert_eq!(run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }))["library"], "user/Kit.vcswatches");
// A library without a name is copied as Library.
let loaded = run(&mut s, "swatch.library.load", json!({"data": "GIMP Palette\nName:\n0 0 0 Black\n"}))["library"].clone();
let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded }));
assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Library.vcswatches"), Some("Library"), Some(true)));
// When the source no longer reads as a library, the copy fails and no new file is left in the folder.
let junk = elsewhere.join("Junk.ase");
std::fs::write(&junk, ase(&[Block::Color("Junk", b"RGB ", &[0.0, 1.0, 0.0], 2)])).unwrap();
let loaded = run(&mut s, "swatch.library.load", json!({ "path": junk.to_string_lossy() }))["library"].clone();
std::fs::write(&junk, b"not a library").unwrap();
let files = std::fs::read_dir(&user).unwrap().count();
let e = s.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string();
assert!(e.contains("the copy `user/Junk.ase` can't be read as a library"), "{e}");
assert_eq!(std::fs::read_dir(&user).unwrap().count(), files);
// A User Defined library comes back as it is; a built-in one is an error.
let r = run(&mut s, "swatch.library.copyToUser", json!({"library": "user/brand.ase"}));
assert_eq!((r["library"].as_str(), r["copied"].as_bool()), (Some("user/brand.ase"), Some(false)));
assert!(s.execute("swatch.library.copyToUser", &json!({"library": "pastels"})).unwrap_err().to_string().contains("built in"));
assert!(s.execute("swatch.library.copyToUser", &json!({})).is_err());
// Without a user library folder (the web, headless sessions) there is nowhere to copy to, and
// the command is off.
let mut headless = session();
let info = find_command("swatch.library.copyToUser").unwrap().info(&headless);
assert!(!info.enabled && info.disabled_reason.as_deref() == Some("no user library folder here"), "{info:?}");
assert!(find_command("swatch.library.copyToUser").unwrap().info(&s).enabled);
let loaded = run(&mut headless, "swatch.library.load", json!({"dataBase64": kit, "name": "Kit.ase"}))["library"].clone();
let e = headless.execute("swatch.library.copyToUser", &json!({ "library": loaded })).unwrap_err().to_string();
assert!(e.contains("no user library folder"), "{e}");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_library_group_stays_one_group_when_a_swatch_has_its_name() {
use vectorcraft_testkit::ase::{Block, ase};
+145
View File
@@ -0,0 +1,145 @@
//! Clear deletes its exact targets, including compound members (#786).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_engine::{EngineError, Session};
fn compound() -> (Session, NodeId, NodeId, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let mut rectangle =
|x| NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap());
let a = rectangle(10);
let b = rectangle(50);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
(s, a, b, c)
}
#[test]
fn clear_selected_compound_member_preserves_its_sibling_and_is_undoable() {
let (mut s, a, b, c) = compound();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
assert!(s.active().unwrap().doc.node(a).is_none());
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
assert!(s.active().unwrap().selection.is_empty());
assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Clear");
assert_eq!(s.active().unwrap().doc.parent_of(a), Some(c));
assert_eq!(s.active().unwrap().selection.objects, vec![a]);
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.active().unwrap().doc.node(a).is_none());
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
}
#[test]
fn clear_explicit_ids_works_without_a_selection_and_overrides_selected_anchors() {
let (mut s, a, b, c) = compound();
s.execute("select.none", &json!({})).unwrap();
let info = s.commands().into_iter().find(|info| info.id == "edit.clear").unwrap();
assert!(!info.enabled, "the menu stays disabled with no selection");
s.execute("edit.clear", &json!({"ids": [a.0]})).unwrap();
assert!(s.active().unwrap().doc.node(a).is_none());
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 0]]})).unwrap();
let sibling = s.active().unwrap().doc.node(b).unwrap().clone();
s.execute("edit.clear", &json!({"ids": [a.0]})).unwrap();
assert!(s.active().unwrap().doc.node(a).is_none());
assert_eq!(s.active().unwrap().doc.node(b).unwrap(), &sibling);
}
#[test]
fn clear_explicit_targets_are_validated_atomically() {
let (mut s, a, _, _) = compound();
s.execute("select.none", &json!({})).unwrap();
for ids in [json!([a.0, 999]), json!([a.0, -1]), json!([a.0, "bad"]), json!([a.0, 2.5]), json!(null), json!(a.0)] {
let doc = s.active().unwrap().doc.clone();
let journal = s.journal.clone();
assert!(matches!(s.execute("edit.clear", &json!({"ids": ids})), Err(EngineError::BadParams { .. })), "{ids}");
assert_eq!(s.active().unwrap().doc, doc);
assert_eq!(s.journal, journal);
}
assert!(s.execute("edit.clear", &json!({})).is_err());
assert!(Session::new().execute("edit.clear", &json!({"ids": [a.0]})).is_err());
}
#[test]
fn clear_compound_and_member_together_deletes_the_compound() {
for explicit in [false, true] {
let (mut s, a, b, c) = compound();
s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap();
s.execute("edit.clear", &if explicit { json!({"ids": [a.0, c.0, c.0]}) } else { json!({}) }).unwrap();
for id in [a, b, c] {
assert!(s.active().unwrap().doc.node(id).is_none());
}
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.parent_of(a), Some(c));
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
}
}
#[test]
fn cut_compound_member_keeps_copy_and_delete_targets_consistent() {
let (mut s, a, b, c) = compound();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(s.clipboard.nodes.len(), 1);
assert_eq!(s.clipboard.nodes[0].id, c);
assert!(s.active().unwrap().doc.node(c).is_none());
s.execute("edit.pasteInPlace", &json!({})).unwrap();
let pasted = s.active().unwrap().selection.objects[0];
assert_eq!(s.active().unwrap().doc.node(pasted).unwrap().children().unwrap().len(), 2);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.parent_of(a), Some(c));
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
}
#[test]
fn clear_direct_selected_anchors_keeps_the_rest_of_the_path() {
let (mut s, a, b, c) = compound();
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 3);
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
}
#[test]
fn clear_guides_and_empty_explicit_targets_keep_their_separate_behavior() {
let (mut s, a, _, _) = compound();
s.execute("guide.add", &json!({"vertical": true, "pos": 10})).unwrap();
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
let history = s.active().unwrap().history.undo.len();
let selection = s.active().unwrap().selection.clone();
s.execute("edit.clear", &json!({"ids": []})).unwrap();
assert_eq!(s.active().unwrap().history.undo.len(), history);
assert_eq!(s.active().unwrap().selection, selection);
assert_eq!(s.active().unwrap().doc.guides.len(), 1);
s.execute("edit.clear", &json!({})).unwrap();
assert!(s.active().unwrap().doc.guides.is_empty());
assert!(s.active().unwrap().doc.node(a).is_some());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.guides.len(), 1);
}
#[test]
fn clear_keeps_layers_but_deletes_an_explicitly_targeted_child() {
let (mut s, a, b, c) = compound();
let layer = s.active().unwrap().doc.layers[0].id;
s.execute("select.none", &json!({})).unwrap();
s.execute("edit.clear", &json!({"ids": [layer.0, a.0]})).unwrap();
assert!(s.active().unwrap().doc.node(layer).is_some());
assert!(s.active().unwrap().doc.node(a).is_none());
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
}
#[test]
fn cut_direct_selected_anchors_retains_anchor_deletion() {
let (mut s, a, b, c) = compound();
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 3);
assert_eq!(s.active().unwrap().doc.parent_of(b), Some(c));
}
+27
View File
@@ -823,6 +823,20 @@ fn swatch_exchange(what: &str, bytes: &[u8]) -> Result<(), TestCaseError> {
})
}
/// The rich document's swatches with a CMYK spot color and a global Lab color added, saved as a
/// swatch exchange file, made once.
fn saved_ase() -> Vec<u8> {
static ASE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
ASE.get_or_init(|| {
let mut s = rich_session();
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 1, "m": 0.5, "y": 0, "k": 0.2}, "spot": true})).unwrap();
s.execute("swatch.new", &json!({"name": "Clay", "color": {"l": 50, "a": 20, "b": -30}, "global": true})).unwrap();
let r = s.execute("swatch.library.save", &json!({"format": "ase"})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
})
.clone()
}
/// A random swatch exchange block (type, body). The body holds a name, a color model when the block
/// is a color, and random bytes for the rest.
fn arb_ase_block() -> impl Strategy<Value = (u16, Vec<u8>)> {
@@ -899,6 +913,19 @@ proptest! {
}
swatch_exchange("mutated swatch exchange file", &bytes)?;
}
#[test]
fn mutated_saved_swatch_exchange_files_never_panic(cut in prop::option::of(0usize..4_000), edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
let mut bytes = saved_ase();
for (at, b) in edits {
let n = bytes.len();
bytes[at % n] = b;
}
if let Some(cut) = cut {
bytes.truncate(cut);
}
swatch_exchange("mutated saved swatch exchange file", &bytes)?;
}
}
/// A small image of each raster format Place reads, with resolution metadata where it has some.
+75
View File
@@ -0,0 +1,75 @@
//! Selection command results and validation for agent callers (#784).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_engine::{EngineError, Session};
fn session() -> (Session, NodeId, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let mut rectangle =
|x| NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap());
let a = rectangle(0);
let b = rectangle(20);
(s, a, b)
}
#[test]
fn selection_commands_reject_invalid_ids_without_changing_state() {
for command in ["select.set", "select.add", "select.toggle"] {
let (mut s, a, b) = session();
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
s.execute("select.key", &json!({"id": b.0})).unwrap();
for invalid in [json!(999), json!(-1), json!(2.5), json!("2"), Value::Null, json!(true), json!(u64::MAX)] {
let selection = s.active().unwrap().selection.clone();
let revision = s.active().unwrap().revision;
let journal = s.journal.clone();
let error = s.execute(command, &json!({"ids": [b.0, invalid]})).unwrap_err();
assert!(matches!(error, EngineError::BadParams { .. }), "{command}: {error}");
assert!(error.to_string().contains(&invalid.to_string()), "the error must identify {invalid}: {error}");
assert_eq!(s.active().unwrap().selection, selection, "{command}: {invalid}");
assert_eq!(s.active().unwrap().revision, revision);
assert_eq!(s.journal, journal);
}
}
}
#[test]
fn selection_commands_require_an_id_array() {
for command in ["select.set", "select.add", "select.toggle"] {
let (mut s, _, _) = session();
for params in [json!({}), json!({"ids": null}), json!({"ids": 2}), json!({"ids": "2"}), json!({"ids": true})] {
let selection = s.active().unwrap().selection.clone();
assert!(matches!(s.execute(command, &params), Err(EngineError::BadParams { .. })), "{command}: {params}");
assert_eq!(s.active().unwrap().selection, selection);
}
}
}
#[test]
fn selection_commands_report_the_resulting_selection() {
let (mut s, a, b) = session();
assert_eq!(s.execute("select.set", &json!({"ids": [b.0, a.0, b.0]})).unwrap(), json!({"count": 2, "ids": [b.0, a.0]}));
assert_eq!(s.execute("select.set", &json!({"ids": []})).unwrap(), json!({"count": 0, "ids": []}));
assert_eq!(s.execute("select.add", &json!({"ids": [a.0, a.0]})).unwrap(), json!({"count": 1, "ids": [a.0]}));
assert_eq!(s.execute("select.add", &json!({"ids": [b.0]})).unwrap(), json!({"count": 2, "ids": [a.0, b.0]}));
assert_eq!(s.execute("select.toggle", &json!({"id": a.0})).unwrap(), json!({"count": 1, "ids": [b.0]}));
assert_eq!(s.execute("select.toggle", &json!({"ids": [b.0, a.0]})).unwrap(), json!({"count": 1, "ids": [a.0]}));
for command in ["select.add", "select.toggle"] {
assert_eq!(s.execute(command, &json!({"ids": []})).unwrap(), json!({"count": 1, "ids": [a.0]}));
}
}
#[test]
fn single_id_toggle_validates_before_mutating() {
let (mut s, a, _) = session();
for invalid in [json!(999), json!(-1), json!(2.5), json!("2"), Value::Null, json!(true)] {
let selection = s.active().unwrap().selection.clone();
assert!(matches!(s.execute("select.toggle", &json!({"id": invalid})), Err(EngineError::BadParams { .. })));
assert_eq!(s.active().unwrap().selection, selection);
}
// A malformed array must not silently fall back to a valid single id.
assert!(s.execute("select.toggle", &json!({"ids": "bad", "id": a.0})).is_err());
}
+65
View File
@@ -86,6 +86,50 @@ pub fn ellipse(r: Rect) -> PathData {
PathData::single(SubPath::new(anchors, true))
}
/// The ellipse inscribed in `r` cut as a pie: from `start` to `end` degrees, measured
/// counterclockwise on screen from 3 o'clock (as Illustrator's Pie Start and End Angle are), then
/// back through the centre. A pie spanning the whole turn (or nothing) is the whole [`ellipse`].
/// The arc is one cubic per quarter turn at most: the centre, then the arc's anchors.
pub fn ellipse_pie(r: Rect, start: f64, end: f64) -> PathData {
let span = (end - start).rem_euclid(360.0);
if !(start.is_finite() && end.is_finite()) || span < 1e-9 || 360.0 - span < 1e-9 {
return ellipse(r);
}
let r = r.abs();
let c = r.center();
let (rx, ry) = (r.width() / 2.0, r.height() / 2.0);
// Screen y runs down: counterclockwise on screen is a falling y.
let at = |deg: f64| {
let t = deg.to_radians();
Point::new(c.x + rx * t.cos(), c.y - ry * t.sin())
};
let tangent = |deg: f64| {
let t = deg.to_radians();
Vec2::new(-rx * t.sin(), -ry * t.cos())
};
let pieces = if span <= 90.0 {
1
} else if span <= 180.0 {
2
} else if span <= 270.0 {
3
} else {
4
};
let step = span / f64::from(pieces);
let k = 4.0 / 3.0 * (step.to_radians() / 4.0).tan();
let mut anchors = vec![Anchor::corner(c)];
for i in 0..=pieces {
let a = start + step * f64::from(i);
let (p, d) = (at(a), tangent(a) * k);
let h_in = if i == 0 { p } else { p - d };
let h_out = if i == pieces { p } else { p + d };
let kind = if i == 0 || i == pieces { AnchorKind::Corner } else { AnchorKind::Smooth };
anchors.push(Anchor { p, h_in, h_out, kind });
}
PathData::single(SubPath::new(anchors, true))
}
/// Regular polygon with `sides` (≥ 3), first vertex straight up from the centre.
pub fn polygon(center: Point, radius: f64, sides: u32, rotation_deg: f64) -> PathData {
let n = sides.max(3);
@@ -207,6 +251,27 @@ mod tests {
crate::corners::cut_corners(&rectangle(r), &radii, &[]).1.concat()
}
/// A pie is the arc from its start to its end angle, counterclockwise on screen, closed through
/// the centre; a whole turn (or none) is the ellipse.
#[test]
fn a_pie_is_its_arc_closed_through_the_centre() {
let r = Rect::new(0.0, 0.0, 200.0, 100.0);
assert_eq!(ellipse_pie(r, 0.0, 360.0), ellipse(r));
assert_eq!(ellipse_pie(r, 90.0, 90.0), ellipse(r));
// A quarter, from 3 o'clock up to 12: the centre, then the arc's two ends.
let q = ellipse_pie(r, 0.0, 90.0);
let a: Vec<Point> = q.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(a.len(), 3);
assert!(a[0].distance(Point::new(100.0, 50.0)) < 1e-9 && a[1].distance(Point::new(200.0, 50.0)) < 1e-9);
assert!(a[2].distance(Point::new(100.0, 0.0)) < 1e-9, "counterclockwise on screen: up");
assert_eq!(q.bounds().map(|b| (b.x0.round(), b.y0.round(), b.x1.round(), b.y1.round())), Some((100.0, 0.0, 200.0, 50.0)));
// Three quarters, from 12 round to 3 o'clock: four pieces at most, the whole box covered.
let big = ellipse_pie(r, 90.0, 0.0);
assert!(big.subpaths[0].anchors.len() <= 6 && big.subpaths[0].closed);
assert_eq!(big.bounds().map(|b| (b.x0.round(), b.y0.round(), b.x1.round(), b.y1.round())), Some((0.0, 0.0, 200.0, 100.0)));
assert!(ellipse_pie(r, f64::NAN, 10.0).anchor_count() == 4, "not a number: the whole ellipse");
}
#[test]
fn rect_is_4_corners_clockwise() {
let p = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0));
+15 -1
View File
@@ -1304,6 +1304,9 @@ impl Exporter<'_> {
let (below, above) = n.appearance.split_contents();
let all = (!n.appearance.items.is_empty()).then(|| {
let mut all = layout.to_bezpath();
for (_, bar) in vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t) {
all.extend(bar.iter());
}
all.apply_affine(t.xf);
to_path(&all).map(|p| (all, p))
});
@@ -1314,16 +1317,27 @@ impl Exporter<'_> {
s.push_transform(&xf(t.xf));
// Real text: the characters' fills as text in embedded fonts.
let plain = (!self.outline_text).then(|| t.plain_text());
let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t);
for (i, run) in t.runs.iter().enumerate() {
let mut bp = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
// Underline and strikethrough bars are paths, with real text too (#847).
let mut run_bars = BezPath::new();
for (_, bar) in bars.iter().filter(|(r, _)| *r == i) {
run_bars.extend(bar.iter());
}
bp.extend(run_bars.iter());
let fill = &run.style.fill;
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
// The outlines left to fill as paths: all of them, or those real text leaves.
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) {
Some(text) => Cow::Owned(ex.glyph_text(s, &layout, i, text, fill)),
Some(text) => {
let mut left = ex.glyph_text(s, &layout, i, text, fill);
left.extend(run_bars.iter());
Cow::Owned(left)
}
None => Cow::Borrowed(&bp),
};
let opaque = (1.0, BlendMode::Normal);
+22 -1
View File
@@ -460,8 +460,12 @@ fn matching_face(
return Some(face.clone());
}
let db = vectorcraft_text::FontDb::global();
let others =
// Other installed versions of its family and style (a font library keeps old ones), then the
// family's other styles.
let versions = db.versions(face).into_iter().skip(1).take(16);
let styles =
db.styles(&face.family).into_iter().filter(|s| !s.eq_ignore_ascii_case(&face.style)).take(64).filter_map(|s| db.face(&face.family, &s));
let others = versions.chain(styles);
let mut best = own.map(|d| (d, face.clone()));
for f in others {
if let Some(d) = glyph_deviation(&f, text, o, vertical)
@@ -636,6 +640,9 @@ struct Builder<'p> {
/// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph
/// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines.
matched: HashMap<u128, Option<Arc<vectorcraft_text::FontFace>>>,
/// Per font: the installed version its type names, when it isn't the one its family and style
/// resolve to (see `CharStyle::font_version`).
versions: HashMap<u128, Option<String>>,
/// The soft mask of the graphics state, the art drawn through it so far, and the masks read.
mask: Option<Arc<MaskSpec>>,
masked: Vec<Arc<Node>>,
@@ -784,6 +791,7 @@ impl<'p> Builder<'p> {
cid_text: HashMap::new(),
font_chars: HashMap::new(),
matched: HashMap::new(),
versions: HashMap::new(),
mask: None,
masked: vec![],
masks: HashMap::new(),
@@ -1356,12 +1364,25 @@ impl<'p> Builder<'p> {
};
info = FontInfo { family: f.family.clone(), style: f.style.clone(), face: Some(f) };
}
// A version of the family and style that the two alone don't resolve to: the type names it.
// Kept per font once its face is decided (until then each glyph asks).
let named = |f: &Option<Arc<vectorcraft_text::FontFace>>| {
let f = f.as_ref()?;
let default = vectorcraft_text::FontDb::global().face(&f.family, &f.style)?;
(default.version != f.version).then(|| f.version.clone())
};
let version = match self.versions.get(&key) {
Some(v) => v.clone(),
None if self.matched.contains_key(&key) => self.versions.entry(key).or_insert_with(|| named(&info.face)).clone(),
None => named(&info.face),
};
let (paint, opacity) = self.paint(paint, stroke.is_some());
let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale));
let look = Look {
font: key,
family: info.family,
style: info.style,
version,
size: at.size,
h_scale: at.h_scale,
fill: stroke.is_none().then_some(paint),
+3
View File
@@ -75,6 +75,8 @@ pub(crate) struct Look {
pub font: u128,
pub family: String,
pub style: String,
/// The installed version of the font when it isn't the one family and style resolve to.
pub version: Option<String>,
pub size: f64,
pub h_scale: f64,
pub fill: Option<Paint>,
@@ -97,6 +99,7 @@ impl Look {
CharStyle {
font_family: self.family.clone(),
font_style: self.style.clone(),
font_version: self.version.clone(),
size: round(self.size),
h_scale: round(self.h_scale),
fill: self.fill.clone().unwrap_or(Paint::None),
+2
View File
@@ -260,6 +260,7 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
font: 1,
family: "Source Sans 3".into(),
style: "Regular".into(),
version: None,
size: 20.0,
h_scale: 100.0,
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
@@ -282,6 +283,7 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
font: 1,
family: "Source Sans 3".into(),
style: "Regular".into(),
version: None,
size: 20.0,
h_scale: 100.0,
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
+23
View File
@@ -290,3 +290,26 @@ fn slanted_type_reopens_as_type_that_still_leans() {
assert!(off < 0.5, "{turn} {lean}: {a:?} vs {b:?}");
}
}
/// Type set in an installed font's older version (a library keeps both, one name) reopens as type
/// in that version: the file's glyphs are matched to it rather than kept as outlines, and the type
/// names the version, which the family and style alone don't pick.
#[test]
fn type_in_another_installed_version_reopens_in_that_version() {
use vectorcraft_text::test_fonts::{TWIN_FAMILY, TWIN_VERSIONS, twin_font};
// The newer version first: the one the family and style resolve to.
FontDb::global().add_font(twin_font(true).unwrap());
FontDb::global().add_font(twin_font(false).unwrap());
for version in [None, Some(TWIN_VERSIONS[0])] {
let st = CharStyle { font_family: TWIN_FAMILY.into(), font_version: version.map(Into::into), ..style(40.0) };
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), "Hamburgefonstiv", st)]);
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
let mut found = vec![];
back.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
found.push((t.plain_text(), t.first_style().font_version.clone()));
}
});
assert_eq!(found, [("Hamburgefonstiv".to_string(), version.map(String::from))], "{version:?}");
}
}
+12 -1
View File
@@ -1653,7 +1653,11 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
.runs
.iter()
.map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) {
Some((f, m)) => (m == vectorcraft_text::FontMatch::Missing, Some(f.id())),
// The version the type names, when it's installed (see `FontDb::face_version`).
Some((f, m)) => {
let face = db.face_version(&r.style.font_family, &r.style.font_style, r.style.font_version.as_deref()).unwrap_or(f);
(m == vectorcraft_text::FontMatch::Missing, Some(face.id()))
}
None => (true, None),
})
.collect();
@@ -1676,6 +1680,13 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
cell.apply_affine(Affine::rotate_about(g.angle, g.origin));
target.extend(cell.iter());
}
// Underline and strikethrough bars, painted as their run's type is (#847).
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
if let Some(r) = runs.get_mut(run) {
r.extend(bar.iter());
}
all.extend(bar.iter());
}
TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines }
}
+15 -7
View File
@@ -1638,8 +1638,13 @@ impl Writer<'_> {
let a = self.attrs(&css::transparency(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let pd = PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf);
let db = vectorcraft_text::FontDb::global();
let lay = vectorcraft_text::layout(db, t);
let mut all = lay.to_bezpath();
for (_, bar) in vectorcraft_text::decorations(&lay, db, t) {
all.extend(bar.iter());
}
let pd = PathData::from_bezpath(&all).transformed(t.xf);
let d = self.path_d(&pd, self.xf);
let tb = Some(t.xf.transform_rect_bbox(lay.bounds));
let (below, above) = n.appearance.split_contents();
@@ -1671,12 +1676,15 @@ impl Writer<'_> {
/// Text as glyph outlines: one compound path per run, painted like the run (gradients span
/// the whole text, as on the canvas).
fn text_outlines(&mut self, n: &Node, t: &TextObject) {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let db = vectorcraft_text::FontDb::global();
let lay = vectorcraft_text::layout(db, t);
let mut runs: Vec<(usize, kurbo::BezPath)> = vec![];
for g in &lay.glyphs {
match runs.last_mut() {
Some((r, bp)) if *r == g.run => bp.extend(g.outline.iter()),
_ => runs.push((g.run, g.outline.clone())),
// Underline and strikethrough bars join their run's outlines.
let bars = vectorcraft_text::decorations(&lay, db, t);
for (run, outline) in lay.glyphs.iter().map(|g| (g.run, &g.outline)).chain(bars.iter().map(|(r, b)| (*r, b))) {
match runs.iter_mut().find(|(r, _)| *r == run) {
Some((_, bp)) => bp.extend(outline.iter()),
None => runs.push((run, outline.clone())),
}
}
let id = self.id_attr(n);
+65 -7
View File
@@ -7,25 +7,49 @@
//! Web (wasm32) builds embed only the UI font, BIZ UDPGothic Regular (~4.7 MB): the browser has no
//! system fonts, and all four fonts (~24 MB) would push the `.wasm` past static hosts' per-file
//! limits (Cloudflare Pages: 25 MiB).
//!
//! It also writes `WEB_FONTS`: the faces listed in `web-fonts.txt`, which web builds fetch from
//! `fonts/<sha16>/` beside the wasm instead of embedding (`cargo xtask web-fonts` copies them
//! there; `apps/vectorcraft-web/src/fonts.rs` fetches them). A list line the manifest lacks is left
//! out with a warning, never an error, and never costs the desktop its fonts.
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
#[path = "build/web_fonts.rs"]
mod web_fonts;
fn main() {
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
println!("cargo::rerun-if-changed=web-fonts.txt");
println!("cargo::rerun-if-changed=build/web_fonts.rs");
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
let (mut fonts, mut web) = (String::new(), String::new());
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from) {
let dir = from_workspace(dir);
match craft_fonts(&dir) {
Ok(entries) => src.push_str(&entries),
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
}
Ok(entries) => fonts = entries,
Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()),
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
}
// Separately: a bad web list must not cost the desktop build its craft fonts. A listed face
// the checkout lacks (an older craft-fonts pin) is left out with a warning, never an error,
// so the apps don't depend on which craft-fonts commit CI pins: that build just fetches
// fewer fonts.
match web_font_entries(&dir) {
Ok((entries, missing)) => {
web = entries;
if !missing.is_empty() {
let msg = format!("{} not in CRAFT_FONTS_DIR={} (an older craft-fonts?)", missing.join(", "), dir.display());
println!("cargo::warning=web build won't fetch: {msg}");
}
}
Err(e) if required => println!("cargo::error=web-fonts.txt: {e}"),
Err(e) => println!("cargo::warning=web build fetches no craft fonts: web-fonts.txt: {e}"),
}
}
src.push_str("];\n");
let src = format!("pub static CRAFT_FONTS: &[CraftFont] = &[\n{fonts}];\npub static WEB_FONTS: &[WebFont] = &[\n{web}];\n");
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
if let Err(e) = std::fs::write(&out, src) {
println!("cargo::error=writing {}: {e}", out.display());
@@ -72,3 +96,37 @@ fn craft_fonts(dir: &Path) -> Result<String, String> {
}
Ok(out)
}
/// One `WebFont { .. }` initialiser per `web-fonts.txt` line, from its craft-fonts manifest row, and
/// the listed faces the manifest lacks (an older craft-fonts checkout), which are left out.
fn web_font_entries(dir: &Path) -> Result<(String, Vec<String>), String> {
let list_path = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()).join("web-fonts.txt");
let list = std::fs::read_to_string(&list_path).map_err(|e| format!("{}: {e}", list_path.display()))?;
let manifest_path = dir.join("fonts/manifest.txt");
// WEB_FONTS follows the manifest (URLs and hashes), on every target.
println!("cargo::rerun-if-changed={}", manifest_path.display());
let manifest = std::fs::read_to_string(&manifest_path).map_err(|e| format!("{}: {e}", manifest_path.display()))?;
let rows: Vec<Vec<&str>> =
manifest.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| l.split(" | ").map(str::trim).collect()).collect();
let (mut out, mut missing) = (String::new(), Vec::new());
for e in web_fonts::parse_list(&list)? {
let Some(row) = rows.iter().find(|r| r.first() == Some(&e.family) && r.get(1) == Some(&e.style)) else {
missing.push(format!("{} {}", e.family, e.style));
continue;
};
let (Some(file), Some(sha)) = (row.get(2), row.get(6)) else {
return Err(format!("malformed manifest row for {} {}", e.family, e.style));
};
let integrity = web_fonts::sri(sha).ok_or_else(|| format!("bad sha256 for {} {}", e.family, e.style))?;
let _ = writeln!(
out,
" WebFont {{ family: {:?}, style: {:?}, startup: {}, url: {:?}, integrity: {:?} }},",
e.family,
e.style,
e.startup,
web_fonts::url(sha, file),
integrity,
);
}
Ok((out, missing))
}
+118
View File
@@ -0,0 +1,118 @@
//! `web-fonts.txt` (the craft-fonts faces web builds fetch instead of embedding) and the
//! `WEB_FONTS` fields built from it. Shared by `build.rs` and the crate's tests (`#[path]`-included
//! by both, so it has no dependencies), and by `xtask/src/web_fonts.rs`, which copies the files to [`url`].
/// One line of `web-fonts.txt`: `when | family | style`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Listed<'a> {
/// `startup` (fetched before the app starts) rather than `background`.
pub startup: bool,
pub family: &'a str,
pub style: &'a str,
}
/// Parse `web-fonts.txt`. Blank lines and `#` comments are skipped; line numbers are 1-based.
pub fn parse_list(text: &str) -> Result<Vec<Listed<'_>>, String> {
let mut out = Vec::new();
for (i, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let fields: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [when, family, style] = fields.as_slice() else {
return Err(format!("web-fonts.txt line {}: expected `when | family | style`", i + 1));
};
let startup = match *when {
"startup" => true,
"background" => false,
other => return Err(format!("web-fonts.txt line {}: `{other}` is neither startup nor background", i + 1)),
};
if family.is_empty() || style.is_empty() {
return Err(format!("web-fonts.txt line {}: empty family or style", i + 1));
}
out.push(Listed { startup, family, style });
}
Ok(out)
}
/// Standard base64 (RFC 4648, with padding).
pub fn base64(data: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = |i: usize| u32::from(chunk.get(i).copied().unwrap_or(0));
let n = (b(0) << 16) | (b(1) << 8) | b(2);
for k in 0..4 {
if k <= chunk.len() {
let six = ((n >> (18 - 6 * k)) & 63) as usize;
s.push(TABLE.get(six).map_or('=', |&c| char::from(c)));
} else {
s.push('=');
}
}
}
s
}
/// The Subresource Integrity value (`sha256-<base64>`) for a 64-digit hex SHA-256; `None` if `hex`
/// isn't one.
pub fn sri(hex: &str) -> Option<String> {
if hex.len() != 64 {
return None;
}
let bytes: Option<Vec<u8>> = (0..32).map(|i| hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect();
Some(format!("sha256-{}", base64(&bytes?)))
}
/// The site-relative URL a face is served at: `fonts/<first 16 hex digits of its SHA-256>/<file
/// name>`. Relative, so the site works under any path; content-addressed, so it can be cached
/// forever.
pub fn url(sha256: &str, file: &str) -> String {
let name = file.rsplit('/').next().unwrap_or(file);
format!("fonts/{}/{name}", sha256.get(..16).unwrap_or(sha256))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_matches_rfc_4648_vectors() {
for (input, want) in
[("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")]
{
assert_eq!(base64(input.as_bytes()), want, "{input:?}");
}
}
#[test]
fn sri_of_the_empty_files_sha256() {
let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert_eq!(sri(empty).as_deref(), Some("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="));
assert_eq!(sri("abc"), None);
assert_eq!(sri(&"zz".repeat(32)), None);
}
#[test]
fn url_is_relative_and_content_addressed() {
let sha = "63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e";
assert_eq!(url(sha, "fonts/noto-sans-arabic/NotoSansArabic.ttf"), "fonts/63111b5b2e074dd4/NotoSansArabic.ttf");
assert!(!url(sha, "x.ttf").starts_with('/'));
}
#[test]
fn list_parsing() {
let l = parse_list("# c\n\nstartup | Noto Sans Arabic | Regular\nbackground | Amiri | Bold Italic\n").unwrap();
assert_eq!(
l,
[
Listed { startup: true, family: "Noto Sans Arabic", style: "Regular" },
Listed { startup: false, family: "Amiri", style: "Bold Italic" }
]
);
assert!(parse_list("soon | A | B").unwrap_err().contains("line 1"));
assert!(parse_list("startup | A").is_err());
assert!(parse_list("startup | | B").is_err());
}
}
+60 -1
View File
@@ -1,7 +1,7 @@
//! Fonts from the optional craft-fonts build input (<https://github.com/storytold/craft-fonts>).
//!
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`CRAFT_FONTS`] holds every font in its
//! manifest (today the Japanese UI and document fonts); built without it, it is empty and Japanese
//! manifest (Japanese UI and document fonts, and Arabic fonts); built without it, it is empty and Japanese
//! text falls back to the installed system fonts. Release builds always set it. See `build.rs`
//! and craftrules `standards/fonts.md`.
@@ -14,6 +14,20 @@ pub struct CraftFont {
pub bytes: &'static [u8],
}
/// A craft-fonts face the web (wasm32) build fetches from beside the wasm instead of embedding,
/// listed in `crates/text/web-fonts.txt` (empty unless built with `CRAFT_FONTS_DIR`). Fetched by
/// `apps/vectorcraft-web/src/fonts.rs`; copied into the site by `cargo xtask web-fonts`.
pub struct WebFont {
pub family: &'static str,
pub style: &'static str,
/// Fetched before the app starts (a fallback the first frame needs), else in the background.
pub startup: bool,
/// Site-relative URL, `fonts/<first 16 hex digits of the SHA-256>/<file name>`.
pub url: &'static str,
/// Subresource Integrity value, `sha256-<base64>`; the browser rejects other bytes.
pub integrity: &'static str,
}
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
impl CraftFont {
@@ -21,6 +35,10 @@ impl CraftFont {
pub fn is_japanese(&self) -> bool {
self.scripts.contains(&"Jpan")
}
/// Is the font for Arabic text?
pub fn is_arabic(&self) -> bool {
self.scripts.contains(&"Arab")
}
/// A serif (Mincho) family, for document text.
pub fn is_mincho(&self) -> bool {
self.family.contains("Mincho")
@@ -43,6 +61,16 @@ pub fn japanese_document_fonts() -> Vec<&'static CraftFont> {
v
}
/// The craft-fonts faces the document font database loads: the Japanese ones in
/// [`japanese_document_fonts`] order, then the Arabic ones in manifest order (Noto Sans Arabic
/// first: the fallback for Arabic text; the other families are picked by name). Empty when built
/// without craft-fonts.
pub fn document_fonts() -> Vec<&'static CraftFont> {
let mut v = japanese_document_fonts();
v.extend(CRAFT_FONTS.iter().filter(|f| f.is_arabic() && !f.is_japanese()));
v
}
/// The craft-fonts faces for Japanese text, best for UI text first: Gothic (sans) families, then
/// the others; `bold` puts bold faces before regular ones. Empty when built without craft-fonts.
pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
@@ -50,3 +78,34 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
v.sort_by_key(|f| (!f.is_gothic(), f.is_bold() != bold));
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn web_fonts_follow_the_list() {
if CRAFT_FONTS.is_empty() {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
return;
}
// The listed faces this craft-fonts checkout has (an older one lacks some; build.rs leaves
// those out with a warning).
let listed: Vec<_> = crate::web_fonts_build::parse_list(include_str!("../web-fonts.txt"))
.unwrap()
.into_iter()
.filter(|l| CRAFT_FONTS.iter().any(|f| f.family == l.family && f.style == l.style))
.collect();
assert_eq!(WEB_FONTS.len(), listed.len());
for (l, w) in listed.iter().zip(WEB_FONTS) {
assert_eq!((l.family, l.style, l.startup), (w.family, w.style, w.startup));
let (dir, name) = w.url.strip_prefix("fonts/").and_then(|r| r.split_once('/')).unwrap();
assert!(dir.len() == 16 && dir.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), "{}", w.url);
assert!(name.ends_with(".ttf") || name.ends_with(".otf"), "{}", w.url);
assert!(w.integrity.starts_with("sha256-") && w.integrity.len() == "sha256-".len() + 44, "{}", w.integrity);
}
let startup: Vec<_> = WEB_FONTS.iter().filter(|w| w.startup).map(|w| w.family).collect();
let noto = CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic");
assert_eq!(startup, if noto { vec!["Noto Sans Arabic"] } else { vec![] });
}
}
+106 -12
View File
@@ -52,6 +52,9 @@ pub struct FontFace {
pub family: String,
/// Typographic style name (e.g. "Semibold", "Italic").
pub style: String,
/// The font's version string (name ID 5, e.g. "Version 7.200"; empty when it has none). Two
/// installed versions of one family and style are told apart by it (see [`FontDb::face_version`]).
pub version: String,
/// usWeightClass-style weight (400 = regular).
pub weight: f32,
pub italic: bool,
@@ -69,6 +72,10 @@ pub struct FontFace {
/// values).
pub(crate) cap_height: f64,
pub(crate) x_height: f64,
/// Underline and strikeout: (offset from the baseline up to the line's top, thickness), in
/// font units (the `post` and OS/2 tables', else a common placement).
pub(crate) underline: (f64, f64),
pub(crate) strikeout: (f64, f64),
pub(crate) shaper: harfrust::ShaperData,
/// The axis settings of the variable font's named instance this face is (user units: `wght`
/// 700); empty for a static face or a variable font's default instance.
@@ -270,9 +277,10 @@ impl FontFace {
use skrifa::raw::TableProvider;
self.skrifa().and_then(|f| f.os2().ok()).map_or(0, |t| t.fs_type())
}
/// May the font file be copied along with a document (its licence doesn't restrict embedding)?
/// May the font file be copied along with a document? Its license allows embedding its
/// outlines, as exports check it ([`Self::embedding`]).
pub fn embeddable(&self) -> bool {
self.fs_type() & 0x000f != 0x0002
self.embedding() != crate::embed::Embedding::Forbidden
}
/// The face's index in its font file ([`Self::file_data`]; collections hold several).
pub fn face_index(&self) -> u32 {
@@ -323,6 +331,10 @@ pub struct FontDb {
/// scan, so what they find never depends on what ran before them.
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock<()>,
/// Families (lowercased) whose installed files were all loaded to find other versions of their
/// faces (see [`FontDb::versions`]); a rescan forgets them.
#[cfg(not(target_arch = "wasm32"))]
version_scanned: RwLock<std::collections::HashSet<String>>,
/// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned.
family_cache: Mutex<Option<Arc<[String]>>>,
menu_cache: Mutex<Option<MenuFamilies>>,
@@ -783,9 +795,15 @@ fn font_files(path: &Path) -> Vec<Vec<u8>> {
std::fs::read(path).into_iter().collect()
}
/// The tables holding outlines the font engine draws: TrueType, CFF, CFF2 and VARC. A face with
/// none of them (outlines in Apple's `hvgl` table, bitmaps only) draws nothing.
#[cfg(not(target_arch = "wasm32"))]
const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
/// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table
/// directories and `name` and `fvar` tables: a scan opens hundreds of font files, many of them
/// megabytes long.
/// megabytes long. Faces without outlines the font engine draws (no [`OUTLINE_TABLES`] table) are
/// left out.
#[cfg(not(target_arch = "wasm32"))]
fn file_face_names(path: &Path) -> Vec<FaceStyle> {
if crate::suitcase::is_suitcase(path) {
@@ -825,6 +843,9 @@ fn reader_face_names(mut file: impl std::io::Read + std::io::Seek) -> Vec<FaceSt
let dir = read_at(start.into(), 12)?;
let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize;
let records = read_at(u64::from(start) + 12, tables * 16)?;
if !records.as_chunks::<16>().0.iter().any(|r| OUTLINE_TABLES.iter().any(|t| r.starts_with(*t))) {
return None;
}
let mut table = |tag: &[u8; 4]| -> Option<Vec<u8>> {
let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?;
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
@@ -890,7 +911,9 @@ static PLATFORM_FONT_FILES: std::sync::OnceLock<PlatformFontFiles> = std::sync::
/// folders, asked again by each scan and each check for installed fonts
/// ([`FontDb::installed_fonts_changed`]). The desktop app lists DirectWrite's system font
/// collection on Windows: fonts a font service such as Adobe Fonts loads in place, from files
/// outside the font folders and unknown to the registry (#579). Only the first call counts.
/// outside the font folders and unknown to the registry (#579). On macOS it lists the fonts
/// CoreText's font manager has available, which apps and font managers can register from any
/// folder. Only the first call counts.
pub fn set_platform_font_files(list: PlatformFontFiles) {
#[cfg(not(target_arch = "wasm32"))]
// A second call keeps the first lister, as documented.
@@ -1020,6 +1043,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
};
let f = skrifa::FontRef::from_index(data, index).ok()?;
let FaceStyle { family, style, keys, variations, weight, italic, traits } = spec;
let version = name(&f, &[StringId::VERSION_STRING]).unwrap_or_default();
// A named instance: outlines, metrics and advances at its axis settings.
let location = if variations.is_empty() { Location::default() } else { f.axes().location(variations.iter().map(|(t, v)| (Tag::new(t), *v))) };
let m = f.metrics(Size::unscaled(), &location);
@@ -1028,22 +1052,32 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
let shaper = harfrust::ShaperData::new(&hb);
let instance =
(!variations.is_empty()).then(|| harfrust::ShaperInstance::from_variations(&hb, variations.iter().map(|(t, v)| (harfrust::Tag::new(t), *v))));
let upem = m.units_per_em.max(1) as f64;
let x_height = m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5);
let decoration = |d: Option<skrifa::metrics::Decoration>, fallback: (f64, f64)| {
d.map(|d| (f64::from(d.offset), f64::from(d.thickness))).filter(|(o, t)| o.is_finite() && *t > 0.0 && t.is_finite()).unwrap_or(fallback)
};
let underline = decoration(m.underline, (-0.075 * upem, 0.05 * upem));
let strikeout = decoration(m.strikeout, (x_height * 0.5 + 0.025 * upem, 0.05 * upem));
Some(FontFace {
keys,
traits,
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
style,
version,
weight: weight.unwrap_or(a.weight.value()),
italic: italic || !matches!(a.style, skrifa::attribute::Style::Normal),
variations,
location,
instance,
upem: m.units_per_em.max(1) as f64,
upem,
ascent: m.ascent as f64,
descent: -(m.descent as f64),
cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72),
x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5),
x_height,
underline,
strikeout,
shaper,
bytes,
index,
@@ -1118,9 +1152,10 @@ impl FontDb {
fn new(font_paths: FontPaths) -> Self {
let mut faces = Vec::new();
// The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho
// first: fallbacks for Japanese text after the requested and bundled fonts.
let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes);
// The bundled fonts, then the craft-fonts faces (when built with them): Japanese, Mincho
// first, then Arabic with Noto Sans Arabic first. They are fallbacks for those scripts
// after the requested and bundled fonts, and families users can pick by name.
let craft = crate::craft_fonts::document_fonts().into_iter().map(|f| f.bytes);
for data in BUNDLED.iter().copied().chain(craft) {
for (i, spec) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), i, spec, None) {
@@ -1137,6 +1172,8 @@ impl FontDb {
aliases: RwLock::new(HashMap::new()),
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock::new(),
#[cfg(not(target_arch = "wasm32"))]
version_scanned: RwLock::new(std::collections::HashSet::new()),
family_cache: Mutex::new(None),
menu_cache: Mutex::new(None),
generation: AtomicU64::new(0),
@@ -1229,8 +1266,13 @@ impl FontDb {
pub fn styles(&self, family: &str) -> Vec<String> {
let (family, _) = self.canonical(family, "");
let family = family.as_str();
let mut v: Vec<(bool, f32, String)> =
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
let mut v: Vec<(bool, f32, String)> = Vec::new();
// Each style once, as its first loaded version describes it (versions can disagree on weight).
for f in self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)) {
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&f.style)) {
v.push((f.italic, f.weight, f.style.clone()));
}
}
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
for cf in &c.faces {
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&cf.style)) {
@@ -1254,7 +1296,14 @@ impl FontDb {
let data = Arc::new(bytes);
let mut added = 0;
for (i, spec) in enumerate_faces(&data) {
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style)) {
// Another version of a family and style is kept: a document may name it (the first one
// loaded stays the one the family and style alone resolve to).
let version = skrifa::FontRef::from_index(&data, i).ok().and_then(|f| name(&f, &[StringId::VERSION_STRING])).unwrap_or_default();
if self
.read_faces()
.iter()
.any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style) && f.version == version)
{
continue;
}
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.map(Path::to_path_buf)) {
@@ -1316,6 +1365,8 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
fn scan_font_dirs(&self) -> usize {
// Files may have been installed since: other versions are looked for again.
self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).clear();
let mut catalog = Catalog::new();
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
let mut postscript = PostScriptNames::new();
@@ -1507,6 +1558,49 @@ impl FontDb {
.or_else(|| self.read_faces().first().cloned())
}
/// [`FontDb::face`] in version `version` of the family and style (a [`FontFace::version`]), when
/// that version is installed; otherwise (or without a version) the face [`FontDb::face`] gives.
pub fn face_version(&self, family: &str, style: &str, version: Option<&str>) -> Option<Arc<FontFace>> {
let face = self.face(family, style)?;
let Some(version) = version.filter(|v| *v != face.version) else { return Some(face) };
let same =
|f: &&Arc<FontFace>| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.version == version;
if let Some(f) = self.read_faces().iter().find(same) {
return Some(f.clone());
}
// Not loaded: the family's installed files may hold it (the catalog doesn't list versions).
self.load_family_files(&face.family);
let found = self.read_faces().iter().find(same).cloned();
Some(found.unwrap_or(face))
}
/// Every installed version of `face`'s family and style, `face` itself first.
pub fn versions(&self, face: &Arc<FontFace>) -> Vec<Arc<FontFace>> {
self.load_family_files(&face.family);
let mut out = vec![face.clone()];
out.extend(
self.read_faces()
.iter()
.filter(|f| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.id != face.id)
.cloned(),
);
out
}
/// Load the installed files of `family` once (other versions of faces already loaded from
/// elsewhere); a no-op on wasm, where no fonts are installed.
fn load_family_files(&self, family: &str) {
#[cfg(not(target_arch = "wasm32"))]
{
let key = family.to_ascii_lowercase();
if self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).insert(key) {
self.load_cataloged(family);
}
}
#[cfg(target_arch = "wasm32")]
let _ = family;
}
/// Is `style` of `family` among the installed fonts?
#[cfg(not(target_arch = "wasm32"))]
fn is_cataloged(&self, family: &str, style: &str) -> bool {
+164
View File
@@ -1,12 +1,109 @@
//! Pattern-free English hyphenation: break long words between syllables at vowel–consonant
//! boundaries (V-CV: "ty-po", and VC-CV: "hap-pen"), never leaving fewer than [`MIN_BEFORE`]
//! letters before or [`MIN_AFTER`] after the hyphen.
//!
//! Preferences › Hyphenation › Exceptions (comma separated) override the points for named words:
//! `typography` never breaks, `ty-pog-ra-phy` breaks only where the hyphens mark. See
//! [`set_hyphenation_exceptions`].
use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard, OnceLock};
/// Only words at least this long are hyphenated (Illustrator's default: longer than 5 letters).
pub const MIN_WORD: usize = 6;
pub const MIN_BEFORE: usize = 3;
pub const MIN_AFTER: usize = 3;
/// An exception for one word: never break it, or break only at the listed character indices.
#[derive(Clone, Debug, PartialEq, Eq)]
enum Exception {
Never,
Only(Vec<usize>),
}
#[derive(Default)]
struct Exceptions {
source: String,
map: HashMap<String, Exception>,
}
fn store() -> MutexGuard<'static, Exceptions> {
static LOCK: OnceLock<Mutex<Exceptions>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(Exceptions::default())).lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn parse_list(source: &str) -> Exceptions {
let mut map = HashMap::new();
for entry in source.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let (word, ex) = parse_exception(entry);
if word.is_empty() {
continue;
}
map.insert(word, ex);
}
Exceptions { source: source.to_string(), map }
}
/// Preferences › Hyphenation › Exceptions: comma-separated words. A word with no hyphens is never
/// hyphenated; hyphens mark the only allowed break points (`ty-pog-ra-phy`). Matching is
/// case-insensitive. Empty clears the list.
pub fn set_hyphenation_exceptions(source: &str) {
*store() = parse_list(source);
}
/// The last string passed to [`set_hyphenation_exceptions`] (empty when none).
pub fn hyphenation_exceptions() -> String {
store().source.clone()
}
fn parse_exception(entry: &str) -> (String, Exception) {
// Illustrator: bare word = never break; hyphens mark the only allowed breaks. No other syntax.
if !entry.contains('-') {
return (normalize_word(entry), Exception::Never);
}
let mut word = String::new();
let mut points = Vec::new();
for ch in entry.chars() {
if ch == '-' {
let at = word.chars().count();
if at > 0 {
points.push(at);
}
} else {
for c in ch.to_lowercase() {
word.push(c);
}
}
}
let n = word.chars().count();
points.retain(|&p| p > 0 && p < n);
points.sort_unstable();
points.dedup();
(word, Exception::Only(points))
}
fn normalize_word(word: &str) -> String {
word.chars().flat_map(|c| c.to_lowercase()).collect()
}
/// Look the word up under the preferences lock. Only the matched [`Exception`] is cloned, never
/// the whole list.
#[cfg(not(test))]
fn exception_for(word: &str) -> Option<Exception> {
store().map.get(&normalize_word(word)).cloned()
}
/// Unit tests read only their own thread's list: tests running in parallel set the process-wide
/// one.
#[cfg(test)]
fn exception_for(word: &str) -> Option<Exception> {
TEST_OVERRIDE.with(|c| c.borrow().as_ref().and_then(|e| e.map.get(&normalize_word(word)).cloned()))
}
fn is_vowel(c: char) -> bool {
matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y') || (c.is_alphabetic() && !c.is_ascii())
}
@@ -25,7 +122,14 @@ fn onset_pair(a: char, b: char) -> bool {
/// Allowed hyphenation points in `word`, as char indices (a hyphen goes *before* the char).
/// Words containing non-letters (other than a trailing apostrophe) are not hyphenated.
/// Preferences exceptions override the pattern for a matching word.
pub fn hyphen_points(word: &str) -> Vec<usize> {
if let Some(ex) = exception_for(word) {
return match ex {
Exception::Never => vec![],
Exception::Only(pts) => pts,
};
}
let c: Vec<char> = word.chars().collect();
let n = c.len();
if n < MIN_WORD || !c.iter().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’')) {
@@ -69,3 +173,63 @@ pub fn hyphenate_word(word: &str) -> String {
}
s
}
#[cfg(test)]
thread_local! {
/// Per-thread list so unit tests don't race the process-wide preferences store.
static TEST_OVERRIDE: std::cell::RefCell<Option<Exceptions>> = const { std::cell::RefCell::new(None) };
}
/// Run `f` with Preferences › Hyphenation › Exceptions applied on this thread only (tests).
#[cfg(test)]
pub(crate) fn with_hyphenation_exceptions_for_test(source: &str, f: impl FnOnce()) {
TEST_OVERRIDE.with(|c| *c.borrow_mut() = Some(parse_list(source)));
f();
TEST_OVERRIDE.with(|c| *c.borrow_mut() = None);
}
#[cfg(test)]
mod tests {
use super::*;
fn with_exceptions(source: &str, f: impl FnOnce()) {
with_hyphenation_exceptions_for_test(source, f);
}
/// #394 Preferences › Hyphenation › Exceptions contract (parse + override).
#[test]
fn hyphenation_exceptions_override_the_pattern() {
let pattern = hyphen_points("typography");
assert_eq!(pattern, vec![4, 7], "pattern: typo-gra-phy");
// Bare word: never break (case-insensitive).
with_exceptions("typography", || {
assert!(hyphen_points("typography").is_empty());
assert!(hyphen_points("Typography").is_empty(), "case-insensitive");
});
// Hyphens mark the only allowed breaks (stricter than the pattern).
with_exceptions("typo-graphy", || {
assert_eq!(hyphen_points("typography"), vec![4], "only the marked break");
assert_eq!(hyphenate_word("typography"), "typo-graphy");
});
// Comma-separated list; empty / whitespace entries ignored.
with_exceptions(" , typography , hap-pen , ", || {
assert!(hyphen_points("typography").is_empty());
assert_eq!(hyphen_points("happen"), vec![3]);
assert!(!hyphen_points("hyphenation").is_empty(), "unlisted words still use the pattern");
});
assert_eq!(hyphen_points("typography"), pattern, "override cleared");
}
/// Process-wide store used by `prefs.set` / restore.
#[test]
fn set_hyphenation_exceptions_round_trips_the_source_string() {
set_hyphenation_exceptions("typography, hap-pen");
assert_eq!(hyphenation_exceptions(), "typography, hap-pen");
set_hyphenation_exceptions("");
assert!(hyphenation_exceptions().is_empty());
}
}
+44 -2
View File
@@ -146,6 +146,46 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
layout_with(db, t, &opts)
}
/// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a
/// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's
/// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift
/// and scaling follow) → (run, bar in text space). Type on a path and vertical type have none yet.
pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usize, BezPath)> {
let mut out = vec![];
if layout.on_path || layout.vertical {
return out;
}
let glyphs = &layout.glyphs;
let mut i = 0;
while let Some(g) = glyphs.get(i) {
// The stretch: the glyphs of this run on this line, in a row.
let end = glyphs.iter().skip(i).position(|h| h.run != g.run || h.line != g.line).map_or(glyphs.len(), |n| i + n);
let stretch = glyphs.get(i..end).unwrap_or_default();
i = end.max(i + 1);
let Some(style) = t.runs.get(g.run).map(|r| &r.style) else { continue };
if !(style.underline || style.strikethrough) {
continue;
}
let Some(face) = db.face_by_id(g.font_id) else { continue };
let (x0, x1) = stretch.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), h| {
let (s, e) = (h.origin.x, h.origin.x + h.advance);
(a.min(s).min(e), b.max(s).max(e))
});
for (on, (offset, thickness)) in [(style.underline, face.underline), (style.strikethrough, face.strikeout)] {
if !on {
continue;
}
// Font units run y down in the glyph's transform: the bar's top is at -offset.
let (top, bottom) = ((g.xf * Point::new(0.0, -offset)).y, (g.xf * Point::new(0.0, -offset + thickness)).y);
let bar = Rect::new(x0, top.min(bottom), x1, top.max(bottom));
if bar.width() > 0.0 && bar.height() > 0.0 && bar.width().is_finite() && bar.height().is_finite() {
out.push((g.run, bar.to_path(0.1)));
}
}
}
out
}
/// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass).
const SHRINK_PASSES: usize = 11;
@@ -263,7 +303,8 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout
TextKind::OnPath { .. } => Affine::IDENTITY,
TextKind::Point => {
let first = cx.style_at(0);
let centre = db.face(&first.font_family, &first.font_style).map_or(EM_CENTER, |f| f.ideographic_centre());
let centre =
db.face_version(&first.font_family, &first.font_style, first.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN
}
_ => QUARTER_TURN,
@@ -1392,7 +1433,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start));
let st = cx.style_at(pr.start);
let (cap, xh) = cap_x_heights(cx.db, st);
let centre = cx.db.face(&st.font_family, &st.font_style).map_or(EM_CENTER, |f| f.ideographic_centre());
let centre =
cx.db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 }
};
if pi > 0 {
+8 -2
View File
@@ -24,14 +24,15 @@ mod suitcase;
pub mod test_fonts;
pub mod thread;
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont};
pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
pub use fontdb::{
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, set_platform_font_files, set_user_font_dirs,
style_weight, system_font_dirs, user_font_dirs,
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{layout, layout_with};
pub use layout::{decorations, layout, layout_with};
pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::{AreaFit, FirstBaseline, VerticalAlign};
@@ -522,4 +523,9 @@ mod tests_typo;
#[cfg(test)]
mod tests_variable;
#[cfg(test)]
mod tests_versions;
#[cfg(test)]
mod tests_vertical;
#[cfg(test)]
#[path = "../build/web_fonts.rs"]
mod web_fonts_build;
+8 -4
View File
@@ -221,7 +221,9 @@ pub(crate) fn is_cjk(c: char) -> bool {
/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading).
pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
let vs = st.v_scale / 100.0;
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()) };
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading());
};
let k = st.size / face.upem;
(face.ascent * k * vs, face.descent * k * vs, st.effective_leading())
}
@@ -229,7 +231,9 @@ pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
/// Cap height and x height (points) of a style's resolved face.
pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) {
let vs = st.v_scale / 100.0;
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.7 * vs, st.size * 0.5 * vs) };
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
return (st.size * 0.7 * vs, st.size * 0.5 * vs);
};
let k = st.size / face.upem * vs;
(face.cap_height * k, face.x_height * k)
}
@@ -291,12 +295,12 @@ pub(crate) fn shape_range(
let a = bytes.start;
let b = bytes.end;
if let Some(art) = art {
if let Some(face) = db.face(&st.font_family, &st.font_style).or_else(|| db.face_covering('a')) {
if let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).or_else(|| db.face_covering('a')) {
out.push(inline_glyph(&face, st, art, source_runs.start, a..b, text, level_at(a)));
}
continue;
}
let Some(primary) = db.face(&st.font_family, &st.font_style) else { continue };
let Some(primary) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { continue };
let pmap = primary.skrifa().map(|f| f.charmap());
// Synthesized Small Caps shape lowercase letters separately (as smaller capitals).
let small_caps = st.small_caps.is_some() && !st.all_caps;
+27
View File
@@ -174,3 +174,30 @@ pub fn vertical_font(vorg: bool) -> Option<Vec<u8>> {
}
Some(builder.build())
}
/// The family of [`twin_font`]: two versions of one family and style.
pub const TWIN_FAMILY: &str = "Twintest Sans";
/// The version strings of [`twin_font`]'s older and newer face.
pub const TWIN_VERSIONS: [&str; 2] = ["Version 1.000", "Version 2.000"];
/// A face of family [`TWIN_FAMILY`], style Regular, in version [`TWIN_VERSIONS`]`[0]` (the bundled
/// Source Sans 3 Regular) or, `newer`, `[1]` (the bundled Inter Regular): one name, two sets of
/// glyphs, as a font library that keeps an old version beside a new one has. `None` when it can't
/// be built.
pub fn twin_font(newer: bool) -> Option<Vec<u8>> {
const OLDER: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
const NEWER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
let font = skrifa::FontRef::new(if newer { NEWER } else { OLDER }).ok()?;
let version = TWIN_VERSIONS[usize::from(newer)];
let name = name_table(&[(1, TWIN_FAMILY), (2, "Regular"), (4, "Twintest Sans Regular"), (5, version), (6, "TwintestSans-Regular")])?;
let mut builder = FontBuilder::new();
builder.add_raw(Tag::new(b"name"), name);
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
+23
View File
@@ -138,6 +138,29 @@ fn tracking_and_scale() {
assert!(b.y1 < -4.0, "shifted up: {b:?}");
}
#[test]
fn underline_and_strikethrough_bars() {
let mut t = point("ab", style(20.0));
t.runs = vec![
TextRun { text: "ab".into(), style: style(20.0), inline: None },
TextRun { text: "cd".into(), style: CharStyle { underline: true, strikethrough: true, ..style(20.0) }, inline: None },
];
let l = layout(db(), &t);
let bars = decorations(&l, db(), &t);
assert_eq!(bars.len(), 2, "one underline and one strikethrough: {bars:?}");
assert!(bars.iter().all(|(run, _)| *run == 1), "only the styled run");
let (under, strike) = (bars[0].1.bounding_box(), bars[1].1.bounding_box());
// The bars span the run's glyphs: from "c" to the end of "d".
let (c, d) = (&l.glyphs[2], &l.glyphs[3]);
assert!((under.x0 - c.origin.x).abs() < 1e-6 && (under.x1 - d.origin.x - d.advance).abs() < 1e-6, "{under:?}");
// y runs down: the underline sits below the baseline, the strikethrough above it, both thin.
assert!(under.y0 > 0.0 && under.y1 < 5.0, "{under:?}");
assert!(strike.y1 < 0.0 && strike.y0 > -10.0, "{strike:?}");
assert!(under.height() < 3.0 && strike.height() < 3.0);
// No bars for plain text.
assert!(decorations(&layout(db(), &point("ab", style(20.0))), db(), &point("ab", style(20.0))).is_empty());
}
#[test]
fn all_caps() {
let a = width(&point("abc", CharStyle { all_caps: true, ..style(10.0) }));
+18
View File
@@ -344,3 +344,21 @@ fn paragraph_direction_is_per_paragraph() {
assert!(l.lines[0].x1.abs() < 1e-6, "Auto aligns the right-to-left paragraph right: {}", l.lines[0].x1);
assert!(l.lines[1].x0.abs() < 1e-6, "…and the left-to-right one left: {}", l.lines[1].x0);
}
#[test]
fn craft_arabic_fonts_load_and_noto_sans_arabic_is_the_arabic_fallback() {
let arabic: Vec<_> = crate::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Arab")).collect();
if arabic.is_empty() {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
return;
}
let db = FontDb::with_font_dirs(vec![]);
for f in &arabic {
assert!(db.has_family(f.family), "{} {} isn't loaded", f.family, f.style);
}
let beh = db.face_covering('ب').expect("some face covers U+0628");
assert_eq!(beh.family, "Noto Sans Arabic");
// Latin still falls back to a bundled face, not to one of the Arabic families.
let a = db.face_covering('a').expect("some face covers 'a'");
assert!(!arabic.iter().any(|f| f.family == a.family), "Latin now falls back to {}", a.family);
}
+36
View File
@@ -19,6 +19,42 @@ fn fs_type_flags_decide_the_embedding() {
assert_eq!(Embedding::from_fs_type(0x0108), Embedding::Whole, "no subsetting");
}
/// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original
/// name) with OS/2 `fsType` set to `fs_type`.
fn licensed_font(family: &str, fs_type: u16) -> Vec<u8> {
let mut data = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").to_vec();
let table = |data: &[u8], tag: &[u8; 4]| {
let f = skrifa::FontRef::new(data).unwrap();
let r = f.table_directory.table_records().iter().find(|r| r.tag().to_be_bytes() == *tag).unwrap();
(r.offset() as usize, r.length() as usize)
};
let (os2, _) = table(&data, b"OS/2");
data[os2 + 8..os2 + 10].copy_from_slice(&fs_type.to_be_bytes());
let (name, len) = table(&data, b"name");
assert_eq!(family.len(), "Source Sans 3".len());
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
let mut i = name;
while let Some(at) = data[i..name + len].windows(from.len()).position(|w| w == from) {
data[i + at..i + at + to.len()].copy_from_slice(&to);
i += at + to.len();
}
}
data
}
/// A font file goes with a document (File → Package) only when exports may embed it: not when
/// its license is restricted or allows bitmaps only.
#[test]
fn fonts_exports_may_not_embed_are_not_embeddable() {
let db = FontDb::with_font_dirs(vec![]);
for (family, fs_type, embeddable) in [("Bitmap Sans 3", 0x0200, false), ("Lowbit Sans 3", 0x0003, false), ("Wholed Sans 3", 0x0100, true)] {
assert!(db.add_font(licensed_font(family, fs_type)) > 0, "{family}");
let face = db.face(family, "Regular").unwrap();
assert_eq!((face.family.as_str(), face.embeddable()), (family, embeddable), "fsType {fs_type:#06x}");
}
}
#[test]
fn a_subset_holds_only_the_glyphs_used_and_maps_their_characters() {
let face = FontDb::global().face("Source Sans 3", "Regular").unwrap();
+16
View File
@@ -104,6 +104,22 @@ fn the_scan_reads_collections_and_loads_fonts_only_when_used() {
assert_eq!(db.find(FAMILY, "Regular").unwrap().face_index(), 0, "the collection's faces load together");
}
#[test]
fn fonts_without_outlines_the_engine_draws_are_left_out() {
let dir = font_dir("outlines");
// The Bold's TrueType outlines under another tag, as in fonts that keep their outlines in
// Apple's `hvgl` table or hold bitmaps only. The tag keeps the table directory sorted.
let bold = dir.join("Sub/Sysfont-Bold.TTF");
let mut data = std::fs::read(&bold).unwrap();
let tables = u16::from_be_bytes([data[4], data[5]]) as usize;
let rec = (0..tables).map(|i| 12 + 16 * i).find(|&r| &data[r..r + 4] == b"glyf").unwrap();
data[rec..rec + 4].copy_from_slice(b"gxyz");
std::fs::write(&bold, data).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
assert_eq!(db.styles(FAMILY), ["Regular"]);
assert_eq!(db.load_system_fonts(), 1);
}
#[test]
fn rescanning_finds_fonts_installed_since() {
let dir = font_dir("rescan");
+20
View File
@@ -415,6 +415,26 @@ fn hyphenated_layout_adds_hyphens() {
}
}
/// Preferences › Hyphenation › Exceptions (#394): listing a word stops the layout from breaking it.
#[test]
fn hyphenation_exceptions_keep_listed_words_whole_in_layout() {
// Same copy as `hyphenated_layout_adds_hyphens` (known to produce soft hyphens at 120pt width).
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
let mut t = area(text, style(14.0), Rect::new(0.0, 0.0, 120.0, 600.0), Justify::Left);
t.para.hyphenate = true;
let with_breaks = layout(db(), &t);
let hyphens = |l: &TextLayout| l.glyphs.iter().filter(|g| g.len == 0).count();
assert!(hyphens(&with_breaks) > 0, "pattern produces soft hyphens");
hyphen::with_hyphenation_exceptions_for_test(
"extraordinarily, comprehensive, typographical, considerations, notwithstanding, everything",
|| {
let blocked = layout(db(), &t);
assert_eq!(hyphens(&blocked), 0, "every long word listed: no soft hyphens");
assert!(blocked.lines.len() >= with_breaks.lines.len());
},
);
}
#[test]
fn soft_hyphen_is_invisible_mid_line() {
let l = layout(db(), &TextObject::point(Point::ZERO, "co\u{00AD}operate", style(20.0)));
+43
View File
@@ -0,0 +1,43 @@
//! Two installed versions of one family and style (a font library keeps an old version beside a
//! new one): both load, the family and style alone keep resolving to the first, and type that names
//! the other version is set in it.
use kurbo::Point;
use vectorcraft_doc::CharStyle;
use super::test_fonts::{TWIN_FAMILY as FAMILY, TWIN_VERSIONS as VERSIONS, twin_font};
use super::*;
/// A database of the bundled fonts, the newer version of the test family loaded first.
fn db() -> FontDb {
let db = FontDb::with_font_dirs(vec![]);
assert_eq!(db.add_font(twin_font(true).expect("the newer test font builds")), 1);
assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 1, "another version is kept");
assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 0, "the same version isn't added twice");
db
}
fn width(db: &FontDb, version: Option<&str>) -> f64 {
let st = CharStyle { font_family: FAMILY.into(), font_version: version.map(Into::into), size: 100.0, ..CharStyle::default() };
layout(db, &TextObject::point(Point::ZERO, "Hamburgefonstiv", st)).glyphs.iter().map(|g| g.advance).sum()
}
#[test]
fn a_version_is_found_by_its_version_string() {
let db = db();
let face = |v: Option<&str>| db.face_version(FAMILY, "Regular", v).expect("a face");
assert_eq!(db.face(FAMILY, "Regular").expect("a face").version, VERSIONS[1], "the first loaded stays the default");
assert_eq!(face(None).version, VERSIONS[1]);
assert_eq!(face(Some(VERSIONS[0])).version, VERSIONS[0]);
assert_eq!(face(Some("Version 9.000")).version, VERSIONS[1], "a version not installed: the default");
let all = db.versions(&face(None));
assert_eq!(all.iter().map(|f| f.version.as_str()).collect::<Vec<_>>(), [VERSIONS[1], VERSIONS[0]]);
}
#[test]
fn type_naming_a_version_is_set_in_it() {
let db = db();
let (newer, older) = (width(&db, None), width(&db, Some(VERSIONS[0])));
assert!((newer - older).abs() > 50.0, "the two versions set the line differently: {newer} vs {older}");
assert_eq!(width(&db, Some(VERSIONS[1])), newer);
}
+36
View File
@@ -0,0 +1,36 @@
# Craft-fonts faces the web (wasm32) build fetches from beside the wasm instead of embedding.
# One face per line: when | family | style (family and style as in craft-fonts' fonts/manifest.txt).
# startup = fetched before the app starts (fallbacks the first frame needs)
# background = fetched after the app starts (fonts users pick by name)
# Read by build.rs (the WEB_FONTS table) and `cargo xtask web-fonts` (copies the files, a Trunk hook).
startup | Noto Sans Arabic | Regular
background | Cairo | Regular
background | Tajawal | ExtraLight
background | Tajawal | Light
background | Tajawal | Regular
background | Tajawal | Medium
background | Tajawal | Bold
background | Tajawal | ExtraBold
background | Tajawal | Black
background | Almarai | Light
background | Almarai | Regular
background | Almarai | Bold
background | Almarai | ExtraBold
background | IBM Plex Sans Arabic | Thin
background | IBM Plex Sans Arabic | ExtraLight
background | IBM Plex Sans Arabic | Light
background | IBM Plex Sans Arabic | Regular
background | IBM Plex Sans Arabic | Medium
background | IBM Plex Sans Arabic | SemiBold
background | IBM Plex Sans Arabic | Bold
background | Noto Kufi Arabic | Regular
background | Amiri | Regular
background | Amiri | Italic
background | Amiri | Bold
background | Amiri | Bold Italic
background | Alexandria | Regular
background | Fustat | Regular
background | Reem Kufi | Regular
background | Aref Ruqaa | Regular
background | Aref Ruqaa | Bold
background | Gulzar | Regular
+1
View File
@@ -20,3 +20,4 @@ serde_json = { workspace = true }
[dev-dependencies]
image = { workspace = true }
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
+10 -4
View File
@@ -59,6 +59,9 @@ struct Preedit {
active: Option<Range<usize>>,
}
/// The edited text's layout, keyed by the font database's generation and the text.
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
#[derive(Default)]
pub struct TypeTool {
vertical: bool,
@@ -86,7 +89,7 @@ pub struct TypeTool {
clicks: (Option<Point>, u8),
/// Styled copy of the last copied range (pasting the same plain text keeps its styles).
clipboard: Vec<TextRun>,
cache: Mutex<Option<(TextObject, Arc<TextLayout>)>>,
cache: Mutex<Option<CachedLayout>>,
}
impl TypeTool {
@@ -106,16 +109,19 @@ impl TypeTool {
}
}
/// Layout of `t`, cached by content (overlays run every frame).
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available (the
/// web build adds fonts after startup).
fn layout(&self, t: &TextObject) -> Arc<TextLayout> {
let fonts = FontDb::global().generation();
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some((k, l)) = c.as_ref()
if let Some(((g, k), l)) = c.as_ref()
&& *g == fonts
&& k == t
{
return l.clone();
}
let l = Arc::new(vectorcraft_text::layout(FontDb::global(), t));
*c = Some((t.clone(), l.clone()));
*c = Some(((fonts, t.clone()), l.clone()));
l
}
+12
View File
@@ -359,3 +359,15 @@ fn new_type_snaps_to_smart_guides() {
let area = json!({"width": 100.0, "height": 100.0});
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 100.0 && v["area"] == area)), "{acts:?}");
}
#[test]
fn layout_cache_follows_fonts_added_later() {
// The web build adds fonts after startup: a caret layout cached before one arrived is stale.
let (d, id, tool) = editing("abc");
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!("not text") };
let before = tool.layout(t);
assert!(Arc::ptr_eq(&before, &tool.layout(t)), "cached while the fonts don't change");
let font = vectorcraft_text::test_fonts::variable_font().unwrap();
assert!(FontDb::global().add_font(font) > 0, "the test font is new to this process");
assert!(!Arc::ptr_eq(&before, &tool.layout(t)), "a font added after the layout must refresh it");
}
+101 -15
View File
@@ -1,24 +1,110 @@
//! The All Tools drawer: every tool as a button; picking one selects it and closes the drawer.
//! The All Tools drawer: every tool with its icon and name, under the headings of the toolbar's
//! groups (Select, Shapes, Draw, Modify, Type, Navigate, Color) and of the tools the Basic toolbar
//! leaves out (#807). The active tool is highlighted; picking one selects it and closes the drawer.
use egui::{Sense, vec2};
use vectorcraft_tools::{TOOL_GROUPS, ToolInfo};
use super::DialogSpec;
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, icons, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: 560.0, ..DialogSpec::FORM };
/// A tool's cell: its icon and name.
const CELL: egui::Vec2 = vec2(176.0, 28.0);
const ICON: f32 = 20.0;
/// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the
/// Advanced toolbar shows.
fn heading(id: &str) -> &'static str {
if let Some((cat, _)) = crate::toolbar::BASIC.iter().find(|(_, slots)| slots.iter().any(|s| s.contains(&id))) {
return cat;
}
match id {
_ if id.starts_with("symbol") => "Symbols",
_ if id.ends_with("Graph") => "Graph",
_ if id.starts_with("perspective") => "Perspective",
"artboard" => "Artboards",
"slice" | "sliceSelection" => "Slices",
_ => "More",
}
}
/// The headings in order, each with its tools in the toolbar's order.
fn sections() -> Vec<(&'static str, Vec<&'static ToolInfo>)> {
let mut out: Vec<(&'static str, Vec<&'static ToolInfo>)> = crate::toolbar::BASIC.iter().map(|(cat, _)| (*cat, vec![])).collect();
for tool in TOOL_GROUPS.iter().flat_map(|g| g.iter()) {
let h = heading(tool.id);
match out.iter_mut().find(|(c, _)| *c == h) {
Some((_, tools)) => tools.push(tool),
None => out.push((h, vec![tool])),
}
}
out.retain(|(_, tools)| !tools.is_empty());
out
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> bool {
let mut picked = false;
for g in vectorcraft_tools::TOOL_GROUPS {
ui.horizontal_wrapped(|ui| {
for tool in g.iter() {
let shown = tl!(tool.label);
let shown = if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown };
if ui.button(shown).clicked() {
app.select_tool(tool.id);
picked = true;
let t = Tokens::get(ui.ctx());
let active = app.session.tool_id();
let mut picked = None;
let room = (ui.ctx().content_rect().height() - 180.0).max(200.0);
egui::ScrollArea::vertical().max_height(room).auto_shrink([false, true]).show(ui, |ui| {
for (cat, tools) in sections() {
widgets::subheader(ui, tl!(cat));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
for tool in tools {
let (rect, resp) = ui.allocate_exact_size(CELL, Sense::click());
let on = tool.id == active;
if on {
ui.painter().rect_filled(rect, 3.0, t.selection);
} else if resp.hovered() {
ui.painter().rect_filled(rect, 3.0, t.hover);
}
let icon = egui::Rect::from_center_size(rect.left_center() + vec2(6.0 + ICON / 2.0, 0.0), vec2(ICON, ICON));
icons::paint(ui, icons::tool_icon(tool.icon), icon, if on { t.text_strong } else { t.icon });
let name = tl!(tool.label);
let name = if crate::i18n::current() == crate::i18n::Lang::EN { name.trim_end_matches(" Tool") } else { name };
let text_at = egui::pos2(icon.right() + 8.0, rect.center().y);
ui.painter().with_clip_rect(rect).text(
text_at,
egui::Align2::LEFT_CENTER,
name,
egui::FontId::proportional(12.0),
if on { t.text_strong } else { t.text },
);
if resp.on_hover_text(crate::toolbar::tip(tool)).clicked() {
picked = Some(tool.id);
}
}
}
});
});
ui.add_space(6.0);
}
});
if let Some(id) = picked {
app.select_tool(id);
}
picked.is_some()
}
#[cfg(test)]
mod tests {
use super::*;
/// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order.
#[test]
fn every_tool_is_listed_once_under_a_heading() {
let s = sections();
let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect();
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
assert_eq!(listed.len(), all.len());
assert!(all.iter().all(|id| listed.contains(id)));
let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect();
assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]);
assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}");
assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}");
}
picked
}
+1
View File
@@ -67,6 +67,7 @@ fn order(kind: &str) -> &'static [&'static str] {
match kind {
"offsetPath" => &["offset", "joins", "miterLimit"],
"splitIntoGrid" => &["rows", "columns", "gutter"],
"artboardOptions" => &["name", "width", "height"],
_ => &[],
}
}
+10 -2
View File
@@ -114,7 +114,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
app.ui.dialog = None;
let n = r["files"].as_array().map_or(0, Vec::len);
let missing = r["missingLinks"].as_array().map_or(0, Vec::len);
let note = if missing > 0 { format!(" ({missing} linked file(s) not found)") } else { String::new() };
// The fonts left out: their license doesn't allow it, or they aren't on this computer.
let fonts = r["skippedFonts"].as_array().into_iter().flatten().filter_map(|f| f["font"].as_str()).collect::<Vec<_>>().join(", ");
let mut note = if missing > 0 { format!(" ({missing} linked file(s) not found)") } else { String::new() };
if !fonts.is_empty() {
note.push_str(&format!(" (fonts not copied: {fonts})"));
}
if web {
let (name, data) = (r["name"].as_str().unwrap_or("package.zip"), r["dataBase64"].as_str().unwrap_or_default());
let bytes = vectorcraft_format::base64_decode(data).ok_or("the package came back damaged")?;
@@ -124,11 +129,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
let folder = r["folder"].as_str().unwrap_or_default().to_string();
app.status(format!("Packaged {n} file(s) in {folder}{note}"));
let shown_note = if missing > 0 {
let mut shown_note = if missing > 0 {
format!(" ({})", crate::i18n::fmt(tl!("{count} linked file(s) not found"), &[("count", &missing.to_string())]))
} else {
String::new()
};
if !fonts.is_empty() {
shown_note.push_str(&format!(" ({})", crate::i18n::fmt(tl!("fonts not copied: {fonts}"), &[("fonts", &fonts)])));
}
let detail = crate::i18n::fmt(
tl!("{count} file(s) in {folder}{note}. Show the package folder?"),
&[("count", &n.to_string()), ("folder", &folder), ("note", &shown_note)],
@@ -2,7 +2,7 @@
//! under Window → Swatch Libraries → User Defined) or a file picked in a save dialog (a download
//! on the web). OK runs `swatch.library.save`.
//!
//! Fields: `name`, `format` (`vcswatches`, `gpl` or `css`), `user` (save to the user library
//! Fields: `name`, `format` (`vcswatches`, `gpl`, `ase` or `css`), `user` (save to the user library
//! folder), `selectedOnly` and `names` (the swatches selected in the Swatches panel), `__user`
//! (there is a user library folder).
//!
@@ -34,12 +34,17 @@ pub fn open(app: &mut VectorcraftApp, names: Vec<String>) -> Result<Value, Strin
/// The fields a Save … Library dialog opens with: the document's name, Save To the user library
/// folder when there is one (`user`), and the panel's selected `names`.
pub(super) fn fields(app: &VectorcraftApp, user: bool, names: Vec<String>) -> Result<Value, String> {
let st = app.session.active().ok_or("no document open")?;
let title = st.title();
let name = title.rsplit_once('.').map_or(title.as_str(), |(s, _)| s).to_string();
let name = document_name(app).ok_or("no document open")?;
Ok(json!({"name": name, "user": user, "__user": user, "selectedOnly": false, "names": names}))
}
/// The active document's name without its extension: the library name the save commands use when
/// none is given.
fn document_name(app: &VectorcraftApp) -> Option<String> {
let title = app.session.active()?.title();
Some(title.rsplit_once('.').map_or(title.as_str(), |(s, _)| s).to_string())
}
fn format_of(d: &Dialog) -> PaletteFormat {
PaletteFormat::parse(&d.str("format")).unwrap_or(PaletteFormat::Native)
}
@@ -62,6 +67,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if format_of(d) == PaletteFormat::Gpl {
widgets::dim_label(ui, tl!("GPL palettes keep solid colours only, as RGB."));
}
if format_of(d) == PaletteFormat::Ase {
widgets::dim_label(ui, tl!("Swatch exchange files keep solid colors only."));
}
false
}
@@ -104,8 +112,10 @@ pub(super) fn params(d: &Dialog) -> Value {
p
}
/// Run save command `cmd` with `p`: into the user library folder, else to a file the user picks
/// (extension `ext`).
/// Run save command `cmd` with `p`: into the user library folder, else to a file the user picks.
/// The save panel suggests the file name that a save into the user library folder uses: the name
/// (the document's when it is empty) with extension `ext`. The web downloads the file under that
/// name.
pub(super) fn save(app: &mut VectorcraftApp, d: &Dialog, cmd: &str, ext: &str, p: Value) -> Result<Value, String> {
if p["user"] == json!(true) {
let r = run_and_close(app, cmd, p)?;
@@ -113,7 +123,9 @@ pub(super) fn save(app: &mut VectorcraftApp, d: &Dialog, cmd: &str, ext: &str, p
return Ok(r);
}
app.ui.dialog = None;
crate::io::save_command_output(app, cmd, ext, p).map(|path| json!({ "path": path }))
let name = Some(d.str("name").trim().to_string()).filter(|n| !n.is_empty()).or_else(|| document_name(app)).unwrap_or_default();
let file = vectorcraft_engine::cmd::swatchlib::library_file_name(&name, ext);
crate::io::save_command_output_named(app, cmd, &file, p).map(|path| json!({ "path": path }))
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
+11
View File
@@ -370,3 +370,14 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() {
let after = bounds(&app);
assert!((after.x0 - before.x0 - 15.0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} → {after:?}");
}
/// #793: Artboard Options lists its fields as Name, Width, Height, not by key.
#[test]
fn artboard_options_lists_name_then_width_and_height() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 612, "height": 792})).unwrap();
open(&mut app, "artboardOptions", json!({"index": 0, "name": "Artboard 1", "width": 612, "height": 792, "x": 0, "y": 0}));
let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx()));
let at = |s: &str| text.find(s).unwrap_or_else(|| panic!("{s} in {text}"));
assert!(at("Name") < at("Width") && at("Width") < at("Height"), "{text}");
}
@@ -106,6 +106,30 @@ fn the_web_downloads_a_zip() {
assert!(got[0].1 > 100);
}
#[test]
fn fonts_left_out_of_the_package_are_named() {
let dir = Folder::new("fonts");
let mut app = saved(&dir);
app.run("text.create", json!({"x": 10, "y": 40, "text": "Kept"})).unwrap();
app.run("text.create", json!({"x": 10, "y": 80, "text": "Gone", "font": "No Such Font Family"})).unwrap();
app.run("ui.packageDialog", json!({})).unwrap();
let out = dir.path("out");
std::fs::create_dir_all(&out).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("folder".into(), json!(out));
let r = confirm(&mut app).unwrap();
assert_eq!(r["skippedFonts"], json!([{"font": "No Such Font Family Regular", "reason": "not available on this computer"}]));
let gone = "fonts not copied: No Such Font Family Regular";
assert!(app.ui.status.contains(gone), "{}", app.ui.status);
let detail = app.ui.dialog.as_ref().unwrap().str("detail");
assert!(detail.contains(gone) && !detail.contains("Source Sans 3"), "{detail}");
// The web's download says so too.
app.ui.dialog = None;
app.services.download = Some(Box::new(|_: &str, _: &[u8]| {}));
app.run("ui.packageDialog", json!({})).unwrap();
confirm(&mut app).unwrap();
assert!(app.ui.status.contains(gone), "{}", app.ui.status);
}
#[test]
fn package_without_a_location_says_so() {
let dir = Folder::new("empty");
+8
View File
@@ -221,6 +221,10 @@
Auto Size New Area Type Ajustar automáticamente el tamaño del nuevo texto de área
Auto Spell Check Revisión ortográfica automática
Auto-Collapse Iconic Panels Contraer automáticamente los paneles de iconos
Ellipse Properties: Propiedades de la elipse:
Invert Pie Invertir sector
Pie End Angle: Ángulo final del sector:
Pie Start Angle: Ángulo inicial del sector:
Show Tool Group Labels Mostrar etiquetas de grupos de herramientas
Auto-Rotate (turn the paper to each artboard) Rotar automáticamente (girar el papel según cada mesa de trabajo)
Automatic flatness Lisura automática
@@ -493,6 +497,7 @@
Copy Fonts Used in the Document Copiar fuentes usadas en el documento
Copy Links Copiar vínculos
Copy Selected Style Copiar estilo seleccionado
Copy to User Defined Copiar en Definido por el usuario
Copy Width Point Copiar punto de anchura
Copyright Copyright
Copyright Info URL: URL de información de copyright:
@@ -2640,6 +2645,7 @@
Swash Decorativa
Swatch Conflict Conflicto de muestras
Swatch Conflicts Conflictos de muestras
Swatch exchange files keep solid colors only. Los archivos de intercambio de muestras solo conservan colores sólidos.
Swatch Libraries Bibliotecas de muestras
Swatch Libraries Menu Menú Bibliotecas de muestras
Swatch Library Biblioteca de muestras
@@ -2990,6 +2996,7 @@
Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando
{count} file(s) in {folder}{note}. Show the package folder? {count} archivo(s) en {folder}{note}. ¿Mostrar la carpeta del paquete?
{count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s)
fonts not copied: {fonts} fuentes no copiadas: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas?
{count} more rules: Export writes every one. {count} reglas más: Exportar las escribe todas.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Se omitieron {count} objetos sin nombre: ponles nombre o activa Objetos sin nombre en el menú del panel.
@@ -3452,6 +3459,7 @@ axis Both Ambos
@msg no symbol instances selected no hay instancias de símbolo seleccionadas
@msg no target object has an opacity mask: select one or give `id` ningún objeto de destino tiene máscara de opacidad: selecciona uno o indica `id`
@msg no threaded text selected no hay texto enlazado seleccionado
@msg no user library folder here aquí no hay ninguna carpeta de bibliotecas de usuario
@msg no user workspace `{from}` no existe el espacio de trabajo de usuario `{from}`
@msg node {_1} is not text el nodo {_1} no es texto
@msg not a DXF drawing no es un dibujo DXF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -222,6 +222,10 @@
Auto Size New Area Type Taille automatique du nouveau texte captif
Auto Spell Check Vérification orthographique automatique
Auto-Collapse Iconic Panels Réduire automatiquement les panneaux en icônes
Ellipse Properties: Propriétés de l’ellipse :
Invert Pie Inverser le secteur
Pie End Angle: Angle de fin du secteur :
Pie Start Angle: Angle de début du secteur :
Show Tool Group Labels Afficher les libellés des groupes d'outils
Auto-Rotate (turn the paper to each artboard) Rotation automatique (orienter le papier selon chaque plan de travail)
Automatic flatness Planéité automatique
@@ -494,6 +498,7 @@
Copy Fonts Used in the Document Copier les polices utilisées dans le document
Copy Links Copier les liens
Copy Selected Style Copier le style sélectionné
Copy to User Defined Copier dans Défini par l’utilisateur
Copy Width Point Copier le point de largeur
Copyright Copyright
Copyright Info URL: URL des informations de copyright :
@@ -2637,6 +2642,7 @@
Swash Lettres ornées
Swatch Conflict Conflit de nuances
Swatch Conflicts Conflits de nuances
Swatch exchange files keep solid colors only. Les fichiers d’échange de nuances ne conservent que les couleurs unies.
Swatch Libraries Bibliothèques de nuances
Swatch Libraries Menu Menu Bibliothèques de nuances
Swatch Library Bibliothèque de nuances
@@ -2987,6 +2993,7 @@
Scrub Numeric Fields by Dragging Modifier les champs numériques en faisant glisser
{count} file(s) in {folder}{note}. Show the package folder? {count} fichier(s) dans {folder}{note}. Afficher le dossier de l’assemblage ?
{count} linked file(s) not found {count} fichier(s) lié(s) introuvable(s)
fonts not copied: {fonts} polices non copiées : {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? {count} image(s) liée(s) modifiée(s) depuis l’enregistrement de ce document. Afficher les nouvelles versions ?
{count} more rules: Export writes every one. {count} règles de plus : Exporter les écrit toutes.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objets sans nom omis : nommez-les ou activez Objets sans nom dans le menu du panneau.
@@ -3449,6 +3456,7 @@ axis Both Les deux
@msg no symbol instances selected aucune occurrence de symbole sélectionnée
@msg no target object has an opacity mask: select one or give `id` aucun objet ciblé n’a de masque d’opacité : sélectionnez-en un ou indiquez `id`
@msg no threaded text selected aucun texte lié sélectionné
@msg no user library folder here aucun dossier de bibliothèques utilisateur ici
@msg no user workspace `{from}` aucun espace de travail utilisateur `{from}`
@msg node {_1} is not text le nœud {_1} n’est pas du texte
@msg not a DXF drawing ce n’est pas un dessin DXF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -221,6 +221,10 @@
Auto Size New Area Type Dimensionamento automatico del nuovo testo in area
Auto Spell Check Controllo ortografico automatico
Auto-Collapse Iconic Panels Comprimi automaticamente i pannelli a icona
Ellipse Properties: Proprietà dell’ellisse:
Invert Pie Inverti settore
Pie End Angle: Angolo finale del settore:
Pie Start Angle: Angolo iniziale del settore:
Show Tool Group Labels Mostra etichette dei gruppi di strumenti
Auto-Rotate (turn the paper to each artboard) Rotazione automatica (orienta la carta in base a ogni tavola da disegno)
Automatic flatness Planarità automatica
@@ -493,6 +497,7 @@
Copy Fonts Used in the Document Copia i font usati nel documento
Copy Links Copia collegamenti
Copy Selected Style Copia stile selezionato
Copy to User Defined Copia in Definito dall'utente
Copy Width Point Copia punto di larghezza
Copyright Copyright
Copyright Info URL: URL informazioni copyright:
@@ -2640,6 +2645,7 @@
Swash Ornamentale
Swatch Conflict Conflitto campioni
Swatch Conflicts Conflitti campioni
Swatch exchange files keep solid colors only. I file di scambio campioni conservano solo i colori pieni.
Swatch Libraries Librerie campioni
Swatch Libraries Menu Menu Librerie campioni
Swatch Library Libreria campioni
@@ -2990,6 +2996,7 @@
Scrub Numeric Fields by Dragging Regola i campi numerici trascinando
{count} file(s) in {folder}{note}. Show the package folder? {count} file in {folder}{note}. Vuoi mostrare la cartella del pacchetto?
{count} linked file(s) not found File collegati non trovati: {count}
fonts not copied: {fonts} font non copiati: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? Immagini collegate modificate dopo il salvataggio di questo documento: {count}. Vuoi mostrare le nuove versioni?
{count} more rules: Export writes every one. Altre {count} regole: Esporta le scrive tutte.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} oggetti senza nome esclusi: assegna loro un nome o attiva Oggetti senza nome nel menu del pannello.
@@ -3452,6 +3459,7 @@ axis Both Entrambi
@msg no symbol instances selected nessuna istanza di simbolo selezionata
@msg no target object has an opacity mask: select one or give `id` nessun oggetto di destinazione ha una maschera di opacità: selezionane uno o specifica `id`
@msg no threaded text selected nessun testo concatenato selezionato
@msg no user library folder here nessuna cartella di librerie utente qui
@msg no user workspace `{from}` nessuna area di lavoro utente `{from}`
@msg node {_1} is not text il nodo {_1} non è testo
@msg not a DXF drawing non è un disegno DXF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+573 -1
View File
@@ -6,7 +6,8 @@
# Japanese localisation was copied. Toggles read as their action (「…を表示」「…を隠す」).
#
# Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`,
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
# tested) — and the status and error messages (`@msg` rows at the end, `COMPLETE_MESSAGES`, tested).
# Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
About VectorCraft VectorCraft について
All Scripts すべての文字
@@ -344,9 +345,13 @@
Appearance Attribute アピアランス属性
Apply 適用
Blending Mode 描画モード
Ellipse Properties: 楕円形のプロパティ:
Fill & Stroke 塗りと線
Fill Color カラー (塗り)
Invert Pie 扇形を反転
Opacity 不透明度
Pie End Angle: 扇形の終了角度:
Pie Start Angle: 扇形の開始角度:
Stroke Color カラー (線)
Stroke Weight 線幅
Graphic Style グラフィックスタイル
@@ -977,6 +982,7 @@
Copy All Styles すべてのスタイルをコピー
Copy Color Value (Hex) カラー値をコピー (16 進)
Copy Selected Style 選択したスタイルをコピー
Copy to User Defined ユーザー定義にコピー
Copy Width Point 幅ポイントをコピー
Copyright 著作権
Copyright Info URL: 著作権情報の URL:
@@ -2417,6 +2423,7 @@
Swash スワッシュ
Swatch Conflict スウォッチの競合
Swatch Conflicts スウォッチの競合
Swatch exchange files keep solid colors only. スウォッチ交換ファイルにはベタ塗りのカラーのみが保存されます。
Swatch Library スウォッチライブラリ
Swatch Name: スウォッチ名:
Swatch Options スウォッチオプション
@@ -2667,6 +2674,7 @@
to →
{count} file(s) in {folder}{note}. Show the package folder? {folder} に {count} 個のファイル{note}。パッケージフォルダーを表示しますか?
{count} linked file(s) not found リンクファイルが {count} 個見つかりません
fonts not copied: {fonts} コピーされなかったフォント:{fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? 保存後に {count} 個のリンク画像が変更されています。新しいバージョンを表示しますか?
{count} more rules: Export writes every one. ほかに {count} 個のルールがあります。書き出しにはすべて含まれます。
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 名前のないオブジェクト {count} 個を除外しました。名前を付けるか、パネルメニューで「名前のないオブジェクト」をオンにしてください。
@@ -3153,3 +3161,567 @@ theme Appearance 外観
Draft ドラフト
Good 標準
Best 高品質
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
# 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.
# Japanese: no final 。 (these are status lines, and reasons that follow a colon), unless the English ends
# with a period; a space between Japanese text and an adjacent Latin word, number or {placeholder}.
@msg Reset {_1} {_1} をリセットしました
@msg Saving 保存中
@msg Saving {_1} {_1} を保存中
@msg Sent to {_1} {_1} に送信しました
@msg `to` must be "corner" or "smooth" `to` には "corner" または "smooth" を指定してください
@msg a layer couldn't be read 読み込めなかったレイヤーがあります
@msg it has more than one artboard アートボードが複数あります
@msg its editing data has no layers 編集データにレイヤーがありません
@msg its editing data is damaged 編集データが破損しています
@msg {_1} files {_1} 個のファイル
@msg A browser with WebGPU or WebGL2 is required. WebGPU または WebGL2 に対応したブラウザーが必要です。
@msg Loading VectorCraft… VectorCraft を読み込んでいます…
@msg Reload the page to go on (changes not saved yet may be lost). 続けるにはページを再読み込みしてください (未保存の変更は失われる場合があります)。
@msg The graphics were lost and couldn't be restarted. グラフィックス機能が利用できなくなり、再起動できませんでした。
@msg The graphics were lost and have been restarted グラフィックス機能が利用できなくなったため、再起動しました
@msg VectorCraft failed to start: {e} VectorCraft を起動できませんでした: {e}
@msg Your unsaved changes are kept in this browser: reload the page to get them back. 未保存の変更はこのブラウザーに残っています: 復元するにはページを再読み込みしてください。
@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} 最初に試したグラフィックスプロセッサーではウィンドウを表示できなかったため、{summary} で VectorCraft を再起動しました
@msg not a DXF drawing DXF 図面ではありません
@msg scale must be a number from {_1} to {_2}, not {s} 縮尺は {_1} から {_2} までの数値にしてください ({s} は指定できません)
@msg the artboard to fit must be a positive size up to {_1} pt 合わせる先のアートボードのサイズは 0 より大きく {_1} pt 以下にしてください
@msg the drawing has no layout `{_1}` (it has {_2}) 図面にレイアウト `{_1}` はありません (含まれるレイアウト: {_2})
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that DWG 図面は読み込めません: CAD アプリで ASCII DXF として保存し、そのファイルを開いてください
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard この縮尺では図面が {w} × {h} pt になり、カンバスに収まりません: 縮尺を小さくするか、アートボードに合わせてください
@msg the scale is not a usable number 縮尺が有効な数値ではありません
@msg the drawing is too large to open 図面が大きすぎて開けません
@msg scale must be a number from {_1} to {_2}, not {_3} 縮尺は {_1} から {_2} までの数値にしてください ({_3} は指定できません)
@msg image '{key}' has no pixels and was left out 画像「{key}」はピクセルがないため省略しました
@msg Embedded {n} image(s) 画像を {n} 枚埋め込みました
@msg object {0} cannot have children オブジェクト {0} には子オブジェクトを含められません
@msg range `{s}` is empty 範囲 `{s}` が空です
@msg range `{s}`: `{part}` names no number 範囲 `{s}`: `{part}` に番号が指定されていません
@msg range `{s}`: `{part}` runs backwards 範囲 `{s}`: `{part}` が逆順になっています
@msg range `{s}`: `{t}` is not a number from 1 to {count} 範囲 `{s}`: `{t}` は 1 から {count} までの数値ではありません
@msg not a graphic style library (format `{_1}`) グラフィックスタイルライブラリではありません (形式: `{_1}`)
@msg Show All Hidden Attributes: nothing is hidden 非表示の属性をすべて表示: 非表示の属性はありません
@msg Expand: no brush strokes selected 拡張: ブラシストロークが選択されていません
@msg no brush named `{name}` `{name}` という名前のブラシが見つかりません
@msg no symbol instances selected シンボルインスタンスが選択されていません
@msg select a symbol instance シンボルインスタンスを選択してください
@msg the art has no bounds アートに境界がありません
@msg Image Trace: the traced image is missing 画像トレース: トレース元の画像が見つかりません
@msg Live Paint Merge: select a Live Paint group and the paths to add ライブペイントの結合: ライブペイントグループと、追加するパスを選択してください
@msg Live Paint: select paths or compound paths ライブペイント: パスまたは複合パスを選択してください
@msg Live Paint: these paths are too degenerate to paint ライブペイント: これらのパスは形状がつぶれすぎていてペイントできません
@msg Shape Builder: no region under the pointer シェイプ形成ツール: ポインターの下に領域がありません
@msg Shape Builder: select paths or compound paths シェイプ形成ツール: パスまたは複合パスを選択してください
@msg image data `{_1}` is missing 画像データ `{_1}` が見つかりません
@msg no Live Paint edge near that point その位置の近くにライブペイントのエッジがありません
@msg no Live Paint face at that point その位置にライブペイントの面がありません
@msg select a Live Paint group ライブペイントグループを選択してください
@msg select an Image Trace object 画像トレースオブジェクトを選択してください
@msg select objects, not layers レイヤーではなくオブジェクトを選択してください
@msg Blend colors: select at least three objects with filled colours カラーのブレンド: カラーの塗りがあるオブジェクトを 3 つ以上選択してください
@msg no saved themes 保存したテーマはありません
@msg `{keep}` is not a side of this axis `{keep}` はこの軸のどちら側でもありません
@msg unknown side `{other}` (left|right|top|bottom) `{other}` は不明な側の指定です (left|right|top|bottom)
@msg an object in perspective would leave the edited grid 遠近グリッド上のオブジェクトが編集後のグリッドの外に出てしまいます
@msg nothing to warp ワープするものがありません
@msg select an object in perspective 遠近グリッド上のオブジェクトを選択してください
@msg select the artwork to warp ワープするアートワークを選択してください
@msg select the objects to transform in perspective 遠近グリッド上で変形するオブジェクトを選択してください
@msg select type in perspective 遠近グリッド上のテキストを選択してください
@msg the object has no geometry オブジェクトに形状がありません
@msg the object has no stroke オブジェクトに線がありません
@msg the object is beyond the plane's horizon オブジェクトが面の水平線を越えた位置にあります
@msg the object isn't in perspective オブジェクトが遠近グリッド上にありません
@msg the object isn't on a perspective plane オブジェクトが遠近グリッドの面上にありません
@msg the object would cross the horizon of the perspective plane オブジェクトが遠近グリッドの面の水平線をまたいでしまいます
@msg the objects would leave the grid オブジェクトがグリッドの外に出てしまいます
@msg the pointer is beyond the plane's horizon ポインターが面の水平線を越えた位置にあります
@msg the selection isn't in perspective 選択範囲が遠近グリッド上にありません
@msg the stroke has no weight 線に線幅がありません
@msg {command} didn't create an object {command} でオブジェクトが作成されませんでした
@msg Make Guides: select paths ガイドを作成: パスを選択してください
@msg Release Guides: no guides ガイドを解除: ガイドがありません
@msg guides are locked ガイドがロックされています
@msg no saved selections 保存した選択範囲はありません
@msg no such artboard 指定されたアートボードが見つかりません
@msg cannot split an open path at its end point オープンパスは端点で分割できません
@msg empty brush stroke ブラシストロークが空です
@msg no such anchor 指定されたアンカーポイントが見つかりません
@msg no such segment 指定されたセグメントが見つかりません
@msg no such subpath 指定されたサブパスが見つかりません
@msg object {id} is not a path オブジェクト {id} はパスではありません
@msg nothing to redo やり直す操作がありません
@msg nothing to undo 取り消す操作がありません
@msg Expand Appearance: the selection has a basic appearance アピアランスを分割: 選択範囲は基本アピアランスです
@msg select (or target) objects whose appearance isn't basic 基本アピアランスではないオブジェクトを選択するか、ターゲットに指定してください
@msg {C}: no effect at index {from} {C}: インデックス {from} に効果がありません
@msg Expand: no gradient fills 拡張: グラデーションの塗りがありません
@msg Expand: nothing to expand 拡張: 拡張するものがありません
@msg batch step {_1} (`{id}`) failed: {e} バッチのステップ {_1} (`{id}`) が失敗しました: {e}
@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP ファイルが対応するのは RGB とグレースケールのみです: colorModel には rgb または gray を指定してください
@msg Targa files are RGB: colorModel rgb Targa ファイルが対応するのは RGB のみです: colorModel には rgb を指定してください
@msg antiAlias `{id}`: none, art or type antiAlias に `{id}` は指定できません: none、art、type のいずれかを指定してください
@msg background {other}: transparent, white, black or a colour such as "#ff8800" 背景に {other} は指定できません: transparent、white、black、または「#ff8800」のようなカラーを指定してください
@msg no artboard {i} (0-based; the document has {count}) アートボード {i} はありません (番号は 0 始まり、このドキュメントには {count} 個あります)
@msg no artboards named アートボードが指定されていません
@msg the document has no artboard ドキュメントにアートボードがありません
@msg this format holds one artboard: export several as PDF or with document.exportForScreens この形式で書き出せるアートボードは 1 つだけです: 複数のアートボードは PDF として書き出すか、document.exportForScreens を使用してください
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) このファイルは PDF 互換性なしで保存されており、編集データを読み込めません ({why})
@msg the export wrote no file 書き出しでファイルが作成されませんでした
@msg {_1} files can be opened but not written (see document.formats) {_1} ファイルは開けますが、保存や書き出しはできません (document.formats を参照してください)
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: この環境にはファイルシステムがありません (開くには dataBase64 を渡し、dataBase64 を受け取るにはパスを省略してください)
@msg Save writes {_1}, not {_2}: use document.export for other formats 保存できる形式は {_1} のみで、{_2} 形式では保存できません: ほかの形式には document.export を使用してください
@msg no changes since the last save 前回の保存以降、変更はありません
@msg unknown format `{f}` (Save writes {_1}) `{f}` は不明な形式です (保存できる形式: {_1})
@msg `svg` must be an object of SVG options (see document.formats) `svg` には SVG オプションのオブジェクトを指定してください (document.formats を参照)
@msg no end record 終端レコードがありません
@msg not a stored central record 無圧縮のセントラルディレクトリレコードではありません
@msg truncated データが途中で切れています
@msg {name}: CRC mismatch {name}: CRC が一致しません
@msg {name}: no local header {name}: ローカルヘッダーがありません
@msg Flatten Transparency: select objects to flatten 透明部分を分割・統合: 分割・統合するオブジェクトを選択してください
@msg Flatten Transparency: {e} 透明部分を分割・統合: {e}
@msg balance must be between 0 and 100 バランスは 0 から 100 までの値にしてください
@msg resolutions must be between 1 and 2400 ppi 解像度は 1 から 2400 ppi までの値にしてください
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) `{name}` は不明なプリセットです (high、medium、low、または保存したプリセット: flattener.presets.list を参照してください)
@msg `at` must be [x, y] `at` は [x, y] の形式で指定してください
@msg `color` must be a colour `color` にはカラーを指定してください
@msg `freeform.points` must be an array `freeform.points` には配列を指定してください
@msg `{key}` must be a number `{key}` には数値を指定してください
@msg a freeform gradient keeps at least one point フリーフォームグラデーションには少なくとも 1 つのポイントが必要です
@msg freeform point {i} needs `at` [x, y] and a valid `color` フリーフォームのポイント {i} には `at` [x, y] と有効な `color` が必要です
@msg no point {i} (the gradient has {_1}) ポイント {i} はありません (このグラデーションのポイントは {_1} 個です)
@msg no segment {segment} on line {line} ライン {line} にセグメント {segment} はありません
@msg unknown freeform mode `{m}` (points, lines) `{m}` は不明なフリーフォームのモードです (points、lines)
@msg `aspect` must be a number (%) `aspect` には数値 (%) を指定してください
@msg `gradient` must be an object `gradient` にはオブジェクトを指定してください
@msg `mode` applies to freeform gradients `mode` はフリーフォームグラデーションにのみ適用されます
@msg `stops` must be an array `stops` には配列を指定してください
@msg `{k}` must be [x, y] `{k}` は [x, y] の形式で指定してください
@msg a gradient needs at least two stops グラデーションには 2 つ以上の分岐点が必要です
@msg give both `start` and `end` [x, y] (or neither) `start` と `end` は両方とも [x, y] で指定するか、どちらも省略してください
@msg stop {i} needs `offset` 分岐点 {i} には `offset` が必要です
@msg stop {i} needs a valid `color` 分岐点 {i} には有効な `color` が必要です
@msg stop {i}: `{k}` must be a number 分岐点 {i}: `{k}` には数値を指定してください
@msg unknown gradient kind `{k}` (linear, radial, freeform) `{k}` は不明なグラデーションの種類です (linear、radial、freeform)
@msg the art could not be sampled アートをサンプリングできませんでした
@msg the art is too light for any dots アートが明るすぎて網点を生成できません
@msg the document has no symbols ドキュメントにシンボルがありません
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` はカラーではありません (#rrggbb またはプリセット名で指定してください)
@msg a colour index is 0 to {_1}, not {n} カラーのインデックスは 0 から {_1} までにしてください ({n} は指定できません)
@msg a colour is an index, #rrggbb or a preset name, not {v} カラーはインデックス、#rrggbb、プリセット名のいずれかで指定してください ({v} は指定できません)
@msg a document needs at least one layer ドキュメントには少なくとも 1 つのレイヤーが必要です
@msg no artwork アートワークがありません
@msg no selection bounds 選択範囲に境界がありません
@msg the layer has no objects to clip by レイヤーにクリッピングマスクとして使えるオブジェクトがありません
@msg the layer or group is locked レイヤーまたはグループがロックされています
@msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています
@msg there are no objects to release レイヤーに分配するオブジェクトがありません
@msg no library `{id}` ライブラリ `{id}` が見つかりません
@msg {path}: {e} {path}: {e}
@msg can't write .{ext} images .{ext} 画像は書き出せません
@msg Replace Spine: select a blend and a path ブレンド軸を置き換え: ブレンドとパスを選択してください
@msg nothing to wrap まとめるものがありません
@msg select a blend ブレンドを選択してください
@msg select a gradient mesh グラデーションメッシュを選択してください
@msg the object has no opacity mask オブジェクトに不透明マスクがありません
@msg Convert to Artboards: selection has no area アートボードに変換: 選択範囲に面積がありません
@msg Convert to Shape: no rectangles or ellipses selected シェイプに変換: 長方形または楕円形が選択されていません
@msg image would be too large; lower the resolution 画像が大きくなりすぎるため、解像度を下げてください
@msg clipboard is empty クリップボードが空です
@msg no anchor points selected アンカーポイントが選択されていません
@msg select at least two objects オブジェクトを 2 つ以上選択してください
@msg no saved presets 保存したプリセットはありません
@msg compound paths can only contain paths 複合パスに含められるのはパスだけです
@msg no bounds 境界がありません
@msg no current layer 現在のレイヤーがありません
@msg no key object キーオブジェクトが設定されていません
@msg no previous transform 前回の変形がありません
@msg nothing to align 整列するものがありません
@msg select a clip group クリップグループを選択してください
@msg select two or more objects to distribute 分布させるオブジェクトを 2 つ以上選択してください
@msg selection has no bounds 選択範囲に境界がありません
@msg the top object must be a path, compound path or text object to use as a clipping mask クリッピングマスクとして使うには、最前面のオブジェクトがパス、複合パス、テキストオブジェクトのいずれかである必要があります
@msg no target object has an opacity mask: select one or give `id` 不透明マスクのあるターゲットオブジェクトがありません: 該当するオブジェクトを選択するか、`id` を指定してください
@msg the object already has an opacity mask オブジェクトにはすでに不透明マスクがあります
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` はプロセスカラーです: 濃淡を適用できるのはグローバルカラーと特色だけです
@msg `{name}` isn't a colour swatch `{name}` はカラースウォッチではありません
@msg a document needs at least one artboard ドキュメントには少なくとも 1 つのアートボードが必要です
@msg empty path パスが空です
@msg join needs open paths 連結にはオープンパスが必要です
@msg path is already closed パスはすでに閉じています
@msg select paths to join 連結するパスを選択してください
@msg direct-select anchor points to cut at (not the end points of open paths) ダイレクト選択ツールで、カットする位置のアンカーポイントを選択してください (オープンパスの端点を除く)
@msg select paths or anchor points パスまたはアンカーポイントを選択してください
@msg Divide Objects Below: select a path 背面のオブジェクトで型抜き: パスを選択してください
@msg Divide Objects Below: select exactly one path 背面のオブジェクトで型抜き: パスを 1 つだけ選択してください
@msg Offset Path: select paths パスのオフセット: パスを選択してください
@msg Outline Stroke: no stroked paths selected パスのアウトライン: 線のあるパスが選択されていません
@msg Split Into Grid: select paths large enough for the grid グリッドに分割: グリッドが収まる大きさのパスを選択してください
@msg empty cutter 型抜きに使うパスが空です
@msg paths contain invalid coordinates パスに無効な座標が含まれています
@msg {label} produced an empty result {label} の結果が空になりました
@msg {label}: lost track of a shape's style {label}: シェイプのスタイル情報が失われました
@msg {label}: select paths {label}: パスを選択してください
@msg {label}: select paths, compound paths, groups or text {label}: パス、複合パス、グループ、テキストのいずれかを選択してください
@msg already editing a pattern すでにパターンを編集中です
@msg no pattern `{name}` パターン `{name}` が見つかりません
@msg not in pattern editing mode パターン編集モードではありません
@msg pattern missing パターンが見つかりません
@msg select a repeat リピートを選択してください
@msg select the art to repeat リピートするアートを選択してください
@msg the document has no patterns ドキュメントにパターンがありません
@msg no artboard アートボードがありません
@msg installing from a path is not available on the web; pass the module as `dataBase64` Web 版ではファイルパスからはインストールできないため、モジュールは `dataBase64` で渡してください
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` は `auto` か、`en` や `zh-hant` などの言語コードにしてください
@msg `{key}` must be a #rrggbb colour `{key}` は #rrggbb 形式のカラーにしてください
@msg `{key}` must be a string `{key}` は文字列にしてください
@msg `{key}` must be a whole number `{key}` は整数にしてください
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` は {min} から {max} までの整数にしてください (指定値: {x})
@msg `{key}` must be between {min} and {max} (got {x}) `{key}` は {min} から {max} までの値にしてください (指定値: {x})
@msg `{key}` must be one of {_1} `{key}` は {_1} のいずれかにしてください
@msg unknown preference `{key}` `{key}` は不明な環境設定の項目です
@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) データ復帰のコピーを保存する場所がありません: 環境設定 → ファイル管理 → データ復帰 → フォルダー (recoveryFolder) を設定してください
@msg `{area}` is no recovery area name `{area}` は復帰用領域の名前として使えません
@msg `{name}` is no recovery entry name `{name}` は復帰用エントリの名前として使えません
@msg no clock to keep a heartbeat by ハートビートの記録に必要な時刻を取得できません
@msg no free recovery area 空いている復帰用領域がありません
@msg no native format ネイティブ形式がありません
@msg nothing encoded 何もエンコードされませんでした
@msg recovery copy `{file}`: {e} 復帰用コピー `{file}` を読み込めませんでした: {e}
@msg {name}: no such recovery entry {name}: 復帰用エントリが見つかりません
@msg Combine Slices: the slices have no bounds スライスを結合: スライスに境界がありません
@msg Create from Guides: the document has no artboard ガイドから作成: ドキュメントにアートボードがありません
@msg Create from Guides: the guides make more than {MAX_PIECES} slices ガイドから作成: ガイドで区切るとスライスが {MAX_PIECES} 個を超えます
@msg Create from Selection: the selection has no area 選択範囲から作成: 選択範囲に面積がありません
@msg Release: no layer to put the rectangle in 解除: 長方形を追加するレイヤーがありません
@msg a slice size must be a positive number of points スライスのサイズはポイント単位の正の数にしてください
@msg divide into 1 to {MAX_PIECES} slices スライスの分割数は 1 から {MAX_PIECES} までにしてください
@msg no slice selected スライスが選択されていません
@msg the document has no slices ドキュメントにスライスがありません
@msg no saved profiles 保存したプロファイルはありません
@msg select a stroke with a variable width 可変幅の線を選択してください
@msg the selected stroke's profile is already listed as {_1} 選択した線のプロファイルは、すでに {_1} として登録されています
@msg a graphic style named `{to}` already exists `{to}` という名前のグラフィックスタイルはすでに存在します
@msg no graphic style `{name}` グラフィックスタイル `{name}` が見つかりません
@msg node {_1} is not text ノード {_1} はテキストではありません
@msg Text Wrap: select a wrap object テキストの回り込み: 回り込みオブジェクトを選択してください
@msg layers can't be wrap objects レイヤーは回り込みオブジェクトにできません
@msg no threaded text selected スレッドテキストが選択されていません
@msg Create Outlines: select text objects アウトラインを作成: テキストオブジェクトを選択してください
@msg paste unavailable 貼り付けを使用できません
@msg Save for Web: the image is empty Web 用に保存: 画像が空です
@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` は「#ff8800」のような形式のカラーではありません
@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: 指定できるカラーは {MAX_TABLE} 色までです
@msg no artboard {i} (0-based; the document has {_1}) アートボード {i} はありません (番号は 0 始まり、このドキュメントには {_1} 個あります)
@msg no selected slice in the image: select slices, or save all of them 画像内に選択されたスライスがありません: スライスを選択するか、すべてのスライスを保存してください
@msg nothing to save: the document has no visible art 保存するものがありません: ドキュメントに表示されているアートがありません
@msg percent must be above 0 and at most 6400, not {p} パーセントは 0 より大きく 6400 以下にしてください ({p} は指定できません)
@msg unknown preset `{name}` (see webExport.presets.list) `{name}` は不明なプリセットです (webExport.presets.list を参照してください)
@msg width and height are at least 1 pixel 幅と高さは 1 ピクセル以上にしてください
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` は 1、3、5 のいずれかにしてください
@msg `{path}` must be a bool or an object of bools `{path}` はブール値、またはブール値からなるオブジェクトにしてください
@msg cannot distort a zero-size bounding box サイズが 0 のバウンディングボックスは変形できません
@msg nothing to distort 変形するものがありません
@msg unknown option `{path}.{k}` `{path}.{k}` は不明なオプションです
@msg command `{0}` is not available right now: {1} コマンド `{0}` は現在使用できません: {1}
@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` で内部エラーが発生しました: {msg} (ドキュメントは変更されていません。このバグを報告してください)
@msg invalid parameters for `{cmd}`: {msg} `{cmd}` のパラメーターが正しくありません: {msg}
@msg no active document アクティブなドキュメントがありません
@msg no interaction in progress 進行中の操作がありません
@msg no such object {0} オブジェクト {0} が見つかりません
@msg result would exceed the canvas (coordinates out of range) 結果がカンバスの範囲を超えます (座標が範囲外です)
@msg unknown command `{0}` `{0}` は不明なコマンドです
@msg `{what}` closes nothing `{what}` に対応する開始部分がありません
@msg `{what}` closes something it didn't open `{what}` が対応しない開始部分を閉じています
@msg a path has too many points ポイントが多すぎるパスがあります
@msg it ends inside a layer レイヤーの途中で終わっています
@msg it has art outside its layers レイヤーの外にアートがあります
@msg it has more than {MAX_NODES} objects オブジェクトが {MAX_NODES} 個を超えています
@msg it has no layers レイヤーがありません
@msg it has too many values in a row 連続する値が多すぎます
@msg it has {_1}, which VectorCraft doesn't read from it yet {_1} が含まれていますが、VectorCraft はまだこのファイルからは読み込めません
@msg its objects are nested too deeply オブジェクトの入れ子が深すぎます
@msg its section ending with `%{end}` doesn't end `%{end}` で終わるはずのセクションが終わっていません
@msg an image VectorCraft can't read was left out VectorCraft で読み込めない画像は除外されました
@msg an image whose data is cut short was left out データが途中で切れている画像は除外されました
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it グラデーション「{name}」はファイル内で定義されていません: 代わりに単色を使用しています
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません
@msg patterns nested too deeply are filled with mid-grey 入れ子が深すぎるパターンは中間のグレーで塗りつぶされています
@msg shadings of an unknown type were left out 種類が不明なシェーディングは除外されました
@msg shadings whose functions are of an unknown type are filled with their middle colour 関数の種類が不明なシェーディングは中間の色で塗りつぶされています
@msg the file's preview header points outside the file ファイルのプレビューヘッダーがファイルの範囲外を指しています
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({_1}): 作成したアプリで PDF または SVG として保存してください
@msg this is not a PostScript file PostScript ファイルではありません
@msg a PostScript error ({name} in `{op}`) PostScript エラー (`{op}` での {name})
@msg a PostScript error ({name} in `{op}`), in {procs} {procs} 内の PostScript エラー (`{op}` での {name})
@msg a PostScript error ({name} in `{op}`: {at}) PostScript エラー (`{op}` での {name}: {at})
@msg a PostScript error ({name} in `{op}`: {at}), in {procs} {procs} 内の PostScript エラー (`{op}` での {name}: {at})
@msg a PostScript error ({name}) PostScript エラー ({name})
@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know VectorCraft の PostScript 読み込み機能が対応していない `{at}` を使用しています
@msg the file was read up to an error ({why}): what follows it was left out ファイルはエラーが発生した箇所まで読み込まれました ({why}): それ以降の内容は除外されました
@msg this file's PostScript couldn't be read ({why}): {what} このファイルの PostScript を読み込めませんでした ({why}): {what}
@msg it has no page ページがありません
@msg it has too many text objects テキストオブジェクトが多すぎます
@msg it has too much text テキストが多すぎます
@msg its editing data doesn't say where its art is 編集データにアートの位置が記録されていません
@msg its editing data doesn't say where its artboard is 編集データにアートボードの位置が記録されていません
@msg its editing data is for a different page 編集データが別のページのものです
@msg its page has text, and its layers have no text objects to put it in ページにテキストがありますが、レイヤーにはそれを入れるテキストオブジェクトがありません
@msg the art on them differs from the page's in {_1}% of it レイヤー上のアートの {_1}% がページのアートと異なります
@msg its Windows metafile preview was opened instead, which may simplify the art 代わりに Windows メタファイルのプレビューを開いたため、アートが簡略化されている場合があります
@msg its preview was placed instead, at screen resolution 代わりにプレビューを画面解像度で配置しました
@msg shadings of more than 4096 patches or triangles were left out パッチまたは三角形が 4096 個を超えるシェーディングは除外されました
@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} {procs} 内で、VectorCraft の PostScript 読み込み機能が対応していない `{at}` を使用しています
@msg the export region is not finite 書き出し範囲に無効な数値が含まれています
@msg a page out of range 範囲外のページがあります
@msg a page's placement is not finite ページの配置に無効な数値が含まれています
@msg nothing to print プリントするものがありません
@msg page size {w} × {h} ページサイズ {w} × {h} は無効です
@msg the preview image is empty プレビュー画像が空です
@msg the thumbnail image is malformed サムネール画像の形式が正しくありません
@msg can't encode the document: {0} ドキュメントをエンコードできません: {0}
@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} バージョン {0} では保存できません: VectorCraft で保存できるのはバージョン {MIN_VERSION} から {VERSION} までです
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) ファイルのバージョン {0} は、この VectorCraft が対応するバージョン ({VERSION}) より新しいものです
@msg invalid image data for `{0}` `{0}` の画像データが正しくありません
@msg not a VectorCraft file: {0} VectorCraft ファイルではありません: {0}
@msg the compressed file unpacks to more than {_1} MB 圧縮ファイルを展開すると {_1} MB を超えます
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files バージョン {0} のファイルは圧縮できません: 圧縮ファイルを開けるのはバージョン {COMPRESSED_SINCE} 以降のみです
@msg a bitmap is empty ビットマップが空です
@msg a bitmap is too large to read ビットマップが大きすぎて読み込めません
@msg compressed (RLE) bitmaps can't be read yet 圧縮 (RLE) されたビットマップはまだ読み込めません
@msg the image's pixels don't match its size 画像のピクセル数がサイズと一致しません
@msg {bpp}-bit bitmaps can't be read {bpp} ビットのビットマップは読み込めません
@msg not an EMF or WMF file EMF ファイルでも WMF ファイルでもありません
@msg its layers use {_1}, which aren't read yet レイヤーで使用されている {_1} はまだ読み込めません
@msg the EMF header is damaged EMF ヘッダーが破損しています
@msg damaged 破損しています
@msg the WMF header is damaged WMF ヘッダーが破損しています
@msg the export region is empty 書き出し範囲が空です
@msg a stream runs past the end ストリームがファイルの末尾を超えています
@msg a stream's length is wrong ストリームの長さが正しくありません
@msg a stream's length isn't a number ストリームの長さが数値ではありません
@msg bad stream length ストリームの長さが無効です
@msg PDF writer error: {0} PDF の書き出し中にエラーが発生しました: {0}
@msg artboard {0} does not exist アートボード {0} は存在しません
@msg cannot read PDF: {0} PDF を読み込めません: {0}
@msg invalid PDF setting: {0} PDF の設定が正しくありません: {0}
@msg page {0} does not exist (the PDF has {1}) ページ {0} は存在しません (この PDF のページ数は {1} です)
@msg the PDF has no pages PDF にページがありません
@msg the PDF is password-protected: give its password この PDF はパスワードで保護されています: パスワードを入力してください
@msg the PDF password is wrong PDF のパスワードが正しくありません
@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG ファイルには仮のページだけが含まれ、アートは含まれていません (PDF 互換なしで保存されています): PDF 互換をオンにして保存し直すか、PDF または SVG として保存してください
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG これは PostScript ファイル (古い形式または PDF 互換なしで保存された .ai、または EPS ファイル) のため、まだ開けません: PDF、PDF 互換の .ai、または SVG として保存してください
@msg unsupported: {0} 対応していません: {0}
@msg a page is missing 見つからないページがあります
@msg a page tree kid isn't a reference ページツリーの子要素が参照になっていません
@msg a page tree node has no kids ページツリーのノードに子要素がありません
@msg an object can't be read 読み込めないオブジェクトがあります
@msg bad page tree ページツリーが無効です
@msg bad trailer トレーラーが無効です
@msg no catalog カタログがありません
@msg no cross-reference table 相互参照テーブルがありません
@msg no pages ページがありません
@msg no plug-in {0} is installed プラグイン {0} はインストールされていません
@msg not a valid plug-in module: {0} 有効なプラグインモジュールではありません: {0}
@msg plug-in ABI: {0} プラグインの ABI に問題があります: {0}
@msg plug-in exceeded its {0} プラグインが {0} を超えました
@msg plug-in failed: {0} プラグインの実行に失敗しました: {0}
@msg plug-in manifest: {0} プラグインのマニフェストに問題があります: {0}
@msg plug-in output: {0} プラグインの出力に問題があります: {0}
@msg plug-in parameters: {0} プラグインのパラメーターに問題があります: {0}
@msg plug-in trapped: {0} プラグインが異常終了しました: {0}
@msg is not an image VectorCraft reads VectorCraft で読み込める画像ではありません
@msg not found 見つかりません
@msg SVG parse error: {0} SVG を解析できませんでした: {0}
@msg decimals must be {_1}–{_2} (got {_3}) 小数点以下の桁数は {_1} から {_2} までにしてください (指定値: {_3})
@msg Exporting {_1} {_1} を書き出し中
@msg Exported {what} {what} を書き出しました
@msg "{0}" is not a colour: write it as #rrggbb 「{0}」はカラーではありません: #rrggbb の形式で指定してください
@msg could not decode image: {0} 画像をデコードできませんでした: {0}
@msg image is empty 画像が空です
@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) 画像が {width} × {height} ピクセルで、画像トレースで扱える上限 ({max} メガピクセル) を超えています
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead フラットカラーのロゴではないため (ベタ塗り部分のうち少数の単色に一致するのは {_1}% のみ)、フラットロゴモードではグラデーションがまだらになります: 代わりにカラーモードを使用してください
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset このトレースではアンカーポイントが {max} 個を超え、ドキュメントを滑らかに表示できなくなります: 「ノイズ」を上げる、「パス」か「カラー」を下げる、または精度の低いプリセットを選択してください
@msg internal error: {msg} (please report this bug) 内部エラー: {msg} (このバグを報告してください)
@msg the background worker stopped バックグラウンド処理が停止しました
@msg {_1} failed: {e} {_1} に失敗しました: {e}
@msg {label}… {label}…
@msg no blend options ブレンドオプションがありません
@msg bad color {v} カラー {v} が正しくありません
@msg Corners edits the corners of a path 「コーナー」で編集できるのはパスの角だけです
@msg give corners of the path: anchors between two straight sides パスの角 (2 本の直線の辺に挟まれたアンカーポイント) を指定してください
@msg select one path, or give its `id` パスを 1 つ選択するか、その `id` を指定してください
@msg the drawing is no longer loaded 図面はもう読み込まれていません
@msg No saved selections (Select → Save Selection…) 保存した選択範囲はありません (選択 → 選択範囲を保存…)
@msg effect {k} is `{_1}`, not `{id}` インデックス {k} の効果は `{id}` ではなく `{_1}` です
@msg no effect at index {k} インデックス {k} に効果がありません
@msg unknown effect `{id}` `{id}` は不明な効果です
@msg `{id}` has no envelope dialog `{id}` にはエンベロープのダイアログがありません
@msg select an envelope エンベロープを選択してください
@msg no format to export to 書き出し形式が指定されていません
@msg nothing to export: the document has no visible art (or turn on Use Artboards) 書き出すものがありません: ドキュメントに表示されているアートがありません (または「アートボードを使用」をオンにしてください)
@msg add a format to export 書き出す形式を追加してください
@msg check an asset to export 書き出すアセットにチェックを入れてください
@msg `{tool}` isn't a freehand tool `{tool}` はフリーハンドツールではありません
@msg bad colour `{hex}` (#rrggbb) カラー `{hex}` が正しくありません (#rrggbb の形式で指定してください)
@msg no gradient stop or freeform point is selected グラデーションの分岐点またはフリーフォームポイントが選択されていません
@msg no graphic style `{n}` グラフィックスタイル `{n}` が見つかりません
@msg select two or more graphic styles to merge 統合するグラフィックスタイルを 2 つ以上選択してください
@msg the PDF is no longer loaded PDF はもう読み込まれていません
@msg no such layer or object 指定されたレイヤーまたはオブジェクトが見つかりません
@msg `{tool}` isn't a Liquify tool `{tool}` はゆがみツールではありません
@msg {_1} linked file(s) can't be read here: their previews show この環境では {_1} 個のリンクファイルを読み込めません: 代わりにプレビューを表示しています
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} 個のリンク画像がディスク上で変更されました: 「リンクを更新」で新しいバージョンを表示できます
@msg pick a colour, gradient or pattern to save as a swatch スウォッチとして保存するカラー、グラデーション、またはパターンを選択してください
@msg Packaged {n} file(s) in {folder}{note} {n} 個のファイルを {folder} にパッケージしました{note}
@msg Packaged {n} file(s){note} {n} 個のファイルをパッケージしました{note}
@msg choose a location for the package パッケージの保存場所を選択してください
@msg missing `folder` `folder` が指定されていません
@msg the package came back damaged 返されたパッケージのデータが破損しています
@msg no preset editor プリセットの編集ダイアログが開いていません
@msg no perspective grid preset named `{name}` `{name}` という名前の遠近グリッドプリセットが見つかりません
@msg no active perspective plane アクティブな遠近面がありません
@msg plane must be left, right or ground plane には left、right、ground のいずれかを指定してください
@msg missing `id` (see plugin.list) `id` が指定されていません (plugin.list を参照)
@msg no plug-in {id} is installed プラグイン {id} はインストールされていません
@msg {_1} is a live effect (Effect › Plug-ins) {_1} はライブ効果です (「効果 › プラグイン」から使用してください)
@msg a preset named `{_1}` exists `{_1}` という名前のプリセットはすでに存在します
@msg no print preset named `{base}` `{base}` という名前のプリントプリセットが見つかりません
@msg no print preset named `{name}` `{name}` という名前のプリントプリセットが見つかりません
@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) `{name}` という名前の保存済みプリントプリセットが見つかりません ({DEFAULT_PRESET} は保護されています: これを元に新しいプリセットを作成してください)
@msg print settings are not an object プリント設定がオブジェクト形式ではありません
@msg no colour group `{g}` カラーグループ `{g}` が見つかりません
@msg select artwork (or a colour group) to recolor 再配色するアートワーク (またはカラーグループ) を選択してください
@msg Downloaded {_1} file(s) {_1} 個のファイルをダウンロードしました
@msg Preview in Browser needs the desktop app (the web downloads files instead) 「ブラウザーでプレビュー」にはデスクトップアプリが必要です (Web 版では代わりにファイルをダウンロードします)
@msg no file system here この環境にはファイルシステムがありません
@msg nothing to preview プレビューするものがありません
@msg Saved {n} files beside {path} {path} と同じ場所にファイルを {n} 個保存しました
@msg `{_1}` is no save command `{_1}` は保存コマンドではありません
@msg name the file ファイル名を入力してください
@msg unknown format 不明な形式です
@msg PDF settings are not an object PDF 設定がオブジェクト形式ではありません
@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) `{name}` という名前の保存済み PDF プリセットが見つかりません (組み込みのプリセットは読み取り専用です: それを元に新しいプリセットを作成してください)
@msg Saved to User Defined: {_1} 「ユーザー定義」に保存しました: {_1}
@msg choose a direction to divide in 分割する方向を選択してください
@msg choice must be "merge" or "add", not `{choice}` choice には "merge" または "add" を指定してください (`{choice}` は指定できません)
@msg `{name}` has no options `{name}` にはオプションがありません
@msg no swatch `{name}` スウォッチ `{name}` が見つかりません
@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` はカラーではありません (#rrggbb またはプリセット名で指定してください)
@msg give the path `id` and the width point `index` パスの `id` と幅ポイントの `index` を指定してください
@msg no such width point 指定された幅ポイントが見つかりません
@msg onto must name a panel floating in another group onto には、別のグループでフローティングしているパネルを指定してください
@msg unknown panel `{raw}` `{raw}` は不明なパネルです
@msg x and y must both be numbers x と y はどちらも数値で指定してください
@msg Downloaded {what} {what} をダウンロードしました
@msg Exported {count} file(s) to {folder} {count} 個のファイルを {folder} に書き出しました
@msg Imported {what}: {_1} {what} を読み込みました: {_1}
@msg Installed plug-in {_1} プラグイン {_1} をインストールしました
@msg Opened with {_1} note(s): {_2} ドキュメントを開きました (注意 {_1} 件): {_2}
@msg Saved {path} {path} を保存しました
@msg no app to open files here この環境にはファイルを開くアプリがありません
@msg no data データがありません
@msg no document ドキュメントがありません
@msg no file manager here この環境にはファイルマネージャーがありません
@msg no revert command 「保存済みの状態に戻す」コマンドがありません
@msg the Save PDF dialog didn't open PDF 保存ダイアログが開きませんでした
@msg the document has never been saved ドキュメントはまだ一度も保存されていません
@msg the export returned unreadable data 書き出し処理から読み取れないデータが返されました
@msg {status} ({e}) {status} ({e})
@msg Exported {what} with {_1} note(s): {first} {what} を書き出しましたが、注意事項が {_1} 件あります: {first}
@msg Saved {path} with {_1} note(s): {first} {path} を保存しましたが、注意事項が {_1} 件あります: {first}
@msg Couldn't open {name}: {e} {name} を開けませんでした: {e}
@msg Couldn't read {name}: {e} {name} を読み込めませんでした: {e}
@msg Internal error: {msg} (please report this bug) 内部エラー: {msg} (このバグを報告してください)
@msg Opened {url} {url} を開きました
@msg No saved views (View → New View…) 保存した表示はありません (表示 → 新規表示…)
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` には、メニュー項目がダイアログを開くコマンドを指定してください (ui.menuDialog を参照)
@msg `{key}` must be true or false `{key}` には true または false を指定してください
@msg brightness must be dark|mediumDark|mediumLight|light brightness には dark|mediumDark|mediumLight|light のいずれかを指定してください
@msg index must be a number or first, previous, next or last index には数値、または first、previous、next、last のいずれかを指定してください
@msg index must be a number or first, previous, next or last, not `{other}` index には数値、または first、previous、next、last のいずれかを指定してください (`{other}` は指定できません)
@msg lang must be one of {_1} lang には {_1} のいずれかを指定してください
@msg missing `name` `name` が指定されていません
@msg missing `tool` `tool` が指定されていません
@msg missing index index が指定されていません
@msg multiple windows land with M11.5 複数ウィンドウは M11.5 で対応予定です
@msg no PNG encoder PNG エンコーダーがありません
@msg no document open ドキュメントが開かれていません
@msg no effect applied yet まだ効果を適用していません
@msg no such perspective grid preset 指定された遠近グリッドプリセットが見つかりません
@msg no such recent file 指定された最近使ったファイルが見つかりません
@msg no such recent font 指定された最近使ったフォントが見つかりません
@msg no such saved selection 指定された選択範囲は保存されていません
@msg no such user library 指定されたユーザーライブラリが見つかりません
@msg no such view 指定された表示が見つかりません
@msg printing isn't available here この環境ではプリントできません
@msg select an image 画像を選択してください
@msg the document has no artboards ドキュメントにアートボードがありません
@msg tool (a tool id) is required tool (ツール ID) を指定してください
@msg unknown tool `{t}` `{t}` は不明なツールです
@msg no such action 指定されたアクションが見つかりません
@msg choose a folder to export to 書き出し先のフォルダーを選択してください
@msg the selection has no URL (or they differ) 選択したオブジェクトに URL がありません (または URL がそれぞれ異なります)
@msg select some type first 先にテキストを選択してください
@msg {_1} font families available {_1} 個のフォントファミリーを使用できます
@msg {_1} has no {_2} style {_1} には {_2} スタイルがありません
@msg Copied the CSS of {rules} object(s) {rules} 個のオブジェクトの CSS をコピーしました
@msg Exported {path} {path} を書き出しました
@msg no CSS to copy: select objects with CSS of their own コピーする CSS がありません: 独自の CSS を持つオブジェクトを選択してください
@msg unknown highlight `{h}` `{h}` は不明なハイライトの指定です
@msg unknown overprints `{o}` (preserve, simulate or discard) `{o}` は不明なオーバープリントの指定です (preserve、simulate、discard のいずれか)
@msg unknown preset `{name}` (see flattener.presets.list) `{name}` は不明なプリセットです (flattener.presets.list を参照してください)
@msg Select text or click into text with the Type tool to insert glyphs 字形を挿入するには、テキストを選択するか、文字ツールでテキスト内をクリックしてください
@msg `library` is a library id or name (see {list}) `library` にはライブラリの ID または名前を指定してください ({list} を参照)
@msg no library `{key}` (see {list}) ライブラリ `{key}` が見つかりません ({list} を参照)
@msg select a linked image: an embedded one has no file リンク画像を選択してください: 埋め込み画像にはファイルがありません
@msg {path} can't be found: relink it first {path} が見つかりません: 先に再リンクしてください
@msg Embedded {n} image(s); {missing} missing: relink them first 画像を {n} 枚埋め込みましたが、{missing} 枚は見つかりません: 先に再リンクしてください
@msg Relinked {_1} image(s) 画像を {_1} 枚再リンクしました
@msg Relinked {n} image(s) 画像を {n} 枚再リンクしました
@msg Relinked {n} image(s); not in that folder: {_1} 画像を {n} 枚再リンクしましたが、次の画像はそのフォルダーにありません: {_1}
@msg Unembedded to {_1} 埋め込みを解除して {_1} に保存しました
@msg Updated {_1} image(s) 画像を {_1} 枚更新しました
@msg no swatch library `{l}` (see swatch.library.list) スウォッチライブラリ `{l}` が見つかりません (swatch.library.list を参照)
@msg no user library folder here この環境にはユーザーライブラリフォルダーがありません
@msg Couldn't place {name}: the document it was dropped on was closed {name} を配置できませんでした: ドロップ先のドキュメントは閉じられています
@msg no files to place 配置するファイルがありません
@msg paths: give at least one file paths: ファイルを 1 つ以上指定してください
@msg Couldn't place {name}: {e} {name} を配置できませんでした: {e}
@msg no print service プリントサービスがありません
@msg the print job has no data プリントジョブにデータがありません
@msg there are no printer settings to open here この環境では開けるプリンター設定がありません
@msg Couldn't save recovery data: {e} 復帰用データを保存できませんでした: {e}
@msg Saved recovery data for {title} {title} の復帰用データを保存しました
@msg Discarded {_1} recovered document(s) 復帰用データのドキュメントを {_1} 件破棄しました
@msg Restored {_1} recovered document(s): save them to keep them 復帰用データからドキュメントを {_1} 件復元しました: 残すには保存してください
@msg Saving recovery data for {_1} {_1} の復帰用データを保存中
@msg bad shortcut ショートカットが正しくありません
@msg can't parse shortcut `{c}` ショートカット `{c}` を解釈できません
@msg can't parse shortcut `{s}` for `{k}` `{k}` のショートカット `{s}` を解釈できません
@msg no dialog open ダイアログが開かれていません
@msg no file reader ファイルの読み込み機能がありません
@msg no file writer ファイルの書き込み機能がありません
@msg not a VectorCraft shortcut set (missing `overrides`) VectorCraft のショートカットセットではありません (`overrides` がありません)
@msg shortcut for `{k}` must be a string `{k}` のショートカットは文字列で指定してください
@msg tool shortcuts must be a single key (optionally with Shift) ツールのショートカットには 1 つのキーを指定してください (Shift との組み合わせも可)
@msg unknown preset (one of {_1}) 不明なプリセットです ({_1} のいずれかを指定してください)
@msg unknown tool or command `{key}` `{key}` は不明なツールまたはコマンドです
@msg {_1} is already used by {_2} {_1} はすでに {_2} で使用されています
@msg Couldn't copy to the system clipboard: {e} システムのクリップボードにコピーできませんでした: {e}
@msg Couldn't paste from the clipboard: {e} クリップボードから貼り付けられませんでした: {e}
@msg nothing selected 何も選択されていません
@msg the {tool} tool has no options {tool} ツールにはオプションがありません
@msg unknown tool `{tool}` `{tool}` は不明なツールです
@msg {tool} isn't in a toolbar group of several tools {tool} は複数のツールからなるツールグループに含まれていません
@msg no such document 指定されたドキュメントが見つかりません
@msg {_1}: not saved, so not closed {_1}: 保存されていないため、閉じませんでした
@msg `{name}` is a built-in workspace; choose another name `{name}` は組み込みのワークスペースなので、別の名前を指定してください
@msg `{to}` is not an available name `{to}` という名前は使用できません
@msg no user workspace `{from}` ユーザーワークスペース `{from}` が見つかりません
@msg unknown workspace (one of {_1}) 不明なワークスペースです ({_1} のいずれかを指定してください)
@msg workspace name can't be empty ワークスペース名を入力してください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -2
View File
@@ -84,8 +84,8 @@ fn plural_east_slavic(n: u64) -> usize {
/// The registry. English first: it is the fallback and the source language.
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`).
// Japanese: the whole interface (every menu string and `tl!` literal) and the status and error
// messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
// Czech: every menu label (`menu_catalogs_translate_every_menu_label`); panels and dialogs not yet.
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_czech, complete_menus: false, catalog: OnceLock::new() },
+3
View File
@@ -480,6 +480,7 @@
Copy Color Value (Hex) Copiar Valor da Cor (Hex)
Copy Selected Style Copiar Estilo Selecionado
Copy Width Point Copiar Ponto de Largura
Copy to User Defined Copiar para Definido pelo Usuário
Copyright Direitos Autorais
Copyright Info URL: URL de Informações de Direitos Autorais:
Copyright Notice: Aviso de Direitos Autorais:
@@ -2297,6 +2298,7 @@
Swatch Options Opções de Amostra
Swatch Options… Opções de Amostra…
Swatch Tiles Blocos de Amostra
Swatch exchange files keep solid colors only. Arquivos de intercâmbio de amostras mantêm apenas cores sólidas.
Swatches Amostras
Switch workspace Trocar espaço de trabalho
Symbol Instance Instância de Símbolo
@@ -2590,6 +2592,7 @@
to para
{count} file(s) in {folder}{note}. Show the package folder? {count} arquivo(s) em {folder}{note}. Mostrar a pasta do pacote?
{count} linked file(s) not found {count} arquivo(s) vinculado(s) não encontrado(s)
fonts not copied: {fonts} fontes não copiadas: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? {count} imagem(ns) vinculada(s) alterada(s) desde que este documento foi salvo. Mostrar as novas versões?
{count} more rules: Export writes every one. {count} regras a mais: a Exportação escreve cada uma.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objetos sem nome deixados de fora: nomeie-os, ou ative Objetos Sem Nome no menu do painel.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -219,6 +219,10 @@
Auto Size New Area Type Автоматически подгонять размер нового текста в области
Auto Spell Check Автоматическая проверка орфографии
Auto-Collapse Iconic Panels Автоматически сворачивать значки панелей
Ellipse Properties: Свойства эллипса:
Invert Pie Инвертировать сектор
Pie End Angle: Конечный угол сектора:
Pie Start Angle: Начальный угол сектора:
Show Tool Group Labels Показывать названия групп инструментов
Auto-Rotate (turn the paper to each artboard) Автоповорот (поворачивать лист по каждой монтажной области)
Automatic flatness Автоматическая плоскостность
@@ -491,6 +495,7 @@
Copy Fonts Used in the Document Копировать шрифты, использованные в документе
Copy Links Копировать связи
Copy Selected Style Копировать выбранный стиль
Copy to User Defined Копировать в «Пользовательский»
Copy Width Point Копировать точку ширины
Copyright Copyright
Copyright Info URL: URL информации об авторских правах:
@@ -2636,6 +2641,7 @@
Swash Росчерк
Swatch Conflict Конфликт образцов
Swatch Conflicts Конфликты образцов
Swatch exchange files keep solid colors only. Файлы обмена образцами сохраняют только сплошные цвета.
Swatch Libraries Библиотеки образцов
Swatch Libraries Menu Меню «Библиотеки образцов»
Swatch Library Библиотека образцов
@@ -2986,6 +2992,7 @@
Scrub Numeric Fields by Dragging Изменять числовые поля перетаскиванием
{count} file(s) in {folder}{note}. Show the package folder? Файлов: {count} в {folder}{note}. Показать папку пакета?
{count} linked file(s) not found Не найдено связанных файлов: {count}
fonts not copied: {fonts} шрифты не скопированы: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? Связанных изображений ({count}) изменились с момента сохранения этого документа. Показать новые версии?
{count} more rules: Export writes every one. Ещё правил: {count}: экспорт записывает все.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Пропущено объектов без имени: {count}: назовите их или включите «Объекты без имени» в меню панели.
@@ -3448,6 +3455,7 @@ axis Both Оба
@msg no symbol instances selected экземпляры символов не выделены
@msg no target object has an opacity mask: select one or give `id` ни у одного целевого объекта нет непрозрачной маски: выделите один или укажите `id`
@msg no threaded text selected связанный текст не выделен
@msg no user library folder here здесь нет папки пользовательских библиотек
@msg no user workspace `{from}` нет пользовательской рабочей среды `{from}`
@msg node {_1} is not text узел {_1} не является текстом
@msg not a DXF drawing не является чертежом DXF
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1 -1
View File
@@ -872,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", "uk"];
const COMPLETE_MESSAGES: &[&str] = &["es", "fr", "it", "ja", "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"];
+100 -92
View File
@@ -215,8 +215,12 @@
Auto Size New Area Type Автоматично підганяти розмір нового тексту в області
Auto Spell Check Автоматична перевірка орфографії
Auto-Collapse Iconic Panels Автоматично згортати значки панелей
Ellipse Properties: Властивості еліпса:
Invert Pie Інвертувати сектор
Pie End Angle: Кінцевий кут сектора:
Pie Start Angle: Початковий кут сектора:
Show Tool Group Labels Показувати назви груп інструментів
Auto-Rotate (turn the paper to each artboard) Автоповорот (повертати лист по кожній монтажній області)
Auto-Rotate (turn the paper to each artboard) Автоматичний поворот (повертати аркуш відповідно до кожної монтажної області)
Automatic flatness Автоматична площинність
Automatically Автоматично
Automatically Save Recovery Data Автоматично зберігати дані відновлення
@@ -240,7 +244,7 @@
Black Чорний
Black and White Чорно-білий
Black and White Logo Чорно-білий логотип
BLANK DOCUMENT PRESETS НАБОРИ ПОРОЖНИХ ДОКУМЕНТІВ
BLANK DOCUMENT PRESETS НАБОРИ ПОРОЖНІХ ДОКУМЕНТІВ
Bleed Виліт
Bleed: Виліт:
Bleeds Вильоти
@@ -435,7 +439,7 @@
Compatible Gradient and Gradient Mesh Printing Сумісний друк градієнтів та градієнтних сіток
Complement Додатковий
Complexity: Складність:
Composite colours are converted to it; separations separate with it when it is a CMYK profile. Складові кольори перетворюються на нього; кольороподіли розділяються з ним, якщо це профіль CMYK.
Composite colours are converted to it; separations separate with it when it is a CMYK profile. Складові кольори перетворюються за цим профілем; кольороподіл також виконується за ним, якщо це профіль CMYK.
Compound Path Складений контур
Compress (RLE) Стиснути (RLE)
Compress text and line art Стискати текст та штриховий малюнок
@@ -477,7 +481,7 @@
Convert to Shape Перетворити на фігуру
Convert to sRGB Перетворити на sRGB
Convert: Перетворити:
Copied the CSS of {rules} object(s) Скопійовано CSS для {rules} об’єкта(ів)
Copied the CSS of {rules} object(s) Скопійовано CSS об’єктів: {rules}
Copies: Копії:
Copy Копіювати
Copy (make a transformed copy) Копіювати (створити перетворену копію)
@@ -487,6 +491,7 @@
Copy Fonts Used in the Document Копіювати шрифти, використані у документі
Copy Links Копіювати зв’язки
Copy Selected Style Копіювати вибраний стиль
Copy to User Defined Копіювати в «Користувацькі»
Copy Width Point Копіювати точку ширини
Copyright Авторські права
Copyright Info URL: URL інформації про авторські права:
@@ -542,11 +547,11 @@
CSS Properties Властивості CSS
Ctrl Ctrl
Currency & Symbols Валюти та символи
Current Brush Поточна пензель
Current Brush Поточний пензель
Current Colors ({total}) Поточні кольори ({total})
Current Symbol Поточний символ
Curvature Кривизна
Curvature Tool Інструмент «Кривізна»
Curvature Tool Інструмент «Кривизна»
Curves… Криві…
Custom Користувацький
Customize… Налаштувати…
@@ -615,7 +620,7 @@
Delete these {n} graphic styles? Видалити ці графічні стилі ({n})?
Delete this saved preset Видалити цей збережений набір
Delete this {n} graphic style? Видалити цей графічний стиль ({n})?
Delete Transparency Flattener Preset Видалити набір об’єднання прозоростей
Delete Transparency Flattener Preset Видалити набір зведення прозорості
Delete Width Point Видалити точку ширини
Delete Workspace Видалити робоче середовище
Delete {kind} Style Видалити стиль {kind}
@@ -785,21 +790,21 @@
Ellipse Еліпс
Ellipse Tool Інструмент «Еліпс»
Ellipse… Еліпс…
Ellipsis Багатокрапка
Em Box Bottom/Left Низ/ліво кегельного майданчика
Ellipsis Трикрапка
Em Box Bottom/Left Нижній/лівий край кегельного майданчика
Em Box Center Центр кегельного майданчика
Em Box Top/Right Верх/право кегельного майданчика
ICF Top/Right Верх/право ICF
ICF Bottom/Left Низ/ліво ICF
ICF Bottom/Left Нижній/лівий край ICF
Em Dash Довге тире
Em Space Кругла шпація
Embed Вбудувати
Embed Fonts (for other applications) Вбудувати шрифти (для інших програм)
Embed Fonts (Glyphs Used) Вбудувати шрифти (використані гліфи)
Embed ICC Profile Використати профіль ICC
Embed ICC Profiles Використати профілі ICC
Embed Image Використати зображення
Embed Image(s) Використати зображення(и)
Embed ICC Profile Вбудувати профіль ICC
Embed ICC Profiles Вбудувати профілі ICC
Embed Image Вбудувати зображення
Embed Image(s) Вбудувати зображення
Embed page thumbnails Вбудувати мініатюри сторінок
Embedded Вбудовано
Embedded Image Впроваджене зображення
@@ -866,7 +871,7 @@
Expand… Розібрати…
Export Експорт
Export All… Експортувати все…
Export and Clipboard Transparency Flattener Settings Параметри об’єднання прозоростей для експорту та буфера обміну
Export and Clipboard Transparency Flattener Settings Параметри зведення прозорості для експорту та буфера обміну
Export Artboard Експортувати монтажну область
Export As Експортувати як
Export As PNG… Експортувати як PNG…
@@ -894,7 +899,7 @@
Export Settings Параметри експорту
Export the selected assets (else all of them) Експортувати виділені ресурси (інакше все)
Export to: Експортувати до:
Export Transparency Flattener Presets Експортувати набори поєднання прозоростей
Export Transparency Flattener Presets Експортувати набори зведення прозорості
Export {count} Selected Експортувати вибрані ({count})
Export: Експорт:
Exported {count} file(s) to {folder} Експортовано файли: {count} до {folder}
@@ -967,18 +972,18 @@
Fit to Artwork Bounds Вписати у межі малюнка
Fit to Selected Art Вписати у виділений малюнок
Flag… Прапор…
Flare Блік
Flare Tool Інструмент «Блик»
Flare Tool Options Параметри інструменту «Блик»
Flare Відблиск
Flare Tool Інструмент «Відблиск»
Flare Tool Options Параметри інструменту «Відблиск»
Flat Image Плоске зображення
Flat Logo Плоский логотип
Flatness: Площинність:
Flatten Artwork Об’єднати малюнок
Flatten Transparency Об’єднати прозорості
Flatten Transparency… Поєднати прозорості…
Flattener preset: Набір об’єднання:
Flattener presets Набори об’єднання
Flattener Preview Попередній перегляд об’єднання
Flatten Transparency Звести прозорість
Flatten Transparency… Звести прозорість…
Flattener preset: Набір зведення:
Flattener presets Набори зведення
Flattener Preview Попередній перегляд зведення
Flip Across Відобразити поперек
Flip Along Відобразити вздовж
Flip Along Horizontal Axis Відобразити по горизонтальній осі
@@ -1114,9 +1119,9 @@
More Options Додаткові параметри
Show Properties Panel Показати панель «Властивості»
Hide Corner Widget Приховати кутовий віджет
Hide Corner Widget for angles greater than Приховувати кутовий віджет для кутів.
Hide Corner Widget for angles greater than Приховувати кутовий віджет для кутів, більших за
Hide Edges Приховати краї
Hide Gradient Annotator Сховати анотатор градієнта
Hide Gradient Annotator Приховати маркер градієнта
Hide Grid Приховати сітку
Hide Guides Приховати напрямні
Hide Hidden Characters Приховати приховані символи
@@ -1170,7 +1175,7 @@
Ignore White Ігнорувати білий
Palette: Палітра:
Limited Обмежена
Full Tone Повнотонова
Full Tone Повний діапазон тонів
Document Library Бібліотека документа
Library: Бібліотека:
Image Зображення
@@ -1188,7 +1193,7 @@
Import PDF Presets Імпортувати набори PDF
Import Perspective Grid Presets Імпортувати набори сітки перспективи
Import Print Presets Імпортувати набори друку
Import Transparency Flattener Presets Імпортувати набори поєднання прозоростей
Import Transparency Flattener Presets Імпортувати набори зведення прозорості
Imported {what}: {} Імпортовано: {what}: {}
Import… Імпорт…
In Tangent Handles Маркери вхідної дотичної
@@ -1242,11 +1247,11 @@
Join the ArtCraft community on Discord (discord.gg/artcraft) Приєднуйтесь до спільноти ArtCraft Discord (discord.gg/artcraft)
Join the lines of each paragraph Поєднувати рядки кожного абзацу
Join Tool Інструмент «З’єднання»
Justification… Виняток…
Justify all lines Вимкнути всі рядки
Justify with last line aligned center Вимкнути з останнім рядком по центру
Justify with last line aligned left Вимкнути з останнім рядком по лівому краю
Justify with last line aligned right Вимкнути з останнім рядком з правого краю
Justification… Вирівнювання за шириною…
Justify all lines Вирівняти всі рядки за шириною
Justify with last line aligned center Вирівняти за шириною, останній рядок — по центру
Justify with last line aligned left Вирівняти за шириною, останній рядок — ліворуч
Justify with last line aligned right Вирівняти за шириною, останній рядок — праворуч
Keep a link to an image file instead of only embedding it Зберігати зв’язок із файлом зображення замість простого впровадження
Keep both sides the same width Зберігати однакову ширину обох сторін
Keep Formatting Зберігати форматування
@@ -1302,7 +1307,7 @@
Left-to-Right Paragraph Direction Напрямок абзацу зліва направо
Left: Зліва:
Legacy Macintosh RGB (Gamma 1.8) Застарілий Macintosh RGB (гама 1.8)
Legacy vector clipboard Застарілий вектор буфер обміну
Legacy vector clipboard Застарілий векторний буфер обміну
Legacy vector clipboard (no transparency) Застарілий векторний буфер обміну (без прозорості)
Less Менше
Letter Letter
@@ -1424,7 +1429,7 @@
Matte: Підкладка:
Maximize Розгорнути
Maximum Максимум
Maximum Editability Максимальна редагованість
Maximum Editability Максимальні можливості редагування
Measure Tool Інструмент «Вимірювання»
Measurement Labels Мітки вимірів
Media Носій
@@ -1449,7 +1454,7 @@
Meters Метри
Method: Метод:
Millimeters Міліметри
Minify Мініфікувати
Minify Мінімізувати
Minimal Мінімальний
Minimize Згорнути
Minus Back Мінус нижній
@@ -1538,8 +1543,8 @@
New Graphic Style… Новий графічний стиль…
New Layer Options… Параметри нового шару…
New Layer… Новий шар…
New Opacity Masks Are Clipping Нові непрозорі маски обрізають
New Opacity Masks Are Inverted Нові непрозорі маски інвертуються
New Opacity Masks Are Clipping Нові маски непрозорості відсікають
New Opacity Masks Are Inverted Нові маски непрозорості інвертуються
New Paragraph Style Новий стиль абзацу
New Pattern Brush from Selection Новий візерунковий пензель з виділеного
New PDF Preset Новий набір PDF
@@ -1575,7 +1580,7 @@
No Document Немає документа
No document open Немає відкритого документа
No document open. Документ не відкрито.
No filter plug-ins installed Фільтруючі модулі не встановлені
No filter plug-ins installed Модулі фільтрів не встановлено
No fonts are available. Шрифти недоступні.
No gradient swatches Немає градієнтних зразків
No graphic styles Немає графічних стилів
@@ -1649,7 +1654,7 @@
Opacity Mask Info Відомості про маску непрозорості
Opacity Mask Options Параметри маски непрозорості
Opacity: Непрозорість:
Opacity: 50% Multiply Непрозорість: 50 % Збільшення
Opacity: 50% Multiply Непрозорість: 50 % Множення
Opacity: Default Непрозорість: за замовчуванням
Opacity: {value} Непрозорість: {value}
Opaque Непрозорий
@@ -1731,22 +1736,22 @@
Outline Stroke Обвести обведення
Outline Text Обвести текст
Outlines and Rules Контури та лінійки
Output Висновок
Output Виведення
Output All Blacks Accurately Виводити всі чорні точно
Output All Blacks as Rich Black Виводити всі чорні як складовий чорний
Output Black Вихідний чорний
Output condition: Умова висновку:
Output Intent Призначення висновку
Output intent profile: Профіль призначення висновку:
Output condition: Умова виведення:
Output Intent Призначення виведення
Output intent profile: Профіль призначення виведення:
Output White Вихідний білий
Output › Mode Висновок › Режим
Output: Висновок:
Output › Mode Виведення › Режим
Output: Виведення:
Overlap: Перекриття:
Overlapping Перекривається
Overlay Накладання
Overprint Накладення
Overprint and Transparency Flattener Накладання та поєднання прозоростей
Overprint and Transparency Flattener Options Параметри накладання та об’єднання прозоростей
Overprint and Transparency Flattener Накладання та зведення прозорості
Overprint and Transparency Flattener Options Параметри накладання та зведення прозорості
Overprint Black… Накладення чорного…
Overprint Fill Накладання заливки
Overprint Preview Перегляд накладання
@@ -1801,7 +1806,7 @@
Pattern Options Параметри візерунка
Pattern options are available in pattern editing mode. Параметри візерунка доступні у режимі редагування візерунка.
PDF PDF
PDF 1.3 files have no transparency: the preset flattens it. Файли PDF 1.3 не підтримують прозорість: набір поєднує її.
PDF 1.3 files have no transparency: the preset flattens it. Файли PDF 1.3 не підтримують прозорість: набір зводить її.
PDF Info Відомості про PDF
PDF Preset Набір PDF
PDF Preset: Набір PDF:
@@ -1933,10 +1938,10 @@
Profile: Профіль:
Progressive Прогресивний
Projecting Cap Закінчення, що виступає
Proof Colors Коліропроба
Proof Colors Пробні кольори
Proof Setup Налаштування кольоропроби
Properties Властивості
Proportional Oldstyle Пропорційні старий стиль
Proportional Oldstyle Пропорційні цифри старого стилю
Pucker & Bloat… Зморщування та роздування …
Pucker Tool Інструмент «Зморщування»
Punctuation Пунктуація
@@ -1950,7 +1955,7 @@
Quick Actions Швидкі дії
Quickly start a new file Швидко створити новий файл
Quit VectorCraft Вийти з VectorCraft
Radar Graph Tool Інструмент «Пелюсточна діаграма»
Radar Graph Tool Інструмент «Пелюсткова діаграма»
Radial Радіальний
Radial Glows Радіальні світіння
Radial Gradient Радіальний градієнт
@@ -2139,12 +2144,12 @@
Restored {} recovered document(s): save them to keep them Відновлено документів: {}: збережіть їх, щоб залишити
Restrict printing, editing and other tasks Обмежити друк, редагування та інші завдання
Reverse Front to Back Зворотний порядок спереду назад
Reverse Gradient Звернути градієнт
Reverse Gradient Змінити напрям градієнта
Reverse Order Зворотний порядок
Reverse Path Direction Звернути напрям контуру
Reverse Path Direction Off Звернути напрям контуру вимкнено
Reverse Path Direction On Звернути напрямок контуру увімкнено
Reverse Spine Звернути кістяк
Reverse Path Direction Змінити напрям контуру
Reverse Path Direction Off Зміна напряму контуру вимкнена
Reverse Path Direction On Зміна напряму контуру увімкнена
Reverse Spine Змінити напрям осі
Revert Повернути
Revert to the saved version of “{name}”? Повернутися до збереженої версії «{name}»?
Replace “{name}”, the file this document was opened from? Замінити «{name}» — файл, з якого відкрито цей документ?
@@ -2168,7 +2173,7 @@
Rotate View Tool Інструмент «Поворот виду»
Rotate… Повернути…
Rotation Поворот
Roughen… Огрубління…
Roughen… Надати нерівності…
Round Cap Кругле закінчення
Round Corners… Заокруглити кути…
Round Join Заокруглене з’єднання
@@ -2238,8 +2243,8 @@
Save these settings as a print preset Зберегти ці параметри як набір друку
Save to Swatches Зберегти у зразки
Save To: Зберегти у:
Save Transparency Flattener Preset Зберегти набір об’єднання прозоростей
Save Transparency Flattener Preset… Зберегти набір об’єднання прозоростей…
Save Transparency Flattener Preset Зберегти набір зведення прозорості
Save Transparency Flattener Preset… Зберегти набір зведення прозорості…
SAVED ЗБЕРЕЖЕНІ
Saved Збережено
Saved recovery data for {title} Збережено дані відновлення для {title}
@@ -2338,14 +2343,14 @@
Select Anchors Виділити опорні точки
Select Appearance Item Виділити елемент оформлення
Select Art Виділити малюнок
Select art and choose Object › Pattern › Make. Перейдіть до малюнка та оберіть «Об’єкт › Візерунок › Створити».
Select art and click New Symbol to make one. Перейдіть до малюнка і натисніть «Новий символ», щоб створити його.
Select art and choose Object › Pattern › Make. Виділіть малюнок та виберіть «Об’єкт › Візерунок › Створити».
Select art and click New Symbol to make one. Виділіть малюнок і натисніть «Новий символ», щоб створити його.
Select art and use New Brush to make one. Виділіть малюнок та використовуйте «Новий пензель», щоб створити його.
Select Freeform Point Виділити довільну точку
Select Gradient Stop Виділити точку переходу градієнта
Select Guides Виділити напрямні
Select Objects Виділити об’єкти
Select objects to see their CSS, or Generate CSS for the whole document. Перейдіть до об’єктів, щоб побачити CSS, або створіть CSS для всього документа.
Select objects to see their CSS, or Generate CSS for the whole document. Виділіть об’єкти, щоб побачити їхній CSS, або створіть CSS для всього документа.
Select Same Tint % Виділити з тим же % відтінку
Select Slices Виділити фрагменти
Select styled art and click New Graphic Style. Виділіть оформлений малюнок та натисніть «Новий графічний стиль».
@@ -2364,14 +2369,14 @@
Selection & Anchor Display Відображення виділення та опорних точок
Selection Only Тільки виділення
Selection Tool Інструмент «Виділення»
Send Backward На один рівень тому
Send Backward На один рівень назад
Send to Back На задній план
Send to Current Layer Надіслати на поточний шар
Sentence case Як у реченні
Separate colors into rows Розділити кольори по рядках
Separate several keywords with commas. Розділяйте ключові слова комами.
Separations list the document's inks here: one page per ink that prints. Кольорові поділу перераховують тут фарби документа: одна сторінка на кожну фарбу, що друкується.
Separations Preview Перегляд кольороподілів
Separations list the document's inks here: one page per ink that prints. Тут перелічено фарби документа для кольороподілу: одна сторінка на кожну фарбу, що друкується.
Separations Preview Перегляд кольороподілу
Serialize Document Серіалізувати документ
Serif / Mincho Serif / Mincho
Server-side (.map) Серверна частина (.map)
@@ -2433,7 +2438,7 @@
Show Embedded Показати впроваджені
Show Find Field Показати поле пошуку
Show Font Names in English Показувати назви шрифтів англійською
Show Gradient Annotator Показати анотатор градієнта
Show Gradient Annotator Показати маркер градієнта
Show Gradient Swatches Показати градієнтні зразки
Show Grid Показати сітку
Show Grid (⌘') Показати сітку (⌘')
@@ -2632,6 +2637,7 @@
Swash Розчерк
Swatch Conflict Конфлікт зразків
Swatch Conflicts Конфлікти зразків
Swatch exchange files keep solid colors only. Файли обміну зразками зберігають лише суцільні кольори.
Swatch Libraries Бібліотеки зразків
Swatch Libraries Menu Меню "Бібліотеки зразків"
Swatch Library Бібліотека зразків
@@ -2660,7 +2666,7 @@
Use Library Item Використати елемент бібліотеки
Graphic Графіка
Show all open documents Показати всі відкриті документи
Symbol Instance Примірник символ
Symbol Instance Екземпляр символу
Symbol Libraries Бібліотеки символів
Symbol Libraries (on the roadmap) Бібліотеки символів (у плані розвитку)
Symbol Screener Tool Інструмент «Прозорість символів»
@@ -2679,7 +2685,7 @@
Tab Stops Табуляції
Tabs Табуляції
Tabular Lining Табличні цифри
Tabular Oldstyle Таблиці старого стилю
Tabular Oldstyle Табличні цифри старого стилю
Target Об’єкт
Target Contents Вміст об’єкта
Target: Об’єкт:
@@ -2688,7 +2694,7 @@
Temperature/Tint… Температура/відтінок…
Template Шаблон
Templates Шаблони
Templates Folder Шаблони папки
Templates Folder Папка шаблонів
Templates… Шаблони…
Text Displayed in Cell: Текст, що відображається в комірці:
Text Export Options Параметри експорту тексту
@@ -2773,12 +2779,12 @@
Transforms Трансформування
Transform… Трансформувати…
Transparency Прозорість
Transparency Flattener Presets Набори поєднання прозоростей
Transparency Flattener Presets… Набори поєднання прозоростей…
Transparency Flattener Presets Набори зведення прозорості
Transparency Flattener Presets… Набори зведення прозорості…
Transparency Grid Сітка прозорості
Transparency Info Відомості про прозорість
Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour. Прозорість поєднується з набором. Текст записується як контури; градієнти – як колірні смуги.
Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth. Прозорість поєднується з набором. Текст записується як контури; градієнти залишаються гладкими.
Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour. Прозорість зводиться за параметрами набору. Текст записується як контури; градієнти — як колірні смуги.
Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth. Прозорість зводиться за параметрами набору. Текст записується як контури; градієнти залишаються гладкими.
Transparency Options Параметри прозорості
Transparent Прозорий
Transparent (Default) Прозорий (за замовчуванням)
@@ -2860,7 +2866,7 @@
Use document bleed settings Використовувати параметри вильоту документа
Use Even-Odd Fill Rule Використовувати правило заливки «парне–непарне»
Use Japanese Crop Marks Використовувати японські мітки обрізання
Use Low Resolution Proxy for Linked EPS Використовувати низькодозволений проксі для пов’язаних EPS
Use Low Resolution Proxy for Linked EPS Використовувати попередній перегляд низької роздільної здатності для пов’язаних EPS
Use Non-Zero Winding Fill Rule Використовувати правило заливки «ненульове заповнення»
Use Precise Cursors Використовувати точні курсори
Use Pressure Pen Використовувати перо з натиском
@@ -2982,6 +2988,7 @@
Scrub Numeric Fields by Dragging Змінювати числові поля перетягуванням
{count} file(s) in {folder}{note}. Show the package folder? файлів: {count} {folder}{note}. Показати папку пакета?
{count} linked file(s) not found не знайдено пов’язаних файлів: {count}
fonts not copied: {fonts} шрифти не скопійовано: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? пов’язані зображення ({count}) змінилися з моменту збереження цього документа. Показати нові версії?
{count} more rules: Export writes every one. ще правил: {count}: експорт записує все.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. пропущено об’єкти без імені: {count}: назвіть їх або увімкніть «Об’єкти без імені» у меню панелі.
@@ -3099,15 +3106,15 @@
Merged PRs Об’єднані PR
Commits Коміти
Lines added Додані рядки
Lines deleted Віддалені рядки
Lines deleted Видалені рядки
Line delta Різниця рядків
Binary assets added Додані бінарні ресурси
Binary assets removed Віддалені бінарні ресурси
Binary assets removed Видалені бінарні ресурси
First commit Перший коміт
Last commit Останній коміт
First Перший
Last Останній
{people} contributors · {commits} commits {people} учасників · {commits} коммітів
{people} contributors · {commits} commits {people} учасників · {commits} комітів
No contributor data was built into this copy. Ця копія не містить даних про учасників.
No model credits were built into this copy. Ця копія не містить відомостей про моделі.
Company Компанія
@@ -3123,15 +3130,15 @@ axis Horizontal Горизонтальна
axis Vertical Вертикальна
axis Both Обидві
Drag to move Перетягніть для переміщення
Drag to float as a toolbar Перетягніть, щоб зробити плаваючою панеллю інструментів
Drag to float as a toolbar Перетягніть, щоб від’єднати панель інструментів
Put back in the toolbar Повернути на панель інструментів
Float Panel Зробити панель плаваючою
Float Panel Від’єднати панель
Dock Panel Прикріпити панель
Drag to float the panel group Перетягніть, щоб зробити групу панелей плаваючою
Drag to float the panel group Перетягніть, щоб від’єднати групу панелей
Put back in the dock Повернути у док
Drag to float the toolbar Перетягніть, щоб зробити панель інструментів плаваючою
Drag to float the toolbar Перетягніть, щоб від’єднати панель інструментів
Drag to the window's left edge to dock Перетягніть до лівого краю вікна для прикріплення
Float Tool Group Зробити групу інструментів плаваючою
Float Tool Group Від’єднати групу інструментів
@msg no library `{id}` немає бібліотеки `{id}`
@msg select some type first спочатку виділіть текст
@msg {_1} has no {_2} style {_1} не має накреслення {_2}
@@ -3153,7 +3160,7 @@ axis Both Обидві
@msg Combine Slices: the slices have no bounds Об’єднання фрагментів: фрагменти не мають кордонів
@msg Convert to Artboards: selection has no area Перетворення на монтажні області: виділення не має області
@msg Convert to Shape: no rectangles or ellipses selected Перетворення на фігуру: не виділено прямокутники або еліпси
@msg Copied the CSS of {rules} object(s) Скопійовано CSS для {rules} об’єкта(ів)
@msg Copied the CSS of {rules} object(s) Скопійовано CSS об’єктів: {rules}
@msg Corners edits the corners of a path «Кути» редагує кути контуру
@msg Couldn't copy to the system clipboard: {e} Не вдалося скопіювати до системного буфера обміну: {e}
@msg Couldn't open {name}: {e} Не вдалося відкрити {name}: {e}
@@ -3176,8 +3183,8 @@ axis Both Обидві
@msg Expand: nothing to expand Розбір: нема чого розбирати
@msg Exported {count} file(s) to {folder} Експортовано файли: {count} до {folder}
@msg Exported {path} Експортовано: {path}
@msg Flatten Transparency: select objects to flatten Об’єднання прозоростей: виділіть об’єкти для об’єднання
@msg Flatten Transparency: {e} Об’єднання прозоростей: {e}
@msg Flatten Transparency: select objects to flatten Зведення прозорості: виділіть об’єкти для зведення
@msg Flatten Transparency: {e} Зведення прозорості: {e}
@msg Image Trace: the traced image is missing Трасування зображення: зображення, що трасується, відсутнє
@msg Imported {what}: {_1} Імпортовано: {what}: {_1}
@msg Installed plug-in {_1} Встановлено модуль {_1}
@@ -3329,7 +3336,7 @@ axis Both Обидві
@msg image would be too large; lower the resolution зображення буде завеликим; зменште роздільну здатність
@msg index must be a number or first, previous, next or last index має бути числом або first, previous, next, last
@msg index must be a number or first, previous, next or last, not `{other}` index повинен бути числом або first, previous, next, last, а не `{other}`
@msg installing from a path is not available on the web; pass the module as `dataBase64` встановлення з файла недоступне у вебверсії; передайте модуль як `dataBase64`
@msg installing from a path is not available on the web; pass the module as `dataBase64` встановлення з файлу недоступне у вебверсії; передайте модуль як `dataBase64`
@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) внутрішня помилка `{cmd}`: {msg} (документ залишений у попередньому стані; будь ласка, повідомте про цю помилку)
@msg internal error: {msg} (please report this bug) внутрішня помилка: {msg} (будь ласка, повідомте про цю помилку)
@msg invalid PDF setting: {0} неприпустимий параметр PDF: {0}
@@ -3435,6 +3442,7 @@ axis Both Обидві
@msg no symbol instances selected екземпляри символів не виділені
@msg no target object has an opacity mask: select one or give `id` у жодного цільового об’єкта немає маски непрозорості: виділіть один або вкажіть `id`
@msg no threaded text selected пов’язаний текст не виділено
@msg no user library folder here тут немає папки користувацьких бібліотек
@msg no user workspace `{from}` немає користувача робочого середовища `{from}`
@msg node {_1} is not text вузол {_1} не є текстом
@msg not a DXF drawing не є кресленням DXF
@@ -3475,7 +3483,7 @@ axis Both Обидві
@msg plug-in exceeded its {0} модуль перевищив свій {0}
@msg plug-in failed: {0} модуль завершився помилково: {0}
@msg plug-in manifest: {0} маніфест модуля: {0}
@msg plug-in output: {0} висновок модуля: {0}
@msg plug-in output: {0} виведення модуля: {0}
@msg plug-in parameters: {0} параметри модуля: {0}
@msg plug-in trapped: {0} модуль зупинено з помилкою: {0}
@msg print settings are not an object параметри друку не є об’єктом
@@ -3500,9 +3508,9 @@ axis Both Обидві
@msg select a symbol instance виділіть екземпляр символу
@msg select an Image Trace object виділіть об’єкт трасування зображення
@msg could not decode image: {0} не вдалося декодувати зображення: {0}
@msg image is empty зображення порожньо
@msg image is empty зображення порожнє
@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) зображення має розмір {width} × {height} пікселів, що більше, ніж приймає трасування зображення ({max} мегапікселів)
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset це трасування створило б більше {max} опорних точок, що більше, ніж документ може плавно відобразити: збільшіть «Шум», зменшіть «Контури» або «Кольори» або виберіть набір з меншою точністю
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset це трасування створило б більше {max} опорних точок, що більше, ніж документ може плавно відобразити: збільште «Шум», зменште «Контури» або «Кольори» або виберіть набір з меншою точністю
@msg select an envelope виділіть оболонку
@msg select an image виділіть зображення
@msg select an object in perspective виділіть об’єкт у перспективі
@@ -3664,7 +3672,7 @@ axis Both Обидві
@msg A browser with WebGPU or WebGL2 is required. Потрібен браузер із WebGPU або WebGL2.
@msg The graphics were lost and couldn't be restarted. Графіку було втрачено, і її не вдалося перезапустити.
@msg Your unsaved changes are kept in this browser: reload the page to get them back. Ваші незбережені зміни зберігаються у цьому браузері: перезавантажте сторінку, щоб повернути їх.
@msg Reload the page to go on (changes not saved yet may be lost). Перезавантажте сторінку, щоб продовжити.
@msg Reload the page to go on (changes not saved yet may be lost). Перезавантажте сторінку, щоб продовжити (незбережені зміни можуть бути втрачені).
@msg tool (a tool id) is required потрібно tool (ідентифікатор інструменту)
@msg {tool} isn't in a toolbar group of several tools {tool} не входить до групи панелі інструментів з кількох інструментів
@msg onto must name a panel floating in another group onto має називати панель, що плаває в іншій групі
@@ -3695,7 +3703,7 @@ axis Both Обидві
Settings Налаштування
Break Link Розірвати зв’язок
On: one locked object showing the document, updated when it changes. Увімк.: один заблокований об’єкт, який показує документ і оновлюється під час його зміни.
Off: an editable copy of its art, embedded in this document. Вимк.: редагована копія зображення, введена в цей документ.
Off: an editable copy of its art, embedded in this document. Вимк.: редагована копія графіки, вбудована в цей документ.
Images: link to the file instead of only embedding it. Зображення: пов’язувати із файлом замість простого впровадження.
VectorCraft documents: one locked object, updated when the file changes. Документи VectorCraft: один заблокований об’єкт, який оновлюється під час зміни файлу.
A file dialog is open: choose there first Відкрито вікно вибору файлу: спочатку зробіть вибір у ньому
Can't render this file because it contains an unexpected character in line 627 and column 40.
+7
View File
@@ -204,6 +204,10 @@
Auto Size New Area Type 自动调整新区域文字大小
Auto Spell Check 自动拼写检查
Auto-Collapse Iconic Panels 自动折叠图标面板
Ellipse Properties: 椭圆属性:
Invert Pie 反转扇形
Pie End Angle: 扇形终止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 显示工具组标签
Auto-Rotate (turn the paper to each artboard) 自动旋转 (为每个画板转动纸张)
Automatic flatness 自动平滑度
@@ -477,6 +481,7 @@
Copy Fonts Used in the Document 复制文档中使用的字体
Copy Links 复制链接
Copy Selected Style 复制所选样式
Copy to User Defined 复制到用户定义
Copy Width Point 复制宽度点
Copyright 版权
Copyright Info URL: 版权信息 URL:
@@ -2557,6 +2562,7 @@
Swash 花饰字
Swatch Conflict 色板冲突
Swatch Conflicts 色板冲突
Swatch exchange files keep solid colors only. 色板交换文件仅保留纯色。
Swatch Libraries 色板库
Swatch Libraries Menu 色板库菜单
Swatch Library 色板库
@@ -2910,6 +2916,7 @@
Scrub Numeric Fields by Dragging 拖动以调整数值字段
{count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 个文件{note}。要显示打包文件夹吗?
{count} linked file(s) not found 找不到 {count} 个链接文件
fonts not copied: {fonts} 未复制的字体:{fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? 自存储此文档后,有 {count} 个链接图像已更改。要显示新版本吗?
{count} more rules: Export writes every one. 另有 {count} 条规则:导出时会全部写出。
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 已略过 {count} 个未命名对象:请为它们命名,或在面板菜单中开启“未命名对象”。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -215,6 +215,10 @@
Auto Size New Area Type 自動調整新區域文字大小
Auto Spell Check 自動拼字檢查
Auto-Collapse Iconic Panels 自動收合圖示面板
Ellipse Properties: 橢圓屬性:
Invert Pie 反轉扇形
Pie End Angle: 扇形終止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 顯示工具組標籤
Auto-Rotate (turn the paper to each artboard) 自動旋轉 (將紙張轉向各工作區域)
Automatic flatness 自動平滑度
@@ -491,6 +495,7 @@
Copy Fonts Used in the Document 複製文件中使用的字體
Copy Links 複製連結
Copy Selected Style 複製所選樣式
Copy to User Defined 複製到使用者定義
Copy Width Point 複製寬度點
Copyright 版權
Copyright Info URL: 著作權資訊網址:
@@ -2643,6 +2648,7 @@
Swash 花體字
Swatch Conflict 色票衝突
Swatch Conflicts 色票衝突
Swatch exchange files keep solid colors only. 色票交換檔案僅保留純色。
Swatch Libraries 色票資料庫
Swatch Libraries Menu 色票資料庫選單
Swatch Library 色票資料庫
@@ -2998,6 +3004,7 @@
Scrub Numeric Fields by Dragging 拖曳以調整數值欄位
{count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 個檔案{note}。要顯示封裝資料夾嗎?
{count} linked file(s) not found 找不到 {count} 個連結檔案
fonts not copied: {fonts} 未複製的字體:{fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? 自儲存此文件後,有 {count} 個連結影像已變更。要顯示新版本嗎?
{count} more rules: Export writes every one. 另有 {count} 條規則:匯出時會全部寫出。
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 已略過 {count} 個未命名物件:請為它們命名,或在面板選單中開啟「未命名物件」。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+24 -8
View File
@@ -516,6 +516,29 @@ pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params
Ok(path)
}
/// Run an engine command that returns `{data}` or `{dataBase64}` and write the bytes to a file
/// picked with file name `name` suggested next to the document (the web downloads them as `name`)
/// → where they went.
pub(crate) fn save_command_output_named(app: &mut VectorcraftApp, id: &str, name: &str, params: Value) -> Result<String, String> {
let mut v = app.session.execute(id, &params).map_err(|e| e.to_string())?;
let bytes = take_output(&mut v)?;
let path = write_named(app, None, name, &bytes)?;
app.status(format!("Saved {path}"));
Ok(path)
}
/// The bytes a command returned, taken out of its result `v`, which keeps the rest: binary output
/// comes as base64, text (swatch libraries) as is.
fn take_output(v: &mut Value) -> Result<Vec<u8>, String> {
let o = v.as_object_mut().ok_or("no data")?;
let bytes = match (o.remove("dataBase64"), o.remove("data")) {
(Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64),
(_, Some(Value::String(text))) => Some(text.into_bytes()),
_ => None,
};
bytes.ok_or_else(|| "no data".into())
}
/// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to
/// that path (else a picked or suggested name) → (path, the command's result without the data).
/// The command runs before a path is asked for, so bad params never open a save dialog, except
@@ -532,14 +555,7 @@ pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut par
path = Some(picked);
}
let mut v = app.session.execute(id, &params).map_err(|e| e.to_string())?;
// Binary output comes as base64, text (swatch libraries) as is; the result keeps the rest.
let o = v.as_object_mut().ok_or("no data")?;
let bytes = match (o.remove("dataBase64"), o.remove("data")) {
(Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64),
(_, Some(Value::String(text))) => Some(text.into_bytes()),
_ => None,
};
let bytes = bytes.ok_or("no data")?;
let bytes = take_output(&mut v)?;
let path = match path {
Some(p) => p,
None => target_path(app, None, ext)?,
+1 -1
View File
@@ -371,7 +371,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.saveSwatchLibrary",
"Save Swatch Library…",
"",
"{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save",
"{names?: [the swatches selected in the Swatches panel]} open Save Swatch Library (dialog `saveSwatchLibrary`: name, format: vcswatches|gpl|ase|css, user: save to the user library folder, selectedOnly); OK runs swatch.library.save",
),
("window.userSwatchLibrary1", "User Swatch Library 1", "", "{} open the 1. User Defined swatch library (swatch.library.list, category user)"),
("window.userSwatchLibrary2", "User Swatch Library 2", "", "{} open the 2. User Defined swatch library"),
@@ -617,6 +617,47 @@ mod tests {
assert!(!builtin_library(&open.id) && builtin_library(vectorcraft_engine::cmd::swatchlib::DOCUMENT_SWATCHES));
}
#[test]
fn the_save_dialog_writes_swatch_exchange_files() {
let written = std::rc::Rc::new(std::cell::RefCell::new(vec![]));
let w = written.clone();
let services = crate::Services {
pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/tmp/{}", p.name)))),
write: Some(Box::new(move |p: &str, b: &[u8]| {
w.borrow_mut().push((p.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("ui.saveSwatchLibrary", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("format".into(), json!("ase"));
d.fields.insert("name".into(), json!("Brand Kit"));
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
assert!(text.contains("Swatch Exchange (.ase)") && text.contains("Swatch exchange files keep solid colors only."), "{text}");
crate::dialogs::confirm(&mut app).unwrap();
let (path, bytes) = written.borrow()[0].clone();
assert_eq!(path, "/tmp/Brand Kit.ase", "the save panel suggests the name");
let lib = vectorcraft_color::palette_io::read_bytes(&bytes, "x").unwrap();
let solid = app.session.doc().unwrap().doc.swatches_iter().filter(|w| w.paint.color().is_some()).count();
assert_eq!(lib.len(), solid, "every solid color of the document");
// The web downloads the file under that name.
let downloads = std::rc::Rc::new(std::cell::RefCell::new(vec![]));
let dl = downloads.clone();
let services =
crate::Services { download: Some(Box::new(move |name: &str, _: &[u8]| dl.borrow_mut().push(name.to_string()))), ..Default::default() };
let mut web = VectorcraftApp::new(Session::new(), services);
web.run("file.new", json!({"width": 100, "height": 100})).unwrap();
web.run("ui.saveSwatchLibrary", json!({})).unwrap();
let d = web.ui.dialog.as_mut().unwrap();
d.fields.insert("format".into(), json!("ase"));
d.fields.insert("name".into(), json!(" Brand: Kit. "));
crate::dialogs::confirm(&mut web).unwrap();
assert_eq!(*downloads.borrow(), ["Brand- Kit.ase"], "the name made safe as a file name, as for User Defined");
}
#[test]
fn swatch_exchange_files_without_a_path_open_in_the_library_panel() {
// A file opened on the web has no path; open_bytes passes its bytes as dataBase64.
+1
View File
@@ -468,6 +468,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
});
corner_radius_row(app, ui, &n, "radius");
}
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"),
_ => {}
}
}
+20
View File
@@ -192,6 +192,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
corner_radius_row(app, ui, &n, "xfp-radius");
}
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => {
widgets::subheader(ui, tl!("Ellipse Properties:"));
pie_rows(app, ui, *pie, "xfp-pie");
}
_ => {
widgets::subheader(ui, tl!("Shape Properties:"));
widgets::dim_label(ui, tl!(n.kind_label()));
@@ -211,6 +215,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// An ellipse's Pie Start and End Angle (degrees, counterclockwise from 3 o'clock; 0 to 360 is the
/// whole ellipse) and Invert Pie, which shows the other part of it (Transform and Properties panels).
pub(crate) fn pie_rows(app: &mut VectorcraftApp, ui: &mut Ui, pie: (f64, f64), id: &str) {
for (label, key, v) in [(tl!("Pie Start Angle:"), "pieStart", pie.0), (tl!("Pie End Angle:"), "pieEnd", pie.1)] {
ui.horizontal(|ui| {
widgets::dim_label(ui, label);
if let Some(a) = widgets::plain_field(ui, (id, key), v, "°", 1, 60.0) {
app.run("object.setLiveShape", json!({ key: a })).ok();
}
});
}
if widgets::flat_button(ui, tl!("Invert Pie"), 90.0).clicked() {
app.run("object.setLiveShape", json!({"invertPie": true})).ok();
}
}
/// The Control bar's underlined "Transform" link: a click toggles the Transform panel in a
/// popover under it, as the Stroke link does with the Stroke panel.
pub fn link(app: &mut VectorcraftApp, ui: &mut Ui) {
+24
View File
@@ -577,4 +577,28 @@ mod tests {
assert!(PREF_SPECS.iter().any(|s| s.category == *c));
}
}
/// Preferences › Hyphenation › Exceptions (#394): OK on the dialog pushes the list into the
/// hyphenator the same way `prefs.set` does (no need to drive the live Preferences window).
#[test]
fn hyphenation_exceptions_dialog_ok_reaches_the_hyphenator() {
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
vectorcraft_text::set_hyphenation_exceptions("");
}
}
let _clear = Clear;
let mut a = app();
open(&mut a, Some("Hyphenation"));
let d = a.ui.dialog.as_mut().unwrap();
assert_eq!(d.str("__category"), "Hyphenation");
d.fields.insert("hyphenationExceptions".into(), json!("typography, hap-pen"));
confirm(&mut a).unwrap();
assert!(a.ui.dialog.is_none());
assert_eq!(a.session.prefs.hyphenation_exceptions, "typography, hap-pen");
assert_eq!(vectorcraft_text::hyphenation_exceptions(), "typography, hap-pen");
assert!(vectorcraft_text::hyphen::hyphen_points("typography").is_empty());
assert_eq!(vectorcraft_text::hyphen::hyphen_points("happen"), vec![3]);
}
}
+39 -2
View File
@@ -414,12 +414,18 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
{
let _ = app.run("edit.clear", json!({}));
}
// Single-key tool shortcuts (no Cmd/Ctrl/Alt).
// Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian,
// Arabic, Russian, Greek…) the key's position stands in for what it types (#793).
let events: Vec<(String, Modifiers)> = ctx.input(|i| {
let mut physical = None;
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Text(t) => Some((t.clone(), i.modifiers)),
egui::Event::Key { physical_key, pressed: true, .. } => {
physical = *physical_key;
None
}
egui::Event::Text(t) => Some((latin(t, physical), i.modifiers)),
_ => None,
})
.collect()
@@ -445,6 +451,16 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
/// What a single-key shortcut reads for `text`, typed by the key at `physical`: the text itself,
/// or, when it holds no ASCII character (a layout without Latin letters), what that key types on a
/// US layout, so V selects the Selection tool whatever the V key types.
fn latin(text: &str, physical: Option<Key>) -> String {
match physical {
Some(k) if !text.is_ascii() => k.symbol_or_name().to_lowercase(),
_ => text.to_string(),
}
}
/// The digit a number-row or keypad key types.
fn digit_of(k: Key) -> Option<u8> {
const DIGITS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9];
@@ -678,6 +694,27 @@ mod tests {
assert_eq!(app.session.tool_id(), "curvature");
}
/// #793: with a layout without Latin letters the one-key shortcuts go by the key's position:
/// the P key typing Persian `ح` chooses the Pen, the V key typing Russian `м` the Selection
/// tool. A Latin layout keeps the letter typed: AZERTY's A (the Q key's place) is A.
#[test]
fn one_key_shortcuts_follow_the_key_on_layouts_without_latin_letters() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let typed = |key, physical, text: &str| {
vec![
egui::Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: Modifiers::NONE },
egui::Event::Text(text.into()),
]
};
frame(&mut app, typed(Key::P, Key::P, "ح"));
assert_eq!(app.session.tool_id(), "pen");
frame(&mut app, typed(Key::V, Key::V, "м"));
assert_eq!(app.session.tool_id(), "selection");
frame(&mut app, typed(Key::A, Key::Q, "a"));
assert_eq!(app.session.tool_id(), "directSelection", "AZERTY: the letter typed");
}
#[test]
fn plus_typed_any_way_zooms_in() {
assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key");
+1 -1
View File
@@ -62,7 +62,7 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[
("Color", &[&["gradient", "mesh"], &["eyedropper", "measure"]]),
];
fn tip(t: &ToolInfo) -> String {
pub(crate) fn tip(t: &ToolInfo) -> String {
match crate::shortcut_editor::tool_shortcut(t.id) {
Some(s) => format!("{} ({})", tl!(t.label), s),
None => tl!(t.label).to_string(),
+13 -1
View File
@@ -785,8 +785,20 @@ pub fn menu_scroll<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R
/// A column of tool or panel buttons (the toolbar, the dock's icon column) that scrolls when the
/// window is too short for it: with the mouse wheel, or the scroll bar shown while the pointer is
/// over it.
/// Its scroll bar stays a slim line at the edge, even while scrolled or dragged, so it never covers
/// the buttons (#808).
pub fn strip_scroll<R>(ui: &mut Ui, id: &str, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner
ui.scope(|ui| {
ui.spacing_mut().scroll = egui::style::ScrollStyle {
bar_width: 4.0,
floating_width: 2.0,
bar_inner_margin: 0.0,
bar_outer_margin: 1.0,
..egui::style::ScrollStyle::floating()
};
egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner
})
.inner
}
/// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a
+6 -4
View File
@@ -154,9 +154,11 @@ Clicking a swatch there runs `swatch.library.add {library, names: [name], apply}
one with Alt), so one undo step adds and applies it; Shift/Cmd-clicks select swatches and colour groups for
Add to Swatches.
`window.swatchLibrary.other {path?}` loads a library file (or another document's swatches) and opens it there;
opening a `.vcswatches`, `.gpl` or `.ase` file with `app.open` does the same. `ui.saveSwatchLibrary {names?}` opens the
`saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`css`, `user`: save to the user library
folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.save` (to a file it asks for a path).
opening a `.vcswatches`, `.gpl` or `.ase` file with `app.open` does the same. A file with the same extension and
bytes as a User Defined library opens as that library. `ui.saveSwatchLibrary {names?}` opens the
`saveSwatchLibrary` dialog (fields `name`, `format`: `vcswatches`/`gpl`/`ase`/`css`, `user`: save to the user library
folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `swatch.library.save` (to a file it asks for a path
and suggests `name` with the format's extension as the file name; the web downloads the file under that name).
Graphic style libraries open in the same panel: `window.graphicStyleLibrary {library}` (`library_panel: {kind:
"graphicStyles", id}`; `library: null` closes it). Clicking a style there runs `graphicStyle.addFromLibrary {library,
@@ -166,7 +168,7 @@ document's graphic styles) and opens it there; opening a `.vcstyles` file with `
Defined libraries are `window.userGraphicStyleLibrary1`…`10` in Window → Graphic Style Libraries.
`ui.saveGraphicStyleLibrary {names?}` opens the `saveGraphicStyleLibrary` dialog (fields `name`, `user`: save to the
user library folder, `selectedOnly` with `names`); `ui.dialog.confirm` runs `graphicStyle.saveLibrary` (to a file it
asks for a path).
asks for a path and suggests `name` with `.vcstyles` as the file name; the web downloads the file under that name).
Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`) opens the `tileEdgeColor` dialog (field
`color`: `#rrggbb` or a preset name such as "Light Blue"); `ui.dialog.confirm` sets the preference
+28 -2
View File
@@ -12,6 +12,13 @@ trunk build --release # writes ../../dist/web (index.html, .js glue
trunk serve --release # dev server on http://127.0.0.1:8766
```
What the web build needs: `trunk`, the `wasm32-unknown-unknown` target and `cargo`, nothing else (no
POSIX shell, so it works the same on Windows). Optionally, `CRAFT_FONTS_DIR` set to an absolute
craft-fonts checkout adds the fonts the site serves beside the wasm: Trunk's `post_build` hook runs
`cargo xtask web-fonts`, which copies them into `dist/web/fonts/` (it does nothing without
`CRAFT_FONTS_DIR`, and skips with a warning any listed face an older checkout lacks). See
[Arabic fonts and the web build](#arabic-fonts-and-the-web-build).
Any static file server works for `dist/web`, for example `python3 -m http.server 8766` inside that directory. The release `.wasm` is about 17.5 MB, or 7.1 MB gzipped, so serve it with compression.
URL flag: `?webgl` forces the WebGL2 backend.
@@ -124,6 +131,25 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci # also runs the Japanese-
CI job and every release job (`release.yml`) check craft-fonts out at a pinned commit; release
packages carry each embedded font's `OFL-<family>.txt`.
### Arabic fonts and the web build
Desktop builds load craft-fonts' Arabic families into the document font database (Noto Sans
Arabic first, the fallback for Arabic text; the others are picked by name).
The web build doesn't embed them (wasm size). Instead `crates/text/web-fonts.txt` lists the faces
it fetches from beside the wasm, `startup` (before the app starts) or `background` (after):
- `crates/text/build.rs` turns the list into `vectorcraft_text::WEB_FONTS` (URL
`fonts/<sha16>/<file>` and the SRI hash, from the craft-fonts manifest).
- `cargo xtask web-fonts` (`xtask/src/web_fonts.rs`), the Trunk `post_build` hook, copies the files
there, checking each one's SHA-256. A listed face the checkout lacks is skipped with a warning, as
in `build.rs`, so an older craft-fonts just means fewer fonts.
- `apps/vectorcraft-web/src/fonts.rs` fetches and registers them, logging `web font …` for any
that fail.
Try it: `cd apps/vectorcraft-web && CRAFT_FONTS_DIR=$PWD/../../../craft-fonts trunk serve`. To serve
another craft-fonts face on the web, add a line to `web-fonts.txt`.
## Localisation
Strings in code stay English and are the default lookup keys. `crates/ui-egui/src/i18n` maps them to display
@@ -167,10 +193,10 @@ locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Ukrainian (`uk`, com
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, Russian and Ukrainian cover every
placeholders are translated in turn (the reason after `: {e}` is often a message too). Spanish, French, Italian, Japanese, 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`, a `ru.tsv` and a `uk.tsv` row (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui
a `fr.tsv`, an `it.tsv`, a `ja.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
+37 -7
View File
@@ -612,6 +612,23 @@ the one highlighted layer or group row, else the one selected group, else the cu
so art added later is clipped too); called again it releases the mask. It returns `{clip}`, and the Layers panel
underlines clipping-path names.
## Object selection
`select.set {ids}` replaces the selection, `select.add {ids}` adds to it, and
`select.toggle {id}` or `select.toggle {ids}` toggles each target.
Every id must be a non-negative integer naming an existing object.
A malformed or unknown id is an error naming that value; the entire selection is left unchanged.
All three commands return `{count, ids}` for the resulting selection, in selection order.
An empty array clears the selection for `select.set` and leaves it alone for Add and Toggle.
`edit.clear {ids?}` deletes the exact selected objects, including individual compound-path members;
their unselected siblings remain in the compound. Supplying `ids` works without a selection and
overrides selected anchors or guides. Every explicit id is validated before any deletion; a malformed
or unknown id leaves the document unchanged. An empty `ids` array does nothing. Layers themselves
are kept, and selecting an ancestor together with its descendants deletes that subtree once.
Without `ids`, direct-selected anchors or selected ruler guides retain their usual Clear behavior.
Cut still removes the objects it copied, including a whole compound when a member is selected.
## Saved selections
Select → Save Selection… keeps the selected objects under a name, in the document: `select.save {name?}` (default
@@ -778,13 +795,22 @@ swatch in the library panel does). `swatch.resetDefaults {replace?}` brings back
{"name":"run_command","arguments":{"command":"swatch.library.get","params":{"library":"earth-tones"}}}
{"name":"run_command","arguments":{"command":"swatch.library.add","params":{"library":"earth-tones","names":["Clay"]}}}
```
`swatch.library.save {path?, format?: "vcswatches"|"gpl"|"css", names?, name?, user?}` writes the document's swatches
as a library (`.vcswatches` keeps colour models, global, spot, gradients and colour groups; `.gpl` is 8-bit RGB;
CSS writes custom properties); without `path` it returns `{data}`, and `user: true` saves into the user library
`swatch.library.save {path?, format?: "vcswatches"|"gpl"|"ase"|"css", names?, name?, user?}` writes the document's
swatches as a library (`.vcswatches` keeps colour models, global, spot, gradients and colour
groups; `.gpl` is 8-bit RGB; a swatch exchange `.ase` file keeps solid colors in their own model
(RGB, CMYK, Lab or Gray) as global, spot or process colors, and color groups, writes a tint swatch
as the color it shows and leaves gradients out; CSS writes custom properties); without `path`
it returns `{data}`, or `{dataBase64}` for `.ase`, and `user: true` saves into the user library
folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? |
dataBase64?, name?}` loads a `.vcswatches`, `.gpl` or swatch exchange (`.ase`) file, or another document's swatches,
as a library to add from. From an `.ase` file it reads RGB, CMYK, Lab and Gray colors as global, spot or process
swatches and keeps their color groups.
swatches and keeps their color groups. A file in the user library folder, or a file with the
same extension and bytes as one there, loads as that User Defined library (category `user`).
`swatch.library.copyToUser {library}` copies a loaded library into the user library folder of the desktop
app (a library file as it is; a document's swatches or a library loaded from `data` or `dataBase64`
as `.vcswatches`) under a name no file there has, and lists it as User Defined from then on; until the app
quits, commands given its loaded id use the copy → `{library, name, count, path, copied}` (`copied: false`
when the folder already held the same file). Without a user library folder the command is disabled.
## Graphic style libraries
@@ -807,7 +833,8 @@ its bounds.
the styles unlinked from swatches, with their opacity, blend mode, isolate and knockout, and the patterns they paint
with); without `path` it returns `{data}`, and `user: true` saves into the user library folder of the desktop app
(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.
file, or another document's graphic styles, as a library to add from; a file in the user library folder, or a file
with the same extension and bytes as one there, loads as that User Defined library.
## Libraries
@@ -2517,9 +2544,12 @@ Type can use the bundled fonts, fonts added to the session and the fonts install
the system's and the user's font folders (Windows: `Fonts` and `%LOCALAPPDATA%\Microsoft\Windows\Fonts`, plus fonts
registered outside them, such as fonts installed as shortcuts, and in the desktop app and `vectorcraft-cli` (MCP
included) the fonts in DirectWrite's system font collection, such as those Adobe Fonts activates while Creative Cloud runs; macOS: `/System/Library/Fonts`, `/Library/Fonts`,
`/Network/Library/Fonts`, `~/Library/Fonts` and downloaded system fonts; Linux and BSD: `/usr/share/fonts`,
`/Network/Library/Fonts`, `~/Library/Fonts` and downloaded system fonts, plus in the desktop app and `vectorcraft-cli`
the fonts CoreText's font manager lists outside them, such as those apps and font managers register from their own
folders; Linux and BSD: `/usr/share/fonts`,
`/usr/local/share/fonts`, `~/.fonts` and the XDG data folders' `fonts`, `~/.local/share/fonts` among them, and in a
Flatpak sandbox the host's fonts). The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup
Flatpak sandbox the host's fonts). Faces without outlines VectorCraft draws (no `glyf`, `CFF`, `CFF2` or `VARC` table, such
as bitmap-only fonts) are left out. The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup
by family name), so opening, placing, pasting and importing files find them whatever ran before. `text.fontList`
lists every family available, the installed ones included, as the font menus do: without the system's hidden
families, whose names start with "." (macOS's ".SF NS", ".LastResort"), which still resolve when a document names
+3 -2
View File
@@ -1,13 +1,14 @@
# Apache (see HOSTING.md). Needs mod_mime; mod_deflate and mod_headers are optional.
AddType application/wasm .wasm
AddType font/ttf .ttf
AddType text/javascript .js
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/wasm text/javascript text/html
AddOutputFilterByType DEFLATE application/wasm text/javascript text/html font/ttf
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "\.(wasm|js)$">
<FilesMatch "\.(wasm|js|ttf)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "^index\.html$">
+13
View File
@@ -50,6 +50,19 @@ location /vectorcraft/ {
Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/.
## Fonts folder
Builds made with craft-fonts (`CRAFT_FONTS_DIR`, all releases) have a `fonts/` folder beside the
wasm: the Arabic fonts the app fetches instead of embedding (about 8 MB, each next to its OFL
licence). Serve it with the rest of the site. Noto Sans Arabic is fetched before the app starts;
the other families load in the background and appear in the font menus as they arrive.
- Serve `.ttf` as `font/ttf` (the browser checks each file's SHA-256, so a wrong type or a
rewritten file makes the app skip that font and log `web font …` in the console).
- The folder names are the files' SHA-256 prefixes, so `fonts/*` can be cached forever
(`Cache-Control: public, max-age=31536000, immutable`); `_headers` and `.htaccess` do this.
- Without the folder the app still runs, but Arabic text shows as boxes.
## Embedding in a page (iframe)
```html
+4
View File
@@ -11,3 +11,7 @@
/index.html
Cache-Control: no-cache
/fonts/*
Content-Type: font/ttf
Cache-Control: public, max-age=31536000, immutable
+4
View File
@@ -12,6 +12,7 @@ mod layers;
mod sha256;
mod stats;
mod version;
mod web_fonts;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
@@ -32,6 +33,8 @@ commands:
ico <out.ico> <png>...
pack PNGs into a Windows .ico (used by packaging/icons.sh)
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk
post_build hook; a no-op without CRAFT_FONTS_DIR)
";
fn main() -> ExitCode {
@@ -49,6 +52,7 @@ fn main() -> ExitCode {
Some("ico") => ico::run(&rest),
Some("version") => version::run(&root(), &rest),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
Some("web-fonts") => web_fonts::run(&root()),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())
+119
View File
@@ -0,0 +1,119 @@
//! `cargo xtask web-fonts`: the Trunk `post_build` hook (`apps/*-web/Trunk.toml`). Copies the
//! craft-fonts faces listed in `crates/text/web-fonts.txt` from `$CRAFT_FONTS_DIR` into the site,
//! each at `fonts/<first 16 hex digits of its SHA-256>/<file>` with its licence beside it: the URLs
//! `crates/text/build.rs` puts in `WEB_FONTS` (both use `crates/text/build/web_fonts.rs`).
//!
//! A no-op without `CRAFT_FONTS_DIR`. A listed face the checkout lacks (an older craft-fonts) is
//! skipped with a warning, as `build.rs` does; a file whose SHA-256 doesn't match the manifest is an
//! error (a corrupt checkout). Rust rather than a shell script, so the hook runs wherever cargo does.
use std::path::{Path, PathBuf};
use crate::sha256;
#[allow(dead_code)] // build.rs uses the rest (sri, base64)
#[path = "../../crates/text/build/web_fonts.rs"]
mod list;
/// The hook: reads `CRAFT_FONTS_DIR` and Trunk's `TRUNK_STAGING_DIR`.
pub fn run(root: &Path) -> Result<(), String> {
let var = |name| std::env::var_os(name).filter(|v| !v.is_empty()).map(PathBuf::from);
run_with(root, var("CRAFT_FONTS_DIR"), var("TRUNK_STAGING_DIR"))
}
fn run_with(root: &Path, craft_fonts: Option<PathBuf>, staging: Option<PathBuf>) -> Result<(), String> {
let Some(dir) = craft_fonts else { return Ok(()) };
// Relative like build.rs: from the workspace root, not the directory Trunk runs hooks in.
let dir = if dir.is_absolute() { dir } else { root.join(dir) };
let staging = staging.ok_or("TRUNK_STAGING_DIR is unset: run this as the Trunk post_build hook")?;
let (copied, skipped) = copy(&root.join("crates/text/web-fonts.txt"), &dir, &staging)?;
if !skipped.is_empty() {
eprintln!("warning: web build won't fetch {} (not in CRAFT_FONTS_DIR={}; an older craft-fonts?)", skipped.join(", "), dir.display());
}
println!("web-fonts: copied {copied} font(s) into {}", staging.join("fonts").display());
Ok(())
}
/// Copy the faces `list_path` names from the craft-fonts checkout `dir` into `site` (at their
/// `fonts/…` URLs). Returns how many were copied and the listed faces the checkout lacks.
fn copy(list_path: &Path, dir: &Path, site: &Path) -> Result<(usize, Vec<String>), String> {
let read = |p: &Path| std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()));
let list = read(list_path)?;
let manifest = read(&dir.join("fonts/manifest.txt"))?;
let rows: Vec<Vec<&str>> =
manifest.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| l.split(" | ").map(str::trim).collect()).collect();
let (mut copied, mut skipped) = (0, Vec::new());
for e in list::parse_list(&list)? {
let Some(row) = rows.iter().find(|r| r.first() == Some(&e.family) && r.get(1) == Some(&e.style)) else {
skipped.push(format!("{} {}", e.family, e.style));
continue;
};
let (Some(file), Some(licence), Some(sha)) = (row.get(2), row.get(5), row.get(6)) else {
return Err(format!("malformed manifest row for {} {}", e.family, e.style));
};
let bytes = std::fs::read(dir.join(file)).map_err(|err| format!("{file}: {err}"))?;
let got = sha256::hex(&bytes);
if got != *sha {
return Err(format!("{file}: sha256 {got}, manifest says {sha}"));
}
let dest = site.join(list::url(sha, file));
let folder = dest.parent().ok_or_else(|| format!("{}: no parent folder", dest.display()))?;
std::fs::create_dir_all(folder).map_err(|err| format!("{}: {err}", folder.display()))?;
std::fs::write(&dest, &bytes).map_err(|err| format!("{}: {err}", dest.display()))?;
let licence_name = Path::new(licence).file_name().ok_or_else(|| format!("{licence}: not a file"))?;
std::fs::copy(dir.join(licence), folder.join(licence_name)).map_err(|err| format!("{licence}: {err}"))?;
copied += 1;
}
Ok((copied, skipped))
}
#[cfg(test)]
mod tests {
use super::*;
/// A fake craft-fonts checkout with one face, and a list naming it plus one it lacks.
fn fixture(name: &str, manifest_sha: Option<&str>) -> (PathBuf, PathBuf, PathBuf, String) {
let base = std::env::temp_dir().join(format!("xtask-web-fonts-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let dir = base.join("craft-fonts");
std::fs::create_dir_all(dir.join("fonts/a")).unwrap();
std::fs::write(dir.join("fonts/a/A-Regular.ttf"), b"font bytes").unwrap();
std::fs::write(dir.join("fonts/a/OFL.txt"), b"licence").unwrap();
let sha = sha256::hex(b"font bytes");
let row_sha = manifest_sha.map_or(sha.clone(), str::to_string);
std::fs::write(
dir.join("fonts/manifest.txt"),
format!("# family | style | file | scripts | licence | licence file | sha256 | source\nA Sans | Regular | fonts/a/A-Regular.ttf | Arab | OFL-1.1 | fonts/a/OFL.txt | {row_sha} | https://example.invalid\n"),
)
.unwrap();
let list = base.join("web-fonts.txt");
std::fs::write(&list, "startup | A Sans | Regular\nbackground | Missing Sans | Bold\n").unwrap();
(list, dir, base.join("site"), sha)
}
#[test]
fn copies_listed_faces_with_their_licence_to_their_urls() {
let (list, dir, site, sha) = fixture("copy", None);
let (copied, skipped) = copy(&list, &dir, &site).unwrap();
assert_eq!(copied, 1);
let at = site.join("fonts").join(&sha[..16]);
assert_eq!(std::fs::read(at.join("A-Regular.ttf")).unwrap(), b"font bytes");
assert_eq!(std::fs::read(at.join("OFL.txt")).unwrap(), b"licence");
assert_eq!(format!("fonts/{}/A-Regular.ttf", &sha[..16]), list::url(&sha, "fonts/a/A-Regular.ttf"));
assert_eq!(skipped, ["Missing Sans Bold"], "a face the checkout lacks is skipped, not an error");
}
#[test]
fn a_file_that_does_not_match_its_manifest_hash_is_an_error() {
let (list, dir, site, _) = fixture("mismatch", Some(&"0".repeat(64)));
let e = copy(&list, &dir, &site).unwrap_err();
assert!(e.contains("A-Regular.ttf") && e.contains("sha256"), "{e}");
}
#[test]
fn without_craft_fonts_the_hook_does_nothing() {
let (_, _, site, _) = fixture("noop", None);
run_with(Path::new("/nonexistent"), None, None).unwrap();
assert!(!site.exists());
}
}