Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 64891c1a2e Fix #234: EPS files from the reference app open
EPS files the reference app saves (its procsets in the prolog, a binary header with a TIFF
preview) now open as editable art instead of failing with "undefinedresource in Generic". When
their PostScript still can't be read, the palette preview they carry is placed with the usual
warning instead of the file being refused.

Root cause: five interpreter gaps and one preview format. The Generic and Category resource
categories didn't exist, so categories couldn't be made from Generic (now Category holds the
standard categories, defineresource in Category makes new ones, undefineresource is added);
clipsave/cliprestore were unknown (now a clip stack in the graphics state); systemdict's
languagelevel was the operator, not the integer 3; getinterval copied arrays and strings
instead of sharing their storage, so astore into a subarray was lost (arrays and strings are
now views into shared storage, and readstring/readline return the part they fill); and
resourceforall was unknown (now enumerates matching names with * ? \ templates). The preview
is an 8-bit palette TIFF with an alpha sample, which the image crate can't decode: a small
bounds-checked reader handles that layout (either byte order, any strips).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:34:27 +07:00
BFlat 83faf568b0 Merge pull request #244 from storytold/fix/issue-231
Fix #231: docs/releasing.md describes the release pipeline
2026-10-07 09:19:52 +07:00
bflatasticandClaude Opus 5.5 ee1e95d1c7 Fix #231: docs/releasing.md describes the release pipeline
release.yml's comments sent readers to docs/releasing.md, which never
existed: the workflow was ported from a sibling app without its document.
docs/releasing.md now covers how a release is cut (version bump with
cargo xtask version, the release branch, the draft, publishing, test runs
with a version override), what each job builds and how (macOS universal
signing and notarization, Windows x64/x86/arm64 with the PE checks and the
two signing options, Linux packages and the glibc 2.35 floor behind the
ubuntu-22.04 runners, FreeBSD in a VM, the web build, craft-fonts), the
draft release job and the signing secrets. Every detail is checked against
this repository's workflow and packaging scripts. The README links it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:17:10 +07:00
BFlat e960baa131 Merge pull request #243 from storytold/fix/i18n-review-followups
i18n: names are never translated; palette cache; locale probe off the UI thread
2026-10-07 08:43:42 +07:00
bflatasticandClaude Opus 5.5 0e5e093dd9 i18n: one label_or_name for lists mixing labels and names; normal style rows
panels::label_or_name and dialogs::shown_names applied the same rule; it
now lives once as i18n::label_or_name (explicit language, testable) and
both delegate to it. zh-hant gains the two normal text style names the
Character and Paragraph Styles panels translate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:37:43 +07:00
bflatasticandClaude Opus 5.5 6572636d62 i18n: panels show user, file and system names as they are
Panels passed names to the translating widgets, so a name that happened to
be a catalog key was translated (a font style "Regular", a workspace called
"Layers"). Font styles, pattern names, recent URLs, ICC profiles, saved
width profiles, user and loaded libraries, plug-in effects and their
parameters, Document Info's artboard/style/font/image rows, text-object
contents in Layers, user text styles in brackets, the Artboard tool's
canvas label and the app-bar workspace switcher now show names verbatim;
built-in entries (Working CMYK, (closest), built-in libraries and effects,
[Normal ... Style]) still translate. Formatted tips around a name are
translated as templates only (panels::label_or_name, library_name).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:36:44 +07:00
bflatasticandClaude Opus 5.5 cf7a43d786 i18n: dialogs show user, file and system names as they are
Lists and headings that mix our labels with names no longer send the
names through the catalog, so a preset, printer or plug-in called
"Regular" or "Black" keeps its name in every language.

- dialogs::shown_names / mixed_dropdown: translate only a list's built-in
  entries and show the rest with widgets::dropdown_names (same indices).
- Artboard lists (Save for Office, Save for Web, save options) and
  plug-in choices: dropdown_names.
- Mixed lists translate only their built-in entries: Recolor's Limit to
  Library, Save for Web, Print, Save PDF and Export for Screens presets,
  printers (Default Printer, PDF File), colour profiles and flattener
  presets (profile_pick, flatten::preset_dropdown), perspective presets,
  New Document's preset cards and Size, and the shortcut set.
- Plug-in dialogs and plug-in effects are headed by the plug-in's name as
  it is; plug-in parameter labels are not translated.
- The confirm dialog shows its message and detail as given, and the
  shared frame no longer translates headings again: each heading is
  translated by its spec. File > Revert translates its template before
  filling in the document's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:36:44 +07:00
bflatasticandClaude Opus 5.5 f4181890bd i18n review follow-ups: names are never translated, palette cache, locale probe off the UI thread
Follow-ups from the review of #205.

- Names that are user, file or system data are shown as they are: menu
  items for recent files, saved views, recent fonts and Type > Font,
  user swatch and graphic style libraries, saved perspective presets,
  custom workspaces (and Reset <name>) and plug-ins no longer go through
  the catalog (menus::shows_a_name). widgets::dropdown_names,
  menu_item_name and dim_name are the non-translating forms of the shared
  widgets, for the panels and dialogs that list such names.
- The command palette builds its entries (shown label, search text) once
  per language and plug-in set instead of translating ~700 labels every
  frame while a query is typed.
- The locale probe (reg.exe / defaults) runs on a background thread at
  startup instead of on the first frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:20:35 +07:00
BFlat 116ff3eee7 Merge pull request #205 from WildfootW/claude/laughing-pasteur-5d7kc7
i18n: UI localisation framework and a Traditional Chinese (zh-hant) catalog
2026-10-07 07:49:14 +07:00
WildfootWandClaude Fable 5.1 902c3ae8c1 i18n: UI localisation framework and a Traditional Chinese (zh-hant) catalog
Strings in code stay English and are the lookup keys; `crates/ui-egui/src/i18n`
maps them to display text at render time from one `*.tsv` catalog per language
(`LANGUAGES` registry, `tl!`/`t`/`tr`/`tr_ctx`/`tr_id`/`tn`/`fmt`). Command ids,
menu paths, `ui.menu.list`, the control channel, the CLI, MCP, status messages
and undo labels never see translated text, so agents, scripts and the existing
tests are unaffected (under `cargo test` the UI is always English).

Wired: in-window menus and the macOS native menu (rebuilt on a language change),
dynamic labels (Undo X, Reset X, Last Effect: X as templates), the command
palette (matches English and translated text), dock tabs, panel tooltips, the
toolbar (category names now cut by characters, not bytes), the dialog frame, the
shared widgets, the Preferences dialog, the shortcut editor, the hint and status
bars, the Home screen, and the literals of every panel and dialog.

Preference `interfaceLanguage` (User Interface › Language): `auto` or a code.
`auto` follows `VECTORCRAFT_LOCALE`, `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE`,
the macOS preferred languages and, on Windows (which sets no `LANG`), the user
locale from `HKCU\Control Panel\International`. `zh-TW`/`zh-HK`/`zh-MO`/
`zh-Hant-*` resolve to `zh-hant`; Simplified locales stay English until a
`zh-hans` catalog exists.

`zh-hant.tsv` (~2,850 rows) is clean-room, in the vocabulary used in Taiwan;
terms shared with PhotoCraft reuse its wording, vector terms use everyday
vector-drawing vocabulary. Tests: catalog well-formedness (placeholders,
ellipses, command ids, no Simplified characters), locale resolution, and two
coverage tests that fail when a menu string, panel/tool/preference label,
dialog button, hint-bar fragment or `tl!` literal has no row in a language
marked `complete_menus`. `VECTORCRAFT_I18N_DUMP=file cargo test -p
vectorcraft-ui-egui dump_source_strings` lists the strings for translators.

Also: two clippy findings under rustc 1.97 fixed in passing.

Integrated with main (squashed onto it, 42 commits on): the Czech interface
(#213) moves from its Rust table to `i18n/cs.tsv` on the registry (all 556
rows, Czech plural rule, its menu-coverage and glyph tests kept); Find Font's
resolution notes and the canvas context menu's labels are translated and
covered by the catalog tests (`menus::CONTEXT_LABELS`); the glyph test checks
CJK text only when the craft-fonts build input is embedded (#220 moved the
Japanese fonts out of this repository); a language saved by an older version
carries over to `interfaceLanguage`; Windows' locale lookup runs reg.exe by its
full path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:37:05 +07:00
BFlat 2507ed9dd9 Merge pull request #216 from fahrim/mcp-protocol-surface
MCP: prompts, resource templates, completions and logging
2026-10-07 07:35:21 +07:00
Fahri Meral e80b7e383b MCP: prompts, resource templates, completions and logging
The server answered tools/* and two fixed resources and nothing else, and
resources/templates/list returned an empty array. This adds the rest of the surface a
client can actually use, against 2025-06-18.

- prompts/list and prompts/get: five workflows (poster, icon-set, recolor,
  trace-and-style, export-set) with templated arguments. A missing required argument
  is -32602 and names itself.
- resources/templates/list, and resources/read for the templates: object/{id},
  command/{id}, effect/{id}, swatch/{name}. Reading one thing at a time instead of
  vectorcraft://document/json, which on a real document is thousands of lines an agent
  pays for again on every change.
- completion/complete for prompt arguments and template variables, ranked prefix
  first, capped at 100 with total and hasMore. The values are read live, so a suggested
  format, effect id, command id, trace preset, swatch or object id is one the engine
  has.
- logging/setLevel and notifications/message. Nothing installed a log::Log before, so
  the engine's log::debug! calls went nowhere; this is the sink, silent until asked.
  Installing it is vectorcraft-cli's job rather than the library's, so an embedder
  that already has a logger keeps it.

docs/mcp.md gains a Protocol section with the capability table and a Not implemented
section: subscriptions, progress, cancellation, sampling, elicitation and Streamable
HTTP all need the transport to interleave reads, which this one does not — handle_line
answers a line with a reply, so it neither reads an incoming cancellation nor can write
progress before the reply it belongs to.

62 tests (50 unit, 12 property/fuzz). protocol_props.rs gained five: prompts survive
junk arguments, completion/complete never panics and always answers at most 100
string values with a consistent hasMore, every template reads or fails cleanly, and
prompts/completions/templates are swept with junk params while asserting the document
comes back byte-identical.

ROADMAP: one bullet under Automation in Shipped so far and the changelog line. The
parity table and the gap list are untouched — MCP is agent automation, so feature
parity does not move.
2026-10-07 07:25:02 +07:00
BFlat 597b651bb6 Merge pull request #233 from theironchef/fix/canvas-edge-click
Canvas: a click just outside the canvas doesn't reach the tools
2026-10-07 07:24:21 +07:00
BFlat 945f8db359 Merge pull request #237 from theironchef/fix/flyout-panel-width
Panels: a flyout wider than 256 points grows towards the canvas
2026-10-07 07:24:13 +07:00
BFlat e50f02e638 Merge pull request #239 from theironchef/fix/dialog-width
Dialogs: the button row is as wide as the fields, not the window
2026-10-07 07:24:06 +07:00
BFlat aeaece9728 Merge pull request #240 from storytold/fix/font-test-installed-fonts
Tests: the font resolution test no longer depends on the installed fonts
2026-10-07 07:24:01 +07:00
bflatasticandClaude Opus 5.5 c6c2c7d0fe Tests: the font resolution test no longer depends on the installed fonts
list_says_how_each_font_resolved expected Source Sans 3 Black to be a
substitute, which holds only while the bundled family is the only Source
Sans 3: on a machine with the full family installed (it ships with many
font packs), Black resolves exactly and the test failed. It now asks for
a style no Source Sans 3 has, so the substitute path is tested anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:17:36 +07:00
theironchefandClaude Opus 5.5 61cd4ed6e6 Dialogs: the button row is as wide as the fields, not the window
The OK/Cancel row was laid out right to left at the full available
width, and a dialog without a maximum width may use the whole window:
the row, and the window with it, spanned the window (Clean Up, Blend
Options, every generic parameter dialog). The row now spans the fields
above it, at least the dialog's minimum width, so a dialog is sized to
its content.

Fixes #238.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:38:09 -07:00
theironchefandClaude Opus 5.5 0e1a5b3e45 Panels: a flyout wider than 256 points grows towards the canvas
The icon panel flyout was placed by its left edge, 262 points left of
the icon column, at a width of 256: a panel whose content is wider
(Separations Preview, Align, Transform, Stroke, Attributes, Gradient)
grew to the right, over the panel icons. It is now pinned by its right
edge, 6 points left of the column, so a wider panel grows towards the
canvas; panels of the usual width sit where they did.

Fixes #236.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:22:27 -07:00
Brandon Thomas b05e575eb8 Merge pull request #235 from storytold/docs/freebsd-platform
README: FreeBSD in the platform line and badge
2026-10-06 18:24:43 -04:00
Brandon ThomasandClaude Opus 5.5 1012361773 README: FreeBSD in the platform line and badge (build, test and release package are green)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:24:32 -04:00
Brandon Thomas f2a2671933 Merge pull request #128 from storytold/pr/freebsd-release
FreeBSD: release package and a packaging check in CI
2026-10-06 18:24:00 -04:00
theironchefandClaude Opus 5.5 10fafed6c4 Canvas: a click just outside the canvas doesn't reach the tools
egui counts a press a few pixels outside the canvas as hovering it (its
interaction radius), but the canvas only takes pointer positions inside
its rect, so such a press fell back to the canvas's centre: a click
just below the document tabs or beside the panels selected, typed or
drew in the middle of the view. Presses, and middle-button pans, now
start only on the canvas itself.

Fixes #232.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:10:50 -07:00
Brandon Thomas aafd565b99 Merge pull request #228 from EthanSuquet/fix/hide-dot-system-fonts
Fonts: the font menus leave out macOS's hidden "." families
2026-10-06 15:57:48 -04:00
Brandon Thomas 571881a830 Merge pull request #213 from petrleocompel/i18n/czech
i18n: Czech interface language
2026-10-06 15:50:25 -04:00
Brandon ThomasandClaude Opus 5.5 347369187f Merge main into #213 (after #207, #208, #209): one ROADMAP line for Japanese, Czech and vertical type
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:49:57 -04:00
Brandon Thomas d899a63286 Merge pull request #208 from imachus/pr/ja-vertical
Type: kinsoku, tate-chu-yoko and upright placement for vertical type
2026-10-06 15:49:07 -04:00
Brandon ThomasandClaude Opus 5.5 f95b032c82 Merge main into #208 (after #207 and #209): keep both IME and vertical-arrow tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:44:50 -04:00
Brandon Thomas ce9f3aa7c6 Merge pull request #207 from imachus/pr/ja-ime
Type: input method composition (Japanese IME) in the Type tool
2026-10-06 15:44:32 -04:00
Brandon Thomas ef3fa1689f Merge pull request #209 from imachus/pr/ja-fonts
Type: find fonts by any of their names; say how each one resolved
2026-10-06 15:44:17 -04:00
Brandon ThomasandClaude Opus 5.5 68352dab02 fmt: the font fallback path list fits on one line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:37:22 -04:00
Brandon ThomasandClaude Opus 5.5 51a9b899c1 Merge main into #209; leave other apps' private font folders alone
Conflicts with #214 (fonts by PostScript name): this branch's name index (FaceKeys,
aliases) stays, and #214's `by_postscript_name` (used by PDF import) reads a PostScript
map filled from it, keyed by the same normalized names.

Removed: scanning ~/Library/Application Support/Adobe (CoreSync/plugins/livetype and
Fonts), and the preference that switched it (Type › Include Fonts of Other Design Apps'
Folders). Fonts activated through another vendor's subscription are licensed for use
through that vendor's own sync; reading their files from VectorCraft would go around that
licence, and the project's clean-room rules keep VectorCraft out of other vendors' files.
Fonts the user has installed in the system or user font folders are still found by every
name, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:36:09 -04:00
BFlat 81e7b915af Merge pull request #229 from storytold/fix/issue-219
Fix #219: right-click on the canvas opens a context menu
2026-10-07 02:34:33 +07:00
EthanSuquetandClaude Opus 5.5 fcc9414480 Fonts: the font menus leave out macOS's hidden "." families
macOS keeps faces for its own interface under family names that start with
"." (".SF NS", ".LastResort", ".Aqua Kana"…); Mac apps don't list them. Since
the font menus list every installed family (#36), they showed 74 of them on a
stock macOS, at the top of every list.

- FontDb::menu_family_list: family_list without names that start with ".",
  cached like it. Only a leading dot hides a family.
- Type › Font, the family dropdowns (Character, Properties), text.fontList and
  Refresh Font List's count use it.
- Lookups by name still see every family: documents and fallbacks that name
  a hidden family still find it (face, has_family, find_family, families,
  text.fontList {family}, Fonts in Document's missing check).
- Test: tests_sysfonts::font_lists_leave_out_hidden_system_families_which_still_resolve
  (a temporary font folder); the engine and UI font-list tests compare with
  the listed families.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:33:21 -04:00
Brandon Thomas 377204f90e Merge pull request #225 from theironchef/fix/shift-drag-selected
Selection tool: Shift+drag on a selected object moves it, constrained
2026-10-06 15:30:18 -04:00
Brandon Thomas 0306d51592 Merge pull request #214 from p0lyg0n/fix/pdf-font-postscript-names
PDF/.ai import: fonts are found by their PostScript name, whatever hyphens the family holds
2026-10-06 15:30:14 -04:00
Brandon Thomas ebc6d1af7b Merge pull request #181 from japbcoelho/fix/cli-closed-stdout
CLI: a closed stdout ends vectorcraft-cli quietly
2026-10-06 15:30:09 -04:00
bflatasticandClaude Opus 5.5 cdb734aede Fix #219: right-click on the canvas opens a context menu
Right-clicking the canvas now opens a context menu, as the reference app
does. The object under the pointer is selected first unless it already is.
The menu then lists what applies to the selection: Undo and Redo, Cut, Copy,
Paste, Isolate Selected Group or Exit Isolation Mode, Group and Ungroup,
Join and Average, Make or Release Clipping Mask and Compound Path, Make
Guides, the Transform, Arrange and Select submenus, and Export Selection.
On empty canvas it lists Paste, the zoom commands, the ruler, grid and guide
toggles and Select All. Commands that can't run now are left out (Undo and
Redo stay, greyed out), and a press on the canvas only closes the menu. Agents list the same menu with the
`ui.contextMenu.list` control method.

Root cause: the canvas ignored the secondary button except in the toolbar's
tool flyouts; there was no context menu at all. The menu reuses the menu
bar's items, rendering and dispatch; Object > Transform and Arrange are now
shared lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:28:52 +07:00
BFlat 2f743f155f Merge pull request #227 from storytold/fix/issue-221
Fix #221: Effect > Pathfinder works on several selected objects
2026-10-07 02:27:53 +07:00
bflatasticandClaude Opus 5.5 4bc0837867 Fix #221: Effect > Pathfinder works on several selected objects
Choosing an Effect > Pathfinder item (Add, Intersect, Exclude, Subtract...)
with several loose objects selected now groups them and applies the live
effect to the group, in one undo step, so the result shows at once and stays
editable. A selection that already holds a group, or a single object, works
as before, and agents get `grouped: true` back when it happens.

Root cause: Pathfinder effects combine the contents of a group or layer.
Applied to loose objects, each one got an effect with nothing to combine, so
nothing visibly changed, while the Pathfinder panel's commands (found by the
command search) act on the selection directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:21:06 +07:00
BFlat 2ba0e1f6e5 Merge pull request #226 from storytold/fix/issue-177
Fix #177: DXF Fit and Aligned text span their two points
2026-10-07 02:20:43 +07:00
theironchefandClaude Opus 5.5 80eb5844c7 Selection tool: Shift+drag on a selected object moves it, constrained
Pressing with Shift on an object that was already selected toggled it
out of the selection at once and went idle, so the drag that followed
moved nothing. The toggle now waits for the release: a Shift-click
still takes the object out of the selection, while a Shift-drag moves
the selection constrained, as it already did for unselected objects
and when Shift was pressed mid-drag.

Fixes #224.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-06 12:16:02 -07:00
bflatasticandClaude Opus 5.5 82be706b94 Fix #177: DXF Fit and Aligned text span their two points
DXF text justified Fit now keeps its height and stretches or squeezes
horizontally to run exactly from its first point to its second; Aligned
text scales as a whole, height included, to do the same, its width factor
kept. Attributes justified the same way follow suit. Without a second point,
or with both points the same, the text keeps its size as before.

Root cause: the importer took the angle of the two points and anchored the
text at the first, but kept the text's height and width factor, ignoring the
distance between the points. The text now records that span and, when it
becomes a text object, is measured in the font it is set in, then given the
horizontal scale (Fit) or size (Aligned) that makes it end at the second
point, kept between 1% and 10,000% of its natural length. The import's
bounds estimate uses the same scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:14:39 +07:00
BFlat 5af667182a Merge pull request #223 from storytold/fix/issue-206
Fix #206: Live Paint treats every path as an edge, open lines included
2026-10-07 02:14:17 +07:00
bflatasticandClaude Opus 5.5 700b6786c4 Fix #206: Live Paint treats every path as an edge, open lines included
The Live Paint Bucket now fills areas enclosed by any paths. Crossing
lines enclose faces it can fill, a line across a shape splits that shape's
face in two, and an area enclosed by paths becomes a face even when nothing
fills it (it starts unfilled). Edges are the paths cut wherever another path
meets them, keeping their stroke, so Shift-click paints each piece. Closed
shapes give the same faces as before.

Root cause: Live Paint built its faces from the Pathfinder regions, which
divide the filled areas of closed shapes. An open path counted only as the
area it would fill if closed, which for a straight line is nothing, so lines
divided nothing and the bucket found no face to fill. A new planar map in
vectorcraft-pathops (live_paint) sweeps every path as an edge: an open path
goes in traced there and back, with crossing counts that never cancel; faces
are walked on the resulting graph (holes included), and their fills and the
edges' sources are found geometrically. The sweep runs in an exactly sheared
frame, because it mishandles a horizontal line traced there and back.
Make, Merge, the bucket and Live Paint Selection all use it;
interior_point moves to vectorcraft-pathops with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:08:39 +07:00
Brandon ThomasandClaude Opus 5.5 77159bb08e FreeBSD: the release and package jobs build with craft-fonts, like the other platforms
Since 546a65f every release job checks storytold/craft-fonts out at its pinned commit and
builds with CRAFT_FONTS_DIR and CRAFT_FONTS_REQUIRED=1. The FreeBSD jobs do the same inside
the VM, so FreeBSD packages carry the Japanese fonts and their OFL texts. The package check
now requires each expected file (the font licences included) instead of any one of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:41:41 -04:00
Brandon Thomas 047a3bfc6f Merge remote-tracking branch 'origin/main' into pr/freebsd-release 2026-10-06 14:41:07 -04:00
Brandon Thomas 856e36c694 Merge pull request #220 from storytold/pr/craft-fonts
Fonts: use craft-fonts as an optional build input; move the Japanese fonts out
2026-10-06 14:37:41 -04:00
Brandon ThomasandClaude Opus 5.5 721d1118f3 Fonts: docs use an absolute CRAFT_FONTS_DIR; note the web size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:19:24 -04:00
Brandon ThomasandClaude Opus 5.5 3491b99321 Fonts: the Japanese-menu glyph test skips without craft-fonts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:09:46 -04:00
Brandon ThomasandClaude Opus 5.5 546a65f77a Fonts: use craft-fonts as an optional build input; move the Japanese fonts out
Shippori Mincho (assets/fonts, from #145) moves to storytold/craft-fonts. crates/text/build.rs
embeds the fonts of a craft-fonts checkout when CRAFT_FONTS_DIR is set (web: BIZ UDPGothic
Regular only) as vectorcraft_text::CRAFT_FONTS; unset, it is empty and Japanese falls back to the
installed fonts. Document text gets the Japanese faces after the bundled fonts (Mincho first); the
UI adds them at the end of every egui family (BIZ UDPGothic first), before ui_fonts' system-font
discovery. Release and FreeBSD CI builds check craft-fonts out at a pinned commit; packages carry
the fonts' OFL texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:42:21 -04:00
Brandon Thomas 77708ad8de Merge pull request #215 from storytold/pr/windows-arm64
Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
2026-10-06 10:13:23 -04:00
Brandon ThomasandClaude Opus 5.5 129c9ca61e Windows ARM64 workflow: pin third-party actions by commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:04 -04:00
Brandon ThomasandClaude Opus 5.5 ceb6defcf3 Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
Release builds add an arm64 MSI and portable zip, cross-compiled on the x64
runner (signing and WiX as for x64). package.ps1 checks each binary's PE
header before packaging: Machine must match -Arch, the app must be a GUI
program and the CLI a console program. It skips the CLI smoke run when an
ARM64 build can't run on the host. A new Windows ARM64 workflow packages the
ARM64 MSI, installs it on a Windows 11 ARM64 runner, checks both installed
exes are ARM64, runs the CLI, and uninstalls. Ported from printcraft#66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:38:29 -04:00
imachusandClaude Opus 5.5 ab6a668aa9 Type: kinsoku, tate-chu-yoko and upright placement for vertical type
Builds on the vertical type from #145 (same data, commands and tools).

- Kinsoku (the strict set, horizontal Japanese too): no line starts with a
  closing bracket, 、。, a middle dot, colon, !?, the long vowel mark,
  iteration marks or small kana; no line ends with an opening bracket.
  Hyphenation breaks obey it too.
- Tate-chu-yoko: a run of two or three half-width digits (10月, 第100回)
  stands upright across the column as one block one em long, squeezed to
  the em when wider; longer numbers stay on their side.
- Upright or on its side by Unicode's Vertical_Orientation: CJK and
  symbols upright, marks that need a vertical form on their side when the
  font has none; upright glyphs advance at least one em, centred, and turn
  about the ideographic em box centre so they sit on the column's centre
  line. Point type keeps its anchor on the first column's centre line.
- `vrt2` is no longer requested: its glyphs are already on their side, so
  with the layout turning Latin and digits too they came out upside down
  (and tate-chu-yoko blocks broke) in fonts that have it, e.g. Hiragino.
- The line→text mapping is one affine (`TextLayout::line_xf`, an exact
  quarter turn plus the point-type offset); `logical_point` and
  `physical_point` use it. Pixel snapping skips vertical type.
- Tests: kinsoku in both directions, tate-chu-yoko blocks, upright vs
  sideways, anchor on the centre line, arrows along columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:02:38 +09:00
imachusandClaude Opus 5.5 8dc2fd077f Type: find fonts by any of their names; say how each one resolved
Documents made elsewhere often name a font differently from the family
name VectorCraft lists: a localized family name (ヒラギノ角ゴシック), a
legacy family/style pair (Hiragino Sans + W3) or a PostScript name
(HiraginoSans-W3). FontDb now indexes every name in a face's name table
and resolves a request to the right face, telling the caller whether it
matched exactly, by another name, by substitution or not at all
(FontMatch).

- text: FaceKeys/aliases, FontDb::resolve, canonical, find_postscript;
  fix a race where a face loaded by another thread was reported missing.
- engine: text.fonts reports status, the resolved face and missing
  glyphs; Replace Font resolves the same way.
- render: substituted-font highlighting uses resolve, so a font found
  by another name is no longer shown as missing.
- Find Font notes how each font was found.
- Preferences › Type › Include Fonts of Other Design Apps' Folders
  (macOS, on by default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:45:10 +09:00
imachusandClaude Opus 5.5 66fa0a998a Type: input method composition (Japanese IME) in the Type tool
Typing with an input method (the macOS Japanese IME and the like) used to
insert the raw keys (romaji came in as ASCII): egui's Ime events weren't
handled.

- Tools gain `ime_preedit` / `composing` / `ime_caret`; the Type tool lays
  the marked text out in place inside its typing session, underlined (the
  clause being converted thicker), and commits it through `text_input`.
  Character ranges from the IME convert to byte ranges in one place. A
  cancelled composition leaves no empty undo step; an empty clear with
  nothing marked never deletes the selection.
- The UI sends Text and Ime events to the tool in order, sends IMEOutput
  every frame while text is edited (what lets egui-winit enable the IME
  and place its candidate window at the caret, or the converting clause),
  and holds back keys, Enter/Escape, clipboard, single-key shortcuts and
  menu commands (Undo, Cut, Select All… also from the native menu) while
  composing. A click, a tool switch or a style change ends the
  composition with the marked text kept as typed; on macOS the IME is
  told to discard it (NSTextInputContext.discardMarkedText) so it doesn't
  come back in the next input.
- Undo during a typing session also ends the Type tool's session, so its
  caret and marked text follow the document.
- Tests: tools (char → byte ranges, underlines and candidate position),
  engine (compose → commit is one step, cancel leaves no step, a click
  commits, Undo mid-composition is safe), ui-egui (synthetic Ime events:
  compose, commit, ⌘Z held back, IME output, click and app switch).

Checked by hand on macOS 27 with the Japanese IME (romaji input, convert,
commit, cancel, Undo/Redo after commit, click away, Latin mode, app
switch). Not checked: Windows and Linux IMEs, the web build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:41:50 +09:00
Petr Leo CompelandClaude Opus 5.5 b045390311 i18n: Czech interface language
VectorCraft ▸ Language gains Čeština (`app.language {lang: "cs"}`,
persisted as "cs"). `Language::tr` now picks a table per language.

The Czech table (crates/ui-egui/src/i18n/czech.rs) translates every
menu title, submenu, item and header in the menu tree, plus the labels
toggling items switch to (Show/Hide Guides, Lock/Unlock Grid, Outline/
Preview, Edit Contents/Edit Envelope…). It was written clean-room from the
meaning of the English labels: commands in the infinitive, menus as nouns.
Left as-is on purpose: the product name, language names, OpenType, the
built-in workspace names and the perspective grid preset names.

Tests:
- every Czech (and Japanese) entry is a menu label, toggled labels included;
- Czech covers every menu label except fonts, sizes, plug-in names and
  an explicit KEEP_AS_IS list;
- ellipses match their source label, entries are unique;
- the Language menu lists every language; `cs` validates and persists;
- each UI font family's own first font has the Czech letters and
  punctuation (no system fonts needed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 14:23:11 +02:00
Brandon Thomas 7a39cb7b89 Merge pull request #212 from storytold/pr/release-0.3.1
Release: VectorCraft v0.3.1
2026-10-06 08:03:26 -04:00
Brandon ThomasandClaude Opus 5.5 15ab25426e Release: VectorCraft v0.3.1
Fixes the Windows installer: the Start Menu shortcut and Add/Remove Programs icon were blank. Release builds no longer open a console window behind the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:03:13 -04:00
Brandon Thomas 899a82d31d Merge pull request #211 from storytold/pr/windows-packaging
Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
2026-10-06 07:53:56 -04:00
Brandon ThomasandClaude Opus 5.5 7bca89dd15 Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
The MSI Icon id had no .ico/.exe extension, which Windows Installer needs for
advertised shortcuts and ARPPRODUCTICON, so both showed a blank icon. Release
builds of the desktop app are now GUI-subsystem (no console window; the CLI stays
a console program), the shortcut/summary description names what VectorCraft does, and
packaging-lint rejects an Icon id without an extension. Found in printcraft#57.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:50:24 -04:00
Brandon Thomas 7f5344a60c Merge pull request #145 from midasdf/feat/japanese-interface-language
Add Japanese menus and initial vertical type tools
2026-10-06 07:43:47 -04:00
Brandon ThomasandClaude Opus 5.5 691a54e7fd Roadmap and README: Japanese interface and initial vertical type
The appended Interface localization section moves into the roadmap's structure: a
Shipped so far entry, the Type advanced row (45%, CJK composition left), the gap list,
the inventories (16 stubbed menu items; Touch Type the only missing tool) and totals
recomputed from the parity table (75.3%, 211–332 h to feature parity); README Status
follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:43:25 -04:00
Brandon ThomasandClaude Opus 5.5 1733fa5fdb i18n: keep only VectorCraft's own menu labels, and test that
47 of the 83 entries were labels from other apps ("New blank PDF", "Comments panel",
"Read mode"…) that never appear here. A test now walks the menu tree and fails on any
entry that isn't one of VectorCraft's labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:43:25 -04:00
Brandon Thomas 7715dcd285 Merge remote-tracking branch 'origin/main' into pr145-v2 2026-10-06 07:43:14 -04:00
Hiroshi InukaiandClaude Opus 5.5 caba979b01 PDF/.ai import: fonts are found by their PostScript name, whatever hyphens the family holds
A document names its fonts by PostScript name, which the importer split at the
first hyphen into family and style. A family with hyphens in its name was never
found: "Rounded-X-Mplus-1c-black" became family "rounded", style
"x-mplus-1c-black", and the text fell back to another font. Common Japanese
families have hyphens in their names.

The system font scan now also indexes each installed face's PostScript name
(read from the same name table it already reads). The importer asks for the face
with that exact name first (`FontDb::by_postscript_name`) and splits the name only
when no installed face has it.

Test: installed faces are found by PostScript name, in any case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:18:53 +09:00
Brandon Thomas 025315f4bf Merge pull request #210 from storytold/fix/clippy-byte-str
clippy: byte string in the envelope test (main's clippy step is red on Rust 1.99)
2026-10-06 07:09:43 -04:00
midasdf 44b2b984ed i18n: add Japanese menus and initial vertical type tools 2026-10-06 16:55:47 +09:00
japbcoelhoandClaude Opus 5.5 32f73e9cce CLI: a closed stdout ends vectorcraft-cli quietly
`vectorcraft-cli commands | head -1` panicked and exited with status 101:

  thread 'main' panicked at library/std/src/io/stdio.rs:1168:9:
  failed printing to stdout: Broken pipe (os error 32)

println! and print! panic when stdout is closed. Output now goes through
outln! and out!, which end the program with status 0 on a broken pipe (the
reader has what it wanted) and report any other write error on stderr with
status 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:09:22 -03:00
Brandon ThomasandClaude Opus 5.5 0aba6ef6e4 Merge main (#129, the shared FreeBSD CI template) into pr/freebsd-release
The build job follows the shared template; this branch adds only the package job
and its PR paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:20:41 -04:00
Brandon ThomasandClaude Opus 5.5 980372f6d0 FreeBSD: release package and a packaging check in CI
- packaging/freebsd/package.sh builds vectorcraft and vectorcraft-cli and writes
  vectorcraft-<version>-freebsd-x86_64.tar.gz, a /usr/local-style tree (bin/, the
  freedesktop desktop entry, MIME type and metainfo, hicolor icons, licences, NOTICE
  and ASSETS.md). FreeBSD's install(1) has no -D and uname -m says amd64.
- release.yml: a FreeBSD x86_64 job in the same FreeBSD 14.3 VM; the version, date
  and SHA reach the VM through `envs:`. The draft release waits for it.
- freebsd.yml: vmactions/freebsd-vm pinned by commit (v1.5.9), git safe.directory for
  the copied checkout, CARGO_BUILD_JOBS=4, and a `package` job that runs the script on
  PRs touching FreeBSD packaging and on demand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:25:46 -04:00
223 changed files with 13917 additions and 2279 deletions

No files matched your search

+56
View File
@@ -1,5 +1,6 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
# The `package` job checks packaging/freebsd/package.sh (the release job runs it for real).
name: FreeBSD
on:
@@ -11,6 +12,8 @@ on:
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
@@ -27,6 +30,13 @@ jobs:
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
# The optional craft-fonts build input (Japanese fonts), so the tests that need it run here
# rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md).
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
@@ -44,5 +54,51 @@ jobs:
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
# The checkout above is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
package:
name: FreeBSD 14 (package)
# The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies)
# and on demand, not on every push to main.
if: github.event_name != 'push'
runs-on: ubuntu-latest
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
# Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
mem: 12288
prepare: |
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM.
# The craft-fonts checkout is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
# Every part the package must have, the craft-fonts licences included.
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
done
- uses: actions/upload-artifact@v4
with:
name: freebsd-package
path: dist/release/*
if-no-files-found: error
+4
View File
@@ -47,6 +47,10 @@ jobs:
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.vectorcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
+92 -1
View File
@@ -70,6 +70,13 @@ jobs:
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
@@ -90,6 +97,8 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
@@ -112,11 +121,21 @@ jobs:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -141,6 +160,8 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
@@ -167,6 +188,13 @@ jobs:
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
@@ -184,6 +212,9 @@ jobs:
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
@@ -201,6 +232,56 @@ jobs:
path: dist/release/*
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
runs-on: ubuntu-latest
timeout-minutes: 150
environment: release
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
# Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
if: inputs.version != ''
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and package in a FreeBSD VM
# v1.5.9, pinned by commit.
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
mem: 12288
# Passed through by name; never templated into the script.
envs: "VECTORCRAFT_VERSION VECTORCRAFT_BUILD_DATE VECTORCRAFT_BUILD_SHA"
prepare: |
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM.
# The craft-fonts checkout is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-freebsd
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
@@ -212,6 +293,13 @@ jobs:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
@@ -227,6 +315,9 @@ jobs:
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
@@ -236,7 +327,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, web]
needs: [version, macos, windows, linux, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
+95
View File
@@ -0,0 +1,95 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: vectorcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: vectorcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'VectorCraft'
foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'vectorcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok vectorcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' }
Write-Output 'ok uninstalled'
+2
View File
@@ -5,3 +5,5 @@ plan/
/corpus
/log
.DS_Store
# A craft-fonts checkout for CRAFT_FONTS_DIR (CI and release workflows check it out here)
/craft-fonts
+1
View File
@@ -22,6 +22,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
+4
View File
@@ -6,6 +6,10 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo
Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`).
**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The small Latin UI/document fonts below predate that rule and stay.
**Optional build input: craft-fonts.** Builds made with `CRAFT_FONTS_DIR=<craft-fonts checkout>` (all official releases) embed the Japanese fonts listed in its [`ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md): BIZ UDPGothic Regular and Bold, Shippori Mincho Regular and BIZ UDMincho Regular (web builds: BIZ UDPGothic Regular only), all under the SIL Open Font License 1.1. They are not files in this repository; release packages carry each one's `OFL-<family>.txt`. Builds without `CRAFT_FONTS_DIR` embed none of them.
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
+1
View File
@@ -22,6 +22,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
Generated
+27 -25
View File
@@ -4211,7 +4211,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"arboard",
"clipboard-win",
@@ -4220,6 +4220,8 @@ dependencies = [
"image",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"rfd",
"serde_json",
"vectorcraft-engine",
@@ -4231,7 +4233,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4243,7 +4245,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4259,7 +4261,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"serde_json",
"vectorcraft-color",
@@ -4275,7 +4277,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4287,7 +4289,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4301,7 +4303,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4315,7 +4317,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"flate2",
"image",
@@ -4347,7 +4349,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"flate2",
"image",
@@ -4364,7 +4366,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"flate2",
"proptest",
@@ -4378,7 +4380,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4390,7 +4392,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"log",
@@ -4409,7 +4411,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4423,7 +4425,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4435,7 +4437,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"aes",
"flate2",
@@ -4462,7 +4464,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"serde",
"serde_json",
@@ -4476,7 +4478,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"flate2",
"gif",
@@ -4501,7 +4503,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"flate2",
"image",
@@ -4525,7 +4527,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4542,7 +4544,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
@@ -4555,7 +4557,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4570,7 +4572,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4583,7 +4585,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"egui",
"egui_extras",
@@ -4611,7 +4613,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"eframe",
"egui",
@@ -5685,7 +5687,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.3.0"
version = "0.3.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+5
View File
@@ -29,3 +29,8 @@ based in part on the work of the Independent JPEG Group.
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
under the Apache License 2.0 with the LLVM exception.
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ
UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL
Open Font License 1.1. Their license texts ship with the release packages as OFL-<family>.txt.
+12 -5
View File
@@ -16,13 +16,13 @@
<p align="center">
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
macOS, Windows and Linux, and in the browser via WebAssembly. Built by the ArtCraft team.
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
</p>
<p align="center">
<img alt="Status: in active development" src="https://img.shields.io/badge/status-in%20active%20development-e8573f">
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-b83a24?logo=rust&logoColor=white">
<img alt="Runs on macOS, Windows, Linux and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-555555">
<img alt="Runs on macOS, Windows, Linux, FreeBSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-555555">
<img alt="MCP server for agents" src="https://img.shields.io/badge/agents-MCP%20server-555555">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-555555">
</p>
@@ -154,6 +154,10 @@ cd apps/vectorcraft-web && trunk build --release # web build → dist
cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names
```
Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
### Use it from Claude Code and other agents
Register the MCP server with Claude Code:
@@ -178,7 +182,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0
**What's missing:**
- 3D and Materials;
- the Photoshop-style raster effects (Effect Gallery);
- vertical and CJK type;
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- Variables and scripting;
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- packaging for Windows and Linux.
@@ -193,7 +197,8 @@ the UI can be swapped without touching the engine.
Agent and contributor rules (clean-room, the asset policy, no panics in shipped code, quality gates) are in
[`AGENTS.md`](AGENTS.md); [`docs/development.md`](docs/development.md#robustness-vectorcraft-never-crashes)
explains how VectorCraft avoids crashing. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
explains how VectorCraft avoids crashing, and [`docs/releasing.md`](docs/releasing.md) how releases are built,
signed and published. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
## The Crafting Apps
@@ -241,7 +246,9 @@ VectorCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-AP
Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors. Required notices are in [NOTICE](NOTICE).
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [ASSETS.md](ASSETS.md).
with its author, source and license in [ASSETS.md](ASSETS.md). Release builds also embed the
Japanese fonts of [craft-fonts](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md)
(SIL Open Font License 1.1).
The app icon (an engraved dragon on VectorCraft red, `#e8573f`) is the owner's original artwork; its
palette and files are in [`assets/app-icon/`](assets/app-icon/README.md).
+29 -23
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-06 (after M4.14–M4.98, M8.1–M8.20 and M14.4–M14.7; see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7 and the MCP prompts, resource templates, completions and logging; see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -14,8 +14,8 @@ _Last updated: 2026-10-06 (after M4.14–M4.98, M8.1–M8.20 and M14.4–M14.7;
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~225–345 h** one agent · **~60–100 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~330–515 h** one agent · **~85–150 h** with 4–6 agents |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents |
## Honest assessment (2026-10-05)
@@ -33,9 +33,9 @@ since 2026-10-01.
| Dimension | Estimate | Evidence and what's missing |
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type and vertical type ×3; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~35%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, vertical/CJK type, Variables (data merge), scripting |
| **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku and tate-chu-yoko; ruby, mojikumi and vertical metrics open), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
@@ -58,9 +58,10 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
The largest single gap. 50–80 h.
4. **Advanced type:** vertical type and its tools, CJK composition, Optical Margin Alignment, the
composer/hyphenation options, tab leaders, a spell-check dictionary (open licence), Touch Type and Snap to Glyph.
41–55 h across type core and advanced.
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, vertical font metrics, manual
tate-chu-yoko), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
initial support. 35–48 h across type core and advanced.
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
generated in code (never Adobe's); dynamic symbols; Start Global Edit. 14–22 h.
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
@@ -88,12 +89,15 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- JSON-lines control channel with real egui pointer and keyboard injection.
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless.
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
- Actions panel that records and plays back commands.
- **UI:** Illustrator 2026 layout restyled to measured values:
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, Czech covers every menu label and Japanese the main menus, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference.
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
@@ -111,8 +115,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech every menu label (tested) and Japanese the main menus; untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko, upright glyphs centred on the column, point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **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 and loaded as `.vcswatches`, `.gpl` or CSS. 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, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
@@ -124,10 +129,11 @@ 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.
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export.
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview.
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
@@ -143,7 +149,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
@@ -153,7 +159,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~330–515** |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
## Parity estimate
@@ -171,7 +177,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder edge cases | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
@@ -179,29 +185,29 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 |
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against real files (only reduced repros so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done) | 15–25 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| **Feature parity** | **103** | **~75%** | | **~225–345** |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
| **Full parity** | | **~56%** | | **~330–515** |
| **Full parity** | | **~56%** | | **~320–500** |
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
(integration, review and shared files such as `menus.rs` serialize some work): **~60–100 h** to feature parity,
**~85–150 h** to full parity.
(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity,
**~80–145 h** to full parity.
_Inventories (2026-10-05):_ 18 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
implemented except four (Touch Type, vertical type ×3); 51 panel modules; Illustrator-style live
_Inventories (2026-10-05):_ 16 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
implemented except Touch Type; 51 panel modules; Illustrator-style live
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
+40 -7
View File
@@ -14,11 +14,41 @@
use std::io::Write;
use std::process::ExitCode;
/// `println!` / `print!` that end the program quietly when stdout is closed
/// (`vectorcraft-cli commands | head`) instead of panicking with "failed printing to stdout:
/// Broken pipe (os error 32)".
macro_rules! outln {
($($arg:tt)*) => {{
use std::io::Write as _;
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
$crate::stdout_failed(e);
}
}};
}
macro_rules! out {
($($arg:tt)*) => {{
use std::io::Write as _;
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
$crate::stdout_failed(e);
}
}};
}
mod perf;
use serde_json::{Value, json};
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
/// ripgrep does; any other write error is reported.
fn stdout_failed(e: std::io::Error) -> ! {
if e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
eprintln!("vectorcraft-cli: can't write to stdout: {e}");
std::process::exit(1);
}
const USAGE: &str = "\
vectorcraft-cli — VectorCraft automation
@@ -68,11 +98,11 @@ fn main() -> ExitCode {
Some("bench") => bench(&args[1..]),
Some("perf") => perf::run(&args[1..]),
Some("-h" | "--help" | "help") | None => {
print!("{}", usage());
out!("{}", usage());
Ok(())
}
Some("-V" | "--version") => {
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
@@ -112,6 +142,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
}
};
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
// The binary owns the logger, not the library: installing one here keeps an embedder that
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
vectorcraft_mcp::logging::install();
let stdin = std::io::stdin();
let stdout = std::io::stdout();
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
@@ -120,7 +153,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
fn commands() -> Result<(), String> {
let mut h = Headless::new();
let v = h.call("engine.commands", json!({}))?;
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
Ok(())
}
@@ -146,7 +179,7 @@ fn convert(args: &[String]) -> Result<(), String> {
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
)
.map_err(|e| format!("export {output}: {e}"))?;
println!("{r}");
outln!("{r}");
Ok(())
}
@@ -162,7 +195,7 @@ fn info(args: &[String]) -> Result<(), String> {
let artboards: Vec<Value> =
doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect();
let v = json!({"file": file, "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts});
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
Ok(())
}
@@ -252,7 +285,7 @@ fn bench(args: &[String]) -> Result<(), String> {
* vectorcraft_geom::Affine::scale(z)
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
let opts = vectorcraft_render::RenderOptions::default();
println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
outln!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
for threads in [vectorcraft_render::default_threads(), 0] {
let mut r = vectorcraft_render::Renderer::new();
r.threads = threads;
@@ -261,7 +294,7 @@ fn bench(args: &[String]) -> Result<(), String> {
for _ in 0..iters {
r.render(&doc, w, h, view, &opts);
}
println!(
outln!(
" threads {threads}: {:.1} ms/frame (drawn {}, culled {})",
t.elapsed().as_secs_f64() * 1000.0 / iters as f64,
r.stats.drawn,
+6 -6
View File
@@ -81,14 +81,14 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let load = load_average();
println!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
if noisy {
println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
}
let t = Instant::now();
let doc = synthetic(n)?;
println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
let mut rows: Vec<(&str, f64, f64)> = vec![];
let opts = RenderOptions::default();
@@ -150,16 +150,16 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
let mut over = 0;
println!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
for (name, ms, budget) in &rows {
let ok = ms <= budget;
over += usize::from(!ok);
println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
}
match (over, noisy) {
(0, _) => Ok(()),
(_, true) => {
println!("{over} over budget, but the machine is busy: re-run when idle.");
outln!("{over} over budget, but the machine is busy: re-run when idle.");
Ok(())
}
_ => Err(format!("{over} budget(s) exceeded")),
+14
View File
@@ -82,3 +82,17 @@ fn mcp_headless_over_stdio() {
assert_eq!(replies[2]["result"]["isError"], false);
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
}
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
/// stdout: Broken pipe (os error 32)" and exit status 101.
#[test]
fn closed_stdout_ends_quietly() {
use std::io::Read;
let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut first = [0u8; 16];
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
drop(child.stdout.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
}
+2
View File
@@ -30,6 +30,8 @@ clipboard-win = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] }
[build-dependencies]
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
+17
View File
@@ -5,6 +5,7 @@
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `vectorcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
mod clipboard;
mod control_server;
@@ -41,12 +42,27 @@ impl eframe::App for App {
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
#[cfg(target_os = "macos")]
if self.0.take_ime_discard() {
discard_marked_text();
}
}
fn on_exit(&mut self) {
save_prefs(&self.0);
}
}
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
/// typed). winit's IME toggle only clears its own copy, so the IME would type it again.
#[cfg(target_os = "macos")]
fn discard_marked_text() {
if let Some(mtm) = objc2::MainThreadMarker::new()
&& let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm)
{
ic.discardMarkedText();
}
}
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
fn prefs_path() -> Option<std::path::PathBuf> {
@@ -230,6 +246,7 @@ fn app_icon() -> egui::IconData {
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
fn main() -> eframe::Result {
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
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 args = std::env::args().skip(1);
+22 -10
View File
@@ -10,6 +10,7 @@ use muda::accelerator::Accelerator;
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::Value;
use vectorcraft_ui_egui::VectorcraftApp;
use vectorcraft_ui_egui::i18n::{self, Lang, t};
use vectorcraft_ui_egui::menus::{self, Item};
enum Handle {
@@ -27,6 +28,8 @@ pub struct NativeMenu {
slots: usize,
/// Plug-in registry revision the Plug-ins submenus were built for.
plugins: u64,
/// The language the labels were built in (a change rebuilds the menu).
language: Lang,
}
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
@@ -47,7 +50,7 @@ impl NativeMenu {
let mut items = HashMap::new();
let mut counter = 0usize;
for (title, entries) in menus::menu_tree() {
let sub = Submenu::new(title, true);
let sub = Submenu::new(t(title), true);
build(app, &sub, &entries, &mut items, &mut counter);
let _ = menu.append(&sub);
}
@@ -58,7 +61,15 @@ impl NativeMenu {
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
.map(|(_, _, _, c)| c.clone())
.collect();
Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app), plugins: menus::plugin_revision() }
Self {
_menu: menu,
items,
last_refresh: 0.0,
generation,
slots: menus::listed_slots(app),
plugins: menus::plugin_revision(),
language: i18n::current(),
}
}
/// Dispatch clicked items and refresh state.
@@ -74,11 +85,12 @@ impl NativeMenu {
return;
}
self.last_refresh = now;
// Shortcuts edited, workspaces added, saved views, recent files or plug-ins changed: rebuild so
// accelerators and lists are current.
// Shortcuts edited, workspaces added, saved views, recent files, plug-ins or the UI language
// changed: rebuild so accelerators, lists and labels are current.
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|| menus::listed_slots(app) != self.slots
|| menus::plugin_revision() != self.plugins
|| i18n::current() != self.language
{
*self = NativeMenu::install(app);
return;
@@ -111,7 +123,7 @@ impl NativeMenu {
"view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters"
)
{
i.set_text(menus::dynamic_label(app, cmd, ""));
i.set_text(menus::display_label(app, cmd, ""));
}
}
Handle::Check(c) => {
@@ -136,13 +148,13 @@ fn build(
let _ = parent.append(&PredefinedMenuItem::separator());
}
Item::Header(h) => {
let _ = parent.append(&MenuItem::new(*h, false, None));
let _ = parent.append(&MenuItem::new(t(h), false, None));
}
Item::Todo(label, sc) => {
let _ = parent.append(&MenuItem::new(*label, false, accel(sc)));
let _ = parent.append(&MenuItem::new(t(label), false, accel(sc)));
}
Item::Sub(label, children) => {
let sub = Submenu::new(*label, true);
let sub = Submenu::new(t(label), true);
build(app, &sub, children, items, counter);
let _ = parent.append(&sub);
}
@@ -153,11 +165,11 @@ fn build(
let id = format!("dc{counter}");
let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None };
let handle = if menus::checked(app, cmd, params).is_some() {
let c = CheckMenuItem::with_id(id.clone(), *label, true, false, sc);
let c = CheckMenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, false, sc);
let _ = parent.append(&c);
Handle::Check(c)
} else {
let i = MenuItem::with_id(id.clone(), *label, true, sc);
let i = MenuItem::with_id(id.clone(), menus::display_label(app, cmd, label), true, sc);
let _ = parent.append(&i);
Handle::Plain(i)
};
+25 -4
View File
@@ -13,7 +13,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Dash, Document, LAY
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
use super::ImportOptions;
use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, TextItem};
use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, Span, TextItem};
use super::reader::{Drawing, LayoutDef};
use crate::{CAP_HEIGHT, MAX_NEST};
@@ -244,14 +244,18 @@ impl<'d, 'a> Build<'d, 'a> {
Geom::Text(t) => {
let lines = t.text.lines().count().max(1) as f64;
let longest = t.text.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64;
let w = longest * t.height * 0.9 * t.width;
let natural = longest * t.height * 0.9 * t.width;
// Fit and aligned text run their span; aligned text's height scales with it.
let k = t.span.and_then(|sp| sp.scale(natural)).unwrap_or(1.0);
let height = if matches!(t.span, Some(Span::Aligned(_))) { t.height * k } else { t.height };
let w = natural * k;
let x0 = match t.justify {
vectorcraft_doc::Justify::Center => -w / 2.0,
vectorcraft_doc::Justify::Right => -w,
_ => 0.0,
};
let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + t.height * 0.3;
Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, t.height)))
let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + height * 0.3;
Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, height)))
}
Geom::Insert(ins) => {
let b = self.block_bounds(&ins.block, depth);
@@ -502,6 +506,9 @@ impl<'d, 'a> Build<'d, 'a> {
let mut obj = TextObject::point(Point::ZERO, &t.text, style);
obj.xf = m * Affine::scale(1.0 / s);
obj.para.justify = t.justify;
if let Some(span) = t.span {
fill_span(&mut obj, span, s);
}
Some(Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))))
}
@@ -581,6 +588,20 @@ impl<'d, 'a> Build<'d, 'a> {
}
}
/// Fit text stretched or squeezed across, aligned text scaled as a whole, to run its span
/// (`s`: points per drawing unit), measured in the font it is set in.
fn fill_span(obj: &mut TextObject, span: Span, s: f64) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj);
let natural = layout.lines.iter().map(|l| l.x1 - l.x0).fold(0.0, f64::max);
let Some(k) = span.scale(natural / s) else { return };
for r in &mut obj.runs {
match span {
Span::Fit(_) => r.style.h_scale *= k,
Span::Aligned(_) => r.style.size *= k,
}
}
}
fn rgb_color([r, g, b]: [u8; 3]) -> Color {
Color::rgb8(r, g, b)
}
+39 -1
View File
@@ -19,6 +19,9 @@ const LINE_SPACING: f64 = 5.0 / 3.0;
const MAX_CELLS: i64 = 10_000;
/// Art farther than this from the origin (drawing units) is damaged data, left out.
const MAX_COORD: f64 = 1e12;
/// How far fit and aligned text may be squeezed or stretched to span their two points: at width
/// factor 1, the 1 to 10,000% horizontal scale the Character panel allows.
const SPAN_SCALE: (f64, f64) = (0.01, 100.0);
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
pub(crate) fn aci_paper(i: u64) -> [u8; 3] {
@@ -121,6 +124,27 @@ pub(crate) struct TextItem {
pub family: Option<String>,
/// Baseline-to-baseline distance (drawing units), for several lines.
pub leading: Option<f64>,
/// The distance aligned and fit text run along their baseline.
pub span: Option<Span>,
}
/// How text fills the distance between its two points (drawing units).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Span {
/// Stretched or squeezed across, its height kept.
Fit(f64),
/// Scaled as a whole, height included.
Aligned(f64),
}
impl Span {
/// The scale that makes text `natural` long run the span, within [`SPAN_SCALE`] (None when
/// either length is degenerate).
pub fn scale(self, natural: f64) -> Option<f64> {
let (Self::Fit(d) | Self::Aligned(d)) = self;
let k = d / natural;
(k.is_finite() && k > 0.0).then(|| k.clamp(SPAN_SCALE.0, SPAN_SCALE.1))
}
}
/// A block reference: one transform per copy (an array insert makes several), each from the
@@ -608,10 +632,13 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0));
let (p1, p2) = (g.point(10), g.point_opt(11));
let mut angle = g.num_or(50, 0.0).to_radians();
let mut span = None;
// Aligned and fit text run from the first point to the second.
let anchor = match (h, p2) {
(3 | 5, Some(p)) => {
angle = (p - p1).atan2();
let d = p1.distance(p);
span = (d > 1e-12).then_some(if h == 3 { Span::Aligned(d) } else { Span::Fit(d) });
p1
}
(0, _) if v == 0 => p1,
@@ -636,6 +663,7 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
family: None,
leading: None,
span,
})
}
@@ -662,5 +690,15 @@ fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12);
let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2());
let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first));
Some(TextItem { text: s, height, xf, width: 1.0, justify, style: g.name(7).unwrap_or("STANDARD").to_uppercase(), family, leading: Some(leading) })
Some(TextItem {
text: s,
height,
xf,
width: 1.0,
justify,
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
family,
leading: Some(leading),
span: None,
})
}
+161
View File
@@ -397,6 +397,167 @@ fn text_and_multiline_text() {
assert!((leading - 3.5 * 5.0 / 3.0).abs() < 1e-9, "{leading}");
}
/// Where the first line's baseline starts and ends, in document coordinates.
fn baseline_ends(t: &TextObject) -> (Point, Point) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let l = &layout.lines[0];
(t.xf * Point::new(l.x0, l.baseline), t.xf * Point::new(l.x1, l.baseline))
}
#[test]
fn fit_and_aligned_text_span_their_two_points() {
// Millimetres: red lines at x = 10 and 90, fit and aligned text between them, and the same
// text left-justified for its natural size.
let e = [
(0, "LINE"),
(62, "1"),
(10, "10"),
(20, "0"),
(11, "10"),
(21, "60"),
(0, "LINE"),
(62, "1"),
(10, "90"),
(20, "0"),
(11, "90"),
(21, "60"),
(0, "TEXT"),
(10, "10"),
(20, "40"),
(40, "5"),
(1, "FIT BETWEEN"),
(72, "5"),
(11, "90"),
(21, "40"),
(0, "TEXT"),
(10, "10"),
(20, "10"),
(40, "5"),
(41, "0.8"),
(1, "ALIGNED"),
(72, "3"),
(11, "90"),
(21, "10"),
(0, "TEXT"),
(10, "10"),
(20, "25"),
(40, "5"),
(1, "FIT BETWEEN"),
(0, "TEXT"),
(10, "10"),
(20, "50"),
(40, "5"),
(41, "0.8"),
(1, "ALIGNED"),
];
let o = ImportOptions { center: false, ..ImportOptions::default() };
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
let [left, right, fit, aligned, fit_natural, aligned_natural] = &leaves(&doc)[..] else { panic!() };
let (x10, x90) = (bounds(left).center().x, bounds(right).center().x);
for n in [fit, aligned] {
let (a, b) = baseline_ends(text_of(n));
assert!((a.x - x10).abs() < 1e-3 && (b.x - x90).abs() < 1e-3, "{a:?} to {b:?}, not {x10} to {x90}");
assert!((a.y - b.y).abs() < 1e-9, "level");
}
// Fit keeps its height and stretches across.
let (st, natural) = (text_of(fit).first_style(), text_of(fit_natural).first_style());
assert_eq!(st.size, natural.size);
assert!(st.h_scale > 100.0, "{}", st.h_scale);
// Aligned grows as a whole, its width factor kept.
let (a, b) = baseline_ends(text_of(aligned_natural));
let k = (x90 - x10) / (b.x - a.x);
let (st, natural) = (text_of(aligned).first_style(), text_of(aligned_natural).first_style());
assert!((st.size - natural.size * k).abs() < 1e-6 && k > 2.0, "{} vs {} × {k}", st.size, natural.size);
assert!((st.h_scale - 80.0).abs() < 1e-9 && (natural.h_scale - 80.0).abs() < 1e-9);
}
#[test]
fn fit_and_aligned_text_without_a_second_point_keep_their_size() {
for h in ["3", "5"] {
let e = [
(0, "TEXT"),
(10, "10"),
(20, "10"),
(40, "7"),
(1, "Gap"),
(72, h),
(0, "TEXT"),
(10, "10"),
(20, "30"),
(40, "7"),
(1, "Gap"),
(72, h),
(11, "10"),
(21, "30"),
];
let doc = open(&entities(&e), &points()).document;
let top = height(&doc);
for (n, y) in leaves(&doc).iter().zip([10.0, 30.0]) {
let t = text_of(n);
let st = t.first_style();
assert!((st.size - 10.0).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
assert!((t.xf * Point::ZERO).distance(Point::new(10.0, top - y)) < 1e-9);
}
}
}
#[test]
fn fit_and_aligned_text_scale_within_limits() {
// A span a billion units long and one a millionth of a unit long.
let e = [
(0, "TEXT"),
(10, "0"),
(20, "0"),
(40, "7"),
(1, "X"),
(72, "5"),
(11, "1e9"),
(21, "0"),
(0, "TEXT"),
(10, "0"),
(20, "10"),
(40, "7"),
(1, "X"),
(72, "3"),
(11, "1e-6"),
(21, "10"),
];
let doc = open(&entities(&e), &points()).document;
let [fit, aligned] = &leaves(&doc)[..] else { panic!() };
let st = text_of(fit).first_style();
assert!((st.size - 10.0).abs() < 1e-9 && (st.h_scale - 10_000.0).abs() < 1e-6, "{} at {}%", st.size, st.h_scale);
let st = text_of(aligned).first_style();
assert!((st.size - 0.1).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
}
#[test]
fn fit_attributes_span_their_two_points() {
let inserts = [
(0, "INSERT"),
(66, "1"),
(2, "Door"),
(10, "0"),
(20, "0"),
(0, "ATTRIB"),
(10, "10"),
(20, "20"),
(40, "7"),
(1, "D-01"),
(2, "TAG"),
(70, "0"),
(72, "5"),
(74, "0"),
(11, "90"),
(21, "20"),
(0, "SEQEND"),
];
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
let tag = leaves(&doc).into_iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap();
let (a, b) = baseline_ends(text_of(&tag));
assert!((a.x - 10.0).abs() < 1e-3 && (b.x - 90.0).abs() < 1e-3, "{a:?} to {b:?}");
assert_eq!(text_of(&tag).first_style().size, 10.0);
}
/// A block "Door" (base point 5, 5) holding `block_art`, and `inserts`.
fn with_door(block_art: &[(i32, &'static str)], inserts: &[(i32, &'static str)]) -> Vec<u8> {
let mut blocks = vec![(0, "BLOCK"), (8, "0"), (2, "Door"), (70, "0"), (10, "5"), (20, "5"), (3, "Door")];
+4
View File
@@ -414,6 +414,9 @@ pub enum TextKind {
/// A text object. `runs` split into paragraphs at `\n`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextObject {
/// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub vertical: bool,
pub kind: TextKind,
/// Maps text space (origin = first baseline start for point type) to the document.
pub xf: Affine,
@@ -437,6 +440,7 @@ pub struct TextObject {
impl TextObject {
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
Self {
vertical: false,
kind: TextKind::Point,
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun { text: text.into(), style }],
+8 -51
View File
@@ -16,7 +16,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _};
use vectorcraft_geom::{FillRule, Point, Rect, Shape as _};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
self as b, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, edge_near, face_at, is_live_paint, sample_polyline, shapes_for, sorted_roots,
@@ -205,52 +205,6 @@ fn stroke_only(paint: Paint, width: f64) -> Appearance {
Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], ..Default::default() }
}
/// A point strictly inside a filled path (for re-finding faces after a rebuild).
pub(crate) fn interior_point(path: &PathData) -> Option<Point> {
let bb = path.bounds()?;
let bp = path.to_bezpath();
let mut edges: Vec<(Point, Point)> = vec![];
let (mut first, mut last) = (Point::ZERO, Point::ZERO);
kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el {
kurbo::PathEl::MoveTo(p) => {
if last != first {
edges.push((last, first));
}
first = p;
last = p;
}
kurbo::PathEl::LineTo(p) => {
edges.push((last, p));
last = p;
}
kurbo::PathEl::ClosePath => {
if last != first {
edges.push((last, first));
}
last = first;
}
_ => {}
});
if last != first {
edges.push((last, first));
}
let mut best: Option<(f64, Point)> = None;
for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] {
let y = bb.y0 + bb.height() * f;
let mut xs: Vec<f64> =
edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect();
xs.sort_by(f64::total_cmp);
for w in xs.windows(2) {
let m = Point::new((w[0] + w[1]) / 2.0, y);
let width = w[1] - w[0];
if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 {
best = Some((width, m));
}
}
}
best.map(|b| b.1)
}
// ---------- Shape Builder ----------
fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
@@ -395,7 +349,11 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
src_group.name = Some(SOURCES_NAME.into());
src_group.visible = false;
children.push(Arc::new(src_group));
let regs = po::regions(&shapes);
// Every path is an edge, open ones included; faces are the areas they enclose.
let (regs, edges) = po::live_paint(&shapes);
if regs.is_empty() && edges.is_empty() {
return Err(EngineError::Other("Live Paint: these paths are too degenerate to paint".into()));
}
let nfaces = regs.len();
for r in regs {
let id = d.alloc_id();
@@ -404,10 +362,9 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
n.name = Some(FACE_NAME.into());
children.push(Arc::new(n));
}
let edges = po::pathfinder(po::PathfinderOp::Outline, &shapes);
let nedges = edges.len();
for e in edges {
let (_, stroke, w) = &styles[e.key as usize];
let Some((_, stroke, w)) = styles.get(e.key as usize) else { continue };
let id = d.alloc_id();
let mut n = Node::path(id, e.path, stroke_only(stroke.clone(), *w));
n.name = Some(EDGE_NAME.into());
@@ -451,7 +408,7 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
.iter()
.filter(|f| !f.appearance.fill_paint().is_none())
.filter_map(|f| Some((interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.collect();
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
.iter()
+2 -1
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
has_doc,
text_create
),
@@ -401,6 +401,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let y = f64_req(p, "y", "text.create")?;
let text = str_param(p, "text").unwrap_or("");
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
if let Some(a) = p.get("area") {
let w = f64_or(a, "width", 200.0);
let h = f64_or(a, "height", 100.0);
+11
View File
@@ -116,10 +116,21 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// A typing session in progress (the Type tool previews the whole session as one interaction).
fn typing_in_progress(s: &Session) -> bool {
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow
// the document.
let typing = typing_in_progress(s);
s.commit_interaction()?;
if typing {
s.set_tool_option("endTyping", &Value::Bool(true));
}
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
+51 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}",
has_doc,
apply
),
@@ -130,6 +130,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
let effect = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
let gid = s.edit(&label, |d, sel| {
let gid = super::object::group_nodes(d, &loose)?;
d.node_mut(gid).ok_or(EngineError::NoNode(gid))?.appearance.effects.push(effect.clone());
sel.set([gid]);
Ok(gid)
})?;
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
}
let item = item_target(s, p, C)?;
let mut index = 0;
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
@@ -143,6 +152,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
}
/// The selected objects a Pathfinder effect should group before it applies: it combines the
/// contents of a group or layer, so on several loose objects (none a group) it would do nothing.
/// `None` when the effect isn't one, objects are named (`ids`) or another target is asked for.
fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<NodeId>>> {
if !effects::is_pathfinder(id) || ["ids", "id", "item", "target"].iter().any(|k| p.get(k).is_some()) {
return Ok(None);
}
let roots = super::appearance::subject_roots(s)?;
let d = &s.doc()?.doc;
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
Ok((roots.len() >= 2 && !grouped).then_some(roots))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let catalog: Vec<Value> = effects::effect_catalog()
.into_iter()
@@ -507,6 +529,34 @@ mod tests {
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn pathfinder_effect_on_loose_objects_groups_them_first() {
let (mut s, a) = session_with_rect();
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
assert_eq!(r["grouped"], true);
let g = NodeId(r["ids"][0].as_u64().unwrap());
let gn = node(&s, g);
assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [a, b], "stacking order kept");
assert!(vectorcraft_render::effects::has_pathfinder(&gn));
assert_eq!(s.doc().unwrap().selection.objects, [g]);
// Live: one united shape, so expanding leaves a single path.
s.execute("effect.expandAppearance", &json!({})).unwrap();
assert_eq!(node(&s, g).children().unwrap().len(), 1);
// Grouping and applying are one undo step.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(g).is_none());
assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b));
// A selection holding a group applies to each object as before; so does a single object.
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
assert!(r.get("grouped").is_none());
assert_eq!(node(&s, a).appearance.effects.len(), 1);
}
#[test]
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
let (mut s, a) = session_with_rect();
+62 -15
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fonts in Document",
[],
None,
"{selectionOnly?} → [{family, style, runs, objects, missing}] sorted by name",
"{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)",
has_doc,
fonts
),
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Replace Font",
[],
None,
"{from: {family, style?}, to: {family, style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
has_doc,
replace
),
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Font List",
[],
None,
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), as the font menus list them (without the system's hidden families, whose names start with a dot; they still resolve by name); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
always,
font_list
),
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Refresh Font List",
[],
None,
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families available, installed faces found)",
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families listed, installed faces found)",
always,
rescan
),
@@ -96,12 +96,12 @@ 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 families = db.families();
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>)> = BTreeMap::new();
let mut found: BTreeMap<(String, String), (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();
e.0 += 1;
e.2.push_str(&r.text);
if !e.1.contains(&id) {
e.1.push(id);
}
@@ -109,10 +109,20 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
}
let list: Vec<Value> = found
.into_iter()
.map(|((family, style), (runs, objects))| {
let have_family = families.iter().any(|f| f.eq_ignore_ascii_case(&family));
let missing = !have_family || !db.styles(&family).iter().any(|st| st.eq_ignore_ascii_case(&style));
json!({ "family": family, "style": style, "runs": runs, "objects": objects.len(), "missing": missing })
.map(|((family, style), (runs, objects, chars))| {
let resolved = db.resolve(&family, &style);
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!({
"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(),
})
})
.collect();
Ok(json!(list))
@@ -130,9 +140,11 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
let (ff, fs) = font_param(p, "from", C)?;
let (tf, ts) = font_param(p, "to", C)?;
let db = vectorcraft_text::FontDb::global();
if !db.families().iter().any(|f| f.eq_ignore_ascii_case(&tf)) {
return Err(bad(C, format!("font family `{tf}` is not available")));
}
// Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans).
let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) {
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(),
_ => return Err(bad(C, format!("font family `{tf}` is not available"))),
};
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
let matches = |st: &vectorcraft_doc::CharStyle| {
st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x))
@@ -196,7 +208,7 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
Ok(json!({ "family": name, "styles": db.styles(family) }))
}
None => Ok(json!({ "families": *db.family_list() })),
None => Ok(json!({ "families": *db.menu_family_list() })),
}
}
@@ -229,7 +241,7 @@ fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
}
d.revision += 1;
}
Ok(json!({ "families": db.family_list().len(), "faces": faces }))
Ok(json!({ "families": db.menu_family_list().len(), "faces": faces }))
}
#[cfg(test)]
@@ -261,6 +273,41 @@ mod tests {
mod open_tests {
use super::*;
#[test]
fn list_says_how_each_font_resolved() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
// "Black Wide" is a style no Source Sans 3 has, bundled or installed, so it is always a
// substitute (the installed family can hold a real Black).
for (text, font, style) in
[("Exact", "Source Sans 3", "Semibold"), ("Closest", "Source Sans 3", "Black Wide"), ("Gone", "No Such Font Family", "Regular")]
{
s.execute("text.create", &json!({"x": 10, "y": 20, "text": text, "font": font, "style": style})).unwrap();
}
let list = s.execute("text.fonts", &json!({})).unwrap();
let rows: Vec<(String, String, String, String)> = list
.as_array()
.unwrap()
.iter()
.map(|f| {
(
f["style"].as_str().unwrap().into(),
f["status"].as_str().unwrap().into(),
f["resolved"]["family"].as_str().unwrap().into(),
f["resolved"]["style"].as_str().unwrap().into(),
)
})
.collect();
let row = |style: &str| rows.iter().find(|r| r.0 == style).cloned().unwrap();
assert_eq!(row("Semibold").1, "exact");
assert_eq!(row("Black Wide").1, "substitute", "{rows:?}");
assert_eq!(row("Black Wide").2, "Source Sans 3");
assert_eq!((row("Regular").1.as_str(), row("Regular").2.as_str()), ("missing", vectorcraft_text::FALLBACK_FAMILY));
s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Ab 雅楽", "font": "Inter"})).unwrap();
let inter = s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|f| f["family"] == "Inter").cloned().unwrap();
assert_eq!(inter["missingGlyphs"], 2, "雅 and 楽 come from a fallback font: {inter}");
}
#[test]
fn opened_documents_have_exact_text_bounds() {
let mut s = Session::new();
+13 -7
View File
@@ -375,20 +375,26 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
fn group(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
let gid = s.edit("Group", |d, sel| {
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
for id in &ids {
d.move_node(*id, Some(gid), usize::MAX)?;
}
let gid = group_nodes(d, &ids)?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0 }))
}
/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was.
pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
for id in ids {
d.move_node(*id, Some(gid), usize::MAX)?;
}
Ok(gid)
}
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Ungroup", |d, sel| {
+7
View File
@@ -260,6 +260,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")),
p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool),
// `auto` or a language code the shell registers (`zh-hant`); the shell shows it as a dropdown.
p!("interfaceLanguage", "User Interface", "Language", "Language", text),
// Performance
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
@@ -390,6 +392,11 @@ pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
Err(format!("`{key}` must be a #rrggbb colour"))
}
}
PrefKind::Text if key == "interfaceLanguage" => {
let s = v.as_str().map(str::trim).unwrap_or("");
let ok = !s.is_empty() && s.len() <= 16 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
if ok { Ok(json!(s.to_ascii_lowercase())) } else { Err(format!("`{key}` must be `auto` or a language code such as `en` or `zh-hant`")) }
}
PrefKind::Text => match v {
Value::String(s) => Ok(json!(s)),
_ => Err(format!("`{key}` must be a string")),
+3 -1
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
has_doc,
create_in_path
),
@@ -368,6 +368,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
};
let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } };
let mut t = TextObject {
vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false),
kind,
xf: Affine::IDENTITY,
runs: vec![TextRun { text, style }],
@@ -404,6 +405,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
let head = edit::slice_runs(&t.runs, 0, end);
let measure = |tr: f64| {
let mut h = TextObject {
vertical: t.vertical,
kind: TextKind::Point,
xf: Affine::IDENTITY,
runs: head.clone(),
+44
View File
@@ -13,6 +13,18 @@ use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("type.orientation.vertical", "Vertical", ["Type", "Orientation"], None, "{ids?|id?} set vertical writing", has_selection, |s, p| {
orientation(s, p, true)
}),
cmd!(
"type.orientation.horizontal",
"Horizontal",
["Type", "Orientation"],
None,
"{ids?|id?} set horizontal writing",
has_selection,
|s, p| orientation(s, p, false)
),
cmd!(
"type.createOutlines",
"Create Outlines",
@@ -61,6 +73,21 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
let ids = text_targets(s, p, "Text Orientation")?;
s.edit("Text Orientation", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
return Err(EngineError::NoNode(*id));
};
t.vertical = vertical;
refresh_bounds(t);
}
Ok(())
})?;
Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
}
/// Recompute the layout bounds cache after a text edit.
pub(crate) fn refresh_bounds(t: &mut TextObject) {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
@@ -288,6 +315,23 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
mod area_tests {
use super::*;
#[test]
fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 100, "y": 50, "text": "§§§§ abc"})).unwrap()["id"].as_u64().unwrap();
let bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
let wide = bounds(&s);
assert!(wide.width() > wide.height());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("type.orientation.vertical", &json!({})).unwrap();
let tall = bounds(&s);
assert!(tall.height() > tall.width(), "{tall:?}");
assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(bounds(&s), wide);
}
#[test]
fn area_options_columns_change_layout() {
let mut s = Session::new();
+4
View File
@@ -391,6 +391,9 @@ pub struct Prefs {
pub large_tabs: bool,
pub ui_scaling: f64,
pub scale_cursor_with_ui: bool,
/// UI language: `auto` (follow the system locale) or a language code such as `en`, `zh-hant`.
/// The list of languages belongs to the shell (`ui-egui` i18n); an unknown code reads as `auto`.
pub interface_language: String,
// Performance
pub gpu_performance: bool,
pub animated_zoom: bool,
@@ -592,6 +595,7 @@ impl Default for Prefs {
large_tabs: false,
ui_scaling: 1.0,
scale_cursor_with_ui: false,
interface_language: s("auto"),
gpu_performance: true,
animated_zoom: true,
history_states: 500,
+50
View File
@@ -310,6 +310,56 @@ fn live_paint_bucket_tool_fills_face() {
assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]);
}
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId {
let r = s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
/// Crossing lines, as in the bug report: the Live Paint Bucket fills the area they enclose.
#[test]
fn live_paint_bucket_fills_an_area_enclosed_by_lines() {
let mut s = session();
let ids = [line(&mut s, (0.0, 0.0), (100.0, 100.0)), line(&mut s, (100.0, 0.0), (0.0, 100.0)), line(&mut s, (-10.0, 80.0), (110.0, 80.0))];
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
let v = ViewInfo::default();
s.select_tool("livePaintBucket", v).unwrap();
s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 1.0));
// Inside the triangle (20,80) (50,50) (80,80): highlighted, then filled.
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, 70.0), v).unwrap();
assert!(!s.overlays(v).is_empty(), "red face highlight");
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 70.0), v).unwrap();
let g = s.doc().unwrap().selection.objects[0];
let gn = node(&s, g);
assert!(vectorcraft_tools::builder::is_live_paint(&gn));
assert_eq!(vectorcraft_tools::builder::faces(&gn).len(), 1);
let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(50.0, 70.0)).unwrap();
assert_eq!(f.appearance.fill_paint(), s.paint.fill);
assert!((vectorcraft_pathops::area(f.path_data().unwrap(), FillRule::NonZero).abs() - 900.0).abs() < 1e-6);
// The lines are edges, cut where they cross, and keep their stroke.
let edges = vectorcraft_tools::builder::edges(&gn);
assert_eq!(edges.len(), 9);
assert!(edges.iter().all(|e| !e.appearance.stroke_paint().is_none()));
// Outside the triangle there's nothing to fill.
assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 20]})).is_err());
}
#[test]
fn a_line_across_a_live_paint_shape_splits_its_face() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
set_fill(&mut s, r, Color::rgb(1.0, 0.0, 0.0));
let l = line(&mut s, (40.0, -20.0), (60.0, 70.0));
s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap();
let made = s.execute("livePaint.make", &json!({})).unwrap();
assert_eq!(made["faces"], 2, "{made}");
let g = NodeId(made["id"].as_u64().unwrap());
s.execute("livePaint.fill", &json!({"group": g.0, "point": [80, 25], "color": "#0000ff"})).unwrap();
let gn = node(&s, g);
let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(x, 25.0)).unwrap().appearance.fill_paint();
assert_eq!(face(10.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the left half keeps the rectangle's fill");
assert_eq!(face(80.0), Paint::solid(Color::from_hex("#0000ff").unwrap()));
}
// ---------- Image Trace ----------
/// A 100×100 image with a black disc (r = 30 px), placed at (50, 50) scaled ×2.
+3 -3
View File
@@ -14,14 +14,14 @@ fn names(v: &Value) -> Vec<String> {
fn the_font_list_names_every_available_family_with_its_styles() {
let mut s = Session::new();
let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]);
assert_eq!(fams, FontDb::global().families());
assert_eq!(fams, *FontDb::global().menu_family_list());
for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] {
assert!(fams.iter().any(|x| x == f), "{f}");
}
// Every installed family, though nothing asked for system fonts.
let installed = FontDb::with_font_dirs(system_font_dirs());
installed.load_system_fonts();
let missing: Vec<String> = installed.families().into_iter().filter(|f| !fams.contains(f)).collect();
let missing: Vec<String> = installed.menu_family_list().iter().filter(|f| !fams.contains(f)).cloned().collect();
assert!(missing.is_empty(), "{missing:?}");
let r = s.execute("text.fontList", &json!({"family": "source sans 3"})).unwrap();
assert_eq!(r["family"], "Source Sans 3");
@@ -38,7 +38,7 @@ fn refreshing_the_font_list_redraws_type_without_editing_the_document() {
s.doc_mut().unwrap().mark_saved();
let revision = s.doc().unwrap().revision;
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
assert_eq!(r["families"], json!(FontDb::global().families().len()));
assert_eq!(r["families"], json!(FontDb::global().menu_family_list().len()));
assert!(r["faces"].is_u64());
let d = s.doc().unwrap();
assert!(d.revision > revision, "the canvas redraws");
+111
View File
@@ -157,6 +157,98 @@ fn undo_during_a_drag_takes_back_only_the_drag() {
assert_eq!(b.x0, 100.0, "back where it was: {b:?}");
}
#[test]
fn ime_composition_shows_in_place_and_commits_as_one_step() {
// Romaji → kana → conversion → commit, as the macOS Japanese IME sends it.
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
let created = undo_labels(&s).len();
s.tool_text("曲:", v).unwrap();
for (t, r) in [("g", 1..1), ("が", 1..1), ("がg", 2..2), ("ががく", 3..3)] {
s.tool_preedit(t, Some(r), v).unwrap();
assert_eq!(obj(&s, id).plain_text(), format!("曲:{t}"), "marked text lays out in place");
assert!(s.tool_composing());
}
// Conversion: the active clause is the whole word.
s.tool_preedit("雅楽", Some(0..2), v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽");
assert_eq!(s.tool_options()["composing"], json!(true));
// macOS clears the marked text, then commits.
s.tool_preedit("", None, v).unwrap();
s.tool_text("雅楽", v).unwrap();
assert!(!s.tool_composing());
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽");
assert_eq!(s.tool_options()["caret"], json!("曲:雅楽".len()));
// Typing goes on after the commit, in the same session.
s.tool_text("。", v).unwrap();
key(&mut s, ToolKey::Escape, Mods::default());
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽。");
assert_eq!(undo_labels(&s).len(), created + 1, "one undo step for the session");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "");
}
#[test]
fn ime_cancel_leaves_no_undo_step_and_keeps_the_text() {
let mut s = session();
let v = ViewInfo::default();
let id = text(&mut s, "雅楽");
s.select_tool("type", v).unwrap();
let t = obj(&s, id);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
let (a, b) = vectorcraft_text::caret_position(&lay, "雅楽".len());
let p = t.xf * a.midpoint(b);
click(&mut s, p.x - 0.1, p.y);
assert!(s.tool_wants_text());
s.set_tool_option("select", &json!({"start": 6, "end": 6}));
let before = undo_labels(&s).len();
s.tool_preedit("えんそう", Some(4..4), v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "雅楽えんそう");
// Escape in the IME: the marked text goes, nothing else changes.
s.tool_preedit("", None, v).unwrap();
assert!(!s.tool_composing());
assert!(!s.in_interaction(), "a cancelled composition leaves no typing session");
assert_eq!(obj(&s, id).plain_text(), "雅楽");
assert_eq!(undo_labels(&s).len(), before);
// A stray clear (no composition) never deletes the selection.
s.set_tool_option("select", &json!({"start": 0, "end": 3}));
s.tool_preedit("", None, v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "雅楽");
// A composition over a selection replaces it.
s.tool_preedit("が", Some(1..1), v).unwrap();
s.tool_preedit("", None, v).unwrap();
s.tool_text("我", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "我楽");
}
#[test]
fn clicking_away_keeps_marked_text_as_typed_and_undo_mid_composition_is_safe() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_preedit("しょうこ", Some(4..4), v).unwrap();
// Keys belong to the IME while it composes.
key(&mut s, ToolKey::Backspace, Mods::default());
assert_eq!(obj(&s, id).plain_text(), "しょうこ");
click(&mut s, 600.0, 500.0);
assert!(!s.tool_composing());
assert_eq!(obj(&s, id).plain_text(), "しょうこ", "the marked text stays, committed");
// The UI holds Undo back while composing; if it comes anyway, the session is committed first.
click(&mut s, 50.0, 50.0);
let id2 = s.doc().unwrap().selection.objects[0];
s.tool_preedit("ひちりき", Some(4..4), v).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.tool_composing());
assert_eq!(obj(&s, id2).plain_text(), "");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(obj(&s, id2).plain_text(), "ひちりき");
}
#[test]
fn type_tool_selection_replace_and_delete() {
let mut s = session();
@@ -378,3 +470,22 @@ fn type_tool_state_does_not_leak_across_documents() {
assert_eq!(obj(&s, id).plain_text(), "First");
assert!(!s.in_interaction());
}
#[test]
fn vertical_text_creation_orientation_and_persistence() {
let mut s = session();
let r = s.execute("text.create", &json!({"x":100,"y":100,"text":"日本語", "vertical":true})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let t = obj(&s, id);
assert!(t.vertical);
let round: TextObject = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap();
assert!(round.vertical);
s.execute("type.orientation.horizontal", &json!({"id":id.0})).unwrap();
assert!(!obj(&s, id).vertical);
s.execute("edit.undo", &json!({})).unwrap();
assert!(obj(&s, id).vertical);
let old = serde_json::to_value(TextObject::point(Point::ZERO, "old", Default::default())).unwrap();
assert!(old.get("vertical").is_none());
let old: TextObject = serde_json::from_value(old).unwrap();
assert!(!old.vertical);
}
+18
View File
@@ -214,6 +214,24 @@ impl Session {
self.tool.wants_text()
}
/// IME marked text for the active tool (see `Tool::ime_preedit`; `active_chars` counts
/// characters).
pub fn tool_preedit(&mut self, text: &str, active_chars: Option<std::ops::Range<usize>>, view: ViewInfo) -> Result<Vec<UiRequest>> {
let acts = self.with_tool_cx(view, |t, cx| t.ime_preedit(cx, text, active_chars));
self.take_tool_panic()?;
self.apply_actions(acts)
}
/// Is the active tool showing uncommitted IME text?
pub fn tool_composing(&self) -> bool {
self.tool.composing()
}
/// The caret line (document space) the IME candidate window follows.
pub fn tool_ime_caret(&mut self, view: ViewInfo) -> Option<(Point, Point)> {
self.with_tool_cx(view, |t, cx| t.ime_caret(cx))
}
pub fn tool_busy(&self) -> bool {
self.tool.busy()
}
+3
View File
@@ -910,6 +910,9 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
"stopped", "begin", "end", "dict", "array", "string", "get", "put", "getinterval", "putinterval", "aload", "astore", "forall",
"[", "]", "<<", ">>", "{", "}", "matrix", "currentmatrix", "setmatrix", "transform", "itransform", "invertmatrix", "pathbbox",
"clippath", "initclip", "currentpoint", "eexec", "cleartomark", "mark", "counttomark", "showpage", "gstate", "setgstate",
"clipsave", "cliprestore", "languagelevel", "cvx", "findresource", "defineresource", "undefineresource", "resourcestatus",
"resourceforall", "/Category", "/Generic", "/ProcSet", "(*)", "(G?n*\\\\*?)", "{pop}", "8 string",
"(*) {pop} 8 string /Category resourceforall", "/Generic /Category findresource dup length dict copy /Category defineresource",
"/ASCII85Decode", "/ASCIIHexDecode", "/FlateDecode", "/RunLengthDecode", "/LZWDecode", "/DCTDecode", "/SubFileDecode",
"/DeviceRGB", "/DeviceCMYK", "/DeviceGray", "/Pattern", "/x", "x", "/Helvetica", "(abc)", "<ff00>", "<~87cURD]i,\"Ebo80~>",
"true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 <ff000000ff00>]",
+17 -15
View File
@@ -12,7 +12,7 @@ use vectorcraft_geom::Affine;
use super::graphics::{Space, process};
use super::interp::{Interp, matrix_of};
use super::lex::{find, hex_decode};
use super::obj::{DictRef, Key, Obj, Op, PsError, Res, ps_err};
use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
use crate::ps;
/// Most bytes one filter or image reads.
@@ -223,20 +223,15 @@ impl Interp<'_> {
ReadHexString | ReadString => {
let s = self.pop_str()?;
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
let n = s.borrow().len();
let n = s.len();
let got = if op == ReadString { self.read(&f, n)? } else { self.read_hex(&f, n)? };
let full = got.len() == n;
self.alloc(got.len())?;
if let Some(d) = s.borrow_mut().get_mut(..got.len()) {
d.copy_from_slice(&got);
}
self.push(Obj::string(got))?;
self.push(Obj::Bool(full))?;
self.fill(s, &got)?;
self.push(Obj::Bool(got.len() == n))?;
}
ReadLine => {
let s = self.pop_str()?;
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
let n = s.borrow().len();
let n = s.len();
let mut line = vec![];
let mut ended = false;
while line.len() < n {
@@ -252,8 +247,7 @@ impl Interp<'_> {
Some(b) => line.push(*b),
}
}
self.alloc(line.len())?;
self.push(Obj::string(line))?;
self.fill(s, &line)?;
self.push(Obj::Bool(ended))?;
}
FlushFile | CloseFile => {
@@ -307,12 +301,20 @@ impl Interp<'_> {
Ok(out)
}
/// Read bytes `got` into string `s` and push the part of `s` they fill.
fn fill(&mut self, s: Shared<u8>, got: &[u8]) -> Res {
if !s.write(0, got) {
return ps_err("rangecheck", "");
}
self.push_interval(Obj::Str(s), 0, got.len())
}
/// `filter`: a file that decodes its source. Over the program's own data it decodes when
/// first read (the data follows the operator that reads it); over other data, at once.
fn filter(&mut self) -> Res {
let name = self.pop()?.text().ok_or(PsError::Ps("typecheck", "filter".into()))?;
let sub = if &*name == "SubFileDecode" {
let eod = self.pop_str()?.borrow().clone();
let eod = self.pop_str()?.to_vec();
let count = self.pop_count()?;
(count, eod)
} else {
@@ -687,8 +689,8 @@ struct Pixels {
fn source(o: &Obj) -> Res<Source> {
Ok(match o {
Obj::File(f) => Source::File(f.clone()),
Obj::Str(s) => Source::Str(s.borrow().clone()),
p @ Obj::Array { .. } => Source::Proc(Obj::Array { items: p.items().cloned().unwrap_or_default(), exec: true }),
Obj::Str(s) => Source::Str(s.to_vec()),
Obj::Array { items, .. } => Source::Proc(Obj::Array { items: items.clone(), exec: true }),
_ => return ps_err("typecheck", "DataSource"),
})
}
+21 -5
View File
@@ -106,6 +106,8 @@ pub(crate) struct GState {
pub cur: Option<Point>,
pub start: Option<Point>,
pub clips: Vec<Arc<Clip>>,
/// The clips `clipsave` saved, for `cliprestore`.
pub saved_clips: Vec<Rc<[Arc<Clip>]>>,
pub overprint: bool,
pub font: Option<DictRef>,
}
@@ -128,6 +130,7 @@ impl Default for GState {
cur: None,
start: None,
clips: vec![],
saved_clips: vec![],
overprint: false,
font: None,
}
@@ -489,7 +492,7 @@ impl Interp<'_> {
let (family, items) = match o {
Obj::Name(n) | Obj::Exec(n) => (n.clone(), vec![]),
Obj::Array { items, .. } => {
let v = items.borrow().clone();
let v = items.to_vec();
let n = v.first().and_then(Obj::text).ok_or(PsError::Ps("typecheck", "setcolorspace".into()))?;
(n, v)
}
@@ -702,7 +705,7 @@ impl Interp<'_> {
let d = match f {
Obj::Dict(d) => d.clone(),
Obj::Array { items, .. } => {
let fs = items.borrow().clone();
let fs = items.to_vec();
let mut out = vec![];
for g in &fs {
out.extend(self.eval(g, t, depth + 1)?.first().copied());
@@ -730,7 +733,7 @@ impl Interp<'_> {
Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect())
}
Some(3.0) => {
let fs = get("Functions").and_then(|o| o.items().map(|i| i.borrow().clone())).unwrap_or_default();
let fs = get("Functions").and_then(|o| o.items().map(|i| i.to_vec())).unwrap_or_default();
let bounds = nums(get("Bounds"));
let encode = nums(get("Encode"));
let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0));
@@ -1095,6 +1098,19 @@ impl Interp<'_> {
self.g.start = self.g.cur;
}
InitClip => self.g.clips.clear(),
ClipSave => {
if self.g.saved_clips.len() >= MAX_GSAVE {
return Err(PsError::Limit("too many saved clips"));
}
self.alloc(std::mem::size_of_val(self.g.clips.as_slice()))?;
let clips = self.g.clips.as_slice().into();
self.g.saved_clips.push(clips);
}
ClipRestore => {
if let Some(clips) = self.g.saved_clips.pop() {
self.g.clips = clips.to_vec();
}
}
Clip | EoClip => {
let bp = self.g.path.clone();
self.clip_with(bp, if op == EoClip { FillRule::EvenOdd } else { FillRule::NonZero });
@@ -1118,7 +1134,7 @@ impl Interp<'_> {
}
RectStroke => {
let m = match self.stack.last() {
Some(Obj::Array { items, .. }) if items.borrow().len() == 6 => Some(self.pop_matrix()?),
Some(Obj::Array { items, .. }) if items.len() == 6 => Some(self.pop_matrix()?),
_ => None,
};
let mut bp = self.rects()?;
@@ -1157,7 +1173,7 @@ fn linear_points(f: &Obj) -> Option<Vec<f64>> {
match num("FunctionType")? {
2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]),
3.0 => {
let fs = d.get(&Key::name("Functions"))?.items()?.borrow().clone();
let fs = d.get(&Key::name("Functions"))?.items()?.to_vec();
if !fs.iter().all(|g| linear_points(g).is_some_and(|p| p.is_empty())) {
return None;
}
+139 -96
View File
@@ -11,7 +11,7 @@ use vectorcraft_geom::Affine;
use super::graphics::{GState, Out};
use super::lex::Lexer;
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err};
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
/// Most operations one file may run.
const MAX_OPS: u64 = 50_000_000;
@@ -61,6 +61,12 @@ fn encoding() -> Obj {
Obj::array(vec![Obj::name(".notdef"); 256])
}
/// The resource categories there are to begin with (instances of `Category`).
const CATEGORIES: &str = "Category Generic Font CIDFont CMap FontSet Encoding Form Pattern ProcSet ColorSpace Halftone \r
ColorRendering IdiomSet InkParams TrapParams OutputDevice ControlLanguage Localization PDL HWOptions \r
Filter ColorSpaceFamily Emulator IODevice ColorRenderingType FMapType FontType FormType HalftoneType \r
ImageType PatternType FunctionType ShadingType";
impl<'a> Interp<'a> {
pub fn new(src: &'a [u8], g: GState, out: Out) -> Self {
let system = new_dict();
@@ -72,7 +78,15 @@ impl<'a> Interp<'a> {
for name in ["StandardEncoding", "ISOLatin1Encoding"] {
d.insert(Key::name(name), encoding());
}
// An integer, as interpreters have it (`systemdict /languagelevel get 2 ge`): run, the
// name pushes it all the same.
d.insert(Key::name("languagelevel"), Obj::Int(3));
}
// Each category's implementation dictionary (`/Generic /Category findresource`).
let categories: Dict =
CATEGORIES.split_whitespace().map(|c| (Key::name(c), Obj::dict(Dict::from([(Key::name("Category"), Obj::name(c))])))).collect();
let resources = new_dict();
resources.borrow_mut().insert(Key::name("Category"), Obj::dict(categories));
let fonts = new_dict();
system.borrow_mut().insert(Key::name("FontDirectory"), Obj::Dict(fonts.clone()));
system.borrow_mut().insert(Key::name("GlobalFontDirectory"), Obj::Dict(fonts.clone()));
@@ -87,7 +101,7 @@ impl<'a> Interp<'a> {
status: new_dict(),
error: new_dict(),
fonts,
resources: new_dict(),
resources,
depth: 0,
ops: 0,
allocated: 0,
@@ -175,7 +189,7 @@ impl<'a> Interp<'a> {
}
}
pub fn pop_str(&mut self) -> Res<Rc<RefCell<Vec<u8>>>> {
pub fn pop_str(&mut self) -> Res<Shared<u8>> {
match self.pop()? {
Obj::Str(s) => Ok(s),
_ => ps_err("typecheck", "a string"),
@@ -183,7 +197,7 @@ impl<'a> Interp<'a> {
}
/// An array or procedure.
pub fn pop_array(&mut self) -> Res<Rc<RefCell<Vec<Obj>>>> {
pub fn pop_array(&mut self) -> Res<Shared<Obj>> {
match self.pop()? {
Obj::Array { items, .. } => Ok(items),
_ => ps_err("typecheck", "an array"),
@@ -197,15 +211,9 @@ impl<'a> Interp<'a> {
}
/// Store `m` into the matrix operand `items` and push it back.
pub fn put_matrix(&mut self, items: Rc<RefCell<Vec<Obj>>>, m: Affine) -> Res {
{
let mut v = items.borrow_mut();
if v.len() != 6 {
return ps_err("rangecheck", "a matrix");
}
for (slot, c) in v.iter_mut().zip(m.as_coeffs()) {
*slot = Obj::Real(c);
}
pub fn put_matrix(&mut self, items: Shared<Obj>, m: Affine) -> Res {
if items.len() != 6 || !items.write(0, &m.as_coeffs().map(Obj::Real)) {
return ps_err("rangecheck", "a matrix");
}
self.push(Obj::Array { items, exec: false })
}
@@ -276,24 +284,24 @@ impl<'a> Interp<'a> {
Ok(())
}
fn run_proc(&mut self, items: &Rc<RefCell<Vec<Obj>>>) -> Res {
fn run_proc(&mut self, items: &Shared<Obj>) -> Res {
self.enter()?;
let r = self.run_items(items);
self.depth -= 1;
r
}
fn run_items(&mut self, items: &Rc<RefCell<Vec<Obj>>>) -> Res {
fn run_items(&mut self, items: &Shared<Obj>) -> Res {
let mut i = 0;
loop {
let Some(o) = items.borrow().get(i).cloned() else { return Ok(()) };
let Some(o) = items.get(i) else { return Ok(()) };
i += 1;
self.exec_token(o)?;
}
}
/// Run `proc` as a loop body: `Ok(false)` when it ran `exit`.
fn body(&mut self, proc: &Obj) -> Res<bool> {
pub fn body(&mut self, proc: &Obj) -> Res<bool> {
self.tick()?;
match self.call(proc.clone()) {
Ok(()) => Ok(true),
@@ -303,7 +311,7 @@ impl<'a> Interp<'a> {
}
/// Run a procedure operand, executable or not (`exec`, `if`, loops).
fn pop_proc(&mut self) -> Res<Obj> {
pub fn pop_proc(&mut self) -> Res<Obj> {
match self.pop()? {
Obj::Array { items, .. } => Ok(Obj::Array { items, exec: true }),
o @ (Obj::Op(_) | Obj::Exec(_)) => Ok(o),
@@ -480,7 +488,7 @@ impl<'a> Interp<'a> {
Gt | Ge | Lt | Le => {
let (b, a) = (self.pop()?, self.pop()?);
let ord = match (&a, &b) {
(Obj::Str(x), Obj::Str(y)) => x.borrow().as_slice().partial_cmp(y.borrow().as_slice()),
(Obj::Str(x), Obj::Str(y)) => x.borrow().partial_cmp(&*y.borrow()),
_ => a.as_num().zip(b.as_num()).and_then(|(x, y)| x.partial_cmp(&y)),
}
.ok_or(PsError::Ps("typecheck", String::new()))?;
@@ -646,14 +654,10 @@ impl<'a> Interp<'a> {
} else {
show_text(&self.pop()?)
};
let n = text.len();
if n > s.borrow().len() {
if !s.write(0, text.as_bytes()) {
return ps_err("rangecheck", "");
}
if let Some(d) = s.borrow_mut().get_mut(..n) {
d.copy_from_slice(text.as_bytes());
}
self.push_interval(Obj::Str(s), 0, n)?;
self.push_interval(Obj::Str(s), 0, text.len())?;
}
ReadOnly | ExecuteOnly | NoAccess | SetPacking | SetGlobal | SetObjectFormat => {
if matches!(op, SetPacking | SetGlobal | SetObjectFormat) {
@@ -683,8 +687,8 @@ impl<'a> Interp<'a> {
}
Length => {
let n = match self.pop()? {
Obj::Array { items, .. } => items.borrow().len(),
Obj::Str(s) => s.borrow().len(),
Obj::Array { items, .. } => items.len(),
Obj::Str(s) => s.len(),
Obj::Dict(d) => d.borrow().len(),
Obj::Name(n) | Obj::Exec(n) => n.len(),
_ => return ps_err("typecheck", ""),
@@ -699,14 +703,8 @@ impl<'a> Interp<'a> {
Get => {
let k = self.pop()?;
let o = match self.pop()? {
Obj::Array { items, .. } => {
let i = index(&k)?;
items.borrow().get(i).cloned().ok_or(PsError::Ps("rangecheck", String::new()))?
}
Obj::Str(s) => {
let i = index(&k)?;
Obj::Int(i64::from(*s.borrow().get(i).ok_or(PsError::Ps("rangecheck", String::new()))?))
}
Obj::Array { items, .. } => items.get(index(&k)?).ok_or(PsError::Ps("rangecheck", String::new()))?,
Obj::Str(s) => Obj::Int(i64::from(s.get(index(&k)?).ok_or(PsError::Ps("rangecheck", String::new()))?)),
Obj::Dict(d) => {
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
let v = d.borrow().get(&key).cloned();
@@ -718,21 +716,21 @@ impl<'a> Interp<'a> {
}
Put => {
let (v, k, c) = (self.pop()?, self.pop()?, self.pop()?);
match c {
Obj::Array { items, .. } => {
let i = index(&k)?;
*items.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = v;
}
let written = match c {
Obj::Array { items, .. } => items.write(index(&k)?, std::slice::from_ref(&v)),
Obj::Str(s) => {
let i = index(&k)?;
let b = v.as_num().ok_or(PsError::Ps("typecheck", String::new()))?;
*s.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = b as i64 as u8;
s.write(index(&k)?, &[b as i64 as u8])
}
Obj::Dict(d) => {
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
insert(&d, key, v)?;
true
}
_ => return ps_err("typecheck", ""),
};
if !written {
return ps_err("rangecheck", "");
}
}
GetInterval => {
@@ -744,41 +742,35 @@ impl<'a> Interp<'a> {
PutInterval => {
let src = self.pop()?;
let at = self.pop_count()?;
match (self.pop()?, src) {
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => {
let from = from.borrow().clone();
let mut v = items.borrow_mut();
let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?;
d.clone_from_slice(&from);
}
(Obj::Str(s), Obj::Str(from)) => {
let from = from.borrow().clone();
let mut v = s.borrow_mut();
let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?;
d.copy_from_slice(&from);
}
let written = match (self.pop()?, src) {
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => items.write(at, &from.to_vec()),
(Obj::Str(s), Obj::Str(from)) => s.write(at, &from.to_vec()),
_ => return ps_err("typecheck", ""),
};
if !written {
return ps_err("rangecheck", "");
}
}
Aload => {
let items = self.pop_array()?;
let all = items.borrow().clone();
for o in all {
for o in items.to_vec() {
self.push(o)?;
}
self.push(Obj::Array { items, exec: false })?;
}
Astore => {
let items = self.pop_array()?;
let n = items.borrow().len();
let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("stackunderflow", String::new()))?;
*items.borrow_mut() = self.stack.split_off(at);
let at = self.stack.len().checked_sub(items.len()).ok_or(PsError::Ps("stackunderflow", String::new()))?;
if !items.write(0, self.stack.get(at..).unwrap_or_default()) {
return ps_err("rangecheck", "");
}
self.stack.truncate(at);
self.push(Obj::Array { items, exec: false })?;
}
Search | AnchorSearch => {
let seek = self.pop_str()?.borrow().clone();
let seek = self.pop_str()?.to_vec();
let s = self.pop_str()?;
let text = s.borrow().clone();
let text = s.to_vec();
let at = if op == Search { super::lex::find(&text, &seek) } else { text.starts_with(&seek).then_some(0) };
match at {
Some(i) => {
@@ -888,7 +880,6 @@ impl<'a> Interp<'a> {
self.push(Obj::Int(v))?;
}
}
LanguageLevel => self.push(Obj::Int(3))?,
Version => self.push(Obj::string(b"3010".to_vec()))?,
Product => self.push(Obj::string(b"VectorCraft".to_vec()))?,
RealTime | UserTime => self.push(Obj::Int(0))?,
@@ -922,19 +913,18 @@ impl<'a> Interp<'a> {
}
}
Obj::Array { items, exec } => {
let from = self.pop_array()?.borrow().clone();
let n = from.len();
{
let mut v = items.borrow_mut();
v.get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.clone_from_slice(&from);
let from = self.pop_array()?.to_vec();
if !items.write(0, &from) {
return ps_err("rangecheck", "copy");
}
self.push_interval(Obj::Array { items, exec }, 0, n)?;
self.push_interval(Obj::Array { items, exec }, 0, from.len())?;
}
Obj::Str(s) => {
let from = self.pop_str()?.borrow().clone();
let n = from.len();
s.borrow_mut().get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.copy_from_slice(&from);
self.push_interval(Obj::Str(s), 0, n)?;
let from = self.pop_str()?.to_vec();
if !s.write(0, &from) {
return ps_err("rangecheck", "copy");
}
self.push_interval(Obj::Str(s), 0, from.len())?;
}
Obj::Dict(d) => {
let from = self.pop_dict()?.borrow().clone();
@@ -946,16 +936,12 @@ impl<'a> Interp<'a> {
Ok(())
}
/// `n` items of an array or string from `at` (a copy: shared storage isn't modelled).
fn push_interval(&mut self, o: Obj, at: usize, n: usize) -> Res {
let end = at.checked_add(n).ok_or(PsError::Ps("rangecheck", String::new()))?;
self.alloc(n * if matches!(o, Obj::Str(_)) { 1 } else { std::mem::size_of::<Obj>() })?;
/// `n` items of an array or string from `at`, sharing its storage.
pub fn push_interval(&mut self, o: Obj, at: usize, n: usize) -> Res {
let range = || PsError::Ps("rangecheck", String::new());
let r = match o {
Obj::Array { items, exec } => {
let v = items.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec();
Obj::Array { items: Rc::new(RefCell::new(v)), exec }
}
Obj::Str(s) => Obj::string(s.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec()),
Obj::Array { items, exec } => Obj::Array { items: items.sub(at, n).ok_or_else(range)?, exec },
Obj::Str(s) => Obj::Str(s.sub(at, n).ok_or_else(range)?),
_ => return ps_err("typecheck", "getinterval"),
};
self.push(r)
@@ -966,7 +952,7 @@ impl<'a> Interp<'a> {
match self.pop()? {
Obj::Array { items, .. } => {
let mut i = 0;
while let Some(o) = items.borrow().get(i).cloned() {
while let Some(o) = items.get(i) {
i += 1;
self.push(o)?;
if !self.body(&p)? {
@@ -975,8 +961,7 @@ impl<'a> Interp<'a> {
}
}
Obj::Str(s) => {
let bytes = s.borrow().clone();
for b in bytes {
for b in s.to_vec() {
self.push(Obj::Int(i64::from(b)))?;
if !self.body(&p)? {
break;
@@ -1000,22 +985,19 @@ impl<'a> Interp<'a> {
/// Replace the names in a procedure that stand for operators by the operators, down through
/// nested procedures.
fn bind(&mut self, items: &Rc<RefCell<Vec<Obj>>>, depth: u32) {
fn bind(&mut self, items: &Shared<Obj>, depth: u32) {
if depth > 64 {
return;
}
let n = items.borrow().len();
for i in 0..n {
let o = items.borrow().get(i).cloned();
match o {
for i in 0..items.len() {
match items.get(i) {
Some(Obj::Exec(name)) => {
if let Some(Obj::Op(op)) = self.lookup(&name)
&& let Some(slot) = items.borrow_mut().get_mut(i)
{
*slot = Obj::Op(op);
if let Some(o @ Obj::Op(_)) = self.lookup(&name) {
// `i` is in range and nothing is reading the procedure: the write can't fail.
items.write(i, &[o]);
}
}
Some(Obj::Array { items: inner, exec: true }) if !Rc::ptr_eq(&inner, items) => self.bind(&inner, depth + 1),
Some(Obj::Array { items: inner, exec: true }) if !inner.shares(items) => self.bind(&inner, depth + 1),
_ => {}
}
}
@@ -1032,6 +1014,67 @@ impl<'a> Interp<'a> {
_ => ps_err("typecheck", ""),
})
}
/// The instances of `category`: `FontDirectory` for fonts.
pub fn instances(&mut self, category: &Obj) -> Res<DictRef> {
if category.text().as_deref() == Some("Font") { Ok(self.fonts.clone()) } else { self.category(category) }
}
/// `template proc scratch category resourceforall`: `proc` run on each instance name that
/// matches `template`, copied into `scratch` (in name order).
pub fn resource_for_all(&mut self) -> Res {
let category = self.pop()?;
let scratch = self.pop_str()?;
let p = self.pop_proc()?;
let template = self.pop_str()?.to_vec();
let mut names: Vec<Vec<u8>> = self.instances(&category)?.borrow().keys().map(|k| show_text(&k.obj()).into_bytes()).collect();
names.sort();
for name in names {
if !self.matches(&template, &name)? {
continue;
}
if !scratch.write(0, &name) {
return ps_err("rangecheck", "resourceforall");
}
self.push_interval(Obj::Str(scratch.clone()), 0, name.len())?;
if !self.body(&p)? {
break;
}
}
Ok(())
}
/// Does `name` match a `resourceforall` template: `*` any run of bytes, `?` any one, `\` the
/// next one as it is? Each step counts as an operation.
fn matches(&mut self, template: &[u8], name: &[u8]) -> Res<bool> {
let (mut t, mut n) = (0, 0);
// Where the last `*` was followed (template, name): a mismatch after it retries from there.
let mut star: Option<(usize, usize)> = None;
loop {
self.tick()?;
if n == name.len() {
return Ok(template.get(t..).unwrap_or_default().iter().all(|b| *b == b'*'));
}
let (want, width) = match template.get(t) {
Some(b'*') => {
t += 1;
star = Some((t, n));
continue;
}
Some(b'?') => (name.get(n).copied(), 1),
Some(b'\\') => (template.get(t + 1).copied(), 2),
c => (c.copied(), 1),
};
if want.is_some() && want == name.get(n).copied() {
(t, n) = (t + width, n + 1);
continue;
}
// The last `*` takes one more byte, else there is no match.
let Some((st, sn)) = star else { return Ok(false) };
star = Some((st, sn + 1));
(t, n) = (st, sn + 1);
}
}
}
/// Put `v` under `k` in `d`, within [`MAX_ALLOC`] entries.
@@ -1057,7 +1100,7 @@ fn equal(a: &Obj, b: &Obj) -> bool {
match (a, b) {
(Obj::Null, Obj::Null) | (Obj::Mark, Obj::Mark) => true,
(Obj::Bool(x), Obj::Bool(y)) => x == y,
(Obj::Array { items: x, .. }, Obj::Array { items: y, .. }) => Rc::ptr_eq(x, y),
(Obj::Array { items: x, .. }, Obj::Array { items: y, .. }) => x.same(y),
(Obj::Dict(x), Obj::Dict(y)) => Rc::ptr_eq(x, y),
(Obj::Op(x), Obj::Op(y)) => x == y,
_ => match (a.as_num(), b.as_num()) {
+4 -1
View File
@@ -192,7 +192,10 @@ fn finish(mut out: Out) -> Imported {
/// A document of `frame`'s size showing TIFF preview `tiff` stretched over it.
fn preview(tiff: &[u8], frame: Rect) -> Option<Document> {
let img = image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).ok()?.to_rgba8();
let img = match image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff) {
Ok(img) => img.to_rgba8(),
Err(_) => crate::tiff::palette_rgba(tiff)?,
};
let (w, h) = img.dimensions();
if w == 0 || h == 0 {
return None;
+77 -11
View File
@@ -1,6 +1,6 @@
//! The PostScript objects the interpreter works with, its operators and its errors.
use std::cell::RefCell;
use std::cell::{Ref, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
@@ -31,10 +31,10 @@ pub(crate) enum Obj {
Name(Rc<str>),
/// An executable name: looked up and run.
Exec(Rc<str>),
Str(Rc<RefCell<Vec<u8>>>),
Str(Shared<u8>),
/// An array; executable ones are procedures.
Array {
items: Rc<RefCell<Vec<Obj>>>,
items: Shared<Obj>,
exec: bool,
},
Dict(DictRef),
@@ -53,15 +53,15 @@ impl Obj {
}
pub fn string(bytes: Vec<u8>) -> Self {
Self::Str(Rc::new(RefCell::new(bytes)))
Self::Str(Shared::new(bytes))
}
pub fn array(items: Vec<Obj>) -> Self {
Self::Array { items: Rc::new(RefCell::new(items)), exec: false }
Self::Array { items: Shared::new(items), exec: false }
}
pub fn proc(items: Vec<Obj>) -> Self {
Self::Array { items: Rc::new(RefCell::new(items)), exec: true }
Self::Array { items: Shared::new(items), exec: true }
}
pub fn dict(d: Dict) -> Self {
@@ -98,7 +98,7 @@ impl Obj {
}
/// The items of an array or procedure.
pub fn items(&self) -> Option<&Rc<RefCell<Vec<Obj>>>> {
pub fn items(&self) -> Option<&Shared<Obj>> {
match self {
Self::Array { items, .. } => Some(items),
_ => None,
@@ -125,6 +125,71 @@ impl Obj {
}
}
/// The values of a string or an array: a run of storage that other strings or arrays may share
/// (`getinterval` makes a new object over part of the same values, as PostScript does).
#[derive(Clone, Debug)]
pub(crate) struct Shared<T> {
buf: Rc<RefCell<Vec<T>>>,
at: usize,
len: usize,
}
impl<T: Clone> Shared<T> {
pub fn new(v: Vec<T>) -> Self {
Self { len: v.len(), buf: Rc::new(RefCell::new(v)), at: 0 }
}
pub fn len(&self) -> usize {
self.len
}
/// The values. Only [`Self::write`] borrows the storage mutably, for the copy alone, so this
/// never meets it.
pub fn borrow(&self) -> Ref<'_, [T]> {
let (at, end) = (self.at, self.at.saturating_add(self.len));
Ref::map(self.buf.borrow(), |v| v.get(at..end).unwrap_or(&[]))
}
pub fn get(&self, i: usize) -> Option<T> {
self.borrow().get(i).cloned()
}
pub fn to_vec(&self) -> Vec<T> {
self.borrow().to_vec()
}
/// `n` values from `at`, sharing the storage (`getinterval`); `None` past the end.
pub fn sub(&self, at: usize, n: usize) -> Option<Self> {
(at.checked_add(n)? <= self.len).then(|| Self { buf: self.buf.clone(), at: self.at + at, len: n })
}
/// Write `vals` from `at`: false when they don't fit (or `vals` is borrowed from the storage).
pub fn write(&self, at: usize, vals: &[T]) -> bool {
if at.saturating_add(vals.len()) > self.len {
return false;
}
let start = self.at + at;
let Ok(mut v) = self.buf.try_borrow_mut() else { return false };
match v.get_mut(start..start + vals.len()) {
Some(d) => {
d.clone_from_slice(vals);
true
}
None => false,
}
}
/// The same object (`eq` on arrays): the same values of the same storage.
pub fn same(&self, o: &Self) -> bool {
Rc::ptr_eq(&self.buf, &o.buf) && self.at == o.at && self.len == o.len
}
/// Do both see the same storage?
pub fn shares(&self, o: &Self) -> bool {
Rc::ptr_eq(&self.buf, &o.buf)
}
}
impl Key {
pub fn name(s: &str) -> Self {
Self::Name(Rc::from(s))
@@ -209,7 +274,7 @@ ops! {
ErrorDict = "errordict", DollarError = "$error", Bind = "bind",
// Virtual memory and the environment.
Save = "save", Restore = "restore", SetGlobal = "setglobal", CurrentGlobal = "currentglobal", VmStatus = "vmstatus",
LanguageLevel = "languagelevel", Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime",
Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime",
Print = "print", EqPrint = "=", EqEqPrint = "==", Pstack = "pstack", Stack = "stack", Flush = "flush",
// Graphics state.
Gsave = "gsave", Grestore = "grestore", GrestoreAll = "grestoreall", InitGraphics = "initgraphics",
@@ -242,8 +307,8 @@ ops! {
RLineTo = "rlineto", CurveTo = "curveto", RCurveTo = "rcurveto", Arc = "arc", Arcn = "arcn", Arct = "arct",
Arcto = "arcto", ClosePath = "closepath", FlattenPath = "flattenpath", ReversePath = "reversepath",
PathBBox = "pathbbox", ClipPath = "clippath", InitClip = "initclip", Clip = "clip", EoClip = "eoclip",
RectClip = "rectclip", Fill = "fill", EoFill = "eofill", Stroke = "stroke", RectFill = "rectfill",
RectStroke = "rectstroke", ShFill = "shfill",
RectClip = "rectclip", ClipSave = "clipsave", ClipRestore = "cliprestore", Fill = "fill", EoFill = "eofill",
Stroke = "stroke", RectFill = "rectfill", RectStroke = "rectstroke", ShFill = "shfill",
// Images.
Image = "image", ImageMask = "imagemask", ColorImage = "colorimage",
// Files.
@@ -252,7 +317,8 @@ ops! {
// Fonts and type.
FindFont = "findfont", ScaleFont = "scalefont", MakeFont = "makefont", SetFont = "setfont", SelectFont = "selectfont",
CurrentFont = "currentfont", DefineFont = "definefont", UndefineFont = "undefinefont", FindResource = "findresource",
DefineResource = "defineresource", ResourceStatus = "resourcestatus", Show = "show", AShow = "ashow",
DefineResource = "defineresource", UndefineResource = "undefineresource", ResourceStatus = "resourcestatus",
ResourceForAll = "resourceforall", Show = "show", AShow = "ashow",
WidthShow = "widthshow", AWidthShow = "awidthshow", XShow = "xshow", YShow = "yshow", XYShow = "xyshow",
KShow = "kshow", GlyphShow = "glyphshow", StringWidth = "stringwidth", CharPath = "charpath",
SetCacheDevice = "setcachedevice", SetCharWidth = "setcharwidth",
+231
View File
@@ -452,3 +452,234 @@ fn device_settings_are_accepted_and_change_nothing() {
assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// Check that `body` draws one square, blue: it sets blue when what it checks holds, else red.
fn blue(body: &str) {
let r = read(&format!("{body} 10 10 80 80 rectfill"));
let o = objects(&r.document);
assert_eq!(o.len(), 1, "{body}: {:?}", r.warnings);
assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0))), "{body}");
assert!(r.warnings.is_empty(), "{body}: {:?}", r.warnings);
}
/// Check that PostScript `cond` leaves `true`.
fn check(cond: &str) {
blue(&format!("{cond} {{ 0 0 1 setrgbcolor }} {{ 1 0 0 setrgbcolor }} ifelse"));
}
#[test]
fn the_generic_category_defines_new_categories() {
// #234: the reference app's prolog.
blue("/Generic /Category findresource pop 0 0 1 setrgbcolor");
blue(
"[/CSA /Gradient /Procedure] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall 0 0 1 setrgbcolor",
);
let define = "[/CSA /Gradient] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall \
/G1 << /Kind 7 >> /Gradient defineresource pop";
check(&format!("{define} /G1 /Gradient findresource /Kind get 7 eq"));
check(&format!("{define} /Gradient /Category resourcestatus {{ pop pop true }} {{ false }} ifelse"));
check(&format!("{define} /G1 /Gradient resourcestatus {{ pop pop true }} {{ false }} ifelse"));
check(&format!("{define} /G1 /Gradient undefineresource /G1 /Gradient resourcestatus not"));
check(&format!("{define} {{ /G2 /Gradient findresource }} stopped"));
// Unknown categories aren't instances of Category; a category is a dictionary.
check("/Nonsense /Category resourcestatus not");
check("{ /Nonsense /Category findresource } stopped");
check("{ /X 5 /Category defineresource } stopped");
}
#[test]
fn clipsave_and_cliprestore_bring_the_clip_back() {
// #234.
blue("clipsave 0 0 50 50 rectclip cliprestore 0 0 1 setrgbcolor");
// The fill after cliprestore covers the whole square, unclipped.
let r = read("clipsave 0 0 50 50 rectclip 1 0 0 setrgbcolor 0 0 10 10 rectfill cliprestore 0 0 1 setrgbcolor 10 10 80 80 rectfill");
let o = objects(&r.document);
assert_eq!(o.len(), 2, "{:?}", r.warnings);
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", o[0].kind);
assert!(matches!(o[1].kind, NodeKind::Path { clipping: false, .. }), "{:?}", o[1].kind);
assert!(near(bounds(&o[1]), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(&o[1]));
// Nested, leaving the rest of the graphics state alone.
let r = read(
"0 0 60 60 rectclip clipsave 0 0 20 20 rectclip clipsave 0 0 5 5 rectclip cliprestore 0 1 0 setrgbcolor cliprestore \
10 10 80 80 rectfill",
);
let NodeKind::Group { children, clip: true } = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
assert!(near(bounds(&children[0]), Rect::new(0.0, 40.0, 60.0, 100.0)), "{:?}", bounds(&children[0]));
assert_eq!(fill(&children[1]), Some(&Paint::solid(Color::rgb(0.0, 1.0, 0.0))));
// grestore undoes the clipsaves after its gsave; cliprestore with nothing saved does nothing.
let o = objects(&read("cliprestore gsave clipsave 0 0 50 50 rectclip grestore cliprestore 10 10 80 80 rectfill").document);
assert!(matches!(o[0].kind, NodeKind::Path { .. }), "{:?}", o[0].kind);
}
#[test]
fn languagelevel_is_an_integer_and_runs() {
// #234.
blue(
"/Level2? systemdict /languagelevel known dup { pop systemdict /languagelevel get 2 ge } if def \
Level2? { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse",
);
check("languagelevel 3 eq");
check("{ languagelevel } bind exec 3 eq");
}
#[test]
fn intervals_share_their_arrays_and_strings() {
// #234: astore into a subarray stores into the array.
blue("/a 4 array def 1 2 a 0 2 getinterval astore pop a 0 get 1 eq { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse");
// put, putinterval and copy into an interval; an interval of an interval.
check("/a [0 0 0 0 0] def a 1 3 getinterval 1 2 getinterval 0 7 put a 2 get 7 eq");
check("/a [0 0 0 0] def a 2 2 getinterval 0 [8 9] putinterval a 3 get 9 eq");
check("/a [0 0 0 0] def [5 6] a 1 2 getinterval copy pop a 2 get 6 eq");
check("/s (abcd) def s 1 2 getinterval 0 (XY) putinterval s (aXYd) eq");
check("/s (abcd) def s 2 1 getinterval 0 90 put s (abZd) eq");
check("/s 8 string def 42 s 2 6 getinterval cvs pop s 2 2 getinterval (42) eq");
// The same interval is the same object; another one is not.
check("/a 4 array def a 0 2 getinterval a 0 2 getinterval eq");
check("/a 4 array def a 0 2 getinterval a 0 3 getinterval ne a 0 2 getinterval a 1 2 getinterval ne and");
// Reads and writes stay inside the interval.
check("/a [1 2 3 4] def { a 1 2 getinterval 2 get } stopped { a 1 2 getinterval 2 0 put } stopped and");
check("/a [1 2 3 4] def a 1 2 getinterval length 2 eq a 1 2 getinterval aload pop 3 eq exch 2 eq and and");
check("/n 0 def [1 2 3 4] 1 2 getinterval { n add /n exch def } forall n 5 eq");
// A procedure from an interval runs only its part.
check("{ 1 2 3 } 1 2 getinterval cvx exec add 5 eq");
// The prolog's stack save: the operands into part of one array, and back.
check(
"/stk 10 array def /lev 3 array def 1 2 3 count dup lev exch 0 exch put stk exch 0 exch getinterval astore pop \
clear stk 0 lev 0 get getinterval aload pop add add 6 eq",
);
}
#[test]
fn resourceforall_enumerates_matching_names() {
// #234.
blue("(*) { pop } 128 string /Category resourceforall 0 0 1 setrgbcolor");
let count = |template: &str| format!("/n 0 def ({template}) {{ pop /n n 1 add def }} 128 string /ProcSet resourceforall n");
let define = "[/Foo /Fop /Bar /F*x] { 1 dict /ProcSet defineresource pop } forall";
check(&format!("{define} {} 4 eq", count("*")));
check(&format!("{define} {} 2 eq", count("Fo?")));
check(&format!("{define} {} 3 eq", count("F*")));
check(&format!("{define} {} 1 eq", count("F\\\\*x")));
check(&format!("{define} {} 0 eq", count("Q*")));
check(&format!("{define} {} 1 eq", count("*a*")));
// Each name is copied into the scratch string; exit stops.
check(&format!("{define} /last () def (B*) {{ dup length string copy /last exch def }} 8 string /ProcSet resourceforall last (Bar) eq"));
check(&format!("{define} /n 0 def (*) {{ pop /n n 1 add def exit }} 8 string /ProcSet resourceforall n 1 eq"));
// A scratch string too small for the names, as the prolog probes it.
check(&format!("{define} {{ (*) {{ pop }} 2 string /ProcSet resourceforall }} stopped"));
check("/n 0 def (Gen*) { pop /n n 1 add def } 16 string /Category resourceforall n 1 eq");
}
/// A `w` × `h` 8-bit palette TIFF with an alpha sample (`ExtraSamples` [1]), uncompressed in
/// strips of `per` rows, in either byte order, as the reference app writes its EPS previews:
/// pixel (x, y) is entry `(x + y) % 3` of a red, green, blue map, at alpha `12 x`.
fn palette_tiff(w: u16, h: u16, per: u16, big: bool) -> Vec<u8> {
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
// Shorts in an entry, left-justified.
let shorts = |v: &[u16]| -> [u8; 4] {
let mut f = [0; 4];
for (i, s) in v.iter().enumerate() {
f[2 * i..2 * i + 2].copy_from_slice(&u16b(*s));
}
f
};
let row = u32::from(w) * 2;
let mut out = vec![];
out.extend(if big { *b"MM\0*" } else { *b"II*\0" });
out.extend([0; 4]);
let data_at = out.len() as u32;
for y in 0..h {
for x in 0..w {
out.extend([((x + y) % 3) as u8, (x * 12) as u8]);
}
}
let map_at = out.len() as u32;
for (c, full) in [0xFFFF, 0x8000, 0xFFFF].into_iter().enumerate() {
for i in 0..256 {
out.extend(u16b(if i == c { full } else { 0 }));
}
}
let strips: Vec<u16> = (0..h.div_ceil(per)).collect();
let offsets_at = out.len() as u32;
for k in &strips {
out.extend(u32b(data_at + u32::from(k * per) * row));
}
let counts_at = out.len() as u32;
for k in &strips {
out.extend(u32b(u32::from(per.min(h - k * per)) * row));
}
let ifd = out.len() as u32;
out[4..8].copy_from_slice(&u32b(ifd));
let n = strips.len() as u32;
let entries: [(u16, u16, u32, [u8; 4]); 12] = [
(256, 3, 1, shorts(&[w])),
(257, 3, 1, shorts(&[h])),
(258, 3, 2, shorts(&[8, 8])),
(259, 3, 1, shorts(&[1])),
(262, 3, 1, shorts(&[3])),
(273, 4, n, u32b(if n == 1 { data_at } else { offsets_at })),
(277, 3, 1, shorts(&[2])),
(278, 3, 1, shorts(&[per])),
(279, 4, n, u32b(if n == 1 { u32::from(h) * row } else { counts_at })),
(284, 3, 1, shorts(&[1])),
(320, 3, 768, u32b(map_at)),
(338, 3, 1, shorts(&[1])),
];
out.extend(u16b(entries.len() as u16));
for (tag, kind, count, field) in entries {
out.extend(u16b(tag));
out.extend(u16b(kind));
out.extend(u32b(count));
out.extend(field);
}
out.extend([0; 4]);
out
}
#[test]
fn a_palette_preview_with_alpha_is_placed() {
// #234: the program fails, so the preview is placed.
let ps = eps("0 0 1 setrgbcolor 10 10 80 80 rectfill frobnicate");
for (big, per) in [(false, 5), (true, 5), (false, 10), (true, 3)] {
let tiff = palette_tiff(20, 10, per, big);
let r = import(&crate::dos_eps(&ps, &tiff).unwrap()).unwrap();
assert!(r.preview, "{big} {per}");
assert!(r.warnings[0].contains("preview"), "{:?}", r.warnings);
let o = objects(&r.document);
let NodeKind::Image(im) = &o[0].kind else { panic!("{:?}", o[0].kind) };
assert_eq!((im.width, im.height), (20, 10));
assert!(near(bounds(&o[0]), Rect::new(0.0, 0.0, 100.0, 100.0)));
let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8();
for (x, y) in [(0, 0), (1, 0), (2, 0), (19, 9), (7, 4), (3, 6)] {
let [r, g, b] = [[255, 0, 0], [0, 128, 0], [0, 0, 255]][((x + y) % 3) as usize];
assert_eq!(png.get_pixel(x, y).0, [r, g, b, (x * 12) as u8], "({x}, {y}) {big} {per}");
}
}
}
#[test]
fn a_damaged_palette_preview_is_refused_without_panicking() {
let tiff = palette_tiff(20, 10, 5, false);
assert!(crate::tiff::palette_rgba(&tiff).is_some());
// Cut anywhere before the last entry's value (the directory is at the end): nothing.
for cut in 0..tiff.len() - 6 {
assert!(crate::tiff::palette_rgba(&tiff[..cut]).is_none(), "{cut}");
}
// Any byte changed: a picture or nothing.
for big in [false, true] {
let tiff = palette_tiff(20, 10, 5, big);
for at in 0..tiff.len() {
for v in [0, 1, 2, 0x7f, 0x80, 0xff] {
let mut t = tiff.clone();
t[at] = v;
let _ = crate::tiff::palette_rgba(&t);
}
}
}
// A damaged preview of an unreadable program: the hard error.
let ps = eps("frobnicate");
for t in [&tiff[..tiff.len() / 2], b"II*\0garbage", &[0; 64]] {
let e = import(&crate::dos_eps(&ps, t).unwrap()).unwrap_err();
assert!(e.contains("can't be read"), "{e}");
}
}
+20 -12
View File
@@ -110,38 +110,46 @@ impl Interp<'_> {
let category = self.pop()?;
let v = self.pop()?;
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "defineresource".into()))?;
let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? };
dict.borrow_mut().insert(k, v.clone());
// A category is defined by its implementation dictionary.
if category.text().as_deref() == Some("Category") && !matches!(v, Obj::Dict(_)) {
return ps_err("typecheck", "defineresource");
}
self.instances(&category)?.borrow_mut().insert(k, v.clone());
self.push(v)?;
}
UndefineResource => {
let category = self.pop()?;
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "undefineresource".into()))?;
self.instances(&category)?.borrow_mut().remove(&k);
}
ResourceStatus => {
let category = self.pop()?;
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "resourcestatus".into()))?;
let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? };
let known = dict.borrow().contains_key(&k);
let known = self.instances(&category)?.borrow().contains_key(&k);
if known {
self.push(Obj::Int(1))?;
self.push(Obj::Int(0))?;
}
self.push(Obj::Bool(known))?;
}
ResourceForAll => self.resource_for_all()?,
Show => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.show(&s)?;
}
AShow => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.nums::<2>()?;
self.show(&s)?;
}
WidthShow => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.pop()?;
self.nums::<2>()?;
self.show(&s)?;
}
AWidthShow => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.nums::<2>()?;
self.pop()?;
self.nums::<2>()?;
@@ -149,11 +157,11 @@ impl Interp<'_> {
}
XShow | YShow | XYShow => {
self.pop()?;
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.show(&s)?;
}
KShow => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.pop()?;
self.show(&s)?;
}
@@ -163,14 +171,14 @@ impl Interp<'_> {
self.show(ch.encode_utf8(&mut [0; 4]).as_bytes())?;
}
StringWidth => {
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
let (_, _, v) = self.set_type(&s, Point::ZERO)?;
self.push_num(v.x)?;
self.push_num(v.y)?;
}
CharPath => {
self.pop_bool()?;
let s = self.pop_str()?.borrow().clone();
let s = self.pop_str()?.to_vec();
self.char_path(&s)?;
}
SetCacheDevice => {
+110 -1
View File
@@ -1,5 +1,6 @@
//! The TIFF preview (little-endian, PackBits strips, written by the raster exports' TIFF writer):
//! 1-bit black and white, or 8-bit RGB with or without an alpha channel. Also the PNG thumbnail.
//! 1-bit black and white, or 8-bit RGB with or without an alpha channel. Also the PNG thumbnail,
//! and a reader for the palette previews other apps write that the `image` crate can't decode.
use std::borrow::Cow;
@@ -50,3 +51,111 @@ pub(crate) fn png(img: &Raster) -> Result<Vec<u8>, String> {
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).map_err(|e| e.to_string())?;
Ok(out)
}
/// Most pixels a palette preview may have.
const MAX_PIXELS: usize = 1 << 26;
/// Most values a TIFF field may have.
const MAX_VALUES: usize = 1 << 20;
/// A TIFF's bytes in its byte order.
struct Tiff<'a> {
b: &'a [u8],
big: bool,
}
impl Tiff<'_> {
fn u16(&self, at: usize) -> Option<u16> {
let b: [u8; 2] = self.b.get(at..at.checked_add(2)?)?.try_into().ok()?;
Some(if self.big { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) })
}
fn u32(&self, at: usize) -> Option<u32> {
let b: [u8; 4] = self.b.get(at..at.checked_add(4)?)?.try_into().ok()?;
Some(if self.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) })
}
/// The values of the directory entry at `at` (bytes, shorts or longs; in the entry when they
/// fit in four bytes).
fn values(&self, at: usize) -> Option<Vec<u32>> {
let (kind, n) = (self.u16(at.checked_add(2)?)?, self.u32(at.checked_add(4)?)? as usize);
let size = match kind {
1 => 1,
3 => 2,
4 => 4,
_ => return None,
};
if n > MAX_VALUES {
return None;
}
let field = at.checked_add(8)?;
let start = if n * size <= 4 { field } else { self.u32(field)? as usize };
(0..n)
.map(|i| {
let at = start.checked_add(i * size)?;
match size {
1 => self.b.get(at).map(|v| u32::from(*v)),
2 => self.u16(at).map(u32::from),
_ => self.u32(at),
}
})
.collect()
}
}
/// An 8-bit palette TIFF, with or without an alpha sample (`ExtraSamples`), uncompressed and
/// interleaved, as RGBA: the layout of other apps' EPS previews. `None` for anything else.
pub(crate) fn palette_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
let big = match bytes.get(..4)? {
b"II*\0" => false,
b"MM\0*" => true,
_ => return None,
};
let t = Tiff { b: bytes, big };
let ifd = t.u32(4)? as usize;
let entries = usize::from(t.u16(ifd)?);
let mut fields = std::collections::HashMap::new();
for i in 0..entries {
let at = ifd.checked_add(2 + i * 12)?;
fields.insert(t.u16(at)?, at);
}
let get = |tag: u16| fields.get(&tag).and_then(|at| t.values(*at));
let one = |tag: u16| get(tag).and_then(|v| v.first().copied());
let (w, h) = (one(256)? as usize, one(257)? as usize);
let samples = one(277).unwrap_or(1) as usize;
let alpha = samples == 2 && matches!(get(338)?.as_slice(), [0..=2]);
let plain = one(259).unwrap_or(1) == 1 && one(262)? == 3 && one(284).unwrap_or(1) == 1;
if !plain || !(samples == 1 || alpha) || get(258)?.iter().any(|b| *b != 8) || w == 0 || h == 0 || w.checked_mul(h)? > MAX_PIXELS {
return None;
}
let map = get(320)?;
if map.len() != 3 * 256 {
return None;
}
// Strips of `per` rows each, in order.
let (offsets, counts) = (get(273)?, get(279)?);
let per = (one(278).unwrap_or(u32::MAX) as usize).min(h);
let row = w * samples;
// The strips are in the file: so is all that's allocated.
if per == 0 || offsets.len() != counts.len() || offsets.len() < h.div_ceil(per) || row * h > bytes.len() {
return None;
}
let mut data = Vec::with_capacity(row * h);
for (k, (at, n)) in offsets.iter().zip(&counts).take(h.div_ceil(per)).enumerate() {
let want = per.min(h - k * per) * row;
if (*n as usize) < want {
return None;
}
let at = *at as usize;
data.extend_from_slice(bytes.get(at..at.checked_add(want)?)?);
}
// Map entries are 16-bit.
let channel = |c: usize, i: usize| map.get(c * 256 + i).map_or(0, |v| ((u64::from(*v).min(65_535) * 255 + 32_767) / 65_535) as u8);
let rgba = data
.chunks_exact(samples)
.flat_map(|px| {
let i = usize::from(px.first().copied().unwrap_or(0));
[channel(0, i), channel(1, i), channel(2, i), px.get(1).copied().unwrap_or(255)]
})
.collect();
image::RgbaImage::from_raw(w as u32, h as u32, rgba)
}
+7
View File
@@ -16,11 +16,16 @@
mod backend;
mod headless;
pub mod logging;
mod prompts;
mod resources;
mod server;
mod tools;
pub use backend::{Backend, Remote};
pub use headless::Headless;
pub use prompts::{PROMPTS, PromptArg, PromptDef};
pub use resources::{DOC_JSON_URI, DOC_URI, TEMPLATES};
pub use server::{PROTOCOL_VERSION, Server};
pub use tools::{ToolResult, call_tool, tool_definitions};
@@ -48,4 +53,6 @@ mod tests_persp;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_protocol;
#[cfg(test)]
mod tests_svg;
+220
View File
@@ -0,0 +1,220 @@
//! `logging/setLevel` and the `notifications/message` it turns on.
//!
//! VectorCraft's crates log through the `log` facade, and until now nothing installed a logger, so
//! every `log::debug!` went nowhere. This module is the sink for them: a `log` logger that turns
//! records into MCP `notifications/message`, filtered to the level the client asked for.
//!
//! The **binary** owns the choice of logger and calls [`install`]; the library never does. A
//! library that installed a process-wide logger and set the max level would silence whatever
//! logger an embedder had already set up, without being asked — so `Server::new` only forwards a
//! `logging/setLevel` the client explicitly sent.
//!
//! Nothing is sent before the client asks for it, because MCP has no business pushing log lines at
//! a client that never asked.
use std::sync::Mutex;
use std::sync::atomic::{AtomicU8, Ordering};
use log::{Level, LevelFilter};
use serde_json::{Value, json};
/// The severity from the last `logging/setLevel`, as an index into [`LEVELS`]; [`OFF`] means silent.
static LEVEL: AtomicU8 = AtomicU8::new(OFF);
/// The stored value that means "no logging".
const OFF: u8 = u8::MAX;
/// Records waiting to be drained into the next `notifications/message` batch.
static QUEUE: Mutex<Vec<Value>> = Mutex::new(Vec::new());
/// RFC 5424 severities, from the least to the most severe, as the spec orders them.
const LEVELS: [&str; 8] = ["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"];
/// The severity a `logging/setLevel` level names, and the `log` level it filters at. `None` for
/// anything the spec doesn't name.
///
/// Four of the eight severities collapse onto `log`'s Error, so the index is kept as well: a
/// client that asked for `emergency` must be able to hear that it is `emergency`, not `error`.
fn parse(name: &str) -> Option<(u8, Level)> {
let level = match name {
"debug" => Level::Debug,
"info" | "notice" => Level::Info,
"warning" => Level::Warn,
"error" | "critical" | "alert" | "emergency" => Level::Error,
_ => return None,
};
Some((LEVELS.iter().position(|l| *l == name)? as u8, level))
}
/// The severity name a `log` record is reported under.
fn severity(level: Level) -> &'static str {
match level {
Level::Trace | Level::Debug => "debug",
Level::Info => "info",
Level::Warn => "warning",
Level::Error => "error",
}
}
/// A `logging/setLevel` level the spec does not name. Nothing was changed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnknownLevel;
/// Turn logging on at `level`, or off with `None`.
///
/// `Err(UnknownLevel)` means the name is not one the spec uses and nothing changed; a rejected
/// level must leave the accepted one in place rather than silencing logging entirely.
pub fn set_level(level: Option<&str>) -> Result<(), UnknownLevel> {
let Some(name) = level else {
LEVEL.store(OFF, Ordering::Relaxed);
log::set_max_level(LevelFilter::Off);
clear();
return Ok(());
};
let Some((index, parsed)) = parse(name) else { return Err(UnknownLevel) };
LEVEL.store(index, Ordering::Relaxed);
log::set_max_level(parsed.to_level_filter());
// Records already queued at the old level are dropped rather than leaked at the new one.
clear();
Ok(())
}
/// The level currently requested, if any.
pub fn level() -> Option<&'static str> {
let index = LEVEL.load(Ordering::Relaxed);
if index == OFF {
return None;
}
LEVELS.get(usize::from(index)).copied()
}
/// Take the queued records, so the server can send them as `notifications/message`.
pub fn drain() -> Vec<Value> {
match QUEUE.lock() {
Ok(mut q) => std::mem::take(&mut *q),
// A poisoned lock only means some other thread panicked while logging; the records in it
// are still readable and worth sending.
Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()),
}
}
/// Forget whatever is queued (used when the client stops listening).
pub fn clear() {
if let Ok(mut q) = QUEUE.lock() {
q.clear();
}
}
/// The `log` logger that feeds [`QUEUE`].
struct Sink;
impl log::Log for Sink {
fn enabled(&self, metadata: &log::Metadata) -> bool {
LEVEL.load(Ordering::Relaxed) != OFF && metadata.level() <= log::max_level()
}
fn log(&self, record: &log::Record) {
if !self.enabled(record.metadata()) {
return;
}
let message = json!({
"level": severity(record.level()),
"logger": record.target(),
"data": record.args().to_string(),
});
// A full queue means the client is not draining; drop rather than grow without bound.
if let Ok(mut q) = QUEUE.lock()
&& q.len() < MAX_QUEUED
{
q.push(message);
}
}
fn flush(&self) {}
}
/// Records kept while the client is behind.
const MAX_QUEUED: usize = 512;
static SINK: Sink = Sink;
/// Install the sink once per process. The binary calls this; the library does not.
///
/// Later calls are ignored: the second `Server` in a test run finds it already there, which is
/// exactly what it wants.
pub fn install() {
// `set_logger` fails only when a logger is already installed; keeping the first one is correct.
let _already_installed = log::set_logger(&SINK);
// Silent until a client sends `logging/setLevel`, so an embedder that installs this sink for
// its own reasons gets no records it did not ask for.
log::set_max_level(LevelFilter::Off);
}
/// The level and the queue are process-wide, so the tests that move them take turns. The server
/// itself is one thread, so nothing else has to.
#[cfg(test)]
pub(crate) fn test_turn() -> std::sync::MutexGuard<'static, ()> {
match TEST_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
#[cfg(test)]
static TEST_LOCK: Mutex<()> = Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels_round_trip_and_reject_nonsense() {
let _turn = test_turn();
install();
assert_eq!(set_level(Some("warning")), Ok(()));
assert_eq!(level(), Some("warning"));
assert_eq!(set_level(Some("nope")), Err(UnknownLevel), "an unknown level changes nothing");
assert_eq!(level(), Some("warning"), "the old level survives a bad request");
assert_eq!(set_level(Some("emergency")), Ok(()));
assert_eq!(level(), Some("emergency"));
assert_eq!(set_level(None), Ok(()));
assert_eq!(level(), None);
}
#[test]
fn every_spec_level_is_understood() {
let _turn = test_turn();
install();
for name in LEVELS {
assert_eq!(set_level(Some(name)), Ok(()), "{name}");
assert_eq!(level(), Some(name), "{name}");
}
set_level(None).ok();
}
#[test]
fn records_appear_as_messages_only_when_asked_for() {
let _turn = test_turn();
install();
set_level(None).ok();
clear();
log::error!("before logging was enabled");
assert!(drain().is_empty(), "silent until set_level");
set_level(Some("info")).ok();
clear();
log::warn!("hello {name}", name = "world");
// Another test thread may log while logging is on, so look for ours rather than counting.
let got = drain();
let ours = got.iter().find(|m| m["data"] == "hello world").unwrap_or_else(|| panic!("not in {got:?}"));
assert_eq!(ours["level"], "warning");
assert!(ours["logger"].as_str().unwrap_or_default().starts_with("vectorcraft_mcp"), "{ours}");
// Below the requested level stays out.
set_level(Some("error")).ok();
clear();
log::info!("too chatty");
assert!(drain().is_empty());
set_level(None).ok();
}
}
+558
View File
@@ -0,0 +1,558 @@
//! MCP prompts (`prompts/list`, `prompts/get`) and `completion/complete`.
//!
//! A prompt is a reusable VectorCraft workflow: `prompts/get` hands the model one templated user
//! message, so a client can offer "make a poster" the way it offers a slash command. Every prompt's
//! arguments double as completion sources, and the resource templates in [`crate::resources`] share
//! the same [`Live`] catalogues, so `completion/complete` can suggest real command ids, effect ids,
//! swatch names and export formats instead of a fixed list that goes stale.
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio::FORMATS;
use crate::backend::Backend;
/// How many values one `completion/complete` reply may carry (the spec's maximum).
const MAX_VALUES: usize = 100;
/// A catalogue the completion values are read from, so they follow the app instead of a copy.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Live {
/// Export formats the engine writes (`document.formats`).
Formats,
/// Image Trace preset names (`imageTrace.presets`).
TracePresets,
/// Live effect ids (`effect.list`).
Effects,
/// Every command id (`engine.commands`).
Commands,
/// Swatch and colour-group names in the document (`swatch.list`).
Swatches,
/// Layer and object ids in the active document (`document.inspect`).
Objects,
}
/// Where one prompt argument's suggestions come from.
#[derive(Clone, Copy)]
pub enum Values {
/// A fixed list, written out here.
Fixed(&'static [&'static str]),
/// The live catalogue.
Live(Live),
}
impl Values {
fn resolve(&self, b: &mut dyn Backend) -> Vec<String> {
match self {
Values::Fixed(v) => v.iter().map(|s| (*s).to_string()).collect(),
Values::Live(live) => live_values(b, *live),
}
}
}
/// The values of one live catalogue, read fresh so a suggestion is never stale.
pub(crate) fn live_values(b: &mut dyn Backend, live: Live) -> Vec<String> {
match live {
Live::Formats => FORMATS.iter().filter(|f| f.write).map(|f| f.id.to_string()).collect(),
Live::TracePresets => names(b, "imageTrace.presets", Some("presets")),
Live::Effects => names(b, "effect.list", Some("catalog")),
// The command catalogue is a control method, not an engine command.
Live::Commands => b.call("engine.commands", json!({})).map(|v| ids_in(&v, None)).unwrap_or_default(),
Live::Swatches => names(b, "swatch.list", Some("swatches")),
Live::Objects => object_ids(b),
}
}
/// The `id` or `name` of every entry a query command returns, optionally under one key.
///
/// These are engine commands, so they go through `engine.execute`; that reaches the same commands
/// in both backends, where a control method the headless session doesn't implement would not.
fn names(b: &mut dyn Backend, command: &str, key: Option<&str>) -> Vec<String> {
b.call("engine.execute", json!({"command": command, "params": {}})).map(|v| ids_in(&v, key)).unwrap_or_default()
}
fn ids_in(reply: &Value, key: Option<&str>) -> Vec<String> {
let items = match key {
Some(k) => reply.get(k).and_then(Value::as_array).map_or(&[][..], Vec::as_slice),
None => reply.as_array().map_or(&[][..], Vec::as_slice),
};
// Catalogs are small, but never trust a document that grew one.
items.iter().take(MAX_VALUES * 8).filter_map(|i| i.get("id").or_else(|| i.get("name"))).filter_map(Value::as_str).map(str::to_string).collect()
}
/// Ids of every layer and object in the active document, for `vectorcraft://object/{id}`.
fn object_ids(b: &mut dyn Backend) -> Vec<String> {
let mut out = Vec::new();
let Ok(v) = b.call("document.inspect", json!({})) else { return out };
let Some(layers) = v.get("layers").and_then(Value::as_array) else { return out };
let mut stack: Vec<&Value> = layers.iter().rev().collect();
while let Some(node) = stack.pop() {
if let Some(id) = node.get("id").and_then(Value::as_i64) {
out.push(id.to_string());
}
if let Some(kids) = node.get("children").and_then(Value::as_array) {
stack.extend(kids.iter().rev());
}
}
out
}
/// One prompt argument.
pub struct PromptArg {
pub name: &'static str,
pub description: &'static str,
pub required: bool,
/// Substituted for `{name}` when the caller leaves the argument out.
pub default: &'static str,
pub values: Values,
}
/// One prompt: a workflow with templated arguments.
pub struct PromptDef {
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub arguments: &'static [PromptArg],
/// The message body; every `{argument}` is replaced with its value.
pub text: &'static str,
}
/// Recolour methods (`recolor.apply`'s `method`), written here because they are a closed set.
const RECOLOR_METHODS: &[&str] = &["exact", "preserveTints", "scaleTints", "tintsShades", "hueShift"];
static NONE: &[&str] = &[];
/// Every prompt, in `prompts/list` order.
pub static PROMPTS: &[PromptDef] = &[
PromptDef {
name: "poster",
title: "Design a poster",
description: "Lay out a poster from a brief: live shapes, gradients, type and live effects, checked with screenshots before export.",
arguments: &[
PromptArg {
name: "brief",
description: "What the poster is for, and the words it must carry",
required: true,
default: "",
values: Values::Fixed(NONE),
},
PromptArg {
name: "palette",
description: "The colours to use, or how to choose them",
required: false,
default: "a limited scheme of three to five colours taken from the brief",
values: Values::Fixed(&[
"a limited scheme of three to five colours taken from the brief",
"a monochrome scheme in one hue, varying value and opacity",
"a two-colour complementary scheme",
"an analogous scheme across neighbouring hues",
]),
},
PromptArg {
name: "text",
description: "The exact headline and supporting lines to set",
required: false,
default: "invent short, concrete wording that fits the brief",
values: Values::Fixed(NONE),
},
],
text: "\
Design a poster in VectorCraft.
Brief: {brief}
Palette: {palette}
Wording: {text}
Coordinates are points in document space: y points down and the origin is the first
artboard's top-left. Work in the document that is already open (inspect_document); only
run file.new when the brief asks for a different artboard size.
Method:
1. Plan the layout before drawing — a hierarchy of shapes, then the type on top.
2. draw_shape for geometry (rectangle with `radius` for corners, ellipse, polygon, star,
line); draw_path for anything with curves, from `points` or from SVG `d`.
3. set_paint for colour. A fill may be \"#rrggbb\", [r,g,b], {c,m,y,k}, {gray} or a whole
paint.setFill params object — pass {\"gradient\": {…}} for a gradient.
4. add_text for type: point type at (x, y), area type with width and height, or mode
\"area\"/\"onPath\" to flow it in or along a path.
5. apply_effect for live effects; call it with no `effect` first to get the catalogue with
each effect's parameters and defaults.
6. screenshot to look at what you made, fix what is wrong, and only then export.
Keep it editable: live shapes, live effects and live text. Do not flatten and do not
rasterise unless the brief asks for it.",
},
PromptDef {
name: "icon-set",
title: "Draw an icon set",
description: "Build a row of consistent icons as live vector shapes on a grid, then check them as a set.",
arguments: &[
PromptArg { name: "subject", description: "What the icons depict", required: true, default: "", values: Values::Fixed(NONE) },
PromptArg {
name: "count",
description: "How many icons in the set",
required: false,
default: "6",
values: Values::Fixed(&["4", "6", "8", "12", "16"]),
},
PromptArg {
name: "detail",
description: "How detailed each icon should be",
required: false,
default: "a single flat silhouette, no strokes",
values: Values::Fixed(&[
"a single flat silhouette, no strokes",
"a filled silhouette with a stroke around it",
"an outlined shape with its strokes only",
"two-tone: a filled shape and a smaller accent shape",
]),
},
],
text: "\
Draw a set of {count} icons of {subject} in VectorCraft, {detail}.
Coordinates are points in document space (y down, origin at the first artboard's
top-left).
Method:
1. Decide the cell size and the gap first, then lay every icon into its own cell so the set
reads as one family: the same stroke weight, the same optical size, the same corner radius.
2. Build each icon from draw_shape and draw_path — live shapes, not strokes traced from
somewhere else. Use `points` with `in`/`out` handles for the curves a silhouette needs.
3. set_paint the whole set the same way, then break one colour out on the object that needs
the accent.
4. screenshot the set and judge it as a set: equal weight, aligned baselines, consistent
padding. Fix what looks off rather than adding more detail.
Return the ids of the icons in a grid, and leave them as separate objects on one layer.",
},
PromptDef {
name: "recolor",
title: "Recolour artwork",
description: "Recolour the selection with explicit rows, or cluster its colours first with recolor.colors.",
arguments: &[
PromptArg {
name: "palette",
description: "The colours to map onto, or 'cluster' to group similar colours first",
required: true,
default: "",
values: Values::Fixed(&[
"cluster",
"a monochrome ramp from light to dark",
"a complementary two-colour scheme",
"one hue per layer, keeping the original values",
]),
},
PromptArg {
name: "method",
description: "How a row's colours take their new colour (recolor.apply's own default is exact; scaleTints suits a recolour driven from recolor.reduce)",
required: false,
default: "exact",
values: Values::Fixed(RECOLOR_METHODS),
},
],
text: "\
Recolour the current selection in VectorCraft with {palette}, using the `{method}` recolour method.
Method:
1. inspect_document to see what is selected and what colours it actually uses.
2. If you do not know the colours, run recolor.colors first: it groups similar colours into
rows (k-means in Lab, weighted by use) and hands back the map. Then edit the `to` colour of
each row and apply it. That is the reliable route for artwork you did not draw.
3. If you know the colours, run recolor.apply directly with `map`: one row per source colour,
`from` as keys or \"#rrggbb\", `to` as a colour. Each new colour keeps the model of the one it
replaces, and gradients, meshes, text and pattern tiles follow.
4. Group the colours with `group` so the result stays editable, and use `limitTo` to snap new
colours to a swatch library.
5. screenshot to check the result against the original intent.
One undo step covers the whole change; gradients and linked swatches stay linked.",
},
PromptDef {
name: "trace-and-style",
title: "Trace an image and style it",
description: "Trace a placed raster with Image Trace, then apply a live effect and paint over the result.",
arguments: &[
PromptArg { name: "path", description: "Path to the image file", required: true, default: "", values: Values::Fixed(NONE) },
PromptArg {
name: "preset",
description: "Image Trace preset to start from",
required: false,
default: "Default",
values: Values::Live(Live::TracePresets),
},
PromptArg {
name: "effect",
description: "A live effect to apply to the traced shapes",
required: false,
default: "stylize.scribble",
values: Values::Live(Live::Effects),
},
],
text: "\
Trace the image at {path} in VectorCraft with the \"{preset}\" preset, then style the result
with the {effect} effect.
Method:
1. open_file the image: it becomes a document of its own pixel size.
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale or
color, then threshold, colors, paths, corners, noise, method abutting or overlapping,
ignoreWhite, snapCurvesToLines. Run imageTrace.presets to see every preset with its numbers.
Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths
near 90.
3. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the
catalogue and each effect's parameters.
4. set_paint over the traced shapes to give the artwork its own colours.
5. screenshot, then remove the placed image if it is still underneath.
The trace is real geometry: every traced shape stays selectable and editable.",
},
PromptDef {
name: "export-set",
title: "Export an artboard set",
description: "Export every artboard in several formats at once, with per-format options, and report what each one lost.",
arguments: &[
PromptArg {
name: "formats",
description: "Formats to write, in order",
required: false,
default: "svg, pdf, png",
values: Values::Live(Live::Formats),
},
PromptArg {
name: "directory",
description: "Where the files go",
required: false,
default: "the current directory",
values: Values::Fixed(NONE),
},
PromptArg {
name: "scale",
description: "Raster scale in pixels per point (raster formats only)",
required: false,
default: "2",
values: Values::Fixed(&["1", "2", "3", "4"]),
},
],
text: "\
Export the document in VectorCraft to {formats}, into {directory}, at {scale} pixels per
point for the raster formats.
Method:
1. document.formats lists every format the engine reads and writes, with each one's options and
their defaults. Read it before exporting so the options you pass are real ones.
2. export once per format. A path's extension picks the format; `options` carries the rest, for
example {\"quality\": 80} for jpg. pdf writes one page per artboard — all of them, or
`artboard` (0-based) / `range` (\"1-3, 5\", 1-based); the other formats write one artboard.
3. export without a path to get the bytes back as dataBase64 instead of a file.
4. Report the `warnings` each export returns: they name what that format cannot carry.
Live effects are kept on export (geometry baked; shadows, glows and blur become SVG filters),
and text stays text unless you pass outlineText.",
},
];
/// The message body as a client should receive it.
///
/// Our source wraps these texts at the column limit, which would leave line breaks in the middle
/// of a sentence — and a substituted argument landing on one of them. So each source line is
/// folded into the paragraph it belongs to, while list items keep a line of their own.
fn flow(text: &str) -> String {
text.split("\n\n")
.map(|paragraph| {
let mut out = String::new();
for line in paragraph.lines().map(str::trim).filter(|l| !l.is_empty()) {
if !out.is_empty() {
out.push(if is_item(line) { '\n' } else { ' ' });
}
out.push_str(line);
}
out
})
.collect::<Vec<_>>()
.join("\n\n")
}
/// A numbered or dashed list item, which starts a line instead of continuing one.
fn is_item(line: &str) -> bool {
if let Some(body) = line.strip_prefix("- ") {
return !body.is_empty();
}
line.split_once(". ").is_some_and(|(number, _)| !number.is_empty() && number.len() <= 3 && number.chars().all(|c| c.is_ascii_digit()))
}
/// `prompts/list`.
pub fn list() -> Value {
let prompts: Vec<Value> = PROMPTS
.iter()
.map(|p| {
let arguments: Vec<Value> =
p.arguments.iter().map(|a| json!({"name": a.name, "description": a.description, "required": a.required})).collect();
json!({"name": p.name, "title": p.title, "description": p.description, "arguments": arguments})
})
.collect();
json!({ "prompts": prompts })
}
/// `prompts/get`: the prompt's messages with `{argument}` filled in.
pub fn get(name: &str, args: &Value) -> Result<Value, String> {
let Some(p) = PROMPTS.iter().find(|p| p.name == name) else {
let ids: Vec<&str> = PROMPTS.iter().map(|p| p.name).collect();
return Err(format!("unknown prompt `{name}` ({})", ids.join("|")));
};
let mut text = flow(p.text);
for a in p.arguments {
let given = args.get(a.name).filter(|v| !v.is_null()).map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
});
let value = match given {
Some(v) if !v.is_empty() => v,
_ if a.required => return Err(format!("prompt `{name}` needs the `{a_name}` argument", a_name = a.name)),
_ => a.default.to_string(),
};
text = text.replace(&format!("{{{}}}", a.name), &value);
}
Ok(json!({
"description": p.description,
"messages": [{"role": "user", "content": {"type": "text", "text": text}}],
}))
}
/// `completion/complete`: values for a prompt argument or a resource-template variable.
///
/// Unknown references and unknown argument names come back empty rather than as an error: a
/// completion is a convenience, and the caller is usually mid-typing.
pub fn complete(b: &mut dyn Backend, params: &Value) -> Value {
let empty = json!({"completion": {"values": [], "total": 0, "hasMore": false}});
let Some(reference) = params.get("ref") else { return empty };
let typed = params.get("argument").and_then(|a| a.get("value")).and_then(Value::as_str).unwrap_or("");
let name = params.get("argument").and_then(|a| a.get("name")).and_then(Value::as_str).unwrap_or("");
let values = match reference.get("type").and_then(Value::as_str) {
Some("ref/prompt") => match reference.get("name").and_then(Value::as_str).and_then(|n| PROMPTS.iter().find(|p| p.name == n)) {
Some(p) => match p.arguments.iter().find(|a| a.name == name) {
Some(a) => a.values.resolve(b),
None => Vec::new(),
},
None => Vec::new(),
},
Some("ref/resource") => match reference.get("uri").and_then(Value::as_str).map(crate::resources::template_source) {
Some((_, Some(live))) => live_values(b, live),
_ => Vec::new(),
},
_ => Vec::new(),
};
// Rank by prefix first (what the user is typing), then by substring.
let needle = typed.to_lowercase();
let mut matches: Vec<String> = values.iter().filter(|v| v.to_lowercase().starts_with(&needle)).cloned().collect();
if matches.is_empty() && !needle.is_empty() {
matches = values.iter().filter(|v| v.to_lowercase().contains(&needle)).cloned().collect();
}
let total = matches.len();
let has_more = total > MAX_VALUES;
matches.truncate(MAX_VALUES);
json!({"completion": {"values": matches, "total": total, "hasMore": has_more}})
}
#[cfg(test)]
mod tests {
use super::*;
fn b() -> Box<dyn Backend> {
Box::new(crate::Headless::with_document())
}
#[test]
fn every_prompt_lists_and_renders() {
let listed = list();
let entries = listed["prompts"].as_array().expect("prompts");
assert_eq!(entries.len(), PROMPTS.len());
for (p, entry) in PROMPTS.iter().zip(entries) {
assert!(!p.name.is_empty() && !p.text.is_empty(), "{}", p.name);
assert_eq!(entry["name"], p.name);
assert_eq!(entry["description"], p.description);
// Arguments are advertised with the names prompts/get will accept.
let names: Vec<&str> = p.arguments.iter().map(|a| a.name).collect();
let listed: Vec<String> =
entry["arguments"].as_array().map_or(Vec::new(), |a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect());
let names: Vec<String> = names.into_iter().map(str::to_string).collect();
assert_eq!(names, listed, "{}: listed arguments", p.name);
// Every required argument supplied: the prompt renders.
let args: Value = json!(p.arguments.iter().filter(|a| a.required).map(|a| (a.name, "x")).collect::<std::collections::BTreeMap<_, _>>());
let v = get(p.name, &args).unwrap_or_else(|e| panic!("{} renders: {e}", p.name));
assert_eq!(v["messages"].as_array().map(Vec::len), Some(1));
assert_eq!(v["messages"][0]["role"], "user");
let text = v["messages"][0]["content"]["text"].as_str().unwrap_or_default();
assert!(text.len() > 200, "{}: too short", p.name);
// Templating left nothing behind, and the source's line wrapping is gone.
// Only a declared argument's braces are placeholders; JSON examples carry braces too.
for a in p.arguments {
assert!(!text.contains(&format!("{{{}}}", a.name)), "{}: `{}` left in place", p.name, a.name);
}
assert!(!text.contains('\n') || text.contains("\n\n") || p.arguments.is_empty(), "{}: stray line break", p.name);
assert!(!text.contains(" "), "{}: double space", p.name);
// Only a missing required argument may stop it.
let missing: Value = json!({});
assert_eq!(get(p.name, &missing).is_err(), p.arguments.iter().any(|a| a.required), "{}", p.name);
}
}
#[test]
fn flow_keeps_paragraphs_and_list_items() {
let flowed = flow("first line\nsecond line\n\nnew paragraph\n1. one\n2. two\n- bullet");
assert_eq!(flowed, "first line second line\n\nnew paragraph\n1. one\n2. two\n- bullet");
assert_eq!(flow(""), "");
assert_eq!(flow("no breaks"), "no breaks");
}
#[test]
fn prompt_arguments_are_filled_in() {
let v = get("export-set", &json!({"formats": "pdf, tiff", "scale": "4"})).unwrap();
let text = v["messages"][0]["content"]["text"].as_str().unwrap_or_default();
assert!(text.contains("pdf, tiff"), "{text}");
assert!(text.contains("4 pixels per point"), "{text}");
}
#[test]
fn unknown_prompt_and_missing_argument_are_errors() {
assert!(get("nope", &json!({})).is_err());
let e = get("poster", &json!({"brief": ""})).unwrap_err();
assert!(e.contains("brief"), "{e}");
}
#[test]
fn completions_rank_prefix_first_and_cap_at_100() {
let mut b = b();
let v = complete(
b.as_mut(),
&json!({"ref": {"type": "ref/prompt", "name": "trace-and-style"}, "argument": {"name": "preset", "value": "photo"}}),
);
let values = v["completion"]["values"].as_array().unwrap();
assert!(!values.is_empty(), "imageTrace.presets should answer: {v}");
assert!(values.iter().all(|x| x.as_str().unwrap_or_default().to_lowercase().contains("photo")), "{v}");
// A nonsense prefix falls back to substring matches, and never exceeds 100.
let v = complete(b.as_mut(), &json!({"ref": {"type": "ref/prompt", "name": "export-set"}, "argument": {"name": "formats", "value": ""}}));
let all = v["completion"]["values"].as_array().unwrap().len();
assert!(all > 3, "formats should answer: {v}");
assert!(v["completion"]["total"].as_u64().unwrap() >= all as u64);
}
#[test]
fn unknown_completion_targets_are_empty_not_errors() {
let mut b = b();
for params in [
json!({"ref": {"type": "ref/prompt", "name": "nope"}, "argument": {"name": "x", "value": ""}}),
json!({"ref": {"type": "ref/prompt", "name": "poster"}, "argument": {"name": "nope", "value": ""}}),
json!({"ref": {"type": "ref/resource", "uri": "vectorcraft://nope/{id}"}, "argument": {"name": "id", "value": ""}}),
json!({"ref": {"type": "ref/other"}, "argument": {"name": "x", "value": ""}}),
json!({}),
] {
let v = complete(b.as_mut(), &params);
assert_eq!(v["completion"]["values"], json!([]), "{params}");
}
}
}
+265
View File
@@ -0,0 +1,265 @@
//! MCP resources: the fixed documents, the templates an agent can address parts of the
//! document by, and reading either.
//!
//! The templates matter for long sessions. `vectorcraft://document/json` returns the whole model,
//! which on a real document is thousands of lines an agent pays for again on every change; the
//! templates hand out one object, one command definition or one effect's parameters instead, and
//! they are what `completion/complete` suggests ids and names from.
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::prompts::Live;
/// The active document's summary: artboards, layer tree, selection, history.
pub const DOC_URI: &str = "vectorcraft://document";
/// The whole document model as JSON.
pub const DOC_JSON_URI: &str = "vectorcraft://document/json";
/// Why a resource could not be read. The distinction matters: the server turns it into `-32002`
/// (no such resource) or `-32602` (bad URI), and guessing from the message text would not hold.
#[derive(Debug)]
pub enum ReadError {
/// The URI names no resource or template this server serves.
NotFound(String),
/// The template has no value, or the value names nothing.
Invalid(String),
/// The backend could not be asked at all.
Backend(String),
}
/// One addressable template: the URI with `{var}`, and what its variable can be.
pub struct Template {
pub uri: &'static str,
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub mime: &'static str,
pub live: Live,
}
/// Every template, in `resources/templates/list` order.
pub static TEMPLATES: &[Template] = &[
Template {
uri: "vectorcraft://object/{id}",
name: "object",
title: "One layer or object",
description: "The layer or object with this id, with its children, bounds and paint (document.inspect)",
mime: "application/json",
live: Live::Objects,
},
Template {
uri: "vectorcraft://command/{id}",
name: "command",
title: "One command",
description: "The command with this id: label, menu path, shortcut, parameter description and whether it is enabled now (engine.commands)",
mime: "application/json",
live: Live::Commands,
},
Template {
uri: "vectorcraft://effect/{id}",
name: "effect",
title: "One live effect",
description: "The live effect with this id and its parameters with their defaults (effect.list)",
mime: "application/json",
live: Live::Effects,
},
Template {
uri: "vectorcraft://swatch/{name}",
name: "swatch",
title: "One swatch",
description: "The swatch or gradient/pattern swatch with this name (swatch.list)",
mime: "application/json",
live: Live::Swatches,
},
];
/// `resources/list`.
pub fn list() -> Value {
json!({"resources": [
{"uri": DOC_URI, "name": "document", "title": "Active document (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"},
{"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "mimeType": "application/json"},
]})
}
/// `resources/templates/list`.
pub fn templates() -> Value {
let list: Vec<Value> = TEMPLATES
.iter()
.map(|t| json!({"uriTemplate": t.uri, "name": t.name, "title": t.title, "description": t.description, "mimeType": t.mime}))
.collect();
json!({ "resourceTemplates": list })
}
/// Which template a URI is an instance of, and what its variable can be.
pub fn template_source(uri: &str) -> (&str, Option<Live>) {
for t in TEMPLATES {
let Some((prefix, _)) = t.uri.split_once('{') else { continue };
if uri.starts_with(prefix) {
return (t.uri, Some(t.live));
}
}
(uri, None)
}
/// `resources/read`: the document behind a resource URI.
pub fn read(b: &mut dyn Backend, uri: &str) -> Result<Value, ReadError> {
if uri == DOC_URI {
return b.call("document.inspect", json!({})).map_err(ReadError::Backend);
}
if uri == DOC_JSON_URI {
return exec(b, "document.json").map_err(ReadError::Backend);
}
let (template, var) = match TEMPLATES.iter().find(|t| template_source(uri).0 == t.uri) {
Some(t) => (t, variable(uri, t.uri)),
None => return Err(ReadError::NotFound(uri.to_string())),
};
if var.is_empty() {
return Err(ReadError::Invalid(format!("{} needs a value for `{}`", template.uri, variable_name(template.uri))));
}
let found = match template.name {
"object" => find_object(b, &var).map_err(ReadError::Backend)?,
// The command catalogue is a control method; the rest are engine commands.
"command" => find_in(b.call("engine.commands", json!({})).map_err(ReadError::Backend)?, None, "id", &var),
"effect" => find_in(exec(b, "effect.list").map_err(ReadError::Backend)?, Some("catalog"), "id", &var),
"swatch" => find_in(exec(b, "swatch.list").map_err(ReadError::Backend)?, Some("swatches"), "name", &var),
other => return Err(ReadError::NotFound(format!("no reader for template `{other}`"))),
};
Ok(match found {
Some(v) => v,
None => return Err(ReadError::Invalid(format!("no {} named `{var}` in the active document", template.name))),
})
}
/// Run a query engine command. That reaches both backends, where a control method the headless
/// session doesn't implement would not.
fn exec(b: &mut dyn Backend, command: &str) -> Result<Value, String> {
b.call("engine.execute", json!({"command": command, "params": {}}))
}
/// The part of a URI between the template's `{` and its `}`.
fn variable(uri: &str, template: &str) -> String {
let Some(open) = template.find('{') else { return String::new() };
let prefix = &template[..open];
let rest = uri.strip_prefix(prefix).unwrap_or("");
let raw = rest.split('}').next().unwrap_or("");
decode(raw)
}
/// The variable's name, e.g. `id` or `name`.
fn variable_name(template: &str) -> &str {
template.split_once('{').and_then(|(_, r)| r.split_once('}')).map_or("", |(n, _)| n)
}
/// Percent-decoding, so a swatch called "Brand 40%" can be addressed as `Brand%2040%25`.
fn decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while let Some(b) = bytes.get(i).copied() {
if b == b'%'
&& i + 2 < bytes.len()
&& let Ok(byte) = u8::from_str_radix(s.get(i + 1..i + 3).unwrap_or(""), 16)
{
out.push(byte);
i += 3;
continue;
}
out.push(b);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// The layer or object with this id in the active document, with its subtree.
fn find_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
let doc = exec(b, "document.inspect")?;
Ok(find_node(doc.get("layers"), id))
}
/// Depth-first search of the layer tree for a node whose `id` matches.
fn find_node(node: Option<&Value>, id: &str) -> Option<Value> {
let nodes = node?.as_array()?;
let mut stack: Vec<&Value> = nodes.iter().rev().collect();
while let Some(n) = stack.pop() {
let hit = match n.get("id") {
Some(Value::String(s)) => s == id,
Some(Value::Number(number)) => number.to_string() == id,
_ => false,
};
if hit {
return Some(n.clone());
}
if let Some(kids) = n.get("children").and_then(Value::as_array) {
stack.extend(kids.iter().rev());
}
}
None
}
/// The item in a query command's array whose `field` equals `value`.
fn find_in(reply: Value, key: Option<&str>, field: &str, value: &str) -> Option<Value> {
let items = match key {
Some(k) => reply.get(k).and_then(Value::as_array).map_or(&[][..], Vec::as_slice),
None => reply.as_array().map_or(&[][..], Vec::as_slice),
};
items.iter().take(MAX_CATALOGUE).find(|i| i.get(field).and_then(Value::as_str) == Some(value)).cloned()
}
/// Catalogs come from the app, but never trust one that grew without bound.
const MAX_CATALOGUE: usize = 20_000;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn variables_are_split_out_and_decoded() {
assert_eq!(variable("vectorcraft://object/42", "vectorcraft://object/{id}"), "42");
assert_eq!(variable("vectorcraft://swatch/Brand%2040%25", "vectorcraft://swatch/{name}"), "Brand 40%");
assert_eq!(variable("vectorcraft://swatch/a%zzb", "vectorcraft://swatch/{name}"), "a%zzb");
assert_eq!(variable("vectorcraft://other", "vectorcraft://object/{id}"), "");
assert_eq!(variable_name("vectorcraft://swatch/{name}"), "name");
assert_eq!(variable_name("no template"), "");
}
#[test]
fn templates_are_addressable() {
assert_eq!(template_source("vectorcraft://object/7"), ("vectorcraft://object/{id}", Some(Live::Objects)));
assert_eq!(template_source("vectorcraft://command/paint.setFill").1, Some(Live::Commands));
assert_eq!(template_source("vectorcraft://document").1, None);
}
#[test]
fn lists_are_well_formed() {
let l = list();
assert_eq!(l["resources"].as_array().map(Vec::len), Some(2));
let t = templates();
let items = t["resourceTemplates"].as_array().expect("templates");
assert_eq!(items.len(), TEMPLATES.len());
for (entry, t) in items.iter().zip(TEMPLATES) {
assert_eq!(entry["uriTemplate"], t.uri);
assert!(t.uri.contains('{'), "{} has no variable", t.uri);
assert!(t.description.len() > 20, "{} needs a description", t.name);
}
}
#[test]
fn missing_and_unknown_uris_are_errors() {
let mut b = Box::new(crate::Headless::with_document());
assert!(read(b.as_mut(), "vectorcraft://nope").is_err());
assert!(read(b.as_mut(), "vectorcraft://object/").is_err());
assert!(read(b.as_mut(), "vectorcraft://object/99999").is_err());
assert!(read(b.as_mut(), DOC_URI).is_ok());
}
#[test]
fn an_object_is_readable_once_it_exists() {
let mut b = Box::new(crate::Headless::with_document());
let made = b.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 40}})).unwrap();
let id = made["id"].as_i64().unwrap_or_default().to_string();
let v = read(b.as_mut(), &format!("vectorcraft://object/{id}")).unwrap();
assert_eq!(v["id"].to_string(), id);
assert!(v["bounds"].is_object(), "{v}");
}
}
+63 -25
View File
@@ -1,10 +1,12 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
use std::io::{BufRead, Write};
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::prompts;
use crate::resources;
use crate::tools::{call_tool, tool_definitions};
/// The MCP revision we implement.
@@ -18,15 +20,14 @@ const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are points in \
document space (y down, origin at the first artboard's top-left; a new document is 612×792). Draw with draw_shape / \
draw_path, change colours with set_paint, look with screenshot and inspect_document. Every menu action is a command: \
find it with list_commands and run it with run_command. New objects become the selection, and most commands act on the \
selection (or on explicit `ids`).";
/// Resource URIs.
pub const DOC_URI: &str = "vectorcraft://document";
pub const DOC_JSON_URI: &str = "vectorcraft://document/json";
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are \
points in document space (y down, origin at the first artboard's top-left; a new document is 612×792). \
Draw with draw_shape / draw_path, change colours with set_paint, look with screenshot and inspect_document. \
Every menu action is a command: find it with list_commands and run it with run_command. New objects become \
the selection, and most commands act on the selection (or on explicit `ids`). prompts/list has ready-made \
workflows, completion/complete finishes a command id, a format, an effect or a swatch name, and \
vectorcraft://object/{id}, vectorcraft://command/{id}, vectorcraft://effect/{id} and \
vectorcraft://swatch/{name} read one thing at a time instead of the whole document.";
/// An MCP server bound to one backend.
pub struct Server {
@@ -56,16 +57,30 @@ impl Server {
self.initialized
}
/// The `notifications/message` records queued since the last call.
///
/// Log records belong to no particular request, so these go out after the reply to whatever
/// line produced them rather than before it. [`serve`] drains this itself; a caller driving
/// [`handle_line`] by hand does the same.
pub fn take_notifications(&mut self) -> Vec<String> {
crate::logging::drain()
.into_iter()
.map(|record| json!({"jsonrpc": "2.0", "method": "notifications/message", "params": record}).to_string())
.collect()
}
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
/// the protocol stream).
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = line?;
if let Some(reply) = self.handle_line(&line) {
output.write_all(reply.as_bytes())?;
let reply = self.handle_line(&line);
let notes = self.take_notifications();
for line in reply.iter().chain(notes.iter()) {
output.write_all(line.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
output.flush()?;
}
Ok(())
}
@@ -135,7 +150,13 @@ impl Server {
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
Ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {}, "resources": {}},
"capabilities": {
"tools": {},
"resources": {},
"prompts": {"listChanged": false},
"completions": {},
"logging": {},
},
"serverInfo": {"name": "vectorcraft", "title": "VectorCraft", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
}))
@@ -147,21 +168,38 @@ impl Server {
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
}
"resources/list" => Ok(json!({"resources": [
{"uri": DOC_URI, "name": "document", "title": "Active document (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"},
{"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "mimeType": "application/json"},
]})),
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
"resources/list" => Ok(resources::list()),
"resources/templates/list" => Ok(resources::templates()),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
let v = match uri {
DOC_URI => self.backend.call("document.inspect", json!({})),
DOC_JSON_URI => self.backend.call("engine.execute", json!({"command": "document.json", "params": {}})),
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
}
.map_err(|e| (INTERNAL_ERROR, e))?;
let v = resources::read(self.backend.as_mut(), uri).map_err(|e| match e {
resources::ReadError::NotFound(uri) => (RESOURCE_NOT_FOUND, format!("resource not found: {uri}")),
resources::ReadError::Invalid(why) => (INVALID_PARAMS, why),
resources::ReadError::Backend(why) => (INTERNAL_ERROR, why),
})?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
}
"prompts/list" => Ok(prompts::list()),
"prompts/get" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing prompt `name`".to_string()))?;
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
prompts::get(name, &args).map_err(|e| (INVALID_PARAMS, e))
}
"completion/complete" => Ok(prompts::complete(self.backend.as_mut(), params)),
"logging/setLevel" => {
// `level` is required. A null level is our own way of turning logging back off,
// which the spec has no method for and a long session needs.
let Some(asked) = params.get("level") else {
return Err((INVALID_PARAMS, "missing `level`".into()));
};
let level = if asked.is_null() { None } else { asked.as_str() };
crate::logging::set_level(level).map_err(|_| match level {
Some(given) => (INVALID_PARAMS, format!("unknown level `{given}`")),
None => (INVALID_PARAMS, "`level` must be a severity name or null".to_string()),
})?;
log::debug!("client set logging to {}", level.unwrap_or("off"));
Ok(json!({}))
}
other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
}
}
+219
View File
@@ -0,0 +1,219 @@
//! The protocol surface beyond tools: prompts, completions, resource templates, subscriptions,
//! logging, progress and cancellation, driven the way a client drives them.
use serde_json::{Value, json};
use crate::{Headless, PROMPTS, Server};
fn server() -> Server {
Server::new(Box::new(Headless::with_document()))
}
/// One request, and whatever the server queued while answering it.
fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> (Value, Vec<Value>) {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let reply = s.handle_line(&line).and_then(|r| serde_json::from_str::<Value>(&r).ok());
let notes: Vec<Value> = s.take_notifications().iter().filter_map(|l| serde_json::from_str(l).ok()).collect();
let reply = reply.unwrap_or(Value::Null);
assert_eq!(reply["jsonrpc"], "2.0", "{reply}");
assert_eq!(reply["id"], id, "{reply}");
(reply, notes)
}
fn result(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let (v, _) = rpc(s, id, method, params);
assert!(v.get("error").is_none(), "{method}: {v}");
v["result"].clone()
}
/// Run a tool and parse the JSON it answers with.
fn tool(s: &mut Server, id: u64, name: &str, args: Value) -> Value {
let r = result(s, id, "tools/call", json!({"name": name, "arguments": args}));
assert_eq!(r["isError"], false, "{name}: {r}");
let text = r["content"][0]["text"].as_str().unwrap_or("null");
serde_json::from_str(text).unwrap_or(Value::Null)
}
/// Draw a rectangle and return its id.
fn rectangle(s: &mut Server, id: u64) -> i64 {
let made = tool(s, id, "draw_shape", json!({"shape": "rectangle", "x": 10, "y": 10, "width": 60, "height": 40}));
made["id"].as_i64().unwrap_or_default()
}
fn err_code(s: &mut Server, id: u64, method: &str, params: Value) -> i64 {
let (v, _) = rpc(s, id, method, params);
v.get("error").and_then(|e| e["code"].as_i64()).unwrap_or_else(|| panic!("{method} should have failed: {v}"))
}
// ---------- capabilities ----------
#[test]
fn initialize_advertises_what_the_server_actually_answers() {
let mut s = server();
let caps = result(&mut s, 1, "initialize", json!({}))["capabilities"].clone();
assert!(caps["tools"].is_object(), "{caps}");
assert!(caps["resources"].is_object(), "{caps}");
// No `subscribe`: this server cannot observe a change while a call is running, so it does not
// claim to. Advertising a capability that silently misses edits is worse than not having it.
assert!(caps["resources"].get("subscribe").is_none(), "{caps}");
assert!(caps["prompts"].is_object(), "prompts are implemented");
assert!(caps["completions"].is_object(), "completion/complete is implemented");
assert!(caps["logging"].is_object(), "logging/setLevel is implemented");
// And the instructions point at them, since that is where a client looks first.
let instructions = result(&mut s, 2, "initialize", json!({}))["instructions"].as_str().unwrap_or_default().to_string();
assert!(instructions.contains("prompts/list"), "{instructions}");
assert!(instructions.contains("points in document space"), "{instructions}");
}
// ---------- prompts ----------
#[test]
fn prompts_are_listed_and_rendered_over_the_protocol() {
let mut s = server();
let listed = result(&mut s, 1, "prompts/list", json!({}))["prompts"].clone();
let entries = listed.as_array().expect("prompts");
assert_eq!(entries.len(), PROMPTS.len());
for e in entries {
assert!(e["name"].as_str().is_some_and(|n| !n.is_empty()), "{e}");
assert!(e["description"].as_str().is_some_and(|d| d.len() > 20), "{e}");
// Arguments are advertised with the flag prompts/get honours.
for a in e["arguments"].as_array().map_or(&[][..], Vec::as_slice) {
assert!(a["required"].is_boolean(), "{a}");
}
}
let got = result(&mut s, 2, "prompts/get", json!({"name": "poster", "arguments": {"brief": "a jazz festival"}}));
assert_eq!(got["messages"].as_array().map(Vec::len), Some(1));
assert_eq!(got["messages"][0]["role"], "user");
let text = got["messages"][0]["content"]["text"].as_str().unwrap_or_default();
assert!(text.contains("a jazz festival"), "{text}");
}
#[test]
fn prompt_argument_errors_are_invalid_params() {
let mut s = server();
assert_eq!(err_code(&mut s, 1, "prompts/get", json!({"name": "poster"})), -32602);
assert_eq!(err_code(&mut s, 2, "prompts/get", json!({"name": "nope"})), -32602);
assert_eq!(err_code(&mut s, 3, "prompts/get", json!({})), -32602);
}
// ---------- completions ----------
#[test]
fn completions_come_from_the_live_catalogues() {
let mut s = server();
// A prompt argument fed by the export formats.
let v = result(
&mut s,
1,
"completion/complete",
json!({"ref": {"type": "ref/prompt", "name": "export-set"}, "argument": {"name": "formats", "value": "sv"}}),
);
let values: Vec<&str> = v["completion"]["values"].as_array().map_or(Vec::new(), |a| a.iter().filter_map(Value::as_str).collect());
assert!(values.contains(&"svg"), "{v}");
assert_eq!(v["completion"]["hasMore"], false);
// A resource template variable, after something exists to point at.
rectangle(&mut s, 2);
let v = result(
&mut s,
3,
"completion/complete",
json!({"ref": {"type": "ref/resource", "uri": "vectorcraft://object/{id}"}, "argument": {"name": "id", "value": ""}}),
);
let values = v["completion"]["values"].as_array().map_or(0, Vec::len);
assert!(values > 0, "{v}");
assert!(v["completion"]["total"].as_u64().unwrap_or(0) >= values as u64);
}
// ---------- resource templates ----------
#[test]
fn templates_are_listed_and_readable() {
let mut s = server();
let listed = result(&mut s, 1, "resources/templates/list", json!({}));
let templates = listed["resourceTemplates"].as_array().expect("templates");
assert!(templates.len() >= 4, "{listed}");
let id = rectangle(&mut s, 2);
let read = result(&mut s, 3, "resources/read", json!({"uri": format!("vectorcraft://object/{id}")}));
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
assert_eq!(body["id"].as_i64(), Some(id), "{body}");
assert!(body["bounds"].is_object(), "{body}");
// A command definition is one small read instead of the whole 400-command catalogue.
let read = result(&mut s, 4, "resources/read", json!({"uri": "vectorcraft://command/paint.setFill"}));
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
assert_eq!(body["id"], "paint.setFill", "{body}");
assert!(body["params"].as_str().is_some_and(|p| !p.is_empty()), "{body}");
// An effect's parameters and defaults.
let read = result(&mut s, 5, "resources/read", json!({"uri": "vectorcraft://effect/stylize.dropShadow"}));
let body: Value = serde_json::from_str(read["contents"][0]["text"].as_str().unwrap_or("{}")).unwrap_or_default();
assert_eq!(body["id"], "stylize.dropShadow", "{body}");
// Missing values and unknown resources are distinguishable.
assert_eq!(err_code(&mut s, 6, "resources/read", json!({"uri": "vectorcraft://object/"})), -32602);
assert_eq!(err_code(&mut s, 7, "resources/read", json!({"uri": "vectorcraft://command/nope"})), -32602);
assert_eq!(err_code(&mut s, 8, "resources/read", json!({"uri": "vectorcraft://nope"})), -32002);
}
// ---------- logging ----------
#[test]
fn logging_levels_are_validated_and_off_by_default() {
let _turn = crate::logging::test_turn();
let mut s = server();
assert_eq!(crate::logging::level(), None, "silent until asked for");
result(&mut s, 1, "logging/setLevel", json!({"level": "debug"}));
assert_eq!(crate::logging::level(), Some("debug"));
// A null level turns it back off; a missing one is a bad request.
result(&mut s, 2, "logging/setLevel", json!({"level": null}));
assert_eq!(crate::logging::level(), None);
assert_eq!(err_code(&mut s, 3, "logging/setLevel", json!({"level": "shouty"})), -32602);
assert_eq!(err_code(&mut s, 4, "logging/setLevel", json!({})), -32602);
// A rejected level leaves the accepted one alone.
result(&mut s, 5, "logging/setLevel", json!({"level": "warning"}));
assert_eq!(err_code(&mut s, 6, "logging/setLevel", json!({"level": "shouty"})), -32602);
assert_eq!(crate::logging::level(), Some("warning"));
result(&mut s, 7, "logging/setLevel", json!({"level": null}));
assert_eq!(crate::logging::level(), None);
}
/// Log records reach the client as `notifications/message`, and only after it asked.
#[test]
fn log_records_go_out_as_notifications_once_asked_for() {
let _turn = crate::logging::test_turn();
// The binary installs the logger, not the library, so a test does it for itself.
crate::logging::install();
let mut s = server();
let input = [
json!({"jsonrpc": "2.0", "id": 1, "method": "logging/setLevel", "params": {"level": "debug"}}).to_string(),
json!({"jsonrpc": "2.0", "id": 2, "method": "prompts/list"}).to_string(),
json!({"jsonrpc": "2.0", "id": 3, "method": "logging/setLevel", "params": {"level": null}}).to_string(),
]
.join("\n");
let mut out = Vec::new();
s.serve(input.as_bytes(), &mut out).unwrap();
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let notes: Vec<&Value> = lines.iter().filter(|l| l["method"] == "notifications/message").collect();
assert!(!notes.is_empty(), "the level change itself is logged: {lines:?}");
for n in &notes {
assert_eq!(n["jsonrpc"], "2.0", "{n}");
assert!(
["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]
.contains(&n["params"]["level"].as_str().unwrap_or_default()),
"{n}"
);
assert!(n["params"]["data"].is_string(), "{n}");
}
// Every reply still came before the notifications that followed it.
let answered = lines.iter().filter(|l| l.get("id").is_some()).count();
assert_eq!(answered, 3, "{lines:?}");
let last_reply = lines.iter().rposition(|l| l.get("id").is_some()).unwrap_or(0);
assert!(lines[last_reply + 1..].iter().all(|l| l.get("id").is_none()), "{lines:?}");
result(&mut s, 4, "logging/setLevel", json!({"level": null}));
}
+231
View File
@@ -182,6 +182,237 @@ fn resources_are_json() {
}
}
// ---------- prompts, completions, templates, subscriptions ----------
/// Every prompt is well formed and renders whatever it is handed.
#[test]
fn prompts_are_valid_and_render_with_junk_arguments() {
let mut s = server();
let listed = rpc(&mut s, 1, "prompts/list", json!({}));
let prompts = listed["result"]["prompts"].as_array().unwrap();
assert!(!prompts.is_empty());
let mut failures = vec![];
for p in prompts {
let name = p["name"].as_str().unwrap();
assert!(p["description"].as_str().unwrap().len() > 20, "{name}");
let args: Vec<&str> = p["arguments"].as_array().map_or(&[][..], Vec::as_slice).iter().filter_map(|a| a["name"].as_str()).collect();
// Missing, empty, junk and hostile arguments all either render or fail cleanly.
let mut cases = vec![json!({}), Value::Null, json!([]), json!("x")];
for a in &args {
for v in junk_values() {
cases.push(json!({ *a: v }));
cases.push(json!({ *a: { "nested": v.clone() } }));
cases.push(json!({ *a: [v.clone()] }));
}
}
// Filled with every argument, the prompt must render.
let full: Value = json!(args.iter().map(|a| (*a, "value")).collect::<std::collections::BTreeMap<_, _>>());
cases.push(full);
for arguments in cases {
let r = catch_quiet(|| rpc(&mut s, 2, "prompts/get", json!({"name": name, "arguments": arguments.clone()})));
match r {
Err(p) => failures.push(format!("PANIC {name} {arguments}: {p}")),
Ok(v) => {
assert!(v["result"].is_object() || v["error"]["code"] == -32602, "{name} {arguments}: {v}");
// Whatever it returned is valid JSON text in a message.
if let Some(messages) = v["result"]["messages"].as_array() {
for m in messages {
assert!(["user", "assistant"].contains(&m["role"].as_str().unwrap()), "{m}");
assert!(m["content"]["text"].is_string(), "{m}");
}
}
}
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// `completion/complete` answers for every prompt argument and never panics on junk.
#[test]
fn completions_are_well_formed_and_survive_junk() {
let mut s = server();
let listed = rpc(&mut s, 1, "prompts/list", json!({}));
let mut cases = vec![];
for p in listed["result"]["prompts"].as_array().unwrap() {
let name = p["name"].as_str().unwrap();
for a in p["arguments"].as_array().map_or(&[][..], Vec::as_slice) {
let arg = a["name"].as_str().unwrap();
for value in ["", "a", "svg", "%20", "..", &"x".repeat(300)] {
cases.push(json!({"ref": {"type": "ref/prompt", "name": name}, "argument": {"name": arg, "value": value}}));
}
}
// An argument the prompt does not have, and a prompt that does not exist.
cases.push(json!({"ref": {"type": "ref/prompt", "name": name}, "argument": {"name": "nope", "value": ""}}));
cases.push(json!({"ref": {"type": "ref/prompt", "name": "nope"}, "argument": {"name": "x", "value": ""}}));
}
for t in templates(&mut s) {
for v in ["", "1", "%", "vectorcraft://x"] {
cases.push(json!({"ref": {"type": "ref/resource", "uri": t["uriTemplate"]}, "argument": {"name": "id", "value": v}}));
}
}
// And malformed references of every shape.
for junk in [json!({}), json!(null), json!([]), json!("x")] {
cases.push(junk);
}
for junk in junk_values().into_iter().take(12) {
cases.push(junk);
}
let mut failures = vec![];
for params in cases {
let r = catch_quiet(|| rpc(&mut s, 2, "completion/complete", params.clone()));
match r {
Err(p) => failures.push(format!("PANIC {params}: {p}")),
Ok(v) => {
assert!(v.get("error").is_none(), "{params}: {v}");
let completion = &v["result"]["completion"];
let values = completion["values"].as_array().unwrap_or_else(|| panic!("{params}: no values"));
assert!(values.len() <= 100, "{params}: {} values", values.len());
assert!(values.iter().all(Value::is_string), "{params}: {v}");
if let Some(total) = completion["total"].as_u64() {
assert!(total >= values.len() as u64, "{params}: {v}");
}
assert_eq!(completion["hasMore"], total_exceeds(completion), "{params}: {v}");
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// `hasMore` is set exactly when the catalogue held more than we sent.
fn total_exceeds(completion: &Value) -> Value {
let sent = completion["values"].as_array().map_or(0, Vec::len);
json!(completion["total"].as_u64().is_some_and(|t| t > sent as u64))
}
fn templates(s: &mut Server) -> Vec<Value> {
rpc(s, 9, "resources/templates/list", json!({}))["result"]["resourceTemplates"].as_array().unwrap().clone()
}
/// Every template lists with a variable, and reading it answers JSON or a clean error.
#[test]
fn resource_templates_are_addressable() {
let mut s = server();
let mut failures = vec![];
for t in templates(&mut s) {
let uri = t["uriTemplate"].as_str().unwrap();
assert!(uri.contains('{') && uri.ends_with('}'), "{t}");
assert!(t["description"].as_str().unwrap().len() > 20, "{t}");
assert!(t["mimeType"].is_string(), "{t}");
// A template with its variable left empty, filled with junk, and with a real value.
let prefix = uri.split('{').next().unwrap();
let mut uris = vec![prefix.to_string(), format!("{prefix}{{"), format!("{prefix}%20"), format!("{prefix}-1")];
let made = call(&mut s, "draw_shape", json!({"shape": "rectangle", "x": 1, "y": 1, "width": 20, "height": 20}));
if let Some(id) =
made["result"]["content"][0]["text"].as_str().and_then(|t| serde_json::from_str::<Value>(t).ok()).and_then(|v| v["id"].as_i64())
{
uris.push(format!("{prefix}{id}"));
}
for uri in uris {
let r = catch_quiet(|| rpc(&mut s, 10, "resources/read", json!({"uri": uri.clone()})));
match r {
Err(p) => failures.push(format!("PANIC {uri}: {p}")),
Ok(v) => {
if let Some(text) = v["result"]["contents"][0]["text"].as_str() {
serde_json::from_str::<Value>(text).unwrap_or_else(|e| panic!("{uri} is not JSON: {e}"));
} else {
let code = v["error"]["code"].as_i64().unwrap_or_default();
assert!(code == -32602 || code == -32002, "{uri}: {v}");
}
}
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Anything a client may send for the prompts, completion, logging and template methods leaves the
/// server answering and the document valid.
#[test]
fn the_new_methods_survive_junk_and_keep_the_document_valid() {
let mut failures = vec![];
let mut s = server();
rpc(&mut s, 1, "tools/call", json!({"name": "draw_shape", "arguments": {"shape": "rectangle", "x": 0, "y": 0, "width": 30, "height": 20}}));
let before = doc_json(&document(&mut s));
let mut cases: Vec<(&str, Value)> = vec![];
for uri in ["vectorcraft://document", "vectorcraft://object/1", "vectorcraft://nope", "", "x://y", "%"] {
cases.push(("resources/read", json!({"uri": uri})));
cases.push(("resources/read", json!({})));
cases.push(("resources/read", json!({"uri": 5})));
}
for level in [json!("debug"), json!("emergency"), json!(null), json!("shouty"), json!(5), json!([]), json!({})] {
cases.push(("logging/setLevel", json!({"level": level})));
}
cases.push(("logging/setLevel", json!({})));
for name in [json!("poster"), json!("nope"), json!(""), json!(5), json!(null), json!([])] {
cases.push(("prompts/get", json!({"name": name})));
cases.push(("prompts/get", json!({"name": name, "arguments": []})));
}
for ref_ in [
json!({"type": "ref/prompt", "name": "poster"}),
json!({"type": "ref/resource", "uri": "vectorcraft://object/{id}"}),
json!({"type": "ref/other"}),
json!("x"),
json!([]),
] {
cases.push(("completion/complete", json!({"ref": ref_, "argument": {"name": "brief", "value": ""}})));
cases.push(("completion/complete", json!({"ref": ref_})));
}
for (method, params) in cases {
let r = catch_quiet(|| rpc(&mut s, 11, method, params.clone()));
match r {
Err(p) => failures.push(format!("PANIC {method} {params}: {p}")),
Ok(v) => {
assert!(v.get("result").is_some() != v.get("error").is_some(), "{method} {params}: {v}");
// Whatever it answered, every notification it queued is a JSON-RPC message.
for line in s.take_notifications() {
let n: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("bad notification {line}: {e}"));
assert_eq!(n["jsonrpc"], "2.0", "{n}");
assert_eq!(n["method"], "notifications/message", "{n}");
assert!(n["params"]["level"].is_string() && n["params"]["data"].is_string(), "{n}");
}
check_document(&document(&mut s)).unwrap_or_else(|e| failures.push(format!("{method} {params}: {e}")));
}
}
}
// None of these edit the document, so it must be exactly as it was.
assert_eq!(doc_json(&document(&mut s)), before, "a read-only method changed the document");
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// Every capability the server advertises is one it actually answers.
#[test]
fn advertised_capabilities_all_answer() {
let mut s = server();
let caps = rpc(&mut s, 1, "initialize", json!({}))["result"]["capabilities"].clone();
let checks: Vec<(&str, &str, Value)> = vec![
("tools", "tools/list", json!({})),
("prompts", "prompts/list", json!({})),
("completions", "completion/complete", json!({"ref": {"type": "ref/prompt", "name": "poster"}, "argument": {"name": "brief", "value": ""}})),
("logging", "logging/setLevel", json!({"level": null})),
("resources", "resources/list", json!({})),
("resources", "resources/templates/list", json!({})),
("resources", "resources/read", json!({"uri": doc_uri_json(&mut s)})),
];
for (capability, method, params) in checks {
assert!(caps[capability].is_object(), "{capability} is not advertised: {caps}");
assert!(rpc(&mut s, 2, method, params).get("error").is_none(), "{method} is advertised but does not answer");
}
// And nothing is advertised that the server does not answer.
assert!(caps["resources"].get("subscribe").is_none(), "subscribe is advertised but not implemented: {caps}");
assert_eq!(err_code(&mut s, 3, "resources/subscribe", json!({"uri": "vectorcraft://document"})), -32601);
}
/// A capability the server advertises must be one it answers, and one it does not advertise must
/// not be there.
fn err_code(s: &mut Server, id: u64, method: &str, params: Value) -> i64 {
let v = rpc(s, id, method, params);
v.get("error").and_then(|e| e["code"].as_i64()).unwrap_or_else(|| panic!("{method} should have failed: {v}"))
}
#[test]
fn undo_redo_tools_restore_documents() {
let mut s = server();
+10 -1
View File
@@ -1,6 +1,6 @@
# vectorcraft-pathops
Path operations for VectorCraft: booleans, the Pathfinder panel, Shape Builder regions, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `vectorcraft_geom::PathData`.
Path operations for VectorCraft: booleans, the Pathfinder panel, Shape Builder regions, Live Paint planar maps, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `vectorcraft_geom::PathData`.
## Booleans (curve-preserving)
@@ -49,6 +49,15 @@ Each result takes its key as follows:
| Crop | Keeps the visible parts of the lower objects inside the front-most object |
| Outline | Returns open edge paths, split at every junction; coincident edges are de-duplicated, and each keeps the front-most key |
## Live Paint
```rust
pub fn live_paint(shapes: &[Shape] /* back → front */) -> (Vec<Region>, Vec<Shape>); // (faces, edges)
pub fn interior_point(path: &PathData) -> Option<Point>;
```
Unlike `regions`, which splits the filled areas of closed shapes, `live_paint` treats every path as an edge, open paths included. Three crossing lines enclose a triangle, a line across a rectangle splits it in two, and an area enclosed by paths becomes a face even when nothing fills it. Each face lists the inputs whose fill covers it; the list is empty for an area that is only enclosed by paths. Each edge is a piece of a path, split wherever another path meets it, and keeps the key of the front-most input it lies on.
## Offset / stroke
```rust
+2 -2
View File
@@ -157,7 +157,7 @@ fn el_points(el: &kurbo::PathEl) -> impl Iterator<Item = Point> {
/// handle within `eps` of its anchor's height is levelled with it (no y-extremum is left in a
/// sub-`eps` sliver of a curve). Anchors move by less than `eps` and handles by less than `2·eps`,
/// the order of the sweep's own tolerance.
fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) {
pub(crate) fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) {
use kurbo::PathEl::*;
let mut ys: Vec<f64> = paths.iter().flat_map(|p| p.elements().iter().filter_map(kurbo::PathEl::end_point)).map(|p| p.y).collect();
ys.sort_by(f64::total_cmp);
@@ -339,7 +339,7 @@ pub(crate) fn all_contours_to_path(c: &Contours, tidy: &Tidy) -> PathData {
/// Contours whose mean width (2·area / perimeter) is below `precision` are numerical debris
/// (e.g. the lens left between two coincident curves) and are dropped, like Illustrator's
/// Pathfinder precision setting does.
fn is_sliver(bp: &BezPath, precision: f64) -> bool {
pub(crate) fn is_sliver(bp: &BezPath, precision: f64) -> bool {
let a = bp.area().abs();
let per = bp.perimeter(1e-6);
per <= 0.0 || 2.0 * a / per < precision
+2
View File
@@ -11,6 +11,7 @@ mod edit;
mod fit;
mod offset;
mod pathfinder;
mod planar;
pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all};
pub use edit::{
@@ -18,6 +19,7 @@ pub use edit::{
};
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
pub use planar::{interior_point, live_paint};
/// Errors from fallible operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
+815
View File
@@ -0,0 +1,815 @@
//! Live Paint planar maps: the faces and edges a set of paths divides the plane into.
//!
//! Unlike Pathfinder [`regions`](crate::regions), which splits the *filled areas* of closed shapes,
//! every path here is an edge, open ones included: three crossing lines enclose a triangle, a line
//! across a rectangle splits it in two, and an area enclosed by paths is a face whether or not
//! anything fills it.
//!
//! The sweep (`linesweeper`) only takes closed paths, so an open path goes in traced there and
//! back. Its two passes would cancel out as winding numbers, so the sweep counts boundary crossings
//! instead ([`Crossings`]), which never cancel. Faces are then walked on the planar graph of the
//! sweep's output segments; which inputs an edge came from and which fills cover a face are found
//! geometrically (the sweep's winding numbers can't be trusted once open paths and coincident
//! edges are in).
//!
//! The sweep runs in a sheared frame ([`SHEAR`]): it merges the two passes of a *horizontal* open
//! path as if they cancelled out, and in the sheared frame no edge drawn at a common angle (0°,
//! 45°, 90°…) is horizontal. The shear is by a power of two, so it maps typical coordinates
//! exactly and keeps exact coincidences (a corner on another shape's edge) exact.
use std::collections::HashMap;
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, PathSeg, Point, Shape as _};
use linesweeper::topology::{Topology, WindingNumber};
use vectorcraft_geom::{FillRule, PathData};
use crate::boolean::{DEFAULT_PRECISION, Seg, Tidy, eps_for, is_sliver, segs_to_subpath, snap_horizontals, tidy_segments, to_seg};
use crate::pathfinder::{Region, Shape};
/// The sweep's frame: y + x / 32. Only edges sloping -1/32 in the document are horizontal in it.
const SHEAR: Affine = Affine::new([1.0, 1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]);
const UNSHEAR: Affine = Affine::new([1.0, -1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]);
/// Boundary crossings per input, counted from the far west: slot `2i` counts input `i`'s
/// segments running one way, slot `2i + 1` the other. An open path traced there and back adds one
/// to each, so its edges never cancel out of the map.
#[derive(Clone, Debug, Default)]
struct Crossings(Vec<u32>);
impl Crossings {
fn get(&self, slot: usize) -> u32 {
self.0.get(slot).copied().unwrap_or(0)
}
}
impl PartialEq for Crossings {
fn eq(&self, other: &Self) -> bool {
(0..self.0.len().max(other.0.len())).all(|i| self.get(i) == other.get(i))
}
}
impl Eq for Crossings {}
impl std::ops::Add for Crossings {
type Output = Crossings;
fn add(mut self, rhs: Self) -> Crossings {
self += rhs;
self
}
}
impl std::ops::AddAssign for Crossings {
fn add_assign(&mut self, rhs: Self) {
if self.0.len() < rhs.0.len() {
self.0.resize(rhs.0.len(), 0);
}
for (a, b) in self.0.iter_mut().zip(rhs.0) {
*a = a.saturating_add(b);
}
}
}
impl WindingNumber for Crossings {
type Tag = usize;
fn single(tag: usize, positive: bool) -> Self {
let slot = 2 * tag + usize::from(!positive);
let mut v = vec![0; slot + 1];
if let Some(c) = v.get_mut(slot) {
*c = 1;
}
Crossings(v)
}
fn of_tag(&self, tag: usize) -> Self {
let mut v = vec![0; 2 * tag + 2];
for slot in [2 * tag, 2 * tag + 1] {
if let Some(c) = v.get_mut(slot) {
*c = self.get(slot);
}
}
Crossings(v)
}
}
/// A point in the middle of a run of segments.
fn mid(segs: &[Seg]) -> Option<Point> {
segs.get(segs.len() / 2).map(|s| s.c.eval(0.5))
}
/// The inputs' segments (in the sweep's frame), to tell which inputs an edge lies on.
struct Outlines {
segs: Vec<(kurbo::Rect, PathSeg, usize)>,
tol: f64,
}
impl Outlines {
fn new(inputs: &[(BezPath, usize)], eps: f64) -> Self {
let segs = inputs.iter().flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i))).collect();
// The sweep moves points by a few `eps` at most.
Self { segs, tol: 16.0 * eps }
}
/// The front-most input whose outline passes through `p`.
fn source_at(&self, p: Point) -> Option<usize> {
use kurbo::ParamCurveNearest;
let tol = self.tol;
self.segs.iter().filter(|(r, s, _)| r.inflate(tol, tol).contains(p) && s.nearest(p, 1e-9).distance_sq <= tol * tol).map(|(_, _, i)| *i).max()
}
}
/// The area an input's fill covers: its closed subpaths (open ones bound no face).
struct Fill {
path: BezPath,
bounds: kurbo::Rect,
rule: FillRule,
}
impl Fill {
fn new(s: &Shape) -> Self {
let mut path = BezPath::new();
for sp in s.path.subpaths.iter().filter(|sp| sp.closed && sp.anchors.len() >= 2) {
sp.to_bezpath_into(&mut path);
}
Self { bounds: path.bounding_box(), path, rule: s.rule }
}
fn contains(&self, p: Point) -> bool {
!self.path.elements().is_empty() && self.bounds.contains(p) && crate::boolean::inside(self.rule, self.path.winding(p))
}
}
/// One output segment of the sweep: an edge of the planar graph between two vertices.
struct Edge {
segs: Vec<Seg>,
ends: [usize; 2],
/// The input it came from (the front-most, where inputs share it).
source: usize,
}
/// Half-edge `2e` runs along edge `e`, `2e + 1` back along it.
fn twin(h: usize) -> usize {
h ^ 1
}
/// The segments of half-edge `h`, in its direction.
fn half_segs(edges: &[Edge], h: usize) -> Vec<Seg> {
let Some(e) = edges.get(h / 2) else { return Vec::new() };
if h.is_multiple_of(2) {
e.segs.clone()
} else {
e.segs.iter().rev().map(|s| Seg { c: CubicBez::new(s.c.p3, s.c.p2, s.c.p1, s.c.p0), line: s.line }).collect()
}
}
/// The vertex half-edge `h` leaves from.
fn origin(edges: &[Edge], h: usize) -> Option<usize> {
edges.get(h / 2).map(|e| e.ends[h % 2])
}
/// Where half-edge `h` (leaving `v`) first gets `r` away from it, as an angle around `v`.
///
/// Edges that only meet at `v` cross a circle around it in the order they leave `v` in, however
/// they start out (tangent curves, or the sweep's short straight stubs at a vertex).
fn leaving_angle(edges: &[Edge], h: usize, v: Point, r: f64) -> f64 {
let segs = half_segs(edges, h);
let mut bp = BezPath::new();
for (k, s) in segs.iter().enumerate() {
if k == 0 {
bp.move_to(s.c.p0);
}
bp.curve_to(s.c.p1, s.c.p2, s.c.p3);
}
let mut prev = segs.first().map_or(v, |s| s.c.p0);
let mut hit: Option<Point> = None;
kurbo::flatten(bp.iter(), (r * 1e-4).max(1e-12), |el| {
if let (None, kurbo::PathEl::LineTo(p)) = (hit, el) {
if p.distance(v) >= r {
// The point on prev → p at distance r (prev is closer).
let (d, f) = (p - prev, prev - v);
let (a, b, c) = (d.hypot2(), 2.0 * f.dot(d), f.hypot2() - r * r);
let t = if a > 0.0 { ((-b + (b * b - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a)).clamp(0.0, 1.0) } else { 1.0 };
hit = Some(prev + d * t);
}
prev = p;
}
});
// An edge too short to reach r (it can't, r is at most half of every chord) leaves along its
// tangent.
let far =
hit.unwrap_or_else(|| segs.first().map_or(v, |s| [s.c.p1, s.c.p2, s.c.p3].into_iter().find(|p| p.distance(s.c.p0) > 0.0).unwrap_or(s.c.p3)));
(far - v).atan2()
}
/// Path of a closed run of half-edges, tidied (split pieces of one input curve joined again).
fn cycle_bezpath(edges: &[Edge], cycle: &[usize]) -> BezPath {
let mut bp = BezPath::new();
for (k, s) in cycle.iter().flat_map(|&h| half_segs(edges, h)).enumerate() {
if k == 0 {
bp.move_to(s.c.p0);
}
if s.line {
bp.line_to(s.c.p3);
} else {
bp.curve_to(s.c.p1, s.c.p2, s.c.p3);
}
}
bp.close_path();
bp
}
/// `cycle` without the spurs it runs out and back along (edges with the same face on both sides,
/// such as a line's loose end).
fn without_spurs(cycle: &[usize]) -> Vec<usize> {
let mut out: Vec<usize> = Vec::with_capacity(cycle.len());
for &h in cycle {
if out.last() == Some(&twin(h)) {
out.pop();
} else {
out.push(h);
}
}
// A spur where the walk started wraps around the ends.
while out.len() >= 2 && out.first().map(|&h| twin(h)) == out.last().copied() {
out.pop();
out.remove(0);
}
out
}
/// Append `open` traced there and back (a closed, zero-area path along it) to `out`.
fn there_and_back(open: &BezPath, out: &mut BezPath) {
let segs: Vec<PathSeg> = open.segments().collect();
let Some(first) = segs.first() else { return };
out.move_to(first.start());
for s in &segs {
out.push(s.as_path_el());
}
for s in segs.iter().rev() {
out.push(s.reverse().as_path_el());
}
out.close_path();
}
/// `path` back from the sweep's frame, its anchors within `tol` of an input anchor (`sorted` by x)
/// put exactly on it, handles moving with them.
fn unshear(mut path: PathData, sorted: &[Point], tol: f64) -> PathData {
path.transform(UNSHEAR);
for a in path.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) {
let lo = sorted.partition_point(|q| q.x < a.p.x - tol);
if let Some(q) = sorted.get(lo..).unwrap_or_default().iter().take_while(|q| q.x <= a.p.x + tol).find(|q| (q.y - a.p.y).abs() <= tol) {
let d = *q - a.p;
a.p = *q;
a.h_in += d;
a.h_out += d;
}
}
path
}
fn find(parent: &mut [usize], mut v: usize) -> usize {
while let Some(&p) = parent.get(v) {
if p == v {
break;
}
let gp = parent.get(p).copied().unwrap_or(p);
if let Some(slot) = parent.get_mut(v) {
*slot = gp;
}
v = p;
}
v
}
/// The Live Paint planar map of `shapes` (back → front).
///
/// Faces are the bounded areas the paths enclose, holes included, each with the inputs whose fill
/// covers it (ascending; empty for an area only enclosed by paths). Edges are the paths split
/// wherever they meet another path, keyed by the input they came from (the front-most where
/// inputs share one). Empty when the paths can't be swept (non-finite or too degenerate).
pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
build(shapes).unwrap_or_default()
}
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
let mut inputs: Vec<(BezPath, usize)> = Vec::new();
for (i, s) in shapes.iter().enumerate() {
let mut bp = BezPath::new();
for sp in s.path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) {
if sp.closed {
sp.to_bezpath_into(&mut bp);
} else {
let mut open = BezPath::new();
sp.to_bezpath_into(&mut open);
there_and_back(&open, &mut bp);
}
}
if !bp.elements().is_empty() {
bp.apply_affine(SHEAR);
inputs.push((bp, i));
}
}
if inputs.is_empty() {
return None;
}
let eps = eps_for(inputs.iter().map(|p| &p.0)).ok()?;
snap_horizontals(&mut inputs.iter_mut().map(|p| &mut p.0).collect::<Vec<_>>(), eps);
// The sweep can panic on rare near-degenerate input; that must never take the app down.
let sweep =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::<Crossings>::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps)));
let top = sweep.ok()?.ok()?;
let tidy = Tidy::keeping(DEFAULT_PRECISION, inputs.iter().map(|p| &p.0));
// The planar graph: vertices are the sweep's points, edges its segments. Points closer than
// the sweep's tolerance are one vertex (it can put a corner that lies on another path's edge
// a rounding error away from where it cuts that edge).
let positions = top.compute_positions();
let cell = 4.0 * eps;
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
let mut points: Vec<Point> = Vec::new();
let mut vertex_id = |p: Point| {
let (cx, cy) = ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64);
let near = (cx - 1..=cx + 1)
.flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y)))
.filter_map(|k| grid.get(&k))
.flatten()
.copied()
.find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps));
near.unwrap_or_else(|| {
points.push(p);
grid.entry((cx, cy)).or_default().push(points.len() - 1);
points.len() - 1
})
};
let mut edges: Vec<Edge> = Vec::new();
let mut between: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
let outlines = Outlines::new(&inputs, eps);
for idx in top.segment_indices() {
let half = idx.first_half();
let segs: Vec<Seg> = positions[idx].path.segments().map(to_seg).collect();
let ends = [vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())];
if segs.is_empty() || ends[0] == ends[1] {
continue;
}
let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue };
// Coincident pieces the sweep left apart are one edge (the front-most input's).
let key = (ends[0].min(ends[1]), ends[0].max(ends[1]));
let here = mid(&segs);
let twin_of = between.get(&key).and_then(|v| {
v.iter().copied().find(|&e| {
let other = edges.get(e).and_then(|e| mid(&e.segs));
matches!((here, other), (Some(a), Some(b)) if a.distance(b) < DEFAULT_PRECISION)
})
});
if let Some(e) = twin_of {
if let Some(e) = edges.get_mut(e)
&& source > e.source
{
e.source = source;
}
continue;
}
between.entry(key).or_default().push(edges.len());
edges.push(Edge { segs, ends, source });
}
let nv = points.len();
// Half-edges leaving each vertex, sorted by direction; walking a face, the next half-edge is
// the one just clockwise of the way back, so bounded faces come out with positive area and
// the outer boundary of each connected piece with negative area.
let mut around: Vec<Vec<usize>> = vec![Vec::new(); nv];
for h in 0..2 * edges.len() {
if let Some(list) = origin(&edges, h).and_then(|v| around.get_mut(v)) {
list.push(h);
}
}
let mut slot_of = vec![0usize; 2 * edges.len()];
for (v, list) in around.iter_mut().enumerate() {
let Some(&at) = points.get(v) else { continue };
// Half the shortest chord of the edges here: every one of them gets that far from it.
let r = 0.5 * list.iter().filter_map(|&h| half_segs(&edges, h).last().map(|s| s.c.p3.distance(at))).fold(f64::INFINITY, f64::min);
let mut keyed: Vec<(f64, usize)> = list.iter().map(|&h| (leaving_angle(&edges, h, at, r), h)).collect();
keyed.sort_by(|a, b| a.0.total_cmp(&b.0));
*list = keyed.into_iter().map(|(_, h)| h).collect();
for (k, &h) in list.iter().enumerate() {
if let Some(s) = slot_of.get_mut(h) {
*s = k;
}
}
}
let next = |h: usize| -> Option<usize> {
let back = twin(h);
let list = around.get(origin(&edges, back)?)?;
let k = *slot_of.get(back)?;
list.get((k + list.len() - 1) % list.len()).copied()
};
// Connected pieces of the graph.
let mut parent: Vec<usize> = (0..nv).collect();
for e in &edges {
let (a, b) = (find(&mut parent, e.ends[0]), find(&mut parent, e.ends[1]));
if let Some(p) = parent.get_mut(a) {
*p = b;
}
}
let piece: Vec<usize> = (0..nv).map(|v| find(&mut parent, v)).collect();
let piece_of = |cycle: &[usize]| cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| piece.get(v)).copied();
// Walk every cycle: positive ones are faces, negative ones outer boundaries (holes in the
// face around them, if any).
let mut seen = vec![false; 2 * edges.len()];
let mut faces: Vec<(Vec<usize>, BezPath, f64)> = Vec::new();
let mut holes: Vec<(Vec<usize>, BezPath)> = Vec::new();
for start in 0..2 * edges.len() {
if seen.get(start).copied().unwrap_or(true) {
continue;
}
let mut cycle = vec![];
let mut h = start;
while let Some(s) = seen.get_mut(h).filter(|s| !**s) {
*s = true;
cycle.push(h);
match next(h) {
Some(n) => h = n,
None => break,
}
}
let cycle = without_spurs(&cycle);
if cycle.is_empty() {
continue;
}
let bp = cycle_bezpath(&edges, &cycle);
let area = bp.area();
if area > 0.0 && !is_sliver(&bp, tidy.precision) {
faces.push((cycle, bp, area));
} else if area < -(tidy.precision * tidy.precision) {
holes.push((cycle, bp));
}
}
// Each hole goes in the smallest face of another piece around it.
let mut face_holes: Vec<Vec<usize>> = vec![Vec::new(); faces.len()];
for (k, (cycle, _)) in holes.iter().enumerate() {
let Some(p) = cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| points.get(v)).copied() else { continue };
let own = piece_of(cycle);
let around_it = faces
.iter()
.enumerate()
.filter(|(_, (fc, bp, _))| piece_of(fc) != own && bp.bounding_box().contains(p) && bp.winding(p) != 0)
.min_by(|a, b| a.1.2.total_cmp(&b.1.2))
.map(|(i, _)| i);
if let Some(list) = around_it.and_then(|i| face_holes.get_mut(i)) {
list.push(k);
}
}
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
// The inputs' anchors, which results land on again up to the sweep's tolerance.
let snap = 2.0 * eps;
let mut anchors: Vec<Point> = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect();
anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
let subpath = |cycle: &[usize]| {
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(&edges, h)).collect();
segs_to_subpath(&tidy_segments(segs, true, &tidy), true)
};
let regions = faces
.iter()
.zip(&face_holes)
.filter_map(|((cycle, _, _), hs)| {
let mut subpaths = vec![subpath(cycle)?];
subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c)));
let path = unshear(PathData::new(subpaths), &anchors, snap);
let sources =
interior_point(&path).map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()).unwrap_or_default();
Some(Region { path, sources })
})
.collect();
// Edges: chains of segments through vertices where nothing else meets.
let degree: Vec<usize> = around.iter().map(Vec::len).collect();
let passes = |v: usize| -> Option<(usize, usize)> {
let list = around.get(v)?;
let (&a, &b) = (list.first()?, list.get(1)?);
(degree.get(v) == Some(&2) && edges.get(a / 2)?.source == edges.get(b / 2)?.source).then_some((a, b))
};
let mut used = vec![false; edges.len()];
let mut chains: Vec<(Vec<Seg>, bool, usize)> = Vec::new();
// A chain from half-edge `h0` up to the next vertex where it doesn't pass straight through;
// closed when it comes back round to where it started.
let walk = |h0: usize, used: &mut Vec<bool>| -> Option<(Vec<Seg>, bool, usize)> {
let source = edges.get(h0 / 2)?.source;
let mut segs = Vec::new();
let mut h = h0;
loop {
let u = used.get_mut(h / 2)?;
if *u {
return Some((segs, true, source));
}
*u = true;
segs.extend(half_segs(&edges, h));
let v = origin(&edges, twin(h))?;
let Some((a, b)) = passes(v) else { return Some((segs, origin(&edges, h0) == Some(v), source)) };
h = if a == twin(h) { b } else { a };
}
};
for v in 0..nv {
if passes(v).is_some() {
continue;
}
for &h in around.get(v).map(Vec::as_slice).unwrap_or_default() {
if !used.get(h / 2).copied().unwrap_or(true)
&& let Some(c) = walk(h, &mut used)
{
chains.push(c);
}
}
}
// What's left are closed loops with no junction on them.
for e in 0..edges.len() {
if !used.get(e).copied().unwrap_or(true)
&& let Some(c) = walk(2 * e, &mut used)
{
chains.push(c);
}
}
let edge_shapes = chains
.into_iter()
.filter_map(|(segs, closed, source)| {
let sp = segs_to_subpath(&tidy_segments(segs, closed, &tidy), closed)?;
Some(Shape::new(unshear(PathData::new(vec![sp]), &anchors, snap), FillRule::NonZero, shapes.get(source)?.key))
})
.collect();
Some((regions, edge_shapes))
}
/// A point strictly inside a filled path, well away from its edges where it can be (for re-finding
/// a Live Paint face after a rebuild).
pub fn interior_point(path: &PathData) -> Option<Point> {
let bb = path.bounds()?;
let bp = path.to_bezpath();
let mut edges: Vec<(Point, Point)> = vec![];
let (mut first, mut last) = (Point::ZERO, Point::ZERO);
kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el {
kurbo::PathEl::MoveTo(p) => {
if last != first {
edges.push((last, first));
}
first = p;
last = p;
}
kurbo::PathEl::LineTo(p) => {
edges.push((last, p));
last = p;
}
kurbo::PathEl::ClosePath => {
if last != first {
edges.push((last, first));
}
last = first;
}
_ => {}
});
if last != first {
edges.push((last, first));
}
let mut best: Option<(f64, Point)> = None;
for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] {
// A little off the round fractions, which on art drawn to a grid fall on the height of
// some corner, where testing the point against other paths is unreliable.
let y = bb.y0 + bb.height() * (f + 0.0061803);
let mut xs: Vec<f64> =
edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect();
xs.sort_by(f64::total_cmp);
for w in xs.windows(2) {
let m = Point::new((w[0] + w[1]) / 2.0, y);
let width = w[1] - w[0];
if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 {
best = Some((width, m));
}
}
}
best.map(|b| b.1)
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use vectorcraft_geom::{Rect, SubPath, shapes};
fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape {
Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key)
}
fn rect(x0: f64, y0: f64, x1: f64, y1: f64, key: u64) -> Shape {
Shape::new(shapes::rectangle(Rect::new(x0, y0, x1, y1)), FillRule::NonZero, key)
}
fn area(r: &Region) -> f64 {
r.path.to_bezpath().area()
}
fn at(faces: &[Region], x: f64, y: f64) -> Vec<&Region> {
faces.iter().filter(|f| f.contains(Point::new(x, y))).collect()
}
#[test]
fn crossing_lines_enclose_a_face() {
// Three lines crossing pairwise, their ends sticking out: one triangle.
let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 1), line((-10.0, 80.0), (110.0, 80.0), 2)];
let (faces, edges) = live_paint(&s);
assert_eq!(faces.len(), 1, "{faces:?}");
let f = &faces[0];
assert!((area(f) - 900.0).abs() < 1e-6, "triangle (20,80) (50,50) (80,80): {}", area(f));
assert!(f.sources.is_empty(), "nothing fills it yet");
assert_eq!(f.path.subpaths.len(), 1);
assert_eq!(f.path.subpaths[0].anchors.len(), 3, "the loose ends are not part of it");
// Each line is cut where the others cross it: 3 + 3 + 3 pieces.
assert_eq!(edges.len(), 9);
assert_eq!(edges.iter().filter(|e| e.key == 2).count(), 3);
}
#[test]
fn a_grid_of_lines_makes_its_cells() {
let mut s = vec![];
for k in 0..4 {
let v = f64::from(k) * 10.0;
s.push(line((v, -5.0), (v, 35.0), 0));
s.push(line((-5.0, v), (35.0, v), 1));
}
let (faces, _) = live_paint(&s);
assert_eq!(faces.len(), 9);
assert!(faces.iter().all(|f| (area(f) - 100.0).abs() < 1e-6));
assert_eq!(at(&faces, 15.0, 15.0).len(), 1);
}
#[test]
fn a_line_across_a_shape_splits_it() {
let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)];
let (faces, edges) = live_paint(&s);
assert_eq!(faces.len(), 2);
assert!(faces.iter().all(|f| f.sources == [0]), "both halves keep the rectangle's fill");
assert!((faces.iter().map(area).sum::<f64>() - 5000.0).abs() < 1e-6);
assert_eq!(at(&faces, 10.0, 25.0).len(), 1);
assert_eq!(at(&faces, 90.0, 25.0).len(), 1);
// The rectangle's outline in two pieces, the line in three.
assert_eq!(edges.iter().filter(|e| e.key == 7).count(), 2);
assert_eq!(edges.iter().filter(|e| e.key == 8).count(), 3);
}
#[test]
fn overlapping_shapes_keep_their_coverage() {
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)];
let (faces, _) = live_paint(&s);
assert_eq!(faces.len(), 3);
assert_eq!(at(&faces, 25.0, 50.0)[0].sources, [0]);
assert_eq!(at(&faces, 75.0, 50.0)[0].sources, [0, 1]);
assert_eq!(at(&faces, 125.0, 50.0)[0].sources, [1]);
// Same faces as the Pathfinder regions.
let regions = crate::regions(&s);
assert_eq!(regions.len(), 3);
for f in &faces {
assert!(regions.iter().any(|r| r.sources == f.sources && (area(r).abs() - area(f)).abs() < 1e-6));
}
}
#[test]
fn a_closed_loop_of_lines_inside_a_shape_is_a_hole_and_a_face() {
let mut s = vec![rect(0.0, 0.0, 100.0, 100.0, 0)];
let sq = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0), (40.0, 40.0)];
for w in sq.windows(2) {
s.push(line(w[0], w[1], 1));
}
// A loose line inside: no face of its own, no hole.
s.push(line((10.0, 10.0), (20.0, 20.0), 2));
let (faces, _) = live_paint(&s);
assert_eq!(faces.len(), 2, "{faces:?}");
let outer = at(&faces, 5.0, 5.0);
assert_eq!(outer.len(), 1);
assert_eq!(outer[0].path.subpaths.len(), 2, "the inner square is a hole in it");
assert!((area(outer[0]) - 9600.0).abs() < 1e-6);
let inner = at(&faces, 50.0, 50.0);
assert_eq!(inner.len(), 1, "the hole doesn't count as the outer face");
assert_eq!(inner[0].sources, [0], "the rectangle fills it");
assert!((area(inner[0]) - 400.0).abs() < 1e-6);
}
#[test]
fn an_area_enclosed_by_shapes_is_an_unfilled_face() {
// A frame of four bars around a 50 × 50 gap.
let s = [rect(0.0, 0.0, 70.0, 10.0, 0), rect(0.0, 60.0, 70.0, 70.0, 0), rect(0.0, 0.0, 10.0, 70.0, 0), rect(60.0, 0.0, 70.0, 70.0, 0)];
let (faces, _) = live_paint(&s);
let gap = at(&faces, 35.0, 35.0);
assert_eq!(gap.len(), 1);
assert!(gap[0].sources.is_empty());
assert!((area(gap[0]) - 2500.0).abs() < 1e-6);
}
#[test]
fn curves_and_a_circle_crossed_by_a_line() {
let circle = Shape::new(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), FillRule::NonZero, 0);
let (faces, edges) = live_paint(&[circle, line((-10.0, 50.0), (110.0, 50.0), 1)]);
assert_eq!(faces.len(), 2);
for f in &faces {
assert!((area(f) - std::f64::consts::PI * 2500.0 / 2.0).abs() < 2.0, "{}", area(f));
assert!(f.path.subpaths[0].anchors.len() <= 4, "no extra anchors from the sweep's splits");
}
assert_eq!(edges.iter().filter(|e| e.key == 0).count(), 2, "two arcs");
}
#[test]
fn a_line_ending_on_an_edge_splits_the_shape() {
let s = [line((90.0, 20.0), (70.0, 10.0), 0), rect(80.0, 10.0, 90.0, 50.0, 1)];
let (faces, _) = live_paint(&s);
assert_eq!(faces.len(), 2);
assert!((area(&faces[0]) + area(&faces[1]) - 400.0).abs() < 1e-6);
for f in &faces {
let p = crate::interior_point(&f.path).unwrap();
assert_eq!(at(&faces, p.x, p.y).len(), 1, "{p:?}");
assert_eq!(f.sources, [1]);
}
}
#[test]
fn nothing_or_garbage_gives_an_empty_map() {
assert_eq!(live_paint(&[]), (vec![], vec![]));
assert!(live_paint(&[line((0.0, 0.0), (f64::NAN, 1.0), 0)]).0.is_empty());
let (faces, edges) = live_paint(&[line((0.0, 0.0), (10.0, 10.0), 0)]);
assert!(faces.is_empty());
assert_eq!(edges.len(), 1);
}
fn arb_segment() -> impl Strategy<Value = Shape> {
let c = || (0..10i32).prop_map(|v| f64::from(v) * 10.0);
prop_oneof![
(c(), c(), c(), c()).prop_map(|(a, b, x, y)| line((a, b), (x, y), 0)),
(c(), c(), 1..5i32, 1..5i32).prop_map(|(x, y, w, h)| rect(x, y, x + f64::from(w) * 10.0, y + f64::from(h) * 10.0, 0)),
(c(), c(), 1..5i32).prop_map(|(x, y, r)| {
let r = f64::from(r) * 10.0;
Shape::new(shapes::ellipse(Rect::new(x, y, x + r, y + r)), FillRule::EvenOdd, 0)
}),
]
}
proptest! {
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
/// Faces never overlap, and a face's sources are exactly the inputs whose fill holds a
/// point inside it.
#[test]
fn faces_are_disjoint_and_know_what_covers_them(shapes in prop::collection::vec(arb_segment(), 1..7)) {
let (faces, _) = live_paint(&shapes);
for f in &faces {
prop_assert!(area(f) > 0.0);
let Some(p) = crate::interior_point(&f.path) else { continue };
prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1, "{:?} in more than one face", p);
let covering: Vec<usize> = shapes
.iter()
.enumerate()
.filter(|(_, s)| s.path.is_closed() && vectorcraft_geom::hit::fill_contains(&s.path.to_bezpath(), s.rule, p))
.map(|(i, _)| i)
.collect();
prop_assert_eq!(&f.sources, &covering, "at {:?}", p);
}
}
/// Closed shapes alone: the filled faces cover exactly the Pathfinder regions.
#[test]
fn filled_faces_are_the_pathfinder_regions(shapes in prop::collection::vec(arb_segment(), 1..6)) {
let closed: Vec<Shape> = shapes.into_iter().filter(|s| s.path.is_closed()).collect();
let (faces, _) = live_paint(&closed);
let filled: f64 = faces.iter().filter(|f| !f.sources.is_empty()).map(area).sum();
let regions: f64 = crate::regions(&closed).iter().map(|r| area(r).abs()).sum();
// Both refit split curves to [`DEFAULT_PRECISION`], so curved areas may differ by up
// to that much along every outline.
let outlines: f64 = closed.iter().map(|s| s.path.to_bezpath().perimeter(1e-6)).sum();
prop_assert!((filled - regions).abs() <= 1e-9 + DEFAULT_PRECISION * outlines, "{} vs {}", filled, regions);
}
/// Lines anywhere (not on a grid): faces stay disjoint, and nothing panics.
#[test]
fn free_lines_make_disjoint_faces(pts in prop::collection::vec((-50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64), 2..9)) {
let lines: Vec<Shape> = pts.into_iter().map(|(a, b, x, y)| line((a, b), (x, y), 0)).collect();
let (faces, edges) = live_paint(&lines);
prop_assert!(edges.len() >= lines.len().min(1));
for f in &faces {
prop_assert!(f.sources.is_empty());
if let Some(p) = crate::interior_point(&f.path) {
prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1);
}
}
}
}
#[test]
fn many_lines_stay_quick() {
// 60 lines through a 100 pt square: about 900 crossings.
let lines: Vec<Shape> = (0..60)
.map(|k| {
let t = f64::from(k) * 0.37;
line((50.0 + 80.0 * t.cos(), 50.0 + 80.0 * t.sin()), (50.0 - 80.0 * (t + 1.1).cos(), 50.0 - 80.0 * (t + 1.1).sin()), 0)
})
.collect();
let started = std::time::Instant::now();
let (faces, _) = live_paint(&lines);
assert!(faces.len() > 500, "{}", faces.len());
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
}
}
+4
View File
@@ -206,6 +206,10 @@ impl Families {
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest,
_ => name,
};
// An installed face of that exact PostScript name: its own family and style.
if let Some((family, style)) = vectorcraft_text::FontDb::global().by_postscript_name(name) {
return (family, style, true);
}
let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, ""));
let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned());
let style = match spaced(strip_ps(style)).as_str() {
+8 -2
View File
@@ -1488,8 +1488,14 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
let mut runs = vec![BezPath::new(); t.runs.len()];
let mut all = BezPath::new();
// Per run: is its family missing, and the face its glyphs should come from.
let faces: Vec<(bool, Option<u32>)> =
t.runs.iter().map(|r| (!db.has_family(&r.style.font_family), db.face(&r.style.font_family, &r.style.font_style).map(|f| f.id()))).collect();
let faces: Vec<(bool, Option<u32>)> = t
.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())),
None => (true, None),
})
.collect();
let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new());
for g in &layout.glyphs {
if let Some(r) = runs.get_mut(g.run) {
+1 -1
View File
@@ -1576,7 +1576,7 @@ impl Writer<'_> {
/// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those
/// below the Characters row under the characters, the others over them.
fn text_node(&mut self, n: &Node, t: &TextObject) {
let chars = |w: &mut Self, n: &Node| if w.opts.outline_text { w.text_outlines(n, t) } else { w.text(n, t) };
let chars = |w: &mut Self, n: &Node| if w.opts.outline_text || t.vertical { w.text_outlines(n, t) } else { w.text(n, t) };
if !n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) {
return chars(self, n);
}
+74
View File
@@ -0,0 +1,74 @@
//! The optional craft-fonts build input (https://github.com/storytold/craft-fonts,
//! `docs/integration.md`): with `CRAFT_FONTS_DIR=<checkout>` set, every font in its
//! `fonts/manifest.txt` is embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty and the app
//! uses its own and the installed fonts. `CRAFT_FONTS_REQUIRED=1` turns a bad checkout into a build
//! error (release builds). Nothing is fetched: the build reads only the local checkout.
//!
//! 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).
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
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) {
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());
}
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
}
}
src.push_str("];\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());
}
}
/// A relative `CRAFT_FONTS_DIR` is taken from the workspace root (where `cargo` is usually run),
/// not from this crate's directory, which is where build scripts run.
fn from_workspace(dir: PathBuf) -> PathBuf {
if dir.is_absolute() {
return dir;
}
let crate_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default());
crate_dir.join("../..").join(dir)
}
/// The one craft-fonts face web builds embed (family, style).
const WEB_FONT: (&str, &str) = ("BIZ UDPGothic", "Regular");
/// One `CraftFont { .. }` initialiser per manifest line.
fn craft_fonts(dir: &Path) -> Result<String, String> {
let manifest = dir.join("fonts/manifest.txt");
println!("cargo::rerun-if-changed={}", manifest.display());
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
};
if wasm && !(*family == WEB_FONT.0 && *style == WEB_FONT.1) {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
println!("cargo::rerun-if-changed={}", path.display());
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
let _ = writeln!(
out,
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},",
scripts.join(", "),
path.display().to_string(),
);
}
Ok(out)
}
+52
View File
@@ -0,0 +1,52 @@
//! 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
//! text falls back to the installed system fonts. Release builds always set it. See `build.rs`
//! and craftrules `standards/fonts.md`.
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
pub struct CraftFont {
pub family: &'static str,
pub style: &'static str,
/// ISO 15924 scripts the font is for, e.g. `"Jpan"`.
pub scripts: &'static [&'static str],
pub bytes: &'static [u8],
}
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
impl CraftFont {
/// Is the font for Japanese text?
pub fn is_japanese(&self) -> bool {
self.scripts.contains(&"Jpan")
}
/// A serif (Mincho) family, for document text.
pub fn is_mincho(&self) -> bool {
self.family.contains("Mincho")
}
/// A sans (Gothic) family, for UI text.
pub fn is_gothic(&self) -> bool {
self.family.contains("Gothic")
}
/// A bold face.
pub fn is_bold(&self) -> bool {
crate::style_weight(self.style) >= 600.0
}
}
/// The craft-fonts faces for Japanese text, best for document text first: Mincho (serif) families,
/// then the others, regular weights before bold. Empty when built without craft-fonts.
pub fn japanese_document_fonts() -> Vec<&'static CraftFont> {
let mut v: Vec<&'static CraftFont> = CRAFT_FONTS.iter().filter(|f| f.is_japanese()).collect();
v.sort_by_key(|f| (!f.is_mincho(), f.is_bold()));
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> {
let mut v: Vec<&'static CraftFont> = CRAFT_FONTS.iter().filter(|f| f.is_japanese()).collect();
v.sort_by_key(|f| (!f.is_gothic(), f.is_bold() != bold));
v
}
+8
View File
@@ -7,6 +7,8 @@ use vectorcraft_doc::CharStyle;
/// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OtFeatures {
/// Vertical glyph alternates (`vert`), set by the writing direction.
pub vertical: bool,
/// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero.
pub ligatures: bool,
/// Contextual alternates (`calt`).
@@ -30,6 +32,7 @@ pub struct OtFeatures {
impl Default for OtFeatures {
fn default() -> Self {
Self {
vertical: false,
ligatures: true,
contextual: true,
discretionary_ligatures: false,
@@ -138,6 +141,11 @@ impl OtFeatures {
v.push(f(tag, true));
}
}
if self.vertical {
// Not `vrt2`: its glyphs are already turned on their side, and the layout turns Latin
// and digits itself (they would lie upside down, and tate-chu-yoko break).
v.push(f(b"vert", true));
}
v
}
}
+374 -13
View File
@@ -35,6 +35,8 @@ enum FontBytes {
/// One loaded font face.
pub struct FontFace {
/// Every name the face answers to (normalized), in every language its name table has.
keys: FaceKeys,
id: u32,
/// Typographic family name (e.g. "Source Sans 3").
pub family: String,
@@ -153,20 +155,28 @@ struct CatalogFamily {
/// [`FontDb::face`] does). Always empty on wasm, which has no system fonts.
type Catalog = HashMap<String, CatalogFamily>;
/// The installed faces' (family, style) by normalized PostScript name ([`norm`]).
type PostScriptNames = HashMap<String, (String, String)>;
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
catalog: RwLock<Catalog>,
/// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai).
postscript: RwLock<PostScriptNames>,
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
font_dirs: Vec<PathBuf>,
/// The installed fonts' other names (localized, legacy, PostScript) by normalized name.
aliases: RwLock<HashMap<String, Vec<Alias>>>,
/// Set once the font folders have been scanned. Lookups by family name wait for the first
/// scan, so what they find never depends on what ran before them.
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock<()>,
/// [`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>>,
generation: AtomicU64,
/// System fallback state: characters no system font covers.
#[cfg(not(target_arch = "wasm32"))]
@@ -217,6 +227,116 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
}
/// Every localized string of `id`, with its language.
fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option<String>, String)> {
font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect()
}
/// Every localized string of `id`.
fn all_names(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<String> {
localized(font, id).into_iter().map(|(_, s)| s).collect()
}
/// The names a face answers to besides its (English) family and style, normalized ([`norm`]):
/// its family in every language (typographic, else legacy: `ヒラギノ角ゴシック`), its style likewise
/// (`ミディアム`), its legacy family names (`Hiragino Sans W3`) with their legacy styles, paired per
/// language record (the Mac and Windows records of a face differ), and its PostScript names
/// (`HiraginoSans-W3`, as PDF and `.ai` files name fonts).
#[derive(Clone, Debug, Default)]
struct FaceKeys {
families: Vec<String>,
styles: Vec<String>,
legacy: Vec<(String, String)>,
postscript: Vec<String>,
}
impl FaceKeys {
fn of(font: &skrifa::FontRef<'_>, family: &str, style: &str) -> Self {
let (typo_family, typo_style) = (all_names(font, StringId::TYPOGRAPHIC_FAMILY_NAME), all_names(font, StringId::TYPOGRAPHIC_SUBFAMILY_NAME));
let (legacy_family, legacy_style) = (localized(font, StringId::FAMILY_NAME), localized(font, StringId::SUBFAMILY_NAME));
// Without typographic names, the legacy ones are the family's.
let family_names: Vec<&str> = if typo_family.is_empty() {
legacy_family.iter().map(|(_, f)| f.as_str()).collect()
} else {
typo_family.iter().map(String::as_str).collect()
};
let mut families: Vec<String> = std::iter::once(family).chain(family_names).map(norm).collect();
// A legacy style only names the face beside its legacy family when the font has a
// typographic style: every weight of `Hiragino Sans` is `Regular` in its own legacy family.
let style_names: Vec<&str> = if typo_style.is_empty() {
legacy_style.iter().map(|(_, s)| s.as_str()).collect()
} else {
typo_style.iter().map(String::as_str).collect()
};
let mut styles: Vec<String> = std::iter::once(style).chain(style_names).map(norm).collect();
let mut legacy: Vec<(String, String)> = if typo_family.is_empty() {
vec![]
} else {
legacy_family
.iter()
.flat_map(|(lf, f)| legacy_style.iter().filter(move |(ls, _)| ls == lf).map(move |(_, s)| (norm(f), norm(s))))
.filter(|(f, s)| !f.is_empty() && !s.is_empty())
.collect()
};
let mut postscript: Vec<String> = all_names(font, StringId::POSTSCRIPT_NAME).iter().map(|n| norm(n)).collect();
for v in [&mut families, &mut styles, &mut postscript] {
v.retain(|k| !k.is_empty());
v.sort();
v.dedup();
}
legacy.sort();
legacy.dedup();
FaceKeys { families, styles, legacy, postscript }
}
}
/// What a name other than a family's own one stands for ([`FontDb::canonical`]).
#[derive(Clone, Debug)]
struct Alias {
/// The family's own name.
family: String,
/// The face the name names, for legacy family names and PostScript names.
style: Option<String>,
/// A legacy family name's legacy style (`Regular` beside `Hiragino Sans W3`).
paired: Option<String>,
}
impl Alias {
/// The aliases of a face `family` / `style` with `keys`, by normalized name.
fn of(family: &str, style: &str, keys: &FaceKeys) -> Vec<(String, Alias)> {
let mut v: Vec<(String, Alias)> =
keys.families.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: None, paired: None })).collect();
v.extend(
keys.legacy
.iter()
.map(|(f, s)| (f.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: Some(s.clone()) })),
);
v.extend(keys.postscript.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: Some(style.to_string()), paired: None })));
v
}
}
/// How a requested family and style resolved ([`FontDb::resolve`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FontMatch {
/// The face of that family and style (by any of its names, in any language).
Exact,
/// The family is there but not the style: its closest style stands in.
Style,
/// The family is unknown: the fallback family stands in.
Missing,
}
impl FontMatch {
pub fn as_str(self) -> &'static str {
match self {
FontMatch::Exact => "exact",
FontMatch::Style => "substitute",
FontMatch::Missing => "missing",
}
}
}
/// A face's (family, style) names.
fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> {
let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?;
@@ -239,10 +359,11 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> {
.collect()
}
/// (family, style) of every face in the font file at `path`, reading only its table directories
/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long.
/// (family, style, PostScript name) of every face in the font file at `path`, reading only its
/// table directories and `name` tables: a scan opens hundreds of font files, many of them
/// megabytes long.
#[cfg(not(target_arch = "wasm32"))]
fn file_face_names(path: &Path) -> Vec<(String, String)> {
fn file_face_names(path: &Path) -> Vec<(String, String, FaceKeys)> {
use std::io::{Read, Seek, SeekFrom};
/// Caps on what a (possibly damaged) file can make the scan read.
const MAX_FACES: u32 = 256;
@@ -284,7 +405,10 @@ fn file_face_names(path: &Path) -> Vec<(String, String)> {
font.extend_from_slice(&u32::to_be_bytes(v));
}
font.extend_from_slice(&table);
face_names(&skrifa::FontRef::new(&font).ok()?)
let f = skrifa::FontRef::new(&font).ok()?;
let (family, style) = face_names(&f)?;
let keys = FaceKeys::of(&f, &family, &style);
Some((family, style, keys))
})
.collect()
}
@@ -301,6 +425,13 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
if let Some(h) = &home {
dirs.push(h.join("Library/Fonts"));
}
// Fonts macOS downloads on demand (Yu Gothic, Yu Mincho, Tsukushi Maru Gothic…): one
// asset folder per font, under a catalog folder whose number changes with macOS.
for e in std::fs::read_dir("/System/Library/AssetsV2").into_iter().flatten().flatten() {
if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") {
dirs.push(e.path());
}
}
} else if cfg!(windows) {
let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| "C:\\Windows".into());
dirs.push(root.join("Fonts"));
@@ -326,7 +457,9 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path:
let m = f.metrics(Size::unscaled(), LocationRef::default());
let a = f.attributes();
let shaper = harfrust::ShaperData::new(&harfrust::FontRef::from_index(data, index).ok()?);
let keys = FaceKeys::of(&f, &family, &style);
Some(FontFace {
keys,
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
family,
style,
@@ -379,11 +512,17 @@ fn style_italic(style: &str) -> bool {
s.contains("italic") || s.contains("oblique") || s == "it"
}
/// [`FontDb::menu_family_list`], with the [`FontDb::family_list`] it was built from.
type MenuFamilies = (Arc<[String]>, Arc<[String]>);
impl FontDb {
/// A database holding the bundled fonts, whose system font scan reads `font_dirs`.
pub fn with_font_dirs(font_dirs: Vec<PathBuf>) -> Self {
let mut faces = Vec::new();
for data in BUNDLED {
// 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);
for data in BUNDLED.iter().copied().chain(craft) {
for (i, family, style) in enumerate_faces(data) {
if let Some(f) = make_face(FontBytes::Static(data), i, family, style, None) {
faces.push(Arc::new(f));
@@ -394,10 +533,13 @@ impl FontDb {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
catalog: RwLock::new(Catalog::new()),
postscript: RwLock::new(PostScriptNames::new()),
font_dirs,
aliases: RwLock::new(HashMap::new()),
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock::new(),
family_cache: Mutex::new(None),
menu_cache: Mutex::new(None),
generation: AtomicU64::new(0),
#[cfg(not(target_arch = "wasm32"))]
sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }),
@@ -465,8 +607,27 @@ impl FontDb {
list
}
/// The families font menus and lists show: [`FontDb::family_list`] without the system's hidden
/// families, whose names start with "." (macOS keeps ".SF NS", ".LastResort" and others for its
/// own interface, and Mac apps don't list them). Those still resolve by name, for documents and
/// fallbacks that name them. Cheap to call every frame.
pub fn menu_family_list(&self) -> Arc<[String]> {
let all = self.family_list();
let mut cache = self.menu_cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some((from, list)) = cache.as_ref()
&& Arc::ptr_eq(from, &all)
{
return list.clone();
}
let list: Arc<[String]> = all.iter().filter(|f| !f.starts_with('.')).cloned().collect();
*cache = Some((all, list.clone()));
list
}
/// Style names available for `family`: upright styles by weight, then italics.
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();
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
@@ -540,6 +701,8 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
fn scan_font_dirs(&self) -> usize {
let mut catalog = Catalog::new();
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
let mut postscript = PostScriptNames::new();
let mut n = 0;
let mut stack = self.font_dirs.clone();
// Each folder once, however links lead back to it.
@@ -559,7 +722,14 @@ impl FontDb {
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
for (family, style) in file_face_names(&p) {
for (family, style, keys) in file_face_names(&p) {
for (k, a) in Alias::of(&family, &style, &keys) {
aliases.entry(k).or_default().push(a);
}
// `keys.postscript` is normalized ([`norm`]), as `by_postscript_name` looks it up.
for ps in &keys.postscript {
postscript.insert(ps.clone(), (family.clone(), style.clone()));
}
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
if entry.name.is_empty() {
entry.name = family;
@@ -571,10 +741,85 @@ impl FontDb {
}
log::debug!("cataloged {n} system font faces");
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog;
*self.aliases.write().unwrap_or_else(|e| e.into_inner()) = aliases;
*self.postscript.write().unwrap_or_else(|e| e.into_inner()) = postscript;
self.changed();
n
}
/// The family's own name and the style for a name given in a document: any of a family's
/// names (`ヒラギノ角ゴシック` is Hiragino Sans), a legacy family name (`Hiragino Sans W6` with its
/// legacy style names the W6 face) or a PostScript name (`HiraginoSans-W6`, as PDF and `.ai`
/// files name fonts). Case and anything but letters and digits are ignored. A name nothing
/// answers to comes back as it is.
pub fn canonical(&self, family: &str, style: &str) -> (String, String) {
if self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) {
return (family.to_string(), style.to_string());
}
let key = norm(family);
let mut found: Vec<Alias> =
self.read_faces().iter().flat_map(|f| Alias::of(&f.family, &f.style, &f.keys)).filter(|(k, _)| *k == key).map(|(_, a)| a).collect();
if let Some(v) = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
found.extend(v.iter().cloned());
}
let ns = norm(style);
// A family name keeps the style asked for; a legacy pair names its face; a PostScript name
// its face whatever style is asked for.
if let Some(a) = found.iter().find(|a| a.style.is_none()) {
return (a.family.clone(), style.to_string());
}
if let Some(a) = found.iter().find(|a| a.paired.as_ref().is_some_and(|p| *p == ns) || a.paired.is_none()) {
return (a.family.clone(), a.style.clone().unwrap_or_else(|| style.to_string()));
}
match found.first() {
Some(a) => (a.family.clone(), style.to_string()),
None => (family.to_string(), style.to_string()),
}
}
/// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how
/// it matched.
pub fn resolve(&self, family: &str, style: &str) -> Option<(Arc<FontFace>, FontMatch)> {
let (family, style) = self.canonical(family, style);
let f = self.face(&family, &style)?;
let m = if !f.family.eq_ignore_ascii_case(&family) {
FontMatch::Missing
} else if norm(&f.style) == norm(&style) || f.keys.styles.contains(&norm(&style)) {
FontMatch::Exact
} else {
FontMatch::Style
};
Some((f, m))
}
/// The installed face whose PostScript name is `name` (`HiraginoSans-W3`; any case), loaded
/// from the system fonts if need be. `None` when no installed font has the name.
pub fn find_postscript(&self, name: &str) -> Option<Arc<FontFace>> {
let key = norm(name);
if key.is_empty() {
return None;
}
let loaded = |db: &FontDb| db.read_faces().iter().find(|f| f.keys.postscript.contains(&key)).cloned();
if let Some(f) = loaded(self) {
return Some(f);
}
self.ensure_catalog();
let face = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key).and_then(|v| {
v.iter().find(|a| a.paired.is_none() && a.style.is_some()).map(|a| (a.family.clone(), a.style.clone().unwrap_or_default()))
});
let (family, style) = face?;
self.face(&family, &style)?;
loaded(self)
}
/// The (family, style) of the installed face whose PostScript name is `name` (ignoring ASCII
/// case). Documents name fonts this way, and a family can hold hyphens
/// ("Rounded-X-Mplus-1c-black" is Rounded-X M+ 1c, black), so the name can't be split.
pub fn by_postscript_name(&self, name: &str) -> Option<(String, String)> {
self.ensure_catalog();
self.postscript.read().unwrap_or_else(|e| e.into_inner()).get(&norm(name)).cloned()
}
/// Load the files of the installed `family`. Returns whether any face was added.
#[cfg(not(target_arch = "wasm32"))]
fn load_cataloged(&self, family: &str) -> bool {
@@ -596,17 +841,21 @@ impl FontDb {
/// only if no font at all is loaded (the bundled fonts failed to parse), in which case text has
/// no glyphs.
pub fn face(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let (family, style) = self.canonical(family, style);
let (family, style) = (family.as_str(), style.as_str());
let found = self.find(family, style);
if found.as_ref().is_some_and(|f| norm(&f.style) == norm(style)) {
return found;
}
// The family, or this style of it, is installed but not loaded yet.
// The family, or this style of it, is installed but not loaded yet. Looked for again
// whether or not this call loaded it: another thread may have just done so (then this
// load adds nothing, but the face is there).
#[cfg(not(target_arch = "wasm32"))]
if (found.is_none() || self.is_cataloged(family, style))
&& self.load_cataloged(family)
&& let Some(f) = self.find(family, style)
{
return Some(f);
if found.is_none() || self.is_cataloged(family, style) {
self.load_cataloged(family);
if let Some(f) = self.find(family, style) {
return Some(f);
}
}
found
.or_else(|| self.find(FALLBACK_FAMILY, style))
@@ -634,7 +883,8 @@ impl FontDb {
/// Is `family` available (loaded, or installed on the system)?
pub fn has_family(&self, family: &str) -> bool {
self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase())
let (family, _) = self.canonical(family, "");
self.is_loaded(&family) || self.read_catalog().contains_key(&family.to_ascii_lowercase())
}
pub(crate) fn is_loaded(&self, family: &str) -> bool {
@@ -767,3 +1017,114 @@ impl OutlinePen for FlipPen {
self.0.close_path();
}
}
#[cfg(test)]
mod alias_tests {
use super::*;
/// A one-table font holding a name table from (platform, language, name id, string) records:
/// Windows Unicode (UTF-16) or Mac Roman (ASCII here).
fn name_font(records: &[(u16, u16, u16, &str)]) -> Vec<u8> {
let (mut head, mut strings) = (vec![], vec![]);
let n = records.len() as u16;
for v in [0, n, 6 + 12 * n] {
head.extend_from_slice(&v.to_be_bytes());
}
for &(platform, lang, id, s) in records {
let bytes: Vec<u8> = if platform == 3 { s.encode_utf16().flat_map(u16::to_be_bytes).collect() } else { s.as_bytes().to_vec() };
let encoding = if platform == 3 { 1 } else { 0 };
for v in [platform, encoding, lang, id, bytes.len() as u16, strings.len() as u16] {
head.extend_from_slice(&v.to_be_bytes());
}
strings.extend(bytes);
}
head.extend(strings);
let mut font = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
font.extend_from_slice(b"name");
for v in [0u32, 28, head.len() as u32] {
font.extend_from_slice(&v.to_be_bytes());
}
font.extend(head);
font
}
fn keys(records: &[(u16, u16, u16, &str)]) -> (String, String, FaceKeys) {
let font = name_font(records);
let f = skrifa::FontRef::new(&font).unwrap();
let (family, style) = face_names(&f).unwrap();
let k = FaceKeys::of(&f, &family, &style);
(family, style, k)
}
const EN: u16 = 0x0409;
const JA: u16 = 0x0411;
/// Laid out like the name table of Hiragino Sans W3.
const HIRAGINO_W3: &[(u16, u16, u16, &str)] = &[
(1, 0, 1, "Hiragino Sans"),
(1, 0, 2, "W3"),
(3, EN, 1, "Hiragino Sans W3"),
(3, EN, 2, "Regular"),
(3, JA, 1, "ヒラギノ角ゴシック W3"),
(3, JA, 2, "Regular"),
(3, EN, 6, "HiraginoSans-W3"),
(3, EN, 16, "Hiragino Sans"),
(3, JA, 16, "ヒラギノ角ゴシック"),
(3, EN, 17, "W3"),
(3, JA, 17, "W3"),
];
#[test]
fn a_face_answers_to_its_names_in_every_language() {
let (family, style, k) = keys(HIRAGINO_W3);
assert_eq!((family.as_str(), style.as_str()), ("Hiragino Sans", "W3"));
let aliases = Alias::of(&family, &style, &k);
let find = |name: &str| aliases.iter().filter(|(n, _)| *n == norm(name)).map(|(_, a)| a.clone()).collect::<Vec<_>>();
assert!(find("ヒラギノ 角ゴシック").iter().any(|a| a.family == "Hiragino Sans" && a.style.is_none()), "a family name in Japanese");
let legacy = find("Hiragino Sans W3");
assert!(legacy.iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.as_deref() == Some("regular")), "{legacy:?}");
assert!(!legacy.iter().any(|a| a.paired.as_deref() == Some("w3")), "Mac family + Windows style is no pair");
assert!(find("HiraginoSans-W3").iter().any(|a| a.style.as_deref() == Some("W3") && a.paired.is_none()), "the PostScript name");
}
#[test]
fn without_typographic_names_the_legacy_ones_are_the_familys() {
let (family, style, k) = keys(&[(3, EN, 1, "Example Serif"), (3, EN, 2, "Bold Italic"), (3, JA, 1, "例セリフ"), (3, JA, 2, "太字斜体")]);
assert_eq!((family.as_str(), style.as_str()), ("Example Serif", "Bold Italic"));
assert!(k.families.contains(&norm("例セリフ")) && k.styles.contains(&norm("太字斜体")) && k.legacy.is_empty());
}
#[test]
fn resolve_says_how_a_font_matched() {
let db = FontDb::with_font_dirs(vec![]);
let m = |f: &str, s: &str| db.resolve(f, s).map(|(face, m)| (face.family.clone(), face.style.clone(), m)).unwrap();
assert_eq!(m("Source Sans 3", "Semibold"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact));
assert_eq!(m("Source Sans 3", "Black").2, FontMatch::Style);
assert_eq!(m("No Such Family", "Regular"), (FALLBACK_FAMILY.into(), "Regular".into(), FontMatch::Missing));
// A PostScript name names its face, whatever style is asked for (text opened from a PDF).
assert_eq!(m("SourceSans3-Semibold", "Regular"), ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact));
let semi = db.find_postscript("sourcesans3-semibold").unwrap();
assert_eq!(semi.style, "Semibold");
assert!(db.find_postscript("NoSuchFont-Regular").is_none());
assert!(db.has_family("SourceSans3-Regular"));
}
}
#[cfg(test)]
mod race_tests {
use super::*;
#[test]
fn a_family_loaded_by_another_thread_meanwhile_is_still_found() {
// Two threads ask for the same installed family at once: each must get it, not the
// fallback (the second one's load finds every face already added).
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts");
let db: &'static FontDb = Box::leak(Box::new(FontDb::with_font_dirs(vec![dir])));
// Only the catalog knows Inter (the bundled faces are dropped).
db.faces.write().unwrap().retain(|f| f.family != "Inter");
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(move || db.face("Inter", "Regular").map(|f| f.family.clone()))).collect();
for t in threads {
assert_eq!(t.join().unwrap().as_deref(), Some("Inter"));
}
}
}
+171 -12
View File
@@ -8,17 +8,22 @@ use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextO
use crate::composer::{Breakpoint, compose};
use crate::fontdb::FontDb;
use crate::hyphen::hyphen_points;
use crate::shape::{SGlyph, cap_x_heights, hyphen_glyph, shape_range, style_metrics};
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, PositionedGlyph, TextLayout};
use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, no_line_end, no_line_start, shape_range, style_metrics};
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout};
const EPS: f64 = 1e-6;
struct Ctx<'a> {
on_path: bool,
db: &'a FontDb,
text: &'a str,
runs: Vec<(Range<usize>, &'a CharStyle)>,
default: CharStyle,
opts: &'a LayoutOptions,
/// Vertical type: lines are laid out as horizontal lines in line space, upright glyphs turned
/// a quarter turn back (see [`stands_upright`]); [`TextLayout::line_xf`] then stands the lines
/// up as columns.
vertical: bool,
out: TextLayout,
}
@@ -31,13 +36,42 @@ impl Ctx<'_> {
fn shape_para(&self, r: Range<usize>) -> Vec<SGlyph> {
let mut v = Vec::with_capacity(r.len());
shape_range(self.db, self.text, r, &self.runs, &self.opts.features, &mut v);
if self.vertical {
tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size);
// An upright glyph advances at least one em down the column (the vertical advance of
// CJK fonts), centred in it: a narrow mark like § must not overlap its neighbours.
for g in v.iter_mut().filter(|g| stands_upright(g)) {
let natural = g.face.advance(g.gid) * g.sx;
let em = g.face.units_per_em() * g.sx;
if natural < em && g.adv > 0.0 {
g.dx += (em - natural) * 0.5;
g.adv += em - natural;
}
}
}
v
}
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
let src = self.db.outline(&g.face, g.gid);
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
let m = pre * local;
let mut m = pre * local;
if self.vertical && self.on_path {
// Vertical path type keeps the baseline path and turns each glyph across it.
m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m;
} else if let (true, Some(t)) = (self.vertical, g.tcy) {
// Tate-chu-yoko: the block set across the column, centred on its em of it.
let em = self.style_at(g.byte).size;
let start = origin.x - t.pen;
let centre = Point::new(start + em * 0.5, origin.y - EM_CENTER * em);
let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0));
let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0));
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m;
} else if self.vertical && stands_upright(g) {
// Turned about the centre of its em box, which the column's centre line runs through.
let em = self.style_at(g.byte).size;
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + advance * 0.5, origin.y - EM_CENTER * em)) * m;
}
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
BezPath::new()
@@ -99,21 +133,122 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
}
}
paras.push(s..text.len());
let mut cx = Ctx { db, text: &text, runs, default: CharStyle::default(), opts, out: TextLayout::default() };
let vertical = t.vertical;
let is_on_path = matches!(&t.kind, TextKind::OnPath { .. });
let vopts;
let opts = if vertical {
vopts = LayoutOptions { features: OtFeatures { vertical: true, ..opts.features }, ..opts.clone() };
&vopts
} else {
opts
};
let mut cx = Ctx { on_path: is_on_path, db, text: &text, runs, default: CharStyle::default(), opts, vertical, out: TextLayout::default() };
// Vertical type: line space turned a quarter turn clockwise (lines become columns, each next
// one to the left). Point type's anchor is on the first column's centre line. Vertical path
// type stays on its path (each glyph turned across it in `emit`).
let line_xf = match &t.kind {
_ if !vertical => Affine::IDENTITY,
TextKind::OnPath { .. } => Affine::IDENTITY,
TextKind::Point => Affine::translate((-EM_CENTER * cx.style_at(0).size, 0.0)) * QUARTER_TURN,
_ => QUARTER_TURN,
};
match &t.kind {
TextKind::Point => flow(&mut cx, &paras, &t.para, None),
TextKind::Area { frame } => {
let regions = Region::cells(&frame.to_bezpath(), opts, &t.wrap);
cx.out.frames = regions.iter().map(|r| r.cell).collect();
let to_lines = line_xf.inverse();
let wrap: Vec<vectorcraft_doc::WrapShape> = if vertical {
t.wrap.iter().map(|w| vectorcraft_doc::WrapShape { path: w.path.transformed(to_lines), ..w.clone() }).collect()
} else {
t.wrap.clone()
};
let regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap);
cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect();
flow(&mut cx, &paras, &t.para, Some(&regions));
}
TextKind::OnPath { path, start } => on_path(&mut cx, &paras, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect),
}
if vertical && !is_on_path {
// Glyph origins, outlines and transforms to text space (lines stay in line space).
for g in &mut cx.out.glyphs {
g.outline.apply_affine(line_xf);
g.xf = line_xf * g.xf;
g.origin = line_xf * g.origin;
g.angle += std::f64::consts::FRAC_PI_2;
}
cx.out.vertical = true;
cx.out.line_xf = line_xf;
}
finish_bounds(&mut cx.out);
cx.out
}
/// Tate-chu-yoko: a run of two or three half-width digits (`10`月, `100`年) stands upright across
/// the column as one block one em long, squeezed to the em when wider; longer numbers (`2026`)
/// stay on their side.
fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
let digits: Vec<bool> = g.iter().map(|g| g.ch.is_ascii_digit()).collect();
let digit = |i: usize| digits.get(i).copied().unwrap_or(false);
let mut i = 0;
while i < g.len() {
if !digit(i) {
i += 1;
continue;
}
let mut end = i;
while digit(end) {
end += 1;
}
if (2..=3).contains(&(end - i)) {
let em = size(&g[i]);
let width: f64 = g[i..end].iter().map(|g| g.adv).sum();
let squeeze = if width > em && width > 0.0 { em / width } else { 1.0 };
let mut ink = 0.0;
for (k, gl) in g[i..end].iter_mut().enumerate() {
let adv = gl.adv;
gl.tcy = Some(Tcy { pen: if k == 0 { 0.0 } else { em }, ink, width, squeeze });
gl.adv = if k == 0 { em } else { 0.0 };
ink += adv;
}
}
i = end;
}
}
/// Height of the centre of the ideographic em box above the baseline, in ems (the em box runs
/// from 0.12 em below the baseline to 0.88 em above it).
const EM_CENTER: f64 = 0.38;
/// A quarter turn clockwise (y down), exact: line space → text space for vertical type.
const QUARTER_TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
/// Does the glyph stand upright in vertical type? CJK characters and symbols do (their vertical
/// forms come from the font's `vert` feature); Latin letters, digits and other characters lie on
/// their side. Brackets, the long vowel mark and similar marks that need a vertical form lie on
/// their side when the font has none (Unicode's Vertical_Orientation Tr).
fn stands_upright(g: &SGlyph) -> bool {
let c = g.ch as u32;
let upright = matches!(c,
0x00A7 | 0x00A9 | 0x00AE | 0x00B1 | 0x00BC..=0x00BE | 0x00D7 | 0x00F7
| 0x2016 | 0x2020 | 0x2021 | 0x2030 | 0x2031 | 0x203B | 0x203C | 0x2042 | 0x2047..=0x2049 | 0x2051
| 0x20DD..=0x20E0 | 0x20E2..=0x20E4 | 0x2100..=0x2101 | 0x2103..=0x2109 | 0x210F | 0x2113 | 0x2116 | 0x2117
| 0x211E..=0x2123 | 0x2125 | 0x2127 | 0x2129 | 0x212E | 0x2135..=0x213F | 0x2145..=0x214A | 0x214C | 0x214D | 0x214F..=0x2189
| 0x2460..=0x24FF | 0x25A0..=0x27BF | 0x2B12..=0x2B2F | 0x2B50..=0x2B59
| 0x1100..=0x11FF | 0x2E80..=0x303F | 0x3040..=0xA4CF | 0xA960..=0xA97F | 0xAC00..=0xD7FF
| 0xE000..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFE50..=0xFE6F | 0xFF00..=0xFFE7
| 0x1F000..=0x1FAFF | 0x20000..=0x3FFFF);
if !upright {
return false;
}
let needs_vertical_form = matches!(c,
0x3001 | 0x3002 | 0x3008..=0x3011 | 0x3013..=0x301F | 0x3030 | 0x30A0 | 0x30FC
| 0xFE50..=0xFE52 | 0xFE59..=0xFE5E | 0xFF01 | 0xFF08 | 0xFF09 | 0xFF0C | 0xFF0E | 0xFF1A | 0xFF1B | 0xFF1F
| 0xFF3B | 0xFF3D | 0xFF3F | 0xFF5B..=0xFF60 | 0xFFE3);
// Without a vertical alternate the shaped glyph is the nominal one.
!needs_vertical_form || g.gid != g.face.glyph_for(g.ch)
}
fn finish_bounds(out: &mut TextLayout) {
let xf = out.line_xf;
let mut b: Option<Rect> = None;
let mut add = |r: Rect| b = Some(b.map_or(r, |b| b.union(r)));
for g in &out.glyphs {
@@ -123,7 +258,7 @@ fn finish_bounds(out: &mut TextLayout) {
}
if !out.on_path {
for l in &out.lines {
add(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent));
add(xf.transform_rect_bbox(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent)));
}
}
out.bounds = b.unwrap_or_default();
@@ -457,7 +592,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
break;
}
x += gl.adv;
if gl.break_after() {
if gl.break_after() && kinsoku_allows(g, j) {
if gl.is_soft_hyphen() {
if x + hyphen_glyph(gl).adv <= width + EPS {
last_break = Some((j + 1, true));
@@ -479,7 +614,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
we += 1;
}
let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum();
let pts = hyphen_breaks(text, g, word_start, we);
let pts: Vec<usize> = hyphen_breaks(text, g, word_start, we).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)).collect();
for &k in pts.iter().rev() {
if k <= j && k > i {
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
@@ -500,6 +635,30 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
(end, hy)
}
/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a
/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line
/// with the one before it).
fn kinsoku_allows(g: &[SGlyph], j: usize) -> bool {
g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch)))
}
/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph).
fn kinsoku_between(before: char, after: Option<char>) -> bool {
!no_line_end(before) && after.is_none_or(|c| !no_line_start(c))
}
#[cfg(test)]
#[test]
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() {
for (a, b) in [('字', '。'), ('弧', '」'), ('」', '、'), ('ャ', 'ー'), ('カ', 'ッ'), ('「', 'か'), ('(', '雅')] {
assert!(!kinsoku_between(a, Some(b)), "{a}{b}");
}
for (a, b) in [('。', '雅'), ('」', 'は'), ('字', '「'), ('の', '演')] {
assert!(kinsoku_between(a, Some(b)), "{a}{b}");
}
assert!(kinsoku_between('。', None));
}
/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go.
fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> {
// Strip leading/trailing punctuation (quotes, commas).
@@ -534,19 +693,19 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec<Breakpoint> {
let end_of_word = gl.is_space() || gl.break_after();
if end_of_word {
if hyphenate && j > ws {
for k in hyphen_breaks(text, g, ws, j) {
for k in hyphen_breaks(text, g, ws, j).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
}
}
let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 };
if j + 1 < g.len() && g[j + 1].byte != gl.byte {
if j + 1 < g.len() && g[j + 1].byte != gl.byte && kinsoku_allows(g, j) {
v.push(Breakpoint { end: j + 1, hyphen: hy });
}
ws = j + 1;
}
}
if hyphenate && g.len() > ws {
for k in hyphen_breaks(text, g, ws, g.len()) {
for k in hyphen_breaks(text, g, ws, g.len()).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
}
}
+25 -8
View File
@@ -10,6 +10,7 @@
#![forbid(unsafe_code)]
mod composer;
pub mod craft_fonts;
pub mod edit;
pub mod embed;
mod features;
@@ -19,8 +20,9 @@ mod layout;
mod shape;
pub mod thread;
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
pub use features::OtFeatures;
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight, system_font_dirs};
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, FontMatch, style_weight, system_font_dirs};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{layout, layout_with};
pub use vectorcraft_doc::TextObject;
@@ -124,6 +126,11 @@ pub struct LineInfo {
#[derive(Clone, Debug, Default)]
pub struct TextLayout {
/// Lines retain inline/block coordinates; glyph geometry is in physical text space.
pub vertical: bool,
/// Inline/block (line) space → physical text space: identity for horizontal type, a quarter
/// turn clockwise for vertical type (point type also centres its first column on the anchor).
pub line_xf: Affine,
pub glyphs: Vec<PositionedGlyph>,
pub lines: Vec<LineInfo>,
/// Ink/advance bounds in text space.
@@ -138,6 +145,13 @@ pub struct TextLayout {
}
impl TextLayout {
/// Convert physical text coordinates to inline/block coordinates.
pub fn logical_point(&self, p: Point) -> Point {
if self.vertical { self.line_xf.inverse() * p } else { p }
}
pub fn physical_point(&self, p: Point) -> Point {
if self.vertical { self.line_xf * p } else { p }
}
/// All glyph outlines combined (e.g. for Create Outlines).
pub fn to_bezpath(&self) -> BezPath {
let mut p = BezPath::new();
@@ -157,7 +171,7 @@ impl TextLayout {
/// the layout changed.
pub fn snap_to_pixels(&mut self, to_device: Affine) -> bool {
let [a, b, c, d, _, _] = to_device.as_coeffs();
if self.on_path || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-12 || d.abs() < 1e-12 {
if self.on_path || self.vertical || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-12 || d.abs() < 1e-12 {
return false;
}
let mut moved = false;
@@ -192,7 +206,7 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) {
} else if let Some(g) = glyphs.last() {
(g.origin + dir(g.angle) * g.advance, g.angle)
} else {
(Point::new(line.x0, line.baseline), 0.0)
(layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 })
};
// Up vector in y-down space, rotated with the baseline.
let d = dir(angle);
@@ -214,6 +228,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
None => layout.lines.first().map_or(0, |l| l.start),
};
}
let p = layout.logical_point(p);
// Nearest line band (vertical distance first, then distance to the line's column).
let dist = |l: &LineInfo| {
let dy = if p.y < l.baseline - l.ascent {
@@ -247,7 +262,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize {
let Some(line) = layout.lines.get(li) else { return 0 };
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
if let Some(g) = glyphs.iter().find(|g| x < g.origin.x + g.advance * 0.5) {
if let Some(g) = glyphs.iter().find(|g| x < layout.logical_point(g.origin).x + g.advance * 0.5) {
return g.byte;
}
line_end(layout, li)
@@ -281,9 +296,9 @@ fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 {
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
return g.origin.x + g.advance * frac;
return layout.logical_point(g.origin).x + g.advance * frac;
}
glyphs.last().map_or(line.x0, |g| g.origin.x + g.advance)
glyphs.last().map_or(line.x0, |g| layout.logical_point(g.origin).x + g.advance)
}
/// Move the caret `delta` lines up (negative) or down, keeping horizontal position `goal_x`
@@ -345,14 +360,14 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
let mut xb = x_in_line(layout, li, b.min(l.end));
if b > l.end {
// The selection continues past the line: include the line break / trailing space.
let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| g.origin.x + g.advance);
let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| layout.logical_point(g.origin).x + g.advance);
xb = last.max(l.x1) + (l.ascent + l.descent) * 0.25;
}
if xb - xa <= 1e-9 {
continue;
}
let (t, bt) = (l.baseline - l.ascent, l.baseline + l.descent);
out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)]);
out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)].map(|p| layout.physical_point(p)));
}
out
}
@@ -369,3 +384,5 @@ mod tests_snap;
mod tests_sysfonts;
#[cfg(test)]
mod tests_typo;
#[cfg(test)]
mod tests_vertical;
+57
View File
@@ -40,6 +40,22 @@ pub(crate) struct SGlyph {
pub xh: f64,
/// First source character of the cluster.
pub ch: char,
/// Vertical type: part of a tate-chu-yoko block (set across the column, upright).
pub tcy: Option<Tcy>,
}
/// A glyph's place in a tate-chu-yoko block: the block takes one em of the column, its glyphs side
/// by side across it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Tcy {
/// Laid-out distance from the block's start to this glyph's pen position.
pub pen: f64,
/// Distance from the block's start to the glyph in the block's own (horizontal) setting.
pub ink: f64,
/// Width of the block's own setting.
pub width: f64,
/// Horizontal scale that fits the block into one em (1 when it fits as it is).
pub squeeze: f64,
}
impl SGlyph {
@@ -77,6 +93,46 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
h
}
/// Kinsoku (Japanese line breaking, the strict set): a character that can't start a line —
/// closing brackets, the Japanese comma and full stop, middle dots, colons, ! and ?, the long
/// vowel mark, iteration marks and small kana.
pub(crate) fn no_line_start(c: char) -> bool {
matches!(c,
')' | ']' | '}' | ',' | '.' | ':' | ';' | '!' | '?' | '»' | '’' | '”' | '‐' | '–' | '‼' | '⁇' | '⁈' | '⁉'
| '、' | '。' | '〉' | '》' | '」' | '』' | '】' | '〕' | '〗' | '〙' | '〛' | '〟' | '〜' | '゠' | '・' | '⦆'
| 'ー' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' | '々' | '〻'
| 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ'
| 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ' | 'ㇰ'..='ㇿ'
| '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ')
}
/// Kinsoku: a character that can't end a line (opening brackets).
pub(crate) fn no_line_end(c: char) -> bool {
matches!(
c,
'(' | '['
| '{'
| '«'
| '‘'
| '“'
| '〈'
| '《'
| '「'
| '『'
| '【'
| '〔'
| '〖'
| '〘'
| '〚'
| '〝'
| '('
| '['
| '{'
| '⦅'
| '「'
)
}
fn is_cjk(c: char) -> bool {
matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
}
@@ -255,6 +311,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
cap,
xh,
ch,
tcy: None,
});
}
}
+70
View File
@@ -421,3 +421,73 @@ fn tab_stops_position_text() {
p.para.tabs = vec![stop(50.0, TabAlign::Left)];
assert!((x_of(&p, 4) - 72.0).abs() < 1e-6);
}
/// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when
/// they aren't: the bundled fonts have none, and [`db`] reads no system fonts.
fn japanese_fonts() -> bool {
let built_in = CRAFT_FONTS.iter().any(|f| f.is_japanese());
if !built_in {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
}
built_in
}
#[test]
fn japanese_text_uses_the_craft_fonts_mincho_after_the_bundled_fonts() {
if !japanese_fonts() {
return;
}
let l = layout(db(), &point("日本語の文字", style(20.0)));
assert_eq!(l.glyphs.len(), 6);
assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty()), "real glyphs, no tofu");
let face = db().face_covering('日').unwrap();
assert!(face.family.contains("Mincho"), "document text falls back to a Mincho face: {}", face.family);
assert!(CRAFT_FONTS.iter().any(|f| f.family == face.family));
// Latin keeps the bundled fallback family.
assert_eq!(db().face_covering('a').unwrap().family, FALLBACK_FAMILY);
}
#[test]
fn japanese_text_lays_out_without_the_craft_fonts() {
// Built either way, Japanese text lays out (as missing glyphs when no font has them).
let l = layout(db(), &point("日本語の文字 abc", style(20.0)));
assert_eq!(l.lines.len(), 1);
assert!(l.glyphs.iter().all(|g| g.advance.is_finite()));
if CRAFT_FONTS.is_empty() {
assert!(db().families().iter().all(|f| !f.contains("Mincho") && !f.contains("Gothic")), "no Japanese font is bundled");
}
}
#[test]
fn vertical_japanese_columns_have_upright_ink_and_edit_geometry() {
if !japanese_fonts() {
return;
}
let mut t = point("日本語\n縦書き", style(30.0));
t.vertical = true;
let l = layout(db(), &t);
assert!(l.vertical);
assert_eq!(l.lines.len(), 2);
assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty()));
assert!(l.glyphs[3].origin.x < l.glyphs[0].origin.x);
assert!(l.glyphs[1].origin.y > l.glyphs[0].origin.y);
for g in &l.glyphs {
let p = g.origin + dir(g.angle) * (g.advance * 0.1);
assert_eq!(hit_byte(&l, p), g.byte);
let (a, b) = caret_position(&l, g.byte);
assert!((a.y - b.y).abs() < 1e-6);
assert!(a.x > b.x);
}
let selection = selection_quads(&l, 0, "日本語".len());
assert_eq!(selection.len(), 1);
}
#[test]
fn vertical_area_type_wraps_into_columns_inside_the_frame() {
let mut t = area("日本語日本語日本語", style(20.0), Rect::new(0.0, 0.0, 100.0, 65.0), Justify::Left);
t.vertical = true;
let l = layout(db(), &t);
assert!(l.lines.len() > 1);
assert!(l.glyphs.iter().all(|g| g.origin.x >= 0.0 && g.origin.x <= 100.0 && g.origin.y >= 0.0 && g.origin.y <= 65.0));
assert!(l.glyphs[l.lines[1].glyph_start].origin.x < l.glyphs[0].origin.x);
}
+43 -1
View File
@@ -9,9 +9,15 @@ const FAMILY: &str = "Sysfont Sans3";
/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name).
fn renamed(file: &str) -> Vec<u8> {
renamed_to(file, FAMILY)
}
/// A bundled Source Sans 3 file renamed `family`, which is as long as the original name.
fn renamed_to(file: &str, family: &str) -> Vec<u8> {
assert_eq!(family.len(), "Source Sans 3".len());
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
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())] {
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
let mut i = 0;
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
data[i + at..i + at + to.len()].copy_from_slice(&to);
@@ -132,6 +138,30 @@ fn the_family_list_is_shared_until_fonts_change() {
assert!(!Arc::ptr_eq(&a, &b) && has(&b, FAMILY) && !has(&a, FAMILY));
}
#[test]
fn font_lists_leave_out_hidden_system_families_which_still_resolve() {
// macOS names the faces it keeps for its own interface with a leading "." (".SF NS").
const HIDDEN: &str = ".Sysfont Sans";
const DOTTED: &str = "Sysfont.Sans3";
let dir = font_dir("hidden");
std::fs::write(dir.join("Hidden.ttf"), renamed_to("SourceSans3-Regular.ttf", HIDDEN)).unwrap();
std::fs::write(dir.join("Dotted.ttf"), renamed_to("SourceSans3-Regular.ttf", DOTTED)).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
let listed = db.menu_family_list();
assert!(has(&listed, FAMILY) && has(&listed, FALLBACK_FAMILY));
assert!(!has(&listed, HIDDEN), "a leading dot hides a family: {listed:?}");
assert!(has(&listed, DOTTED), "a dot elsewhere doesn't");
assert_eq!(listed.len() + 1, db.family_list().len(), "only the hidden family is left out");
assert!(Arc::ptr_eq(&listed, &db.menu_family_list()), "shared until the fonts change");
// Documents and fallbacks that name it still find it.
assert!(has(&db.families(), HIDDEN) && db.has_family(HIDDEN));
assert_eq!(db.find_family(HIDDEN).as_deref(), Some(HIDDEN));
assert_eq!(db.face(HIDDEN, "Regular").unwrap().family, HIDDEN);
// Loading it changed the fonts: the list is built again, still without it.
let again = db.menu_family_list();
assert!(!Arc::ptr_eq(&listed, &again) && *listed == *again);
}
#[test]
fn installed_styles_of_a_loaded_family_load_when_asked_for() {
let dir = font_dir("styles");
@@ -142,3 +172,15 @@ fn installed_styles_of_a_loaded_family_load_when_asked_for() {
// A style nobody has still gets the closest one.
assert_eq!(db.face(FAMILY, "Black").unwrap().style, "Bold");
}
/// Documents name fonts by PostScript name, and families can hold hyphens: the installed face of
/// that exact name gives the family and style, however the name splits.
#[test]
fn installed_faces_are_found_by_postscript_name() {
let dir = font_dir("postscript");
let db = FontDb::with_font_dirs(vec![dir]);
// The renamed Source Sans 3 files keep their PostScript names.
assert_eq!(db.by_postscript_name("SourceSans3-Bold"), Some((FAMILY.to_string(), "Bold".to_string())));
assert_eq!(db.by_postscript_name("sourcesans3-regular"), Some((FAMILY.to_string(), "Regular".to_string())), "any case");
assert_eq!(db.by_postscript_name("Rounded-X-Mplus-1c-black"), None);
}
+130
View File
@@ -0,0 +1,130 @@
//! Vertical type (bundled fonts only: `§` and `×` stand upright like CJK, letters lie on their side).
use super::*;
use kurbo::Shape;
use vectorcraft_doc::{CharStyle, TextKind};
use vectorcraft_geom::PathData;
fn vertical(text: &str) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
t.xf = Affine::IDENTITY;
t.vertical = true;
t
}
fn ink(l: &TextLayout, i: usize) -> Rect {
l.glyphs[i].outline.bounding_box()
}
#[test]
fn columns_run_down_and_follow_each_other_to_the_left() {
let l = layout(FontDb::global(), &vertical("§§\n§"));
assert!(l.vertical);
let (a, b, c) = (ink(&l, 0), ink(&l, 1), ink(&l, 2));
assert!(b.center().y > a.center().y + 15.0, "down the column: {a:?} {b:?}");
assert!((a.center().x - b.center().x).abs() < 0.5, "one column");
assert!(c.center().x < a.center().x - 15.0, "the next column is to the left: {c:?}");
// Point type: the anchor is on the first column's centre line.
assert!(a.center().x.abs() < 4.0, "{a:?}");
assert!(l.bounds.contains(a.center()) && l.bounds.contains(c.center()));
}
#[test]
fn upright_marks_stand_and_letters_lie_on_their_side() {
let h = layout(FontDb::global(), &TextObject::point(Point::ZERO, "§l", CharStyle { size: 20.0, ..CharStyle::default() }));
let v = layout(FontDb::global(), &vertical("§l"));
let (hs, hl, vs, vl) = (ink(&h, 0), ink(&h, 1), ink(&v, 0), ink(&v, 1));
assert!((vs.width() - hs.width()).abs() < 0.01 && (vs.height() - hs.height()).abs() < 0.01, "§ upright: {hs:?} {vs:?}");
assert!((vl.width() - hl.height()).abs() < 0.01 && (vl.height() - hl.width()).abs() < 0.01, "l turned: {hl:?} {vl:?}");
assert!(vl.width() > vl.height(), "a tall l lies across the column");
}
#[test]
fn caret_and_selection_turn_with_the_columns() {
let l = layout(FontDb::global(), &vertical("§§§"));
let (top, bottom) = caret_position(&l, "§".len());
assert!((top.y - bottom.y).abs() < 1e-6 && (top.x - bottom.x).abs() > 10.0, "a horizontal caret across the column: {top:?} {bottom:?}");
let between = (ink(&l, 0).center().y + ink(&l, 1).center().y) / 2.0;
assert!((top.y - between).abs() < 3.0, "between the first two marks: {top:?} {between} {:?} {:?}", ink(&l, 0), ink(&l, 1));
let q = selection_quads(&l, 0, "§§".len());
let r = Rect::from_points(q[0][0], q[0][2]);
assert!(r.height() > r.width() && r.contains(ink(&l, 0).center()) && r.contains(ink(&l, 1).center()) && !r.contains(ink(&l, 2).center()));
// Clicking a mark finds it.
let c = ink(&l, 2).center();
assert_eq!(hit_byte(&l, c + Vec2::new(0.0, 6.0)), "§§§".len());
assert_eq!(hit_byte(&l, c - Vec2::new(0.0, 6.0)), "§§".len());
}
#[test]
fn area_type_starts_at_the_right_edge_and_wraps_to_the_left() {
let mut t = vertical(&"§".repeat(12));
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 105.0).to_path(0.1)) };
let l = layout(FontDb::global(), &t);
let first = ink(&l, 0);
assert!(first.x1 <= 100.0 + 1e-6 && first.x1 > 80.0 && first.y0 >= 0.0, "top right: {first:?}");
let cols: Vec<f64> = l.glyphs.iter().map(|g| (g.outline.bounding_box().center().x * 10.0).round()).collect();
let mut distinct = cols.clone();
distinct.dedup();
assert!(distinct.len() >= 2 && distinct.windows(2).all(|w| w[1] < w[0]), "columns right to left: {distinct:?}");
assert!(l.glyphs.iter().all(|g| g.outline.bounding_box().y1 <= 105.0 + 1e-6), "inside the frame");
}
#[test]
fn type_on_a_path_stays_horizontal() {
let mut t = vertical("ab");
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0 };
assert!(!layout(FontDb::global(), &t).vertical);
}
#[test]
fn three_digits_stand_across_the_column_squeezed_into_one_em() {
let l = layout(FontDb::global(), &vertical("§100§"));
let block: Vec<Rect> = (1..4).map(|i| ink(&l, i)).collect();
assert!(block.windows(2).all(|w| w[1].center().x > w[0].center().x + 2.0 && (w[1].center().y - w[0].center().y).abs() < 1.0), "{block:?}");
let span = block[0].union(block[2]);
assert!(span.width() <= 20.0 + 1e-6, "squeezed into the 20 pt em: {span:?}");
assert!(ink(&l, 4).center().y - ink(&l, 0).center().y < 45.0, "one em of the column");
}
#[test]
fn two_digits_stand_across_the_column_and_longer_numbers_lie_on_their_side() {
let l = layout(FontDb::global(), &vertical("§12§2026"));
let (one, two) = (ink(&l, 1), ink(&l, 2));
assert!((one.center().y - two.center().y).abs() < 1.0 && two.center().x > one.center().x + 3.0, "12 side by side: {one:?} {two:?}");
assert!(one.height() > one.width(), "upright digits are taller than wide: {one:?}");
let (before, after) = (ink(&l, 0), ink(&l, 3));
assert!(after.center().y - before.center().y < 45.0, "the block takes one em (20 pt) of the column");
let year: Vec<Rect> = (4..8).map(|i| ink(&l, i)).collect();
assert!(year.windows(2).all(|w| w[1].center().y > w[0].center().y + 5.0), "2026 runs down the column on its side");
}
/// Each line's text, from the layout's line ranges.
fn line_texts(t: &TextObject, l: &TextLayout) -> Vec<String> {
let text = t.plain_text();
l.lines.iter().map(|li| text.get(li.start..li.end).unwrap_or("").trim_end().to_string()).collect()
}
#[test]
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_brackets_off_line_ends() {
for vertical_type in [false, true] {
// Five characters fit a line: without kinsoku "。" would start the second line.
let text = "あいうえお。かきくけ「こさしすせそ」";
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
t.xf = Affine::IDENTITY;
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 101.0, 400.0).to_path(0.1)) };
if vertical_type {
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 400.0, 101.0).to_path(0.1)) };
t.vertical = true;
}
let l = layout(FontDb::global(), &t);
let lines = line_texts(&t, &l);
assert!(lines.len() > 2, "{lines:?}");
for (i, line) in lines.iter().enumerate() {
if i > 0 {
let first = line.chars().next().unwrap_or(' ');
assert!(!crate::shape::no_line_start(first), "line {i} starts with {first}: {lines:?}");
}
let last = line.chars().last().unwrap_or(' ');
assert!(!crate::shape::no_line_end(last), "line {i} ends with {last}: {lines:?}");
}
}
}
+20 -3
View File
@@ -243,9 +243,15 @@ pub fn hatch(path: &BezPath, gap: f64) -> BezPath {
struct RegionCache {
key: Option<(usize, Vec<NodeId>)>,
regions: Vec<(po::Region, BezPath, Rect)>,
/// Live Paint faces (every path an edge, open ones included) rather than Shape Builder
/// regions (the filled areas of the shapes).
live: bool,
}
impl RegionCache {
fn live_paint() -> Self {
Self { live: true, ..Default::default() }
}
fn get(&mut self, cx: &ToolContext) -> &[(po::Region, BezPath, Rect)] {
let key = (cx.doc as *const Document as usize, cx.selection.objects.clone());
if self.key.as_ref() != Some(&key) {
@@ -254,7 +260,8 @@ impl RegionCache {
self.regions = if shapes.is_empty() {
vec![]
} else {
po::regions(&shapes)
let regions = if self.live { po::live_paint(&shapes).0 } else { po::regions(&shapes) };
regions
.into_iter()
.filter_map(|r| {
let bp = r.path.to_bezpath();
@@ -437,12 +444,17 @@ fn lp_overlays(h: &LpHover, cache: &RegionCache) -> Vec<Overlay> {
/// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click
/// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a
/// Live Paint group first.
#[derive(Default)]
pub struct LivePaintBucketTool {
cache: RegionCache,
hover: LpHover,
}
impl Default for LivePaintBucketTool {
fn default() -> Self {
Self { cache: RegionCache::live_paint(), hover: LpHover::None }
}
}
impl Tool for LivePaintBucketTool {
fn id(&self) -> &'static str {
"livePaintBucket"
@@ -500,12 +512,17 @@ impl Tool for LivePaintBucketTool {
// ---------- Live Paint Selection ----------
/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge.
#[derive(Default)]
pub struct LivePaintSelectionTool {
cache: RegionCache,
hover: LpHover,
}
impl Default for LivePaintSelectionTool {
fn default() -> Self {
Self { cache: RegionCache::live_paint(), hover: LpHover::None }
}
}
impl Tool for LivePaintSelectionTool {
fn id(&self) -> &'static str {
"livePaintSelection"
+16 -1
View File
@@ -385,6 +385,21 @@ pub trait Tool: Send {
fn text_input(&mut self, _cx: &ToolContext, _s: &str) -> Vec<Action> {
vec![]
}
/// IME composition (marked text) for the tool that wants text: `text` replaces the previous
/// marked text (or the selection); an empty `text` ends the composition. `active_chars` is the
/// clause being converted, in characters of `text`. The committed result arrives through
/// [`Tool::text_input`].
fn ime_preedit(&mut self, _cx: &ToolContext, _text: &str, _active_chars: Option<std::ops::Range<usize>>) -> Vec<Action> {
vec![]
}
/// Is uncommitted IME text being shown? Keys, shortcuts and Undo wait while it is.
fn composing(&self) -> bool {
false
}
/// Where the IME candidate window goes: the caret line (top, bottom) in document space.
fn ime_caret(&self, _cx: &ToolContext) -> Option<(Point, Point)> {
None
}
fn notify(&mut self, _cx: &ToolContext, _what: &str) {}
/// Called when the user switches away (finish pending work).
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
@@ -414,7 +429,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"directSelection" => Box::new(direct::DirectSelectionTool::new(false)),
"groupSelection" => Box::new(direct::DirectSelectionTool::new(true)),
"pen" => Box::new(pen::PenTool::default()),
"type" | "areaType" | "typeOnPath" => Box::new(text::TypeTool::new(id)),
"type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath" => Box::new(text::TypeTool::new(id)),
"rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)),
// Not in the toolbar: `file.place.queue` loads it.
"place" => Box::new(place::PlaceTool::default()),
+59 -7
View File
@@ -21,6 +21,9 @@ enum State {
Moving {
start: Point,
began: bool,
/// Shift-pressed on this selected object: released without a drag, it leaves the
/// selection.
deselect: Option<NodeId>,
},
Scaling {
handle: Handle,
@@ -141,16 +144,19 @@ impl Tool for SelectionTool {
Some(h) => {
let top = h.top_object(cx.isolation);
let mut out = vec![];
let mut deselect = None;
if m.shift {
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
// A Shift-click takes a selected object out of the selection when it
// is released; a Shift-drag moves the selection, constrained.
if cx.selection.contains(top) {
self.state = State::Idle;
return out;
deselect = Some(top);
} else {
out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 })));
}
} else if !cx.selection.contains(top) {
out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] })));
}
self.state = State::Moving { start: p, began: false };
self.state = State::Moving { start: p, began: false, deselect };
out
}
None => {
@@ -185,7 +191,7 @@ impl Tool for SelectionTool {
d = vectorcraft_geom::Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0);
self.guides.clear();
}
self.state = State::Moving { start, began: true };
self.state = State::Moving { start, began: true, deselect: None };
self.measure = Some((p, cx.offset_label(d.x, d.y)));
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
out
@@ -215,12 +221,16 @@ impl Tool for SelectionTool {
self.state = State::Idle;
c.finish()
}
(PointerKind::Up, State::Moving { began, .. }) => {
(PointerKind::Up, State::Moving { began, deselect, .. }) => {
self.state = State::Idle;
self.measure = None;
self.guides.clear();
self.targets = None;
if began { vec![Action::Commit] } else { vec![] }
match (began, deselect) {
(true, _) => vec![Action::Commit],
(false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],
(false, None) => vec![],
}
}
(PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => {
self.state = State::Idle;
@@ -339,6 +349,48 @@ mod tests {
assert_eq!(a, vec![Action::Commit]);
}
#[test]
fn shift_drag_on_a_selected_object_moves_it_constrained() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = SelectionTool::default();
let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() });
// Pressing keeps the selection: nothing is toggled yet.
assert!(t.pointer(&cx, &shift(PointerKind::Down, 150.0, 150.0)).is_empty());
let a = t.pointer(&cx, &shift(PointerKind::Drag, 190.0, 160.0));
assert_eq!(a[0], Action::Begin("Move".into()));
assert!(
matches!(&a[1], Action::Preview(c, v) if c == "object.transform"
&& v["matrix"][4].as_f64().is_some_and(|x| x > 39.0) && v["matrix"][5] == 0.0),
"moved right, constrained to horizontal: {a:?}"
);
assert_eq!(t.pointer(&cx, &shift(PointerKind::Up, 190.0, 160.0)), vec![Action::Commit]);
}
#[test]
fn shift_click_toggles_an_object_on_release() {
let (d, id) = doc_with_rect();
let p = paint();
let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() });
let toggle = vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))];
// Selected: it leaves the selection when the click is released.
let mut s = Selection::default();
s.add(id);
let cx1 = cx(&d, &s, &p);
let mut t = SelectionTool::default();
assert!(t.pointer(&cx1, &shift(PointerKind::Down, 150.0, 150.0)).is_empty());
assert_eq!(t.pointer(&cx1, &shift(PointerKind::Up, 150.0, 150.0)), toggle);
// Not selected: it joins the selection at once, ready to be dragged.
let s = Selection::default();
let cx2 = cx(&d, &s, &p);
let mut t = SelectionTool::default();
assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 150.0, 150.0)), toggle);
assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty());
}
#[test]
fn marquee_selects() {
let (d, id) = doc_with_rect();
+140 -16
View File
@@ -12,7 +12,11 @@
//! `text.editRange` (the changed span, with its styled runs) against the session snapshot.
//! Styling a selected range goes through `text.setRangeStyle` (the Character panel reads the
//! selection from [`Tool::options`]).
//!
//! IME: the marked text of a composition is part of the typing session (so it lays out in place),
//! underlined, until the IME commits it (it then goes through [`Tool::text_input`]) or clears it.
use std::ops::Range;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
@@ -38,8 +42,18 @@ struct Typing {
cur: Vec<TextRun>,
}
/// IME marked text inside the typing session, not yet committed.
#[derive(Clone, Debug, PartialEq)]
struct Preedit {
/// Byte range of the marked text in the session's runs.
range: Range<usize>,
/// The clause being converted, in bytes relative to `range.start` (empty: the IME's cursor).
active: Option<Range<usize>>,
}
#[derive(Default)]
pub struct TypeTool {
vertical: bool,
mode: Mode,
/// Text object being edited.
editing: Option<NodeId>,
@@ -51,6 +65,7 @@ pub struct TypeTool {
/// Horizontal position kept by Up/Down (text space).
goal_x: Option<f64>,
typing: Option<Typing>,
preedit: Option<Preedit>,
press: Option<Point>,
drag: Option<Point>,
/// The press landed in the edited text: dragging selects.
@@ -65,11 +80,11 @@ pub struct TypeTool {
impl TypeTool {
pub fn new(id: &str) -> Self {
let mode = match id {
"areaType" => Mode::Area,
"typeOnPath" => Mode::OnPath,
"areaType" | "verticalAreaType" => Mode::Area,
"typeOnPath" | "verticalTypeOnPath" => Mode::OnPath,
_ => Mode::Type,
};
Self { mode, ..Default::default() }
Self { mode, vertical: id.starts_with("vertical"), ..Default::default() }
}
fn text<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> {
@@ -112,8 +127,10 @@ impl TypeTool {
(self.caret.min(self.anchor), self.caret.max(self.anchor))
}
/// End the typing session (one undo step).
/// End the typing session (one undo step). Marked text left by an IME stays as typed (as
/// clicking away from a composition does in macOS text views); the UI interrupts the IME.
fn commit(&mut self) -> Vec<Action> {
self.preedit = None;
if self.typing.take().is_some() { vec![Action::Commit] } else { vec![] }
}
@@ -186,6 +203,7 @@ impl TypeTool {
self.anchor = caret;
self.goal_x = None;
self.typing = None;
self.preedit = None;
}
/// Byte under `p` in the edited text if `p` is inside it (frame, path band or layout bounds).
@@ -236,16 +254,20 @@ impl TypeTool {
let on_path = self.mode == Mode::OnPath;
if let Some(pid) = Self::path_at(cx, start, !on_path) {
let mode = if on_path { "onPath" } else { "area" };
out.push(Action::Exec("text.createInPath".into(), json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y]})));
out.push(Action::Exec(
"text.createInPath".into(),
json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical}),
));
out.push(Action::Notify("text.editNew".into()));
return out;
}
}
let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
let params = match area {
let mut params = match area {
Some(r) => json!({"x": r.x0, "y": r.y0 + 12.0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
None => json!({"x": start.x, "y": start.y, "text": ""}),
};
params["vertical"] = json!(self.vertical);
out.push(Action::Exec("text.create".into(), params));
out.push(Action::Notify("text.editNew".into()));
out
@@ -272,6 +294,14 @@ impl TypeTool {
}
}
/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes).
/// `None` when `r` runs past the end of `s` or backwards.
fn char_range_to_bytes(s: &str, r: Range<usize>) -> Option<Range<usize>> {
let byte = |c: usize| if c == s.chars().count() { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) };
let (a, b) = (byte(r.start)?, byte(r.end)?);
(a <= b).then_some(a..b)
}
/// Lengths of the common prefix and suffix of `a` and `b` (on char boundaries, not overlapping).
fn common_affixes(a: &str, b: &str) -> (usize, usize) {
let mut p = 0;
@@ -294,10 +324,13 @@ fn common_affixes(a: &str, b: &str) -> (usize, usize) {
impl Tool for TypeTool {
fn id(&self) -> &'static str {
match self.mode {
Mode::Type => "type",
Mode::Area => "areaType",
Mode::OnPath => "typeOnPath",
match (self.mode, self.vertical) {
(Mode::Type, false) => "type",
(Mode::Type, true) => "verticalType",
(Mode::Area, false) => "areaType",
(Mode::Area, true) => "verticalAreaType",
(Mode::OnPath, false) => "typeOnPath",
(Mode::OnPath, true) => "verticalTypeOnPath",
}
}
fn busy(&self) -> bool {
@@ -363,6 +396,15 @@ impl Tool for TypeTool {
return vec![];
}
let s: String = s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect();
// An IME commit replaces its marked text (an empty commit just removes it).
let committing = self.preedit.is_some();
if committing && s.is_empty() {
return self.ime_preedit(cx, "", None);
}
if let Some(p) = self.preedit.take() {
self.anchor = p.range.start;
self.caret = p.range.end;
}
if s.is_empty() {
return vec![];
}
@@ -379,13 +421,17 @@ impl Tool for TypeTool {
self.clicks = (None, 0);
// Pasting what we copied keeps its formatting (unless pasted text is kept plain).
let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect();
if s.len() > 1 && s == clip && !cx.paste_plain_text {
if !committing && s.len() > 1 && s == clip && !cx.paste_plain_text {
let runs = self.clipboard.clone();
return self.replace(cx, "", Some(runs));
}
self.replace(cx, &s, None)
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
// The IME owns the keys while it composes (the UI doesn't send them; this is a guard).
if self.preedit.is_some() {
return vec![];
}
let Some(t) = self.current(cx) else {
self.editing = None;
return vec![];
@@ -397,6 +443,17 @@ impl Tool for TypeTool {
let (a, b) = self.sel();
let word = mods.cmd || mods.alt;
let lay = self.layout(&t);
let key = if lay.vertical {
match key {
ToolKey::Up => ToolKey::Left,
ToolKey::Down => ToolKey::Right,
ToolKey::Right => ToolKey::Up,
ToolKey::Left => ToolKey::Down,
other => other,
}
} else {
key
};
let vertical = matches!(key, ToolKey::Up | ToolKey::Down);
if !vertical {
self.goal_x = None;
@@ -437,7 +494,7 @@ impl Tool for TypeTool {
ToolKey::Up | ToolKey::Down => {
let d = if key == ToolKey::Up { -1 } else { 1 };
let from = if a != b && !mods.shift { if d < 0 { a } else { b } } else { self.caret };
let x = self.goal_x.unwrap_or_else(|| vectorcraft_text::caret_position(&lay, from).0.x);
let x = self.goal_x.unwrap_or_else(|| lay.logical_point(vectorcraft_text::caret_position(&lay, from).0).x);
let to = if mods.cmd {
// Cmd+Up/Down: paragraph start / end.
let p = edit::paragraph_at(&text, from);
@@ -466,6 +523,51 @@ impl Tool for TypeTool {
_ => vec![],
}
}
fn ime_preedit(&mut self, cx: &ToolContext, text: &str, active_chars: Option<Range<usize>>) -> Vec<Action> {
if self.editing.is_none() {
return vec![];
}
// A clear with nothing marked (some integrations send one when the IME is enabled) must
// not delete the selection.
if text.is_empty() && self.preedit.is_none() {
return vec![];
}
let text: String = text.chars().filter(|c| !c.is_control()).collect();
// The new marked text replaces the previous one, or the selection when it starts.
if let Some(p) = self.preedit.take() {
self.anchor = p.range.start;
self.caret = p.range.end;
}
self.clicks = (None, 0);
let mut out = self.replace(cx, &text, None);
if text.is_empty() {
// Cancelled before anything else was typed: no empty undo step.
if self.typing.as_ref().is_some_and(|ty| ty.cur == ty.base) {
self.typing = None;
out.push(Action::Cancel);
}
return out;
}
let end = self.caret;
let start = end.saturating_sub(text.len());
self.preedit = Some(Preedit { range: start..end, active: active_chars.and_then(|r| char_range_to_bytes(&text, r)) });
out
}
fn composing(&self) -> bool {
self.preedit.is_some()
}
fn ime_caret(&self, cx: &ToolContext) -> Option<(Point, Point)> {
let t = self.current(cx)?;
let lay = self.layout(&t);
let len = t.plain_text().len();
// The candidate window follows the clause being converted, else the caret.
let at = match &self.preedit {
Some(p) => p.range.start + p.active.as_ref().map_or(0, |r| r.start),
None => self.caret.min(self.anchor),
};
let (a, b) = vectorcraft_text::caret_position(&lay, at.min(len));
Some((t.xf * a, t.xf * b))
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.finish(cx)
}
@@ -491,13 +593,14 @@ impl Tool for TypeTool {
self.fresh = Self::text(cx, id).filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id);
}
}
/// `{editing, start, end, caret, anchor, typing}` — the Character panel styles `start..end`.
/// `{editing, start, end, caret, anchor, typing, composing}` — the Character panel styles
/// `start..end`.
fn options(&self) -> Value {
match self.editing {
Some(id) => {
// Offsets may exceed the text after Select All; callers clamp to the text length.
let (a, b) = self.sel();
json!({"editing": id.0, "start": a, "end": b, "caret": self.caret, "anchor": self.anchor, "typing": self.typing.is_some()})
json!({"editing": id.0, "start": a, "end": b, "caret": self.caret, "anchor": self.anchor, "typing": self.typing.is_some(), "composing": self.preedit.is_some()})
}
None => json!({"editing": null}),
}
@@ -506,7 +609,10 @@ impl Tool for TypeTool {
/// `commitTyping` (the caller commits the engine interaction).
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"commitTyping" | "endTyping" => self.typing = None,
"commitTyping" | "endTyping" => {
self.typing = None;
self.preedit = None;
}
"selectAll" if self.editing.is_some() => {
self.anchor = 0;
self.caret = usize::MAX / 4;
@@ -525,6 +631,7 @@ impl Tool for TypeTool {
}
"stopEditing" => {
self.typing = None;
self.preedit = None;
self.editing = None;
}
_ => {}
@@ -559,7 +666,24 @@ impl Tool for TypeTool {
o.push(Overlay::Path { path: f, color: [79, 128, 255], width: 1.0, dashed: false });
}
let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len));
if a == b {
if let Some(pe) = &self.preedit {
// Marked text: a thin underline, a thick one under the clause being converted.
let under = |o: &mut Vec<Overlay>, r: Range<usize>, width: f32| {
for q in vectorcraft_text::selection_quads(&lay, r.start.min(len), r.end.min(len)) {
let (l, rr) = (t.xf * q[3], t.xf * q[2]);
o.push(Overlay::Path { path: vectorcraft_geom::Line::new(l, rr).to_path(0.1), color: [0, 0, 0], width, dashed: false });
}
};
under(&mut o, pe.range.clone(), 1.0);
match &pe.active {
Some(r) if !r.is_empty() => under(&mut o, pe.range.start + r.start..pe.range.start + r.end, 2.5),
_ => {
let at = pe.range.start + pe.active.as_ref().map_or(pe.range.len(), |r| r.start);
let (p, q) = vectorcraft_text::caret_position(&lay, at.min(len));
o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false });
}
}
} else if a == b {
let (p, q) = vectorcraft_text::caret_position(&lay, self.caret.min(len));
o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false });
} else {
+74
View File
@@ -218,3 +218,77 @@ fn area_and_path_tools_convert_clicked_paths() {
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 100.0));
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.createInPath" && p["mode"] == "onPath")), "{acts:?}");
}
#[test]
fn vertical_tools_preserve_their_ids_and_create_vertical_text() {
for id in ["verticalType", "verticalAreaType", "verticalTypeOnPath"] {
let tool = TypeTool::new(id);
assert_eq!(tool.id(), id);
assert!(tool.vertical);
}
}
#[test]
fn ime_ranges_convert_characters_to_bytes() {
assert_eq!(char_range_to_bytes("ががく", 1..3), Some(3..9));
assert_eq!(char_range_to_bytes("ががく", 3..3), Some(9..9));
assert_eq!(char_range_to_bytes("a雅b", 1..2), Some(1..4));
assert_eq!(char_range_to_bytes("ががく", 2..4), None, "past the end");
#[allow(clippy::reversed_empty_ranges)]
let backwards = 2..1;
assert_eq!(char_range_to_bytes("ががく", backwards), None, "backwards");
}
#[test]
fn marked_text_is_underlined_with_the_converting_clause_thick() {
let (mut d, id, mut tool) = editing("曲");
let snap = d.clone();
let (sel, p) = (Selection::default(), paint());
tool.caret = 3;
tool.anchor = 3;
let acts = tool.ime_preedit(&cx(&d, &sel, &p), "雅楽演奏", Some(2..4));
apply(&mut d, &snap, &acts);
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "曲雅楽演奏");
assert_eq!(tool.preedit, Some(Preedit { range: 3..15, active: Some(6..12) }));
let widths: Vec<f32> = tool
.overlays(&cx(&d, &sel, &p))
.iter()
.filter_map(|o| match o {
Overlay::Path { width, .. } => Some(*width),
_ => None,
})
.collect();
assert_eq!(widths, vec![1.0, 2.5], "a thin underline, then the thick one under 演奏");
// The candidate window follows the converting clause.
let (top, _) = tool.ime_caret(&cx(&d, &sel, &p)).unwrap();
let lay = vectorcraft_text::layout(FontDb::global(), t);
let (at, _) = vectorcraft_text::caret_position(&lay, 9);
assert!((top - t.xf * at).hypot() < 1e-6);
// Keys wait for the IME; a commit replaces the marked text.
assert!(key(&mut tool, &d, ToolKey::Backspace, Mods::default()).is_empty());
let acts = tool.text_input(&cx(&d, &sel, &p), "雅楽演奏会");
apply(&mut d, &snap, &acts);
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "曲雅楽演奏会");
assert!(!tool.composing());
}
#[test]
fn arrows_follow_the_columns_of_vertical_type() {
let (mut d, id) = doc_with_text("§§§\n§§§");
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.vertical = true;
}
let mut tool = TypeTool::new("type");
tool.start_editing(id, 0);
let s = "§".len();
key(&mut tool, &d, ToolKey::Down, Mods::default());
assert_eq!(tool.caret, s, "↓ goes down the column to the next character");
key(&mut tool, &d, ToolKey::Left, Mods::default());
assert_eq!(tool.caret, "§§§\n§".len(), "← goes on to the next column, keeping the place in it");
key(&mut tool, &d, ToolKey::Right, Mods::default());
assert_eq!(tool.caret, s, "→ comes back");
key(&mut tool, &d, ToolKey::Up, Mods::default());
assert_eq!(tool.caret, 0);
}
+268 -23
View File
@@ -158,10 +158,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
fit(app, "view.fitArtboard");
}
let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag());
handle_input(app, ui, &resp, rect);
// A press on the canvas while the context menu is open only closes it (a drag too, which egui
// alone would leave open); the tool doesn't get it.
if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) {
egui::Popup::close_all(ui.ctx());
} else {
handle_input(app, ui, &resp, rect);
}
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
panel_drop(app, ui, &resp, &xf);
context_menu(app, &resp, &xf);
let painter = ui.painter_at(rect);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
@@ -330,6 +337,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let overlays = app.session.overlays(view_info);
draw_overlays(&painter, &xf, &overlays, &t);
}
ime_output(app, ui.ctx(), &xf);
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
if app.ui.view.rulers && app.ui.screen_mode < 3 {
@@ -436,10 +444,15 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
let tool = app.session.tool_id();
let pan_mode = space || tool == "hand";
let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. }));
if pointer.primary_pressed() && resp.hovered() && !middle_pan {
// egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a
// press on the canvas itself reaches the tools, else it would land at the canvas's centre.
if pointer.primary_pressed()
&& resp.hovered()
&& !middle_pan
&& let Some(p) = hover
{
ui.ctx().memory_mut(|mem| mem.stop_text_input());
app.ui.flyout = None;
let p = hover.unwrap_or(rect.center());
let d = if pan_mode {
Drag::Pan { start: p, center: v.center, middle: false }
} else if tool == "zoom" {
@@ -460,9 +473,12 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
kind
};
ui.data_mut(|dd| dd.insert_temp(drag_id(), d));
} else if drag.is_none() && resp.hovered() && pointer.button_pressed(egui::PointerButton::Middle) {
} else if drag.is_none()
&& resp.hovered()
&& pointer.button_pressed(egui::PointerButton::Middle)
&& let Some(start) = hover
{
// Middle-button drag pans the view whatever the tool.
let start = hover.unwrap_or(rect.center());
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Pan { start, center: v.center, middle: true }));
} else if let Some(d) = drag {
let p = pointer.interact_pos().unwrap_or(rect.center());
@@ -821,6 +837,25 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt)
}
/// Right-click: the object under the pointer is selected first unless it already is, then the
/// context menu lists what applies to the selection ([`crate::menus::context_items`]).
fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) {
if resp.secondary_clicked()
&& let Some(p) = resp.interact_pointer_pos()
&& let Some(st) = app.session.active()
&& let Some(top) = hit_at(app, xf.to_doc(p), xf.zoom).map(|h| h.top_object(st.isolation))
&& !st.selection.contains(top)
{
// A locked or hidden object can't be selected; the menu is then for the selection as is.
let _ = app.run("select.set", json!({ "ids": [top.0] }));
}
let mut clicked = None;
resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked));
if let Some((id, p)) = clicked {
crate::menus::invoke(app, &id, p);
}
}
/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer,
/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
@@ -1144,6 +1179,34 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
}
}
/// While the Type tool edits, let the system IME compose (egui-winit allows it only in frames that
/// set `ime`) and keep its candidate window under the caret.
fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
let ours = app.session.tool_wants_text() && !ctx.egui_wants_keyboard_input() && app.ui.dialog.is_none() && !app.ui.palette_open;
let caret = if ours { app.session.tool_ime_caret(app.view_info()) } else { None };
let Some((a, b)) = caret else {
// The IME goes away with its marked text: keep that text as typed, like a click away.
crate::shortcuts::keep_marked_text(app);
return;
};
let r = egui::Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand2(vec2(1.0, 0.0));
// The tool ended the composition itself: the IME must drop what it still has marked.
let interrupt = app.ime_marked.is_some() && !app.session.tool_composing();
if interrupt {
app.ime_marked = None;
app.ime_discard = true;
}
ctx.output_mut(|o| {
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: interrupt });
});
}
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? It holds a name, so it is
/// shown as it is; the tools' other labels ("anchor", "path", Puppet Warp's warning) are ours.
fn names_an_artboard(text: &str) -> bool {
text.split_once(" - ").is_some_and(|(n, _)| n.len() >= 2 && n.bytes().all(|b| b.is_ascii_digit()))
}
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
for o in overlays {
match o {
@@ -1179,7 +1242,13 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
}
Overlay::Label { p: pt, text, color } => {
let sp = xf.to_screen(*pt) + vec2(8.0, -14.0);
p.text(sp, egui::Align2::LEFT_TOP, text, egui::FontId::proportional(11.0), c32(*color));
p.text(
sp,
egui::Align2::LEFT_TOP,
crate::panels::label_or_name(text, !names_an_artboard(text)),
egui::FontId::proportional(11.0),
c32(*color),
);
}
Overlay::Highlight { quad, color } => {
let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);
@@ -1218,21 +1287,21 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
let inner = rect.shrink2(vec2((rect.width() - 820.0).max(40.0) / 2.0, 60.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));
let ui = &mut child;
ui.label(egui::RichText::new("Welcome to VectorCraft").font(theme::semibold(26.0)).color(t.text));
ui.label(egui::RichText::new(tl!("Welcome to VectorCraft")).font(theme::semibold(26.0)).color(t.text));
ui.add_space(4.0);
ui.label(egui::RichText::new("Vector illustration — fast, open, scriptable.").size(14.0).color(t.text_dim));
ui.label(egui::RichText::new(tl!("Vector illustration — fast, open, scriptable.")).size(14.0).color(t.text_dim));
ui.add_space(22.0);
ui.horizontal(|ui| {
if widgets::primary_button(ui, "New file").clicked() {
if widgets::primary_button(ui, tl!("New file")).clicked() {
app.run("file.newDialog", json!({})).ok();
}
ui.add_space(8.0);
if widgets::secondary_button(ui, "Open").clicked() {
if widgets::secondary_button(ui, tl!("Open")).clicked() {
app.run("file.open", json!({})).ok();
}
});
ui.add_space(28.0);
ui.label(egui::RichText::new("Quickly start a new file").font(theme::semibold(14.0)).color(t.text));
ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
// A few of New Document's presets (`file.newPresets`), as its cards.
let presets = ["Letter", "A4", "Web 1920×1080", "Phone 390×844", "Postcard", "Social Square Post 1080×1080"];
@@ -1246,7 +1315,7 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
}
});
ui.add_space(28.0);
ui.label(egui::RichText::new("Community").font(theme::semibold(14.0)).color(t.text));
ui.label(egui::RichText::new(tl!("Community")).font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
crate::community::links(app, ui);
}
@@ -1277,18 +1346,18 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_)));
let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command)
if n > 1 {
items.push(("Group", "group", "object.group"));
items.push(("Unite", "squares-unite", "object.pathfinder.unite"));
items.push((tl!("Group"), "group", "object.group"));
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite"));
} else if is_group {
items.push(("Ungroup", "ungroup", "object.ungroup"));
items.push(("Isolate", "square-dashed", "object.isolate"));
items.push((tl!("Ungroup"), "ungroup", "object.ungroup"));
items.push((tl!("Isolate"), "square-dashed", "object.isolate"));
} else if is_text {
items.push(("Create Outlines", "type", "type.createOutlines"));
items.push((tl!("Create Outlines"), "type", "type.createOutlines"));
} else {
items.push(("Offset Path", "square-dashed", "object.path.offsetPath"));
items.push(("Simplify", "spline", "object.path.simplify"));
items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath"));
items.push((tl!("Simplify"), "spline", "object.path.simplify"));
}
items.push(("Duplicate", "copy", "edit.duplicate"));
items.push((tl!("Duplicate"), "copy", "edit.duplicate"));
let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default();
let anchor = xf.to_screen(Point::new(b.center().x, b.y1));
let est_w = 118.0 + items.iter().map(|(l, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
@@ -1328,14 +1397,14 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
Stroke::new(1.0, t.button_border),
StrokeKind::Outside,
);
if resp.on_hover_text("Fill").clicked() {
if resp.on_hover_text(tl!("Fill")).clicked() {
app.session.fill_active = true;
app.ui.open_panel = Some("swatches".into());
}
if widgets::icon_button(ui, "lock", "Lock (⌘2)", false, 30.0).clicked() {
if widgets::icon_button(ui, "lock", tl!("Lock (⌘2)"), false, 30.0).clicked() {
run = Some("object.lock".into());
}
if widgets::icon_button(ui, "ellipsis", "Hide Contextual Task Bar", false, 30.0).clicked() {
if widgets::icon_button(ui, "ellipsis", tl!("Hide Contextual Task Bar"), false, 30.0).clicked() {
run = Some("window.taskBar".into());
}
});
@@ -1358,10 +1427,54 @@ mod tests {
out.textures_delta.clear();
}
/// The Artboard tool's label holds the artboard's name (never translated); the tools' own
/// labels are interface text.
#[test]
fn artboard_labels_name_the_artboard() {
assert!(names_an_artboard("01 - Layers") && names_an_artboard("12 - Artboard 12 - copy"));
assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer"));
}
fn middle(pos: Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }
}
/// One headless frame with the canvas under a 40-point bar, as it is under the document tabs.
fn frame_under_bar(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
ui.add_space(40.0);
show(app, ui);
});
out.textures_delta.clear();
}
#[test]
fn a_click_just_outside_the_canvas_does_not_reach_the_tool() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
// A square in the middle of the artboard, where the view is centred.
let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.session.execute("select.none", &json!({})).unwrap();
let ctx = egui::Context::default();
frame_under_bar(&mut app, &ctx, vec![]);
let rect = app.canvas_rect.unwrap();
let click = |app: &mut VectorcraftApp, p: Pos2| {
let button =
|pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame_under_bar(app, &ctx, vec![egui::Event::PointerMoved(p)]);
frame_under_bar(app, &ctx, vec![button(true)]);
frame_under_bar(app, &ctx, vec![button(false)]);
};
let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone();
// Two pixels above the canvas, within egui's interaction radius: nothing happens.
click(&mut app, pos2(rect.center().x, rect.top() - 2.0));
assert!(selected(&app).is_empty(), "a click above the canvas selected {:?}", selected(&app));
// At the canvas's centre: the square.
click(&mut app, rect.center());
assert_eq!(selected(&app), vec![vectorcraft_doc::NodeId(id)]);
}
#[test]
fn middle_drag_pans_the_view_with_any_tool() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -1517,4 +1630,136 @@ mod tests {
assert_eq!(*st.doc, *before);
assert_eq!(st.history.undo.len(), undo);
}
/// One frame of keyboard handling and canvas, as the app runs them; returns the IME output.
fn typing_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Option<egui::output::IMEOutput> {
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
crate::shortcuts::handle(app, ui.ctx());
show(app, ui);
});
out.textures_delta.clear();
out.platform_output.ime
}
fn preedit(t: &str, chars: usize) -> egui::Event {
egui::Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(chars..chars) })
}
fn plain(app: &VectorcraftApp, id: u64) -> String {
match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.plain_text(),
_ => panic!("not text"),
}
}
#[test]
fn japanese_ime_composes_on_the_canvas_with_its_window_at_the_caret() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
// Not editing: the IME stays off (single-key tool shortcuts keep working).
assert!(typing_frame(&mut app, &ctx, vec![]).is_none());
app.select_tool("type");
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let at = xf.to_screen(Point::new(100.0, 100.0));
let click =
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
typing_frame(&mut app, &ctx, vec![click(at, false)]);
assert!(app.session.tool_wants_text());
let id = app.session.active().unwrap().selection.objects[0].0;
// Editing: the IME is allowed, its window at the caret (the text's baseline at y = 100).
let ime = typing_frame(&mut app, &ctx, vec![]).expect("IME allowed while editing");
assert!(ime.rect.contains(pos2(ime.rect.center().x, at.y)) && (ime.rect.center().x - at.x).abs() < 2.0, "{:?} vs {at:?}", ime.rect);
assert!(!ime.should_interrupt_composition);
// gagaku → がが → 雅楽, then commit (the macOS sequence).
typing_frame(&mut app, &ctx, vec![preedit("g", 1)]);
typing_frame(&mut app, &ctx, vec![preedit("が", 1), preedit("がg", 2)]);
typing_frame(&mut app, &ctx, vec![preedit("ががく", 3)]);
assert_eq!(plain(&app, id), "ががく");
// While composing: plain text events, the clipboard and Undo (the native menu's ⌘Z
// reaches `menus::invoke`) all wait.
typing_frame(&mut app, &ctx, vec![egui::Event::Text("x".into()), egui::Event::Paste("y".into())]);
crate::menus::invoke(&mut app, "edit.undo", json!({}));
assert!(!crate::menus::enabled(&app, "edit.undo"));
assert_eq!(plain(&app, id), "ががく");
let ime2 =
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "雅楽".into(), active_range_chars: Some(0..2) })])
.unwrap();
assert!(ime2.rect.center().x <= ime.rect.center().x + 1.0, "the window stays at the clause being converted");
typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅楽".into()))]);
assert_eq!(plain(&app, id), "雅楽");
assert!(!app.session.tool_composing());
// After the commit, text and Undo work again.
typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]);
assert_eq!(plain(&app, id), "雅楽!");
crate::menus::invoke(&mut app, "edit.undo", json!({}));
assert_eq!(plain(&app, id), "");
}
#[test]
fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
typing_frame(&mut app, &ctx, vec![]);
app.select_tool("type");
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(300.0, 250.0)));
let click =
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), click(a, true)]);
typing_frame(&mut app, &ctx, vec![click(a, false)]);
let id = app.session.active().unwrap().selection.objects[0].0;
typing_frame(&mut app, &ctx, vec![preedit("しょうこ", 4)]);
// macOS sends nothing for a click away; the tool keeps the marked text and the IME is
// told to drop its composition.
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]);
let ime = typing_frame(&mut app, &ctx, vec![click(b, false)]);
assert_eq!(plain(&app, id), "しょうこ");
assert!(!app.session.tool_composing());
assert!(ime.expect("editing the new text").should_interrupt_composition);
assert!(app.ime_marked.is_none());
assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text");
// Interrupted once, not every frame.
assert!(!typing_frame(&mut app, &ctx, vec![]).unwrap().should_interrupt_composition);
assert!(!app.take_ime_discard());
}
#[test]
fn ime_edge_orders_never_eat_committed_text_or_lose_the_composition() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
typing_frame(&mut app, &ctx, vec![]);
app.select_tool("type");
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let at = xf.to_screen(Point::new(100.0, 100.0));
let click =
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
typing_frame(&mut app, &ctx, vec![click(at, false)]);
let id = app.session.active().unwrap().selection.objects[0].0;
typing_frame(&mut app, &ctx, vec![egui::Event::Text("雅".into())]);
let backspace =
|pressed| egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
// The IME empties its marked text and the Backspace comes in the same frame.
typing_frame(&mut app, &ctx, vec![preedit("が", 1)]);
typing_frame(&mut app, &ctx, vec![preedit("", 0), backspace(true), backspace(false)]);
assert_eq!(plain(&app, id), "雅", "the committed character stays");
// A bare line-break commit ends the composition with the marked text, no newline.
typing_frame(&mut app, &ctx, vec![preedit("がく", 2)]);
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("\n".into()))]);
assert!(!app.session.tool_composing());
assert_eq!(plain(&app, id), "雅がく");
assert!(app.take_ime_discard());
// Switching apps mid-composition: the composition goes on when the window comes back.
typing_frame(&mut app, &ctx, vec![preedit("らく", 2)]);
typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(false)]);
typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(true), preedit("らくか", 3)]);
assert!(app.session.tool_composing());
assert!(!app.take_ime_discard());
assert_eq!(plain(&app, id), "雅がくらくか");
}
}
+68 -26
View File
@@ -30,7 +30,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
crate::brand::paint_mark(ui, r);
ui.add_space(4.0);
let on_home = app.ui.home.is_some() || app.session.active().is_none();
if widgets::icon_button(ui, "house", "Home", on_home, 24.0).clicked() {
if widgets::icon_button(ui, "house", tl!("Home"), on_home, 24.0).clicked() {
app.run("app.home", json!({})).ok();
}
ui.add_space(2.0);
@@ -48,7 +48,10 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() };
let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom()));
let room = right.width();
let ws = ui.painter().layout_no_wrap(app.ui.workspace.clone(), egui::FontId::proportional(12.0), t.text);
// Built-in workspace names translate; the user's own stay as typed.
let ws_name =
if crate::workspaces::is_builtin(&app.ui.workspace) { tl!(&app.ui.workspace).to_string() } else { app.ui.workspace.clone() };
let ws = ui.painter().layout_no_wrap(ws_name, egui::FontId::proportional(12.0), t.text);
let ws_w = (ws.size().x + 36.0).clamp(112.0, 190.0);
let gap = ui.spacing().item_spacing.x;
let with_search = ws_w + 8.0 + gap + 200.0;
@@ -62,7 +65,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel });
ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text);
icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim);
let wresp = wresp.on_hover_text("Switch workspace");
let wresp = wresp.on_hover_text(tl!("Switch workspace"));
egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui));
ui.add_space(8.0);
// Search box → command palette.
@@ -79,13 +82,13 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().text(
r.left_center() + vec2(26.0, 0.0),
egui::Align2::LEFT_CENTER,
"Search commands and tools",
tl!("Search commands and tools"),
egui::FontId::proportional(11.5),
t.text_dim,
);
resp.clicked()
} else {
widgets::icon_button(ui, "search", "Search commands and tools", false, 24.0).clicked()
widgets::icon_button(ui, "search", tl!("Search commands and tools"), false, 24.0).clicked()
};
if open_palette {
app.ui.palette_open = true;
@@ -116,7 +119,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled);
}
let Some(st) = app.session.active() else {
ui.label(egui::RichText::new("No Document").color(t.text_dim));
ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim));
return;
};
let sel = st.selection.objects.clone();
@@ -124,15 +127,15 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
let anchor_mode = !st.selection.anchors.is_empty();
let label = match &first {
Some(_) if sel.len() == 1 && anchor_mode => "Anchor Point",
Some(_) if sel.len() == 1 && anchor_mode => tl!("Anchor Point"),
Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => {
if im.link.is_some() {
"Linked File"
tl!("Linked File")
} else {
"Embedded"
tl!("Embedded")
}
}
_ => crate::panels::appearance::object_label(app),
_ => tl!(crate::panels::appearance::object_label(app)),
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
ui.add_space(6.0);
@@ -147,7 +150,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
crate::panels::paint_chip(app, ui, true, 22.0, true);
// The link opens the Stroke panel as a popover under it; then the weight spinner
// (with presets) and the width profile.
let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options");
let link = ui.link(egui::RichText::new(tl!("Stroke:")).size(12.0).color(t.text).underline()).on_hover_text(tl!("Stroke options"));
stroke_panel::popover(app, &link);
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
@@ -155,7 +158,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.add_space(4.0);
ui.separator();
if ui.link(egui::RichText::new("Opacity:").size(12.0).color(t.text).underline()).clicked() {
if ui.link(egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("transparency".into());
}
if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0)
@@ -166,19 +169,19 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
if !sel.is_empty() {
// Style picker: the selection's graphic style; its menu applies another.
ui.add_space(4.0);
if ui.link(egui::RichText::new("Style:").size(12.0).color(t.text).underline()).clicked() {
if ui.link(egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("graphicStyles".into());
}
let resp = widgets::chip_button(ui, 22.0, true, |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r))
.on_hover_text("Graphic Style");
.on_hover_text(tl!("Graphic Style"));
egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui));
}
ui.separator();
if sel.is_empty() {
if widgets::flat_button(ui, "Document Setup", 112.0).clicked() {
if widgets::flat_button(ui, tl!("Document Setup"), 112.0).clicked() {
app.run("file.documentSetup", json!({})).ok();
}
if widgets::flat_button(ui, "Preferences", 90.0).clicked() {
if widgets::flat_button(ui, tl!("Preferences"), 90.0).clicked() {
app.run("edit.preferences", json!({})).ok();
}
return;
@@ -192,7 +195,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})),
("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})),
] {
if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() {
if widgets::icon_button(ui, icon, tl!(tip), false, 24.0).clicked() {
let mut p = p;
if sel.len() == 1 {
p["to"] = json!("artboard");
@@ -211,7 +214,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
if k == "height" {
crate::panels::transform::constrain_link(app, ui);
}
ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim));
ui.label(egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) {
app.run("object.setBounds", json!({k: nv, "reference": 4, "proportional": link})).ok();
}
@@ -225,10 +228,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
/// "Name* @ 66.67 % (<Opacity Mask>/Opacity Mask)".
fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool) -> String {
let mode = if d.doc.mask_edit.is_some() {
"<Opacity Mask>/Opacity Mask".to_string()
format!("<{0}>/{0}", tl!("Opacity Mask"))
} else {
let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" };
format!("{color}/{}", if outline { "Outline" } else { "Preview" })
format!("{color}/{}", if outline { tl!("Outline") } else { tl!("Preview") })
};
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %"))
}
@@ -305,6 +308,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
if pattern_edit.is_some() {
let mut x = bar.right() - 6.0;
for (label, cmd) in [("Cancel", "object.pattern.cancel"), ("Done", "object.pattern.done"), ("Save a Copy", "object.pattern.saveCopy")] {
let label = tl!(label);
let w = 12.0 + 7.0 * label.len() as f32;
let r = egui::Rect::from_min_max(egui::pos2(x - w, bar.top() + 3.0), egui::pos2(x, bar.bottom() - 3.0));
let resp = ui.interact(r, ui.id().with(("pat-bar", cmd)), Sense::click());
@@ -346,10 +350,10 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
ui.separator();
if ui.selectable_label(false, "Fit on Screen").clicked() {
if ui.selectable_label(false, tl!("Fit on Screen")).clicked() {
app.run("view.fitArtboard", json!({})).ok();
}
if ui.selectable_label(false, "Fit All").clicked() {
if ui.selectable_label(false, tl!("Fit All")).clicked() {
app.run("view.fitAll", json!({})).ok();
}
},
@@ -377,7 +381,13 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::icon_button(ui, icon, "", false, 18.0);
}
ui.separator();
let tool = vectorcraft_tools::tool_info(app.session.tool_id()).map(|t| t.label.trim_end_matches(" Tool")).unwrap_or("");
let tool = vectorcraft_tools::tool_info(app.session.tool_id())
.map(|t| {
// The catalog keys carry the full label ("Selection Tool"); only English drops the suffix.
let shown = tl!(t.label);
if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown }
})
.unwrap_or("");
ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
@@ -563,19 +573,51 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
/// The hint bar's segments for `tool`, with keys named as the menus name them (Alt and Ctrl on
/// Windows and Linux, ⌥ and ⌘ on macOS). Tools without a hint point to Search Commands.
/// Every hint-bar fragment (the action after the last `+` of a chord included), so the catalog tests
/// can insist a complete language covers them.
pub fn hint_strings() -> Vec<&'static str> {
let mut v = Vec::new();
for tool in vectorcraft_tools::catalog::all_tools() {
for &(text, bold) in hint_for(tool.id).unwrap_or(&[]) {
let text = if bold { text.rsplit_once('+').map_or(text, |(_, k)| k) } else { text };
if !text.is_empty() && !text.chars().all(|c| c.is_ascii_digit()) && !v.contains(&text) {
v.push(text);
}
}
}
v
}
fn hint_segments(tool: &str) -> Vec<(Cow<'static, str>, bool)> {
let search;
let segments = match hint_for(tool) {
Some(s) => s,
None => {
search = match menus::shortcut_of("help.commandPalette") {
Some(sc) => [("Press ", false), (sc, true), (" to search every command", false)],
None => [("Use ", false), ("Help › Search Commands…", true), (" to search every command", false)],
Some(sc) => [(tl!("Press "), false), (sc, true), (tl!(" to search every command"), false)],
None => [(tl!("Use "), false), (tl!("Help › Search Commands…"), true), (tl!(" to search every command"), false)],
};
&search
}
};
segments.iter().map(|&(text, bold)| (if bold { Cow::Owned(menus::pretty_shortcut(text)) } else { Cow::Borrowed(text) }, bold)).collect()
segments
.iter()
.map(|&(text, bold)| {
// Key names ("Click", "Esc") translate; in a chord ("Alt+Drag") the modifiers are named for
// the platform and only the action after the last `+` translates.
if !bold {
return (Cow::Borrowed(tl!(text)), bold);
}
match text.rsplit_once('+') {
Some((_, key)) if !key.is_empty() => {
let pretty = menus::pretty_shortcut(text);
let shown = pretty.strip_suffix(key).map_or(pretty.clone(), |m| format!("{m}{}", tl!(key)));
(Cow::Owned(shown), bold)
}
_ => (Cow::Owned(menus::pretty_shortcut(tl!(text))), bold),
}
})
.collect()
}
pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
+5 -5
View File
@@ -12,7 +12,7 @@ const DISCORD: Color32 = Color32::from_rgb(0x58, 0x65, 0xF2);
/// The Discord button's label, height and width.
fn discord_layout(ui: &Ui, large: bool) -> (std::sync::Arc<egui::Galley>, f32, f32) {
let (h, font, label) = if large { (36.0, theme::semibold(14.0), "Join our Discord") } else { (24.0, theme::semibold(12.0), "Discord") };
let (h, font, label) = if large { (36.0, theme::semibold(14.0), tl!("Join our Discord")) } else { (24.0, theme::semibold(12.0), "Discord") };
let galley = ui.painter().layout_no_wrap(label.to_string(), font, Color32::WHITE);
let w = galley.size().x + h * 0.55 + h * 0.9;
(galley, h, w)
@@ -33,7 +33,7 @@ pub fn discord_button(app: &mut VectorcraftApp, ui: &mut Ui, large: bool) -> egu
let ir = egui::Rect::from_center_size(r.left_center() + vec2(h * 0.35 + icon / 2.0, 0.0), vec2(icon, icon));
icons::paint(ui, "message-circle", ir, Color32::WHITE);
ui.painter().galley(egui::pos2(ir.right() + h * 0.2, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
let resp = resp.on_hover_text("Join the ArtCraft community on Discord (discord.gg/artcraft)");
let resp = resp.on_hover_text(tl!("Join the ArtCraft community on Discord (discord.gg/artcraft)"));
if resp.clicked() {
app.open_link("help.discord");
}
@@ -58,9 +58,9 @@ pub fn links(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space(8.0);
let l = app.session.execute("help.links", &serde_json::json!({})).unwrap_or_default();
let s = |k: &str| l[k].as_str().unwrap_or("").to_string();
link(app, ui, "globe", "ArtCraft website", "help.website", &s("website"));
link(app, ui, "external-link", "VectorCraft on getartcraft.com", "help.appPage", &s("appPage"));
link(app, ui, "git-branch", "Source code on GitHub", "help.github", &s("github"));
link(app, ui, "globe", tl!("ArtCraft website"), "help.website", &s("website"));
link(app, ui, "external-link", tl!("VectorCraft on getartcraft.com"), "help.appPage", &s("appPage"));
link(app, ui, "git-branch", tl!("Source code on GitHub"), "help.github", &s("github"));
}
#[cfg(test)]
+2
View File
@@ -5,6 +5,7 @@
//! - `engine.commands`: engine + UI commands with enablement
//! - `document.inspect`: document summary; `ui.inspect`: UI state
//! - `ui.menu.list`: flattened menu tree
//! - `ui.contextMenu.list`: the canvas context menu for the current selection, flattened
//! - `ui.tool.select {tool}`, `ui.tool.list`
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`:
//! drive the active tool through the same path as the mouse
@@ -117,6 +118,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
"document.inspect" => wrap(app.run("document.inspect", json!({}))),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()),
"ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()),
"ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
"ui.tool.list" => ok(serde_json::to_value(vectorcraft_tools::TOOL_GROUPS).unwrap_or_default()),
"ui.pointer" => {
+8 -5
View File
@@ -9,7 +9,7 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
return;
}
let mut open = true;
egui::Window::new("About VectorCraft")
egui::Window::new(tl!("About VectorCraft"))
.collapsible(false)
.resizable(false)
.open(&mut open)
@@ -22,16 +22,19 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
crate::brand::paint_mark(ui, r);
ui.vertical(|ui| {
ui.label(egui::RichText::new("VectorCraft").font(theme::semibold(22.0)));
ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION")));
ui.label(crate::i18n::fmt(
tl!("Version {version} — open-source vector illustration in pure Rust."),
&[("version", env!("CARGO_PKG_VERSION"))],
));
});
});
ui.add_space(12.0);
crate::community::links(app, ui);
ui.add_space(12.0);
ui.label(
egui::RichText::new(
"Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft.",
)
egui::RichText::new(tl!(
"Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft."
))
.size(11.0),
);
});
+4 -2
View File
@@ -4,14 +4,16 @@ use super::DialogSpec;
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "All Tools".into(), body, ok: None, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, ..DialogSpec::FORM };
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() {
if ui.button(tool.label.trim_end_matches(" Tool")).clicked() {
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;
}
@@ -7,7 +7,7 @@ use super::{DialogSpec, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Artboard Options".into(), confirm, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Artboard Options").into(), confirm, ..DialogSpec::FORM };
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = Value::Object(d.fields.clone());
+4 -4
View File
@@ -28,7 +28,7 @@ const LABEL_W: f32 = 82.0;
const SPACING: [(&str, &str); 3] = [("smooth", "Smooth Color"), ("steps", "Specified Steps"), ("distance", "Specified Distance")];
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Blend Options".into(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Blend Options").into(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM };
/// Open Blend Options on the selected blend's options (else those new blends start with).
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
@@ -63,7 +63,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let mode = d.str("spacing");
let shown = SPACING.iter().find(|(v, _)| *v == mode).map_or(SPACING[0].1, |(_, l)| l);
let labels = SPACING.map(|(_, l)| l);
widgets::label_row(ui, "Spacing:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Spacing:"), LABEL_W, |ui| {
if let Some((value, _)) = widgets::dropdown(ui, "blend-spacing", shown, &labels, 150.0).and_then(|i| SPACING.get(i)) {
d.fields.insert("spacing".into(), json!(value));
}
@@ -84,9 +84,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
});
ui.add_space(6.0);
widgets::label_row(ui, "Orientation:", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Orientation:"), LABEL_W, |ui| {
let path = d.str("orientation") == "path";
for (value, along, tip) in [("page", false, "Align to Page"), ("path", true, "Align to Path")] {
for (value, along, tip) in [("page", false, tl!("Align to Page")), ("path", true, tl!("Align to Path"))] {
if orientation_button(ui, along, path == along, tip) {
d.fields.insert("orientation".into(), json!(value));
}
+6 -6
View File
@@ -21,7 +21,7 @@ pub const KIND: &str = "colorBalance";
const CMD: &str = "edit.colors.adjustBalance";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Adjust Colors".into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Adjust Colors").into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM };
/// A colour mode: its `mode` id, label and channels as (field, label).
struct Mode {
@@ -83,28 +83,28 @@ fn track(key: &str, x: f32) -> egui::Color32 {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let m = mode(d);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Color Mode:");
widgets::dim_label(ui, tl!("Color Mode:"));
let labels: Vec<&str> = MODES.iter().map(|m| m.label).collect();
if let Some(i) = widgets::dropdown(ui, "balance-mode", m.label, &labels, 120.0) {
d.fields.insert("mode".into(), json!(MODES[i].id));
}
ui.add_space(12.0);
if widgets::check(ui, "Convert", d.bool("convert"), m.id != GLOBAL) {
if widgets::check(ui, tl!("Convert"), d.bool("convert"), m.id != GLOBAL) {
d.fields.insert("convert".into(), json!(!d.bool("convert")));
}
});
ui.add_space(8.0);
let m = mode(d);
if m.id == GLOBAL {
widgets::dim_label(ui, "Shifts the tints of global and spot colors; other colors stay.");
widgets::dim_label(ui, tl!("Shifts the tints of global and spot colors; other colors stay."));
}
for (k, label) in m.channels {
form::slider(ui, d, k, label, -100.0..=100.0, "%", &|x| track(k, x));
}
ui.add_space(8.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Adjust Options:");
for (k, label) in [("fill", "Fill"), ("stroke", "Stroke")] {
widgets::dim_label(ui, tl!("Adjust Options:"));
for (k, label) in [("fill", tl!("Fill")), ("stroke", tl!("Stroke"))] {
if widgets::check(ui, label, d.bool(k), true) {
d.fields.insert(k.into(), json!(!d.bool(k)));
}
@@ -17,7 +17,8 @@ use crate::theme::Tokens;
/// The dialog kind of Color Guide Options.
pub const KIND: &str = "colorGuideOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Color Guide Options".into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Color Guide Options").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
/// Open the dialog with the panel's current options.
pub fn open(app: &mut VectorcraftApp) {
@@ -38,14 +39,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let v = (60.0 + 160.0 * x) as u8;
egui::Color32::from_rgb(v, v, v)
};
form::slider(ui, d, "steps", "Steps:", 1.0..=max, "", &ramp);
form::slider(ui, d, "amount", "Variation:", 0.0..=100.0, "%", &ramp);
form::slider(ui, d, "steps", tl!("Steps:"), 1.0..=max, "", &ramp);
form::slider(ui, d, "amount", tl!("Variation:"), 0.0..=100.0, "%", &ramp);
ui.horizontal(|ui| {
ui.add_space(form::SLIDER_LABEL + ui.spacing().item_spacing.x);
let (r, _) = ui.allocate_exact_size(vec2(form::SLIDER_WIDTH, 14.0), Sense::hover());
let font = egui::FontId::proportional(11.0);
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, "Less", font.clone(), t.text_dim);
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, "More", font, t.text_dim);
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, tl!("Less"), font.clone(), t.text_dim);
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, tl!("More"), font, t.text_dim);
});
ui.add_space(10.0);
// Preview: the base colour's row of variations.
+6 -6
View File
@@ -24,7 +24,7 @@ use crate::{VectorcraftApp, widgets};
pub(super) const KIND: &str = "colorPicker";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Color Picker".into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Color Picker").into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM };
/// Side of the colour field (and height of the channel slider).
const FIELD: f32 = 240.0;
@@ -222,7 +222,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
toggle_swatches = widgets::flat_button(ui, if swatches { "Color Models" } else { "Color Swatches" }, 110.0).clicked();
toggle_swatches = widgets::flat_button(ui, if swatches { tl!("Color Models") } else { tl!("Color Swatches") }, 110.0).clicked();
});
});
ui.add_space(10.0);
@@ -232,7 +232,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
});
ui.add_space(8.0);
if widgets::check(ui, "Only Web Colors", web, true) {
if widgets::check(ui, tl!("Only Web Colors"), web, true) {
d.fields.insert("webOnly".into(), json!(!web));
}
if let Some(ch) = new_channel {
@@ -254,8 +254,8 @@ fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool {
widgets::paint_chip(ui, top, &Paint::solid(*new));
widgets::paint_chip(ui, bottom, &Paint::solid(*original));
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
ui.interact(top, ui.id().with("cp-new"), Sense::hover()).on_hover_text("New");
ui.interact(bottom, ui.id().with("cp-original"), Sense::click()).on_hover_text("Original: click to restore").clicked()
ui.interact(top, ui.id().with("cp-new"), Sense::hover()).on_hover_text(tl!("New"));
ui.interact(bottom, ui.id().with("cp-original"), Sense::click()).on_hover_text(tl!("Original: click to restore")).clicked()
}
/// The HSB and RGB fields with the channel radios, hex, Lab and CMYK. Returns the edited colour
@@ -334,7 +334,7 @@ fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option<Col
egui::ScrollArea::vertical().id_salt("cp-swatches").max_height(FIELD).show(ui, |ui| {
ui.set_width(FIELD + 24.0);
let Some(st) = app.session.active() else {
widgets::dim_label(ui, "Open a document to see its swatches.");
widgets::dim_label(ui, tl!("Open a document to see its swatches."));
return;
};
let doc = &st.doc;
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::VectorcraftApp;
use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| d.str("__label"),
heading: |d| tl!(&d.str("__label")).to_string(),
body: |app, ui, d| {
let lengths = lengths(&d.str("__command"));
form::param_fields(ui, d, &|k| lengths.contains(&k), app.session.general_unit());
+4 -2
View File
@@ -2,7 +2,8 @@
//! as deleting swatches.
//!
//! Fields: `message` (the question, shown as the heading), `detail` (an optional line under it),
//! `__command` and `__params` (the command OK runs).
//! `__command` and `__params` (the command OK runs). Both texts are shown as given: callers
//! translate their template before filling in a name, so the name is never translated.
use serde_json::{Value, json};
@@ -28,7 +29,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
..DialogSpec::FORM
};
/// Ask `message` (with an optional `detail` line); OK runs `command` with `params`.
/// Ask `message` (with an optional `detail` line), both in the UI language; OK runs `command`
/// with `params`.
pub fn ask(app: &mut VectorcraftApp, message: &str, detail: &str, command: &str, params: Value) {
let fields = json!({"message": message, "detail": detail, "__command": command, "__params": params});
app.ui.dialog = Some(Dialog::new(KIND, fields));
+24 -22
View File
@@ -21,7 +21,7 @@ const UI_ONLY: [&str; 2] = ["tab", "bleedLinked"];
pub(super) const LABEL: f32 = 130.0;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Document Setup".into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Document Setup").into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM };
/// Open Document Setup on the active document's settings.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
@@ -79,28 +79,28 @@ fn unit(d: &Dialog) -> Unit {
fn general(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let units: Vec<(&str, &str)> = Unit::ALL.iter().map(|u| (u.label(), u.label())).collect();
choice(ui, d, "units", "Units:", &units);
choice(ui, d, "units", tl!("Units:"), &units);
ui.add_space(6.0);
let unit = unit(d);
widgets::label_row(ui, "Bleed:", LABEL, |ui| form::bleed(ui, d, unit, 64.0));
widgets::label_row(ui, tl!("Bleed:"), LABEL, |ui| form::bleed(ui, d, unit, 64.0));
ui.add_space(6.0);
widgets::label_row(ui, "", LABEL, |ui| {
// Applies the settings, then edits the artboards with the Artboard tool.
if widgets::flat_button(ui, "Edit Artboards", 120.0).clicked() && confirm(app, d).is_ok() {
if widgets::flat_button(ui, tl!("Edit Artboards"), 120.0).clicked() && confirm(app, d).is_ok() {
app.select_tool("artboard");
}
});
ui.add_space(12.0);
check(ui, d, "outlineImages", "Show Images in Outline Mode");
check(ui, d, "highlightSubstitutedFonts", "Highlight Substituted Fonts");
check(ui, d, "highlightSubstitutedGlyphs", "Highlight Substituted Glyphs");
check(ui, d, "outlineImages", tl!("Show Images in Outline Mode"));
check(ui, d, "highlightSubstitutedFonts", tl!("Highlight Substituted Fonts"));
check(ui, d, "highlightSubstitutedGlyphs", tl!("Highlight Substituted Glyphs"));
}
fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
widgets::subheader(ui, "Transparency Grid");
widgets::subheader(ui, tl!("Transparency Grid"));
let sizes: Vec<(&str, &str)> = GridSize::ALL.iter().map(|g| (g.id(), g.label())).collect();
choice(ui, d, "gridSize", "Grid Size:", &sizes);
choice(ui, d, "gridSize", tl!("Grid Size:"), &sizes);
let colors: Vec<String> = d
.fields
.get("gridColors")
@@ -112,7 +112,7 @@ fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
.find(|(_, pair)| colors.len() == 2 && colors.iter().zip(pair).all(|(h, c)| h.eq_ignore_ascii_case(&c.to_hex())))
.map_or("Custom", |(n, _)| n);
let names: Vec<&str> = GRID_COLOR_PRESETS.iter().map(|(n, _)| *n).chain(["Custom"]).collect();
widgets::label_row(ui, "Grid Colors:", LABEL, |ui| {
widgets::label_row(ui, tl!("Grid Colors:"), LABEL, |ui| {
if let Some((_, pair)) = widgets::dropdown(ui, "gridColors", preset, &names, 220.0).and_then(|i| GRID_COLOR_PRESETS.get(i)) {
d.fields.insert("gridColors".into(), json!(pair.map(|c| c.to_hex())));
}
@@ -136,27 +136,29 @@ fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
});
ui.add_space(4.0);
check(ui, d, "simulatePaper", "Simulate Colored Paper");
check(ui, d, "simulatePaper", tl!("Simulate Colored Paper"));
ui.add_space(14.0);
widgets::subheader(ui, "Export and Clipboard Transparency Flattener Settings");
widgets::subheader(ui, tl!("Export and Clipboard Transparency Flattener Settings"));
let presets: Vec<String> = app.session.flattener_presets().into_iter().map(|p| p.name).collect();
let options: Vec<(&str, &str)> = presets.iter().map(|p| (p.as_str(), p.as_str())).collect();
choice(ui, d, "flattenerPreset", "Preset:", &options);
check(ui, d, "discardWhiteOverprint", "Discard White Overprint in Output");
choice(ui, d, "flattenerPreset", tl!("Preset:"), &options);
check(ui, d, "discardWhiteOverprint", tl!("Discard White Overprint in Output"));
}
fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let langs: Vec<(&str, &str)> = LANGUAGES.iter().map(|(l, _)| (*l, *l)).collect();
let before = d.str("language");
choice(ui, d, "language", "Language:", &langs);
choice(ui, d, "language", tl!("Language:"), &langs);
// A language brings its quotes.
if d.str("language") != before
&& let Some(q) = vectorcraft_doc::setup::language_quotes(&d.str("language"))
{
d.fields.insert("quotes".into(), json!({"double": q.double.iter().collect::<String>(), "single": q.single.iter().collect::<String>()}));
}
for (key, label, choices) in [("double", "Double Quotes:", Quotes::DOUBLE_CHOICES), ("single", "Single Quotes:", Quotes::SINGLE_CHOICES)] {
for (key, label, choices) in
[("double", tl!("Double Quotes:"), Quotes::DOUBLE_CHOICES), ("single", tl!("Single Quotes:"), Quotes::SINGLE_CHOICES)]
{
let pairs: Vec<String> = choices.iter().map(|p| p.iter().collect()).collect();
let labels: Vec<&str> = pairs.iter().map(String::as_str).collect();
let cur = d.fields.get("quotes").and_then(|q| q.get(key)).and_then(Value::as_str).unwrap_or("").to_string();
@@ -168,16 +170,16 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
}
});
}
check(ui, d, "typographersQuotes", "Use Typographer's Quotes");
check(ui, d, "typographersQuotes", tl!("Use Typographer's Quotes"));
ui.add_space(14.0);
widgets::subheader(ui, "Options");
widgets::subheader(ui, tl!("Options"));
egui::Grid::new("docsetup-scripts").num_columns(3).spacing([16.0, 6.0]).show(ui, |ui| {
ui.label("");
for h in ["Size", "Position"] {
for h in [tl!("Size"), tl!("Position")] {
ui.label(egui::RichText::new(h).size(11.0).color(t.text_dim));
}
ui.end_row();
for (key, label) in [("superscript", "Superscript:"), ("subscript", "Subscript:")] {
for (key, label) in [("superscript", tl!("Superscript:")), ("subscript", tl!("Subscript:"))] {
ui.label(egui::RichText::new(label).color(t.text));
for part in ["size", "position"] {
let v = d.fields.get(key).and_then(|m| m.get(part)).and_then(Value::as_f64).unwrap_or(0.0);
@@ -189,14 +191,14 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) {
}
ui.end_row();
}
ui.label(egui::RichText::new("Small Caps:").color(t.text));
ui.label(egui::RichText::new(tl!("Small Caps:")).color(t.text));
if let Some(n) = widgets::plain_field(ui, "smallCaps", d.f64("smallCapsSize", 70.0), "%", 1, 70.0) {
d.fields.insert("smallCapsSize".into(), json!(n));
}
ui.end_row();
});
ui.add_space(6.0);
choice(ui, d, "exportText", "Export:", &[("editable", "Preserve Text Editability"), ("appearance", "Preserve Text Appearance")]);
choice(ui, d, "exportText", tl!("Export:"), &[("editable", tl!("Preserve Text Editability")), ("appearance", tl!("Preserve Text Appearance"))]);
}
#[cfg(test)]
+8 -8
View File
@@ -22,7 +22,7 @@ use crate::{VectorcraftApp, io, widgets};
pub const KIND: &str = "dxfImport";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| if d.str("mode") == "place" { "Place DXF" } else { "DXF Import Options" }.into(),
heading: |d| if d.str("mode") == "place" { tl!("Place DXF") } else { tl!("DXF Import Options") }.into(),
body,
confirm,
min_width: 560.0,
@@ -141,19 +141,19 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let layouts: Vec<String> =
d.fields.get("layouts").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
let rows: Vec<(&str, &str)> = layouts.iter().map(|l| (l.as_str(), l.as_str())).collect();
choice(ui, d, "layout", "Layout:", &rows);
choice(ui, d, "layout", tl!("Layout:"), &rows);
ui.add_space(10.0);
widgets::subheader(ui, "Scaling");
check(ui, d, "fit", "Scale to Fit Artboard");
widgets::subheader(ui, tl!("Scaling"));
check(ui, d, "fit", tl!("Scale to Fit Artboard"));
ui.add_enabled_ui(!d.bool("fit"), |ui| super::dxf_options::scale_row(ui, d));
ui.horizontal(|ui| {
ui.add_space(LABEL + 8.0);
check(ui, d, "scaleLineweights", "Scale Lineweights");
check(ui, d, "scaleLineweights", tl!("Scale Lineweights"));
});
ui.add_space(10.0);
widgets::subheader(ui, "Options");
check(ui, d, "center", "Center Artwork");
check(ui, d, "mergeLayers", "Merge Layers");
widgets::subheader(ui, tl!("Options"));
check(ui, d, "center", tl!("Center Artwork"));
check(ui, d, "mergeLayers", tl!("Merge Layers"));
});
});
false
+22 -15
View File
@@ -18,8 +18,15 @@ use crate::{VectorcraftApp, io, widgets};
pub(super) const KIND: &str = "dxfOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "DXF Options".into(), body, confirm, ok: Some("Export"), min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("DXF Options").into(),
body,
confirm,
ok: Some("Export"),
min_width: 420.0,
max_width: Some(460.0),
..DialogSpec::FORM
};
/// Fields that are not remembered between exports (they belong to one export).
const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"];
@@ -59,24 +66,24 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let versions: Vec<(&str, &str)> = DxfVersion::ALL.iter().map(|v| (v.id(), v.label())).collect();
choice(ui, d, "version", "Version:", &versions);
choice(ui, d, "version", tl!("Version:"), &versions);
ui.add_space(4.0);
scale_row(ui, d);
ui.horizontal(|ui| {
ui.add_space(LABEL + 8.0);
check(ui, d, "scaleLineweights", "Scale Lineweights");
check(ui, d, "scaleLineweights", tl!("Scale Lineweights"));
});
ui.add_space(4.0);
let depths: Vec<(&str, &str)> = ColorDepth::ALL.iter().map(|c| (c.id(), c.label())).collect();
choice(ui, d, "colors", "Number of Colors:", &depths);
choice(ui, d, "colors", tl!("Number of Colors:"), &depths);
let version = DxfVersion::from_id(&d.str("version")).unwrap_or_default();
if ColorDepth::from_id(&d.str("colors")) == Some(ColorDepth::True) && version < DxfVersion::R2004 {
note(ui, "True colour needs version 2004 or later: the nearest of 256 colours is written.");
note(ui, tl!("True colour needs version 2004 or later: the nearest of 256 colours is written."));
}
let rasters: Vec<(&str, &str)> = RasterFormat::ALL.iter().map(|r| (r.id(), r.label())).collect();
choice(ui, d, "rasterFormat", "Raster File Format:", &rasters);
choice(ui, d, "rasterFormat", tl!("Raster File Format:"), &rasters);
ui.add_space(10.0);
widgets::subheader(ui, "Export Option");
widgets::subheader(ui, tl!("Export Option"));
let preserve = Preserve::from_id(&d.str("preserve")).unwrap_or_default();
ui.horizontal(|ui| {
for p in Preserve::ALL {
@@ -88,21 +95,21 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
ui.add_space(4.0);
let selection = app.session.active().is_some_and(|st| !st.selection.is_empty());
if widgets::check(ui, "Export Selected Art Only", d.bool("selectedOnly") && selection, selection) {
if widgets::check(ui, tl!("Export Selected Art Only"), d.bool("selectedOnly") && selection, selection) {
d.fields.insert("selectedOnly".into(), json!(!d.bool("selectedOnly")));
}
check(ui, d, "alterPaths", "Alter Paths for Appearance");
check(ui, d, "alterPaths", tl!("Alter Paths for Appearance"));
// Preserve Appearance always outlines type.
let appearance = preserve == Preserve::Appearance;
if widgets::check(ui, "Outline Text", appearance || d.bool("outlineText"), !appearance) {
if widgets::check(ui, tl!("Outline Text"), appearance || d.bool("outlineText"), !appearance) {
d.fields.insert("outlineText".into(), json!(!d.bool("outlineText")));
}
ui.add_space(8.0);
super::form::caption(
ui,
match preserve {
Preserve::Appearance => "Type becomes outlines, and strokes a CAD line can't draw become filled shapes.",
Preserve::Editability => "Type stays text, and every stroke is a line with its lineweight and dashes.",
Preserve::Appearance => tl!("Type becomes outlines, and strokes a CAD line can't draw become filled shapes."),
Preserve::Editability => tl!("Type stays text, and every stroke is a line with its lineweight and dashes."),
},
);
false
@@ -113,7 +120,7 @@ pub(super) fn scale_row(ui: &mut egui::Ui, d: &mut Dialog) {
let t = crate::theme::Tokens::get(ui.ctx());
let units: Vec<&str> = Unit::ALL.iter().filter(|u| **u != Unit::FeetInches).map(|u| u.label()).collect();
let unit = Unit::named(&d.str("unit")).unwrap_or(Unit::Millimeters);
widgets::label_row(ui, "Scale:", LABEL, |ui| {
widgets::label_row(ui, tl!("Scale:"), LABEL, |ui| {
ui.label(egui::RichText::new("1").color(t.text));
if let Some(u) = widgets::dropdown(ui, "dxf-unit", unit.label(), &units, 120.0).and_then(|i| units.get(i)) {
d.fields.insert("unit".into(), json!(u));
@@ -124,7 +131,7 @@ pub(super) fn scale_row(ui: &mut egui::Ui, d: &mut Dialog) {
let (lo, hi) = (*vectorcraft_cad::SCALE_RANGE.start(), *vectorcraft_cad::SCALE_RANGE.end());
d.fields.insert("scale".into(), json!(v.clamp(lo, hi)));
}
ui.label(egui::RichText::new("Units").color(t.text));
ui.label(egui::RichText::new(tl!("Units")).color(t.text));
});
}
+27 -8
View File
@@ -17,7 +17,19 @@ use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| heading_label(d), body, confirm, preview: true, ..DialogSpec::FORM };
/// The effect's name (`__label`, English) in the UI language: the catalog may carry it with or
/// without its trailing ellipsis. A plug-in effect's name is shown as it is.
fn heading_label(d: &Dialog) -> String {
let label = d.str("__label");
if vectorcraft_plugins::effect::plugin_id(&d.str("__effect")).is_some() {
return label;
}
let dotted = format!("{label}…");
let shown = tl!(&dotted);
if shown != dotted { shown.trim_end_matches('…').to_string() } else { tl!(&label).to_string() }
}
/// The dialog kind asking whether to edit an effect that is already applied or add another.
/// Fields: `__effect`, `__label`, `__item?` (as given to `effect.dialog`), `__target` and
@@ -26,9 +38,9 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"),
pub const EXISTS: &str = "effectExists";
pub(super) const EXISTS_SPEC: DialogSpec = DialogSpec {
heading: |d| format!("{} is already applied", d.str("__label")),
heading: |d| crate::i18n::fmt(tl!("{effect} is already applied"), &[("effect", &heading_label(d))]),
body: |_, ui, _| {
ui.label(egui::RichText::new("Edit the applied effect, or add another one?").color(Tokens::get(ui.ctx()).text_dim));
ui.label(egui::RichText::new(tl!("Edit the applied effect, or add another one?")).color(Tokens::get(ui.ctx()).text_dim));
false
},
confirm: confirm_exists,
@@ -123,7 +135,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
};
ui.add_space(6.0);
let mut pv = d.bool("preview");
let pv_changed = ui.checkbox(&mut pv, "Preview").changed();
let pv_changed = ui.checkbox(&mut pv, tl!("Preview")).changed();
d.fields.insert("preview".into(), json!(pv));
if pv && (changed || pv_changed || !app.session.in_interaction()) {
let label = d.str("__label");
@@ -189,7 +201,14 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::slider_w(ui, d, (key, label, LABEL_W), *min..=*max, "", rail);
}
if d.fields.contains_key("channel") {
form::choice(ui, d, "channel", "Channel", (LABEL_W, 120.0), &[("rgb", "RGB"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")]);
form::choice(
ui,
d,
"channel",
tl!("Channel"),
(LABEL_W, 120.0),
&[("rgb", "RGB"), ("red", tl!("Red")), ("green", tl!("Green")), ("blue", tl!("Blue"))],
);
}
if d.fields.contains_key("points") {
ui.horizontal(|ui| {
@@ -197,7 +216,7 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
curve_graph(ui, d);
});
}
for (key, label, width) in [("points", "Points", 220.0), ("color", "Color", 120.0)] {
for (key, label, width) in [("points", tl!("Points"), 220.0), ("color", tl!("Color"), 120.0)] {
if d.fields.contains_key(key) {
widgets::label_row(ui, label, LABEL_W, |ui| {
form::text(ui, d, key, width);
@@ -205,14 +224,14 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
}
if d.fields.contains_key("gamma") {
widgets::label_row(ui, "Gamma", LABEL_W, |ui| {
widgets::label_row(ui, tl!("Gamma"), LABEL_W, |ui| {
let mut g = d.f64("gamma", 1.0);
if ui.add(egui::DragValue::new(&mut g).speed(0.01).range(0.1..=10.0).max_decimals(2)).changed() {
d.fields.insert("gamma".into(), json!(g));
}
});
}
for (key, label) in [("colorize", "Colorize"), ("preserveLightness", "Preserve Lightness")] {
for (key, label) in [("colorize", tl!("Colorize")), ("preserveLightness", tl!("Preserve Lightness"))] {
if d.fields.contains_key(key) {
form::check(ui, d, key, label);
}
+27 -27
View File
@@ -44,10 +44,10 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, preview
fn heading(d: &Dialog) -> String {
match d.kind.as_str() {
WARP => "Warp Options",
MESH if d.bool("reset") => "Reset Envelope Mesh",
MESH => "Envelope Mesh",
_ => "Envelope Options",
WARP => tl!("Warp Options"),
MESH if d.bool("reset") => tl!("Reset Envelope Mesh"),
MESH => tl!("Envelope Mesh"),
_ => tl!("Envelope Options"),
}
.into()
}
@@ -143,44 +143,44 @@ fn count(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
fn warp_body(ui: &mut egui::Ui, d: &mut Dialog) {
let styles: Vec<(&str, &str)> = WARP_STYLES.iter().map(|(id, label)| (*id, label.trim_end_matches('…'))).collect();
form::choice(ui, d, "style", "Style:", (LABEL_W, 160.0), &styles);
form::choice(ui, d, "style", tl!("Style:"), (LABEL_W, 160.0), &styles);
let horizontal = d.bool("horizontal");
widgets::label_row(ui, "", LABEL_W, |ui| {
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
for (h, label) in [(true, tl!("Horizontal")), (false, tl!("Vertical"))] {
if widgets::radio(ui, label, horizontal == h, true) {
set(d, "horizontal", json!(h));
}
ui.add_space(10.0);
}
});
percent(ui, d, "bend", "Bend:");
percent(ui, d, "bend", tl!("Bend:"));
ui.add_space(6.0);
widgets::subheader(ui, "Distortion");
percent(ui, d, "h", "Horizontal:");
percent(ui, d, "v", "Vertical:");
widgets::subheader(ui, tl!("Distortion"));
percent(ui, d, "h", tl!("Horizontal:"));
percent(ui, d, "v", tl!("Vertical:"));
}
fn mesh_body(ui: &mut egui::Ui, d: &mut Dialog) {
count(ui, d, "rows", "Rows:");
count(ui, d, "cols", "Columns:");
count(ui, d, "rows", tl!("Rows:"));
count(ui, d, "cols", tl!("Columns:"));
if d.bool("reset") {
ui.add_space(4.0);
form::check(ui, d, "maintainShape", "Maintain Envelope Shape");
form::check(ui, d, "maintainShape", tl!("Maintain Envelope Shape"));
}
}
fn options_body(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::subheader(ui, "Rasters");
widgets::subheader(ui, tl!("Rasters"));
ui.horizontal(|ui| {
ui.add_space(12.0);
form::check(ui, d, "antiAlias", "Anti-Alias");
form::check(ui, d, "antiAlias", tl!("Anti-Alias"));
});
ui.add_space(4.0);
widgets::subheader(ui, "Preserve Shape Using:");
widgets::subheader(ui, tl!("Preserve Shape Using:"));
let clip = d.str("preserveShape") != "transparency";
ui.horizontal(|ui| {
ui.add_space(12.0);
for (value, label) in [("clippingMask", "Clipping Mask"), ("transparency", "Transparency")] {
for (value, label) in [("clippingMask", tl!("Clipping Mask")), ("transparency", tl!("Transparency"))] {
if widgets::radio(ui, label, clip == (value == "clippingMask"), true) {
set(d, "preserveShape", json!(value));
}
@@ -189,12 +189,12 @@ fn options_body(ui: &mut egui::Ui, d: &mut Dialog) {
});
ui.add_space(4.0);
let rail = Tokens::get(ui.ctx()).input_border;
form::slider_w(ui, d, ("fidelity", "Fidelity:", LABEL_W), 0.0..=100.0, "", &move |_| rail);
form::slider_w(ui, d, ("fidelity", tl!("Fidelity:"), LABEL_W), 0.0..=100.0, "", &move |_| rail);
ui.add_space(4.0);
form::check(ui, d, "distortAppearance", "Distort Appearance");
form::check(ui, d, "distortAppearance", tl!("Distort Appearance"));
// Gradients and patterns bend only with the appearance.
let appearance = d.bool("distortAppearance");
for (key, label) in [("distortLinearGradients", "Distort Linear Gradients"), ("distortPatternFills", "Distort Pattern Fills")] {
for (key, label) in [("distortLinearGradients", tl!("Distort Linear Gradients")), ("distortPatternFills", tl!("Distort Pattern Fills"))] {
let on = d.bool(key);
ui.horizontal(|ui| {
ui.add_space(20.0);
@@ -256,7 +256,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let Some((editing, bar)) = bar_of(app) else { return };
let mut run: Option<(&str, Value)> = None;
ui.separator();
for (contents, icon, tip) in [(false, "dc-mesh", "Edit Envelope"), (true, "shapes", "Edit Contents")] {
for (contents, icon, tip) in [(false, "dc-mesh", tl!("Edit Envelope")), (true, "shapes", tl!("Edit Contents"))] {
if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents {
run = Some(("object.envelope.editContents", json!({ "editing": contents })));
}
@@ -270,23 +270,23 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
Bar::Warp { style, bend, h, v, horizontal } => {
let styles: Vec<&str> = WARP_STYLES.iter().map(|(_, l)| l.trim_end_matches('…')).collect();
let current = WARP_STYLES.iter().find(|(id, _)| id == style).map_or(style.as_str(), |(_, l)| l.trim_end_matches('…'));
widgets::dim_label(ui, "Style:");
widgets::dim_label(ui, tl!("Style:"));
if let Some((id, _)) = widgets::dropdown(ui, "cb-envelope-style", current, &styles, 110.0).and_then(|i| WARP_STYLES.get(i)) {
run = Some((OPTIONS_CMD, json!({ "style": id })));
}
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
for (h, label) in [(true, tl!("Horizontal")), (false, tl!("Vertical"))] {
if widgets::radio(ui, label, *horizontal == h, true) {
run = Some((OPTIONS_CMD, json!({ "horizontal": h })));
}
}
for (key, label, value) in [("bend", "Bend:", *bend), ("h", "H:", *h), ("v", "V:", *v)] {
for (key, label, value) in [("bend", tl!("Bend:"), *bend), ("h", tl!("H:"), *h), ("v", tl!("V:"), *v)] {
if let Some(n) = field(ui, key, label, value, "%") {
run = Some((OPTIONS_CMD, json!({ key: n.round().clamp(-100.0, 100.0) })));
}
}
}
Bar::Mesh { rows, cols } => {
for (key, label, value) in [("rows", "Rows:", *rows), ("cols", "Columns:", *cols)] {
for (key, label, value) in [("rows", tl!("Rows:"), *rows), ("cols", tl!("Columns:"), *cols)] {
if let Some(n) = field(ui, key, label, f64::from(value), "") {
run = Some((RESET_MESH, json!({ key: n.round().clamp(1.0, 50.0), "maintainShape": true })));
}
@@ -301,11 +301,11 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
Bar::TopObject => None,
};
if let Some(r) = reset
&& widgets::flat_button(ui, "Reset", 50.0).on_hover_text("Reset Envelope Shape").clicked()
&& widgets::flat_button(ui, tl!("Reset"), 50.0).on_hover_text(tl!("Reset Envelope Shape")).clicked()
{
run = Some(r);
}
if widgets::icon_button(ui, "dc-options", "Envelope Options", false, 24.0).clicked() {
if widgets::icon_button(ui, "dc-options", tl!("Envelope Options"), false, 24.0).clicked() {
crate::menus::invoke(app, OPTIONS_CMD, json!({}));
}
if let Some((cmd, p)) = run
+25 -18
View File
@@ -18,8 +18,15 @@ use crate::{VectorcraftApp, io, widgets};
pub(super) const KIND: &str = "epsOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "EPS Options".into(), body, confirm, ok: Some("Export"), min_width: 420.0, max_width: Some(460.0), ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("EPS Options").into(),
body,
confirm,
ok: Some("Export"),
min_width: 420.0,
max_width: Some(460.0),
..DialogSpec::FORM
};
/// Fields that are not remembered between exports (they belong to one export).
const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"];
@@ -62,15 +69,15 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) {
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
widgets::subheader(ui, "Preview");
widgets::subheader(ui, tl!("Preview"));
let previews: Vec<(&str, &str)> = Preview::ALL.iter().map(|p| (p.id(), p.label())).collect();
choice(ui, d, "previewFormat", "Format:", &previews);
choice(ui, d, "previewFormat", tl!("Format:"), &previews);
// Only the colour preview can keep transparency.
let color = Preview::from_id(&d.str("previewFormat")) == Some(Preview::TiffColor);
let transparent = d.bool("transparentPreview");
ui.horizontal(|ui| {
ui.add_space(LABEL + 8.0);
for (label, on) in [("Transparent", true), ("Opaque", false)] {
for (label, on) in [(tl!("Transparent"), true), (tl!("Opaque"), false)] {
if widgets::radio(ui, label, color && transparent == on, color) {
d.fields.insert("transparentPreview".into(), json!(on));
}
@@ -78,22 +85,22 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
});
ui.add_space(8.0);
widgets::subheader(ui, "Transparency");
widgets::subheader(ui, tl!("Transparency"));
let overprints: Vec<(&str, &str)> = Overprint::ALL.iter().map(|o| (o.id(), o.label())).collect();
choice(ui, d, "overprints", "Overprints:", &overprints);
choice(ui, d, "overprints", tl!("Overprints:"), &overprints);
let presets = app.session.flattener_presets();
let names: Vec<(&str, &str)> = presets.iter().map(|p| (p.name.as_str(), p.name.as_str())).collect();
choice(ui, d, "flattenerPreset", "Preset:", &names);
choice(ui, d, "flattenerPreset", tl!("Preset:"), &names);
ui.add_space(8.0);
widgets::subheader(ui, "Fonts");
widgets::subheader(ui, tl!("Fonts"));
// Type is written as glyph outlines, which need no fonts.
widgets::check(ui, "Embed Fonts (for other applications)", true, false);
widgets::check(ui, tl!("Embed Fonts (for other applications)"), true, false);
ui.add_space(8.0);
widgets::subheader(ui, "Options");
check(ui, d, "includeLinkedFiles", "Include Linked Files");
check(ui, d, "thumbnails", "Include Document Thumbnails");
check(ui, d, "cmykPostScript", "Include CMYK PostScript in RGB Files");
check(ui, d, "compatibleGradients", "Compatible Gradient and Gradient Mesh Printing");
widgets::subheader(ui, tl!("Options"));
check(ui, d, "includeLinkedFiles", tl!("Include Linked Files"));
check(ui, d, "thumbnails", tl!("Include Document Thumbnails"));
check(ui, d, "cmykPostScript", tl!("Include CMYK PostScript in RGB Files"));
check(ui, d, "compatibleGradients", tl!("Compatible Gradient and Gradient Mesh Printing"));
ui.add_space(4.0);
let levels: Vec<(&str, &str)> = Level::ALL.iter().map(|l| (l.id(), l.label())).collect();
// A level set as a number (`level: 2`) reads as its id.
@@ -101,14 +108,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let id = n.to_string();
d.fields.insert("level".into(), json!(id));
}
choice(ui, d, "level", "PostScript:", &levels);
choice(ui, d, "level", tl!("PostScript:"), &levels);
ui.add_space(8.0);
super::form::caption(
ui,
if Level::from_id(&d.str("level")) == Some(Level::Two) || d.bool("compatibleGradients") {
"Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour."
tl!("Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour.")
} else {
"Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth."
tl!("Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth.")
},
);
false
+6 -6
View File
@@ -21,7 +21,7 @@ const CMD: &str = "object.expand";
/// The options and their checkbox labels.
const OPTIONS: [(&str, &str); 3] = [("object", "Object"), ("fill", "Fill"), ("stroke", "Stroke")];
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Expand".into(), body, confirm, min_width: 260.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Expand").into(), body, confirm, min_width: 260.0, ..DialogSpec::FORM };
/// Open Expand for the selection, every option on (as `object.expand {}` does).
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
@@ -38,7 +38,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let set = |d: &mut Dialog, key: &str, v: Value| {
d.fields.insert(key.into(), v);
};
widgets::subheader(ui, "Expand");
widgets::subheader(ui, tl!("Expand"));
for (key, label) in OPTIONS {
let on = d.bool(key);
ui.horizontal(|ui| {
@@ -49,26 +49,26 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
}
ui.add_space(10.0);
widgets::subheader(ui, "Expand Gradient To");
widgets::subheader(ui, tl!("Expand Gradient To"));
// Only gradient fills that Fill expands care.
let enabled = d.bool("__fill") && d.bool("fill");
let objects = d.str("gradient") != "mesh";
ui.horizontal(|ui| {
ui.add_space(12.0);
if widgets::radio(ui, "Gradient Mesh", !objects, enabled) {
if widgets::radio(ui, tl!("Gradient Mesh"), !objects, enabled) {
set(d, "gradient", json!("mesh"));
}
});
ui.horizontal(|ui| {
ui.add_space(12.0);
if widgets::radio(ui, "Specify:", objects, enabled) {
if widgets::radio(ui, tl!("Specify:"), objects, enabled) {
set(d, "gradient", json!("objects"));
}
ui.add_enabled_ui(enabled && objects, |ui| {
if let Some(n) = widgets::plain_field(ui, "expand-steps", d.f64("steps", DEFAULT_STEPS as f64), "", 0, 52.0) {
set(d, "steps", json!(n.round().clamp(1.0, MAX_STEPS as f64)));
}
widgets::dim_label(ui, "Objects");
widgets::dim_label(ui, tl!("Objects"));
});
});
false
+7 -7
View File
@@ -15,7 +15,7 @@ use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Export As".into(), body, confirm, ok: Some("Export…"), min_width: 380.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Export As").into(), body, confirm, ok: Some("Export…"), min_width: 380.0, ..DialogSpec::FORM };
/// The formats Export As writes (the native format is Save's).
static FORMATS: LazyLock<Vec<&'static Format>> = LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && f.id != "vectorcraft").collect());
@@ -63,7 +63,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let Some(f) = chosen(d) else { return false };
let missing = unavailable(d);
egui::Grid::new("export-as").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
ui.label(egui::RichText::new("Format:").color(t.text_dim));
ui.label(egui::RichText::new(tl!("Format:")).color(t.text_dim));
let shown = missing.map_or(f.label, |(label, _)| label);
if let Some(g) = widgets::dropdown_with(ui, "export-as-format", shown, &LABELS, 160.0, |i| i < FORMATS.len()).and_then(|i| FORMATS.get(i)) {
d.fields.insert("format".into(), json!(g.id));
@@ -75,13 +75,13 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::caption(ui, hint);
}
ui.add_space(10.0);
form::check(ui, d, "useArtboards", "Use Artboards");
form::check(ui, d, "useArtboards", tl!("Use Artboards"));
let use_artboards = d.bool("useArtboards");
ui.add_enabled_ui(use_artboards, |ui| {
ui.horizontal(|ui| {
ui.add_space(22.0);
let mut all = d.bool("all");
if ui.radio_value(&mut all, true, "All").changed() | ui.radio_value(&mut all, false, "Range:").changed() {
if ui.radio_value(&mut all, true, tl!("All")).changed() | ui.radio_value(&mut all, false, tl!("Range:")).changed() {
d.fields.insert("all".into(), json!(all));
}
let mut range = d.str("range");
@@ -93,9 +93,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(8.0);
let ext = f.extensions[0];
let hint = match (use_artboards, f.id) {
(true, "pdf") => "One page per artboard.".to_string(),
(true, _) => format!("One file per artboard: <name>-<artboard>.{ext}"),
(false, _) => "The bounds of the visible art.".to_string(),
(true, "pdf") => tl!("One page per artboard.").to_string(),
(true, _) => crate::i18n::fmt(tl!("One file per artboard: <name>-<artboard>.{ext}"), &[("ext", ext)]),
(false, _) => tl!("The bounds of the visible art.").to_string(),
};
ui.label(egui::RichText::new(hint).color(t.text_dim).size(11.5));
false
@@ -22,7 +22,7 @@ use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "Export for Screens".into(),
heading: |_| tl!("Export for Screens").into(),
body,
confirm,
ok: Some("Export Artboard"),
@@ -256,7 +256,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let tab = usize::from(d.str("tab") == "assets");
if let Some(i) = widgets::tab_bar(ui, &["Artboards", "Assets"], tab) {
if let Some(i) = widgets::tab_bar(ui, &[tl!("Artboards"), tl!("Assets")], tab) {
d.fields.insert("tab".into(), json!(if i == 1 { "assets" } else { "artboards" }));
}
ui.add_space(8.0);
@@ -282,7 +282,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
formats(ui, d);
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Prefix:");
widgets::dim_label(ui, tl!("Prefix:"));
form::text(ui, d, "prefix", 140.0);
});
}
@@ -303,13 +303,13 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
boards.resize(n, true);
let before = boards.clone();
let select = d.str("select");
widgets::dim_label(ui, "Select:");
widgets::dim_label(ui, tl!("Select:"));
ui.horizontal(|ui| {
if widgets::radio(ui, "All", select == "all", true) {
if widgets::radio(ui, tl!("All"), select == "all", true) {
set_select(d, "all");
boards.iter_mut().for_each(|b| *b = true);
}
if widgets::radio(ui, "Range:", select == "range", true) {
if widgets::radio(ui, tl!("Range:"), select == "range", true) {
set_select(d, "range");
}
ui.add_enabled_ui(select == "range", |ui| {
@@ -320,20 +320,20 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
});
});
if widgets::radio(ui, "Full Document", select == "full", true) {
if widgets::radio(ui, tl!("Full Document"), select == "full", true) {
set_select(d, "full");
}
ui.add_space(4.0);
form::check(ui, d, "includeBleed", "Include Bleed");
form::check(ui, d, "includeBleed", tl!("Include Bleed"));
ui.add_space(6.0);
let typed = boards != before;
let checked = boards.clone();
ui.add_enabled_ui(d.str("select") != "full", |ui| {
ui.horizontal(|ui| {
if ui.small_button("Select All").clicked() {
if ui.small_button(tl!("Select All")).clicked() {
boards.iter_mut().for_each(|b| *b = true);
}
if ui.small_button("Clear").clicked() {
if ui.small_button(tl!("Clear")).clicked() {
boards.iter_mut().for_each(|b| *b = false);
}
});
@@ -381,16 +381,16 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let Some(st) = app.session.active() else { return };
if st.doc.assets.is_empty() {
ui.add_space(24.0);
widgets::dim_label(ui, "No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel.");
widgets::dim_label(ui, tl!("No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel."));
return;
}
let mut on = checked_assets(app, d);
let before = on.clone();
ui.horizontal(|ui| {
if ui.small_button("Select All").clicked() {
if ui.small_button(tl!("Select All")).clicked() {
on = st.doc.assets.iter().map(|a| a.id).collect();
}
if ui.small_button("Clear").clicked() {
if ui.small_button(tl!("Clear")).clicked() {
on.clear();
}
});
@@ -417,23 +417,23 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
/// Export to: the folder (typed or picked), Open Location after Export and Create Sub-folders; the
/// web downloads instead.
fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
widgets::dim_label(ui, "Export to:");
widgets::dim_label(ui, tl!("Export to:"));
if io::is_web(app) {
widgets::dim_label(ui, "Your browser downloads the files (several as one .zip).");
widgets::dim_label(ui, tl!("Your browser downloads the files (several as one .zip)."));
} else {
let pick = app.services.pick_folder.is_some();
ui.horizontal(|ui| {
form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 });
if pick
&& widgets::icon_button(ui, "folder-open", "Choose a folder", false, 26.0).clicked()
&& widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked()
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|pick| pick())
{
d.fields.insert("folder".into(), json!(f));
}
});
form::check(ui, d, "openLocation", "Open Location after Export");
form::check(ui, d, "openLocation", tl!("Open Location after Export"));
}
form::check(ui, d, "subfolders", "Create Sub-folders");
form::check(ui, d, "subfolders", tl!("Create Sub-folders"));
}
/// The preset, the Format Settings gear (which sets `__settings`), the format rows (scale, suffix,
@@ -442,9 +442,9 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
let mut rows: Vec<Value> = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default();
let mut edited = false;
ui.horizontal(|ui| {
widgets::dim_label(ui, "Formats:");
widgets::dim_label(ui, tl!("Formats:"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::icon_button(ui, "settings", "Format Settings", false, 24.0).clicked() {
if widgets::icon_button(ui, "settings", tl!("Format Settings"), false, 24.0).clicked() {
let first = rows.first().and_then(|r| r["format"].as_str()).filter(|f| SETTINGS.iter().any(|(id, _)| id == f));
d.fields.insert("__settings".into(), json!(first.unwrap_or("png")));
}
@@ -469,7 +469,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
let k = ((ui.available_width() - 66.0) / 262.0).clamp(0.6, 1.0);
let (scale_w, suffix_w, format_w) = (96.0 * k, 70.0 * k, 96.0 * k);
egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
for head in ["Scale", "Suffix", "Format", ""] {
for head in [tl!("Scale"), tl!("Suffix"), tl!("Format"), ""] {
widgets::dim_label(ui, head);
}
ui.end_row();
@@ -507,7 +507,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
edited = true;
}
ui.add_enabled_ui(!one, |ui| {
if widgets::icon_button(ui, "x", "Remove", false, 22.0).clicked() {
if widgets::icon_button(ui, "x", tl!("Remove"), false, 22.0).clicked() {
remove = Some(i);
}
});
@@ -518,7 +518,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
rows.remove(i);
edited = true;
}
if widgets::secondary_button(ui, "+ Add Scale").clicked() {
if widgets::secondary_button(ui, tl!("+ Add Scale")).clicked() {
// One more than the largest factor so far.
let largest = rows
.iter()
@@ -541,7 +541,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) {
/// Format Settings: the options of format `id` that apply to every row of it, then Done.
fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
let t = Tokens::get(ui.ctx());
widgets::subheader(ui, "Format Settings");
widgets::subheader(ui, tl!("Format Settings"));
ui.add_space(6.0);
let at = SETTINGS.iter().position(|(f, _)| *f == id).unwrap_or(0);
if let Some((f, _)) = widgets::dropdown(ui, "efs-settings-format", SETTINGS_LABELS[at], &SETTINGS_LABELS, 150.0).and_then(|i| SETTINGS.get(i)) {
@@ -557,8 +557,10 @@ fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
let labels: Vec<&str> =
d.fields.get("__presets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
ui.horizontal(|ui| {
ui.label(egui::RichText::new("PDF Preset:").color(t.text_dim));
if let Some(p) = widgets::dropdown(ui, "efs-pdf-preset", &s.str("preset"), &labels, 220.0).and_then(|i| labels.get(i)) {
ui.label(egui::RichText::new(tl!("PDF Preset:")).color(t.text_dim));
// The built-in presets are ours (translated); the saved ones are names.
let builtin = |k: usize| labels.get(k).is_some_and(|n| super::save_pdf::is_builtin_preset(n));
if let Some(p) = super::mixed_dropdown(ui, "efs-pdf-preset", &s.str("preset"), &labels, 220.0, builtin).and_then(|i| labels.get(i)) {
s.fields.insert("preset".into(), json!(p));
}
});
@@ -571,7 +573,7 @@ fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
all.insert(id.into(), Value::Object(s.fields));
}
ui.add_space(10.0);
if widgets::secondary_button(ui, "Done").clicked() {
if widgets::secondary_button(ui, tl!("Done")).clicked() {
d.fields.insert("__settings".into(), json!(""));
}
}
+5 -5
View File
@@ -15,7 +15,7 @@ use crate::{VectorcraftApp, widgets};
/// The dialog kind of Eyedropper Options.
pub const KIND: &str = "eyedropperOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Eyedropper Options".into(), body, confirm, min_width: 460.0, ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Eyedropper Options").into(), body, confirm, min_width: 460.0, ..DialogSpec::FORM };
/// The raster sample sizes: (pixels square, label).
const SAMPLE_SIZES: [(u64, &str); 3] = [(1, "Point Sample"), (3, "3 x 3 Average"), (5, "5 x 5 Average")];
@@ -70,7 +70,7 @@ fn set_all(v: &mut Value, on: bool) {
/// One tree (`key`: `pickUp` or `apply`) of checkboxes; a branch's box sets everything under it.
fn tree(ui: &mut egui::Ui, d: &mut Dialog, key: &str, title: &str) {
ui.vertical(|ui| {
ui.label(egui::RichText::new(title).strong());
ui.label(egui::RichText::new(tl!(title)).strong());
ui.add_space(4.0);
let Some(t) = d.fields.get_mut(key) else { return };
for (path, label, depth) in TREE {
@@ -90,7 +90,7 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let size = d.fields.get("sampleSize").and_then(Value::as_u64).unwrap_or(1);
let current = SAMPLE_SIZES.iter().find(|(n, _)| *n == size).map_or(SAMPLE_SIZES[0].1, |s| s.1);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Raster Sample Size:");
widgets::dim_label(ui, tl!("Raster Sample Size:"));
let labels = SAMPLE_SIZES.map(|s| s.1);
if let Some(i) = widgets::dropdown(ui, "eyedropper-sample", current, &labels, 150.0) {
d.fields.insert("sampleSize".into(), json!(SAMPLE_SIZES[i].0));
@@ -98,9 +98,9 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
ui.add_space(10.0);
ui.horizontal_top(|ui| {
tree(ui, d, "pickUp", "Eyedropper Picks Up:");
tree(ui, d, "pickUp", tl!("Eyedropper Picks Up:"));
ui.add_space(24.0);
tree(ui, d, "apply", "Eyedropper Applies:");
tree(ui, d, "apply", tl!("Eyedropper Applies:"));
});
false
}
+18 -16
View File
@@ -22,7 +22,7 @@ const PENDING: &str = "__keyword";
const FIELD: f32 = 320.0;
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "File Info".into(), body, confirm, min_width: 500.0, max_width: Some(500.0), ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("File Info").into(), body, confirm, min_width: 500.0, max_width: Some(500.0), ..DialogSpec::FORM };
/// Open File Info on the active document's.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
@@ -62,19 +62,19 @@ fn add_keywords(d: &mut Dialog) {
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
for (key, label) in [("title", "Document Title:"), ("author", "Author:"), ("authorTitle", "Author Title:")] {
for (key, label) in [("title", tl!("Document Title:")), ("author", tl!("Author:")), ("authorTitle", tl!("Author Title:"))] {
widgets::label_row(ui, label, LABEL, |ui| {
form::text(ui, d, key, FIELD);
});
ui.add_space(4.0);
}
top_row(ui, "Description:", |ui| {
top_row(ui, tl!("Description:"), |ui| {
form::text_area(ui, d, "description", FIELD, 3);
});
ui.add_space(4.0);
widgets::label_row(ui, "Rating:", LABEL, |ui| rating(ui, d));
widgets::label_row(ui, tl!("Rating:"), LABEL, |ui| rating(ui, d));
ui.add_space(4.0);
top_row(ui, "Keywords:", |ui| {
top_row(ui, tl!("Keywords:"), |ui| {
ui.vertical(|ui| {
ui.set_max_width(FIELD + 12.0);
chips(ui, d);
@@ -87,28 +87,28 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
r.request_focus();
}
let typed = !d.str(PENDING).trim().is_empty();
if (widgets::flat_button(ui, "Add", 56.0).clicked() || enter) && typed {
if (widgets::flat_button(ui, tl!("Add"), 56.0).clicked() || enter) && typed {
add_keywords(d);
}
});
form::caption(ui, "Separate several keywords with commas.");
form::caption(ui, tl!("Separate several keywords with commas."));
});
});
ui.add_space(10.0);
widgets::subheader(ui, "Copyright");
widgets::subheader(ui, tl!("Copyright"));
let statuses: Vec<(&str, &str)> = CopyrightStatus::ALL.iter().map(|c| (c.id(), c.label())).collect();
choice(ui, d, "copyrightStatus", "Copyright Status:", &statuses);
choice(ui, d, "copyrightStatus", tl!("Copyright Status:"), &statuses);
ui.add_space(4.0);
top_row(ui, "Copyright Notice:", |ui| {
top_row(ui, tl!("Copyright Notice:"), |ui| {
form::text_area(ui, d, "copyrightNotice", FIELD, 2);
});
ui.add_space(4.0);
widgets::label_row(ui, "Copyright Info URL:", LABEL, |ui| {
widgets::label_row(ui, tl!("Copyright Info URL:"), LABEL, |ui| {
form::text(ui, d, "copyrightUrl", FIELD);
});
ui.add_space(10.0);
widgets::subheader(ui, "Dates");
for (key, label) in [("created", "Created:"), ("modified", "Modified:")] {
widgets::subheader(ui, tl!("Dates"));
for (key, label) in [("created", tl!("Created:")), ("modified", tl!("Modified:"))] {
// ISO 8601 UTC from the engine, shown as "2026-10-05 14:03:00 UTC".
let shown = d
.fields
@@ -127,7 +127,7 @@ fn top_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
let t = Tokens::get(ui.ctx());
ui.horizontal_top(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(LABEL, 24.0), egui::Sense::hover());
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text);
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, tl!(label), egui::FontId::proportional(12.5), t.text);
add(ui);
});
}
@@ -148,7 +148,7 @@ fn rating(ui: &mut egui::Ui, d: &mut Dialog) {
t.text_dim
};
icons::paint(ui, "star", rect.shrink(2.0), tint);
let resp = resp.on_hover_text(if i == 1 { "1 star".to_string() } else { format!("{i} stars") });
let resp = resp.on_hover_text(crate::i18n::tn(u64::from(i), "{n} star", "{n} stars"));
if resp.clicked() {
d.fields.insert("rating".into(), json!(if i == current { 0 } else { i }));
}
@@ -173,7 +173,9 @@ fn chips(ui: &mut egui::Ui, d: &mut Dialog) {
ui.painter().rect_stroke(rect, egui::CornerRadius::same(11), egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
ui.painter().galley(egui::pos2(rect.left() + 9.0, rect.center().y - galley.size().y / 2.0), galley, t.text_strong);
let x = egui::Rect::from_center_size(egui::pos2(rect.right() - 11.0, rect.center().y), egui::vec2(14.0, 14.0));
let resp = ui.interact(x, ui.id().with(("keyword-remove", i)), egui::Sense::click()).on_hover_text(format!("Remove “{k}”"));
let resp = ui
.interact(x, ui.id().with(("keyword-remove", i)), egui::Sense::click())
.on_hover_text(crate::i18n::fmt(tl!("Remove “{keyword}”"), &[("keyword", k.as_str())]));
icons::paint(ui, "x", x.shrink(2.0), if resp.hovered() { t.text_strong } else { t.text_dim });
if resp.clicked() {
remove = Some(i);
+34 -21
View File
@@ -31,7 +31,7 @@ const PRESET_NAME: &str = "__presetName";
const CUSTOM: &str = "[Custom]";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Flatten Transparency".into(), body, confirm, preview: true, min_width: 420.0, ..DialogSpec::FORM };
DialogSpec { heading: |_| tl!("Flatten Transparency").into(), body, confirm, preview: true, min_width: 420.0, ..DialogSpec::FORM };
/// Open Flatten Transparency for the selection with the default preset.
pub fn open(app: &mut VectorcraftApp) {
@@ -86,12 +86,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let current = options(d, saved);
let presets = app.session.flattener_presets();
ui.horizontal(|ui| {
widgets::dim_label(ui, "Preset:");
widgets::dim_label(ui, tl!("Preset:"));
if let Some(p) = preset_dropdown(ui, "flatten-preset", &presets, &d.str("preset"), current.as_ref().ok(), 220.0) {
put_options(&mut d.fields, &p.options);
applied(d, &p.name);
}
if widgets::flat_button(ui, "Save Preset…", 100.0).clicked() {
if widgets::flat_button(ui, tl!("Save Preset…"), 100.0).clicked() {
let open = !d.bool(SAVING);
d.fields.insert(SAVING.into(), json!(open));
d.fields.insert(PRESET_NAME.into(), json!(app.session.new_preset_name()));
@@ -118,22 +118,22 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
fn save_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Name:");
widgets::dim_label(ui, tl!("Name:"));
let r = form::text_edit(ui, d, PRESET_NAME, 180.0);
// Enter saves the preset instead of flattening.
let enter = r.lost_focus() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
if (widgets::flat_button(ui, "Save", 60.0).clicked() || enter)
if (widgets::flat_button(ui, tl!("Save"), 60.0).clicked() || enter)
&& let Err(e) = save_preset(app, d)
{
app.status(e);
}
if widgets::flat_button(ui, "Cancel", 60.0).clicked() {
if widgets::flat_button(ui, tl!("Cancel"), 60.0).clicked() {
d.fields.insert(SAVING.into(), json!(false));
}
});
let name = d.str(PRESET_NAME);
if app.session.prefs.flattener_presets.iter().any(|p| p.name.eq_ignore_ascii_case(name.trim())) {
widgets::dim_label(ui, "Replaces the saved preset of that name.");
widgets::dim_label(ui, tl!("Replaces the saved preset of that name."));
}
}
@@ -153,7 +153,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
/// The flattener preset dropdown: `presets` by name, showing `name` while `options` are that
/// preset's, else [Custom]. Returns the preset chosen.
/// preset's, else [Custom]. The built-in presets are ours (translated); the saved ones are names.
/// Returns the preset chosen.
pub(crate) fn preset_dropdown<'a>(
ui: &mut egui::Ui,
id: &str,
@@ -162,9 +163,14 @@ pub(crate) fn preset_dropdown<'a>(
options: Option<&FlattenOptions>,
width: f32,
) -> Option<&'a FlattenerPreset> {
let shown = presets.iter().find(|p| p.name == name && Some(&p.options) == options).map_or(CUSTOM, |p| p.name.as_str());
let shown = presets.iter().find(|p| p.name == name && Some(&p.options) == options).map_or(tl!(CUSTOM), |p| p.name.as_str());
let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
widgets::dropdown(ui, id, shown, &names, width).map(|i| &presets[i])
super::mixed_dropdown(ui, id, shown, &names, width, |k| names.get(k).is_some_and(|n| is_builtin(n))).and_then(|i| presets.get(i))
}
/// Is `name` a built-in flattener preset's?
fn is_builtin(name: &str) -> bool {
FlattenOptions::PRESETS.iter().any(|id| FlattenOptions::preset_label(id) == Some(name))
}
/// The width of the option value fields.
@@ -177,25 +183,25 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions
let t = Tokens::get(ui.ctx());
let before = o.clone();
let dim = |ui: &mut egui::Ui, s: &str| {
ui.label(egui::RichText::new(s).color(t.text_dim).size(11.5));
ui.label(egui::RichText::new(tl!(s)).color(t.text_dim).size(11.5));
};
ui.add_enabled_ui(enabled, |ui| {
widgets::dim_label(ui, "Raster/Vector Balance:");
widgets::dim_label(ui, tl!("Raster/Vector Balance:"));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
dim(ui, "Rasters");
dim(ui, tl!("Rasters"));
let rail = (ui.available_width() - FIELD - 56.0).clamp(80.0, 200.0);
if let (Some(x), _) = widgets::color_slider(ui, (id, "balance-slider"), (o.balance / 100.0) as f32, rail, &|_| t.input_border) {
o.balance = (x as f64 * 100.0).round();
}
dim(ui, "Vectors");
dim(ui, tl!("Vectors"));
if let Some(v) = widgets::plain_field(ui, (id, "balance"), o.balance, "", 0, FIELD - 12.0) {
o.balance = v.round().clamp(0.0, 100.0);
}
});
ui.add_space(4.0);
egui::Grid::new((id, "resolutions")).num_columns(2).spacing([8.0, 4.0]).show(ui, |ui| {
for (label, key) in [("Line Art and Text Resolution:", 0), ("Gradient and Mesh Resolution:", 1)] {
for (label, key) in [(tl!("Line Art and Text Resolution:"), 0), (tl!("Gradient and Mesh Resolution:"), 1)] {
widgets::dim_label(ui, label);
ui.horizontal(|ui| {
let v = if key == 0 { &mut o.line_art_ppi } else { &mut o.gradient_ppi };
@@ -209,12 +215,12 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions
});
ui.add_space(4.0);
let switches: [(&str, &mut bool); 6] = [
("Convert All Text to Outlines", &mut o.text_to_outlines),
("Convert All Strokes to Outlines", &mut o.strokes_to_outlines),
("Clip Complex Regions", &mut o.clip_complex_regions),
("Anti-alias Rasters", &mut o.anti_alias),
("Preserve Alpha Transparency", &mut o.preserve_alpha),
("Preserve Overprints and Spot Colors", &mut o.preserve_overprints),
(tl!("Convert All Text to Outlines"), &mut o.text_to_outlines),
(tl!("Convert All Strokes to Outlines"), &mut o.strokes_to_outlines),
(tl!("Clip Complex Regions"), &mut o.clip_complex_regions),
(tl!("Anti-alias Rasters"), &mut o.anti_alias),
(tl!("Preserve Alpha Transparency"), &mut o.preserve_alpha),
(tl!("Preserve Overprints and Spot Colors"), &mut o.preserve_overprints),
];
for (label, v) in switches {
if widgets::check(ui, label, *v, enabled) {
@@ -230,6 +236,13 @@ mod tests {
use super::*;
use vectorcraft_engine::Session;
/// The built-in presets are told from saved ones by name (only theirs are translated).
#[test]
fn built_in_presets_are_known_by_name() {
assert!(FlattenOptions::builtin_presets().iter().all(|p| is_builtin(&p.name)));
assert!(!is_builtin("Regular") && !is_builtin("high"));
}
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more