Compare commits

...
Author SHA1 Message Date
bflatastic 278fba490a i18n(ka): the pdfInfo thumbnail page error (#2914) 2026-10-19 00:16:42 +07:00
bflatastic d807236bf2 i18n(zh-hant): the shared parameter-check errors, now that Traditional Chinese translates every message (#2930, #2784)
#2784 made zh-hant a language that translates every status and error message while #2930 added the shared parameter-check errors to the others; main's shared_param_check_errors_are_translated failed on zh-hant.
2026-10-19 00:15:35 +07:00
BFlat 9c1d1d56ed Merge pull request #2944 from dotCooCoo/fix/save-for-web-preview-folder-per-run
Save for Web: each browser preview is written to its own folder
2026-10-19 00:10:12 +07:00
BFlat 53bd154059 Merge pull request #2938 from dotCooCoo/fix/legacy-ai-names-windows-1252
Legacy .ai export: the title and spot color and gradient names are written in Windows-1252
2026-10-19 00:09:56 +07:00
BFlat 1f2e90a7b3 Merge pull request #2937 from dotCooCoo/fix/ai-image-left-out-warns
.ai import: the editing data reader warns about each image it can't read
2026-10-19 00:09:38 +07:00
BFlat c7f748c75c Merge pull request #2936 from dotCooCoo/fix/editing-data-hash-reads-utf16-desc
PDF and .ai import: editing data with a UTF-16 description is checked against its pages
2026-10-19 00:09:23 +07:00
BFlat 4de769175e Merge pull request #2935 from dotCooCoo/fix/issue-2830
Legacy .ai export: an F line follows each %AI5_EndRaster
2026-10-19 00:09:07 +07:00
BFlat 9a8e2ac6a0 Merge pull request #2930 from dotCooCoo/fix/number-param-error-translated
i18n: number, object id and date parameter errors are translated
2026-10-19 00:08:55 +07:00
BFlat 419ed87cff Merge pull request #2929 from dotCooCoo/fix/anchor-commands-refuse-a-bad-subpath
Paths: anchor commands refuse a subpath that is not a whole number
2026-10-19 00:08:43 +07:00
BFlat eeb9513bbb Merge pull request #2921 from dotCooCoo/fix/links-on-mapped-drives
Links: a document opened from a mapped drive finds links on its share
2026-10-19 00:08:31 +07:00
BFlat c092ca1a48 Merge pull request #2917 from dotCooCoo/fix/menu-commands-refuse-text-numbers
Transform Each, Rasterize, Mosaic, Trim Marks and Rearrange: a number given as text is refused
2026-10-19 00:08:16 +07:00
BFlat ba2a11a807 Merge pull request #2914 from dotCooCoo/fix/pdfinfo-reads-postscript
MCP: document.pdfInfo answers one page for an EPS, a .ps file or a PostScript .ai
2026-10-19 00:08:04 +07:00
BFlat f1757c7bda Merge pull request #2907 from dotCooCoo/fix/eps-tiff-predictor-all-depths
EPS and .ai import: images with TIFF predictor 2 draw their samples at every bit depth
2026-10-19 00:07:47 +07:00
BFlat e616f1fe5c Merge pull request #2784 from dotCooCoo/feat/zh-hant-messages
i18n: Traditional Chinese translates status-bar and error messages
2026-10-19 00:07:32 +07:00
BFlat 647d51eaf6 Merge pull request #2765 from dotCooCoo/feat/open-targa-files
File formats: Targa (.tga) files open and place
2026-10-19 00:07:17 +07:00
BFlat 0d34a58b7b Merge pull request #2943 from youngeuibae/page-api
web: window.vectorcraft, the control channel for the page
2026-10-19 00:07:07 +07:00
BFlat 3a685eae61 Merge pull request #2939 from MortenHusted/feat/select-by-color
Select: select.color picks objects by fill and stroke colour, no object needed
2026-10-19 00:06:55 +07:00
BFlat e4d94109a2 Merge pull request #2922 from dotCooCoo/fix/issue-2871
Type: a click away from the text being edited ends editing without making new text
2026-10-19 00:06:44 +07:00
BFlat 4a90d13fc0 Merge pull request #2916 from dotCooCoo/fix/issue-2881
Fields: arrow keys and the wheel step to the next whole unit, and Ctrl/Cmd steps to the next tenth
2026-10-19 00:06:26 +07:00
BFlat 8a2d0db225 Merge pull request #2915 from EricKuck/fix/ai-outer-glow-dilation
Effects: an Outer Glow grows its solid silhouette by a quarter raster pixel before the blur
2026-10-19 00:06:16 +07:00
BFlat e358bbfdfb Merge pull request #2928 from nrq6993-a11y/stream/vc-issue-2719-wysiwyg-zoom
Fix #2719: honor Display Print Size for status-bar 100% zoom
2026-10-19 00:06:00 +07:00
BFlat 7d7853d7fd Merge pull request #2931 from MortenHusted/fix/raster-scale-only
CLI, MCP: the raster scale no longer resizes DXF exports
2026-10-19 00:05:46 +07:00
BFlat 0a14001126 Merge pull request #2911 from nrq6993-a11y/stream/vc-issue-2646-floating-panel-opaque
Fix #2646: keep undocked shared-dock panels opaque
2026-10-19 00:05:29 +07:00
BFlat 910e33b796 Merge pull request #2927 from nrq6993-a11y/stream/vc-issue-2727-mask-editing-dim
Fix #2727: opacity-mask editing no longer dims the canvas
2026-10-19 00:04:45 +07:00
BFlat 9b64fce9ec Merge pull request #2913 from Higgs32584/fix/2707-cmyk-icc-output-tags
CMYK profiles carry every tag an ICC output profile requires (#2707)
2026-10-19 00:04:16 +07:00
BFlat cd35c27a53 Merge pull request #2941 from devius/ka-fixes
i18n(ka): Letter and Word Spacing as in the other Craft apps; "weren't read" in the catalog's usual form
2026-10-19 00:04:00 +07:00
dotCooCoo ab01a1a927 Save for Web: each browser preview is written to its own folder
Preview in Browser wrote every page to one folder,
VectorCraft Save for Web in the system's temporary folder. A second
preview of a document with the same title, from the same app or from
another one running, overwrote the first preview's page, and any image
file of the same name (PNG-8 and PNG-24 both write .png), before the
browser read them. The same overwrite made
dialogs::tests_save_for_web::preview_in_browser_opens_the_page fail now
and then, when another Save for Web test in the same binary, or a test
run in another checkout on the machine, previewed Untitled-1 at the
same time.

Each preview now goes to a new folder inside that one, named after the
process id and a count of the process's previews. A name that an
earlier process with the same id left there is skipped for the next
count. The page keeps its name and its images folder. A preview first
removes the entries of VectorCraft Save for Web that were last changed
an hour ago or earlier, which also clears the files the single folder
held before.

Docs: docs/target-app-parity.md.
2026-10-19 00:03:53 +07:00
dotCooCoo d054b5b4c7 Legacy .ai export: the title and spot color and gradient names are written in Windows-1252
Saving a version 7 .ai wrote the title, spot color names and gradient
names as the UTF-8 bytes of their characters: a spot color named Café
was written (Caf\303\251) in the header's custom colors and in each x.
Type and, since #2722, layer names are written in Windows-1252, the
encoding the file installs with TE.

These names now go through #2722's Windows-1252 path, so Café is
written (Caf\351). A character outside Windows-1252 is written as ?,
with one warning for all of these names. A spot color keeps one name
in the header, in each x and X and in gradient stops, and a gradient
keeps one name in its definition and in each Bg. Two spot colors or
two gradients whose names become the same that way are numbered (? 2).

Docs: docs/file-format-parity.md.
2026-10-19 00:03:52 +07:00
dotCooCoo ba32ef5b09 .ai import: the editing data reader warns about each image it can't read
An image in a .ai's editing data whose operands had no matrix, fewer
than 11 numbers, or a matrix of other than six numbers was left out
with no warning. So was one whose matrix maps it onto a line or a
point. The reader's warnings did not mention such an image, while the
reader warned "an image VectorCraft can't read was left out" for an
image whose size or bit depth it can't use.

Each of those exits now adds the same warning, as does the one where
the decoded pixels don't make an image.
2026-10-19 00:03:51 +07:00
dotCooCoo f0c2d8c890 PDF and .ai import: editing data with a UTF-16 description is checked against its pages
A PDF or PDF-compatible .ai saved with Preserve Editing carries the
native document as an embedded file, with a hash of its pages at the
end of the file's /Desc. The import read /Desc with from_utf8_lossy and
looked for "hash ". /Desc is a PDF text string, which may be written as
UTF-16BE with a byte order mark (ISO 32000-1 7.9.2.2). Written that
way it holds no UTF-8 "hash ", so the import found no hash, took the
editing data as intact and reopened the native document with no
warning, even after another app had changed the pages.

/Desc is now read with text_string, the importer's decoder for text
strings, so the hash is found in UTF-16BE, UTF-8 and PDFDocEncoding.
Changed pages open as artwork with the "changed in another app"
warning. The embedded file's /UF and /F names are decoded the same
way, so editing data named only in UTF-16BE is found.
A /Desc without a hash is still trusted, as before.
2026-10-19 00:03:51 +07:00
dotCooCoo e5c9054324 Legacy .ai export: an F line follows each %AI5_EndRaster
alongtheblue-sketch took 0.12.0's version 7 output of the SVGs in #2692
and #2830, edited it by hand and opened it in the reference app
(https://github.com/storytold/vectorcraft/pull/2801#issuecomment-6244853548).
Writing the image lines as #2801 does, with the bracketed matrix and
the 60-digit data lines, did not change the result there. With one
line, F, added after %AI5_EndRaster, the reference app opened the image
in its clipping group and the art after it in both files.

The writer now writes F after every %AI5_EndRaster: clipped, grouped
and loose images, flattened images and images in pattern tiles. No path
is open at that point, so the line paints nothing. VectorCraft's reader
opens the files with the same objects as before, and the file's
PostScript prolog draws the same page.
2026-10-19 00:03:50 +07:00
dotCooCoo 9fe0f03de2 i18n: number, object id and date parameter errors are translated
The errors of the parameter checks that many commands share had no
@msg row in any catalog: "`{key}` must be a number, not {v}" from
check_numbers (object.move, object.rotate, object.setBounds and the
other callers), "missing integer `{key}`" from index_param in
cmd/appearance.rs (appearance, effect and freeform gradient commands),
the three object id errors of checked_ids_param and checked_id, and
the Unix seconds error of date_param. A template
matches only the whole text, so the row for "`{key}` must be a
number" did not match the longer check_numbers message. These errors
showed in English in the status bar, and "no such object id {value}"
matched the general "no such object {0}" row and showed half in
English. The scan in complete_languages_translate_every_message does
not read messages built with bad(), so that test did not report them.

Each of the 6 messages now has a row in the 18 catalogs that translate
every status and error message and in Bulgarian, Danish, Indonesian and
Dutch, and "missing number `{key}`" has a row in Danish and Indonesian.
No English string changes.

Docs: docs/target-app-parity.md.
2026-10-19 00:03:48 +07:00
BFlat 429e147164 Merge pull request #2940 from ElPaule/i18n/de-missing-entries
i18n(de): add missing PSD error messages and effect Soft entry
2026-10-19 00:03:48 +07:00
dotCooCoo c0ec14b86d Paths: anchor commands refuse a subpath that is not a whole number
path.curvatureEdit, path.removeAnchor, path.convertAnchor,
path.reshapeSegment, path.split, path.setHandle and path.insertAnchor
read subpath as an unsigned integer and fell back to 0 for any other
value. A subpath of 1.5, -1 or "1" edited subpath 0 and the command
reported success. path.split read anchor and segment with as_u64 too
and treated any other value as absent, so a bad anchor beside a good
segment split at the segment.

These commands now refuse a subpath that is not a whole number of 0 or
more with "missing integer `subpath`", the error path.curvatureEdit,
path.removeAnchor, path.convertAnchor and path.reshapeSegment already
give for a bad anchor or segment, and path.split refuses such an anchor
or segment too. Without a subpath, or with null, they edit subpath 0 as
before. The document and the undo history stay as they were after each
error. The params text of the six commands that listed subpath as
required now marks it optional.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:47 +07:00
dotCooCoo 89a73abc38 Links: a document opened from a mapped drive finds links on its share
craft-docpath follows a network path only on the share the document is
on, and its caller contract asks for a document opened from a mapped
drive to be passed by its UNC path. fileio::document_path passed the
drive path as given, so a document opened as Z:\jobs\doc.vectorcraft
treated a link stored as \\server\share\img\logo.png, on the share
behind Z:, as on another computer: the image was missing, and
document.open, links.check and links.info gave refused: "the path is on
another computer".

On Windows, when a stored path is refused as on another computer and
the document is on a drive, document_path now reads the final path of
the document's folder (std::fs::canonicalize). When that names a share
(\\?\UNC\server\share\...), the path is checked again against the
document's path on that share. Links on other computers stay refused,
and paths that were followed resolve as before. Opening a document,
Link Info, links.check and the linked-file watch all use it. Other
systems are unchanged.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:47 +07:00
dotCooCoo cc8944ad2a Transform Each, Rasterize, Mosaic, Trim Marks and Rearrange: a number given as text is refused
Transform Each, Rasterize, Create Object Mosaic, Create Trim Marks and
Rearrange All Artboards read their optional numbers with f64_or, which
falls back to a default for a value that is not a JSON number.
object.transformEach {"scaleH": "50"} left the selected objects at
their size and reported success. Rasterize did the same with ppi,
Create Object Mosaic did the same with width, height, spacingX and
spacingY, Create Trim Marks did the same with offset, length, bleed and
weight, and Rearrange All Artboards did the same with spacing. Crop
Image cropped to the artboard when rect was a string or another value
that is not an array.

These commands now fail with "`<key>` must be a number, not <value>"
for a value that is not a number or null, as the transform commands
do, and change nothing. Transform Each checks scaleH, scaleV, moveH,
moveV and rotate. Crop Image refuses a rect that is not an array of
four numbers with "rect must be [x, y, width, height]", the message it
already gave for an array of the wrong length; a null rect crops to the
artboard, as a missing one does.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:46 +07:00
dotCooCoo 05264b2801 MCP: document.pdfInfo answers one page for an EPS, a .ps file or a PostScript .ai
document.pdfInfo refused PostScript (an EPS, a .ps file, or a .ai in
PostScript form), mostly with "this is a PostScript file ..., not a
PDF", although document.open and file.place read the same file
through the EPS reader as one page.

It now answers {pages: 1, needsPassword: false, pageInfo: [{width,
height, rotation: 0, boxes: {}}]}. The page is the file's bounding box
(%%HiResBoundingBox when it has one, a US Letter page without one),
read from its comments without running its program. thumbnail: 1 is a
PNG of what that page prints, read by the EPS reader without any
editing data the file carries. A program whose header is only %! is
read as PostScript, as clipboard.importEps reads it.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:45 +07:00
dotCooCoo 372887fc6c EPS and .ai import: images with TIFF predictor 2 draw their samples at every bit depth
The PostScript interpreter that reads EPS and PostScript .ai files
accepts a FlateDecode or LZWDecode filter with Predictor 2 at
BitsPerComponent 1, 2, 4, 8 and 16, but undid the horizontal
differencing only at 8 bits. At the other depths the filter gave back
the stored differences, and the image drew them instead of its
samples.

The predictor is now undone at every depth it accepts, as ISO 32000-1
§7.4.4.4 describes: each component adds the same component of the
pixel to its left, within its bit width, and each row starts again.
16-bit components add as big-endian 16-bit numbers. The padding bits
at the end of a row stay as they are, and 8-bit data takes the same
path as before. The PNG predictors are unchanged.

Docs: docs/target-app-parity.md.
2026-10-19 00:03:43 +07:00
dotCooCoo 841fc0cd51 i18n: Traditional Chinese translates status-bar and error messages
Squashed from #2784 (its merge of main dropped).
2026-10-18 23:50:58 +07:00
dotCooCoo d7e7066251 File formats: Targa (.tga) files open and place
Squashed from #2765 (its merge of main dropped).
2026-10-18 23:50:58 +07:00
focuerandClaude Opus 5.5 dc7216f0e4 web: rustfmt window.vectorcraft
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:50:58 +07:00
MortenHusted 36aecff81d Select: select.color reads compound paths and empty type as document.inspect does
A compound path holds its members, so select.color passed over it and
looked at the members instead, whose paint inspect doesn't report: a
black compound path (art with holes, as PDFs bring in) never matched
"#000000". It now matches by its own fill and stroke, and its members
never do. Type without characters reads as the default character fill,
as inspect reports it (CodeRabbit on #2939).
2026-10-18 23:50:58 +07:00
focuerandClaude Opus 5.5 9dbab52168 web: window.vectorcraft, the control channel for the page
The same JSON methods as the desktop control server (engine.execute, ui.inspect, ui.screenshot, …),
as promises on window.vectorcraft, like FilmCraft's window.filmcraft: an agent driving the editor in a
browser tab or an <iframe> uses the same commands as the CLI and the MCP server. openFile opens
bytes the way a dropped file opens; captureDownloads keeps saves in the page for readFile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:50:58 +07:00
dotCooCoo 09a6fbe470 Type: a click away from the text being edited ends editing without making new text
While a Type tool edited text, a click on blank canvas ended the
editing and also placed new point type at the click. Area Type and
Type on a Path did the same with a click on a path.

Such a click now only ends the editing. The typing session is
committed, empty point type that a click placed is discarded as before,
and the Type tool in use stays active; the next click makes new type.
The same applies when the editing began with a double-click from a
selection tool. A click on other type still edits it at once, and a
drag that draws an area-type frame still makes the frame.
2026-10-18 23:50:57 +07:00
dotCooCoo b30d80e056 Fields: arrow keys and the wheel step to the next whole unit, and Ctrl/Cmd steps to the next tenth
In number fields, Up/Down and a wheel notch added a whole step and kept
the fraction, so 5.1 mm stepped up to 6.1 mm. A plain step now goes to
the next whole unit in its direction (5.1 goes up to 6 and down to 5; 5
goes up to 6 and down to 4), and a Ctrl/Cmd step goes to the next tenth,
in the field's own unit. Repeated presses or notches of one size add or
take one step each, and presses and notches are applied in the order
they came. A Shift step still adds or takes ten and keeps the fraction.

The number fields that widgets.rs draws for the panels, the Control bar
and the dialogs (unit, percent, degree and count fields) all use this
step (widgets::step_value).
2026-10-18 23:50:57 +07:00
EricKuckandClaude Fable 5.1 1b8e3d9ae1 Outer Glow grows its solid silhouette by a quarter raster pixel before the blur, as the glows AI files export measure
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 23:50:57 +07:00
bflatastic 12d107cc56 fmt (#2928) 2026-10-18 23:50:57 +07:00
MortenHusted eba9abc21e CLI, MCP: the raster scale no longer resizes DXF exports
`vectorcraft-cli run/convert --scale` and the MCP export tool's `scale`
are raster pixels per point, but they were passed to every export, and
DXF reads `scale` as drawing units per unit: a 2x PNG next to a DXF
doubled the drawing and left it unitless ($INSUNITS 0). They now go
only to raster formats. DXF's own scale stays reachable through the
format options.
2026-10-18 23:50:57 +07:00
bflatastic 88494319da fmt (#2911) 2026-10-18 23:50:57 +07:00
MortenHusted d25289a3be Select: select.color picks objects by fill and stroke colour, no object needed
Scripts had to know an object id before they could select art by colour
(select.same.* and select.magicWand start from one). select.color
{fill?, stroke?} takes colours as paint.setFill does and selects the
objects whose fill and stroke read as them in #rrggbb, as
document.inspect reports colours, with Select > Same's selection rules.

The label is translated in each of the 23 catalogs, which
complete_languages_translate_every_menu_string requires.
2026-10-18 23:50:57 +07:00
nrq6993-a11yandCursor 2025c357d9 Fix #2719: honor Display Print Size for status-bar 100% zoom
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:50:57 +07:00
nrq6993-a11yandCursor 16dcaa425b Fix #2727: opacity-mask editing no longer dims the canvas
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:48:57 +07:00
nrq6993-a11yandCursor bf19ada5e1 Fix #2646: keep undocked shared-dock panels opaque
Floating groups on the canvas now always get a panel-colour backdrop from layout geometry when egui has not registered the area yet, and panel bodies paint their own fill so empty regions do not show through.

Co-authored-by: Cursor <[email protected]>
2026-10-18 23:48:57 +07:00
Higgs32584andClaude Opus 5.5 c5798f446e CMYK profiles carry every tag an ICC output profile requires (#2707)
The built-in CMYK profiles embedded in CMYK exports had only the
perceptual and colorimetric tables. ICC.1 requires an output profile to
also have the saturation tables (A2B2, B2A2) and the gamut tag (gamt).
Windows colour management rejects the profile without them ("Bitmap color
context is not valid"), so Microsoft Photos couldn't show a CMYK TIFF
that embedded it, while the RGB export opened fine.

Write the saturation tables (same as colorimetric) and a 9x9x9 gamut
table (in gamut where a Lab colour separates and returns within dE 3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:48:56 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 4675a1633e i18n(ka): Letter and Word Spacing as in the other Craft apps; "weren't read" in the catalog's usual form
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 23:48:55 +07:00
ElPaule d99b04e594 i18n(de): add missing PSD error messages and effect Soft entry 2026-10-18 23:48:54 +07:00
BFlat 0f6154e7b9 Merge pull request #2934 from devius/fonts-georgian-pin
Release: embed the current craft-fonts (adds Noto Sans Georgian, craft-fonts#27)
2026-10-18 23:45:27 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 fc594b51ee Release: build the web version with craft-fonts too (web-craft-fonts)
Without it the shared release workflow builds the web release without CRAFT_FONTS_DIR, so
web-fonts.txt's startup faces (Arabic, Thai, Georgian) are not served (CodeRabbit review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 20:29:04 +04:00
BFlat cdba0aac03 Merge pull request #2900 from nrq6993-a11y/stream/vc-issue-2723-reshape-falloff
Fix #2723: Reshape tool falloff follows the path, so nodes stretch progressively
2026-10-18 20:14:02 +07:00
BFlat f44c3e9593 Merge pull request #2895 from Silvertree2010/fix/h2559-unit-named-ft-resolves-to-feet-i
Resolve the "ft" unit suffix to Feet in Unit::named
2026-10-18 20:13:47 +07:00
BFlat c965f49fda Merge pull request #2905 from storytold/fix/2850-pencil-redraw
Pencil: a stroke that starts and ends on a selected path redraws the section between (Edit Selected Paths, #2850)
2026-10-18 20:13:22 +07:00
BFlat a10604bbc6 Merge pull request #2904 from storytold/fix/2860-recent-cards
New Document: Recent and Saved cards keep the orientation they were made in (#2860)
2026-10-18 20:13:13 +07:00
bflatastic 4f526b4e3f Reshape: the lengths along the path are measured once per drag, and the default reach stays the path's size (#2900)
anchor_arc_distance measured every segment again for each anchor, so a reshape cost the square of the anchor count; path.reshape now measures the positions once, and the two helpers only it used are gone. The default radius stays half the subpath's bounding-box diagonal, now measured along the path: half its length reached the far corners of a square, moving art the grab is nowhere near (reshape_moves_the_grabbed_point_and_its_neighbourhood).
2026-10-18 20:12:47 +07:00
NetterSilverandClaude Haiku 5.5 f2601137ab Resolve the "ft" unit suffix to Feet in Unit::named
Found while testing crates/doc/src/lib.rs:274 (Unit::named)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 20:12:47 +07:00
bflatastic 9938a31c9b Pencil: a stroke that starts and ends on a selected path redraws the section between (Edit Selected Paths, #2850)
path.freehand takes redraw: {id, within}: the stroke's ends are placed on the path, anchors added there, and the section between them replaced by the fitted stroke, the shorter way round a closed subpath; a stroke that doesn't end on the path still draws a new one. The Pencil and Paintbrush send it for a stroke that starts on a selected path's outline away from its ends, within Edit Selected Paths' distance.
2026-10-18 20:12:47 +07:00
bflatastic 5f37fccdce New Document: Recent and Saved cards keep the orientation they were made in, so one A3 card is chosen, not two (#2860)
The cards turned to the chosen orientation in every category, so a portrait and a landscape A3 in Recent looked the same and both read as chosen. Only the built-in presets' cards turn now.
2026-10-18 20:12:47 +07:00
nrq6993-a11yandCursor e8e88c14f2 Fix #2723: Reshape falloff uses arc length along the path
Co-authored-by: Cursor <[email protected]>
2026-10-18 20:12:47 +07:00
BFlat 2cdb0d2d53 Merge pull request #2893 from dylanmtaylor/chore/drop-competitor-references
docs: drop references to competing commercial software
2026-10-18 19:36:12 +07:00
Dylan M. Taylor fe16f852db docs: clarify readiness audience and tracked features baseline 2026-10-18 08:28:33 -04:00
BFlat 43bb588e5e Merge pull request #2897 from storytold/fix/2879-baselines
Canvas: selected point type's baseline marker runs under each line, centred and right-aligned type too (#2879)
2026-10-18 19:20:48 +07:00
BFlat a57eaa0782 Merge pull request #2896 from storytold/fix/2873-w-weights
SVG export: a Japanese face's numbered weight (W0–W9) is its font-weight, W6 600 (#2873)
2026-10-18 19:20:39 +07:00
BFlat 007df2ee24 Merge pull request #2877 from AJMakesStuff/main
Add interactive Mirror Repeat axis controls
2026-10-18 19:20:05 +07:00
BFlat 0955e6845c Merge pull request #2884 from nrq6993-a11y/stream/vc-issue-2576-font-poll
Fix #2576: poll for fonts that font managers activate while the app is in front
2026-10-18 19:19:49 +07:00
BFlat 4bafe834a6 Merge pull request #2887 from ham9zah/fix/arabic-alternate-glyph-highlight
Part of #394: required shaping is not an alternate glyph
2026-10-18 19:19:33 +07:00
BFlat 87603e4c35 Merge pull request #2876 from ham9zah/fix/selection-clip-group-outlines
G1: test that a group of clipped images shows only its masks and their bounds
2026-10-18 19:19:19 +07:00
BFlat e60882a3a1 Merge pull request #2870 from lewandowskit/pl-terminologia
i18n(pl): Artboard is "obszar kompozycji", Guides "linie pomocnicze"
2026-10-18 19:19:05 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 25a987a94f Release: embed the current craft-fonts (adds Noto Sans Georgian, craft-fonts#27)
The release and FreeBSD workflows pinned craft-fonts 3d9ab3a1, two commits before #27,
so release builds had no Georgian face for the ka interface.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 16:09:28 +04:00
Dylan M. Taylor 090659b78b docs: drop references to competing commercial software 2026-10-18 07:52:58 -04:00
107 changed files with 4005 additions and 261 deletions

No files matched your search

+1 -1
View File
@@ -35,5 +35,5 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
require-craft-fonts: true
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
run-package-check: true
+2 -1
View File
@@ -26,7 +26,8 @@ jobs:
with:
app-name: vectorcraft
app-title: VectorCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
Generated
+1
View File
@@ -5007,6 +5007,7 @@ dependencies = [
"js-sys",
"log",
"rfd",
"serde_json",
"vectorcraft-engine",
"vectorcraft-format",
"vectorcraft-text",
+1 -1
View File
@@ -65,7 +65,7 @@ wat = "1.228"
# Without `text` (glyph runs, through glifo and a second skrifa): the renderer draws the outlines
# vectorcraft-text gives it. The other default features stay.
vello_cpu = { version = "0.2", default-features = false, features = ["std", "png", "u8_pipeline", "f32_pipeline"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp", "tga"] }
# Without `text` and the system fonts (fontdb, harfrust and skrifa, at other versions than ours):
# SVG import reads `<text>` from the XML itself and never gives usvg a font. A test of the SVG
# crate turns `text` on.
+9 -12
View File
@@ -11,11 +11,11 @@
<h1 align="center">VectorCraft</h1>
<p align="center">
<b>Vector illustration; an open-source, clean-room reimplementation of Adobe Illustrator, rebuilt in pure Rust.</b>
<b>Vector illustration; an open-source, modern vector art application, built in pure Rust.</b>
</p>
<p align="center">
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
A fast, open-source vector illustration workflow. It runs natively on
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
</p>
@@ -147,7 +147,7 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
## Why VectorCraft
- **Familiar.** Illustrator's layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
- **Familiar.** A familiar layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
Pathfinder, Smart Guides, Appearance, Swatches, Layers and more. You already know how to use it.
- **Fast.** Multithreaded SIMD rendering off the UI thread. 20,000 shapes render in about 27 ms at
full retina resolution while the interface stays at 120 fps.
@@ -224,9 +224,8 @@ VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the st
and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and
[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing.
**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against
all of Illustrator): everyday
**Where we are (2026-10-10): alpha.** About 88% of core menu items, tools, effects and formats exist
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday
vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
@@ -240,20 +239,20 @@ and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-
The interface
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
[localization-parity.md](docs/localization-parity.md) has the details.
The scores are self-assessed from our code against Illustrator's public documentation;
The scores are self-assessed from our code against public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
**What's missing:**
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
- about half of the raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- scripting, and the rest of Variables (image and graph kinds, dataset import);
- generative AI features.
**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability,
alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in
alongside the raster-effects package; then type, recent additions and 3D. The ranked list is in
[`docs/gaps.md`](docs/gaps.md).
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
@@ -372,8 +371,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and VectorCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+17 -4
View File
@@ -79,14 +79,16 @@ USAGE:
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
is a template, written as file.saveAsTemplate writes it). Prints one JSON result per step.
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
--artboard or --range).
@@ -278,7 +280,8 @@ fn convert(args: &[String]) -> Result<(), String> {
} else {
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
let mut params = with_pixel_scale(params, &output, scale);
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
@@ -302,6 +305,16 @@ fn is_template(path: &str) -> bool {
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
}
/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster.
/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave
/// its units unset.
fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value {
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) {
params["scale"] = json!(scale);
}
params
}
fn info(args: &[String]) -> Result<(), String> {
let file = args.first().ok_or("info needs a FILE")?;
let mut h = Headless::new();
@@ -391,7 +404,7 @@ fn run(args: &[String]) -> Result<(), String> {
let r = if is_template(&path) {
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
} else {
h.call("app.export", json!({"path": path, "scale": scale}))
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
};
let r = r.map_err(|e| format!("export {path}: {e}"))?;
emit(json!({"step": "export", "result": r}))?;
+22
View File
@@ -58,6 +58,28 @@ fn run_batch_exports() {
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
}
#[test]
fn scale_sizes_raster_exports_only() {
// --scale is pixels per point. DXF has a `scale` of its own (drawing units per mm) that would
// resize the drawing and leave its units unset.
let svg = tmp("scale.svg");
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="30" height="20"/></svg>"#)
.unwrap();
let svg = svg.to_str().unwrap();
let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png"));
let ok = |args: &[&str]| {
let out = Command::new(BIN).args(args).output().unwrap();
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
};
ok(&["convert", svg, plain.to_str().unwrap()]);
ok(&["convert", svg, scaled.to_str().unwrap(), "--scale", "2"]);
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "convert --scale 2 changed the DXF");
ok(&["run", "--in", svg, "--export", scaled.to_str().unwrap(), "--export", png.to_str().unwrap(), "--scale", "2"]);
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "run --scale 2 changed the DXF");
// 100 pt at scale 2 → 200 px (IHDR width).
assert_eq!(std::fs::read(&png).unwrap()[16..20], 200u32.to_be_bytes());
}
#[test]
fn run_reports_errors() {
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
+1
View File
@@ -26,6 +26,7 @@ log = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
serde_json = { workspace = true }
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
+184
View File
@@ -0,0 +1,184 @@
//! `window.vectorcraft`: the control channel for the page, as promises.
//!
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
//! the CLI and the MCP server:
//!
//! ```js
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
//! await vectorcraft.request("ui.inspect") // control method → its result
//! await vectorcraft.commands() // command list
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
//! vectorcraft.captureDownloads(true) // saves stay in the page…
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
//! ```
//!
//! Results resolve with the method's `result`; errors reject with an `Error`.
use std::cell::{Cell, RefCell};
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use serde_json::{Value, json};
use vectorcraft_ui_egui::ControlRequest;
use wasm_bindgen::prelude::*;
use crate::saved_files::SavedFiles;
use crate::web::Inbox;
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
thread_local! {
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
static CAPTURE: Cell<bool> = const { Cell::new(false) };
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
}
/// Wire the page's requests to the app: control requests go to `tx`, opened files to `inbox`.
/// After a graphics restart the app keeps its channel: `tx` is `None` and only the context changes.
pub fn connect(tx: Option<Sender<ControlRequest>>, inbox: Inbox, ctx: egui::Context) {
if let Some(tx) = tx {
TX.with(|t| *t.borrow_mut() = Some(tx));
}
INBOX.with(|i| *i.borrow_mut() = Some(inbox));
CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
fn repaint() {
CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
}
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
pub fn capture(path: &str, bytes: &[u8]) -> bool {
if !CAPTURE.with(Cell::get) {
return false;
}
SAVED.with(|s| s.borrow_mut().keep(path, bytes));
true
}
fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
fn from_js(v: &JsValue) -> Value {
if v.is_undefined() || v.is_null() {
return json!({});
}
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
}
/// Settle the promises of answered requests (called every frame).
pub fn poll_replies() {
WAITING.with(|w| {
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
Ok(v) => {
if v.get("ok").and_then(Value::as_bool) == Some(true) {
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
} else {
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
}
false
}
Err(TryRecvError::Empty) => true,
Err(TryRecvError::Disconnected) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
false
}
});
});
}
fn request(method: &str, params: Value) -> js_sys::Promise {
let method = method.to_string();
let p = js_sys::Promise::new(&mut |resolve, reject| {
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
if sent {
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
} else {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
}
});
repaint();
p
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
/// Install `window.vectorcraft`.
pub fn install() -> Result<(), JsValue> {
let obj = js_sys::Object::new();
set_fn(
&obj,
"request",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(&params)))
.into_js_value(),
)?;
set_fn(
&obj,
"execute",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
request("engine.execute", json!({"command": command, "params": from_js(&params)}))
})
.into_js_value(),
)?;
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
set_fn(
&obj,
"screenshot",
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(&params))).into_js_value(),
)?;
set_fn(
&obj,
"openFile",
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(|name: String, bytes: js_sys::Uint8Array| {
let queued = INBOX.with(|i| {
i.borrow().as_ref().is_some_and(|inbox| {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes.to_vec()));
true
})
});
repaint();
JsValue::from_bool(queued)
})
.into_js_value(),
)?;
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
set_fn(
&obj,
"files",
Closure::<dyn Fn() -> JsValue>::new(|| {
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
})
.into_js_value(),
)?;
set_fn(
&obj,
"readFile",
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
SAVED.with(|s| {
s.borrow()
.get(&name)
.map(|b| js_sys::Uint8Array::from(b).into())
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
})
})
.into_js_value(),
)?;
let w = web_sys::window().ok_or("no window")?;
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
Ok(())
}
+4
View File
@@ -16,12 +16,16 @@
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
//! language code) stands in for the browser's languages when the interface language is Automatic.
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
mod fonts;
#[cfg(target_arch = "wasm32")]
mod locks;
#[cfg(any(target_arch = "wasm32", test))]
mod pencil;
#[cfg(any(target_arch = "wasm32", test))]
mod saved_files;
#[cfg(target_arch = "wasm32")]
mod web;
+70
View File
@@ -0,0 +1,70 @@
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
/// Saved files kept (the oldest go first).
pub const MAX_SAVED: usize = 16;
/// The last component of a `/` or `\` path.
fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
}
/// Files by name, newest last: saving a name again replaces it.
#[derive(Default)]
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
impl SavedFiles {
/// Keep `bytes` under the last component of `path`.
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
let name = file_name(path).to_string();
self.0.retain(|(n, _)| *n != name);
self.0.push((name, bytes.to_vec()));
let extra = self.0.len().saturating_sub(MAX_SAVED);
self.0.drain(..extra);
}
/// A kept file by its name, or by a path ending in it.
pub fn get(&self, name: &str) -> Option<&[u8]> {
let wanted = file_name(name);
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
}
/// `(name, size)` of each kept file, oldest first.
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_by_name_replaces_and_reads_back_by_path() {
let mut s = SavedFiles::default();
s.keep("exports/poster.png", b"one");
s.keep(r"C:\out\poster.png", b"two");
s.keep("notes.txt", b"three");
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("missing.png"), None);
}
#[test]
fn drops_the_oldest_past_the_cap() {
let mut s = SavedFiles::default();
for i in 0..MAX_SAVED + 3 {
s.keep(&format!("f{i}.png"), &[0]);
}
assert_eq!(s.list().count(), MAX_SAVED);
assert_eq!(s.get("f0.png"), None);
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
}
#[test]
fn a_trailing_separator_keeps_the_whole_name() {
let mut s = SavedFiles::default();
s.keep("odd/", b"x");
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
}
}
+16 -2
View File
@@ -19,7 +19,7 @@ use wasm_bindgen::JsCast as _;
use crate::locks::WebLocks;
use crate::pencil::SharedPointers;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
/// whether Shift was held: where dropped files land.
@@ -122,9 +122,18 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
// The old services asked the old context for frames.
app.services = services;
app.status("The graphics were lost and have been restarted");
crate::api::connect(None, host.inbox.clone(), ctx.clone());
app
}
None => VectorcraftApp::new(Session::new(), services),
None => {
// `window.vectorcraft`: the page's control channel (crate::api).
let (control_tx, control_rx) = std::sync::mpsc::channel();
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
if let Err(e) = crate::api::install() {
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
}
VectorcraftApp::new(Session::new(), services).with_control(control_rx)
}
};
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
}),
@@ -386,6 +395,7 @@ impl eframe::App for WebShell {
}
}
app.logic(ctx);
crate::api::poll_replies();
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@@ -399,6 +409,7 @@ impl eframe::App for WebShell {
if let Some(app) = &mut self.app {
app.ui(ui);
}
crate::api::poll_replies();
}
}
@@ -544,6 +555,9 @@ impl RecoveryStore for BrowserStore {
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
if crate::api::capture(path, bytes) {
return Ok(());
}
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
let window = web_sys::window().ok_or("no window")?;
+19 -2
View File
@@ -320,9 +320,14 @@ fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
const A2B_GRID: usize = 9;
const B2A_GRID: usize = 17;
/// Grid points per Lab channel of the gamut table.
const GAMUT_GRID: usize = 9;
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
/// It carries every tag ICC.1 requires of an output profile: the tables for all three intents
/// and the gamut tag (Windows rejects a CMYK profile without them, so Photos couldn't show a CMYK
/// TIFF that embedded one, #2707).
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
@@ -337,6 +342,13 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
}
// Gamut: 0 where a colour separates and comes back within a ΔE of 3, else out of gamut.
let mut gamut = Vec::with_capacity(GAMUT_GRID.pow(3));
for i in 0..GAMUT_GRID.pow(3) {
let [l, a, b] = [2, 1, 0].map(|d| unit(i / GAMUT_GRID.pow(d) % GAMUT_GRID, GAMUT_GRID));
let want = lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0);
gamut.push(if lab::delta_e2000(want, to_lab(from_lab(want))) <= 3.0 { 0 } else { u16::MAX });
}
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
let mut grid_points = [0u8; 16];
@@ -346,8 +358,9 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
num_output_channels: outputs as u8,
grid_points,
clut: Some(LutStore::Store16(clut)),
// A curves sit on the device side, B curves on the PCS side.
a_curves: identity(4),
// A curves sit on the device side (the gamut tag's is its one output), B curves on the
// PCS side.
a_curves: identity(inputs + outputs - 3),
b_curves: identity(3),
m_curves: vec![],
matrix: Matrix3d::IDENTITY,
@@ -356,6 +369,7 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
};
let a2b = table(4, 3, A2B_GRID, a2b);
let b2a = table(3, 4, B2A_GRID, b2a);
let gamut = table(3, 1, GAMUT_GRID, gamut);
let mut p = ColorProfile::default();
p.profile_class = ProfileClass::OutputDevice;
p.color_space = DataColorSpace::Cmyk;
@@ -364,9 +378,12 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
p.media_white_point = Some(p.white_point);
p.lut_a_to_b_perceptual = Some(a2b.clone());
p.lut_a_to_b_saturation = Some(a2b.clone());
p.lut_a_to_b_colorimetric = Some(a2b);
p.lut_b_to_a_perceptual = Some(b2a.clone());
p.lut_b_to_a_saturation = Some(b2a.clone());
p.lut_b_to_a_colorimetric = Some(b2a);
p.gamut = Some(gamut);
labelled(p, name)
}
+17
View File
@@ -135,3 +135,20 @@ fn written_profiles_align_their_tags() {
}
}
}
/// #2707: the CMYK profiles written here carry every tag ICC.1 requires of an output profile (the
/// tables of all three intents and the gamut tag): Windows rejects one without them, and Photos
/// then can't show a CMYK TIFF that embeds it.
#[test]
fn cmyk_profiles_have_the_tags_of_an_output_profile() {
for name in BUILTIN_CMYK {
let icc = icc_bytes(name).unwrap();
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
let tags: Vec<&[u8]> = (0..be(128)).map(|i| &icc[132 + 12 * i..136 + 12 * i]).collect();
for want in [b"desc", b"cprt", b"wtpt", b"A2B0", b"A2B1", b"A2B2", b"B2A0", b"B2A1", b"B2A2", b"gamt"] {
assert!(tags.contains(&&want[..]), "{name}: no {}", String::from_utf8_lossy(want));
}
let p = parse(&icc);
assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}");
}
}
+12 -1
View File
@@ -274,7 +274,12 @@ impl Unit {
pub fn named(name: &str) -> Option<Unit> {
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given()))
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
// is left to its label and key.
Unit::ALL
.into_iter()
.find(|u| letters(u.label()).eq(given()))
.or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
}
/// A point value as the canvas measurement labels show it: two decimals and the suffix
/// (`12.50 mm`).
@@ -801,6 +806,12 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
pub const FORMAT_VERSION: u32 = 1;
impl Document {
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
/// isn't a number).
pub fn raster_ppi(&self) -> f64 {
Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
}
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
/// Rulers, coordinate fields and guide snapping use the same supported range.
pub fn ruler_origin_or_zero(&self) -> Point {
+7
View File
@@ -98,6 +98,13 @@ fn units_are_named_by_label_key_or_suffix() {
assert_eq!(Unit::named(""), None);
}
#[test]
fn a_shared_suffix_names_the_plain_unit() {
assert_eq!(Unit::named(Unit::Feet.suffix()), Some(Unit::Feet));
assert_eq!(Unit::named("FT"), Some(Unit::Feet));
assert_eq!(Unit::named(Unit::FeetInches.label()), Some(Unit::FeetInches));
}
#[test]
fn readouts_show_two_decimals() {
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
+39
View File
@@ -594,6 +594,45 @@ pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
}
}
/// Grow a `w` × `h` plane by `radius` pixels each way, the box of SVG's feMorphology: each value
/// becomes the largest within the radius. A fractional radius blends the two whole radii around
/// it, so a silhouette grows smoothly as the raster scales. Does nothing for a radius of zero or
/// less, or when the sizes don't match.
pub fn dilate_plane(a: &mut Vec<f32>, w: usize, h: usize, radius: f64) {
if radius.is_nan() || radius <= 0.0 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
return;
}
let radius = radius.min(MAX_BOX as f64);
let whole = radius.floor() as usize;
let frac = (radius - whole as f64) as f32;
let mut tmp = vec![0.0; a.len()];
for (len, other) in [(w, h), (h, w)] {
max_plane(a, &mut tmp, len, whole, frac);
std::mem::swap(a, &mut tmp);
transpose(a, &mut tmp, len, other);
std::mem::swap(a, &mut tmp);
}
}
/// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest
/// value within `whole`, blended by `frac` towards the largest within `whole + 1`.
fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) {
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
for (x, o) in out.iter_mut().enumerate() {
let lo = x.saturating_sub(whole + 1);
let hi = (x + whole + 1).min(w - 1);
let (mut inner, mut ring) = (row[x], row[x]);
for (j, v) in row[lo..=hi].iter().enumerate() {
if (lo + j).abs_diff(x) <= whole {
inner = inner.max(*v);
}
ring = ring.max(*v);
}
*o = inner + (ring - inner) * frac;
}
}
}
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
pub(super) type Px16 = [u16; 4];
+20
View File
@@ -41,6 +41,26 @@ fn fx(id: &str, p: serde_json::Value) -> PixelFx {
}
}
#[test]
fn dilate_plane_grows_by_its_radius_and_blends_a_fraction() {
// GUARDS: an Outer Glow grows its silhouette by a quarter raster pixel before the blur; a step in
// that growth as the raster scales would show as the glow's width jumping with the zoom.
let mut dot = vec![0.0f32; 7 * 7];
dot[3 * 7 + 3] = 255.0;
let mut grown = dot.clone();
dilate_plane(&mut grown, 7, 7, 1.0);
assert_eq!(grown[2 * 7 + 2], 255.0);
assert_eq!(grown[7 + 3], 0.0);
let mut half = dot.clone();
dilate_plane(&mut half, 7, 7, 1.5);
assert_eq!(half[3 * 7 + 4], 255.0);
assert_eq!(half[3 * 7 + 5], 127.5);
assert_eq!(half[3 * 7 + 6], 0.0);
let mut same = dot.clone();
dilate_plane(&mut same, 7, 7, 0.0);
assert_eq!(same, dot);
}
fn grey(v: u8) -> [u8; 4] {
[v, v, v, 255]
}
+6
View File
@@ -29,6 +29,8 @@ pub enum RasterFx {
/// instead of `color`; `None` when it is `color`.
darkness: Option<f32>,
},
/// Blurs the art's solid silhouette grown by a quarter of a pixel of the document's raster grid,
/// as the glows AI files export measure.
OuterGlow {
mode: BlendMode,
opacity: f32,
@@ -93,6 +95,10 @@ impl RasterFx {
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
match self {
RasterFx::Rasterize(r) => r.image(bounds, line),
RasterFx::OuterGlow { .. } => {
let o = self.outset(bounds) + 0.25 * line;
bounds.inflate(o, o)
}
fx => {
let o = fx.outset(bounds);
bounds.inflate(o, o)
+124 -12
View File
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
has_doc,
freehand
),
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Anchor Point",
[],
None,
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
has_doc,
remove_anchor
),
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert Anchor Point",
[],
None,
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
"{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
has_doc,
convert_anchor
),
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape Segment",
[],
None,
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
"{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
has_doc,
reshape_segment
),
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Split Path",
[],
None,
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
has_doc,
split
),
@@ -215,6 +215,15 @@ fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
/// value is [`usize_req`]'s error.
pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(_) => usize_req(p, key, cmd).map(Some),
}
}
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
match &mut n.kind {
@@ -548,6 +557,87 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
}
/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of
/// `pd` to another, each within `within` of it, replaces the section between them (the shorter
/// way round a closed subpath) with the stroke fitted at `tol`. The subpath's index and its new
/// shape; None when the stroke doesn't start and end on the same subpath, or meets it once.
fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option<(usize, SubPath)> {
use kurbo::ParamCurveArclen;
let (first, last) = (*pts.first()?, *pts.last()?);
let (si, _, _, _, d_a) = pd.nearest(first)?;
let (sj, _, _, _, d_b) = pd.nearest(last)?;
if si != sj || d_a > within || d_b > within || pts.len() < 2 {
return None;
}
let mut sp = pd.subpaths.get(si)?.clone();
if sp.anchors.len() < 2 {
return None;
}
// The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each
// point is looked up on the subpath as it is by then: splitting a straight segment makes two
// new ones, which a `t` along the old one doesn't measure.
let place = |sp: &mut SubPath, q: Point| {
let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?;
let n = sp.anchors.len();
Some(if t <= 1e-6 {
(seg % n, false)
} else if t >= 1.0 - 1e-6 {
((seg + 1) % n, false)
} else {
(sp.insert_anchor(seg, t), true)
})
};
let (mut from, _) = place(&mut sp, first)?;
let (to, added) = place(&mut sp, last)?;
if added && to <= from {
from += 1;
}
if from == to {
return None;
}
let n = sp.anchors.len();
// The length of the run of segments from anchor i forward to anchor j.
let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::<f64>();
// The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from
// `end` round to `start`, then the stroke's inner anchors follow.
let (start, end, reversed) = if sp.closed {
if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) }
} else if from < to {
(from, to, false)
} else {
(to, from, true)
};
let mut stroke = pts.to_vec();
if reversed {
stroke.reverse();
}
let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None };
*s0 = s1.p;
let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None };
*e0 = e1.p;
let fit = fit_freehand(&stroke, tol, false).anchors;
let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None };
let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default();
let (before, after): (Vec<Anchor>, Vec<Anchor>) = if sp.closed {
((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![])
} else {
(sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec())
};
let mut anchors = before;
if let Some(a) = anchors.last_mut() {
*a = Anchor::with_handles(a.p, a.h_in, f0.h_out);
}
anchors.extend_from_slice(inner);
let at_end = anchors.len();
anchors.extend(after);
// The anchor the stroke ends on: the first after it, or round to the first of a closed one.
let end_at = if sp.closed { 0 } else { at_end };
if let Some(a) = anchors.get_mut(end_at) {
*a = Anchor::with_handles(a.p, f1.h_in, a.h_out);
}
Some((si, SubPath::new(anchors, sp.closed)))
}
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.freehand";
let samples = samples_param(p, "points", C)?;
@@ -630,6 +720,28 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
})?;
return Ok(json!({ "id": id.0 }));
}
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
let within = f64_or(rd, "within", 12.0);
if !within.is_finite() || within < 0.0 {
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
}
let (pd, _) = path_of(&s.doc()?.doc, id)?;
// shortcut: a stroke pressure profile is left as it was, measured along the old path; fit
// it to the new length if Calligraphic pressure strokes get redrawn.
if let Some((si, sp)) = redraw_section(&pd, &pts, tol, within) {
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
*path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;
if keep_selected {
sel.set([id]);
} else {
sel.remove(id);
}
Ok(())
})?;
return Ok(json!({ "id": id.0 }));
}
}
if pts.len() < 2 {
return Err(bad(C, "need at least two points"));
}
@@ -692,7 +804,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
(None, None) => None,
@@ -756,7 +868,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.removeAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let removed = s.edit("Delete Anchor Point", |d, sel| {
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
@@ -863,7 +975,7 @@ fn remove_anchors_of(
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.convertAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let smooth = str_param(p, "to") == Some("smooth");
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
@@ -898,7 +1010,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.reshapeSegment";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let seg = usize_req(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
@@ -923,9 +1035,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
fn split(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.split";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let anchor = usize_opt(p, "anchor", C)?;
let seg = usize_opt(p, "segment", C)?;
if anchor.is_none() && seg.is_none() {
return Err(bad(C, "need `anchor` or `segment` + `t`"));
}
+21 -2
View File
@@ -481,6 +481,10 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
/// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]).
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
/// either, become RGBA too, whether they store their channels together or one plane per channel.
/// A Targa file, which starts with no signature, is recognized by its header
/// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores
/// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels
/// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw.
/// A TIFF with more strips or tiles than the decoder can count
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
@@ -493,6 +497,9 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
}
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
if reader.format().is_none() && super::tgaread::is_targa(bytes) {
reader.set_format(image::ImageFormat::Tga);
}
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_RASTER_ALLOC);
reader.limits(limits);
@@ -511,13 +518,25 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let cms = vectorcraft_color::cms::active();
vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
};
let img = match (f.id == "tiff").then(extra_samples).flatten() {
let mut img = match (f.id == "tiff").then(extra_samples).flatten() {
Some(img) => img,
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
Some(read) => read.map_err(err)?,
None => reader.decode().map_err(err)?.to_rgba8(),
None => {
use image::ImageDecoder as _;
let decoder = reader.into_decoder().map_err(err)?;
// The allowance counts the decoded samples and the RGBA pixels they become (#1717).
let (w, h) = decoder.dimensions();
if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC {
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
}
image::DynamicImage::from_decoder(decoder).map_err(err)?.to_rgba8()
}
},
};
if f.id == "tga" {
super::tgaread::fix_alpha(bytes, &mut img);
}
as_png(img, ppi)
}
+87 -9
View File
@@ -46,6 +46,7 @@ mod save;
mod screens;
mod svg;
mod text;
mod tgaread;
pub mod zip;
use serde_json::{Value, json};
@@ -206,7 +207,7 @@ pub fn specs() -> Vec<CommandSpec> {
"PDF Info",
[],
None,
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}",
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}; a PostScript file (an EPS, a .ps file, a .ai in PostScript form) → {pages: 1, needsPassword: false, pageInfo: [{width, height (its bounding box, a US Letter page without one), rotation: 0, boxes: {}}]} (password and cropTo don't apply; thumbnail: 1 shows what the page prints)",
always,
pdfimport::pdf_info
),
@@ -646,7 +647,16 @@ pub const FORMATS: &[Format] = &[
},
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
Format {
id: "tga",
label: "Targa",
extensions: &["tga", "vda", "icb", "vst"],
mime: "image/x-tga",
read: true,
write: true,
raster: true,
options: TGA_OPTIONS,
},
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS },
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
// vector app for a raster editor's documents.
@@ -674,6 +684,10 @@ pub const OPEN_EXTS: &[&str] = &[
"tif",
"tiff",
"bmp",
"tga",
"vda",
"icb",
"vst",
"psd",
"psb",
"vctemplate",
@@ -690,9 +704,9 @@ pub const OPEN_EXTS: &[&str] = &[
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
/// them, and the packaging declares no `.epsf` or `.ps` type (File › Open and Place still read
/// them all).
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps"];
/// them, and the packaging declares no `.epsf`, `.ps` or Targa type (File › Open and Place still
/// read them all).
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst"];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
@@ -733,6 +747,10 @@ pub const PLACE_EXTS: &[&str] = &[
"tif",
"tiff",
"bmp",
"tga",
"vda",
"icb",
"vst",
"psd",
"psb",
"vctemplate",
@@ -1085,14 +1103,74 @@ pub(crate) fn absolute_path(path: &str) -> String {
/// stored in, when it has one: a relative path is taken from its folder. Only files on this
/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a
/// path that is still relative (no document) is refused, as the working directory isn't the
/// document's choice. Every stored path reaches the file system through this.
/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive
/// ([`share_path`]): a path refused as on another computer is checked again against the
/// document's path on that share. Every stored path reaches the file system through this.
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
document_path_with(stored, document, share_path)
}
/// [`document_path`], with `share` giving a document's path on the network share behind its drive.
pub(crate) fn document_path_with(
stored: &str,
document: Option<&str>,
share: impl FnOnce(&str) -> Option<String>,
) -> std::result::Result<String, craft_docpath::Refused> {
use craft_docpath::Refused;
let document = document.map(absolute_path);
let path = craft_docpath::resolve(stored, document.as_deref().map(std::path::Path::new))?;
let resolve = |document: Option<&str>| craft_docpath::resolve(stored, document.map(std::path::Path::new));
let path = match resolve(document.as_deref()) {
Err(Refused::Network) => {
let share = document.as_deref().and_then(share).ok_or(Refused::Network)?;
resolve(Some(share.as_str()))?
}
path => path?,
};
if !path.is_absolute() {
return Err(craft_docpath::Refused::Invalid);
return Err(Refused::Invalid);
}
path.into_os_string().into_string().map_err(|_| craft_docpath::Refused::Invalid)
path.into_os_string().into_string().map_err(|_| Refused::Invalid)
}
/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is
/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may
/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as
/// `\\?\UNC\server\share\…`.
#[cfg(windows)]
pub(crate) fn share_path(document: &str) -> Option<String> {
share_of(document, |folder| {
may_read(folder).ok()?;
std::fs::canonicalize(folder).ok()
})
}
#[cfg(not(windows))]
pub(crate) fn share_path(_: &str) -> Option<String> {
None
}
/// The path of `document` on the network share its drive is mapped to, given `final_path`, which
/// returns the final path of a folder: `None` when `document` is not on a drive (`C:\` or `C:/`)
/// or `final_path` gives no share (`\\?\UNC\server\share\…`), such as a folder on a local volume.
#[cfg(any(windows, test))]
pub(crate) fn share_of(document: &str, final_path: impl FnOnce(&str) -> Option<std::path::PathBuf>) -> Option<String> {
let mut chars = document.chars();
if !matches!((chars.next(), chars.next(), chars.next()), (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic()) {
return None;
}
// The drive's root folder keeps its separator (`Z:\`).
let split = document.rfind(['\\', '/'])?;
let (folder, name) = (document.get(..split.max(3))?, document.get(split + 1..)?);
if name.is_empty() {
return None;
}
let target = final_path(folder)?;
let target = target.to_str()?;
let unc = target.get(..8).filter(|p| p.eq_ignore_ascii_case(r"\\?\UNC\")).and(target.get(8..))?.trim_end_matches('\\');
if unc.split('\\').filter(|c| !c.is_empty()).count() < 2 {
return None;
}
Some(format!(r"\\{unc}\{name}"))
}
#[cfg(target_arch = "wasm32")]
+33 -5
View File
@@ -1,6 +1,6 @@
//! Opening and placing PDF (and PDF-compatible `.ai`/`.ait`) files: the options `document.open`
//! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages,
//! boxes, a thumbnail) for the Import PDF dialog.
//! boxes, a thumbnail; one page for a PostScript file) for the Import PDF dialog.
use serde_json::{Value, json};
use vectorcraft_doc::{ColorMode, Document};
@@ -162,6 +162,10 @@ const INFO: &str = "document.pdfInfo";
pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
let bytes = source(p, INFO)?.bytes;
let o = LoadOptions::from_params(INFO, p)?;
// Programs whose header is only `%!` are PostScript too, as `clipboard.importEps` reads them.
if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) {
return postscript_info(&bytes, p);
}
let info = match vectorcraft_pdf::info(&bytes, o.password.as_deref()) {
Ok(i) => i,
Err(e @ (PdfError::NeedsPassword | PdfError::WrongPassword)) => {
@@ -183,10 +187,34 @@ pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
let page = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).filter(|i| *i < info.pages.len());
let page = page.ok_or_else(|| bad(INFO, format!("thumbnail: no page {n} (the PDF has {})", info.pages.len())))?;
let (doc, _) = page_document(&bytes, page, &o)?;
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
let png = super::encode(&doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
out["thumbnail"] = json!(vectorcraft_format::base64_encode(&png));
out["thumbnail"] = json!(thumbnail(&doc, p)?);
}
Ok(out)
}
/// `document.pdfInfo` of a PostScript file (an EPS, a `.ps` file, a `.ai` in PostScript form): one
/// page, the page [`vectorcraft_eps::page`] reads, with no PDF boxes. Its thumbnail is the file as
/// the EPS reader reads it without any editing data it carries: what the page prints, or the
/// file's preview when its PostScript can't be read.
fn postscript_info(bytes: &[u8], p: &Value) -> Result<Value> {
let page = vectorcraft_eps::page(bytes).map_err(err)?;
let info = json!({"width": page.width(), "height": page.height(), "rotation": 0, "boxes": {}});
let mut out = json!({"pages": 1, "needsPassword": false, "pageInfo": [info]});
if let Some(n) = p.get("thumbnail").and_then(Value::as_u64) {
if n != 1 {
return Err(bad(INFO, format!("thumbnail: no page {n} (the file has 1)")));
}
let doc = vectorcraft_eps::import_with(bytes, false).map_err(err)?.document;
out["thumbnail"] = json!(thumbnail(&doc, p)?);
}
Ok(out)
}
/// A PNG of `doc`'s first artboard, `thumbnailSize` px (default 160, from 16 to 1024) on its longer
/// side, base64.
fn thumbnail(doc: &Document, p: &Value) -> Result<String> {
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
let png = super::encode(doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
Ok(vectorcraft_format::base64_encode(&png))
}
+16
View File
@@ -189,6 +189,22 @@ fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
}
/// A 16-bit RGB TIFF whose samples need more than the allowance (6 bytes a pixel), though its RGBA
/// pixels would fit, is refused as too large to draw before it is decoded.
#[test]
fn tiffs_whose_samples_pass_the_allowance_are_too_large() {
let plane = |v: u16| vec![v; 6];
let mut file = hand_tiff(2, 16, &[], false, &[plane(9000), plane(30_000), plane(60_000)]);
// The loop sets ImageWidth, ImageLength and RowsPerStrip (the first, second and eighth entries) to `side`.
let side = 20_000u32;
assert!(u64::from(side * side) * 6 > MAX_RASTER_ALLOC && u64::from(side * side) * 4 <= MAX_RASTER_ALLOC);
for at in [18, 30, 102] {
file[at..at + 4].copy_from_slice(&side.to_le_bytes());
}
let e = raster_image(&file).err().expect("refused");
assert_eq!(e.to_string(), "an image of 20000 × 20000 pixels is too large to draw");
}
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
/// reads (#1466).
#[test]
@@ -1,6 +1,8 @@
//! EPS and PostScript files opened and placed: our own EPS files restore the document they carry,
//! others are read by the PostScript interpreter or come in as their preview.
use std::io::Write as _;
use serde_json::{Value, json};
use super::tests_svgedit::{comparable, rich};
@@ -93,6 +95,33 @@ fn cmyk_images_keep_their_inks_when_an_eps_opens_or_is_placed() {
assert_eq!(images(&s), [want.clone(), want]);
}
/// A 4-bit gray image compressed with Flate and TIFF predictor 2 draws the samples it had before
/// differencing, in an EPS and in a PostScript `.ai`.
#[test]
fn tiff_predicted_4_bit_images_open_from_eps_and_postscript_ai_files() {
// Two rows of four samples, 0 5 15 3 and 9 9 2 0, differenced to 0 5 10 4 and 9 0 9 14.
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
z.write_all(&[0x05, 0xa4, 0x90, 0x9e]).unwrap();
let hex: String = z.finish().unwrap().iter().map(|b| format!("{b:02x}")).collect();
let ps = format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n%%EndComments\n40 20 scale 4 2 4 [4 0 0 -2 0 2] currentfile /ASCIIHexDecode filter \
<< /Predictor 2 /Colors 1 /BitsPerComponent 4 /Columns 4 >> /FlateDecode filter image\n{hex}>\nshowpage\n%%EOF\n"
);
for name in ["predicted.eps", "predicted.ai"] {
let mut s = Session::new();
let r = open(&mut s, name, ps.as_bytes()).unwrap();
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
let d = &s.doc().unwrap().doc;
let mut grays = vec![];
d.walk(|n| {
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
grays = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8().into_raw();
}
});
assert_eq!(grays, [0, 5, 15, 3, 9, 9, 2, 0].map(|v| v * 17), "{name}");
}
}
#[test]
fn an_unreadable_eps_file_comes_in_as_its_preview() {
let mut s = Session::new();
@@ -69,6 +69,33 @@ fn pages_changed_elsewhere_open_as_artwork_with_a_warning() {
assert_eq!(r["warnings"][0], load::EDITING_DAMAGED, "{r}");
}
/// `pdf` with its editing file's description rewritten by another app in UTF-16BE with a byte
/// order mark: "hash " and the hash it held, padded with spaces to its length.
fn utf16_described(pdf: &[u8]) -> Vec<u8> {
let from = b"(VectorCraft editing data; page hash ";
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
let mut desc = b"(\xFE\xFF".to_vec();
desc.extend(b"hash ".iter().chain(hash).flat_map(|&c| [0, c]));
desc.push(b')');
desc.resize(len, b' ');
patched(pdf, &pdf[at..at + len], &desc)
}
#[test]
fn pages_changed_elsewhere_are_noticed_with_a_utf16_description() {
let mut s = rich();
let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap());
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
for (name, file) in [("edited.pdf", pdf), ("edited.ai", ai)] {
let r = open(&mut s, name, &utf16_described(&file));
assert_eq!((&r["warnings"], &r["restored"]), (&json!([]), &json!(true)), "{name}: the same pages: {r}");
let edited = patched(&file, b"/MediaBox[0 0 300 200]", b"/MediaBox[0 0 300 201]");
let r = open(&mut s, name, &utf16_described(&edited));
assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_STALE), &json!(false)), "{name}: {r}");
}
}
#[test]
fn the_legacy_payload_name_is_accepted() {
let mut s = rich();
@@ -157,27 +157,31 @@ fn a_postscript_ai_file_says_it_cannot_be_opened_yet() {
let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string();
assert!(e.contains(name) && e.contains("PostScript") && e.contains("save it as PDF"), "{name}: {e}");
}
// An EPS with a binary header too, and the PDF info of either.
// An EPS with a binary header too, and the PDF info of each. The plain program's PDF info gives
// its page, and its thumbnail gives the reader's error; the binary file's header points at no
// PostScript, so its PDF info is an error.
let mut binary = vec![0xC5, 0xD0, 0xD3, 0xC6];
binary.extend_from_slice(&[0; 28]);
binary.extend_from_slice(ps);
assert!(s.execute("document.open", &json!({"name": "x.eps", "dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string().contains("PostScript"));
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap();
assert_eq!((v["pages"].as_u64(), v["pageInfo"][0]["width"].as_f64()), (Some(1), Some(100.0)), "{v}");
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps), "thumbnail": 1})).unwrap_err().to_string();
assert!(e.contains("PostScript") && e.contains("frobnicate"), "{e}");
assert!(s.active().is_none());
}
/// PostScript given where a PDF is read (`document.pdfInfo`, `clipboard.importPdf`) is refused
/// with an error that names File › Open and File › Place, and both of those read the file.
/// PostScript given where a PDF is read (`clipboard.importPdf`) is refused with an error that
/// names File › Open and File › Place, and both of those read the file.
#[test]
fn postscript_read_as_a_pdf_names_open_and_place() {
let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor 10 10 50 50 rectfill\nshowpage\n%%EOF\n";
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for cmd in ["document.pdfInfo", "clipboard.importPdf"] {
let e = s.execute(cmd, &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{cmd}: {e}");
assert!(!e.contains("can't be opened"), "{cmd}: {e}");
}
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{e}");
assert!(!e.contains("can't be opened"), "{e}");
for name in ["legacy.ai", "art.eps"] {
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap();
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
@@ -186,6 +190,44 @@ fn postscript_read_as_a_pdf_names_open_and_place() {
}
}
/// `document.pdfInfo` reads PostScript (an EPS, a `.ai` in PostScript form, a `.ps` file) as one
/// page, its bounding box or a letter page without one, and its thumbnail shows that page. Each
/// file here opens on that page with `editingData: false`.
#[test]
fn pdf_info_reads_postscript_as_one_page() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
let data = |r: Value| vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let eps = data(s.execute("document.exportEps", &json!({"useArtboards": true})).unwrap());
let ai = data(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap());
assert!(eps.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) && ai.starts_with(b"%!PS"));
let hires = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 101 41\n%%HiResBoundingBox: 0 0 100.5 40.25\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
let letter = b"%!PS\n1 0 0 setrgbcolor 72 72 144 72 rectfill\nshowpage\n";
let bare = b"%!\n0 0 1 setrgbcolor 100 100 50 50 rectfill\nshowpage\n";
for (name, bytes, size) in [
("art.eps", &eps[..], (300.0, 200.0)),
("legacy.ai", &ai, (101.0, 51.0)),
("hires.eps", hires, (100.5, 40.25)),
("page.ps", letter, (612.0, 792.0)),
("bare.ps", bare, (612.0, 792.0)),
] {
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 100})).unwrap();
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap();
assert_eq!(img.width().max(img.height()), 100, "{name}");
let mut v = v;
v["thumbnail"] = json!("png");
let page = json!({"width": size.0, "height": size.1, "rotation": 0, "boxes": {}});
assert_eq!(v, json!({"pages": 1, "needsPassword": false, "pageInfo": [page], "thumbnail": "png"}), "{name}");
s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes), "editingData": false})).unwrap();
let board = s.doc().unwrap().doc.artboards[0].rect;
assert_eq!((board.width(), board.height()), size, "{name}: the page document.open reads");
}
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&eps), "thumbnail": 2})).unwrap_err().to_string();
assert!(e.contains("no page 2"), "{e}");
}
/// A page showing only text, with an editor's private data when `private`.
fn text_page(private: bool, also: &str) -> PdfPage {
PdfPage {
+148 -2
View File
@@ -1,5 +1,5 @@
//! TIFF, BMP and Targa export through `document.export`: the options reach the files, impossible
//! ones are refused, and the files open again.
//! ones are refused, and the files open again; Targa files open and place.
use serde_json::{Value, json};
@@ -135,6 +135,152 @@ fn targa_depth_reaches_the_file() {
}
}
/// The RGBA pixels of the image `document.open` or `file.place` stored for the first object of the
/// active document's first layer.
fn stored_pixels(s: &Session) -> image::RgbaImage {
let doc = &s.doc().unwrap().doc;
let im = match &doc.layers[0].children().unwrap()[0].kind {
vectorcraft_doc::NodeKind::Image(im) => im.clone(),
other => panic!("expected an image, got {other:?}"),
};
image::load_from_memory(&doc.images[&im.key].bytes).unwrap().to_rgba8()
}
/// A Targa header: no image id or color map, image type `kind`, `w` × `h` pixels of `depth` bits,
/// image descriptor `descriptor`.
fn targa_header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec<u8> {
let mut f = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0];
f.extend(w.to_le_bytes());
f.extend(h.to_le_bytes());
f.extend([depth, descriptor]);
f
}
/// Targa files open and place at their pixel size with the transparency they store, by any of the
/// four Targa extensions; copied in a file manager they paste as a bitmap; the dialogs list them.
#[test]
fn targa_files_open_and_place() {
use crate::cmd::clipboard::{PASTE_ORDER, file_flavour};
let mut s = session();
let b64 = vectorcraft_format::base64_encode;
// VectorCraft's own exports: the transparent corner stays transparent at 16 and 32 bits (24
// bits are flattened on white) and the black rectangle stays black.
for depth in [16, 24, 32] {
let tga = export(&mut s, json!({"format": "tga", "depth": depth}));
let r = s.execute("document.open", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
assert_eq!((&r["format"], &r["warnings"]), (&json!("tga"), &json!([])), "{depth} bits: {r}");
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(60.0, 40.0));
let px = stored_pixels(&s);
assert_eq!(px.dimensions(), (60, 40));
let corner = if depth == 24 { [255; 4] } else { [0, 0, 0, 0] };
assert_eq!(px.get_pixel(5, 5).0[3], corner[3], "{depth} bits: the corner's alpha");
if depth == 24 {
assert_eq!(px.get_pixel(5, 5).0, corner, "flattened on white");
}
let black = px.get_pixel(40, 20).0;
assert!(black[3] == 255 && black[..3].iter().all(|c| *c < 80), "{depth} bits: {black:?}");
// The next export starts from the session's document again.
s = session();
}
let tga = export(&mut s, json!({"format": "tga", "depth": 32}));
for name in ["art.vda", "ART.ICB", "art.vst"] {
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(&tga)})).unwrap();
assert_eq!(r["format"], "tga", "{name}: {r}");
}
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
let r = s.execute("file.place", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("tga"), Some(1)), "{r}");
assert_eq!(stored_pixels(&s).get_pixel(5, 5).0[3], 0, "placed with its transparency");
// Copied in a file manager, a Targa file pastes as a bitmap.
assert_eq!(file_flavour("art.tga", &tga, &PASTE_ORDER), Some("image/x-tga"));
let r = s.execute("clipboard.importImage", &json!({"mime": "image/x-tga", "dataBase64": b64(&tga)})).unwrap();
assert_eq!((&r["count"], &r["pixelWidth"], &r["pixelHeight"]), (&json!(1), &json!(60), &json!(40)), "{r}");
// The dialogs list the four extensions; the desktop packaging doesn't associate them.
for e in ["tga", "vda", "icb", "vst"] {
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
}
let targa = ("Targa", &["tga", "vda", "icb", "vst"][..]);
assert!(open_filters().any(|f| f == targa) && place_filters().any(|f| f == targa));
}
/// Targa pixels in every layout: run-length encoded rows stored top down (as the `image` crate
/// writes them), a 16-bit file whose one attribute bit per pixel is its alpha, with runs, stored
/// right to left and top down, 32-bit files with no attribute bits (opaque), with eight, and with
/// each Attributes Type in an extension area, and color-mapped files whose 16-bit entries carry the
/// attribute bit.
#[test]
fn targa_layouts_and_alpha_read() {
let src = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 200, 50, if x == 1 { 128 } else { 255 }]));
let mut rle = Vec::new();
src.write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
assert_eq!((rle[2], rle[17] & 0x20), (10, 0x20), "run-length encoded true color, stored top down");
assert_eq!(raster_image(&rle).map(|r| image::load_from_memory(&r.blob.bytes).unwrap().to_rgba8()).unwrap(), src);
// 3 × 2, 5-5-5 color and an attribute bit, right to left (bit 4), top down (bit 5): a raw
// packet of red, blue and a clear pixel for the first row, then a run of green.
let mut f = targa_header(10, 3, 2, 16, 0x31);
f.push(0x02);
f.extend([0xfc00u16, 0x801f, 0x0000].iter().flat_map(|v| v.to_le_bytes()));
f.push(0x82);
f.extend(0x83e0u16.to_le_bytes());
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
let px = |x, y| img.get_pixel(x, y).0;
assert_eq!((px(2, 0), px(1, 0), px(0, 0)[3]), ([255, 0, 0, 255], [0, 0, 255, 255], 0), "the first row, right to left");
assert!((0..3).all(|x| px(x, 1) == [0, 255, 0, 255]), "the run");
// 2 × 1 at 32 bits with no attribute bits: the fourth byte isn't alpha, whatever it holds.
for fourth in [[0, 0], [0, 255]] {
let mut f = targa_header(2, 2, 1, 32, 0x20);
f.extend([10, 20, 30, fourth[0], 40, 50, 60, fourth[1]]);
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([30, 20, 10, 255], [60, 50, 40, 255]), "{fourth:?}");
}
// The same with eight attribute bits: the fourth byte is alpha, also when it is 0 everywhere.
let mut f = targa_header(2, 2, 1, 32, 0x28);
f.extend([10, 20, 30, 0, 40, 50, 60, 0]);
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
assert!(img.pixels().all(|p| p.0[3] == 0));
// The extension area's Attributes Type: undefined data (1, 2) isn't alpha, premultiplied colors
// (4) are divided by their alpha, and alpha (3) or 0, the field left unset, keeps it.
for (kind, want) in [(0, [50, 50, 50, 128]), (1, [50, 50, 50, 255]), (2, [50, 50, 50, 255]), (3, [50, 50, 50, 128]), (4, [100, 100, 100, 128])] {
let mut f = targa_header(2, 1, 1, 32, 0x28);
f.extend([50, 50, 50, 128]);
let mut area = vec![0; 495];
area[..2].copy_from_slice(&495u16.to_le_bytes());
area[494] = kind;
let at = f.len() as u32;
f.extend(area);
f.extend(at.to_le_bytes());
f.extend([0; 4]);
f.extend(b"TRUEVISION-XFILE.\0");
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
assert_eq!(img.get_pixel(0, 0).0, want, "Attributes Type {kind}");
}
// Color-mapped, one attribute bit, top down: 16-bit entries 5 (clear black) and 6 (red), as a
// raw row of 8-bit indices and as runs.
for (kind, data) in [(1, vec![6, 5, 6]), (9, vec![0x80, 6, 0x00, 5, 0x80, 6])] {
let mut f = vec![0, 1, kind, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 1, 0, 8, 0x21];
f.extend([0x0000u16, 0xfc00].iter().flat_map(|v| v.to_le_bytes()));
f.extend(data);
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
let row: Vec<[u8; 4]> = (0..3).map(|x| img.get_pixel(x, 0).0).collect();
assert_eq!(row, [[255, 0, 0, 255], [0, 0, 0, 0], [255, 0, 0, 255]], "image type {kind}");
}
// A grayscale image the decoder could hold whose RGBA pixels pass the allowance is refused from
// its header.
let huge = targa_header(11, 32768, 32768, 8, 0);
let e = raster_image(&huge).err().expect("refused");
assert_eq!(e.to_string(), "an image of 32768 × 32768 pixels is too large to draw");
// Headers with values the specification doesn't define aren't Targa files.
for bad in [targa_header(2, 0, 1, 24, 0), targa_header(1, 1, 1, 8, 0), targa_header(2, 1, 1, 12, 0), targa_header(2, 1, 1, 24, 0xc0)] {
assert!(!super::tgaread::is_targa(&bad), "{bad:?}");
}
assert!(raster_image(b"nothing an image reader knows").is_err());
}
#[test]
fn the_formats_are_listed_and_write_one_file_per_artboard() {
let mut s = Session::new();
@@ -143,7 +289,7 @@ fn the_formats_are_listed_and_write_one_file_per_artboard() {
let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone();
let (tiff, bmp, tga) = (find("tiff"), find("bmp"), find("tga"));
assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true)));
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(false)));
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(true)));
for (f, keys) in [
(&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..]),
(&bmp, &["colorModel", "depth", "fileFormat", "rle", "flipRows", "reduction", "dither"]),
+152
View File
@@ -0,0 +1,152 @@
//! Reading Targa files (`.tga`, `.vda`, `.icb`, `.vst`) for File › Open and File › Place, from the
//! Truevision TGA File Format Specification, version 2.0. The `image` crate decodes the pixels.
//! A Targa file starts with no signature, so [`is_targa`] checks its header's fields instead, and
//! [`fix_alpha`] gives the decoded image the transparency the file stores. The file is untrusted:
//! every read is checked.
/// Does `bytes` start with a Targa header? The fields must hold values the specification defines:
/// a color map type of 0 or 1; an image type of 1, 2, 3, 9, 10 or 11 (color-mapped, true-color or
/// black-and-white, raw or run-length encoded), with a color map for the color-mapped types; a
/// color map entry size of 15, 16, 24 or 32 bits when there is a map; a width and a height; a pixel
/// depth of 8, 15, 16, 24 or 32 bits; and bits 6 and 7 of the image descriptor clear.
pub(super) fn is_targa(bytes: &[u8]) -> bool {
let [_, map_type, image_type, _, _, _, _, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
return false;
};
matches!(map_type, 0 | 1)
&& matches!(image_type, 1 | 2 | 3 | 9 | 10 | 11)
&& (map_type == 1 || !matches!(image_type, 1 | 9))
&& (map_type == 0 || matches!(entry_bits, 15 | 16 | 24 | 32))
&& u16::from_le_bytes([w0, w1]) > 0
&& u16::from_le_bytes([h0, h1]) > 0
&& matches!(depth, 8 | 15 | 16 | 24 | 32)
&& descriptor & 0xc0 == 0
}
/// Give `img`, decoded from Targa file `bytes`, the transparency the file stores. Bits 0 to 3 of
/// the image descriptor give the number of attribute bits per pixel, and the extension area's
/// Attributes Type, when the file has one, says what they hold:
/// - with no attribute bits, or attribute data the Attributes Type calls undefined (1 or 2), the
/// image is opaque, whatever a 32-bit pixel's fourth byte holds (the decoder reads it as alpha);
/// - with one bit, a 16-bit true-color pixel takes that bit as its alpha (clear: transparent), and
/// a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; the decoder drops
/// the bit;
/// - with more, the decoder's alpha stays, and colors the Attributes Type calls premultiplied (4)
/// are divided by it.
///
/// An Attributes Type of 0 ("no alpha") with attribute bits in the descriptor counts as alpha.
pub(super) fn fix_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
let Some(bits) = bytes.get(17).map(|d| d & 0x0f) else { return };
let kind = attributes_type(bytes);
if bits == 0 || matches!(kind, Some(1 | 2)) {
img.pixels_mut().for_each(|p| p.0[3] = 255);
return;
}
if bits == 1 {
one_bit_alpha(bytes, img);
}
if kind == Some(4) {
for p in img.pixels_mut() {
let a = u16::from(p.0[3]);
for c in &mut p.0[..3] {
// A fully transparent pixel keeps its colors.
if let Some(v) = (u16::from(*c) * 255 + a / 2).checked_div(a) {
*c = v.min(255) as u8;
}
}
}
}
}
/// The Attributes Type of a Targa 2.0 file's extension area: `None` without the footer's
/// `TRUEVISION-XFILE.` signature, an extension area, or one of the 495 bytes the specification
/// gives it.
fn attributes_type(bytes: &[u8]) -> Option<u8> {
let footer = bytes.len().checked_sub(26)?;
if bytes.get(footer + 8..)? != b"TRUEVISION-XFILE.\0" {
return None;
}
let at = usize::try_from(u32::from_le_bytes(bytes.get(footer..footer + 4)?.try_into().ok()?)).ok()?;
// The area follows the header and ends before the footer; its first field is its size.
if at < 18 || at.checked_add(495)? > footer || u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?]) != 495 {
return None;
}
bytes.get(at + 494).copied()
}
/// The attribute bit of each pixel of a Targa image with one attribute bit per pixel, as `img`'s
/// alpha: the top bit of a 16-bit true-color pixel, or of the 16-bit color map entry an 8- or
/// 16-bit index names, raw or run-length encoded. Rows and columns are stored in the order the
/// image descriptor's bits 5 (top to bottom) and 4 (right to left) give. Other images, and pixels
/// the data doesn't reach, keep the alpha they have.
fn one_bit_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
let [id_len, map_type, image_type, o0, o1, m0, m1, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
return;
};
let (w, h) = (u32::from(u16::from_le_bytes([w0, w1])), u32::from(u16::from_le_bytes([h0, h1])));
let mapped = matches!(image_type, 1 | 9);
// The number of bytes each pixel takes.
let size = match image_type {
2 | 10 if depth == 16 => 2,
1 | 9 if map_type == 1 && entry_bits == 16 && matches!(depth, 8 | 16) => usize::from(depth / 8),
_ => return,
};
if img.dimensions() != (w, h) {
return;
}
let (first, entries) = (u16::from_le_bytes([o0, o1]), u16::from_le_bytes([m0, m1]));
let map_at = 18 + usize::from(id_len);
let map_len = if map_type == 1 { usize::from(entries) * usize::from(entry_bits).div_ceil(8) } else { 0 };
// The attribute bit is the top bit of the little-endian pixel or color map entry.
let attribute = |at: usize| -> Option<bool> {
let px = bytes.get(at..at.checked_add(size)?)?;
let high = if mapped {
let index = match *px {
[i] => u16::from(i),
[lo, hi] => u16::from_le_bytes([lo, hi]),
_ => return None,
};
let entry = index.checked_sub(first).filter(|e| *e < entries)?;
*bytes.get(map_at + usize::from(entry) * 2 + 1)?
} else {
*px.get(1)?
};
Some(high & 0x80 != 0)
};
let (top_down, right_to_left) = (descriptor & 0x20 != 0, descriptor & 0x10 != 0);
let total = u64::from(w) * u64::from(h);
let mut set = |i: u64, opaque: bool| {
let (row, col) = ((i / u64::from(w)) as u32, (i % u64::from(w)) as u32);
let y = if top_down { row } else { h - 1 - row };
let x = if right_to_left { w - 1 - col } else { col };
img.get_pixel_mut(x, y).0[3] = if opaque { 255 } else { 0 };
};
let mut at = map_at + map_len;
let mut i = 0;
while i < total {
if image_type < 9 {
let Some(opaque) = attribute(at) else { return };
set(i, opaque);
(i, at) = (i + 1, at + size);
continue;
}
let Some(&packet) = bytes.get(at) else { return };
let count = u64::from(packet & 0x7f) + 1;
at += 1;
if packet & 0x80 != 0 {
// A run packet repeats one pixel.
let Some(opaque) = attribute(at) else { return };
at += size;
for _ in 0..count.min(total - i) {
set(i, opaque);
i += 1;
}
} else {
for _ in 0..count.min(total - i) {
let Some(opaque) = attribute(at) else { return };
set(i, opaque);
(i, at) = (i + 1, at + size);
}
}
}
}
+7 -2
View File
@@ -341,6 +341,7 @@ pub(crate) fn transform_each_again(s: &mut Session, p: &Value) -> Result<Value>
/// repeats; a dialog's preview records it as it commits (`Session::commit_interaction`).
fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
let roots = selected_roots(s)?;
check_numbers(p, &["scaleH", "scaleV", "moveH", "moveV", "rotate"], "object.transformEach")?;
// Bounded so coordinates stay finite (and the document stays serializable).
let sh = f64_or(p, "scaleH", 100.0).clamp(-100_000.0, 100_000.0);
let sv = f64_or(p, "scaleV", 100.0).clamp(-100_000.0, 100_000.0);
@@ -480,6 +481,7 @@ impl Rasterized {
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels.
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str) -> Result<Rasterized> {
check_numbers(p, &["ppi"], cmd)?;
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
if !(1.0..=2400.0).contains(&ppi) {
return Err(bad(cmd, "ppi must be between 1 and 2400"));
@@ -600,6 +602,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
check_numbers(p, &["width", "height", "spacingX", "spacingY"], C)?;
let (nw, nh) = (f64_or(p, "width", b.width()).max(0.1), f64_or(p, "height", b.height()).max(0.1));
let resize = Affine::translate(b.origin().to_vec2())
* Affine::scale_non_uniform(nw / b.width().max(1e-9), nh / b.height().max(1e-9))
@@ -671,8 +674,8 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
return trim_image(s, id, (**im).clone());
}
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
let rect = match p.get("rect").and_then(Value::as_array) {
Some(a) if a.len() == 4 => {
let rect = match p.get("rect").filter(|v| !v.is_null()) {
Some(Value::Array(a)) if a.len() == 4 => {
let v: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
if v.len() != 4 {
return Err(bad(C, "rect must be [x, y, width, height]"));
@@ -832,6 +835,7 @@ impl Session {
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.createTrimMarks";
check_numbers(p, &["offset", "length", "bleed", "weight"], C)?;
let style = match str_param(p, "style") {
Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
None => s.crop_mark_style(),
@@ -1001,6 +1005,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
L::Row => (1, n_ab),
L::Column => (n_ab, 1),
};
check_numbers(p, &["spacing"], CMD)?;
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
let move_art = bool_or(p, "moveArtwork", true);
let locked_and_hidden = s.prefs.move_locked_with_artboard;
+17 -7
View File
@@ -7,6 +7,7 @@ use vectorcraft_doc::{NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
use super::create::anchor_from_json;
use super::draw2::usize_opt;
use super::pathops::Beside;
use super::*;
use crate::EngineError;
@@ -37,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Direction Handle",
[],
None,
"{id, subpath, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
has_doc,
set_handle
),
@@ -100,11 +101,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape",
[],
None,
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius (default: half the subpath's size)",
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius, measured along the path (default: half the subpath's size)",
has_doc,
reshape
),
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor),
cmd!(
"path.cutAtAnchors",
"Cut Path at Selected Anchor Points",
@@ -215,7 +216,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
let out = str_param(p, "which") != Some("in");
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
@@ -680,18 +681,27 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
Some((i, dist)) if dist <= tol => i,
_ => sp.insert_anchor(seg, t),
};
let origin = sp.anchors[grabbed].p;
// Each anchor's distance along the path from the grabbed one, the shorter way round a
// closed path: the lengths are measured once, as a path can have many anchors.
let pos = sp.anchor_arc_positions();
let total = pos.last().copied().unwrap_or(0.0);
// The default reach is the subpath's size, as before, now measured along it.
let r = radius.unwrap_or_else(|| {
let mut bp = vectorcraft_geom::BezPath::new();
sp.to_bezpath_into(&mut bp);
let b = vectorcraft_geom::Shape::bounding_box(&bp);
(b.width().hypot(b.height()) / 2.0).max(1.0)
});
let from = pos.get(grabbed).copied().unwrap_or(0.0);
let closed = sp.closed;
for (i, a) in sp.anchors.iter_mut().enumerate() {
let w = if i == grabbed {
1.0
} else {
let q = (a.p.distance(origin) / r).min(1.0);
let d = (pos.get(i).copied().unwrap_or(f64::INFINITY) - from).abs();
let d = if closed { d.min(total - d) } else { d };
// A distance or radius that isn't finite gives NaN here, which `min` reads as 1: no move.
let q = (d / r).min(1.0);
(1.0 - q * q).powi(2)
};
let v = delta * w;
@@ -706,7 +716,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
let idx = s.edit("Add Anchor Point", |d, _| {
+32
View File
@@ -89,6 +89,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
anchors_many
),
cmd!(
"select.color",
"Select by Color",
[],
None,
"{fill?: colour, stroke?: colour (as paint.setFill's color; at least one)} select the objects whose fill (and stroke) reads as that colour in #rrggbb, as document.inspect reports colours, whatever its swatch or tint; with both, the objects that match both. No object needs to be selected first; Select > Reselect repeats it → {count}",
has_doc,
select_color
),
cmd!(
"select.same.fillColor",
"Fill Color",
@@ -528,6 +537,29 @@ fn stroke_weight(n: &Node) -> f64 {
if paint.is_none() { 0.0 } else { width }
}
/// Select by Color: the objects whose fill and stroke read as the colours given, compared as the
/// `#rrggbb` `document.inspect` reports, so a script can pick art by colour without an object id.
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.color";
let hex = |key: &str| match p.get(key).filter(|v| !v.is_null()) {
None => Ok(None),
Some(v) => color_value(v).map(|c| Some(c.to_hex())).ok_or_else(|| bad(C, format!("`{key}`: not a colour: {v}"))),
};
let (fill, stroke) = (hex("fill")?, hex("stroke")?);
if fill.is_none() && stroke.is_none() {
return Err(bad(C, "give `fill`, `stroke` or both"));
}
let reads_as = |paint: Paint, hex: Option<&str>| hex.is_none_or(|h| paint.color().is_some_and(|c| c.to_hex() == h));
select_where(s, C, p, |d, n| paint_reported(d, n) && reads_as(fill_of(n), fill.as_deref()) && reads_as(stroke_of(n).0, stroke.as_deref()))
}
/// Does `document.inspect` report a fill and stroke for `n`? A compound path's are its own, though
/// it holds its members, and its members report none.
fn paint_reported(d: &Document, n: &Node) -> bool {
let compound = |n: &Node| matches!(n.kind, NodeKind::Compound { .. });
compound(n) || (!n.is_container() && !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound))
}
fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
let st = s.doc()?;
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
+3 -2
View File
@@ -87,10 +87,11 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(s.menu.wand))
}
/// The fill an object shows: type's comes from its characters (the first run speaks for all).
/// The fill an object shows: type's comes from its characters (the first run speaks for all; type
/// without any, the default character fill, as `document.inspect` reports it).
pub(crate) fn fill_of(n: &Node) -> Paint {
match &n.kind {
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_default(),
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_else(|| vectorcraft_doc::CharStyle::default().fill),
_ => n.appearance.fill_paint(),
}
}
+46
View File
@@ -2343,6 +2343,29 @@ fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
assert!(pts.iter().filter(|p| (p.y - 100.0).abs() < 1e-9).count() == 2);
}
#[test]
fn reshape_falloff_follows_arc_length_on_open_paths() {
let mut s = session();
let id = NodeId(
s.execute(
"path.create",
&json!({
"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 200, "y": 0}, {"x": 300, "y": 0}],
"closed": false
}),
)
.unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
let pts: Vec<_> = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect();
assert!((pts[3].y - 100.0).abs() < 1e-6, "grabbed end moves full delta: {pts:?}");
assert!(pts[0].y.abs() < 1e-6, "far end stays put: {pts:?}");
assert!(pts[2].y > 1.0 && pts[2].y < 100.0, "mid-anchor moves partially: {pts:?}");
assert!(pts[1].y < pts[2].y, "nearer anchors move more: {pts:?}");
}
#[test]
fn graph_tool_drag_creates_a_graph_and_asks_for_data() {
let mut s = session();
@@ -2429,6 +2452,29 @@ fn object_mosaic_refuses_columns_and_rows_that_are_not_whole_numbers() {
assert_eq!(r["tiles"], json!(2));
}
/// A new width or height, or a spacing, given as text or as another value that is not a number is an error naming the
/// key, and no mosaic is made.
#[test]
fn object_mosaic_refuses_a_size_or_spacing_given_as_text() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 50.0);
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let before = undo(&s);
for (p, msg) in [
(json!({"width": "200"}), "`width` must be a number, not \"200\""),
(json!({"columns": 2, "height": "50 pt"}), "`height` must be a number, not \"50 pt\""),
(json!({"spacingX": "4"}), "`spacingX` must be a number, not \"4\""),
(json!({"spacingY": [4]}), "`spacingY` must be a number, not [4]"),
] {
let r = s.execute("object.createObjectMosaic", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
assert_eq!(undo(&s), before, "{p}");
}
let r = s.execute("object.createObjectMosaic", &json!({"columns": 2, "rows": 1, "width": 200, "spacingX": null})).unwrap();
assert_eq!(r["tiles"], json!(2));
}
#[test]
fn snap_to_pixel_rounds_drawing_and_moves() {
let mut s = session();
+181
View File
@@ -130,6 +130,62 @@ fn pencil_continues_selected_open_path() {
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 100.0));
}
/// Edit Selected Paths in the middle of a path (#2850): a Pencil stroke that starts and ends on a
/// selected path replaces the section between, drawn either way; the path stays one path with
/// its ends, and is one undo step.
#[test]
fn pencil_redraws_the_section_of_a_selected_path_between_its_ends() {
let bump = [(100.0, 101.0), (125.0, 70.0), (150.0, 60.0), (175.0, 70.0), (200.0, 101.0)];
for reversed in [false, true] {
let mut s = session();
let id = line(&mut s, 0.0, 100.0, 300.0, 100.0);
let mut pts = bump.to_vec();
if reversed {
pts.reverse();
}
let undo = undo_steps(&s);
drag(&mut s, "pencil", &pts, Mods::default());
assert_eq!(paths(&s).len(), 1, "no new object");
assert_eq!(undo_steps(&s), undo + 1);
let sp = &path(&s, id).subpaths[0];
assert!(!sp.closed);
let ps: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
assert!(near(ps[0], Point::new(0.0, 100.0)) && near(*ps.last().unwrap(), Point::new(300.0, 100.0)), "{ps:?}");
// The section starts and ends on the line, where the stroke met it, and rises between.
let on = |x: f64| ps.iter().any(|p| (p.x - x).abs() < 1.5 && (p.y - 100.0).abs() < 1e-6);
assert!(on(100.0) && on(200.0), "{reversed}: {ps:?}");
let top = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath()).y0;
assert!(top < 75.0, "the bump: top {top}");
assert!(ps.windows(2).all(|w| w[0].x <= w[1].x + 1.0), "in order along the line: {ps:?}");
}
}
/// On a closed path the stroke replaces the shorter way between where it starts and ends, and the
/// path stays closed; a stroke that doesn't end on the path draws a new one.
#[test]
fn pencil_redraws_the_shorter_way_round_a_closed_path() {
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 200.0, 100.0);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("path.freehand", &json!({"points": [[50, 1], [80, -25], [100, -30], [120, -25], [150, 1]], "redraw": {"id": id.0, "within": 4}}))
.unwrap();
assert_eq!(paths(&s).len(), 1);
let sp = &path(&s, id).subpaths[0];
assert!(sp.closed);
let ps: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
for corner in [(0.0, 0.0), (200.0, 0.0), (200.0, 100.0), (0.0, 100.0)] {
assert!(ps.iter().any(|p| near(*p, Point::new(corner.0, corner.1))), "{corner:?} kept: {ps:?}");
}
let top = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath()).y0;
assert!(top < -20.0, "the dip: top {top}");
// Not ending on the path: a new path, the rectangle as it was.
let before = path(&s, id);
s.execute("path.freehand", &json!({"points": [[50, 100], [80, 150], [120, 160]], "redraw": {"id": id.0, "within": 4}})).unwrap();
assert_eq!(paths(&s).len(), 2);
assert_eq!(path(&s, id), before);
assert!(s.execute("path.freehand", &json!({"points": [[0, 0], [1, 1]], "redraw": {"id": id.0, "within": -1}})).is_err());
}
/// With Keep selected off, the path a stroke draws or continues is not selected afterwards, the
/// rest of the selection stays, and the stroke is one undo step.
#[test]
@@ -811,6 +867,131 @@ fn draw2_commands_reject_bad_params() {
}
}
/// A path of two open subpaths, each of three anchors from x 100 to x 300 with a middle anchor that
/// has two handles: subpath 0 at y 100, subpath 1 at y 300.
fn two_subpaths(s: &mut Session) -> NodeId {
let id = curve(s);
let sub = |y: f64| {
let anchors = json!([
{"x": 100, "y": y, "out": [130, y - 30.0]},
{"x": 200, "y": y, "in": [170, y - 30.0], "out": [230, y - 30.0]},
{"x": 300, "y": y, "in": [270, y - 30.0]},
]);
json!({"anchors": anchors, "closed": false})
};
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [sub(100.0), sub(300.0)]})).unwrap();
id
}
/// Runs `cmd` with `p` and `key` set to each of 1.5, -1 and "1": each call fails with an error that
/// names `key`, and the document and the undo history stay as they were.
fn refuses_an_index_that_is_not_whole(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
for v in [json!(1.5), json!(-1), json!("1")] {
let mut p = p.clone();
p[key] = v;
let before = s.doc().unwrap().doc.clone();
let undo = undo_steps(s);
let Err(e) = s.execute(cmd, &p) else { panic!("{cmd} {p} succeeded") };
assert!(e.to_string().contains(&format!("`{key}`")), "{cmd} {p}: {e}");
assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document");
assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step");
}
}
/// Runs `cmd` with `p` on path `id` (made by [`two_subpaths`]) with `subpath: 1`, without `subpath`
/// and with `subpath: null`, undoing each: the first changes subpath 1 alone, and the others change
/// subpath 0 alone.
fn edits_the_subpath_given(s: &mut Session, cmd: &str, p: &serde_json::Value, id: NodeId) {
let before = path(s, id).subpaths;
for (si, p) in [(1, with(p, json!({"subpath": 1}))), (0, p.clone()), (0, with(p, json!({"subpath": null})))] {
s.execute(cmd, &p).unwrap();
let after = path(s, id).subpaths;
assert_eq!(after.len(), 2, "{cmd} {p}");
assert!(after[si] != before[si] && after[1 - si] == before[1 - si], "{cmd} {p}: {after:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(s, id).subpaths, before, "{cmd} {p}: undo");
}
}
#[test]
fn curvature_edit_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "op": "move", "anchor": 1, "x": 200, "y": 150});
refuses_an_index_that_is_not_whole(&mut s, "path.curvatureEdit", &p, "subpath");
edits_the_subpath_given(&mut s, "path.curvatureEdit", &p, id);
}
#[test]
fn remove_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 1});
refuses_an_index_that_is_not_whole(&mut s, "path.removeAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.removeAnchor", &p, id);
}
#[test]
fn convert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 1, "to": "corner"});
refuses_an_index_that_is_not_whole(&mut s, "path.convertAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.convertAnchor", &p, id);
}
#[test]
fn reshape_segment_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 20});
refuses_an_index_that_is_not_whole(&mut s, "path.reshapeSegment", &p, "subpath");
edits_the_subpath_given(&mut s, "path.reshapeSegment", &p, id);
}
#[test]
fn split_refuses_a_subpath_anchor_or_segment_that_is_not_a_whole_number() {
// A bad `anchor` beside a good `segment` is refused, and so is a bad `segment` beside a good
// `anchor`.
let mut s = session();
let id = two_subpaths(&mut s);
let at_anchor = json!({"id": id.0, "anchor": 1});
let at_segment = json!({"id": id.0, "segment": 0, "t": 0.5});
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "subpath");
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "subpath");
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "anchor");
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "segment");
let before = path(&s, id).subpaths;
for (si, p) in [(1, with(&at_anchor, json!({"subpath": 1}))), (0, at_anchor.clone()), (0, with(&at_anchor, json!({"subpath": null})))] {
s.execute("path.split", &p).unwrap();
let after = path(&s, id).subpaths;
// The subpath split becomes two of two anchors each, in its place.
let kept = if si == 1 { &after[0] } else { &after[2] };
assert!(after.len() == 3 && after[si].anchors.len() == 2 && after[si + 1].anchors.len() == 2, "{p}: {after:?}");
assert_eq!(kept, &before[1 - si], "{p}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths, before, "{p}: undo");
}
}
#[test]
fn set_handle_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 0, "which": "out", "x": 140, "y": 250});
refuses_an_index_that_is_not_whole(&mut s, "path.setHandle", &p, "subpath");
edits_the_subpath_given(&mut s, "path.setHandle", &p, id);
}
#[test]
fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "segment": 0, "t": 0.5});
refuses_an_index_that_is_not_whole(&mut s, "path.insertAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.insertAnchor", &p, id);
}
#[test]
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
+24 -1
View File
@@ -62,10 +62,14 @@ fn clicking_without_typing_leaves_no_text_and_no_history() {
click(&mut s, 150.0, 100.0);
s.select_tool("rectangle", v).unwrap();
assert!(texts(&s).is_empty());
// Click in two places: the first is gone when the second is placed.
// Click in two places: the second click discards the first type and places none (#2871); a
// third places new type.
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 300.0);
click(&mut s, 400.0, 300.0);
assert!(texts(&s).is_empty());
assert!(!s.tool_wants_text());
click(&mut s, 400.0, 300.0);
assert_eq!(texts(&s).len(), 1);
s.select_tool("selection", v).unwrap();
assert!(texts(&s).is_empty());
@@ -95,6 +99,25 @@ fn typed_text_stays_and_typing_it_away_leaves_nothing() {
assert_eq!(undo_labels(&s), kept);
}
/// A click on blank canvas while type is being edited ends the editing and makes no new type; the
/// Type tool stays, and the next click places point type (#2871, #2854).
#[test]
fn a_click_away_from_typed_text_ends_editing_without_new_type() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("Hi", v).unwrap();
click(&mut s, 400.0, 300.0);
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Hi"]);
assert_eq!(s.tool_id(), "type");
assert!(!s.tool_wants_text());
assert_eq!(undo_labels(&s), ["Type", "Typing"]);
click(&mut s, 400.0, 300.0);
assert_eq!(texts(&s).len(), 2);
assert!(s.tool_wants_text());
}
#[test]
fn frames_stay_when_left_empty() {
let mut s = session();
+13 -7
View File
@@ -371,7 +371,7 @@ fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() {
assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}");
}
/// #2692's files: an image clipped to a circle inside a circle's stroke, and an image with
/// #2692's file, an image clipped to a circle inside a circle's stroke, and #2830's, an image with
/// see-through pixels (which the save flattens into an image inside a clipping group), each with
/// an orange square on the next layer.
const CLIPPED_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="200" viewBox="0 0 400 200">
@@ -389,14 +389,16 @@ const SEE_THROUGH_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmln
<g id="above"><rect x="220" y="20" width="160" height="160" fill="#f7941d"/></g>
</svg>"##;
/// #2692: the image inside a clipping group, clipped in the file or flattened by the save, is
/// written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space inside
/// each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`. The next
/// #2692 and #2830: the image inside a clipping group, clipped in the file or flattened by the
/// save, is written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space
/// inside each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`.
/// A line `F`, which the specification's examples do not show, follows `%AI5_EndRaster`. The next
/// layer follows with the square's exact lines, and the file opens again with both layers, the
/// clipping group, the image and the square.
/// clipping group, the image, the square and, besides the clipping path, only the paths the SVG
/// holds.
#[test]
fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
for (svg, layer) in [(CLIPPED_IMAGE, "clipped-image"), (SEE_THROUGH_IMAGE, "alpha-image")] {
for (svg, layer, paths) in [(CLIPPED_IMAGE, "clipped-image", 2), (SEE_THROUGH_IMAGE, "alpha-image", 1)] {
let mut s = Session::new();
s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
@@ -411,7 +413,8 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
let end = at + lines[at..].iter().position(|l| *l == "%AI5_EndRaster").unwrap();
let (last, full) = lines[at + 1..end].split_last().unwrap();
assert!(full.iter().all(|l| l.len() == 61) && (2..=61).contains(&last.len()), "{layer}");
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
assert_eq!(lines[end + 1], "F", "{layer}");
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
let square = "%AI5_BeginLayer\n1 1 1 1 0 0 1 255 79 79 Lb\n(above) Ln\n0 A\n0.9686 0.5804 0.1137 Xa\n0 O\n0 XR\n\
220 180 m\n380 180 L\n380 20 L\n220 20 L\n220 180 L\nf\nLB\n%AI5_EndLayer--\n%%PageTrailer\n";
assert!(text.contains(square), "{text}");
@@ -419,6 +422,9 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
let d = &s.doc().unwrap().doc;
assert_eq!(layer_names(d), [layer, "above"]);
assert_eq!(images(d), (1, 1), "{layer}");
let mut drawn = 0;
d.walk(|n| drawn += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: false, .. })));
assert_eq!(drawn, paths, "{layer}");
assert_eq!(colour_at(d, 300.0, 100.0), [247, 148, 29], "{layer}");
assert_ne!(colour_at(d, 100.0, 100.0), [255, 255, 255], "{layer}");
}
+80
View File
@@ -88,6 +88,86 @@ fn linked_files_on_other_computers_are_never_read() {
assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys());
}
/// The final path Windows gives a folder of the drive `Z:` mapped to `\\server\share`. The
/// share's root folder has no trailing separator (`\\?\UNC\server\share`).
fn on_share(folder: &str) -> Option<std::path::PathBuf> {
let rest = folder.get(2..)?.trim_start_matches(['\\', '/']).replace('/', "\\");
let root = r"\\?\UNC\server\share";
Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() })
}
#[test]
fn a_document_on_a_mapped_drive_is_on_the_share_behind_it() {
use crate::cmd::fileio::share_of;
// craft-docpath refuses a link on the share when the document is given by its drive letter.
let link = r"\\server\share\img\logo.png";
assert_eq!(craft_docpath::resolve(link, Some(std::path::Path::new(r"Z:\jobs\doc.vectorcraft"))), Err(craft_docpath::Refused::Network));
assert!(craft_docpath::resolve(link, Some(std::path::Path::new(r"\\server\share\jobs\doc.vectorcraft"))).is_ok());
// The document's path on the share, from its folder's final path.
let mut asked = vec![];
let mut ask = |folder: &str| {
asked.push(folder.to_string());
on_share(folder)
};
assert_eq!(share_of(r"Z:\jobs\doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\jobs\doc.vectorcraft"));
assert_eq!(share_of(r"Z:\doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\doc.vectorcraft"));
assert_eq!(share_of("Z:/jobs/doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\jobs\doc.vectorcraft"));
assert_eq!(asked, [r"Z:\jobs", r"Z:\", "Z:/jobs"]);
let unc = |p: &'static str| move |_: &str| Some(std::path::PathBuf::from(p));
assert_eq!(share_of(r"Z:\doc.vectorcraft", unc(r"\\?\unc\server\share\")).as_deref(), Some(r"\\server\share\doc.vectorcraft"));
// A folder on a local volume (also behind a `subst` drive), a folder that can't be opened,
// and a final path without a share name give no path on a share.
assert_eq!(share_of(r"C:\art\doc.vectorcraft", unc(r"\\?\C:\art")), None);
assert_eq!(share_of(r"S:\doc.vectorcraft", unc(r"\\?\D:\real")), None);
assert_eq!(share_of(r"Z:\gone\doc.vectorcraft", |_| None), None);
assert_eq!(share_of(r"Z:\doc.vectorcraft", unc(r"\\?\UNC\server")), None);
// A document given by its share, or not by a drive, is not looked up.
for doc in [
r"\\server\share\doc.vectorcraft",
r"\\?\Z:\doc.vectorcraft",
"/Users/a/doc.vectorcraft",
"doc.vectorcraft",
"Z:doc.vectorcraft",
r"Z:\jobs\",
] {
assert_eq!(share_of(doc, |f: &str| panic!("{doc}: looked up {f}")), None, "{doc}");
}
}
#[cfg(windows)]
#[test]
fn links_on_a_mapped_drives_share_are_followed() {
use crate::cmd::fileio::{document_path_with, share_of};
let doc = Some(r"Z:\jobs\doc.vectorcraft");
let share = |d: &str| share_of(d, on_share);
// A link on the share behind the drive is followed; one on another share still isn't.
assert_eq!(document_path_with(r"\\server\share\img\logo.png", doc, share).as_deref(), Ok(r"\\server\share\img\logo.png"));
assert_eq!(document_path_with(r"\\SERVER\Share\logo.png", doc, share).as_deref(), Ok(r"\\SERVER\Share\logo.png"));
assert_eq!(document_path_with(r"\\other\share\logo.png", doc, share), Err(craft_docpath::Refused::Network));
assert_eq!(document_path_with("file://server/share/logo.png", doc, share), Err(craft_docpath::Refused::Network));
// On a local volume, the link stays refused.
assert_eq!(document_path_with(r"\\server\share\logo.png", doc, |_| None), Err(craft_docpath::Refused::Network));
// Links that were followed resolve as before, without looking the share up.
let unasked = |d: &str| -> Option<String> { panic!("looked up {d}") };
assert_eq!(document_path_with(r"img\logo.png", doc, unasked).as_deref(), Ok(r"Z:\jobs\img\logo.png"));
assert_eq!(document_path_with(r"D:\art\logo.png", doc, unasked).as_deref(), Ok(r"D:\art\logo.png"));
}
#[test]
fn a_document_off_a_mapped_drive_keeps_its_path() {
use crate::cmd::fileio::share_path;
let dir = Folder::new("docpath-share");
// A folder on this computer's own disk, and one that doesn't exist.
assert_eq!(share_path(&dir.file("doc.vectorcraft")), None);
assert_eq!(share_path(&dir.file("gone/doc.vectorcraft")), None);
// Elsewhere than Windows, no document is looked up.
if cfg!(not(windows)) {
assert_eq!(share_path(r"Z:\jobs\doc.vectorcraft"), None);
}
}
#[test]
fn svg_images_on_other_computers_are_never_read() {
let dir = Folder::new("docpath-svg");
+36
View File
@@ -157,6 +157,42 @@ fn a_linked_photoshop_document_places_and_updates() {
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
}
/// An uncompressed 24-bit `w`×`h` Targa file of one color.
fn targa(w: u16, h: u16, rgb: [u8; 3]) -> Vec<u8> {
let mut f = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0];
f.extend(w.to_le_bytes());
f.extend(h.to_le_bytes());
f.extend([24, 0]);
let [r, g, b] = rgb;
f.extend(std::iter::repeat_n([b, g, r], usize::from(w) * usize::from(h)).flatten());
f
}
/// A Targa file placed linked shows its pixels and updates when it changes, and a link can be
/// relinked to one.
#[test]
fn a_linked_targa_file_places_updates_and_relinks() {
let dir = Folder::new("tga");
let pic = dir.file("art/photo.tga");
write(&pic, &targa(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
assert_eq!(image(&s, id).link.unwrap().path, pic);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
write(&pic, &targa(400, 400, BLUE));
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [id.0], "missing": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height), (400, 400));
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
let other = dir.file("other/red.vst");
write(&other, &targa(300, 100, RED));
let r = s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap();
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height, im.link.unwrap().path), (300, 100, other));
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
#[test]
fn the_relative_path_finds_the_file_after_the_folder_moves() {
let a = Folder::new("move-a");
+154
View File
@@ -83,6 +83,13 @@ fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than a number.
fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be a number, not {}", p[key]);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
// ---------- Object → Lock / Hide ----------
#[test]
@@ -399,6 +406,29 @@ fn transform_each_refuses_a_reference_or_seed_that_is_not_a_whole_number() {
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 50.0, 50.0));
}
/// Transform Each refuses a scale, move or rotation given as text or as another value that is not a
/// number, and the object stays as it was; numbers for the scales and the horizontal move transform it.
#[test]
fn transform_each_refuses_a_number_given_as_text() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
let before = (undo_len(&s), bounds(&s, a));
for (key, p) in [
("scaleH", json!({"scaleH": "50"})),
("scaleV", json!({"scaleH": 50, "scaleV": "50%"})),
("moveH", json!({"moveH": "10"})),
("moveV", json!({"moveH": 10, "moveV": [5]})),
("rotate", json!({"rotate": "45"})),
("rotate", json!({"moveH": 10, "rotate": true})),
] {
refuses_non_number(&mut s, "object.transformEach", &p, key);
assert_eq!((undo_len(&s), bounds(&s, a)), before, "{p}");
}
s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50, "moveH": 10, "moveV": null})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(35.0, 25.0, 85.0, 75.0));
}
/// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as
/// Effect › Distort › Transform and Illustrator's Transform Each checkboxes).
#[test]
@@ -571,6 +601,22 @@ fn rasterize_replaces_selection_with_image() {
assert_eq!(selected(&s), vec![img]);
}
/// Rasterize refuses a resolution given as text or as another value that is not a number, and the
/// selected art stays as it was.
#[test]
fn rasterize_refuses_a_ppi_given_as_text() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
sel(&mut s, &[a]);
let before = (undo_len(&s), node(&s, a));
for p in [json!({"ppi": "144"}), json!({"ppi": "300 ppi", "padding": 0}), json!({"ppi": [72]})] {
refuses_non_number(&mut s, "object.rasterize", &p, "ppi");
assert_eq!((undo_len(&s), node(&s, a)), before, "{p}");
}
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 144, "padding": 0})).unwrap());
assert!(matches!(node(&s, img).kind, NodeKind::Image(_)));
}
#[test]
fn rasterize_says_when_an_image_could_not_be_decoded() {
let mut s = session();
@@ -608,6 +654,25 @@ fn crop_image_to_rect() {
assert!(s.execute("object.cropImage", &json!({})).is_err());
}
/// Crop Image refuses a `rect` that is not an array, such as text, and the image stays as it was; a
/// null `rect` crops to the artboard as a missing one does.
#[test]
fn crop_image_refuses_a_rect_given_as_text() {
let mut s = session();
let a = rect(&mut s, -20.0, -20.0, 100.0, 100.0);
sel(&mut s, &[a]);
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
let before = (undo_len(&s), node(&s, img));
for p in [json!({"rect": "0, 0, 20, 20"}), json!({"rect": {"x": 0, "y": 0, "width": 20, "height": 20}}), json!({"rect": 20})] {
let r = s.execute("object.cropImage", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "rect must be [x, y, width, height]"), "{p}: {r:?}");
assert_eq!((undo_len(&s), node(&s, img)), before, "{p}");
}
s.execute("object.cropImage", &json!({"rect": null})).unwrap();
let b = bounds(&s, img);
assert!(close(b.x0, 0.0) && close(b.y0, 0.0), "{b:?}");
}
#[test]
fn crop_image_disabled_without_image() {
let mut s = session();
@@ -656,6 +721,30 @@ fn trim_marks_around_selection() {
assert!(close(b.x0, vb.x0 - 30.0) && close(b.y1, vb.y1 + 30.0), "{b:?} {vb:?}");
}
/// Create Trim Marks refuses an offset, length, bleed or weight given as text or as another value
/// that is not a number, around the selection and around the artboards, and makes no marks.
#[test]
fn trim_marks_refuse_a_number_given_as_text() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
let calls = [
("offset", json!({"offset": "10"})),
("length", json!({"length": "20 pt"})),
("bleed", json!({"style": "japanese", "bleed": "3"})),
("weight", json!({"weight": true})),
("offset", json!({"allArtboards": true, "offset": {"value": 10}})),
];
for ids in [vec![a], vec![]] {
sel(&mut s, &ids);
let before = (undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len());
for (key, p) in &calls {
refuses_non_number(&mut s, "object.createTrimMarks", p, key);
assert_eq!((undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len()), before, "{p}");
}
}
s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20, "bleed": null})).unwrap();
}
// ---------- Convert to Shape ----------
#[test]
@@ -791,6 +880,22 @@ fn rearrange_refuses_columns_that_are_not_a_whole_number() {
assert_eq!((r["rows"].clone(), r["columns"].clone()), (json!(2), json!(1)));
}
/// Rearrange All Artboards refuses a spacing given as text or as another value that is not a number,
/// and the artboards stay where they were.
#[test]
fn rearrange_refuses_a_spacing_given_as_text() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let before = (boards(&s), undo_len(&s));
for p in [json!({"spacing": "50"}), json!({"columns": 1, "spacing": "50 pt"}), json!({"layout": "row", "spacing": false})] {
refuses_non_number(&mut s, "artboard.rearrange", &p, "spacing");
assert_eq!((boards(&s), undo_len(&s)), before, "{p}");
}
s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50})).unwrap();
assert_eq!(boards(&s)[1], Rect::new(0.0, 650.0, 400.0, 950.0));
}
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
@@ -1026,6 +1131,55 @@ fn select_same_paint_reads_type_characters() {
assert_eq!(same(&mut s, t1, "select.same.fillAndStroke"), vec![t1, r]);
}
/// Select by Color needs no object to start from: it matches fills and strokes as
/// `document.inspect` reports them, `#rrggbb`.
#[test]
fn select_color_matches_fill_and_stroke_without_a_selection() {
let mut s = session();
let [a, b, c] = [0.0, 20.0, 40.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0));
let t = text(&mut s, "a");
fill(&mut s, a, "#000000");
fill(&mut s, t, "#000000");
// A grey as a PDF stores it reads as #fefefe.
s.execute("paint.setFill", &json!({"ids": [b.0], "color": [0.996, 0.996, 0.996]})).unwrap();
fill(&mut s, c, "#ff0000");
s.execute("paint.setStroke", &json!({"ids": [a.0, c.0], "color": "#0000ff"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
let pick = |s: &mut Session, p: Value| {
s.execute("select.color", &p).unwrap();
let mut v = selected(s);
v.sort();
v
};
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t]);
assert_eq!(pick(&mut s, json!({"fill": "#fefefe"})), vec![b]);
assert_eq!(pick(&mut s, json!({"stroke": "#0000ff"})), vec![a, c]);
assert_eq!(pick(&mut s, json!({"fill": "#000000", "stroke": "#0000ff"})), vec![a]);
// Select > Reselect repeats it.
s.execute("select.none", &json!({})).unwrap();
s.execute("select.reselect", &json!({})).unwrap();
assert_eq!(selected(&s), vec![a]);
// A compound path reads as its own paint, never its members'.
let outer = rect(&mut s, 100.0, 0.0, 50.0, 50.0);
let inner = rect(&mut s, 110.0, 10.0, 20.0, 20.0);
fill(&mut s, outer, "#000000");
fill(&mut s, inner, "#000000");
sel(&mut s, &[outer, inner]);
let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t, compound]);
fill(&mut s, compound, "#ff0000");
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t]);
assert_eq!(pick(&mut s, json!({"fill": "#ff0000"})), vec![c, compound]);
// Type without characters reads as the default character fill, black.
let empty = text(&mut s, "x");
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(empty).map(|n| &mut n.kind) {
t.runs.clear();
}
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t, empty]);
assert!(s.execute("select.color", &json!({})).is_err());
assert!(s.execute("select.color", &json!({"fill": "not a colour"})).is_err());
}
#[test]
fn select_direction_handles_selects_all_anchors() {
let mut s = session();
+17
View File
@@ -3,6 +3,8 @@
//! report mixed values, and saves and exports never carry the editing layer.
use serde_json::{Value, json};
use vectorcraft_geom::Affine;
use vectorcraft_render::{RenderOptions, Renderer};
use super::*;
use crate::cmd::maskedit::MASK_EDIT_LAYER;
@@ -46,6 +48,21 @@ fn data(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
/// #2727: the canvas passes `isolation` while mask art is edited; the editing layer is not drawn on
/// the page, so isolation dimming must not run or the artwork washes out.
#[test]
fn mask_editing_does_not_dim_the_canvas() {
let mut s = session();
editing_mask(&mut s);
let st = s.doc().unwrap();
let layer = st.isolation.expect("mask editing isolates the temporary layer");
let render = |opts: &RenderOptions| Renderer::new().render(&st.doc, 300, 300, Affine::IDENTITY, opts);
let plain = render(&RenderOptions::default());
let canvas = render(&RenderOptions { isolated: Some(layer), ..Default::default() });
assert_eq!(plain.pixel(50, 100), canvas.pixel(50, 100), "inside the mask");
assert_eq!(plain.pixel(250, 100), canvas.pixel(250, 100), "outside the mask");
}
#[test]
fn mask_options_act_on_the_masked_object_while_editing() {
let mut s = session();
+4 -1
View File
@@ -1181,7 +1181,10 @@ fn new_type_starts_with_placeholder_text() {
gesture(&mut s, v, &[(Down, 100.0, 150.0), (Drag, 300.0, 300.0), (Up, 300.0, 300.0)]);
assert!(text(&s).len() > placeholder.len());
set_pref(&mut s, "placeholderText", json!(false));
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
// The first click ends editing the area type (#2871); the second places point type.
for _ in 0..2 {
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
}
assert_eq!(text(&s), "");
}
+74 -1
View File
@@ -1428,6 +1428,57 @@ proptest! {
}
}
/// Targa files, which start with no signature: run-length encoded 24 bits (as the `image` crate
/// writes them), a 16-bit export with a one-bit alpha, and by hand a 16-bit run-length encoded file
/// stored right to left and top down, a run-length encoded color-mapped one with 16-bit entries,
/// and a 32-bit one with an extension area.
fn targa_samples() -> Vec<(&'static str, Vec<u8>)> {
let mut rle = vec![];
image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10])).write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
let mut s = rich_session();
let r = s.execute("document.export", &json!({"format": "tga", "depth": 16, "ppi": 2})).unwrap();
let export = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let mut alpha = vec![0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 2, 0, 16, 0x31, 0x02, 0x00, 0xfc, 0x1f, 0x80, 0x00, 0x00];
alpha.extend([0x82, 0xe0, 0x83]);
let mut mapped = vec![0, 1, 9, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 2, 0, 8, 0x21, 0x00, 0x00, 0x00, 0xfc];
mapped.extend([0x80, 6, 0x00, 5, 0x83, 6]);
let mut extended = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 0x28, 50, 50, 50, 128];
let mut area = vec![0; 495];
area[..2].copy_from_slice(&495u16.to_le_bytes());
area[494] = 4;
extended.extend(area);
extended.extend(22u32.to_le_bytes());
extended.extend([0; 4]);
extended.extend(b"TRUEVISION-XFILE.\0");
vec![("a.tga", rle), ("b.tga", export), ("c.tga", alpha), ("d.vda", mapped), ("e.vst", extended)]
}
proptest! {
#![proptest_config(config())]
/// Mutated Targa files (headers, run-length packets, color maps, the extension area and the
/// footer) never crash placing them.
#[test]
fn mutated_targa_files_place_without_panics(which in 0usize..5, cut in 0usize..600, edits in prop::collection::vec((0usize..560, any::<u8>()), 0..12)) {
let (name, mut bytes) = targa_samples().swap_remove(which);
for (at, b) in edits {
if let Some(x) = bytes.get_mut(at) {
*x = b;
}
}
bytes.truncate(cut.max(8));
let r = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8});
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
let _ = no_panic(s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})));
});
prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err());
}
}
// ---------- SVG saved with hidden layers and editing data ----------
/// The rich document with a hidden layer, saved as SVG with its editing data.
@@ -1741,6 +1792,27 @@ proptest! {
let text = mutate_text(&ccitt_images_eps(), cut, &edits);
survive("mutated EPS CCITT images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
}
/// TIFF-predicted Flate data (Predictor 2) at every depth the filter accepts, with any bytes, 1 to 5
/// colors and 1 to 39 columns, read by images of up to 23 × 7 pixels at 1, 2, 4, 8, 12 or 16 bits:
/// read as an image, or as an error.
#[test]
fn eps_tiff_predicted_images_never_panic(
data in prop::collection::vec(any::<u8>(), 0..300),
bpc in prop::sample::select(vec![1u8, 2, 4, 8, 16]),
colors in 1u8..6,
columns in 1u16..40,
image_bpc in prop::sample::select(vec![1u8, 2, 4, 8, 12, 16]),
(width, height) in (1u8..24, 1u8..8),
) {
let hex: String = deflated(&data).iter().map(|b| format!("{b:02x}")).collect();
let text = format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n40 30 scale {width} {height} {image_bpc} [{width} 0 0 -{height} 0 {height}] \
currentfile /ASCIIHexDecode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> /FlateDecode filter image\n\
{hex}>\nshowpage\n%%EOF\n"
);
survive("TIFF-predicted EPS images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
}
}
/// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from
@@ -1851,7 +1923,8 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
"status", "token", "bytesavailable", "resetfile", "pdfmark", "execform", "nulldevice", "strokepath", "pathforall",
"currenthsbcolor", "currentcolorrendering", "gcheck", "rootfont", "setcachedevice2", "writestring", "(%stdout) (w) file",
"//x", "{ //dup }", "/ReusableStreamDecode", "currentfile /ASCII85Decode filter /ReusableStreamDecode filter",
"<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>",
"<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /Predictor 2 /Colors 3 /BitsPerComponent 4 /Columns 3 >> /LZWDecode",
"<< /Predictor 2 /BitsPerComponent 16 /Columns 5 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>",
// Tiling patterns, uncoloured ones and patterns drawing patterns.
"<< /PatternType 1 /PaintType 1 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop 0 0 3 3 rectfill } >> matrix makepattern",
"<< /PatternType 1 /PaintType 2 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop dup setpattern 0 0 3 3 rectfill } >>",
+17 -4
View File
@@ -2012,9 +2012,15 @@ impl<'a> Reader<'a> {
/// An image of operands `vals` and samples `data`.
fn image_from(&mut self, vals: Vec<V>, data: &[u8]) -> Result<(), String> {
let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default();
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { return Ok(()) };
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else {
self.warn("an image VectorCraft can't read was left out");
return Ok(());
};
let n = Self::nums(&vals);
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else { return Ok(()) };
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else {
self.warn("an image VectorCraft can't read was left out");
return Ok(());
};
let (w, h) = (*w as u64, *h as u64);
if w == 0 || h == 0 || w.saturating_mul(h) > MAX_PIXELS || ![1.0, 8.0].contains(bits) {
self.warn("an image VectorCraft can't read was left out");
@@ -2080,7 +2086,10 @@ impl<'a> Reader<'a> {
rgba.extend_from_slice(&[px[0], px[1], px[2], a]);
}
}
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { return Ok(()) };
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else {
self.warn("an image VectorCraft can't read was left out");
return Ok(());
};
let mut png = vec![];
if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
self.warn("an image VectorCraft can't read was left out");
@@ -2096,10 +2105,14 @@ impl<'a> Reader<'a> {
// The matrix is an art-space matrix (y up) applied to the pixels with their rows running
// downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead
// gives the same place only when the image isn't rotated (b = c = 0).
let [a, b, c, d, tx, ty] = m else { return Ok(()) };
let [a, b, c, d, tx, ty] = m else {
self.warn("an image VectorCraft can't read was left out");
return Ok(());
};
let to_doc = self.space();
let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP;
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
self.warn("an image VectorCraft can't read was left out");
return Ok(());
}
let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf));
+34
View File
@@ -677,6 +677,40 @@ fn images_with_alpha_channels() {
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
}
/// Editing data `data` opens without its one image and with the warning "an image VectorCraft can't read was left out" once.
fn image_left_out_with_a_warning(data: &[u8]) {
let r = read(data).unwrap();
assert!(art(&r.doc).is_empty(), "{:#?}", art(&r.doc));
let left_out = r.warnings.iter().filter(|w| *w == "an image VectorCraft can't read was left out").count();
assert_eq!(left_out, 1, "{:?}", r.warnings);
}
#[test]
fn an_image_without_a_matrix_is_left_out_with_a_warning() {
image_left_out_with_a_warning(&image("", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
}
#[test]
fn an_image_with_too_few_operands_is_left_out_with_a_warning() {
// The operands hold 10 numbers after the matrix, one short of the binary flag.
let text = String::from_utf8(image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), b"abc", 3)).unwrap();
let data = text.replacen("] 0 0 1 1 1 1 8 3 0 0 1 0 4 4 0 0\n", "] 0 0 1 1 1 1 8 3 0 0\n", 1);
assert_ne!(data, text);
image_left_out_with_a_warning(data.as_bytes());
}
#[test]
fn an_image_whose_matrix_isnt_six_numbers_is_left_out_with_a_warning() {
image_left_out_with_a_warning(&image("[ 1 0 0 1 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
}
#[test]
fn an_image_whose_matrix_has_no_area_is_left_out_with_a_warning() {
image_left_out_with_a_warning(&image("[ 0 0 0 0 0 0 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
// This matrix maps the image onto a line.
image_left_out_with_a_warning(&image("[ 1 2 2 4 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
}
/// The raster matrix `[a b c d tx ty]` is an art-space matrix (y up) applied to the image's pixels
/// with their rows running downwards, `(u, -v)`: art x = a·u − c·v + tx, art y = b·u − d·v + ty.
/// Without rotation (b = c = 0) that is the same as flipping the matrix's result; a rotated image
+37 -7
View File
@@ -324,16 +324,46 @@ impl Predictor {
// Bytes per pixel (at least one) is how far back the left neighbour is.
let bpp = (self.colors * self.bpc).div_ceil(8).max(1);
if self.kind == 2 {
// Horizontal differencing (ISO 32000-1, §7.4.4.4): each component is stored as its
// difference from the same component of the pixel to its left, wrapping within its bit
// width. Each row starts again, and the padding bits at a row's end stay as they are.
let mut out = data.to_vec();
// Horizontal differencing, for 8-bit components (the common case).
if self.bpc == 8 {
for line in out.chunks_mut(row.max(1)) {
for i in bpp..line.len() {
let left = line.get(i - bpp).copied().unwrap_or(0);
if let Some(b) = line.get_mut(i) {
*b = b.wrapping_add(left);
for line in out.chunks_mut(row.max(1)) {
match self.bpc {
8 => {
for i in bpp..line.len() {
let left = line.get(i - bpp).copied().unwrap_or(0);
if let Some(b) = line.get_mut(i) {
*b = b.wrapping_add(left);
}
}
}
// 16-bit components are big-endian and add as 16-bit numbers.
16 => {
let byte = |line: &[u8], i: usize| line.get(i).copied().unwrap_or(0);
for i in (bpp..line.len().saturating_sub(1)).step_by(2) {
let left = u16::from_be_bytes([byte(line, i - bpp), byte(line, i - bpp + 1)]);
let sum = u16::from_be_bytes([byte(line, i), byte(line, i + 1)]).wrapping_add(left);
if let Some(w) = line.get_mut(i..i + 2) {
w.copy_from_slice(&sum.to_be_bytes());
}
}
}
// 1, 2 or 4 bits: components are packed from each byte's high bit.
bits @ (1 | 2 | 4) => {
let mask = (1u8 << bits) - 1;
let shift = |k: usize| 8 - bits - k * bits % 8;
let sample = |line: &[u8], k: usize| line.get(k * bits / 8).map_or(0, |b| (b >> shift(k)) & mask);
let samples = (line.len() * 8 / bits).min(self.colors * self.columns);
for k in self.colors..samples {
let sum = sample(line, k).wrapping_add(sample(line, k - self.colors)) & mask;
if let Some(b) = line.get_mut(k * bits / 8) {
*b = (*b & !(mask << shift(k))) | (sum << shift(k));
}
}
}
// `Predictor::of` accepts no other depth.
_ => {}
}
}
return out;
+17 -3
View File
@@ -186,13 +186,27 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
import_with(bytes, true)
}
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
/// (else what its page prints, as any EPS).
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
/// The PostScript of an EPS or PostScript file (behind its binary header, if it has one).
fn program(bytes: &[u8]) -> Result<&[u8], String> {
let (ps, _) = crate::sections(bytes).ok_or("the file's preview header points outside the file")?;
if !ps.starts_with(b"%!") {
return Err("this is not a PostScript file".into());
}
Ok(ps)
}
/// An EPS or PostScript file's page at the origin, read from its comments without running its
/// program: its bounding box (`%%HiResBoundingBox` when it has one), a US Letter page without
/// one. [`import_with`] runs the program on the same page when it reads what the page prints.
pub fn page(bytes: &[u8]) -> Result<Rect, String> {
let [llx, lly, urx, ury] = Dsc::read(program(bytes)?).bbox.unwrap_or(LETTER);
Ok(Rect::new(0.0, 0.0, urx - llx, ury - lly))
}
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
/// (else what its page prints, as any EPS).
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
let ps = program(bytes)?;
let dsc = Dsc::read(ps);
// A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no
// type), and its program has all of it, which is read as the format (as before the file
+54
View File
@@ -408,3 +408,57 @@ fn predicted_rows_of_an_odd_width() {
let want: Vec<[u8; 4]> = raw.iter().flat_map(|row| (0..3).map(move |x| [row[3 * x], row[3 * x + 1], row[3 * x + 2], 255])).collect();
assert_eq!(pixels(&r, 3, 5), want);
}
/// Rows of `bpc`-bit samples packed from the high bit (16-bit ones big-endian), each row padded to
/// a whole byte.
fn packed(rows: &[Vec<u32>], bpc: usize) -> Vec<u8> {
let mut out = vec![];
for row in rows {
let mut bytes = vec![0u8; (row.len() * bpc).div_ceil(8)];
for (k, v) in row.iter().enumerate() {
if bpc == 16 {
bytes[2 * k..2 * k + 2].copy_from_slice(&(*v as u16).to_be_bytes());
} else {
let bit = k * bpc;
bytes[bit / 8] |= (*v as u8) << (8 - bpc - bit % 8);
}
}
out.extend(bytes);
}
out
}
/// A TIFF-predicted Flate stream (Predictor 2, horizontal differencing: ISO 32000-1 §7.4.4.4) at
/// 1, 2, 4, 8 and 16 bits a component, gray and RGB, five pixels a row: each component is the
/// difference from the same component of the pixel to its left, within its bit width, and each
/// row starts again. The filter gives back the samples before differencing, read here as 8-bit
/// gray pixels one byte each.
#[test]
fn tiff_predicted_rows_at_every_depth() {
let columns = 5;
let mut wrong = vec![];
for bpc in [1, 2, 4, 8, 16] {
for colors in [1, 3] {
let max = (1u32 << bpc) - 1;
let rows: Vec<Vec<u32>> =
(0..3u32).map(|y| (0..(columns * colors) as u32).map(|i| (i * i * 40_503 + i * 7 + y * 12_345 + 1) & max).collect()).collect();
let differenced: Vec<Vec<u32>> = rows
.iter()
.map(|row| (0..row.len()).map(|i| if i < colors { row[i] } else { row[i].wrapping_sub(row[i - colors]) & max }).collect())
.collect();
let (want, data) = (packed(&rows, bpc), packed(&differenced, bpc));
let width = want.len() / 3;
let r = read(&format!(
"30 30 scale << /ImageType 1 /Width {width} /Height 3 /BitsPerComponent 8 /Decode [0 1] /ImageMatrix [{width} 0 0 -3 0 3] \
/DataSource currentfile /ASCII85Decode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> \
/FlateDecode filter >> image\n{}",
crate::ps::ascii85(&crate::ps::deflate(&data))
));
let got: Vec<u8> = pixels(&r, width as u32, 3).iter().map(|p| p[0]).collect();
if got != want {
wrong.push(format!("{bpc} bits, {colors} colors: {got:?}, not {want:?}"));
}
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
+86 -32
View File
@@ -29,7 +29,7 @@
//! `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB` open) to stroke it too.
//! - Images (§8.1, §8.4): `%AI5_File:`, `%AI5_BeginRaster`,
//! `[ a b c d tx ty ] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in hex on comment lines of 60
//! digits, `%AI5_EndRaster`.
//! digits, `%AI5_EndRaster`, then a line `F` that §8.4's examples do not show.
//! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx
//! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each
//! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`,
@@ -50,7 +50,9 @@
//! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden
//! layer they stay where they are). Type that 8-bit text can't hold (characters outside
//! Windows-1252, vertical type, inline graphics, gradient or pattern fills) is written as its
//! outlines, and all type with [`LegacyAiOptions::outline_text`].
//! outlines, and all type with [`LegacyAiOptions::outline_text`]. Layer, spot color and gradient
//! names and the title are written in Windows-1252 too, and a character outside it is written as
//! `?`. Two spot colors or two gradients whose names become the same that way are numbered (`? 2`).
//!
//! The prolog is a procedure set of the writer's own ([`PROLOG`]) that gives the operators their
//! meaning in PostScript, so the file also prints and previews as a PostScript program (its
@@ -87,6 +89,8 @@ pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
pub const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is written opaque: legacy .ai files have none";
pub const LAYER_NAME: &str = "characters outside Windows Latin-1 in layer names are written as ? (legacy .ai files can't hold them)";
pub const TITLE_AND_COLOR_NAMES: &str =
"characters outside Windows Latin-1 in the title and in spot color and gradient names are written as ? (legacy .ai files can't hold them)";
pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)";
const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape";
const FREEFORM: &str = "freeform gradients are written as their average colour";
@@ -163,11 +167,12 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
.filter(|b| b.is_finite())
.reduce(|a, b| a.union(b))
.unwrap_or(Rect::new(0.0, below, width, top));
let title = w.latin_name(&o.title, TITLE_AND_COLOR_NAMES);
let mut s = String::new();
s.push_str(PS_HEADER);
s.push('\n');
let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION"));
let _ = writeln!(s, "%%Title: {}", ps::string(&o.title));
let _ = writeln!(s, "%%Title: {}", latin_string(&title));
if let Some(t) = o.created {
let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t);
let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})");
@@ -179,12 +184,12 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
s.push('\n');
s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n");
for (i, (name, _)) in w.spots.iter().enumerate() {
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::string(name));
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, latin_string(name));
}
for (i, (name, cmyk)) in w.spots.iter().enumerate() {
s.push_str(if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " });
ps::push_nums(&mut s, &cmyk.map(f64::from));
let _ = writeln!(s, "{}", ps::string(name));
let _ = writeln!(s, "{}", latin_string(name));
}
for (i, (name, _)) in w.fonts.iter().enumerate() {
let _ = writeln!(s, "{} {name}", if i == 0 { "%%DocumentFonts:" } else { "%%+" });
@@ -335,7 +340,7 @@ const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header com
/// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops
/// last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, `%AI5_EndGradient`.
fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
let name = ps::string(name);
let name = latin_string(name);
let kind = if g.kind == GradientKind::Radial { 1 } else { 0 };
let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {kind} {} Bd\n[", g.stops.len());
for stop in g.stops.iter().rev() {
@@ -356,7 +361,7 @@ fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
}
Ink::Spot { cmyk, name, tint } => {
ps::push_nums(s, &cmyk.map(f64::from));
let _ = write!(s, "{} ", ps::string(&name));
let _ = write!(s, "{} ", latin_string(w.spot_name(&name)));
ps::push_num(s, f64::from(1.0 - tint));
s.push_str(" 3 ");
}
@@ -393,6 +398,13 @@ fn invertible(a: Affine) -> bool {
det.is_finite() && det.abs() > 1e-12
}
/// Name `name` as a PostScript string of its characters' Windows-1252 codes (a character outside
/// Windows-1252 is left out: [`Writer::latin_name`] replaces them first).
fn latin_string(name: &str) -> String {
let bytes: Vec<u8> = name.chars().filter_map(latin::encode).collect();
ps::bytes(&bytes)
}
struct Writer<'a> {
doc: &'a Document,
/// The document → art space.
@@ -401,8 +413,10 @@ struct Writer<'a> {
body: String,
/// Gradient definitions, by the name objects use.
gradients: Vec<(String, Gradient)>,
/// Spot colours used: name and full CMYK equivalent.
/// Spot colors used: the name written and full CMYK equivalent.
spots: Vec<(String, [f32; 4])>,
/// The name each spot color (by its swatch's name) is written with ([`Self::spot`]).
spot_names: Vec<(String, String)>,
warnings: Vec<String>,
/// The lock state the last `A` set.
locked: bool,
@@ -430,6 +444,7 @@ impl<'a> Writer<'a> {
body: String::new(),
gradients: vec![],
spots: vec![],
spot_names: vec![],
warnings: vec![],
locked: false,
nest: 0,
@@ -454,23 +469,62 @@ impl<'a> Writer<'a> {
// ---------- layers and objects ----------
/// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it
/// is written as `?`, with a warning.
fn layer_name(&mut self, name: &str) -> String {
/// `name` with each character outside Windows-1252 (the encoding type and names are written
/// in) replaced by `?`; adds the warning `warning` when it replaces one.
fn latin_name(&mut self, name: &str, warning: &str) -> String {
let mut lost = false;
let bytes: Vec<u8> = name
let kept = name
.chars()
.map(|c| {
latin::encode(c).unwrap_or_else(|| {
if latin::encode(c).is_some() {
c
} else {
lost = true;
b'?'
})
'?'
}
})
.collect();
if lost {
self.warn(LAYER_NAME);
self.warn(warning);
}
ps::bytes(&bytes)
kept
}
/// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it
/// is written as `?`, with a warning.
fn layer_name(&mut self, name: &str) -> String {
latin_string(&self.latin_name(name, LAYER_NAME))
}
/// Note spot color `name` (its swatch's name), of full CMYK equivalent `cmyk`, as a custom
/// color of the document, and give the name it is written with. Its characters outside
/// Windows-1252 are written as `?` (with a warning), and it is numbered when another spot color
/// is already written with that name; the name is the same each time.
fn spot(&mut self, name: &str, cmyk: [f32; 4]) -> String {
let written = match self.spot_names.iter().find(|(n, _)| n == name) {
Some((_, w)) => w.clone(),
None => {
let base = self.latin_name(name, TITLE_AND_COLOR_NAMES);
let mut written = base.clone();
let mut i = 1;
while self.spot_names.iter().any(|(_, w)| *w == written) {
i += 1;
written = format!("{base} {i}");
}
self.spot_names.push((name.to_string(), written.clone()));
written
}
};
if !self.spots.iter().any(|(n, _)| *n == written) {
self.spots.push((written.clone(), cmyk));
}
written
}
/// The name spot color `name` (its swatch's name) is written with, once [`Self::spot`] has
/// noted it.
fn spot_name<'n>(&'n self, name: &'n str) -> &'n str {
self.spot_names.iter().find(|(n, _)| n == name).map_or(name, |(_, w)| w.as_str())
}
/// A top-level layer. False when it isn't a layer.
@@ -965,7 +1019,10 @@ impl<'a> Writer<'a> {
/// Set the fill (or with `stroke`, the stroke) colour to solid paint `p`.
fn color(&mut self, p: &Paint, stroke: bool) {
let ink = self.ink(p);
let ink = match self.ink(p) {
Ink::Spot { cmyk, name, tint } => Ink::Spot { name: self.spot(&name, cmyk), cmyk, tint },
ink => ink,
};
let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill };
let s = &mut self.body;
match ink {
@@ -983,13 +1040,10 @@ impl<'a> Writer<'a> {
}
Ink::Spot { cmyk, name, tint } => {
ps::push_nums(s, &cmyk.map(f64::from));
let _ = write!(s, "{} ", ps::string(&name));
let _ = write!(s, "{} ", latin_string(&name));
// The format's tint is how much lighter than the full colour (0: full).
ps::push_num(s, f64::from(1.0 - tint));
let _ = writeln!(s, " {}", op("x", "X"));
if !self.spots.iter().any(|(n, _)| *n == name) {
self.spots.push((name, cmyk));
}
}
}
}
@@ -1009,10 +1063,8 @@ impl<'a> Writer<'a> {
if let Some(sw) = &stop.swatch {
// Spot colours in stops are custom colours too.
let p = Paint::Solid { color: stop.color, swatch: Some(sw.clone()), tint: stop.tint };
if let Ink::Spot { cmyk, name, .. } = self.ink(&p)
&& !self.spots.iter().any(|(n, _)| *n == name)
{
self.spots.push((name, cmyk));
if let Ink::Spot { cmyk, name, .. } = self.ink(&p) {
self.spot(&name, cmyk);
}
}
}
@@ -1036,21 +1088,21 @@ impl<'a> Writer<'a> {
s.push_str("Bh\n");
}
s.push_str("1 ");
s.push_str(&ps::string(&name));
s.push_str(&latin_string(&name));
s.push(' ');
ps::push_nums(&mut s, &[start.x, start.y, angle, length]);
s.push_str("1 0 0 1 0 0 Bg\n");
s
}
/// The name of the definition of `g` (its swatch's name when it has one), defined the first
/// time.
/// The name of the definition of `g` (its swatch's name when it has one, with each character
/// outside Windows-1252 written as `?` and a warning), defined the first time.
fn define(&mut self, g: &Gradient, swatch: Option<&str>) -> String {
if let Some((name, _)) = self.gradients.iter().find(|(_, d)| d == g) {
return name.clone();
}
let taken = |n: &str, defs: &[(String, Gradient)]| defs.iter().any(|(m, _)| m == n);
let base = swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient").to_string();
let base = self.latin_name(swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient"), TITLE_AND_COLOR_NAMES);
let mut name = base.clone();
let mut i = 1;
while taken(&name, &self.gradients) {
@@ -1227,7 +1279,9 @@ impl<'a> Writer<'a> {
/// An image (§8): `%AI5_File:`, `%AI5_BeginRaster`, its matrix (an art-space matrix applied to
/// the pixels with their rows running downwards, `(u, -v)`) as `[ a b c d tx ty ]` and its
/// operands with `XI`, its RGB samples in hex on comment lines of [`HEX_LINE`] digits,
/// `%AI5_EndRaster`.
/// `%AI5_EndRaster`, then `F`. §8.4's examples have no `F`; without it, an app reading the file
/// can leave out an image in a clipping group and the art after it. No path is open there, so
/// the `F` paints nothing.
fn image(&mut self, im: &ImageObject) {
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let m = self.to_art * im.xf * flip;
@@ -1281,7 +1335,7 @@ impl<'a> Writer<'a> {
ps::push_nums(s, &[a, b, c, d, e, f]);
let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI");
s.push_str(&hex);
s.push_str("%AI5_EndRaster\n");
s.push_str("%AI5_EndRaster\nF\n");
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ mod ps;
mod scene;
mod tiff;
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai};
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai, page};
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
pub use print::{PrintJob, PrintPage, print};
+137 -10
View File
@@ -316,14 +316,15 @@ fn images_below_a_square(images: bool) -> Document {
doc
}
/// Each image the file holds (§8.4): the lines from `%AI5_File:` to `%AI5_EndRaster`.
/// Each image the file holds (§8.4): the lines from `%AI5_File:` to the line after
/// `%AI5_EndRaster`.
fn rasters(text: &str) -> Vec<Vec<&str>> {
let lines: Vec<&str> = text.lines().collect();
let mut out = vec![];
for (i, l) in lines.iter().enumerate() {
if *l == "%AI5_File:" {
let end = lines[i..].iter().position(|l| *l == "%AI5_EndRaster").map(|n| i + n).unwrap();
out.push(lines[i..=end].to_vec());
out.push(lines[i..=(end + 1).min(lines.len() - 1)].to_vec());
}
}
out
@@ -332,8 +333,9 @@ fn rasters(text: &str) -> Vec<Vec<&str>> {
/// Every image, in a clipping group, a group or neither, is written as §8.1's syntax and §8.4's
/// examples write one: `%AI5_File:`, `%AI5_BeginRaster`, the matrix with a space inside each
/// bracket and the operands on the `XI` line, the samples in hex on comment lines of 60 digits,
/// `%AI5_EndRaster`. The art after the images is written as it is without them, and the file
/// reads back with its layers, clipping group, images and square (#2692).
/// `%AI5_EndRaster`. A line `F`, which the examples do not show, follows each image. The art
/// after the images is written as it is without them, and the file reads back with its layers,
/// clipping group, images and square (#2692, #2830).
#[test]
fn images_are_written_in_the_form_the_specification_shows() {
let out = write(&images_below_a_square(true));
@@ -341,8 +343,8 @@ fn images_are_written_in_the_form_the_specification_shows() {
let images = rasters(&text);
assert_eq!(images.len(), 3, "{text}");
for lines in &images {
let [file, begin, xi, data @ .., end] = lines.as_slice() else { panic!("{lines:?}") };
assert_eq!((*file, *begin, *end), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster"));
let [file, begin, xi, data @ .., end, paint] = lines.as_slice() else { panic!("{lines:?}") };
assert_eq!((*file, *begin, *end, *paint), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster", "F"));
let matrix = xi.strip_prefix("[ ").and_then(|l| l.strip_suffix(" ] 0 0 16 16 16 16 8 3 0 0 0 0 XI")).unwrap_or_else(|| panic!("{xi}"));
assert_eq!(matrix.split(' ').filter(|v| v.parse::<f64>().is_ok()).count(), 6, "{xi}");
let digits: Vec<&str> = data.iter().map(|l| l.strip_prefix('%').unwrap_or_else(|| panic!("{l}"))).collect();
@@ -352,9 +354,10 @@ fn images_are_written_in_the_form_the_specification_shows() {
assert_eq!(digits.concat().len(), 16 * 16 * 3 * 2);
}
// The clipping group: `q`, the clipping path painted `h W n` (§5.6.2), the image, `Q`; the
// group: `u`, the image, `U`.
assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\n"), "{text}");
assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nU\n"), "{text}");
// group: `u`, the image, `U`; the loose image, then the layer's end.
assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nQ\n"), "{text}");
assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nU\n"), "{text}");
assert!(text.contains("\n%AI5_EndRaster\nF\nLB\n"), "{text}");
let layer = |t: &str| -> String {
let at = t.find("(Above) Ln\n").unwrap();
t[at..].split("%AI5_EndLayer--").next().unwrap().to_string()
@@ -367,8 +370,11 @@ fn images_are_written_in_the_form_the_specification_shows() {
let below = children(&doc.layers[0]);
assert_eq!(below.len(), 3, "{below:#?}");
let NodeKind::Group { children: window, clip: true } = &below[0].kind else { panic!("{:?}", below[0].kind) };
assert_eq!(window.len(), 2, "{window:#?}");
assert!(matches!(window[0].kind, NodeKind::Path { clipping: true, .. }) && matches!(window[1].kind, NodeKind::Image(_)), "{window:#?}");
assert!(matches!(&below[1].kind, NodeKind::Group { children, clip: false } if matches!(children[0].kind, NodeKind::Image(_))));
assert!(
matches!(&below[1].kind, NodeKind::Group { children, clip: false } if children.len() == 1 && matches!(children[0].kind, NodeKind::Image(_)))
);
assert!(matches!(below[2].kind, NodeKind::Image(_)));
let above = children(&doc.layers[1]);
assert_eq!(above.len(), 1);
@@ -606,6 +612,127 @@ fn spot_colours_are_custom_colours() {
assert!((tint - 0.5).abs() < 1e-6);
}
/// A document with a spot color swatch for each of `spots`, a square filled with each at half
/// tint, and a square whose linear gradient fill (the first spot color to white) names the
/// gradient swatch `gradient` (the document holds no such swatch; the writer reads only the
/// fill's name).
fn spots_and_gradient(spots: &[&str], gradient: &str) -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
for (i, name) in spots.iter().enumerate() {
let c = i as f32 * 0.5;
doc.swatches.push(vectorcraft_color::Swatch {
name: (*name).into(),
paint: Paint::solid(Color::cmyk(c, 0.5, 1.0, 0.0)),
global: true,
spot: true,
});
let fill = Paint::Solid { color: Color::cmyk(c / 2.0, 0.25, 0.5, 0.0), swatch: Some((*name).into()), tint: 0.5 };
let x = 10.0 + 40.0 * i as f64;
let n = shape(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 30.0, 40.0)), fill, Paint::None);
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let mut first = GradientStop::new(0.0, Color::cmyk(0.0, 0.5, 1.0, 0.0));
first.swatch = Some(spots[0].into());
let g = Gradient { kind: GradientKind::Linear, stops: vec![first, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))] };
let mut paint = GradientPaint::new(g);
paint.swatch = Some(gradient.into());
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 50.0, 50.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None);
doc.insert(Some(layer), usize::MAX, n).unwrap();
doc
}
/// `doc` written with the title `title`, and run as a PostScript program ([`prints_as`]).
fn write_titled(doc: &Document, title: &str) -> LegacyAiOutput {
let o = LegacyAiOptions { title: title.into(), created: Some(0), ..LegacyAiOptions::default() };
let out = export_legacy_ai(doc, &o).unwrap();
prints_as(&out.bytes, doc, &o);
out
}
/// The spot colors and gradients of the objects of `doc`'s first layer, in paint order: each
/// solid fill's swatch, and each gradient fill's swatch with its first stop's.
fn inks(doc: &Document) -> Vec<Option<String>> {
let mut out = vec![];
for n in children(&doc.layers[0]) {
match n.appearance.fill_paint() {
Paint::Solid { swatch, .. } => out.push(swatch.clone()),
Paint::Gradient(g) => {
out.push(g.swatch.clone());
out.push(g.gradient.stops.first().and_then(|s| s.swatch.clone()));
}
_ => {}
}
}
out
}
/// The title, spot color names and gradient names are written in Windows-1252, as layer names
/// are: é is 351 (octal) in the header's custom colors, each `x`, the gradient's definition, its
/// stops and `Bg`. The spot color and gradient names read back as they were.
#[test]
fn spot_gradient_and_title_names_are_written_in_windows_1252() {
let doc = spots_and_gradient(&["Caf\u{e9}"], "D\u{e9}grad\u{e9}");
let out = write_titled(&doc, "R\u{e9}sum\u{e9}");
let text = String::from_utf8(out.bytes.clone()).unwrap();
for needle in [
"%%Title: (R\\351sum\\351)\n",
"%%DocumentCustomColors: (Caf\\351)\n",
"%%CMYKCustomColor: 0 0.5 1 0 (Caf\\351)\n",
"0 0.5 1 0 (Caf\\351) 0.5 x\n",
"%AI5_BeginGradient: (D\\351grad\\351)\n(D\\351grad\\351) 0 2 Bd\n",
"0 0.5 1 0 (Caf\\351) 0 3 50 0 %_Bs\n",
"1 (D\\351grad\\351) ",
] {
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
}
assert!(!text.contains("\\303"), "{text}");
assert!(!out.warnings.iter().any(|w| w == TITLE_AND_COLOR_NAMES), "{:?}", out.warnings);
let read = crate::import(&out.bytes).unwrap().document;
let names = inks(&read);
let want = ["Caf\u{e9}", "D\u{e9}grad\u{e9}", "Caf\u{e9}"].map(|n| Some(n.to_string()));
assert_eq!(names, want);
}
/// A character Windows-1252 can't hold is written as `?`, with one warning for all of these
/// names. Names that become the same are numbered, so the header, each `x` and each `Bg` still
/// tell the colors and gradients apart.
#[test]
fn names_outside_windows_1252_are_replaced_and_kept_apart() {
// 赤 and 青 (spot colors), 渐变 and グラ (gradients), 日本 (the title).
let mut doc = spots_and_gradient(&["\u{8d64}", "\u{9752}"], "\u{6e10}\u{53d8}");
let g = Gradient {
kind: GradientKind::Linear,
stops: vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))],
};
let mut paint = GradientPaint::new(g);
paint.swatch = Some("\u{30b0}\u{30e9}".into());
let n = shape(&mut doc, shapes::rectangle(Rect::new(60.0, 50.0, 100.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, n).unwrap();
let out = write_titled(&doc, "\u{65e5}\u{672c}");
let text = String::from_utf8(out.bytes.clone()).unwrap();
for needle in [
"%%Title: (??)\n",
"%%DocumentCustomColors: (?)\n%%+ (? 2)\n",
"%%CMYKCustomColor: 0 0.5 1 0 (?)\n%%+ 0.5 0.5 1 0 (? 2)\n",
"0 0.5 1 0 (?) 0.5 x\n",
"0.5 0.5 1 0 (? 2) 0.5 x\n",
"(??) 0 2 Bd\n",
"(?? 2) 0 2 Bd\n",
"0 0.5 1 0 (?) 0 3 50 0 %_Bs\n",
"1 (??) ",
"1 (?? 2) ",
] {
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
}
assert_eq!(out.warnings.iter().filter(|w| *w == TITLE_AND_COLOR_NAMES).count(), 1, "{:?}", out.warnings);
assert!(!out.warnings.iter().any(|w| w == LAYER_NAME), "{:?}", out.warnings);
let read = crate::import(&out.bytes).unwrap().document;
let want = [Some("?"), Some("? 2"), Some("??"), Some("?"), Some("?? 2"), None].map(|n| n.map(String::from));
assert_eq!(inks(&read), want);
}
#[test]
fn losses_are_warned() {
let mut doc = sample();
+13
View File
@@ -221,6 +221,19 @@ impl SubPath {
let b = &self.anchors[(i + 1) % n];
!a.has_out() && !b.has_in()
}
/// Arc-length at each anchor from the start of this subpath (one entry per anchor).
pub fn anchor_arc_positions(&self) -> Vec<f64> {
let n = self.anchors.len();
if n == 0 {
return vec![];
}
let mut pos = vec![0.0];
for i in 0..self.segment_count() {
let len = kurbo::ParamCurveArclen::arclen(&self.segment(i), 1e-3);
pos.push(pos.last().copied().unwrap_or(0.0) + len);
}
pos
}
pub fn to_bezpath_into(&self, out: &mut BezPath) {
let Some(first) = self.anchors.first() else { return };
out.move_to(first.p);
+12
View File
@@ -92,6 +92,7 @@ fn export_tool_lists_engine_formats_and_options() {
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
assert!(open["description"].as_str().unwrap().contains(".ait"));
assert!(open["description"].as_str().unwrap().contains(".eps, .epsf, .ps, "), "{open}");
assert!(open["description"].as_str().unwrap().contains(".bmp, .tga, .vda, .icb, .vst, "), "{open}");
}
/// A fake app that records `engine.execute` calls and answers each with an empty object.
@@ -132,6 +133,17 @@ fn export_is_the_same_call_in_the_app_and_headless() {
assert_eq!(direct["dataBase64"], r["dataBase64"]);
}
#[test]
fn the_raster_scale_leaves_other_formats_alone() {
// DXF's own `scale` (drawing units per mm) comes through `options`; the raster one would resize
// the drawing and leave its units unset.
let mut h = two_boards();
let dxf = |h: &mut Headless, args: Value| text(&call_tool(h, "export", &args))["dataBase64"].clone();
let plain = dxf(&mut h, json!({"format": "dxf"}));
assert!(dxf(&mut h, json!({"format": "dxf", "scale": 2})) == plain, "the raster scale changed the DXF");
assert!(dxf(&mut h, json!({"format": "dxf", "options": {"scale": 2}})) != plain, "the DXF scale was dropped");
}
#[test]
fn exporting_native_keeps_the_document_path() {
let mut h = two_boards();
+16 -4
View File
@@ -2,7 +2,7 @@
//! [`Backend`].
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format};
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format, writable_format};
use crate::backend::Backend;
@@ -1003,9 +1003,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
params.remove(k);
}
}
for k in ["path", "format", "scale", "artboard", "range", "outlineText"] {
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
params.insert(k.into(), v.clone());
for k in named_export_params(a) {
if let Some(v) = a.get(*k).filter(|v| !v.is_null()) {
params.insert((*k).into(), v.clone());
}
}
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
@@ -1054,6 +1054,18 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
}
}
/// The named `export` arguments passed on to `document.export`. `scale` is raster pixels per
/// point, so only a raster format gets it: DXF's own (drawing units per unit, given in `options`)
/// would resize the drawing and leave its units unset.
fn named_export_params(a: &Args) -> &'static [&'static str] {
let format = writable_format(a.get("format").and_then(Value::as_str), a.get("path").and_then(Value::as_str));
if format.is_ok_and(|f| f.raster) {
&["path", "format", "scale", "artboard", "range", "outlineText"]
} else {
&["path", "format", "artboard", "range", "outlineText"]
}
}
/// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an
/// `isError` result so the model can read and correct them.
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
+8 -5
View File
@@ -13,6 +13,7 @@ use krilla::metadata::DateTime;
use crate::PdfError;
use crate::import_color::items;
use crate::import_scan::text_string;
use crate::lab_spot::find;
/// The embedded file holding the native document (its name among the PDF's attachments).
@@ -84,8 +85,9 @@ pub(crate) fn editing_in(pdf: &Pdf) -> Option<Editing> {
let files = spec.get::<Dict<'_>>(b"EF")?;
let stream = files.get::<Stream<'_>>(b"F").or_else(|| files.get::<Stream<'_>>(b"UF"))?;
let data = stream.decoded().map(|d| d.into_owned()).unwrap_or_default();
// The hash ends the description; editing data described otherwise is trusted.
let desc = spec.get::<PdfString<'_>>(b"Desc").map(|d| String::from_utf8_lossy(d.as_bytes()).into_owned()).unwrap_or_default();
// The hash ends the description (a text string, in any of its encodings); editing data
// described otherwise is trusted.
let desc = spec.get::<PdfString<'_>>(b"Desc").map(|d| text_string(d.as_bytes())).unwrap_or_default();
let intact = match desc.rsplit_once("hash ") {
Some((_, hash)) => hash.trim() == page_hash(pdf),
None => true,
@@ -119,11 +121,12 @@ fn editing_spec<'a>(node: &Dict<'a>, depth: usize, seen: &mut HashSet<ObjectIden
})
}
/// A file specification naming the editing file (current or legacy name).
/// A file specification naming the editing file (current or legacy name), in any text string
/// encoding.
fn is_editing_file(spec: &Dict<'_>) -> bool {
[&b"UF"[..], b"F"].into_iter().filter_map(|k| spec.get::<PdfString<'_>>(k)).any(|name| {
let name = name.as_bytes();
name == EDITING_FILE.as_bytes() || name == LEGACY_EDITING_FILE.as_bytes()
let name = text_string(name.as_bytes());
name == EDITING_FILE || name == LEGACY_EDITING_FILE
})
}
+46
View File
@@ -72,6 +72,35 @@ fn changed_pages_are_noticed() {
assert!(editing(&r.bytes).unwrap().intact);
}
/// `pdf` with its editing file's description rewritten by another app as "hash " and the hash it
/// held, in the text string encoding `bom` starts (UTF-16BE or UTF-8), padded with spaces to its
/// length.
fn described(pdf: &[u8], bom: &[u8]) -> Vec<u8> {
let from = b"(VectorCraft editing data; page hash ";
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
let mut desc = [b"(", bom].concat();
for &c in b"hash ".iter().chain(hash) {
if bom.len() == 2 {
desc.push(0);
}
desc.push(c);
}
desc.push(b')');
desc.resize(len, b' ');
patched(pdf, &pdf[at..at + len], &desc)
}
#[test]
fn the_hash_is_read_from_a_description_in_any_encoding() {
let r = pdf_with(Some(b"native"), false);
let resized = patched(&r.bytes, b"/MediaBox[0 0 100 100]", b"/MediaBox[0 0 100 101]");
for bom in [&[0xFE, 0xFF][..], &[0xEF, 0xBB, 0xBF]] {
assert!(editing(&described(&r.bytes, bom)).unwrap().intact, "{bom:?}: the same pages");
assert!(!editing(&described(&resized, bom)).unwrap().intact, "{bom:?}: another page size");
}
}
#[test]
fn the_legacy_name_is_read_and_plain_pdfs_have_no_editing_data() {
let r = pdf_with(Some(b"legacy"), false);
@@ -153,3 +182,20 @@ fn direct_name_tree_kids_are_all_searched() {
let e = editing(&raw_pdf(&objects)).unwrap();
assert_eq!(e.data, b"hello");
}
#[test]
fn a_file_name_written_as_utf16_is_found() {
let utf16: String = EDITING_FILE.bytes().map(|c| format!("00{c:02X}")).collect();
for key in ["UF", "F"] {
let objects = [
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 3 0 R >> >>".to_string(),
"<< /Type /Pages /Kids [6 0 R] /Count 1 >>".to_string(),
"<< /Names [(a) 4 0 R] >>".to_string(),
format!("<< /Type /Filespec /{key} <FEFF{utf16}> /EF << /F 5 0 R >> >>"),
"<< /Length 5 >>\nstream\nhello\nendstream".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>".to_string(),
];
let e = editing(&raw_pdf(&objects)).unwrap_or_else(|| panic!("/{key}: not found"));
assert_eq!(e.data, b"hello", "/{key}");
}
}
+20 -2
View File
@@ -584,7 +584,7 @@ thread_local! {
/// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a
/// number).
fn doc_raster_ppi(doc: &Document) -> f64 {
Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
doc.raster_ppi()
}
/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the
@@ -1052,7 +1052,9 @@ impl Renderer {
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved)
}
None => {
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
let grow = if matches!(fx, RasterFx::OuterGlow { .. }) { 18.0 / doc_raster_ppi(f.doc) } else { 0.0 };
let spread = (blur.max(0.0) * 1.5 + grow) / f.px + 2.0;
let reach = reach + Vec2::new(dx, dy);
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread);
let (x0, y0) = (r.x0.floor(), r.y0.floor());
@@ -1112,6 +1114,7 @@ impl Renderer {
// An Outer Glow blurs a solid silhouette: wherever the art has any alpha, as AI files draw a glow round a glow.
let solid = matches!(fx, RasterFx::OuterGlow { .. });
let mut a: Vec<f32> = pm.data().iter().map(|p| if solid && p.a > 0 { 255.0 } else { p.a as f32 }).collect();
effects::pixel::dilate_plane(&mut a, wu, hu, grow / f.px / scale);
effects::pixel::blur_plane(&mut a, wu, hu, sigma);
let [cr, cg, cb] = f.ink.rgb(&color);
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
@@ -2589,6 +2592,21 @@ mod tests {
assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}");
}
#[test]
fn an_outer_glow_grows_its_silhouette_by_a_quarter_raster_pixel() {
// GUARDS: the glows AI files export measure as the solid silhouette grown by a quarter of a pixel
// of the document's raster grid, then blurred: a quarter point at 72 ppi, next to nothing at 720.
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
let green_outside = |ppi: f64| {
let mut d =
doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]);
d.raster_effects_ppi = ppi;
render(&d).pixel(27, 50)[1]
};
let (coarse, fine) = (green_outside(72.0), green_outside(720.0));
assert!(coarse + 3 < fine, "the red glow is stronger at 72 ppi: 72 ppi {coarse}, 720 ppi {fine}");
}
#[test]
fn a_glow_glows_round_the_glows_before_it_in_the_stack() {
// GUARDS: the second glow is of the art with the first glow, not of the bare art, as AI files and the SVG export draw it.
+3 -1
View File
@@ -1222,7 +1222,9 @@ impl Renderer {
/// In isolation mode, while drawing outside the isolated container and the art dimmed around
/// it: the isolated container and its layers and groups, from the top layer down to it.
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
// Opacity-mask editing isolates the temporary layer for tools; that layer is not painted on
// the page (the mask applies on the object), so canvas dimming would wash out everything.
let isolated = f.opts.isolated.filter(|_| f.doc.mask_edit.is_none() && !self.isolation_settled)?;
let doc = std::ptr::from_ref(f.doc) as usize;
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
+8 -3
View File
@@ -1651,7 +1651,11 @@ impl Writer<'_> {
return 0;
}
let bounds = n.visual_bounds_with(&self.doc.symbol_boxes());
let reach: f64 = fx.iter().map(|f| f.outset(bounds.unwrap_or_default())).sum::<f64>() + 2.0;
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
let grow = 18.0 / self.doc.raster_ppi();
let reach: f64 =
fx.iter().map(|f| f.outset(bounds.unwrap_or_default()) + if matches!(f, RasterFx::OuterGlow { .. }) { grow } else { 0.0 }).sum::<f64>()
+ 2.0;
let region = bounds.map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach));
let mut opened = 0;
for f in fx.iter().rev() {
@@ -1691,9 +1695,10 @@ impl Writer<'_> {
self.num(*dy),
flood(color, *opacity)
),
// The glow blurs a solid silhouette: alpha × 255 makes any alpha full.
// The glow blurs a solid silhouette (alpha × 255 makes any alpha full) grown by a quarter raster pixel.
RasterFx::OuterGlow { opacity, blur, color, .. } => format!(
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"linear\" slope=\"255\"/></feComponentTransfer><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"linear\" slope=\"255\"/></feComponentTransfer><feMorphology operator=\"dilate\" radius=\"{}\"/><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
fmt_num(grow, 3),
sd(*blur),
flood(color, *opacity)
),
+17 -4
View File
@@ -10,7 +10,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use serde_json::json;
use usvg::filter::{ColorInterpolation, ColorMatrixKind, CompositeOperator, Filter, Input, Kind, TransferFunction};
use usvg::filter::{ColorInterpolation, ColorMatrixKind, CompositeOperator, Filter, Input, Kind, MorphologyOperator, TransferFunction};
use usvg::roxmltree;
use vectorcraft_color::Color;
use vectorcraft_doc::Effect;
@@ -177,8 +177,9 @@ fn color(c: usvg::Color) -> Color {
Color::rgb8(c.red, c.green, c.blue)
}
/// Evaluate one filter's primitives; `None` when one does something the effects can't.
fn eval(f: &Filter) -> Option<V> {
/// Evaluate one filter's primitives, `k` the scale from the filter's user space to the document;
/// `None` when one does something the effects can't.
fn eval(f: &Filter, k: f64) -> Option<V> {
let mut results: HashMap<&str, V> = HashMap::new();
let mut last = V::Graphic;
for p in f.primitives() {
@@ -238,6 +239,18 @@ fn eval(f: &Filter) -> Option<V> {
_ => return None,
}
}
// Our Outer Glow's silhouette grown by a quarter raster pixel (`export`): a dilation of a
// plain silhouette by up to that much at 72 ppi is that step.
Kind::Morphology(m)
if m.operator() == MorphologyOperator::Dilate
&& m.radius_x().get() == m.radius_y().get()
&& f64::from(m.radius_x().get()) * k <= 0.25 + 1e-6 =>
{
match input(m.input())? {
V::Alpha(s) if s.plain() => V::Alpha(s),
_ => return None,
}
}
Kind::DropShadow(d) if matches!(input(d.input())?, V::Graphic) => {
let shape =
Shape { sd: sd(d.std_dev_x().get(), d.std_dev_y().get()), dx: d.dx() as f64, dy: d.dy() as f64, moved: true, inverted: false };
@@ -264,7 +277,7 @@ pub(super) fn effects(filters: &[Arc<Filter>], ts: Affine, blends: &HashMap<Stri
let blur = |sd: f64| num(2.0 * sd * k);
let mode = blends.get(f.id()).map_or("normal", String::as_str);
let paint = |c: Color, o: f32| (c.to_hex(), num(o as f64 * 100.0));
let (id, params) = match eval(f)? {
let (id, params) = match eval(f, k)? {
V::Graphic => continue,
V::Blurred(sd, _) => ("blur.gaussian", json!({ "radius": blur(sd) })),
V::Feathered(sd) => ("stylize.feather", json!({ "radius": blur(sd) })),
+22 -2
View File
@@ -27,6 +27,9 @@ pub struct GestureTool {
began: bool,
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
extend: Option<(NodeId, bool)>,
/// Pencil/Paintbrush starting on a selected path away from its ends: (path, Edit Selected
/// Paths' distance in points); the stroke redraws the section it ends on (#2850).
redraw: Option<(NodeId, f64)>,
/// Fit tolerance in points (Pencil/Paintbrush/Smooth "Fidelity").
pub fidelity: f64,
/// Brush / eraser diameter in points.
@@ -66,6 +69,7 @@ impl GestureTool {
active: false,
began: false,
extend: None,
redraw: None,
fidelity,
size: 10.0,
fill: false,
@@ -143,6 +147,18 @@ impl GestureTool {
None
}
/// The selected path whose outline is near `p` (Edit Selected Paths, off at 0 pixels).
fn find_redraw(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, f64)> {
let tol = cx.tol(self.edit_within);
if self.edit_within <= 0.0 || !tol.is_finite() {
return None;
}
cx.selection.objects.iter().find_map(|id| {
let pd = cx.doc.node(*id).and_then(|n| n.path_data()).filter(|_| cx.doc.is_editable(*id))?;
pd.nearest(p).filter(|n| n.4 <= tol).map(|_| (*id, tol))
})
}
/// Parameters of `path.freehand` for the current stroke.
pub fn freehand_params(&self, closed: bool) -> Value {
let mut v = json!({
@@ -155,6 +171,8 @@ impl GestureTool {
});
if let Some((id, start)) = self.extend {
v["extend"] = json!({"id": id.0, "end": if start { "start" } else { "end" }});
} else if let Some((id, within)) = self.redraw {
v["redraw"] = json!({"id": id.0, "within": within});
}
v
}
@@ -211,8 +229,9 @@ impl Tool for GestureTool {
self.active = true;
self.began = false;
self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None };
// A path continued starts at its end; a new one where it snaps.
let start = if self.snaps() && self.extend.is_none() { self.snap.press(cx, p, &[], None) } else { p };
self.redraw = if self.draws_path() && self.extend.is_none() { self.find_redraw(cx, p) } else { None };
// A path continued starts at its end, one redrawn on it; a new one where it snaps.
let start = if self.snaps() && self.extend.is_none() && self.redraw.is_none() { self.snap.press(cx, p, &[], None) } else { p };
self.clear();
self.push(start, ev.pressure);
vec![]
@@ -260,6 +279,7 @@ impl Tool for GestureTool {
self.began = false;
self.clear();
self.extend = None;
self.redraw = None;
out
}
_ => vec![],
+12 -5
View File
@@ -2,9 +2,10 @@
//!
//! Type: click places point type, drag draws an area-type frame, clicking into existing text
//! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a
//! baseline (`text.createInPath`). Point type placed by a click and left empty is discarded when
//! editing ends (`text.discardEmpty`); frames keep their shape. Where new type goes (the click, the
//! frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too.
//! baseline (`text.createInPath`). While type is being edited, a click that lands on no type only
//! ends the editing, and the next click makes new type. Point type placed by a click and left
//! empty is discarded when editing ends (`text.discardEmpty`); frames keep their shape. Where new
//! type goes (the click, the frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too.
//!
//! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends
//! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a
@@ -271,14 +272,21 @@ impl TypeTool {
Some(out)
}
/// The button released after a press at `start` (new type goes at `at`).
/// The button released after a press at `start` (new type goes at `at`). While type is being
/// edited, a click that lands on no type only ends the editing; the next click makes new type
/// (#2871). A drag that draws an area-type frame makes it either way.
fn on_up(&mut self, cx: &ToolContext, start: Point, at: Point) -> Vec<Action> {
// Click into existing text: place the caret.
if let Some(out) = self.edit_at(cx, start) {
return out;
}
let was_editing = self.editing.is_some_and(|id| Self::text(cx, id).is_some());
let mut out = self.finish(cx);
let drag = self.drag.take();
let area = drag.map(|d| Rect::from_points(at, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
if was_editing && area.is_none() {
return out;
}
// Area Type / Type on a Path: click a path.
if self.mode != Mode::Type && drag.is_none() {
let on_path = self.mode == Mode::OnPath;
@@ -292,7 +300,6 @@ impl TypeTool {
return out;
}
}
let area = drag.map(|d| Rect::from_points(at, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
let mut params = match area {
Some(r) => json!({"x": r.x0, "y": r.y0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
None => json!({"x": at.x, "y": at.y, "text": ""}),
+102
View File
@@ -401,6 +401,108 @@ fn escape_returns_to_previous_tool_when_set() {
assert_eq!(acts2.last(), Some(&Action::SwitchTool("selection".into())));
}
/// A click from `tool` at (x, y) → the actions of the press and the release.
fn click(tool: &mut TypeTool, c: &ToolContext, x: f64, y: f64) -> Vec<Action> {
let mut acts = tool.pointer(c, &PointerEvent::new(PointerKind::Down, x, y));
acts.extend(tool.pointer(c, &PointerEvent::new(PointerKind::Up, x, y)));
acts
}
fn creates(acts: &[Action]) -> bool {
acts.iter().any(|a| matches!(a, Action::Exec(cmd, _) if cmd == "text.create" || cmd == "text.createInPath"))
}
/// While type is being edited, a click on blank canvas ends the editing and makes no new type;
/// the Type tool stays, and the next click makes point type (#2871, #2854). The same holds when
/// the editing began with a double-click from a selection tool.
#[test]
fn a_click_away_from_the_type_being_edited_only_ends_editing() {
for prev in [None, Some("selection")] {
let (d, _, mut tool) = editing("Hello");
if let Some(prev) = prev {
tool.set_option("prevTool", &json!(prev));
}
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
let acts = click(&mut tool, &c, 400.0, 400.0);
assert!(!creates(&acts), "{prev:?}: {acts:?}");
assert!(!acts.iter().any(|a| matches!(a, Action::SwitchTool(_))), "{prev:?}: {acts:?}");
assert_eq!((tool.id(), tool.editing), ("type", None));
assert_eq!(tool.prev_tool.as_deref(), prev);
let acts = click(&mut tool, &c, 400.0, 400.0);
assert!(
acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 400.0 && v.get("area").is_none())),
"{acts:?}"
);
}
}
/// Point type a click placed and left empty is still discarded by the click away, and that click
/// places no other type.
#[test]
fn a_click_away_from_empty_new_type_discards_it_and_makes_none() {
let (d, id, mut tool) = editing("");
tool.fresh = Some(id);
let (sel, p) = (Selection::default(), paint());
let acts = click(&mut tool, &cx(&d, &sel, &p), 400.0, 400.0);
assert_eq!(acts, vec![Action::Exec("text.discardEmpty".into(), json!({"id": id.0}))]);
}
/// Area Type and Type on a Path: a click on a path while other type is being edited ends the
/// editing only; the next click turns the path into a frame or a baseline.
#[test]
fn a_click_on_a_path_while_editing_only_ends_editing() {
let (mut d, rect) = crate::testutil::doc_with_rect();
let l = d.layers[0].id;
let id = d.alloc_id();
let t = TextObject::point(Point::new(300.0, 400.0), "Hello", CharStyle { size: 20.0, ..Default::default() });
d.insert(Some(l), 1, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
let (sel, p) = (Selection::default(), paint());
let c = cx(&d, &sel, &p);
for (tool_id, at) in [("areaType", (100.0, 150.0)), ("typeOnPath", (150.0, 100.0))] {
let mut tool = TypeTool::new(tool_id);
tool.start_editing(id, 0);
let acts = click(&mut tool, &c, at.0, at.1);
assert!(!creates(&acts), "{tool_id}: {acts:?}");
assert_eq!(tool.editing, None);
let acts = click(&mut tool, &c, at.0, at.1);
assert!(
acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.createInPath" && v["path"] == json!(rect.0))),
"{tool_id}: {acts:?}"
);
}
}
/// While type is being edited, a drag that draws an area-type frame still makes it, and a click
/// on other type edits that type at once.
#[test]
fn a_drag_or_a_click_on_other_type_while_editing_acts_at_once() {
let (mut d, id, mut tool) = editing("Hello");
let (sel, p) = (Selection::default(), paint());
let acts = {
let c = cx(&d, &sel, &p);
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, 400.0, 400.0));
tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 400.0, 400.0))
};
let area = json!({"width": 100.0, "height": 100.0});
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["area"] == area)), "{acts:?}");
let l = d.layers[0].id;
let other = d.alloc_id();
let t = TextObject::point(Point::new(100.0, 300.0), "Other", CharStyle { size: 20.0, ..Default::default() });
d.insert(Some(l), 1, Node::new(other, NodeKind::Text(Box::new(t)))).unwrap();
let c = cx(&d, &sel, &p);
let t = TypeTool::text(&c, other).unwrap().clone();
let lay = vectorcraft_text::layout(FontDb::global(), &t);
let (top, bot) = vectorcraft_text::caret_position(&lay, 2);
let at = t.xf * top.midpoint(bot);
tool.start_editing(id, 0);
let acts = click(&mut tool, &c, at.x, at.y);
assert!(!creates(&acts), "{acts:?}");
assert!(acts.contains(&Action::Exec("select.set".into(), json!({"ids": [other.0]}))), "{acts:?}");
assert_eq!(tool.editing, Some(other));
}
/// Selected area type whose text doesn't fit its frame shows the red out port with any tool, not
/// only the Type tools (#2323); text that fits shows none.
#[test]
+18 -5
View File
@@ -6711,11 +6711,12 @@ mod tests {
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 other = app.session.execute("text.create", &json!({"x": 300, "y": 250, "text": "Other", "size": 24})).unwrap()["id"].as_u64().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 (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(310.0, 245.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)]);
@@ -6723,17 +6724,29 @@ mod tests {
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)]);
// told to drop its composition. A press on other type edits that type at once.
let ime = typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]);
assert_eq!(plain(&app, id), "しょうこ");
assert!(!app.session.tool_composing());
assert!(ime.expect("editing the new text").should_interrupt_composition);
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(other)]);
assert!(ime.expect("editing the other 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");
typing_frame(&mut app, &ctx, vec![click(b, false)]);
// Interrupted once, not every frame.
assert!(!typing_frame(&mut app, &ctx, vec![]).unwrap().should_interrupt_composition);
assert!(!app.take_ime_discard());
// A click on blank canvas ends the editing (#2871): the marked text stays, the frame asks
// for no IME, and the host tells the system IME to drop its composition.
let blank = xf.to_screen(Point::new(150.0, 200.0));
typing_frame(&mut app, &ctx, vec![preedit("が", 1)]);
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(blank), click(blank, true)]);
let ime = typing_frame(&mut app, &ctx, vec![click(blank, false)]);
assert!(plain(&app, other).contains('が'), "{}", plain(&app, other));
assert!(!app.session.tool_composing() && !app.session.tool_wants_text());
assert!(ime.is_none());
assert!(app.ime_marked.is_none());
assert!(app.take_ime_discard());
}
#[test]
+25 -10
View File
@@ -390,14 +390,14 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited
/// "Name* @ 66.67 % (<Opacity Mask>/Opacity Mask)".
fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool) -> String {
fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool, display_print_size: bool) -> String {
let mode = if d.doc.mask_edit.is_some() {
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 { tl!("Outline") } else { tl!("Preview") })
};
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %"))
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %"))
}
/// Whether a document tab's × comes before its title (macOS) rather than after it (Windows,
@@ -435,7 +435,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
.enumerate()
.map(|(i, d)| {
let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0);
let title = tab_title(d, zoom, app.ui.view.outline);
let title = tab_title(d, zoom, app.ui.view.outline, app.session.prefs.display_print_size);
let color = if Some(i) == active { t.text_strong } else { t.text_dim };
(title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color))
})
@@ -653,11 +653,17 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0);
egui::ComboBox::from_id_salt("zoom-combo").selected_text(egui::RichText::new(zoom_label(zoom)).size(11.5)).width(76.0).show_ui(
ui,
|ui| {
let print = app.session.prefs.display_print_size;
let shown = crate::state::zoom_display_percent(zoom, print);
egui::ComboBox::from_id_salt("zoom-combo")
.selected_text(egui::RichText::new(zoom_label(zoom, print)).size(11.5))
.width(76.0)
.show_ui(ui, |ui| {
for z in ZOOM_STOPS.iter().rev() {
if ui.selectable_label((z / 100.0 - zoom).abs() < 1e-4, zoom_label(z / 100.0)).clicked() {
if ui
.selectable_label((z - shown).abs() < 0.01, zoom_label(crate::state::zoom_from_display_percent(*z, print), print))
.clicked()
{
app.run("view.setZoom", json!({"zoom": z})).ok();
}
}
@@ -668,8 +674,7 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
if ui.selectable_label(false, tl!("Fit All")).clicked() {
app.run("view.fitAll", json!({})).ok();
}
},
);
});
let rot = app.view().map(|v| v.rotation).unwrap_or(0.0);
egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui(
ui,
@@ -1139,6 +1144,16 @@ mod tests {
assert!(app.run("view.goToArtboard", json!({"index": "sideways"})).is_err());
}
/// storytold/vectorcraft#2719: status-bar 100% matches Actual Size when Display Print Size is on.
#[test]
fn set_zoom_100_matches_actual_size_with_display_print_size() {
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.session.execute("prefs.set", &json!({"key": "displayPrintSize", "value": true})).unwrap();
app.run("view.setZoom", json!({"zoom": 100})).unwrap();
assert!((app.view().unwrap().zoom - 96.0 / 72.0).abs() < 1e-12);
}
/// Screen readers name the artboard navigator's four buttons, the ones that lead nowhere from
/// the first artboard disabled.
#[test]
@@ -1413,7 +1428,7 @@ mod tests {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
let title = |s: &Session| super::tab_title(s.active().unwrap(), 1.0, false);
let title = |s: &Session| super::tab_title(s.active().unwrap(), 1.0, false, false);
assert!(title(&s).ends_with("(RGB/Preview)"), "{}", title(&s));
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
assert!(title(&s).ends_with("(<Opacity Mask>/Opacity Mask)"), "{}", title(&s));
+55 -3
View File
@@ -241,10 +241,12 @@ fn presets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.spacing_mut().item_spacing = egui::vec2(12.0, 12.0);
let (w, h, preset, units) = (d.f64("width", 0.0), d.f64("height", 0.0), d.str("preset"), unit(d));
for s in &list {
// The cards show the chosen orientation (square sizes have none) and the units chosen
// in Preset Details; choosing one keeps both.
// The built-in presets' cards show the chosen orientation (square sizes have none)
// and the units chosen in Preset Details; choosing one keeps both. Recent and Saved
// show each entry as it was made, so a portrait and a landscape A3 are two cards
// (#2860).
let mut s = DocSettings { units, ..s.clone() };
if (w > h && s.height > s.width) || (h > w && s.width > s.height) {
if cat > 1 && ((w > h && s.height > s.width) || (h > w && s.width > s.height)) {
std::mem::swap(&mut s.width, &mut s.height);
}
let selected = d.fields.contains_key(APPLIED) && s.name == preset && s.width == w && s.height == h;
@@ -636,6 +638,56 @@ mod tests {
app.ui.dialog.as_ref().unwrap().fields.get(k).cloned().unwrap_or_default()
}
/// Recent shows each entry as it was made: a portrait and a landscape A3 are two cards of
/// their own orientations, and only the chosen one is selected (#2860).
#[test]
fn recent_cards_keep_the_orientation_they_were_made_in() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let a3 = vectorcraft_engine::cmd::newdoc::find(&app.session, "A3").unwrap();
let landscape = vectorcraft_engine::cmd::newdoc::DocSettings { width: a3.height, height: a3.width, ..a3.clone() };
app.session.prefs.recent_new_docs = vec![landscape, a3];
app.run("file.newDialog", json!({})).unwrap();
assert_eq!(field(&app, "category"), json!("Recent"));
// Frames half a second apart on one context: the first lays the dialog out, and its fade
// in is over by the last.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut time = 0.0;
let mut run = |app: &mut VectorcraftApp| {
time += 0.5;
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1000.0)));
let raw = egui::RawInput { screen_rect, time: Some(time), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
out
};
run(&mut app);
run(&mut app);
let out = run(&mut app);
fn walk(s: &egui::Shape, out: &mut Vec<egui::Shape>) {
match s {
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
s => out.push(s.clone()),
}
}
let mut shapes = vec![];
out.shapes.iter().for_each(|c| walk(&c.shape, &mut shapes));
// The page thumbnails (white, 60 on their long side) and the selected card's outline.
let thumbs: Vec<egui::Vec2> = shapes
.iter()
.filter_map(|s| match s {
egui::Shape::Rect(r) if r.fill == egui::Color32::WHITE && (r.rect.width().max(r.rect.height()) - 60.0).abs() < 0.01 => {
Some(r.rect.size())
}
_ => None,
})
.collect();
assert_eq!((thumbs.iter().filter(|s| s.x > s.y).count(), thumbs.iter().filter(|s| s.y > s.x).count()), (1, 1), "{thumbs:?}");
let selected =
shapes.iter().filter(|s| matches!(s, egui::Shape::Rect(r) if r.stroke.width == 2.0 && r.rect.size() == egui::vec2(146.0, 150.0))).count();
assert_eq!(selected, 1, "one card chosen");
}
/// A double-click on a preset card creates the document from it (#663); a click only chooses it.
#[test]
fn a_double_clicked_preset_creates_the_document() {
+32 -5
View File
@@ -173,7 +173,7 @@ fn has_warnings(r: &Value) -> bool {
r["warnings"].as_array().is_some_and(|w| !w.is_empty())
}
/// `file.saveForWeb.browser`: the HTML page and its images in a temporary folder, opened in the
/// `file.saveForWeb.browser`: the HTML page and its images in a new temporary folder, opened in the
/// default browser (desktop). When the export has warnings, the status bar shows the first in
/// place of "Opened …".
pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
@@ -196,12 +196,39 @@ pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Val
Ok(json!({ "path": path, "warnings": r["warnings"] }))
}
/// The folder browser previews go to.
/// The age at which a later browser preview removes an earlier preview's folder.
#[cfg(not(target_arch = "wasm32"))]
const PREVIEW_KEEP: std::time::Duration = std::time::Duration::from_secs(3600);
/// The folder a browser preview goes to: a new one for each preview, in the temporary folder's
/// `VectorCraft Save for Web`.
#[cfg(not(target_arch = "wasm32"))]
fn temp_folder() -> Result<String, String> {
let dir = std::env::temp_dir().join("VectorCraft Save for Web");
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
Ok(dir.to_string_lossy().replace('\\', "/"))
preview_folder(&std::env::temp_dir().join("VectorCraft Save for Web"), PREVIEW_KEEP)
}
/// A new folder in `root`, named after the process id and a count of the process's previews. A
/// name that is already taken, by an earlier process with the same id, is skipped for the next
/// count. Entries of `root` last changed `keep` or longer ago are removed first.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn preview_folder(root: &std::path::Path, keep: std::time::Duration) -> Result<String, String> {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
for e in std::fs::read_dir(root).into_iter().flatten().flatten() {
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age >= keep);
if old {
// Best effort: one that can't be removed now is tried again by the next preview.
let _ = if e.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(e.path()) } else { std::fs::remove_file(e.path()) };
}
}
std::fs::create_dir_all(root).map_err(|e| format!("{}: {e}", root.display()))?;
loop {
let dir = root.join(format!("{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
match std::fs::create_dir(&dir) {
Ok(()) => return Ok(dir.to_string_lossy().replace('\\', "/")),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(format!("{}: {e}", dir.display())),
}
}
}
#[cfg(target_arch = "wasm32")]
@@ -141,6 +141,68 @@ fn preview_in_browser_opens_the_page() {
assert!(log.last().unwrap().starts_with("open file://") && log.last().unwrap().ends_with("Untitled-1.html"), "{log:?}");
}
/// Each preview has its own folder: a second one, from another app or the same one, leaves the
/// first page and its image as they were.
#[test]
fn a_later_browser_preview_leaves_an_earlier_one_alone() {
let dir = vectorcraft_testkit::temp_dir("sfw-browser-two");
let (mut first, _) = app(&dir, false);
let (mut second, _) = app(&dir, false);
let page = |r: serde_json::Value| r["path"].as_str().unwrap().to_string();
let jpg = page(first.run("file.saveForWeb.browser", json!({"format": "jpg"})).unwrap());
let gif = page(second.run("file.saveForWeb.browser", json!({"format": "gif"})).unwrap());
let png = page(first.run("file.saveForWeb.browser", json!({"format": "png24"})).unwrap());
assert!(jpg != gif && jpg != png && gif != png, "{jpg} {gif} {png}");
let root = std::env::temp_dir().join("VectorCraft Save for Web").to_string_lossy().replace('\\', "/");
for (page, image) in [(&jpg, "images/Untitled-1.jpg"), (&gif, "images/Untitled-1.gif"), (&png, "images/Untitled-1.png")] {
assert!(page.starts_with(&root) && page.ends_with("/Untitled-1.html"), "{page}");
assert!(std::fs::read_to_string(page).unwrap().contains(image), "{page}");
let folder = std::path::Path::new(page).parent().unwrap();
assert!(folder.join(image).exists(), "{page}");
}
}
/// Making a preview's folder removes the folders and files in its root that are `keep` old or
/// older, and keeps younger ones.
#[test]
fn a_browser_preview_removes_old_previews() {
let root = vectorcraft_testkit::temp_dir("sfw-browser-old").join("previews");
let _ = std::fs::remove_dir_all(&root);
let earlier = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap();
std::fs::create_dir_all(format!("{earlier}/images")).unwrap();
std::fs::write(format!("{earlier}/images/Untitled-1.gif"), b"GIF89a").unwrap();
std::fs::write(root.join("Untitled-1.html"), b"<html>").unwrap();
// The folder and the page are younger than an hour, so both stay.
let kept = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap();
assert!(std::path::Path::new(&earlier).exists() && root.join("Untitled-1.html").exists());
// With a `keep` of zero every entry is old enough: all three go, and only the new folder is left.
let next = save_for_web::preview_folder(&root, std::time::Duration::ZERO).unwrap();
assert!(!std::path::Path::new(&earlier).exists() && !std::path::Path::new(&kept).exists() && !root.join("Untitled-1.html").exists());
assert!(std::path::Path::new(&next).is_dir());
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1);
}
/// A folder that an earlier process with the same id left, younger than `keep`, is not reused:
/// the preview makes a new, empty one.
#[test]
fn a_browser_preview_skips_a_folder_an_earlier_process_left() {
let root = vectorcraft_testkit::temp_dir("sfw-browser-reused").join("previews");
let _ = std::fs::remove_dir_all(&root);
let keep = std::time::Duration::from_secs(3600);
let earlier = save_for_web::preview_folder(&root, keep).unwrap();
let count: u64 = earlier.rsplit('-').next().unwrap().parse().unwrap();
// The next 200 names, as an earlier process with the same id would have left them.
for n in count + 1..=count + 200 {
let left = root.join(format!("{}-{n}", std::process::id()));
std::fs::create_dir_all(left.join("images")).unwrap();
std::fs::write(left.join("Untitled-1.html"), b"<html>").unwrap();
}
let next = save_for_web::preview_folder(&root, keep).unwrap();
assert_ne!(next, earlier);
assert_eq!(std::fs::read_dir(&next).unwrap().count(), 0, "{next}");
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 202);
}
#[test]
fn saving_says_in_the_status_bar_when_an_image_could_not_be_decoded() {
const NOTE: &str = "with 1 note(s): an image couldn't be decoded and is missing from the picture";
+8
View File
@@ -3726,6 +3726,8 @@ axis Both И двете
@msg `{key}` must be a #rrggbb color `{key}` трябва да бъде цвят във формат #rrggbb
@msg `{key}` must be a #rrggbb colour `{key}` трябва да бъде цвят във формат #rrggbb
@msg `{key}` must be a number `{key}` трябва да бъде число
@msg `{key}` must be a number, not {v} `{key}` трябва да бъде число, а не {v}
@msg {key} must be Unix seconds (an integer) or null {key} трябва да бъде Unix време в секунди (цяло число) или null
@msg `{key}` must be a string `{key}` трябва да бъде низ
@msg `{key}` must be a whole number `{key}` трябва да бъде цяло число
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` трябва да бъде цяло число между {min} и {max} (получено е {x})
@@ -3852,6 +3854,7 @@ axis Both И двете
@msg missing `tool` липсва `tool`
@msg missing index липсва индекс
@msg missing number `{key}` липсва число `{key}`
@msg missing integer `{key}` липсва цяло число `{key}`
@msg multiple windows land with M11.5 множество прозорци се зареждат с M11.5
@msg name the file именувайте файла
@msg no CSS to copy: select objects with CSS of their own няма CSS за копиране: изберете обекти със собствен CSS
@@ -4283,6 +4286,9 @@ axis Both И двете
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: резултатът от стъпка {i} няма такава стойност (напишете $$ за литерален знак $)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: няма стъпка {i} преди тази (стъпките се броят от 0; напишете $$ за литерален знак $)
@msg object ids are non-negative integers, not {v} идентификаторите на обектите са неотрицателни цели числа, а не {v}
@msg {key} must be an array of non-negative integer object ids {key} трябва да бъде масив от идентификатори на обекти, които са неотрицателни цели числа
@msg invalid object id {value}: expected a non-negative integer невалиден идентификатор на обект {value}: очаква се неотрицателно цяло число
@msg no such object id {value} няма обект с идентификатор {value}
@msg automation filesystem access is not granted: read authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за четене: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за запис: {path}
@msg automation path rejected: outside the read root {root}: {path} пътят за автоматизация е отхвърлен: извън корена за четене {root}: {path}
@@ -5170,3 +5176,5 @@ theme Appearance Външен вид
Minimum must not exceed Desired, nor Desired Maximum. Минималната стойност не може да надвишава желаната, нито желаната – максималната.
Justification Подравняване
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them променливите и наборите от данни на файла не бяха прочетени (променливи: {variables}, набори от данни: {data_sets}): панелът „Променливи“ не ги показва
# Select by Color (select.color)
Select by Color Избиране по цвят
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -4104,6 +4104,8 @@ imageTrace Outline Obrys
@msg `{key}` must be a #rrggbb color `{key}` musí být barva #rrggbb
@msg `{key}` must be a #rrggbb colour `{key}` musí být barva #rrggbb
@msg `{key}` must be a number `{key}` musí být číslo
@msg `{key}` must be a number, not {v} `{key}` musí být číslo, ne {v}
@msg {key} must be Unix seconds (an integer) or null {key} musí být unixový čas v sekundách (celé číslo) nebo null
@msg `{key}` must be a string `{key}` musí být řetězec
@msg `{key}` must be a whole number `{key}` musí být celé číslo
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` musí být celé číslo mezi {min} a {max} (zadáno {x})
@@ -4234,6 +4236,7 @@ imageTrace Outline Obrys
@msg missing `tool` chybí `tool`
@msg missing index chybí index
@msg missing number `{key}` chybí číslo `{key}`
@msg missing integer `{key}` chybí celé číslo `{key}`
@msg multiple windows land with M11.5 více oken přijde s M11.5
@msg name the file pojmenujte soubor
@msg no CSS to copy: select objects with CSS of their own žádné CSS ke kopírování: vyberte objekty s vlastním CSS
@@ -4356,6 +4359,7 @@ imageTrace Outline Obrys
@msg object {id} is not a selected top-level object objekt {id} není vybraný objekt nejvyšší úrovně
@msg page size {w} × {h} velikost stránky {w} × {h}
@msg page {0} does not exist (the PDF has {1}) stránka {0} neexistuje (PDF jich má {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: stránka {n} neexistuje (soubor má 1)
@msg paste unavailable vložení není k dispozici
@msg path is already closed cesta už je uzavřená
@msg paths contain invalid coordinates cesty obsahují neplatné souřadnice
@@ -4629,6 +4633,9 @@ imageTrace Outline Obrys
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: výsledek kroku {i} takovou hodnotu nemá (pro samotný znak $ napište $$)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: před tímto krokem není krok {i} (kroky se číslují od 0; pro samotný znak $ napište $$)
@msg object ids are non-negative integers, not {v} ID objektů jsou nezáporná celá čísla, ne {v}
@msg {key} must be an array of non-negative integer object ids {key} musí být pole ID objektů, která jsou nezáporná celá čísla
@msg invalid object id {value}: expected a non-negative integer neplatné ID objektu {value}: očekává se nezáporné celé číslo
@msg no such object id {value} objekt s ID {value} neexistuje
@msg automation filesystem access is not granted: read authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění ke čtení: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění k zápisu: {path}
@msg automation path rejected: outside the read root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro čtení {root}: {path}
@@ -5205,3 +5212,5 @@ mark-style Japanese Japonské
Minimum must not exceed Desired, nor Desired Maximum. Minimum nesmí překročit požadovanou hodnotu ani požadovaná hodnota maximum.
Justification Zarovnání do bloku
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them proměnné a sady dat souboru nebyly načteny (proměnné: {variables}, sady dat: {data_sets}): panel Proměnné je nezobrazuje
# Select by Color (select.color)
Select by Color Vybrat podle barvy
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -3855,6 +3855,8 @@ imageTrace Outline Kontur
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` skal være `auto` eller en sprogkode som `en` eller `zh-hant`
@msg `{key}` must be a #rrggbb colour `{key}` skal være en #rrggbb-farve
@msg `{key}` must be a number `{key}` skal være et tal
@msg `{key}` must be a number, not {v} `{key}` skal være et tal, ikke {v}
@msg {key} must be Unix seconds (an integer) or null {key} skal være Unix-tid i sekunder (et heltal) eller null
@msg `{key}` must be a string `{key}` skal være en streng
@msg `{key}` must be a whole number `{key}` skal være et heltal
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` skal være et heltal mellem {min} og {max} (fik {x})
@@ -3974,6 +3976,8 @@ imageTrace Outline Kontur
@msg missing `name` `name` mangler
@msg missing `tool` `tool` mangler
@msg missing index indeks mangler
@msg missing number `{key}` tallet `{key}` mangler
@msg missing integer `{key}` heltallet `{key}` mangler
@msg multiple windows land with M11.5 flere vinduer lander med M11.5
@msg name the file giv filen et navn
@msg no CSS to copy: select objects with CSS of their own ingen CSS at kopiere: markér objekter med deres egen CSS
@@ -4356,6 +4360,9 @@ imageTrace Outline Kontur
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: resultatet af trin {i} har ingen sådan værdi (skriv $$ for at få et bogstaveligt $)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: der findes intet trin {i} før dette (trin nummereres fra 0; skriv $$ for at få et bogstaveligt $)
@msg object ids are non-negative integers, not {v} objekt-id'er er ikke-negative heltal, ikke {v}
@msg {key} must be an array of non-negative integer object ids {key} skal være et array af objekt-id'er, der er ikke-negative heltal
@msg invalid object id {value}: expected a non-negative integer ugyldigt objekt-id {value}: et ikke-negativt heltal var forventet
@msg no such object id {value} der findes intet objekt med id {value}
@msg automation filesystem access is not granted: read authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: læserettighed mangler: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: skriverettighed mangler: {path}
@msg automation path rejected: outside the read root {root}: {path} automatiseringssti afvist: uden for læseroden {root}: {path}
@@ -5188,3 +5195,5 @@ swatch Bright Violet Kraftig violet
Full Justify Lige margener
Minimum must not exceed Desired, nor Desired Maximum. Minimum må ikke overstige Ønsket, og Ønsket ikke Maksimum.
Justification Justering
# Select by Color (select.color)
Select by Color Markér efter farve
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -3739,6 +3739,8 @@ theme Appearance Erscheinungsbild
@msg `{key}` must be a #rrggbb color `{key}` muss eine Farbe der Form #rrggbb sein
@msg `{key}` must be a #rrggbb colour `{key}` muss eine Farbe der Form #rrggbb sein
@msg `{key}` must be a number `{key}` muss eine Zahl sein
@msg `{key}` must be a number, not {v} `{key}` muss eine Zahl sein, nicht {v}
@msg {key} must be Unix seconds (an integer) or null {key} muss eine Unix-Zeit in Sekunden (eine ganze Zahl) oder null sein
@msg `{key}` must be a string `{key}` muss eine Zeichenfolge sein
@msg `{key}` must be a whole number `{key}` muss eine ganze Zahl sein
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` muss eine ganze Zahl zwischen {min} und {max} sein (erhalten: {x})
@@ -3869,6 +3871,7 @@ theme Appearance Erscheinungsbild
@msg missing `tool` `tool` fehlt
@msg missing index der Index fehlt
@msg missing number `{key}` die Zahl `{key}` fehlt
@msg missing integer `{key}` die ganze Zahl `{key}` fehlt
@msg multiple windows land with M11.5 mehrere Fenster kommen mit M11.5
@msg name the file benennen Sie die Datei
@msg no CSS to copy: select objects with CSS of their own kein CSS zum Kopieren: Wählen Sie Objekte mit eigenem CSS aus
@@ -3993,6 +3996,7 @@ theme Appearance Erscheinungsbild
@msg object {id} is not a selected top-level object Objekt {id} ist kein ausgewähltes Objekt der obersten Stufe
@msg page size {w} × {h} Seitengröße {w} × {h}
@msg page {0} does not exist (the PDF has {1}) Seite {0} ist nicht vorhanden (die PDF-Datei hat {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: Seite {n} ist nicht vorhanden (die Datei hat 1)
@msg paste unavailable Einfügen nicht verfügbar
@msg path is already closed der Pfad ist bereits geschlossen
@msg paths contain invalid coordinates die Pfade enthalten ungültige Koordinaten
@@ -4302,6 +4306,9 @@ theme Appearance Erscheinungsbild
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: Das Ergebnis von Schritt {i} hat keinen solchen Wert (schreiben Sie $$ für ein literales $)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: Vor diesem Schritt gibt es keinen Schritt {i} (Schritte zählen ab 0; schreiben Sie $$ für ein literales $)
@msg object ids are non-negative integers, not {v} Objekt-IDs sind nicht negative ganze Zahlen, nicht {v}
@msg {key} must be an array of non-negative integer object ids {key} muss ein Array von Objekt-IDs sein, die nicht negative ganze Zahlen sind
@msg invalid object id {value}: expected a non-negative integer ungültige Objekt-ID {value}: erwartet wird eine nicht negative ganze Zahl
@msg no such object id {value} kein Objekt mit der ID {value}
@msg automation filesystem access is not granted: read authority is absent: {path} Dateisystemzugriff für die Automatisierung ist nicht gewährt: Die Leseberechtigung fehlt: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} Dateisystemzugriff für die Automatisierung ist nicht gewährt: Die Schreibberechtigung fehlt: {path}
@msg automation path rejected: outside the read root {root}: {path} Automatisierungspfad abgelehnt: außerhalb des Lesestammordners {root}: {path}
@@ -4385,6 +4392,9 @@ theme Appearance Erscheinungsbild
@msg not a PSD file keine PSD-Datei
@msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode PSD-Dateien im Mehrkanalmodus können nicht gelesen werden: Speichern Sie die Datei im RGB-, CMYK- oder Graustufenmodus
@msg the PSD file has an unknown colour mode ({m}) die PSD-Datei hat einen unbekannten Farbmodus ({m})
@msg the PSD file is cut short die PSD-Datei ist unvollständig
@msg the PSD file's header is damaged der Header der PSD-Datei ist beschädigt
@msg the PSD file's image data can't be read: {e} die Bilddaten der PSD-Datei können nicht gelesen werden: {e}
@msg the PSD file's image data uses an unknown compression ({m}) die Bilddaten der PSD-Datei verwenden eine unbekannte Komprimierung ({m})
@msg the PSD image is too large to read ({_1} × {_2} pixels) das PSD-Bild ist zu groß zum Lesen ({_1} × {_2} Pixel)
@msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden
@@ -4636,6 +4646,7 @@ lighting Light Licht
Top Right Oben rechts
Bottom Right Unten rechts
effect Regular Regelmäßig
effect Soft Weich
Bottom Left Unten links
# Corner Radius: a field per corner of a live rectangle.
Single Corner Radius Einheitlicher Eckenradius
@@ -4781,3 +4792,5 @@ effect Regular Regelmäßig
Minimum Minimum
Justification Abstände
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them die Variablen und Datensätze der Datei wurden nicht gelesen (Variablen: {variables}, Datensätze: {data_sets}): Das Bedienfeld „Variablen“ listet sie nicht auf
# Select by Color (select.color)
Select by Color Nach Farbe auswählen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -2097,6 +2097,8 @@ axis Both Και τα δύο
@msg `{key}` must be a #rrggbb color Το `{key}` πρέπει να είναι χρώμα #rrggbb
@msg `{key}` must be a #rrggbb colour Το `{key}` πρέπει να είναι χρώμα #rrggbb
@msg `{key}` must be a number Το `{key}` πρέπει να είναι αριθμός
@msg `{key}` must be a number, not {v} Το `{key}` πρέπει να είναι αριθμός, όχι {v}
@msg {key} must be Unix seconds (an integer) or null {key}: πρέπει να είναι χρόνος Unix σε δευτερόλεπτα (ακέραιος) ή null
@msg `{key}` must be a string Το `{key}` πρέπει να είναι συμβολοσειρά
@msg `{key}` must be a whole number Το `{key}` πρέπει να είναι ακέραιος αριθμός
@msg `{key}` must be a whole number between {min} and {max} (got {x}) Το `{key}` πρέπει να είναι ακέραιος από {min} έως {max} (δόθηκε {x})
@@ -3909,6 +3911,7 @@ theme Appearance Εμφάνιση
@msg missing `tool` λείπει το `tool`
@msg missing index λείπει ο δείκτης
@msg missing number `{key}` λείπει ο αριθμός `{key}`
@msg missing integer `{key}` λείπει ο ακέραιος `{key}`
@msg multiple windows land with M11.5 τα πολλαπλά παράθυρα θα προστεθούν στην έκδοση M11.5
@msg name the file δώστε όνομα στο αρχείο
@msg no CSS to copy: select objects with CSS of their own δεν υπάρχει CSS για αντιγραφή: επιλέξτε αντικείμενα με δικό τους CSS
@@ -4032,6 +4035,7 @@ theme Appearance Εμφάνιση
@msg object {id} is not a selected top-level object το αντικείμενο {id} δεν είναι επιλεγμένο αντικείμενο ανώτατης βαθμίδας
@msg page size {w} × {h} μέγεθος σελίδας {w} × {h}
@msg page {0} does not exist (the PDF has {1}) η σελίδα {0} δεν υπάρχει (το PDF έχει {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: η σελίδα {n} δεν υπάρχει (το αρχείο έχει 1)
@msg paste unavailable η επικόλληση δεν είναι διαθέσιμη
@msg path is already closed η διαδρομή είναι ήδη κλειστή
@msg paths contain invalid coordinates οι διαδρομές περιέχουν μη έγκυρες συντεταγμένες
@@ -4333,6 +4337,9 @@ theme Appearance Εμφάνιση
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: το αποτέλεσμα του βήματος {i} δεν έχει τέτοια τιμή (γράψτε $$ για κυριολεκτικό $)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: δεν υπάρχει βήμα {i} πριν από αυτό (η αρίθμηση αρχίζει από 0· γράψτε $$ για κυριολεκτικό $)
@msg object ids are non-negative integers, not {v} τα αναγνωριστικά αντικειμένων πρέπει να είναι μη αρνητικοί ακέραιοι, όχι {v}
@msg {key} must be an array of non-negative integer object ids {key}: πρέπει να είναι πίνακας αναγνωριστικών αντικειμένων που είναι μη αρνητικοί ακέραιοι
@msg invalid object id {value}: expected a non-negative integer μη έγκυρο αναγνωριστικό αντικειμένου {value}: αναμένεται μη αρνητικός ακέραιος
@msg no such object id {value} δεν υπάρχει αντικείμενο με αναγνωριστικό {value}
@msg automation filesystem access is not granted: read authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα ανάγνωσης: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα εγγραφής: {path}
@msg automation path rejected: outside the read root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας ανάγνωσης {root}: {path}
@@ -5157,3 +5164,5 @@ effect Points Σημεία
Minimum must not exceed Desired, nor Desired Maximum. Το ελάχιστο δεν πρέπει να υπερβαίνει το επιθυμητό, ούτε το επιθυμητό το μέγιστο.
Justification Πλήρης στοίχιση
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them οι μεταβλητές και τα σύνολα δεδομένων του αρχείου δεν διαβάστηκαν (μεταβλητές: {variables}, σύνολα δεδομένων: {data_sets}): ο πίνακας Μεταβλητές δεν τα εμφανίζει
# Select by Color (select.color)
Select by Color Επιλογή βάσει χρώματος
Can't render this file because it contains an unexpected character in line 2006 and column 19.
+9
View File
@@ -3802,6 +3802,8 @@ axis Both Ambos
@msg `{key}` must be a #rrggbb color `{key}` debe ser un color #rrggbb
@msg `{key}` must be a #rrggbb colour `{key}` debe ser un color #rrggbb
@msg `{key}` must be a number `{key}` debe ser un número
@msg `{key}` must be a number, not {v} `{key}` debe ser un número, no {v}
@msg {key} must be Unix seconds (an integer) or null {key} debe ser un tiempo Unix en segundos (un entero) o null
@msg `{key}` must be a string `{key}` debe ser un texto
@msg `{key}` must be a whole number `{key}` debe ser un número entero
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` debe ser un número entero entre {min} y {max} (se recibió {x})
@@ -3932,6 +3934,7 @@ axis Both Ambos
@msg missing `tool` falta `tool`
@msg missing index falta index
@msg missing number `{key}` falta el número `{key}`
@msg missing integer `{key}` falta el número entero `{key}`
@msg multiple windows land with M11.5 las ventanas múltiples llegan con M11.5
@msg name the file ponle nombre al archivo
@msg no CSS to copy: select objects with CSS of their own no hay CSS que copiar: selecciona objetos con CSS propio
@@ -4055,6 +4058,7 @@ axis Both Ambos
@msg object {id} is not a selected top-level object el objeto {id} no es un objeto seleccionado de nivel superior
@msg page size {w} × {h} tamaño de página {w} × {h}
@msg page {0} does not exist (the PDF has {1}) la página {0} no existe (el PDF tiene {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: la página {n} no existe (el archivo tiene 1)
@msg paste unavailable pegar no está disponible
@msg path is already closed el trazado ya está cerrado
@msg paths contain invalid coordinates los trazados contienen coordenadas no válidas
@@ -4351,6 +4355,9 @@ axis Both Ambos
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: el resultado del paso {i} no tiene ese valor (escriba $$ para un $ literal)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: no hay ningún paso {i} antes de este (los pasos se cuentan desde 0; escriba $$ para un $ literal)
@msg object ids are non-negative integers, not {v} los identificadores de objeto son enteros no negativos, no {v}
@msg {key} must be an array of non-negative integer object ids {key} debe ser una lista de identificadores de objeto que sean enteros no negativos
@msg invalid object id {value}: expected a non-negative integer identificador de objeto no válido {value}: se esperaba un entero no negativo
@msg no such object id {value} no existe el objeto con el identificador {value}
@msg automation filesystem access is not granted: read authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de lectura: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de escritura: {path}
@msg automation path rejected: outside the read root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de lectura {root}: {path}
@@ -5147,3 +5154,5 @@ mark-style Japanese Japonesas
Minimum must not exceed Desired, nor Desired Maximum. El mínimo no puede superar el deseado, ni el deseado el máximo.
Justification Justificación
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them no se leyeron las variables ni los conjuntos de datos del archivo (variables: {variables}, conjuntos de datos: {data_sets}): el panel Variables no los muestra
# Select by Color (select.color)
Select by Color Seleccionar por color
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -4031,6 +4031,8 @@ imageTrace Outline Ääriviiva
@msg `{key}` must be a #rrggbb color `{key}` on oltava #rrggbb-väri
@msg `{key}` must be a #rrggbb colour `{key}` on oltava #rrggbb-väri
@msg `{key}` must be a number `{key}` on oltava luku
@msg `{key}` must be a number, not {v} `{key}` on oltava luku, ei {v}
@msg {key} must be Unix seconds (an integer) or null {key} on oltava Unix-aika sekunteina (kokonaisluku) tai null
@msg `{key}` must be a string `{key}` on oltava merkkijono
@msg `{key}` must be a whole number `{key}` on oltava kokonaisluku
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` on oltava kokonaisluku väliltä {min}–{max} (saatiin {x})
@@ -4157,6 +4159,7 @@ imageTrace Outline Ääriviiva
@msg missing `tool` `tool` puuttuu
@msg missing index indeksi puuttuu
@msg missing number `{key}` luku `{key}` puuttuu
@msg missing integer `{key}` kokonaisluku `{key}` puuttuu
@msg multiple windows land with M11.5 useat ikkunat tulevat versiossa M11.5
@msg name the file anna tiedostolle nimi
@msg no CSS to copy: select objects with CSS of their own ei kopioitavaa CSS:ää: valitse objekteja, joilla on oma CSS
@@ -4279,6 +4282,7 @@ imageTrace Outline Ääriviiva
@msg object {id} is not a selected top-level object objekti {id} ei ole valittu ylimmän tason objekti
@msg page size {w} × {h} sivun koko {w} × {h}
@msg page {0} does not exist (the PDF has {1}) sivua {0} ei ole olemassa (PDF-tiedostossa on {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: sivua {n} ei ole olemassa (tiedostossa on 1)
@msg paste unavailable liittäminen ei ole käytettävissä
@msg path is already closed polku on jo suljettu
@msg paths contain invalid coordinates polut sisältävät virheellisiä koordinaatteja
@@ -4551,6 +4555,9 @@ imageTrace Outline Ääriviiva
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: vaiheen {i} tuloksessa ei ole tällaista arvoa (kirjoita $$, jos tarkoitat merkkiä $)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: ennen tätä ei ole vaihetta {i} (vaiheet numeroidaan 0:sta; kirjoita $$, jos tarkoitat merkkiä $)
@msg object ids are non-negative integers, not {v} objektitunnukset ovat ei-negatiivisia kokonaislukuja, ei {v}
@msg {key} must be an array of non-negative integer object ids {key} on oltava taulukko objektitunnuksia, jotka ovat ei-negatiivisia kokonaislukuja
@msg invalid object id {value}: expected a non-negative integer virheellinen objektitunnus {value}: odotettiin ei-negatiivista kokonaislukua
@msg no such object id {value} objektia tunnuksella {value} ei ole
@msg automation filesystem access is not granted: read authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: lukuoikeus puuttuu: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: kirjoitusoikeus puuttuu: {path}
@msg automation path rejected: outside the read root {root}: {path} automaation polku hylättiin: lukujuuren {root} ulkopuolella: {path}
@@ -5169,3 +5176,5 @@ mark-style Japanese Japanilainen
Minimum must not exceed Desired, nor Desired Maximum. Minimi ei saa ylittää haluttua arvoa eikä haluttu arvo maksimia.
Justification Tasaus
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them tiedoston muuttujia ja tietojoukkoja ei luettu (muuttujia: {variables}, tietojoukkoja: {data_sets}): Muuttujat-paneeli ei näytä niitä
# Select by Color (select.color)
Select by Color Valitse värin mukaan
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -3799,6 +3799,8 @@ axis Both Les deux
@msg `{key}` must be a #rrggbb color `{key}` doit être une couleur #rrggbb
@msg `{key}` must be a #rrggbb colour `{key}` doit être une couleur #rrggbb
@msg `{key}` must be a number `{key}` doit être un nombre
@msg `{key}` must be a number, not {v} `{key}` doit être un nombre, pas {v}
@msg {key} must be Unix seconds (an integer) or null {key} doit être un temps Unix en secondes (un entier) ou null
@msg `{key}` must be a string `{key}` doit être une chaîne
@msg `{key}` must be a whole number `{key}` doit être un nombre entier
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` doit être un nombre entier compris entre {min} et {max} (reçu : {x})
@@ -3929,6 +3931,7 @@ axis Both Les deux
@msg missing `tool` `tool` manquant
@msg missing index index manquant
@msg missing number `{key}` nombre `{key}` manquant
@msg missing integer `{key}` nombre entier `{key}` manquant
@msg multiple windows land with M11.5 les fenêtres multiples arrivent avec M11.5
@msg name the file nommez le fichier
@msg no CSS to copy: select objects with CSS of their own aucun CSS à copier : sélectionnez des objets ayant leur propre CSS
@@ -4052,6 +4055,7 @@ axis Both Les deux
@msg object {id} is not a selected top-level object l’objet {id} n’est pas un objet sélectionné de premier niveau
@msg page size {w} × {h} format de page {w} × {h}
@msg page {0} does not exist (the PDF has {1}) la page {0} n’existe pas (le PDF en a {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail : la page {n} n’existe pas (le fichier en a 1)
@msg paste unavailable collage indisponible
@msg path is already closed le tracé est déjà fermé
@msg paths contain invalid coordinates les tracés contiennent des coordonnées non valides
@@ -4362,6 +4366,9 @@ axis Both Les deux
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}` : le résultat de l’étape {i} n’a pas cette valeur (écrivez $$ pour un $ littéral)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}` : il n’y a pas d’étape {i} avant celle-ci (les étapes sont comptées à partir de 0 ; écrivez $$ pour un $ littéral)
@msg object ids are non-negative integers, not {v} les identifiants d’objet sont des entiers positifs ou nuls, pas {v}
@msg {key} must be an array of non-negative integer object ids {key} doit être un tableau d’identifiants d’objet, entiers positifs ou nuls
@msg invalid object id {value}: expected a non-negative integer identifiant d’objet {value} non valide (un entier positif ou nul est attendu)
@msg no such object id {value} aucun objet ne porte l’identifiant {value}
@msg automation filesystem access is not granted: read authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation de lecture : {path}
@msg automation filesystem access is not granted: write authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation d’écriture : {path}
@msg automation path rejected: outside the read root {root}: {path} chemin d’automatisation refusé : hors du dossier racine de lecture {root} : {path}
@@ -5204,3 +5211,5 @@ theme Appearance Apparence
Minimum must not exceed Desired, nor Desired Maximum. Le minimum ne doit pas dépasser la valeur optimale, ni la valeur optimale le maximum.
Justification Justification
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them les variables et jeux de données du fichier n’ont pas été lus (variables : {variables}, jeux de données : {data_sets}) : le panneau Variables ne les affiche pas
# Select by Color (select.color)
Select by Color Sélectionner par couleur
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -4323,6 +4323,8 @@ imageTrace Outline Körvonal
@msg `{key}` must be a #rrggbb color `{key}`: #rrggbb szín kell
@msg `{key}` must be a #rrggbb colour `{key}`: #rrggbb szín kell
@msg `{key}` must be a number `{key}`: szám kell
@msg `{key}` must be a number, not {v} `{key}`: szám kell, ez nem az: {v}
@msg {key} must be Unix seconds (an integer) or null {key}: Unix-idő kell másodpercben (egész szám) vagy null
@msg `{key}` must be a string `{key}`: szöveg kell
@msg `{key}` must be a whole number `{key}`: egész szám kell
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}`: {min} és {max} közötti egész szám kell (kapott érték: {x})
@@ -4569,6 +4571,7 @@ imageTrace Outline Körvonal
@msg missing `tool` hiányzik: `tool`
@msg missing index hiányzik az index
@msg missing number `{key}` hiányzik a szám: `{key}`
@msg missing integer `{key}` hiányzik az egész szám: `{key}`
@msg missing panel layout hiányzik a panelelrendezés
@msg move requires a destination panel az áthelyezéshez célpanel kell
@msg move requires an existing destination panel az áthelyezéshez létező célpanel kell
@@ -4698,6 +4701,9 @@ imageTrace Outline Körvonal
@msg nothing to warp nincs mit torzítani
@msg nothing to wrap nincs mit körbefuttatni
@msg object ids are non-negative integers, not {v} az objektumazonosító nemnegatív egész szám, ez nem az: {v}
@msg {key} must be an array of non-negative integer object ids {key}: objektumazonosítók tömbje kell, mindegyik nemnegatív egész szám
@msg invalid object id {value}: expected a non-negative integer érvénytelen objektumazonosító: {value} (nemnegatív egész szám kell)
@msg no such object id {value} nincs ilyen objektumazonosító: {value}
@msg object {0} cannot have children ennek az objektumnak nem lehetnek gyermekei: {0}
@msg object {id} is not a path az objektum nem útvonal: {id}
@msg object {id} is not a selected top-level object az objektum nem kijelölt legfelső szintű objektum: {id}
@@ -4713,6 +4719,7 @@ imageTrace Outline Körvonal
@msg Packaged {n} file(s){note} Becsomagolt fájlok: {n}{note}
@msg page size {w} × {h} oldalméret: {w} × {h}
@msg page {0} does not exist (the PDF has {1}) nincs ilyen oldal: {0} (a PDF oldalszáma: {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: nincs ilyen oldal: {n} (a fájl oldalszáma: 1)
@msg panel group is empty a panelcsoport üres
@msg panel must name a registered panel a panel egy regisztrált panelt kell megnevezzen
@msg paste unavailable a beillesztés nem érhető el
@@ -5191,3 +5198,5 @@ imageTrace Outline Körvonal
Minimum must not exceed Desired, nor Desired Maximum. A minimum nem haladhatja meg a kívánt értéket, sem a kívánt érték a maximumot.
Justification Sorkizárás
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them a fájl változói és adatkészletei nem lettek beolvasva (változók: {variables}, adatkészletek: {data_sets}): a Változók panel nem listázza őket
# Select by Color (select.color)
Select by Color Kijelölés szín szerint
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -3855,6 +3855,8 @@ imageTrace Outline Kontur
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` harus `auto` atau kode bahasa seperti `en` atau `zh-hant`
@msg `{key}` must be a #rrggbb colour `{key}` harus berupa warna #rrggbb
@msg `{key}` must be a number `{key}` harus berupa angka
@msg `{key}` must be a number, not {v} `{key}` harus berupa angka, bukan {v}
@msg {key} must be Unix seconds (an integer) or null {key} harus berupa waktu Unix dalam detik (bilangan bulat) atau null
@msg `{key}` must be a string `{key}` harus berupa string
@msg `{key}` must be a whole number `{key}` harus berupa bilangan bulat
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` harus berupa bilangan bulat antara {min} dan {max} (diperoleh {x})
@@ -3974,6 +3976,8 @@ imageTrace Outline Kontur
@msg missing `name` `name` tidak ada
@msg missing `tool` `tool` tidak ada
@msg missing index indeks tidak ada
@msg missing number `{key}` angka `{key}` tidak ada
@msg missing integer `{key}` bilangan bulat `{key}` tidak ada
@msg multiple windows land with M11.5 beberapa jendela hadir bersama M11.5
@msg name the file beri nama file
@msg no CSS to copy: select objects with CSS of their own tidak ada CSS untuk disalin: pilih objek yang memiliki CSS sendiri
@@ -4356,6 +4360,9 @@ imageTrace Outline Kontur
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: hasil langkah {i} tidak memiliki nilai seperti itu (tulis $$ untuk $ literal)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: tidak ada langkah {i} sebelum langkah ini (langkah dihitung mulai dari 0; tulis $$ untuk $ literal)
@msg object ids are non-negative integers, not {v} id objek adalah bilangan bulat non-negatif, bukan {v}
@msg {key} must be an array of non-negative integer object ids {key} harus berupa larik id objek yang berupa bilangan bulat non-negatif
@msg invalid object id {value}: expected a non-negative integer id objek {value} tidak valid: harus berupa bilangan bulat non-negatif
@msg no such object id {value} tidak ada objek dengan id {value}
@msg automation filesystem access is not granted: read authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas baca tidak ada: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas tulis tidak ada: {path}
@msg automation path rejected: outside the read root {root}: {path} jalur otomatisasi ditolak: di luar akar baca {root}: {path}
@@ -5188,3 +5195,5 @@ swatch Bright Violet Violet cerah
Full Justify Rata Penuh
Minimum must not exceed Desired, nor Desired Maximum. Minimum tidak boleh melebihi Diinginkan, dan Diinginkan tidak boleh melebihi Maksimum.
Justification Justifikasi
# Select by Color (select.color)
Select by Color Pilih berdasarkan warna
Can't render this file because it contains an unexpected character in line 3768 and column 19.
+9
View File
@@ -3841,6 +3841,8 @@ axis Both Entrambi
@msg `{key}` must be a #rrggbb color `{key}` deve essere un colore #rrggbb
@msg `{key}` must be a #rrggbb colour `{key}` deve essere un colore #rrggbb
@msg `{key}` must be a number `{key}` deve essere un numero
@msg `{key}` must be a number, not {v} `{key}` deve essere un numero, non {v}
@msg {key} must be Unix seconds (an integer) or null {key} deve essere un tempo Unix in secondi (un intero) o null
@msg `{key}` must be a string `{key}` deve essere una stringa
@msg `{key}` must be a whole number `{key}` deve essere un numero intero
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` deve essere un numero intero tra {min} e {max} (ricevuto {x})
@@ -3971,6 +3973,7 @@ axis Both Entrambi
@msg missing `tool` manca `tool`
@msg missing index manca index
@msg missing number `{key}` manca il numero `{key}`
@msg missing integer `{key}` manca il numero intero `{key}`
@msg multiple windows land with M11.5 le finestre multiple arriveranno con M11.5
@msg name the file assegna un nome al file
@msg no CSS to copy: select objects with CSS of their own nessun CSS da copiare: seleziona oggetti con un CSS proprio
@@ -4094,6 +4097,7 @@ axis Both Entrambi
@msg object {id} is not a selected top-level object l'oggetto {id} non è un oggetto selezionato di primo livello
@msg page size {w} × {h} dimensioni pagina {w} × {h}
@msg page {0} does not exist (the PDF has {1}) la pagina {0} non esiste (il PDF ne ha {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: la pagina {n} non esiste (il file ne ha 1)
@msg paste unavailable impossibile incollare
@msg path is already closed il tracciato è già chiuso
@msg paths contain invalid coordinates i tracciati contengono coordinate non valide
@@ -4390,6 +4394,9 @@ axis Both Entrambi
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: il risultato del passaggio {i} non ha questo valore (scrivi $$ per un $ letterale)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: non c’è un passaggio {i} prima di questo (i passaggi si contano da 0; scrivi $$ per un $ letterale)
@msg object ids are non-negative integers, not {v} gli ID oggetto sono interi non negativi, non {v}
@msg {key} must be an array of non-negative integer object ids {key} deve essere un array di ID oggetto interi non negativi
@msg invalid object id {value}: expected a non-negative integer ID oggetto {value} non valido: è richiesto un intero non negativo
@msg no such object id {value} nessun oggetto con ID {value}
@msg automation filesystem access is not granted: read authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di lettura: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di scrittura: {path}
@msg automation path rejected: outside the read root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di lettura {root}: {path}
@@ -4819,3 +4826,5 @@ lighting Light Luce
Minimum Minimo
Justification Giustificazione
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them le variabili e i set di dati del file non sono stati letti (variabili: {variables}, set di dati: {data_sets}): il pannello Variabili non li elenca
# Select by Color (select.color)
Select by Color Seleziona per colore
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -3927,6 +3927,8 @@ swatch Bright Violet ビビッドバイオレット
@msg `freeform.points` must be an array `freeform.points` には配列を指定してください
@msg `freeform.lines` must be an array `freeform.lines` には配列を指定してください
@msg `{key}` must be a number `{key}` には数値を指定してください
@msg `{key}` must be a number, not {v} `{key}` には数値を指定してください ({v} は使えません)
@msg {key} must be Unix seconds (an integer) or null {key} には Unix 時間の秒数 (整数) または null を指定してください
@msg a freeform gradient keeps at least one point フリーフォームグラデーションには少なくとも 1 つのポイントが必要です
@msg freeform point {i} needs `at` [x, y] and a valid `color` フリーフォームのポイント {i} には `at` [x, y] と有効な `color` が必要です
@msg freeform line {i} must be an array of point indices フリーフォームのライン {i} にはポイントのインデックスの配列を指定してください
@@ -4166,6 +4168,7 @@ swatch Bright Violet ビビッドバイオレット
@msg cannot read PDF: {0} PDF を読み込めません: {0}
@msg invalid PDF setting: {0} PDF の設定が正しくありません: {0}
@msg page {0} does not exist (the PDF has {1}) ページ {0} は存在しません (この PDF のページ数は {1} です)
@msg thumbnail: no page {n} (the file has 1) thumbnail: ページ {n} は存在しません (このファイルのページ数は 1 です)
@msg the PDF has no pages PDF にページがありません
@msg the PDF is password-protected: give its password この PDF はパスワードで保護されています: パスワードを入力してください
@msg the PDF password is wrong PDF のパスワードが正しくありません
@@ -4318,6 +4321,7 @@ swatch Bright Violet ビビッドバイオレット
@msg missing `tool` `tool` が指定されていません
@msg missing index index が指定されていません
@msg missing number `{key}` 数値 `{key}` が指定されていません
@msg missing integer `{key}` 整数 `{key}` が指定されていません
@msg multiple windows land with M11.5 複数ウィンドウは M11.5 で対応予定です
@msg no PNG encoder PNG エンコーダーがありません
@msg no document open ドキュメントが開かれていません
@@ -4428,6 +4432,9 @@ swatch Bright Violet ビビッドバイオレット
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ステップ {i} の結果にその値はありません ($ そのものを書くには $$ と入力してください)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: この前にステップ {i} はありません (ステップは 0 から数えます。$ そのものを書くには $$ と入力してください)
@msg object ids are non-negative integers, not {v} オブジェクト ID は 0 以上の整数です ({v} は使えません)
@msg {key} must be an array of non-negative integer object ids {key} にはオブジェクト ID (0 以上の整数) の配列を指定してください
@msg invalid object id {value}: expected a non-negative integer オブジェクト ID {value} は正しくありません: 0 以上の整数を指定してください
@msg no such object id {value} ID が {value} のオブジェクトは見つかりません
@msg automation filesystem access is not granted: read authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 読み込み権限がありません: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 書き込み権限がありません: {path}
@msg automation path rejected: outside the read root {root}: {path} 自動化のパスを拒否しました: 読み込みルート {root} の外です: {path}
@@ -4859,3 +4866,5 @@ lighting Light ライト
Minimum 最小
Justification 文字組み
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them ファイルの変数とデータセットは読み込まれませんでした (変数: {variables}、データセット: {data_sets})。変数パネルには表示されません
# Select by Color (select.color)
Select by Color カラーで選択
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12 -3
View File
@@ -3618,6 +3618,8 @@ effect Regular ჩვეულებრივი
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` უნდა იყოს `auto` ან ენის კოდი, მაგალითად `en` ან `zh-hant`
@msg `{key}` must be a #rrggbb colour `{key}` უნდა იყოს #rrggbb ფერი
@msg `{key}` must be a number `{key}` უნდა იყოს რიცხვი
@msg `{key}` must be a number, not {v} `{key}` უნდა იყოს რიცხვი და არა {v}
@msg {key} must be Unix seconds (an integer) or null {key} უნდა იყოს Unix-დრო წამებში (მთელი რიცხვი) ან null
@msg `{key}` must be a string `{key}` უნდა იყოს სტრიქონი
@msg `{key}` must be a whole number `{key}` უნდა იყოს მთელი რიცხვი
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` უნდა იყოს მთელი რიცხვი {min}-დან {max}-მდე (მიღებულია {x})
@@ -4108,6 +4110,9 @@ effect Regular ჩვეულებრივი
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ნაბიჯ {i}-ის შედეგს ასეთი მნიშვნელობა არ აქვს (ლიტერალური $-ისთვის დაწერეთ $$)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: ამის წინ ნაბიჯი {i} არ არის (ნაბიჯები 0-დან ითვლება; ლიტერალური $-ისთვის დაწერეთ $$)
@msg object ids are non-negative integers, not {v} ობიექტების id არაუარყოფითი მთელი რიცხვებია და არა {v}
@msg {key} must be an array of non-negative integer object ids {key} უნდა იყოს ობიექტების id-ების მასივი, სადაც ყოველი id არაუარყოფითი მთელი რიცხვია
@msg invalid object id {value}: expected a non-negative integer ობიექტის არასწორი id {value}: მოსალოდნელია არაუარყოფითი მთელი რიცხვი
@msg no such object id {value} ობიექტი id-ით {value} არ არსებობს
@msg automation filesystem access is not granted: read authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: წაკითხვის უფლება არ არის: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: ჩაწერის უფლება არ არის: {path}
@msg automation path rejected: outside the read root {root}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირის {root} გარეთაა: {path}
@@ -5072,6 +5077,7 @@ imageTrace Outline კონტური
@msg library panel `{given}` isn't open ბიბლიოთეკის პანელი `{given}` გახსნილი არ არის
@msg mask art ends outside a mask dictionary ნიღბის ნახატი ნიღბის ლექსიკონის გარეთ მთავრდება
@msg missing number `{key}` აკლია რიცხვი `{key}`
@msg missing integer `{key}` აკლია მთელი რიცხვი `{key}`
@msg no such spine point ხერხემლის ასეთი წერტილი არ არსებობს
@msg no such spine segment ხერხემლის ასეთი სეგმენტი არ არსებობს
@msg not a blend ეს გადასვლა არ არის
@@ -5097,8 +5103,8 @@ imageTrace Outline კონტური
Full Justify სრული ორმხრივი სწორება
Glyph Scaling: გლიფების მასშტაბირება:
Justification სწორება
Letter Spacing: ასოთა დაშორება:
Word Spacing: სიტყვათა დაშორება:
Letter Spacing: ასოებს შორის დაშორება:
Word Spacing: სიტყვებს შორის დაშორება:
Minimum must not exceed Desired, nor Desired Maximum. მინიმალური არ უნდა აღემატებოდეს სასურველს, ხოლო სასურველი — მაქსიმალურს.
Single Word Justification: ერთი სიტყვის სწორება:
Apply {name} სტილის „{name}“ გამოყენება
@@ -5108,7 +5114,10 @@ imageTrace Outline კონტური
@msg no history to clear გასასუფთავებელი ისტორია არ არის
@msg {_1} files are written by file.saveAsTemplate {_1} ფაილებს წერს file.saveAsTemplate
@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 წერს ძველი ფორმატის .ai ფაილებს; .ait შაბლონი ყოველთვის PDF-თან თავსებადია
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them ფაილის ცვლადები და მონაცემთა ნაკრებები ვერ წაიკითხა (ცვლადები: {variables}, მონაცემთა ნაკრებები: {data_sets}): პანელი „ცვლადები“ მათ არ აჩვენებს
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them ფაილის ცვლადებისა და მონაცემთა ნაკრებების წაკითხვა ვერ მოხერხდა (ცვლადები: {variables}, მონაცემთა ნაკრებები: {data_sets}): პანელი „ცვლადები“ მათ არ აჩვენებს
@msg its zlib compression is damaged მისი zlib შეკუმშვა დაზიანებულია
@msg its Zstandard compression is damaged მისი Zstandard შეკუმშვა დაზიანებულია
@msg it decompresses to more than {_1} MB გაშლისას {_1} MB-ს აღემატება
# Select by Color (select.color)
Select by Color მონიშვნა ფერის მიხედვით
@msg thumbnail: no page {n} (the file has 1) thumbnail: გვერდი {n} არ არსებობს (ფაილს 1 გვერდი აქვს)
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -3859,6 +3859,8 @@ theme Appearance 외관
@msg `freeform.points` must be an array `freeform.points`는 배열이어야 합니다
@msg `freeform.lines` must be an array `freeform.lines`는 배열이어야 합니다
@msg `{key}` must be a number `{key}`은(는) 숫자여야 합니다
@msg `{key}` must be a number, not {v} `{key}`은(는) 숫자여야 하며 {v}은(는) 안 됩니다
@msg {key} must be Unix seconds (an integer) or null {key}은(는) 초 단위 Unix 시간(정수) 또는 null이어야 합니다
@msg a freeform gradient keeps at least one point 자유형 그레이디언트에는 점이 하나 이상 있어야 합니다
@msg freeform point {i} needs `at` [x, y] and a valid `color` 자유형 점 {i}에는 `at` [x, y]과 올바른 `color`가 필요합니다
@msg freeform line {i} must be an array of point indices 자유형 선 {i}은(는) 점 인덱스 배열이어야 합니다
@@ -4098,6 +4100,7 @@ theme Appearance 외관
@msg cannot read PDF: {0} PDF를 읽을 수 없습니다: {0}
@msg invalid PDF setting: {0} 잘못된 PDF 설정: {0}
@msg page {0} does not exist (the PDF has {1}) 페이지 {0}이(가) 없습니다(PDF의 페이지 수: {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: 페이지 {n}이(가) 없습니다(파일의 페이지 수: 1)
@msg the PDF has no pages PDF에 페이지가 없습니다
@msg the PDF is password-protected: give its password PDF가 암호로 보호되어 있습니다: 암호를 입력하세요
@msg the PDF password is wrong PDF 암호가 올바르지 않습니다
@@ -4250,6 +4253,7 @@ theme Appearance 외관
@msg missing `tool` `tool`이 없습니다
@msg missing index index가 없습니다
@msg missing number `{key}` 숫자 `{key}`이(가) 없습니다
@msg missing integer `{key}` 정수 `{key}`이(가) 없습니다
@msg multiple windows land with M11.5 여러 창은 M11.5에서 지원됩니다
@msg no PNG encoder PNG 인코더가 없습니다
@msg no document open 열린 문서가 없습니다
@@ -4360,6 +4364,9 @@ theme Appearance 외관
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: 단계 {i}의 결과에 해당 값이 없습니다(문자 그대로의 $는 $$로 쓰세요)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: 이 단계 앞에 단계 {i}이(가) 없습니다(단계는 0부터 셉니다. 문자 그대로의 $는 $$로 쓰세요)
@msg object ids are non-negative integers, not {v} 개체 id는 음이 아닌 정수여야 하며 {v}은(는) 안 됩니다
@msg {key} must be an array of non-negative integer object ids {key}은(는) 음이 아닌 정수인 개체 id의 배열이어야 합니다
@msg invalid object id {value}: expected a non-negative integer 개체 id {value}이(가) 올바르지 않습니다: 음이 아닌 정수여야 합니다
@msg no such object id {value} id가 {value}인 개체가 없습니다
@msg automation filesystem access is not granted: read authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 읽기 권한이 없습니다: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 쓰기 권한이 없습니다: {path}
@msg automation path rejected: outside the read root {root}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {root} 밖입니다: {path}
@@ -4782,3 +4789,5 @@ lighting Light 광원
Minimum 최소
Justification 양끝 맞춤
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 파일의 변수와 데이터 세트를 읽지 않았습니다(변수: {variables}, 데이터 세트: {data_sets}). 변수 패널에 표시되지 않습니다
# Select by Color (select.color)
Select by Color 색상별 선택
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -2
View File
@@ -219,8 +219,8 @@ pub static LANGUAGES: [LangInfo; 24] = [
complete_menus: true,
catalog: OnceLock::new(),
},
// Traditional Chinese in the vocabulary used in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*`
// locales all resolve here (see `candidates`).
// Traditional Chinese in the vocabulary used in Taiwan: the whole interface and the status and
// error messages; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve here (see `candidates`).
LangInfo {
code: "zh-hant",
name: "繁體中文",
+8
View File
@@ -3869,6 +3869,8 @@ imageTrace Outline Contour
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` moet `auto` zijn of een taalcode zoals `en` of `zh-hant`
@msg `{key}` must be a #rrggbb colour `{key}` moet een #rrggbb-kleur zijn
@msg `{key}` must be a number `{key}` moet een getal zijn
@msg `{key}` must be a number, not {v} `{key}` moet een getal zijn, niet {v}
@msg {key} must be Unix seconds (an integer) or null {key} moet Unix-tijd in seconden (een geheel getal) of null zijn
@msg `{key}` must be a string `{key}` moet een string zijn
@msg `{key}` must be a whole number `{key}` moet een geheel getal zijn
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` moet een geheel getal tussen {min} en {max} zijn (kreeg {x})
@@ -4370,6 +4372,9 @@ imageTrace Outline Contour
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: het resultaat van stap {i} heeft geen dergelijke waarde (schrijf $$ voor een letterlijke $)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: er is geen stap {i} vóór deze stap (stappen tellen vanaf 0; schrijf $$ voor een letterlijke $)
@msg object ids are non-negative integers, not {v} object-id's zijn niet-negatieve gehele getallen, niet {v}
@msg {key} must be an array of non-negative integer object ids {key} moet een array van object-id's zijn die niet-negatieve gehele getallen zijn
@msg invalid object id {value}: expected a non-negative integer ongeldige object-id {value}: een niet-negatief geheel getal verwacht
@msg no such object id {value} er is geen object met id {value}
@msg automation filesystem access is not granted: read authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de leesbevoegdheid ontbreekt: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de schrijfbevoegdheid ontbreekt: {path}
@msg automation path rejected: outside the read root {root}: {path} automatiseringspad afgewezen: buiten de leesroot {root}: {path}
@@ -5198,6 +5203,7 @@ swatch Bright Violet Felviolet
@msg index must be ≥ 0 index moet ≥ 0 zijn
@msg mask art ends outside a mask dictionary maskerillustratie eindigt buiten een maskerwoordenboek
@msg missing number `{key}` getal `{key}` ontbreekt
@msg missing integer `{key}` geheel getal `{key}` ontbreekt
@msg no such spine point dat aspunt bestaat niet
@msg no such spine segment dat assegment bestaat niet
@msg not a blend geen vermenging
@@ -5231,3 +5237,5 @@ swatch Bright Violet Felviolet
Full Justify Volledig uitvullen
Minimum must not exceed Desired, nor Desired Maximum. Minimum mag niet groter zijn dan Gewenst, en Gewenst niet groter dan Maximum.
Justification Uitvullen
# Select by Color (select.color)
Select by Color Selecteren op kleur
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -3926,6 +3926,8 @@ imageTrace Outline Kontur
@msg `{key}` must be a #rrggbb color `{key}` musi być kolorem #rrggbb
@msg `{key}` must be a #rrggbb colour `{key}` musi być kolorem #rrggbb
@msg `{key}` must be a number `{key}` musi być liczbą
@msg `{key}` must be a number, not {v} `{key}` musi być liczbą, nie {v}
@msg {key} must be Unix seconds (an integer) or null {key} musi być czasem Unix w sekundach (liczbą całkowitą) lub null
@msg `{key}` must be a string `{key}` musi być ciągiem znaków
@msg `{key}` must be a whole number `{key}` musi być liczbą całkowitą
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` musi być liczbą całkowitą od {min} do {max} (podano {x})
@@ -4056,6 +4058,7 @@ imageTrace Outline Kontur
@msg missing `tool` brak `tool`
@msg missing index brak indeksu
@msg missing number `{key}` brak liczby `{key}`
@msg missing integer `{key}` brak liczby całkowitej `{key}`
@msg multiple windows land with M11.5 wiele okien pojawi się w M11.5
@msg name the file nadaj nazwę plikowi
@msg no CSS to copy: select objects with CSS of their own brak CSS do skopiowania: zaznacz obiekty z własnym CSS
@@ -4180,6 +4183,7 @@ imageTrace Outline Kontur
@msg object {id} is not a selected top-level object obiekt {id} nie jest zaznaczonym obiektem najwyższego poziomu
@msg page size {w} × {h} rozmiar strony {w} × {h}
@msg page {0} does not exist (the PDF has {1}) strona {0} nie istnieje (PDF ma {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: strona {n} nie istnieje (plik ma 1)
@msg paste unavailable wklejanie niedostępne
@msg path is already closed ścieżka jest już zamknięta
@msg paths contain invalid coordinates ścieżki zawierają nieprawidłowe współrzędne
@@ -4454,6 +4458,9 @@ imageTrace Outline Kontur
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: wynik kroku {i} nie ma takiej wartości (wpisz $$, aby uzyskać znak $)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: przed tym krokiem nie ma kroku {i} (kroki liczone od 0; wpisz $$, aby uzyskać znak $)
@msg object ids are non-negative integers, not {v} identyfikatory obiektów to nieujemne liczby całkowite, nie {v}
@msg {key} must be an array of non-negative integer object ids {key} musi być tablicą identyfikatorów obiektów, które są nieujemnymi liczbami całkowitymi
@msg invalid object id {value}: expected a non-negative integer nieprawidłowy identyfikator obiektu {value}: oczekiwano nieujemnej liczby całkowitej
@msg no such object id {value} brak obiektu o identyfikatorze {value}
@msg automation filesystem access is not granted: read authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do odczytu: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do zapisu: {path}
@msg automation path rejected: outside the read root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym odczytu {root}: {path}
@@ -5057,3 +5064,5 @@ mark-style Japanese Japońskie
Minimum must not exceed Desired, nor Desired Maximum. Minimum nie może przekraczać wartości optymalnej, a wartość optymalna maksimum.
Justification Justowanie
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them zmienne i zestawy danych pliku nie zostały wczytane (zmienne: {variables}, zestawy danych: {data_sets}): panel Zmienne ich nie wyświetla
# Select by Color (select.color)
Select by Color Zaznacz według koloru
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -3391,6 +3391,8 @@ axis Both Ambos
@msg `{key}` must be a #rrggbb color `{key}` deve ser uma cor #rrggbb
@msg `{key}` must be a #rrggbb colour `{key}` deve ser uma cor #rrggbb
@msg `{key}` must be a number `{key}` deve ser um número
@msg `{key}` must be a number, not {v} `{key}` deve ser um número, não {v}
@msg {key} must be Unix seconds (an integer) or null {key} deve ser um tempo Unix em segundos (um inteiro) ou null
@msg `{key}` must be a string `{key}` deve ser uma string
@msg `{key}` must be a whole number `{key}` deve ser um número inteiro
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` deve ser um número inteiro entre {min} e {max} (recebido {x})
@@ -3515,6 +3517,7 @@ axis Both Ambos
@msg missing `tool` falta `tool`
@msg missing index falta o índice
@msg missing number `{key}` falta o número `{key}`
@msg missing integer `{key}` falta o número inteiro `{key}`
@msg multiple windows land with M11.5 várias janelas chegam com o M11.5
@msg name the file dê um nome ao arquivo
@msg no CSS to copy: select objects with CSS of their own não há CSS para copiar: selecione objetos que tenham CSS próprio
@@ -3636,6 +3639,7 @@ axis Both Ambos
@msg object {id} is not a path o objeto {id} não é um traçado
@msg page size {w} × {h} tamanho da página {w} × {h}
@msg page {0} does not exist (the PDF has {1}) a página {0} não existe (o PDF tem {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: a página {n} não existe (o arquivo tem 1)
@msg paste unavailable colar indisponível
@msg path is already closed o traçado já está fechado
@msg paths contain invalid coordinates os traçados contêm coordenadas inválidas
@@ -3904,6 +3908,9 @@ axis Both Ambos
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: o resultado da etapa {i} não tem esse valor (escreva $$ para um $ literal)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: não há etapa {i} antes desta (as etapas contam a partir de 0; escreva $$ para um $ literal)
@msg object ids are non-negative integers, not {v} os ids de objeto são inteiros não negativos, não {v}
@msg {key} must be an array of non-negative integer object ids {key} deve ser uma matriz de ids de objeto que sejam inteiros não negativos
@msg invalid object id {value}: expected a non-negative integer id de objeto inválido {value}: era esperado um inteiro não negativo
@msg no such object id {value} não existe objeto com o id {value}
@msg automation filesystem access is not granted: read authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de leitura: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de gravação: {path}
@msg automation path rejected: outside the read root {root}: {path} caminho da automação rejeitado: fora da raiz de leitura {root}: {path}
@@ -5122,3 +5129,5 @@ mark-style Japanese Japonesas
Minimum must not exceed Desired, nor Desired Maximum. O mínimo não pode passar do desejado, nem o desejado do máximo.
Justification Justificação
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them as variáveis e os conjuntos de dados do arquivo não foram lidos (variáveis: {variables}, conjuntos de dados: {data_sets}): o painel Variáveis não os lista
# Select by Color (select.color)
Select by Color Selecionar por Cor
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -3837,6 +3837,8 @@ axis Both Оба
@msg `{key}` must be a #rrggbb color `{key}` должно быть цветом #rrggbb
@msg `{key}` must be a #rrggbb colour `{key}` должно быть цветом #rrggbb
@msg `{key}` must be a number `{key}` должно быть числом
@msg `{key}` must be a number, not {v} `{key}` должно быть числом, а не {v}
@msg {key} must be Unix seconds (an integer) or null {key} должно быть временем Unix в секундах (целым числом) или null
@msg `{key}` must be a string `{key}` должно быть строкой
@msg `{key}` must be a whole number `{key}` должно быть целым числом
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` должно быть целым числом от {min} до {max} (получено {x})
@@ -3967,6 +3969,7 @@ axis Both Оба
@msg missing `tool` отсутствует `tool`
@msg missing index отсутствует index
@msg missing number `{key}` отсутствует число `{key}`
@msg missing integer `{key}` отсутствует целое число `{key}`
@msg multiple windows land with M11.5 несколько окон появятся в M11.5
@msg name the file дайте файлу имя
@msg no CSS to copy: select objects with CSS of their own нет CSS для копирования: выделите объекты с собственным CSS
@@ -4090,6 +4093,7 @@ axis Both Оба
@msg object {id} is not a selected top-level object объект {id} не является выделенным объектом верхнего уровня
@msg page size {w} × {h} размер страницы {w} × {h}
@msg page {0} does not exist (the PDF has {1}) страница {0} не существует (в PDF {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: страница {n} не существует (в файле 1)
@msg paste unavailable вставка недоступна
@msg path is already closed контур уже замкнут
@msg paths contain invalid coordinates контуры содержат недопустимые координаты
@@ -4388,6 +4392,9 @@ axis Both Оба
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: в результате шага {i} нет такого значения (чтобы написать сам $, введите $$)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед этим шагом нет шага {i} (шаги считаются с 0; чтобы написать сам $, введите $$)
@msg object ids are non-negative integers, not {v} идентификаторы объектов — неотрицательные целые числа, а не {v}
@msg {key} must be an array of non-negative integer object ids {key} должно быть массивом идентификаторов объектов, которые являются неотрицательными целыми числами
@msg invalid object id {value}: expected a non-negative integer недопустимый идентификатор объекта {value}: ожидается неотрицательное целое число
@msg no such object id {value} объект с идентификатором {value} не существует
@msg automation filesystem access is not granted: read authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на чтение: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на запись: {path}
@msg automation path rejected: outside the read root {root}: {path} путь автоматизации отклонён: вне корневой папки для чтения {root}: {path}
@@ -4815,3 +4822,5 @@ lighting Light Свет
Minimum Минимум
Justification Выключка
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them переменные и наборы данных файла не прочитаны (переменных: {variables}, наборов данных: {data_sets}): панель «Переменные» их не показывает
# Select by Color (select.color)
Select by Color Выделить по цвету
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -3731,6 +3731,8 @@ axis Both Båda
@msg `{key}` must be a #rrggbb color `{key}` måste vara en färg i formatet #rrggbb
@msg `{key}` must be a #rrggbb colour `{key}` måste vara en färg i formatet #rrggbb
@msg `{key}` must be a number `{key}` måste vara ett tal
@msg `{key}` must be a number, not {v} `{key}` måste vara ett tal, inte {v}
@msg {key} must be Unix seconds (an integer) or null {key} måste vara Unix-tid i sekunder (ett heltal) eller null
@msg `{key}` must be a string `{key}` måste vara en sträng
@msg `{key}` must be a whole number `{key}` måste vara ett heltal
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` måste vara ett heltal mellan {min} och {max} (fick {x})
@@ -3857,6 +3859,7 @@ axis Both Båda
@msg missing `tool` `tool` saknas
@msg missing index index saknas
@msg missing number `{key}` talet `{key}` saknas
@msg missing integer `{key}` heltalet `{key}` saknas
@msg multiple windows land with M11.5 flera fönster kommer med M11.5
@msg name the file namnge filen
@msg no CSS to copy: select objects with CSS of their own ingen CSS att kopiera: markera objekt som har egen CSS
@@ -3980,6 +3983,7 @@ axis Both Båda
@msg object {id} is not a selected top-level object objekt {id} är inte ett markerat objekt på toppnivå
@msg page size {w} × {h} sidstorlek {w} × {h}
@msg page {0} does not exist (the PDF has {1}) sidan {0} finns inte (PDF-filen har {1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: sidan {n} finns inte (filen har 1)
@msg paste unavailable inklistring är inte tillgänglig
@msg path is already closed banan är redan sluten
@msg paths contain invalid coordinates banorna innehåller ogiltiga koordinater
@@ -4287,6 +4291,9 @@ axis Both Båda
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: resultatet från steg {i} har inget sådant värde (skriv $$ för ett bokstavligt $)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: det finns inget steg {i} före det här (stegen räknas från 0; skriv $$ för ett bokstavligt $)
@msg object ids are non-negative integers, not {v} objekt-id:n är icke-negativa heltal, inte {v}
@msg {key} must be an array of non-negative integer object ids {key} måste vara en array med objekt-id:n som är icke-negativa heltal
@msg invalid object id {value}: expected a non-negative integer ogiltigt objekt-id {value}: ett icke-negativt heltal förväntades
@msg no such object id {value} det finns inget objekt med id {value}
@msg automation filesystem access is not granted: read authority is absent: {path} automatiseringen har inte fått åtkomst till filsystemet: läsbehörighet saknas: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} automatiseringen har inte fått åtkomst till filsystemet: skrivbehörighet saknas: {path}
@msg automation path rejected: outside the read root {root}: {path} automatiseringssökvägen avvisades: utanför läsroten {root}: {path}
@@ -5161,3 +5168,5 @@ theme Appearance Utseende
Minimum must not exceed Desired, nor Desired Maximum. Minimum får inte överstiga Önskat, och Önskat inte Maximum.
Justification Justering
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them filens variabler och datauppsättningar lästes inte (variabler: {variables}, datauppsättningar: {data_sets}): panelen Variabler visar dem inte
# Select by Color (select.color)
Select by Color Markera efter färg
Can't render this file because it contains an unexpected character in line 5 and column 25.
+45 -1
View File
@@ -1823,9 +1823,53 @@ fn select_key_param_error_is_translated() {
}
}
/// The errors of the parameter checks that many commands share (a number given as text, a missing
/// number or index, a fraction for a whole number, `ids` that aren't a list of object ids, an
/// object id that is negative or names no object, a date that isn't Unix seconds) are translated
/// whole by their own rows in each language of [`COMPLETE_MESSAGES`], in Bulgarian, and in Danish,
/// Indonesian and Dutch. The value in the message, such as a quoted `"60 mm"`, is shown as given.
#[test]
fn shared_param_check_errors_are_translated() {
use serde_json::json;
use vectorcraft_engine::EngineError;
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("select.all", &json!({})).unwrap();
// A command, its params, the English template of its error and the template's values.
type Case = (&'static str, serde_json::Value, &'static str, &'static [(&'static str, &'static str)]);
let cases: [Case; 10] = [
("object.move", json!({"dx": "60 mm"}), "`{key}` must be a number, not {v}", &[("key", "dx"), ("v", r#""60 mm""#)]),
("object.rotate", json!({"angle": true}), "`{key}` must be a number, not {v}", &[("key", "angle"), ("v", "true")]),
("object.setBounds", json!({"width": [1, 2]}), "`{key}` must be a number, not {v}", &[("key", "width"), ("v", "[1,2]")]),
("shape.rectangle", json!({"x": 0, "y": 0, "width": 10}), "missing number `{key}`", &[("key", "height")]),
("appearance.setItem", json!({}), "missing integer `{key}`", &[("key", "index")]),
("object.transformEach", json!({"reference": 2.5}), "`{key}` must be a whole number", &[("key", "reference")]),
("edit.clear", json!({"ids": "all"}), "{key} must be an array of non-negative integer object ids", &[("key", "ids")]),
("edit.clear", json!({"ids": [-1]}), "invalid object id {value}: expected a non-negative integer", &[("value", "-1")]),
("edit.clear", json!({"ids": [99999]}), "no such object id {value}", &[("value", "99999")]),
("file.new", json!({"created": "yesterday"}), "{key} must be Unix seconds (an integer) or null", &[("key", "created")]),
];
let mut untranslated = Vec::new();
for (cmd, p, template, values) in cases {
let e = s.execute(cmd, &p).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { msg, .. } if *msg == fmt(template, values)), "{cmd} {p}: {e}");
for code in COMPLETE_MESSAGES.iter().chain(&["bg", "da", "id", "nl"]) {
let lang = Lang::from_code(code).unwrap();
let (entries, _) = parse_entries(lang.0.source);
let row = entries.iter().find(|(ctx, src, _)| ctx == "@msg" && src == template);
let shown = message(lang, &e.to_string());
if shown.contains("invalid parameters") || !row.is_some_and(|(_, _, tr)| shown.contains(&fmt(tr, values))) {
untranslated.push(format!("{code}: {shown}"));
}
}
}
assert!(untranslated.is_empty(), "untranslated parameter check errors:\n{}", untranslated.join("\n"));
}
/// Languages whose catalogs cover every status and error message.
const COMPLETE_MESSAGES: &[&str] =
&["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ka", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "zh-hans", "fi"];
&["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ka", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "zh-hans", "zh-hant", "fi"];
/// Crates whose error and status messages reach the status bar.
const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"];
+9
View File
@@ -3825,6 +3825,8 @@ axis Both Обидві
@msg `{key}` must be a #rrggbb color `{key}` має бути кольором #rrggbb
@msg `{key}` must be a #rrggbb colour `{key}` має бути кольором #rrggbb
@msg `{key}` must be a number `{key}` має бути числом
@msg `{key}` must be a number, not {v} `{key}` має бути числом, а не {v}
@msg {key} must be Unix seconds (an integer) or null {key} має бути часом Unix у секундах (цілим числом) або null
@msg `{key}` must be a string `{key}` має бути рядком
@msg `{key}` must be a whole number `{key}` має бути цілим числом
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` має бути цілим числом від {min} до {max} (отримано {x})
@@ -3955,6 +3957,7 @@ axis Both Обидві
@msg missing `tool` відсутня `tool`
@msg missing index відсутня index
@msg missing number `{key}` відсутнє число `{key}`
@msg missing integer `{key}` відсутнє ціле число `{key}`
@msg multiple windows land with M11.5 кілька вікон з’являться в M11.5
@msg name the file дайте файлу ім’я
@msg no CSS to copy: select objects with CSS of their own ні CSS для копіювання: виділіть об’єкти з власним CSS
@@ -4078,6 +4081,7 @@ axis Both Обидві
@msg object {id} is not a selected top-level object об’єкт {id} не є виділеним об’єктом верхнього рівня
@msg page size {w} × {h} розмір сторінки {w} × {h}
@msg page {0} does not exist (the PDF has {1}) сторінка {0} не існує (в PDF{1})
@msg thumbnail: no page {n} (the file has 1) thumbnail: сторінка {n} не існує (у файлі 1)
@msg paste unavailable вставка недоступна
@msg path is already closed контур вже замкнутий
@msg paths contain invalid coordinates контури містять неприпустимі координати
@@ -4374,6 +4378,9 @@ axis Both Обидві
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: у результаті кроку {i} немає такого значення (щоб написати сам $, введіть $$)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед цим кроком немає кроку {i} (кроки рахуються з 0; щоб написати сам $, введіть $$)
@msg object ids are non-negative integers, not {v} ідентифікатори об’єктів — невід’ємні цілі числа, а не {v}
@msg {key} must be an array of non-negative integer object ids {key} має бути масивом ідентифікаторів об’єктів, які є невід’ємними цілими числами
@msg invalid object id {value}: expected a non-negative integer неприпустимий ідентифікатор об’єкта {value}: очікується невід’ємне ціле число
@msg no such object id {value} об’єкт з ідентифікатором {value} не існує
@msg automation filesystem access is not granted: read authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на читання: {path}
@msg automation filesystem access is not granted: write authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на запис: {path}
@msg automation path rejected: outside the read root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для читання {root}: {path}
@@ -4799,3 +4806,5 @@ lighting Light Світло
Minimum Мінімум
Justification Вирівнювання за шириною
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them змінні та набори даних файлу не прочитано (змінних: {variables}, наборів даних: {data_sets}): панель «Змінні» їх не показує
# Select by Color (select.color)
Select by Color Виділити за кольором
Can't render this file because it contains an unexpected character in line 725 and column 40.
+9
View File
@@ -3971,6 +3971,8 @@ theme Appearance Giao diện
@msg `{key}` must be a #rrggbb color `{key}` phải là màu #rrggbb
@msg `{key}` must be a #rrggbb colour `{key}` phải là màu #rrggbb
@msg `{key}` must be a number `{key}` phải là một số
@msg `{key}` must be a number, not {v} `{key}` phải là một số, không phải {v}
@msg {key} must be Unix seconds (an integer) or null {key} phải là thời gian Unix tính bằng giây (số nguyên) hoặc null
@msg `{key}` must be a string `{key}` phải là một chuỗi
@msg `{key}` must be a whole number `{key}` phải là số nguyên
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` phải là số nguyên từ {min} đến {max} (nhận được {x})
@@ -4215,6 +4217,7 @@ theme Appearance Giao diện
@msg missing `tool` thiếu `tool`
@msg missing index thiếu chỉ mục
@msg missing number `{key}` thiếu số `{key}`
@msg missing integer `{key}` thiếu số nguyên `{key}`
@msg missing panel layout thiếu bố cục bảng
@msg move requires a destination panel move cần bảng đích
@msg move requires an existing destination panel move cần bảng đích tồn tại
@@ -4347,6 +4350,9 @@ theme Appearance Giao diện
@msg nothing to warp không có gì để biến dạng
@msg nothing to wrap không có gì để bọc
@msg object ids are non-negative integers, not {v} id đối tượng là số nguyên không âm, không phải {v}
@msg {key} must be an array of non-negative integer object ids {key} phải là một mảng id đối tượng là số nguyên không âm
@msg invalid object id {value}: expected a non-negative integer id đối tượng {value} không hợp lệ: cần một số nguyên không âm
@msg no such object id {value} không có đối tượng nào có id {value}
@msg object {0} cannot have children đối tượng {0} không thể có phần tử con
@msg object {id} is not a path đối tượng {id} không phải là đường dẫn
@msg object {id} is not a selected top-level object đối tượng {id} không phải là đối tượng cấp cao nhất đã chọn
@@ -4362,6 +4368,7 @@ theme Appearance Giao diện
@msg Packaged {n} file(s){note} Đã đóng gói {n} tệp{note}
@msg page size {w} × {h} kích thước trang {w} × {h}
@msg page {0} does not exist (the PDF has {1}) trang {0} không tồn tại (PDF có {1} trang)
@msg thumbnail: no page {n} (the file has 1) thumbnail: trang {n} không tồn tại (tệp có 1 trang)
@msg panel group is empty nhóm bảng trống
@msg panel must name a registered panel panel phải đặt tên bảng đã đăng ký
@msg paste unavailable dán không khả dụng
@@ -4799,3 +4806,5 @@ theme Appearance Giao diện
Minimum Tối thiểu
Justification Căn đều
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them các biến và tập dữ liệu của tệp chưa được đọc (biến: {variables}, tập dữ liệu: {data_sets}): bảng Biến không liệt kê chúng
# Select by Color (select.color)
Select by Color Chọn theo màu
Can't render this file because it contains an unexpected character in line 3948 and column 10.
+9
View File
@@ -1767,6 +1767,7 @@ file-filter Templates 模板
@msg a key object sits on that point 该点上有关键对象
@msg a spine keeps at least two points 脊线至少保留两个点
@msg missing number `{key}` 缺少数字 `{key}`
@msg missing integer `{key}` 缺少整数 `{key}`
@msg index must be ≥ 0 index 不能为负数
@msg x and y must be finite x 和 y 必须是有限值
@msg t, dx and dy must be finite t、dx 和 dy 必须是有限值
@@ -3678,6 +3679,8 @@ imageTrace Outline 轮廓
@msg `freeform.points` must be an array `freeform.points` 必须是数组
@msg `freeform.lines` must be an array `freeform.lines` 必须是数组
@msg `{key}` must be a number `{key}` 必须是数字
@msg `{key}` must be a number, not {v} `{key}` 必须是数字,而不是 {v}
@msg {key} must be Unix seconds (an integer) or null {key} 必须是以秒为单位的 Unix 时间 (整数) 或 null
@msg a freeform gradient keeps at least one point 任意形状渐变至少保留一个点
@msg freeform point {i} needs `at` [x, y] and a valid `color` 任意形状点 {i} 需要 `at` [x, y] 和有效的 `color`
@msg freeform line {i} must be an array of point indices 任意形状线 {i} 必须是由点索引组成的数组
@@ -3911,6 +3914,7 @@ imageTrace Outline 轮廓
@msg cannot read PDF: {0} 无法读取 PDF:{0}
@msg invalid PDF setting: {0} PDF 设置无效:{0}
@msg page {0} does not exist (the PDF has {1}) 页面 {0} 不存在 (该 PDF 有 {1} 页)
@msg thumbnail: no page {n} (the file has 1) thumbnail:页面 {n} 不存在 (该文件有 1 页)
@msg the PDF has no pages 该 PDF 没有页面
@msg the PDF is password-protected: give its password 该 PDF 受密码保护:请提供其密码
@msg the PDF password is wrong PDF 密码错误
@@ -4164,6 +4168,9 @@ imageTrace Outline 轮廓
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`:步骤 {i} 的结果中没有此值 (要表示 $ 本身,请写 $$)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`:此步骤之前没有步骤 {i} (步骤从 0 开始计数;要表示 $ 本身,请写 $$)
@msg object ids are non-negative integers, not {v} 对象 id 是非负整数,而不是 {v}
@msg {key} must be an array of non-negative integer object ids {key} 必须是由非负整数对象 id 组成的数组
@msg invalid object id {value}: expected a non-negative integer 无效的对象 id {value}:应为非负整数
@msg no such object id {value} 没有 id 为 {value} 的对象
@msg automation filesystem access is not granted: read authority is absent: {path} 未授予自动化文件系统访问权限:缺少读取权限:{path}
@msg automation filesystem access is not granted: write authority is absent: {path} 未授予自动化文件系统访问权限:缺少写入权限:{path}
@msg automation path rejected: outside the read root {root}: {path} 已拒绝自动化路径:位于读取根目录 {root} 之外:{path}
@@ -5041,3 +5048,5 @@ lighting Light 光源
Minimum 最小值
Justification 对齐设置
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 文件的变量和数据集未被读取(变量:{variables},数据集:{data_sets}):“变量”面板不会列出它们
# Select by Color (select.color)
Select by Color 按颜色选择
Can't render this file because it contains an unexpected character in line 2 and column 73.
+751
View File
@@ -1548,6 +1548,735 @@ file-filter Templates 範本
Keyboard Shortcut Conflicts 鍵盤快速鍵衝突
Keyboard Shortcut Set 鍵盤快速鍵組
Keyboard Shortcuts… 鍵盤快速鍵…
# Status and error messages (`@msg`), translated where the status bar shows them; the message itself
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
# Traditional Chinese: no final 。 unless the English ends with a period; a space between Chinese text and
# an adjacent Latin word, number or {placeholder}; 、 between the items of a list; 「」 around the name of
# a panel, menu item or setting.
@msg `{key}` must be an array of these names: {_1} `{key}` 必須是由下列名稱組成的陣列:{_1}
@msg Reset {_1} 重設 {_1}
@msg Saving 正在儲存
@msg Saving {_1} 正在儲存 {_1}
@msg Sent to {_1} 已傳送至 {_1}
@msg `to` must be "corner" or "smooth" `to` 必須是 "corner" 或 "smooth"
@msg a layer couldn't be read 有一個圖層無法讀取
@msg it has more than one artboard 它有多個工作區域
@msg its editing data has no layers 其編輯資料中沒有圖層
@msg its editing data is damaged 其編輯資料已損毀
@msg its zlib compression is damaged 其 zlib 壓縮已損毀
@msg its Zstandard compression is damaged 其 Zstandard 壓縮已損毀
@msg it decompresses to more than {_1} MB 解壓縮後超過 {_1} MB
@msg its type is to come in as outlines, which only its page has 其文字需以外框匯入,而只有其頁面含有外框
@msg {_1} files {_1} 檔案
@msg A browser with WebGPU or WebGL2 is required. 需要支援 WebGPU 或 WebGL2 的瀏覽器。
@msg Loading VectorCraft… 正在載入 VectorCraft…
@msg Reload the page to go on (changes not saved yet may be lost). 請重新載入頁面以繼續 (尚未儲存的變更可能會遺失)。
@msg The graphics were lost and couldn't be restarted. 圖形已遺失,且無法重新啟動。
@msg The graphics were lost and have been restarted 圖形已遺失,現已重新啟動
@msg VectorCraft failed to start: {e} VectorCraft 無法啟動:{e}
@msg Your unsaved changes are kept in this browser: reload the page to get them back. 您未儲存的變更保留在此瀏覽器中:重新載入頁面即可取回。
@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} 最先嘗試的圖形處理器無法顯示視窗,因此 VectorCraft 已改在 {summary} 上重新啟動
@msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次啟動時在 {name} 上於視窗出現前結束,因此這次在 {summary} 上啟動。若要再次嘗試該圖形處理器,請變更「偏好設定 › 效能 › 圖形處理器」,或設定 WGPU_BACKEND 後再啟動
@msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft 上次啟動時在列出圖形處理器期間結束,因此這次只以 OpenGL 在 {summary} 上啟動。若要再次嘗試其他圖形後端,請變更「偏好設定 › 效能 › 圖形處理器」,或設定 WGPU_BACKEND 後再啟動
@msg not a DXF drawing 不是 DXF 圖面
@msg scale must be a number from {_1} to {_2}, not {s} 比例必須是 {_1} 到 {_2} 之間的數字,而非 {s}
@msg the artboard to fit must be a positive size up to {_1} pt 要符合的工作區域尺寸必須為正數,且不超過 {_1} pt
@msg the drawing has no layout `{_1}` (it has {_2}) 圖面中沒有配置 `{_1}` (它有 {_2})
@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that 這是 DWG 圖面,無法讀取:請在 CAD 應用程式中將其儲存為 ASCII DXF,再開啟該檔案
@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard 以此比例,圖面為 {w} × {h} pt,超過畫布所能容納:請選擇較小的比例,或使其符合工作區域
@msg the scale is not a usable number 比例不是可用的數字
@msg the drawing is too large to open 圖面過大,無法開啟
@msg scale must be a number from {_1} to {_2}, not {_3} 比例必須是 {_1} 到 {_2} 之間的數字,而非 {_3}
@msg image '{key}' has no pixels and was left out 影像 '{key}' 沒有像素,已略過
@msg Embedded {n} image(s) 已內嵌 {n} 個影像
@msg object {0} cannot have children 物件 {0} 不能有子物件
@msg range `{s}` is empty 範圍 `{s}` 是空的
@msg range `{s}`: `{part}` names no number 範圍 `{s}`:`{part}` 沒有指定數字
@msg range `{s}`: `{part}` runs backwards 範圍 `{s}`:`{part}` 的順序顛倒
@msg range `{s}`: `{t}` is not a number from 1 to {count} 範圍 `{s}`:`{t}` 不是 1 到 {count} 之間的數字
@msg not a graphic style library (format `{_1}`) 不是繪圖樣式資料庫 (格式為 `{_1}`)
@msg Show All Hidden Attributes: nothing is hidden 顯示所有隱藏屬性:沒有隱藏的項目
@msg Expand: no brush strokes selected 展開:未選取筆刷筆畫
@msg no brush named `{name}` 沒有名為 `{name}` 的筆刷
@msg no brush selected 未選取筆刷
@msg no symbol instances selected 未選取符號實體
@msg select a symbol instance 請選取符號實體
@msg the art has no bounds 圖稿沒有邊界
@msg Image Trace: the traced image is missing 影像描圖:找不到描圖的影像
@msg Live Paint Merge: select a Live Paint group and the paths to add 即時上色合併:請選取即時上色群組和要加入的路徑
@msg Live Paint: select paths or compound paths 即時上色:請選取路徑或複合路徑
@msg Live Paint: these paths are too degenerate to paint 即時上色:這些路徑過度退化,無法上色
@msg Shape Builder: no region under the pointer 形狀建立:指標下方沒有區域
@msg Shape Builder: select paths or compound paths 形狀建立:請選取路徑或複合路徑
@msg image data `{_1}` is missing 找不到影像資料 `{_1}`
@msg no Live Paint edge near that point 該點附近沒有即時上色邊緣
@msg no Live Paint face at that point 該點上沒有即時上色表面
@msg select a Live Paint group 請選取即時上色群組
@msg select an Image Trace object 請選取影像描圖物件
@msg select objects, not layers 請選取物件,而非圖層
@msg Blend colors: select at least three objects with filled colours 漸變顏色:請選取至少三個有填色的物件
@msg no saved themes 沒有已儲存的主題
@msg `{keep}` is not a side of this axis `{keep}` 不是此軸的一側
@msg unknown side `{other}` (left|right|top|bottom) 未知的邊 `{other}` (left|right|top|bottom)
@msg an object in perspective would leave the edited grid 透視中的物件會離開所編輯的格點
@msg nothing to warp 沒有可彎曲的內容
@msg select an object in perspective 請選取透視中的物件
@msg select the artwork to warp 請選取要彎曲的圖稿
@msg select the objects to transform in perspective 請選取要在透視中變形的物件
@msg select type in perspective 請選取透視中的文字
@msg the object has no geometry 物件沒有幾何形狀
@msg the object has no stroke 物件沒有筆畫
@msg the object is beyond the plane's horizon 物件位於平面的地平線之外
@msg the object isn't in perspective 物件不在透視中
@msg the object isn't on a perspective plane 物件不在透視平面上
@msg the object would cross the horizon of the perspective plane 物件會越過透視平面的地平線
@msg the objects would leave the grid 物件會離開格點
@msg the pointer is beyond the plane's horizon 指標位於平面的地平線之外
@msg the selection isn't in perspective 選取範圍不在透視中
@msg the stroke has no weight 筆畫沒有寬度
@msg {command} didn't create an object {command} 未建立物件
@msg Make Guides: select paths 製作參考線:請選取路徑
@msg Release Guides: no guides 釋放參考線:沒有參考線
@msg guides are locked 參考線已鎖定
@msg no saved selection recalled 未叫出任何已儲存的選取範圍
@msg no saved selections 沒有已儲存的選取範圍
@msg no such artboard 沒有這個工作區域
@msg cannot split an open path at its end point 無法在開放路徑的端點處分割路徑
@msg closing needs three anchors 封閉需要三個錨點
@msg empty brush stroke 筆刷筆畫是空的
@msg no such anchor 沒有這個錨點
@msg no such segment 沒有這個線段
@msg no such subpath 沒有這個子路徑
@msg object {id} is not a path 物件 {id} 不是路徑
@msg object {id} is not a selected top-level object 物件 {id} 不是已選取的最上層物件
@msg nothing to redo 沒有可重做的動作
@msg nothing to undo 沒有可復原的動作
@msg no history to clear 沒有可清除的步驟記錄
@msg Expand Appearance: the selection has a basic appearance 展開外觀:選取範圍只有基本外觀
@msg select (or target) objects whose appearance isn't basic 請選取 (或指定目標) 外觀不是基本外觀的物件
@msg {C}: no effect at index {from} {C}:索引 {from} 處沒有效果
@msg Expand: no gradient fills 展開:沒有漸層填色
@msg Expand: nothing to expand 展開:沒有可展開的內容
@msg batch step {_1} (`{id}`) failed: {e} 批次步驟 {_1} (`{id}`) 失敗:{e}
@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP 檔案為 RGB 或灰階:colorModel rgb 或 gray
@msg Targa files are RGB: colorModel rgb Targa 檔案為 RGB:colorModel rgb
@msg antiAlias `{id}`: none, art or type antiAlias `{id}`:none、art 或 type
@msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}:transparent、white、black 或顏色,例如 "#ff8800"
@msg no artboard {i} (0-based; the document has {count}) 沒有工作區域 {i} (從 0 起算;文件有 {count} 個)
@msg no artboards named 未指定工作區域
@msg the document has no artboard 文件沒有工作區域
@msg this format holds one artboard: export several as PDF or with document.exportForScreens 此格式只能容納一個工作區域:若要匯出多個,請匯出為 PDF 或使用 document.exportForScreens
@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) 此檔案儲存時未啟用 PDF 相容性,且無法讀取其編輯資料 ({why})
@msg the export wrote no file 匯出未寫入任何檔案
@msg {_1} files can be opened but not written (see document.formats) {_1} 檔案可以開啟,但無法寫入 (請參閱 document.formats)
@msg {_1} files are written by file.saveAsTemplate {_1} 檔案由 file.saveAsTemplate 寫入
@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}:此處沒有檔案系統 (若要開啟,請傳入 dataBase64;不提供路徑即可取得 dataBase64)
@msg Save writes {_1}, not {_2}: use document.export for other formats 「儲存」會寫入 {_1},而非 {_2}:其他格式請使用 document.export
@msg no changes since the last save 自上次儲存後沒有變更
@msg unknown format `{f}` (Save writes {_1}) 未知的格式 `{f}` (「儲存」會寫入 {_1})
@msg `svg` must be an object of SVG options (see document.formats) `svg` 必須是由 SVG 選項組成的物件 (請參閱 document.formats)
@msg no end record 沒有結束記錄
@msg not a stored central record 不是未壓縮 (stored) 的中央目錄記錄
@msg truncated 已截斷
@msg {name}: CRC mismatch {name}:CRC 不符
@msg {name}: no local header {name}:沒有區域檔頭
@msg Flatten Transparency: select objects to flatten 透明度平面化:請選取要平面化的物件
@msg Flatten Transparency: {e} 透明度平面化:{e}
@msg balance must be between 0 and 100 平衡必須介於 0 到 100 之間
@msg resolutions must be between 1 and 2400 ppi 解析度必須介於 1 到 2400 ppi 之間
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) 未知的預設集 `{name}` (high、medium、low 或已儲存的預設集:請參閱 flattener.presets.list)
@msg `at` must be [x, y] `at` 必須是 [x, y]
@msg `color` must be a colour `color` 必須是顏色
@msg `freeform.points` must be an array `freeform.points` 必須是陣列
@msg `freeform.lines` must be an array `freeform.lines` 必須是陣列
@msg `{key}` must be a number `{key}` 必須是數字
@msg a freeform gradient keeps at least one point 任意形狀漸層至少要保留一個點
@msg freeform point {i} needs `at` [x, y] and a valid `color` 任意形狀點 {i} 需要 `at` [x, y] 和有效的 `color`
@msg freeform line {i} must be an array of point indices 任意形狀線條 {i} 必須是由點索引組成的陣列
@msg no point {i} (the gradient has {_1}) 沒有點 {i} (此漸層有 {_1} 個)
@msg no segment {segment} on line {line} 線條 {line} 上沒有線段 {segment}
@msg unknown freeform mode `{m}` (points, lines) 未知的任意形狀模式 `{m}` (points、lines)
@msg `aspect` must be a number (%) `aspect` 必須是數字 (%)
@msg `gradient` must be an object `gradient` 必須是物件
@msg `mode` applies to freeform gradients `mode` 只適用於任意形狀漸層
@msg `stops` must be an array `stops` 必須是陣列
@msg `{k}` must be [x, y] `{k}` 必須是 [x, y]
@msg a gradient needs at least two stops 漸層至少需要兩個色標
@msg give both `start` and `end` [x, y] (or neither) 請同時提供 `start` 和 `end` [x, y] (或兩者都不提供)
@msg stop {i} needs `offset` 色標 {i} 需要 `offset`
@msg stop {i} needs a valid `color` 色標 {i} 需要有效的 `color`
@msg stop {i}: `{k}` must be a number 色標 {i}:`{k}` 必須是數字
@msg unknown gradient kind `{k}` (linear, radial, freeform) 未知的漸層類型 `{k}` (linear、radial、freeform)
@msg the art could not be sampled 無法對圖稿取樣
@msg the art is too light for any dots 圖稿太淡,無法產生任何網點
@msg the document has no symbols 文件中沒有符號
@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` 不是顏色 (#rrggbb 或預設集名稱)
@msg a colour index is 0 to {_1}, not {n} 顏色索引應為 0 到 {_1},而非 {n}
@msg a colour is an index, #rrggbb or a preset name, not {v} 顏色應為索引、#rrggbb 或預設集名稱,而非 {v}
@msg a document needs at least one layer 文件至少需要一個圖層
@msg no artwork 沒有圖稿
@msg no selection bounds 選取範圍沒有邊界
@msg the layer has no objects to clip by 圖層中沒有可用來剪裁的物件
@msg the layer or group is locked 圖層或群組已鎖定
@msg the guide is on a hidden or locked layer 參考線位於隱藏或鎖定的圖層上
@msg the target layer is locked or hidden 目標圖層已鎖定或隱藏
@msg the layer to flatten into is hidden 要平面化到的圖層已隱藏
@msg there are no objects to release 沒有可釋放的物件
@msg no library `{id}` 沒有資料庫 `{id}`
@msg {path}: not a file {path}:不是檔案
@msg {path}: {e} {path}:{e}
@msg can't write .{ext} images 無法寫入 .{ext} 影像
@msg Replace Spine: select a blend and a path 取代脊線:請選取漸變物件和路徑
@msg nothing to wrap 沒有可繞圖的內容
@msg select a blend 請選取漸變物件
@msg select a gradient mesh 請選取漸層網格
@msg the object has no opacity mask 物件沒有不透明度遮色片
@msg Convert to Artboards: selection has no area 轉換為工作區域:選取範圍沒有面積
@msg Convert to Shape: no rectangles or ellipses selected 轉換為形狀:未選取矩形或橢圓形
@msg image would be too large; lower the resolution 影像會過大;請降低解析度
@msg clipboard is empty 剪貼簿是空的
@msg no anchor points selected 未選取錨點
@msg select at least two objects 請選取至少兩個物件
@msg no saved presets 沒有已儲存的預設集
@msg compound paths can only contain paths 複合路徑只能包含路徑
@msg no bounds 沒有邊界
@msg no current layer 沒有目前圖層
@msg no key object 沒有關鍵物件
@msg no previous transform 沒有上一次的變形
@msg nothing to align 沒有可對齊的內容
@msg select a clip group 請選取剪裁群組
@msg select two or more objects to distribute 請選取兩個以上要均分的物件
@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis 必須是 "vertical"、"horizontal" 或以度為單位的角度,而非 `{v}`
@msg give horizontal or vertical 請提供 horizontal 或 vertical
@msg selection has no bounds 選取範圍沒有邊界
@msg the top object must be a path, compound path or text object to use as a clipping mask 最上層物件必須是路徑、複合路徑或文字物件,才能用作剪裁遮色片
@msg no target object has an opacity mask: select one or give `id` 沒有任何目標物件有不透明度遮色片:請選取一個,或提供 `id`
@msg the object already has an opacity mask 物件已有不透明度遮色片
@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` 是印刷色:色調只適用於全域色和特別色
@msg `{name}` isn't a colour swatch `{name}` 不是顏色色票
@msg a document needs at least one artboard 文件至少需要一個工作區域
@msg empty path 路徑是空的
@msg join needs open paths 連接需要開放路徑
@msg path is already closed 路徑已封閉
@msg select paths to join 請選取要連接的路徑
@msg direct-select anchor points to cut at (not the end points of open paths) 請直接選取要剪開的錨點 (不能是開放路徑的端點)
@msg select paths or anchor points 請選取路徑或錨點
@msg Divide Objects Below: select a path 分割下方物件:請選取路徑
@msg Divide Objects Below: select exactly one path 分割下方物件:請只選取一條路徑
@msg Offset Path: select paths 位移複製:請選取路徑
@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) 位移複製:位移後所選路徑不留任何部分 (內縮量大於形狀)
@msg Outline Stroke: no stroked paths selected 外框筆畫:未選取有筆畫的路徑
@msg Split Into Grid: select paths large enough for the grid 分割為格線:請選取大到足以容納格線的路徑
@msg empty cutter 切割物件是空的
@msg paths contain invalid coordinates 路徑包含無效的座標
@msg {label} produced an empty result {label} 產生了空的結果
@msg {label}: lost track of a shape's style {label}:遺失了某個形狀的樣式
@msg {label}: select paths {label}:請選取路徑
@msg {label}: select paths, compound paths, groups or text {label}:請選取路徑、複合路徑、群組或文字
@msg already editing a pattern 已在編輯圖樣
@msg no pattern `{name}` 沒有圖樣 `{name}`
@msg not in pattern editing mode 不在圖樣編輯模式中
@msg pattern missing 找不到圖樣
@msg select a repeat 請選取重複物件
@msg select an Intertwine group 請選取交織群組
@msg select the art to repeat 請選取要重複的圖稿
@msg the document has no patterns 文件中沒有圖樣
@msg no artboard 沒有工作區域
@msg installing from a path is not available on the web; pass the module as `dataBase64` 網頁版無法從路徑安裝;請以 `dataBase64` 傳入模組
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` 必須是 `auto` 或語言代碼,例如 `en` 或 `zh-hant`
@msg `{key}` must be a #rrggbb colour `{key}` 必須是 #rrggbb 顏色
@msg `{key}` must be a string `{key}` 必須是字串
@msg `{key}` must be a whole number `{key}` 必須是整數
@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` 必須是介於 {min} 和 {max} 之間的整數 (收到 {x})
@msg `{key}` must be between {min} and {max} (got {x}) `{key}` 必須介於 {min} 和 {max} 之間 (收到 {x})
@msg `{key}` must be one of {_1} `{key}` 必須是 {_1} 其中之一
@msg unknown preference `{key}` 未知的偏好設定 `{key}`
@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) 「資料復原」沒有可存放拷貝的位置:請設定「偏好設定 → 檔案處理 → 資料復原 → 資料夾」 (recoveryFolder)
@msg `{area}` is no recovery area name `{area}` 不是復原區域名稱
@msg `{name}` is no recovery entry name `{name}` 不是復原項目名稱
@msg no clock to keep a heartbeat by 沒有可用來維持心跳的時鐘
@msg no free recovery area 沒有可用的復原區域
@msg no native format 沒有原生格式
@msg nothing encoded 沒有編碼任何內容
@msg recovery copy `{file}`: {e} 復原拷貝 `{file}`:{e}
@msg {name}: no such recovery entry {name}:沒有這個復原項目
@msg Combine Slices: the slices have no bounds 合併切片:切片沒有邊界
@msg Create from Guides: the document has no artboard 從參考線建立:文件沒有工作區域
@msg Create from Guides: the guides make more than {MAX_PIECES} slices 從參考線建立:參考線會產生超過 {MAX_PIECES} 個切片
@msg Create from Selection: the selection has no area 從選取範圍建立:選取範圍沒有面積
@msg Release: no layer to put the rectangle in 釋放:沒有可放置矩形的圖層
@msg a slice size must be a positive number of points 切片尺寸必須是正數 (以點為單位)
@msg divide into 1 to {MAX_PIECES} slices 請分割為 1 到 {MAX_PIECES} 個切片
@msg no slice selected 未選取切片
@msg the document has no slices 文件中沒有切片
@msg no saved profiles 沒有已儲存的描述檔
@msg select a stroke with a variable width 請選取寬度可變的筆畫
@msg the selected stroke's profile is already listed as {_1} 所選筆畫的描述檔已列為 {_1}
@msg a graphic style named `{to}` already exists 名為 `{to}` 的繪圖樣式已存在
@msg no graphic style `{name}` 沒有繪圖樣式 `{name}`
@msg node {_1} is not text 節點 {_1} 不是文字
@msg Text Wrap: select a wrap object 文字繞圖:請選取繞圖物件
@msg layers can't be wrap objects 圖層不能作為繞圖物件
@msg no threaded text selected 未選取串連文字
@msg Create Outlines: select text objects 建立外框:請選取文字物件
@msg paste unavailable 無法貼上
@msg Save for Web: the image is empty 儲存為網頁用:影像是空的
@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}:`{h}` 不是像 "#ff8800" 這樣的顏色
@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}:最多 {MAX_TABLE} 種顏色
@msg no artboard {i} (0-based; the document has {_1}) 沒有工作區域 {i} (從 0 起算;文件有 {_1} 個)
@msg no selected slice in the image: select slices, or save all of them 影像中沒有已選取的切片:請選取切片,或全部儲存
@msg nothing to save: the document has no visible art 沒有可儲存的內容:文件中沒有可見的圖稿
@msg percent must be above 0 and at most 6400, not {p} 百分比必須大於 0 且不超過 6400,而非 {p}
@msg unknown preset `{name}` (see webExport.presets.list) 未知的預設集 `{name}` (請參閱 webExport.presets.list)
@msg width and height are at least 1 pixel 寬度和高度至少為 1 像素
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` 必須是 1、3 或 5
@msg `{path}` must be a bool or an object of bools `{path}` 必須是布林值或由布林值組成的物件
@msg cannot distort a zero-size bounding box 無法扭曲尺寸為零的邊框
@msg nothing to distort 沒有可扭曲的內容
@msg unknown option `{path}.{k}` 未知的選項 `{path}.{k}`
@msg command `{0}` is not available right now: {1} 指令 `{0}` 目前無法使用:{1}
@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}` 發生內部錯誤:{msg} (文件維持原狀;請回報此錯誤)
@msg invalid parameters for `{cmd}`: {msg} `{cmd}` 的參數無效:{msg}
@msg no active document 沒有作用中的文件
@msg no interaction in progress 沒有進行中的互動
@msg no such object {0} 沒有物件 {0}
@msg result would exceed the canvas (coordinates out of range) 結果會超出畫布 (座標超出範圍)
@msg objects can't be nested more than 32 deep 物件的巢狀層級不能超過 32 層
@msg the SVG's elements are nested more than {0} deep SVG 的元素巢狀超過 {0} 層
@msg unknown command `{0}` 未知的指令 `{0}`
@msg `{what}` closes nothing `{what}` 沒有可結束的內容
@msg `{what}` closes something it didn't open `{what}` 結束了並非由它開始的內容
@msg a path has too many points 有一條路徑的點過多
@msg it ends inside a layer 它在圖層內部結束
@msg it has art outside its layers 它有位於圖層之外的圖稿
@msg it has more than {MAX_NODES} objects 它有超過 {MAX_NODES} 個物件
@msg it has no layers 它沒有圖層
@msg it has too many values in a row 它連續的值過多
@msg it has {_1}, which VectorCraft doesn't read from it yet 它含有 {_1},VectorCraft 目前尚無法從中讀取
@msg its objects are nested too deeply 其物件巢狀層級過深
@msg its section ending with `%{end}` doesn't end 其以 `%{end}` 結尾的區段沒有結束
@msg an image VectorCraft can't read was left out 已略過 VectorCraft 無法讀取的影像
@msg an image whose data is cut short was left out 已略過資料不完整的影像
@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 檔案中未定義漸層「{name}」:已改用單色替代
@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 具有多個填色或筆畫、效果或筆刷的物件已依其繪製外觀匯入 ({_1}):其外觀目前尚無法編輯
@msg patterns nested too deeply are filled with mid-grey 巢狀層級過深的圖樣以中灰色填滿
@msg shadings of an unknown type were left out 已略過類型未知的著色
@msg shadings whose functions are of an unknown type are filled with their middle colour 函數類型未知的著色以其中間顏色填滿
@msg the file's preview header points outside the file 檔案的預覽檔頭指向檔案之外
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it 無法讀取此 PostScript 檔案 ({why}):請在建立它的應用程式中將其儲存為 PDF 或 SVG
@msg this is not a PostScript file 這不是 PostScript 檔案
@msg a PostScript error ({name} in `{op}`) PostScript 錯誤 ({name},位於 `{op}`)
@msg a PostScript error ({name} in `{op}`), in {procs} PostScript 錯誤 ({name},位於 `{op}`),在 {procs} 中
@msg a PostScript error ({name} in `{op}`: {at}) PostScript 錯誤 ({name},位於 `{op}`:{at})
@msg a PostScript error ({name} in `{op}`: {at}), in {procs} PostScript 錯誤 ({name},位於 `{op}`:{at}),在 {procs} 中
@msg a PostScript error ({name}) PostScript 錯誤 ({name})
@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know 它使用了 `{at}`,VectorCraft 的 PostScript 讀取器無法辨識
@msg the file was read up to an error ({why}): what follows it was left out 檔案已讀取到發生錯誤處 ({why}):之後的內容已略過
@msg this file's PostScript couldn't be read ({why}): {what} 無法讀取此檔案的 PostScript ({why}):{what}
@msg it has no page 它沒有頁面
@msg it has too many text objects 它的文字物件過多
@msg it has too much text 它的文字過多
@msg its editing data doesn't say where its art is 其編輯資料未指出其圖稿的位置
@msg its editing data doesn't say where its artboard is 其編輯資料未指出其工作區域的位置
@msg its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page 其編輯資料屬於 {w} × {h} pt 的頁面,而不是檔案本身 {pw} × {ph} pt 的頁面
@msg its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale} 其編輯資料屬於 {w} × {h} pt 的頁面,即比例為 {scale} 的檔案 {pw} × {ph} pt 頁面
@msg its page has text, and its layers have no text objects to put it in 其頁面中有文字,但其圖層中沒有可放置這些文字的文字物件
@msg the art on them differs from the page's in {_1}% of it 其上的圖稿與頁面的圖稿有 {_1}% 不同
@msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same 其上的圖稿與頁面的圖稿有 {_1}% 不同:仍從其編輯資料匯入
@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer 未讀取檔案的圖層 ({_1}):檔案以其頁面匯入,位於單一圖層中
@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part 未從檔案的編輯資料讀取圖層 ({_1}):圖層來自其 PDF 部分
@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 未讀取檔案的變數與資料集 (變數:{variables},資料集:{data_sets}):「變數」面板不會列出它們
@msg its Windows metafile preview was opened instead, which may simplify the art 已改為開啟其 Windows 中繼檔預覽,圖稿可能因此簡化
@msg its preview was placed instead, at screen resolution 已改為以螢幕解析度置入其預覽
@msg shadings of more than 4096 patches or triangles were left out 已略過超過 4096 個面片或三角形的著色
@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} 它在 {procs} 中使用了 `{at}`,VectorCraft 的 PostScript 讀取器無法辨識
@msg the export region is not finite 匯出區域不是有限值
@msg a page out of range 頁面超出範圍
@msg a page's placement is not finite 頁面的位置不是有限值
@msg nothing to print 沒有可列印的內容
@msg page size {w} × {h} 頁面尺寸 {w} × {h}
@msg the preview image is empty 預覽影像是空的
@msg the thumbnail image is malformed 縮圖影像格式錯誤
@msg can't encode the document: {0} 無法編碼文件:{0}
@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} 無法儲存版本 {0}:VectorCraft 寫入的版本為 {MIN_VERSION} 到 {VERSION}
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) 檔案版本 {0} 比此 VectorCraft 支援的版本 ({VERSION}) 更新
@msg invalid image data for `{0}` `{0}` 的影像資料無效
@msg not a VectorCraft file: {0} 不是 VectorCraft 檔案:{0}
@msg the compressed file unpacks to more than {_1} MB 壓縮檔解壓縮後超過 {_1} MB
@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files 版本 {0} 的檔案無法壓縮:只有版本 {COMPRESSED_SINCE} 及更新版本才能開啟壓縮檔
@msg a bitmap is empty 有一個點陣圖是空的
@msg a bitmap is too large to read 有一個點陣圖過大,無法讀取
@msg compressed (RLE) bitmaps can't be read yet 目前尚無法讀取壓縮 (RLE) 點陣圖
@msg the image's pixels don't match its size 影像的像素與其尺寸不符
@msg {bpp}-bit bitmaps can't be read 無法讀取 {bpp} 位元點陣圖
@msg not an EMF or WMF file 不是 EMF 或 WMF 檔案
@msg its layers use {_1}, which aren't read yet 其圖層使用了 {_1},目前尚無法讀取
@msg the EMF header is damaged EMF 檔頭已損毀
@msg damaged 已損毀
@msg the WMF header is damaged WMF 檔頭已損毀
@msg the export region is empty 匯出區域是空的
@msg a stream runs past the end 有一個資料流超出結尾
@msg a stream's length is wrong 有一個資料流的長度錯誤
@msg a stream's length isn't a number 有一個資料流的長度不是數字
@msg bad stream length 資料流長度錯誤
@msg PDF writer error: {0} PDF 寫入器錯誤:{0}
@msg artboard {0} does not exist 工作區域 {0} 不存在
@msg cannot read PDF: {0} 無法讀取 PDF:{0}
@msg invalid PDF setting: {0} PDF 設定無效:{0}
@msg page {0} does not exist (the PDF has {1}) 頁面 {0} 不存在 (此 PDF 有 {1} 頁)
@msg the PDF has no pages 此 PDF 沒有頁面
@msg the PDF is password-protected: give its password 此 PDF 受密碼保護:請提供其密碼
@msg the PDF password is wrong PDF 密碼錯誤
@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG 此檔案只含有預留位置頁面,而沒有其圖稿 (儲存時未啟用 PDF 相容性):請啟用 PDF 相容性後重新儲存,或儲存為 PDF 或 SVG
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS 這是 PostScript 檔案 (以舊格式或未啟用 PDF 相容性儲存的 .ai,或 EPS 檔案),而非 PDF:「檔案 › 開啟」和「檔案 › 置入」會將其當作 EPS 讀取
@msg unsupported: {0} 不支援:{0}
@msg a page is missing 找不到某個頁面
@msg a page tree kid isn't a reference 有一個頁面樹子節點不是參照
@msg a page tree node has no kids 有一個頁面樹節點沒有子節點
@msg an object can't be read 無法讀取某個物件
@msg bad page tree 頁面樹錯誤
@msg bad trailer 檔尾錯誤
@msg no catalog 沒有目錄
@msg no cross-reference table 沒有交互參照表
@msg no pages 沒有頁面
@msg no plug-in {0} is installed 未安裝增效模組 {0}
@msg not a valid plug-in module: {0} 不是有效的增效模組:{0}
@msg plug-in ABI: {0} 增效模組 ABI:{0}
@msg plug-in exceeded its {0} 增效模組超出其 {0}
@msg plug-in failed: {0} 增效模組失敗:{0}
@msg plug-in manifest: {0} 增效模組資訊清單:{0}
@msg plug-in output: {0} 增效模組輸出:{0}
@msg plug-in parameters: {0} 增效模組參數:{0}
@msg plug-in trapped: {0} 增效模組執行時發生錯誤 (trap):{0}
@msg is not an image VectorCraft reads 不是 VectorCraft 可讀取的影像
@msg not found 找不到
@msg SVG parse error: {0} SVG 剖析錯誤:{0}
@msg decimals must be {_1}–{_2} (got {_3}) 小數位數必須為 {_1}–{_2} (收到 {_3})
@msg Exporting {_1} 正在匯出 {_1}
@msg Exported {what} 已匯出 {what}
@msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是顏色:請寫成 #rrggbb
@msg could not decode image: {0} 無法解碼影像:{0}
@msg image is empty 影像是空的
@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) 影像為 {width} × {height} 像素,超過「影像描圖」可接受的上限 ({max} 百萬像素)
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead 這不是單色標誌 (其實色區域只有 {_1}% 符合少數幾種單色),因此「扁平標誌」模式會把其中的漸層變成色塊:請改用「顏色」模式
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset 此描圖會產生超過 {max} 個錨點,超過文件可流暢顯示的數量:請提高「雜訊」、降低「路徑」或「顏色」,或選擇精確度較低的預設集
@msg internal error: {msg} (please report this bug) 內部錯誤:{msg} (請回報此錯誤)
@msg the background worker stopped 背景處理程序已停止
@msg {_1} failed: {e} {_1} 失敗:{e}
@msg {label}… {label}…
@msg no blend options 沒有漸變選項
@msg bad color {v} 顏色 {v} 無效
@msg the Color Picker is not open 「顏色選擇器」未開啟
@msg the screen color sampler is already open 螢幕取色器已開啟
@msg this system has no screen color sampler 此系統沒有螢幕取色器
@msg Corners edits the corners of a path 「轉角」用於編輯路徑的轉角
@msg give corners of the path: anchors where its sides meet at an angle 請提供路徑的轉角:其各邊以角度相交處的錨點
@msg select one path, or give its `id` 請選取一條路徑,或提供其 `id`
@msg the drawing is no longer loaded 圖面已不再載入
@msg No saved selections (Select → Save Selection…) 沒有已儲存的選取範圍 (選取 → 儲存選取範圍…)
@msg effect {k} is `{_1}`, not `{id}` 效果 {k} 是 `{_1}`,而非 `{id}`
@msg no effect at index {k} 索引 {k} 處沒有效果
@msg unknown effect `{id}` 未知的效果 `{id}`
@msg `{id}` has no envelope dialog `{id}` 沒有封套對話框
@msg select an envelope 請選取封套
@msg no format to export to 沒有可匯出的格式
@msg nothing to export: the document has no visible art (or turn on Use Artboards) 沒有可匯出的內容:文件中沒有可見的圖稿 (或請開啟「使用工作區域」)
@msg add a format to export 請新增要匯出的格式
@msg check an asset to export 請勾選要匯出的資產
@msg `{tool}` isn't a freehand tool `{tool}` 不是手繪工具
@msg bad colour `{hex}` (#rrggbb) 顏色 `{hex}` 無效 (#rrggbb)
@msg no gradient stop or freeform point is selected 未選取漸層色標或任意形狀點
@msg no graphic style `{n}` 沒有繪圖樣式 `{n}`
@msg select two or more graphic styles to merge 請選取兩個以上要合併的繪圖樣式
@msg the PDF is no longer loaded 此 PDF 已不再載入
@msg no such layer or object 沒有這個圖層或物件
@msg `{tool}` isn't a Liquify tool `{tool}` 不是液化工具
@msg {_1} linked file(s) can't be read here: their previews show 此處無法讀取 {_1} 個連結檔案:顯示的是其預覽
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions 有 {_1} 個連結影像已在磁碟上變更:「更新連結」可顯示新版本
@msg pick a colour, gradient or pattern to save as a swatch 請挑選要儲存為色票的顏色、漸層或圖樣
@msg Packaged {n} file(s) in {folder}{note} 已將 {n} 個檔案封裝到 {folder}{note}
@msg Packaged {n} file(s){note} 已封裝 {n} 個檔案{note}
@msg choose a location for the package 請選擇封裝的位置
@msg missing `folder` 未提供 `folder`
@msg the package came back damaged 傳回的封裝檔案已損毀
@msg no preset editor 沒有預設集編輯器
@msg no perspective grid preset named `{name}` 沒有名為 `{name}` 的透視格點預設集
@msg no active perspective plane 沒有作用中的透視平面
@msg plane must be left, right or ground plane 必須是 left、right 或 ground
@msg missing `id` (see plugin.list) 未提供 `id` (請參閱 plugin.list)
@msg no plug-in {id} is installed 未安裝增效模組 {id}
@msg {_1} is a live effect (Effect › Plug-ins) {_1} 是即時效果 (效果 › 增效模組)
@msg a preset named `{_1}` exists 名為 `{_1}` 的預設集已存在
@msg a swatch or color group named `{n}` exists 名為 `{n}` 的色票或顏色群組已存在
@msg no print preset named `{base}` 沒有名為 `{base}` 的列印預設集
@msg no print preset named `{name}` 沒有名為 `{name}` 的列印預設集
@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) 沒有名為 `{name}` 的已儲存列印預設集 ({DEFAULT_PRESET} 受保護:請以它為基礎新增一個)
@msg print settings are not an object 列印設定不是物件
@msg no colour group `{g}` 沒有顏色群組 `{g}`
@msg select artwork (or a colour group) to recolor 請選取要重新上色的圖稿 (或顏色群組)
@msg Downloaded {_1} file(s) 已下載 {_1} 個檔案
@msg Preview in Browser needs the desktop app (the web downloads files instead) 「在瀏覽器中預覽」需要桌面應用程式 (網頁版會改為下載檔案)
@msg no file system here 此處沒有檔案系統
@msg nothing to preview 沒有可預覽的內容
@msg Saved {n} files beside {path} 已在 {path} 旁儲存 {n} 個檔案
@msg `{_1}` is no save command `{_1}` 不是儲存指令
@msg name the file 請為檔案命名
@msg unknown format 未知的格式
@msg PDF settings are not an object PDF 設定不是物件
@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) 沒有名為 `{name}` 的已儲存 PDF 預設集 (內建預設集為唯讀:請以它們為基礎新增一個)
@msg Saved to User Defined: {_1} 已儲存至「使用者定義」:{_1}
@msg choose a direction to divide in 請選擇分割方向
@msg choice must be "merge" or "add", not `{choice}` choice 必須是 "merge" 或 "add",而非 `{choice}`
@msg `{name}` has no options `{name}` 沒有選項
@msg no swatch `{name}` 沒有色票 `{name}`
@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` 不是顏色 (#rrggbb 或預設集名稱)
@msg give the path `id` and the width point `index` 請提供路徑的 `id` 和寬度點的 `index`
@msg no such width point 沒有這個寬度點
@msg onto must name a panel floating in another group onto 必須指定浮動在另一個群組中的面板
@msg below and above must name a panel floating in another group below 和 above 必須指定浮動在另一個群組中的面板
@msg give only one of onto, below and above onto、below 和 above 只能提供其中一個
@msg column must be true or a number from 1 column 必須是 true 或從 1 起算的數字
@msg unknown panel `{raw}` 未知的面板 `{raw}`
@msg x and y must both be numbers x 和 y 都必須是數字
@msg Downloaded {what} 已下載 {what}
@msg Exported {count} file(s) to {folder} 已將 {count} 個檔案匯出至 {folder}
@msg Imported {what}: {_1} 已匯入 {what}:{_1}
@msg Installed plug-in {_1} 已安裝增效模組 {_1}
@msg Opened with {_1} note(s): {_2} 已開啟,附有 {_1} 則註解:{_2}
@msg Saved {path} 已儲存 {path}
@msg no app to open files here 此處沒有可開啟檔案的應用程式
@msg no data 沒有資料
@msg no document 沒有文件
@msg no file manager here 此處沒有檔案管理員
@msg no revert command 沒有回復指令
@msg the Save PDF dialog didn't open 「儲存 PDF」對話框未開啟
@msg the document has never been saved 文件從未儲存過
@msg the export returned unreadable data 匯出傳回了無法讀取的資料
@msg {status} ({e}) {status} ({e})
@msg Exported {what} with {_1} note(s): {first} 已匯出 {what},附有 {_1} 則註解:{first}
@msg Saved {path} with {_1} note(s): {first} 已儲存 {path},附有 {_1} 則註解:{first}
@msg Couldn't open {name}: {e} 無法開啟 {name}:{e}
@msg Couldn't read {name}: {e} 無法讀取 {name}:{e}
@msg Internal error: {msg} (please report this bug) 內部錯誤:{msg} (請回報此錯誤)
@msg Opened {url} 已開啟 {url}
@msg No saved views (View → New View…) 沒有已儲存的檢視 (檢視 → 新增檢視…)
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` 必須是選單項目會開啟對話框的指令 (請參閱 ui.menuDialog)
@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` 必須是 grayscale、rgb、hsb、cmyk 或 websafe
@msg `{key}` must be true or false `{key}` 必須是 true 或 false
@msg brightness must be dark|mediumDark|mediumLight|light brightness 必須是 dark|mediumDark|mediumLight|light
@msg index must be a number or first, previous, next or last index 必須是數字,或 first、previous、next 或 last
@msg index must be a number or first, previous, next or last, not `{other}` index 必須是數字,或 first、previous、next 或 last,而非 `{other}`
@msg lang must be one of {_1} lang 必須是 {_1} 其中之一
@msg missing `name` 未提供 `name`
@msg missing `tool` 未提供 `tool`
@msg missing index 未提供 index
@msg multiple windows land with M11.5 多個視窗將於 M11.5 推出
@msg no PNG encoder 沒有 PNG 編碼器
@msg no document open 沒有開啟的文件
@msg no effect applied yet 尚未套用任何效果
@msg no such perspective grid preset 沒有這個透視格點預設集
@msg no such recent file 沒有這個最近使用的檔案
@msg no such recent font 沒有這個最近使用的字體
@msg no such saved selection 沒有這個已儲存的選取範圍
@msg no such user library 沒有這個使用者資料庫
@msg no such view 沒有這個檢視
@msg printing isn't available here 此處無法列印
@msg select an image 請選取影像
@msg the document has no artboards 文件沒有工作區域
@msg tool (a tool id) is required 需要 tool (工具 id)
@msg unknown tool `{t}` 未知的工具 `{t}`
@msg no such action 沒有這個動作
@msg choose a folder to export to 請選擇要匯出至的資料夾
@msg the selection has no URL (or they differ) 選取範圍沒有 URL (或其 URL 各不相同)
@msg select some type first 請先選取一些文字
@msg {_1} font families available 有 {_1} 個可用的字體家族
@msg {_1} has no {_2} style {_1} 沒有 {_2} 樣式
@msg Copied the CSS of {rules} object(s) 已複製 {rules} 個物件的 CSS
@msg Copied the script report 已複製指令碼報告
@msg Exported {path} 已匯出 {path}
@msg no CSS to copy: select objects with CSS of their own 沒有可複製的 CSS:請選取有自己 CSS 的物件
@msg unknown highlight `{h}` 未知的反白顯示 `{h}`
@msg unknown overprints `{o}` (preserve, simulate or discard) 未知的疊印方式 `{o}` (preserve、simulate 或 discard)
@msg unknown preset `{name}` (see flattener.presets.list) 未知的預設集 `{name}` (請參閱 flattener.presets.list)
@msg Select text or click into text with the Type tool to insert glyphs 請選取文字,或使用文字工具在文字中按一下,以插入字符
@msg `library` is a library id or name (see {list}) `library` 是資料庫 id 或名稱 (請參閱 {list})
@msg no library `{key}` (see {list}) 沒有資料庫 `{key}` (請參閱 {list})
@msg select a linked image: an embedded one has no file 請選取連結的影像:內嵌的影像沒有檔案
@msg {path} can't be found: relink it first 找不到 {path}:請先重新連結
@msg Embedded {n} image(s); {missing} missing: relink them first 已內嵌 {n} 個影像;遺失 {missing} 個:請先重新連結
@msg Relinked {_1} image(s) 已重新連結 {_1} 個影像
@msg Relinked {n} image(s) 已重新連結 {n} 個影像
@msg Relinked {n} image(s); not in that folder: {_1} 已重新連結 {n} 個影像;不在該資料夾中:{_1}
@msg Unembedded to {_1} 已取消內嵌至 {_1}
@msg Updated {_1} image(s) 已更新 {_1} 個影像
@msg no swatch library `{l}` (see swatch.library.list) 沒有色票資料庫 `{l}` (請參閱 swatch.library.list)
@msg no user library folder here 此處沒有使用者資料庫資料夾
@msg Couldn't place {name}: the document it was dropped on was closed 無法置入 {name}:放置它的文件已關閉
@msg no files to place 沒有要置入的檔案
@msg paths: give at least one file paths:請提供至少一個檔案
@msg Couldn't place {name}: {e} 無法置入 {name}:{e}
@msg no print service 沒有列印服務
@msg the print job has no data 列印工作沒有資料
@msg there are no printer settings to open here 此處沒有可開啟的印表機設定
@msg Couldn't save recovery data: {e} 無法儲存復原資料:{e}
@msg Saved recovery data for {title} 已儲存 {title} 的復原資料
@msg Discarded {_1} recovered document(s) 已捨棄 {_1} 份復原的文件
@msg Restored {_1} recovered document(s): save them to keep them 已還原 {_1} 份復原的文件:請儲存以保留
@msg Saving recovery data for {_1} 正在儲存 {_1} 的復原資料
@msg This shortcut file is in a form VectorCraft can't read. VectorCraft 無法讀取此快速鍵檔案的格式。
@msg bad shortcut 快速鍵無效
@msg can't parse shortcut `{c}` 無法剖析快速鍵 `{c}`
@msg can't parse shortcut `{s}` for `{k}` 無法剖析 `{k}` 的快速鍵 `{s}`
@msg no dialog open 沒有開啟的對話框
@msg no file reader 沒有檔案讀取器
@msg no file writer 沒有檔案寫入器
@msg not a VectorCraft shortcut set (missing `overrides`) 不是 VectorCraft 快速鍵組 (沒有 `overrides`)
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace 不是快速鍵組:文字開頭不是斜線名稱和左大括號
@msg shortcut for `{k}` must be a string `{k}` 的快速鍵必須是字串
@msg tool shortcuts must be a single key (optionally with Shift) 工具快速鍵必須是單一按鍵 (可搭配 Shift)
@msg unknown preset (one of {_1}) 未知的預設集 (應為 {_1} 其中之一)
@msg unknown tool or command `{key}` 未知的工具或指令 `{key}`
@msg {_1} is already used by {_2} {_1} 已由 {_2} 使用
@msg Couldn't copy to the system clipboard: {e} 無法複製到系統剪貼簿:{e}
@msg Couldn't paste from the clipboard: {e} 無法從剪貼簿貼上:{e}
@msg nothing selected 未選取任何物件
@msg the {tool} tool has no options {tool} 工具沒有選項
@msg unknown tool `{tool}` 未知的工具 `{tool}`
@msg {tool} isn't in a toolbar group of several tools {tool} 不在包含多個工具的工具列群組中
@msg no such document 沒有這個文件
@msg {_1}: not saved, so not closed {_1}:未儲存,因此未關閉
@msg `{name}` is a built-in workspace; choose another name `{name}` 是內建工作區;請選擇其他名稱
@msg `{to}` is not an available name `{to}` 不是可用的名稱
@msg no user workspace `{from}` 沒有使用者工作區 `{from}`
@msg unknown workspace (one of {_1}) 未知的工作區 (應為 {_1} 其中之一)
@msg workspace name can't be empty 工作區名稱不能是空的
@msg No fonts are missing in this document 此文件中沒有遺失的字體
@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft 不會搜尋其他應用程式或系統的資料夾:{path}
@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft 不會搜尋「使用者」中的 Shared 或 Public 資料夾:{path}
@msg {path} is not a folder {path} 不是資料夾
@msg the search couldn't start: try again in a moment 無法開始搜尋:請稍後再試
@msg searching a folder isn't available on the web 網頁版無法搜尋資料夾
@msg every search thread stopped on an internal error (please report this bug) 所有搜尋執行緒都因內部錯誤而停止 (請回報此錯誤)
@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it 此處未設定 VectorCraft 的字體資料夾:桌面應用程式和 vectorcraft-cli 會設定它
@msg choose the font files to add 請選擇要加入的字體檔案
@msg {n} font file(s) are now in {folder} {n} 個字體檔案現已位於 {folder}
@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} {n} 個字體檔案現已位於 {folder};有 {failed} 個無法加入:{reason}
@msg {n} font(s) aren't available here: their text shows in another font 此處沒有 {n} 種字體:使用這些字體的文字會以其他字體顯示
@msg not a file 不是檔案
@msg not a font file 不是字體檔案
@msg that document is no longer open 該文件已不再開啟
@msg larger than 256 MB 大於 256 MB
@msg the files come to more than 1 GB: add fewer at a time 檔案總計超過 1 GB:請每次少加入一些
@msg every numbered name for it is taken in the Fonts folder 字體資料夾中所有編號名稱都已被使用
@msg every numbered name for {name} is taken in the document's folder 文件所在資料夾中 {name} 的所有編號名稱都已被使用
@msg No linked files to look for 沒有要尋找的連結檔案
@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} 已重新連結 {n} 個影像;無法重新連結 {name}:{reason}
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它
@msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v}
@msg `corners` must be an array of anchor indices, not {v} `corners` 必須是由錨點索引組成的陣列,而非 {v}
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`:步驟 {i} 的結果中沒有此值 (若要表示 $ 本身,請寫 $$)
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`:此步驟之前沒有步驟 {i} (步驟從 0 起算;若要表示 $ 本身,請寫 $$)
@msg object ids are non-negative integers, not {v} 物件 id 是非負整數,而非 {v}
@msg automation filesystem access is not granted: read authority is absent: {path} 未授予自動化檔案系統存取權:沒有讀取權限:{path}
@msg automation filesystem access is not granted: write authority is absent: {path} 未授予自動化檔案系統存取權:沒有寫入權限:{path}
@msg automation path rejected: outside the read root {root}: {path} 已拒絕自動化路徑:位於讀取根目錄 {root} 之外:{path}
@msg automation path rejected: outside the write root {root}: {path} 已拒絕自動化路徑:位於寫入根目錄 {root} 之外:{path}
@msg automation path rejected: path is empty 已拒絕自動化路徑:路徑是空的
@msg automation path rejected: a link that leads nowhere: {path} 已拒絕自動化路徑:連結指向不存在的位置:{path}
@msg automation path rejected: cannot resolve {path}: {e} 已拒絕自動化路徑:無法解析 {path}:{e}
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} 已拒絕自動化路徑:在不存在的資料夾下往上層周遊:{path}
@msg automation path rejected: reserved device names are not allowed: {path} 已拒絕自動化路徑:不允許使用保留的裝置名稱:{path}
@msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode 無法讀取多重色版 PSD 檔案:請以 RGB、CMYK 或灰階模式儲存
@msg not a PSD file 不是 PSD 檔案
@msg the PSD file has an unknown colour mode ({m}) PSD 檔案的色彩模式不明 ({m})
@msg the PSD file's image data uses an unknown compression ({m}) PSD 檔案的影像資料使用了不明的壓縮方式 ({m})
@msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 影像過大,無法讀取 ({_1} × {_2} 像素)
@msg zipped 32-bit PSD image data with prediction can't be read 無法讀取使用預測的 ZIP 壓縮 32 位元 PSD 影像資料
@msg {depth}-bit PSD files in this colour mode can't be read 無法讀取此色彩模式的 {depth} 位元 PSD 檔案
@msg the PSD file is cut short PSD 檔案不完整
@msg the PSD file's header is damaged PSD 檔頭已損毀
@msg the PSD file's image data can't be read: {e} 無法讀取 PSD 檔案的影像資料:{e}
@msg Imported library {name} 已匯入資料庫 {name}
@msg invalid restored panel group 還原的面板群組無效
@msg Invalid panel layout; restored the default dock 面板配置無效;已還原預設停駐區
@msg action is required 需要 action
@msg anchor must name a registered panel anchor 必須指定已註冊的面板
@msg before must name a panel before 必須指定面板
@msg docking action contains an unknown panel or an excessive split path 停駐動作包含未知的面板或過長的分割路徑
@msg group must be true or false group 必須是 true 或 false
@msg move requires a destination panel 移動需要目的地面板
@msg move requires an existing destination panel 移動需要現有的目的地面板
@msg onto must name a floating panel onto 必須指定浮動面板
@msg panel group is empty 面板群組是空的
@msg panel must name a registered panel panel 必須指定已註冊的面板
@msg saved panel layout is invalid; reset the workspace first 已儲存的面板配置無效;請先重設工作區
@msg x and y must be provided together x 和 y 必須同時提供
@msg zone must be a string zone 必須是字串
@msg zone must be tab, center, left, right, top or bottom zone 必須是 tab、center、left、right、top 或 bottom
@msg {key} must be a finite coordinate {key} 必須是有限的座標
@msg invalid native panel set 原生面板組無效
@msg missing panel layout 沒有面板配置
@msg unsupported native docking operation 不支援的原生停駐操作
@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) 壓縮檔解壓縮後超過 VectorCraft 的讀取上限 (文件 {_1} MB,每個影像 {_2} MB)
@msg the document is too large to save compressed ({0}): save it with Use Compression off 文件過大,無法壓縮儲存 ({0}):請關閉「使用壓縮」後儲存
@msg the file stopped being read (please report this bug) 檔案讀取已中斷 (請回報此錯誤)
@msg the image can't be read as a texture 無法將影像當作紋理讀取
@msg give a fraction `t` from 0 to 1 請提供 0 到 1 之間的比例 `t`
@msg give the path `id` 請提供路徑的 `id`
@msg invalid stroke widths 筆畫寬度無效
@msg object has no weighted stroke 物件沒有具寬度的筆畫
@msg Angles require two straight segments 角度需要兩條直線段
@msg Choose two different points 請選擇兩個不同的點
@msg Choose two non-parallel straight segments 請選擇兩條不平行的直線段
@msg Choose two nonzero angle arms 請選擇兩條長度不為零的角邊
@msg Dimension point is outside the canvas 尺寸標註點位於畫布之外
@msg Dimension size is outside its supported range 尺寸標註大小超出支援的範圍
@msg First measured segment no longer exists 第一條測量線段已不存在
@msg Invalid dimension distance 尺寸標註距離無效
@msg Invalid dimension font name 尺寸標註字體名稱無效
@msg Invalid drawing scale 圖紙比例無效
@msg Invalid real scale 實際比例無效
@msg Measured anchor no longer exists 測量的錨點已不存在
@msg Measured segment no longer exists 測量的線段已不存在
@msg Non-finite dimension 尺寸標註不是有限值
@msg Scale must be drawing:real, for example 1:10 比例必須為 圖紙:實際,例如 1:10
@msg Scale ratio must be between 1:1000000 and 1000000:1 比例必須介於 1:1000000 和 1000000:1 之間
@msg Second measured segment no longer exists 第二條測量線段已不存在
@msg Unknown dimension mode 未知的尺寸標註模式
@msg Unknown dimension option {k} 未知的尺寸標註選項 {k}
@msg span must be "object" or "selection", got {v} span 必須是 "object" 或 "selection",收到 {v}
@msg span: "selection" places a `gradient`, not a swatch span: "selection" 放置的是 `gradient`,而非色票
@msg span: "selection" places a gradient: give `gradient` span: "selection" 會放置漸層:請提供 `gradient`
@msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" 放置線性或放射狀漸層,而非任意形狀漸層
@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" 會自行放置漸層:請不要提供 `start` 和 `end` (它們已依文件座標放置漸層)
@msg Batch: {count} processed 批次:已處理 {count} 個
@msg Batch: {count} processed, {failed} failed 批次:已處理 {count} 個,失敗 {failed} 個
@msg choose a destination folder 請選擇目的地資料夾
@msg choose a source folder 請選擇來源資料夾
@msg open a document with data sets 請開啟含有資料集的文件
@msg the action has no steps 動作沒有步驟
@msg cancelled; nothing was changed 已取消;未做任何變更
@msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it 「儲存」會寫入 {_1},而非 {_2}:若要寫入該格式,請使用 file.saveAsTemplate
@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 會寫入舊版 .ai 檔案;.ait 範本一律與 PDF 相容
@msg a mask has more than one art block 遮色片含有多個圖稿區塊
@msg mask art ends outside a mask dictionary 遮色片圖稿在遮色片字典之外結束
@msg `{key}` must be a #rrggbb color `{key}` 必須是 #rrggbb 格式的顏色
@msg a linked image can't become a gradient mesh: embed it first 連結的影像無法轉換為漸層網格:請先內嵌
@msg the image has no usable size or position 影像沒有可用的尺寸或位置
@msg the image's pixels can't be read 無法讀取影像的像素
@msg Persistent libraries not found: {files} 找不到持久資料庫:{files}
@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` 不是資料庫面板 (請參閱 window.swatchLibrary)
@msg library panel `{given}` isn't open 資料庫面板 `{given}` 未開啟
@msg too many library panels were opened in this session 本次工作階段開啟的資料庫面板過多
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 影像為 {_1} × {_2} 像素,有一邊超過 {LARGE_TRACE_SIDE}:仍會描圖,但速度較慢。使用精確度較低的預設集或較小的影像拷貝,描圖會較快。
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 影像已以 {_1} × {_2} 像素描圖,由 {_3} × {_4} 縮小而來 (最多 {_5} 百萬像素)。
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one 「編輯原稿」只能開啟影像與文件檔案;「{name}」不屬於這兩類
@msg only http, https and mailto URLs open in the browser, not “{url}” 只有 http、https 和 mailto URL 會在瀏覽器中開啟,「{url}」不會
@msg {id} opens another app: only the person at the keyboard can run it {id} 會開啟另一個應用程式:只有在鍵盤前操作的人才能執行
@msg “{name}” is not a file 「{name}」不是檔案
# Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands.
Import Shortcut Set 匯入快速鍵組
Import Shortcut Set… 匯入快速鍵組…
@@ -1847,6 +2576,19 @@ file-filter Templates 範本
Move Slices 移動切片
Move Spine Point 移動脊線點
Reshape Spine Segment 改變脊線線段外框
@msg select a blend or give `id` 請選取漸變物件,或提供 `id`
@msg not a blend 不是漸變物件
@msg the blend has no spine 漸變物件沒有脊線
@msg the spine has no segments 脊線沒有線段
@msg no such spine point 沒有這個脊線點
@msg no such spine segment 沒有這個脊線線段
@msg a key object sits on that point 該點上有關鍵物件
@msg a spine keeps at least two points 脊線至少要保留兩個點
@msg missing number `{key}` 未提供數字 `{key}`
@msg index must be ≥ 0 index 必須 ≥ 0
@msg x and y must be finite x 和 y 必須是有限值
@msg t, dx and dy must be finite t、dx 和 dy 必須是有限值
@msg handle must be in or out handle 必須是 in 或 out
Move Swatch 移動色票
Move Up 上移
Move… 移動…
@@ -3421,6 +4163,7 @@ mark-style Japanese 日式
Delete Variable 刪除變數
Lock Variables 鎖定變數
Unlock Variables 解除鎖定變數
@msg variables are locked 變數已鎖定
List Variables 列出變數
Bind Variable 綁定變數
Unbind Variable 解除變數綁定
@@ -4094,3 +4837,11 @@ lighting Light 光源
Minimum must not exceed Desired, nor Desired Maximum. 最小值不可超過所需值,所需值不可超過最大值。
Minimum 最小值
Justification 對齊設定
# Select by Color (select.color)
Select by Color 依顏色選取
@msg missing integer `{key}` 未提供整數 `{key}`
@msg `{key}` must be a number, not {v} `{key}` 必須是數字,而非 {v}
@msg {key} must be Unix seconds (an integer) or null {key} 必須是以秒為單位的 Unix 時間 (整數) 或 null
@msg {key} must be an array of non-negative integer object ids {key} 必須是由非負整數物件 id 組成的陣列
@msg invalid object id {value}: expected a non-negative integer 無效的物件 id {value}:應為非負整數
@msg no such object id {value} 沒有 id 為 {value} 的物件
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -708,6 +708,7 @@ impl VectorcraftApp {
/// [`Self::run`] it, inside what asks for file dialogs.
fn run_now(&mut self, id: &str, params: Value) -> Result<Value, String> {
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
self.run_count = self.run_count.wrapping_add(1);
// In debug builds the app's own calls are held to the params descriptions too (#1442): a
// panel passing a key its command doesn't read shows up in the tests.
@@ -1420,6 +1421,7 @@ impl VectorcraftApp {
}
fn ui_frame(&mut self, ui: &mut egui::Ui) {
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
let ctx = ui.ctx().clone();
if !self.fonts_ready {
ctx.request_repaint();
+4 -2
View File
@@ -1249,6 +1249,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"view.snapToPoint" => flag(&mut app.ui.view.snap_to_point),
"view.zoomIn" | "view.zoomOut" => {
let up = id == "view.zoomIn";
let print = app.session.prefs.display_print_size;
// Selection & Anchor Display › Zoom to Selection: the selection comes to the middle.
let focus = if app.session.prefs.zoom_to_selection { app.selection_box().map(|b| b.center()) } else { None };
match app.view_mut() {
@@ -1257,13 +1258,14 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
if let Some(c) = focus {
v.center = c;
}
Ok(json!({"zoom": v.zoom * 100.0}))
Ok(json!({"zoom": crate::state::zoom_display_percent(v.zoom, print)}))
}
None => Err("no document".into()),
}
}
"view.setZoom" => {
let z = p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0) / 100.0;
let print = app.session.prefs.display_print_size;
let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print);
let center =
p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
match app.view_mut() {
+14 -5
View File
@@ -504,6 +504,11 @@ struct PanelContent<'a> {
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
fn body(&mut self, ui: &mut egui::Ui, panel: &String) {
let t = crate::theme::Tokens::get(ui.ctx());
let back = ui.max_rect();
if back.is_positive() {
ui.painter().rect_filled(back, 0.0, t.panel);
}
let width = ui.available_width();
let height = (ui.available_height() - 20.0).max(0.0);
if crate::state::DockTab::from_id(panel).is_some() {
@@ -637,10 +642,13 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
let fills: Vec<_> = display
.floating
.iter()
.filter_map(|group| group.panels.first())
.map(|first| {
.filter_map(|group| {
let first = group.panels.first()?;
let layer = egui::LayerId::new(egui::Order::Middle, area.with(("floating", first)));
(layer, ui.ctx().layer_painter(layer).add(egui::Shape::Noop))
let [x, y, w, h] = group.rect;
let layout_rect = egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(w, h));
let fill = ui.ctx().layer_painter(layer).add(egui::Shape::Noop);
Some((layer, layout_rect, fill))
})
.collect();
let mut draw = |ui: &mut egui::Ui| {
@@ -787,8 +795,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect));
}
}
for (layer, fill) in fills {
if let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(layer.id)) {
for (layer, layout_rect, fill) in fills {
let rect = ui.ctx().memory(|memory| memory.area_rect(layer.id)).filter(|rect| rect.is_positive()).unwrap_or(layout_rect);
if rect.is_positive() {
ui.ctx().layer_painter(layer).set(fill, egui::Shape::rect_filled(rect, 0.0, t.panel));
}
}
+22 -1
View File
@@ -616,7 +616,7 @@ fn the_window_menu_item_hides_a_shown_panel_and_shows_it_again() {
}
/// A floating group lies on the canvas, not on the dock's card, so its tab strip and body paint
/// their own opaque background (#2421).
/// their own opaque background (#2421, #2646).
#[test]
fn floating_group_paints_an_opaque_background() {
let mut app = app();
@@ -641,6 +641,27 @@ fn floating_group_paints_an_opaque_background() {
assert!(opaque_at(pos2(690.0, 610.0)), "body is transparent");
}
/// Undocking a tabbed dock panel leaves an opaque backdrop even in the empty part of its body (#2646).
#[test]
fn undocked_layers_panel_body_is_opaque() {
let mut app = app();
app.run("window.panel.float", json!({"panel":"layers","x":300,"y":160,"width":300,"height":420})).unwrap();
let h = harness(app, vec2(1280.0, 800.0));
fn topmost(shape: &egui::Shape, p: Pos2, found: &mut Option<egui::Color32>) {
match shape {
egui::Shape::Rect(r) if r.rect.contains(p) && r.rect.width() >= 80.0 && r.stroke.is_empty() && r.fill.a() > 0 => *found = Some(r.fill),
egui::Shape::Vec(v) => v.iter().for_each(|s| topmost(s, p, found)),
_ => {}
}
}
let opaque_at = |p: Pos2| {
let mut found = None;
h.output().shapes.iter().for_each(|s| topmost(&s.shape, p, &mut found));
found.is_some_and(|c| c.a() == 255)
};
assert!(opaque_at(pos2(580.0, 520.0)), "empty layers body shows the canvas");
}
/// Library panels (#2603) float in the shared layout titled by their libraries' names, and their
/// menu's Persistent item keeps them for the next launch.
#[test]
+48 -10
View File
@@ -62,25 +62,53 @@ pub const ROTATE_VIEW_ANGLES: [(&str, f64); 12] = [
("-90°", -90.0),
];
use std::sync::atomic::{AtomicBool, Ordering};
/// Zoom stop stepping reads this (set each UI frame and before commands) so canvas wheel zoom
/// matches the status bar when Display Print Size at 100% Zoom is on.
static ZOOM_STOPS_PRINT_SIZE: AtomicBool = AtomicBool::new(false);
pub fn sync_zoom_stops_display_print_size(v: bool) {
ZOOM_STOPS_PRINT_SIZE.store(v, Ordering::Relaxed);
}
/// Illustrator's preset zoom stops, in percent.
pub const ZOOM_STOPS: [f64; 26] = [
3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0,
4800.0, 6400.0, 12800.0, 25600.0, 64000.0,
];
/// Document zoom at 100% in the status bar: one screen point per document point, or with
/// Preferences › General › Display Print Size at 100% Zoom, one document inch in 96 screen points.
pub fn zoom_baseline(display_print_size: bool) -> f64 {
if display_print_size { 96.0 / 72.0 } else { 1.0 }
}
pub fn zoom_display_percent(z: f64, display_print_size: bool) -> f64 {
z / zoom_baseline(display_print_size) * 100.0
}
pub fn zoom_from_display_percent(pct: f64, display_print_size: bool) -> f64 {
pct / 100.0 * zoom_baseline(display_print_size)
}
pub fn next_zoom(z: f64, up: bool) -> f64 {
let pct = z * 100.0;
next_zoom_with_display(z, up, ZOOM_STOPS_PRINT_SIZE.load(Ordering::Relaxed))
}
pub fn next_zoom_with_display(z: f64, up: bool, display_print_size: bool) -> f64 {
let pct = zoom_display_percent(z, display_print_size);
let stop = if up {
ZOOM_STOPS.iter().find(|s| **s > pct + 0.01).copied().unwrap_or(64000.0)
} else {
ZOOM_STOPS.iter().rev().find(|s| **s < pct - 0.01).copied().unwrap_or(3.13)
};
stop / 100.0
zoom_from_display_percent(stop, display_print_size)
}
/// Format like Illustrator's tab/zoom field: up to 2 decimals, no trailing zeros.
pub fn zoom_label(z: f64) -> String {
let s = format!("{:.2}", z * 100.0);
pub fn zoom_label(z: f64, display_print_size: bool) -> String {
let s = format!("{:.2}", zoom_display_percent(z, display_print_size));
let s = s.trim_end_matches('0').trim_end_matches('.');
format!("{s}%")
}
@@ -821,12 +849,22 @@ mod tests {
#[test]
fn zoom_steps() {
assert_eq!(next_zoom(1.0, true), 1.5);
assert!((next_zoom(1.0, false) - 0.6667).abs() < 1e-9);
assert_eq!(next_zoom(640.0, true), 640.0);
assert_eq!(zoom_label(0.6667), "66.67%");
assert_eq!(zoom_label(1.0), "100%");
assert_eq!(zoom_label(0.125), "12.5%");
assert_eq!(next_zoom_with_display(1.0, true, false), 1.5);
assert!((next_zoom_with_display(1.0, false, false) - 0.6667).abs() < 1e-9);
assert_eq!(next_zoom_with_display(640.0, true, false), 640.0);
assert_eq!(zoom_label(0.6667, false), "66.67%");
assert_eq!(zoom_label(1.0, false), "100%");
assert_eq!(zoom_label(0.125, false), "12.5%");
}
/// storytold/vectorcraft#2719: 100% in the zoom field matches Actual Size when Display Print Size is on.
#[test]
fn zoom_percent_honors_display_print_size_baseline() {
let b = zoom_baseline(true);
assert!((zoom_from_display_percent(100.0, true) - b).abs() < 1e-12);
assert_eq!(zoom_from_display_percent(100.0, false), 1.0);
assert_eq!(zoom_label(b, true), "100%");
assert_eq!(next_zoom_with_display(b, true, true), zoom_from_display_percent(150.0, true));
}
#[test]
+84 -5
View File
@@ -447,16 +447,95 @@ fn arrow_keys_step_a_focused_field() {
(Key::ArrowUp, Modifiers::NONE, 5.0),
(Key::ArrowUp, Modifiers::SHIFT, 15.0),
(Key::ArrowDown, Modifiers::COMMAND, 14.9),
(Key::ArrowDown, Modifiers::NONE, 13.9),
(Key::ArrowDown, Modifiers::NONE, 14.0),
] {
assert!(f.frame(vec![key(k, mods)], &draw).is_some(), "{k:?} {mods:?}");
assert!((mm.get() - want).abs() < 1e-6, "{k:?} {mods:?}: {}", mm.get());
}
assert_eq!(f.selection(), Some((0, "13.9 mm".len())), "the new value is selected");
assert_eq!(f.selection(), Some((0, "14 mm".len())), "the new value is selected");
// Enter after the steps commits nothing more.
assert_eq!(f.frame(vec![enter()], &draw), None);
}
/// #2881, #2793: ↑/↓ and the wheel step a fractional value to the next whole unit in their
/// direction (5.1 mm: up to 6, down to 5), and Ctrl/Cmd steps to the next tenth; Shift adds or takes
/// ten and keeps the fraction. In each field's own unit: millimeters, points, percent, degrees.
#[test]
fn steps_go_to_the_next_whole_unit_or_tenth() {
use egui::MouseWheelUnit::Line;
let u = Unit::Millimeters;
let mm = Cell::new(0.0);
let mm_field = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(u.to_pt(mm.get())), u, 80.0).inspect(|&v| mm.set(u.from_pt(v)));
let mut f = Field::new();
f.frame(vec![], &mm_field);
f.frame(f.click(1), &mm_field);
f.frame(vec![], &mm_field);
let at = f.rect.get().center();
// From `from` (set from outside, which the focused field follows), the value `events` give.
let mut step = |from: f64, events: Vec<Event>| {
mm.set(from);
f.frame(vec![], &mm_field);
assert!(f.frame(events, &mm_field).is_some(), "from {from}");
// The field shows the new value before the next one is set.
f.frame(vec![], &mm_field);
mm.get()
};
let arrow = |k, mods| vec![key(k, mods)];
let turn = |dy, mods| wheel(at, Line, dy, mods);
for (from, events, want) in [
(5.1, arrow(Key::ArrowUp, Modifiers::NONE), 6.0),
(5.1, arrow(Key::ArrowDown, Modifiers::NONE), 5.0),
(5.0, arrow(Key::ArrowUp, Modifiers::NONE), 6.0),
(5.0, arrow(Key::ArrowDown, Modifiers::NONE), 4.0),
(-2.5, arrow(Key::ArrowUp, Modifiers::NONE), -2.0),
(-2.5, arrow(Key::ArrowDown, Modifiers::NONE), -3.0),
(5.13, arrow(Key::ArrowUp, Modifiers::COMMAND), 5.2),
(5.13, arrow(Key::ArrowDown, Modifiers::COMMAND), 5.1),
(5.1, arrow(Key::ArrowUp, Modifiers::SHIFT), 15.1),
(5.1, arrow(Key::ArrowDown, Modifiers::SHIFT), -4.9),
(5.1, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowUp, Modifiers::NONE)], 7.0),
// Several presses in one frame step in the order they came.
(5.1, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowDown, Modifiers::NONE)], 5.0),
(5.13, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowUp, Modifiers::COMMAND)], 6.1),
(24.8, turn(1.0, Modifiers::NONE), 25.0),
(24.8, turn(-1.0, Modifiers::NONE), 24.0),
(24.8, turn(2.0, Modifiers::NONE), 26.0),
(24.8, turn(-2.0, Modifiers::NONE), 23.0),
(24.0, turn(1.0, Modifiers::NONE), 25.0),
(24.83, turn(1.0, Modifiers::COMMAND), 24.9),
(24.8, turn(1.0, Modifiers::SHIFT), 34.8),
] {
let got = step(from, events);
assert!((got - want).abs() < 1e-6, "{from} mm to {got}, not {want}");
}
// A points field steps whole points.
let pt = Cell::new(12.25);
let pt_field = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(pt.get()), Unit::Points, 80.0).inspect(|&v| pt.set(v));
let mut f = Field::new();
f.frame(vec![], &pt_field);
f.frame(f.click(1), &pt_field);
f.frame(vec![], &pt_field);
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &pt_field), Some(13.0));
// Percent and degree fields step whole percents and degrees.
for suffix in ["%", "°"] {
let mut f = Field::new();
let value = Cell::new(37.5);
let plain = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", value.get(), suffix, 2, 80.0).inspect(|&v| value.set(v));
f.frame(vec![], &plain);
f.frame(f.click(1), &plain);
f.frame(vec![], &plain);
for (k, mods, want) in [
(Key::ArrowDown, Modifiers::NONE, 37.0),
(Key::ArrowUp, Modifiers::NONE, 38.0),
(Key::ArrowUp, Modifiers::COMMAND, 38.1),
(Key::ArrowUp, Modifiers::NONE, 39.0),
] {
assert_eq!(f.frame(vec![key(k, mods)], &plain), Some(want), "{suffix} {k:?} {mods:?}");
f.frame(vec![], &plain);
}
}
}
/// Plain fields (degrees, percent, counts) step too, at their precision.
#[test]
fn arrow_keys_step_plain_fields() {
@@ -498,17 +577,17 @@ fn the_wheel_steps_a_focused_field_under_the_pointer() {
f.frame(f.click(1), &draw);
f.frame(vec![], &draw);
for (dy, mods, want) in
[(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 23.9)]
[(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 24.0)]
{
assert!(f.frame(wheel(at, Line, dy, mods), &draw).is_some(), "{dy} {mods:?}");
assert!((mm.get() - want).abs() < 1e-6, "{dy} {mods:?}: {}", mm.get());
}
assert_eq!(f.selection(), Some((0, "23.9 mm".len())), "the new value is selected");
assert_eq!(f.selection(), Some((0, "24 mm".len())), "the new value is selected");
// Points: a step for each line's worth, what is left carried to the next turn.
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
assert_eq!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw), None);
assert!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw).is_some());
assert!((mm.get() - 24.9).abs() < 1e-6, "{}", mm.get());
assert!((mm.get() - 25.0).abs() < 1e-6, "{}", mm.get());
assert_eq!(f.frame(wheel(Pos2::new(390.0, 190.0), Line, 1.0, Modifiers::NONE), &draw), None, "the pointer elsewhere");
// Plain fields (degrees, percent, counts) too.
let mut f = Field::new();
+90 -29
View File
@@ -260,15 +260,31 @@ pub(crate) fn round_to(v: f64, decimals: i32) -> f64 {
}
/// One step of a numeric field with each modifier, most specific first (a plain pattern would also
/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one.
/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to
/// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten.
const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)];
/// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next
/// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1
/// goes up to 6 and down to 5, and 5 goes up to 6 and down to 4. A value within a millionth of a
/// step of a multiple counts as that multiple.
fn step_to_multiple(v: f64, size: f64, n: f64) -> f64 {
if n == 0.0 {
return v;
}
let q = v / size;
let from = if n > 0.0 { (q + 1e-6).floor() } else { (q - 1e-6).ceil() };
(from + n) * size
}
/// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf`
/// shows (`read` parses it, `show` formats it) by one per press or wheel notch ([`STEPS`]: Shift
/// ten, Ctrl/Cmd a tenth), applied at once as in Illustrator's panels and dialogs. The new text
/// replaces `buf`, all selected so typing replaces it. Returns the new number when a step was
/// taken. While the pointer is over the focused field the wheel is the field's (the panel under it
/// doesn't scroll); an unfocused field leaves the wheel to the panel and the canvas.
/// shows (`read` parses it, `show` formats it) per press or wheel notch ([`STEPS`]): a plain step
/// goes to the next whole number in its direction (5.1 up to 6, down to 5), a Ctrl/Cmd step goes to
/// the next tenth, and a Shift step adds or takes ten. The new value is applied at once, as in
/// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing
/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused
/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the
/// wheel to the panel and the canvas.
fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> Option<f64>, show: impl Fn(f64) -> String) -> Option<f64> {
// Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the
// window to report keyboard focus.
@@ -281,41 +297,51 @@ fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) ->
// left of a notch carries over to the next turn.
let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel"));
let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 };
let mut steps = 0.0;
// Each press and each turn of whole notches in the order they came: its modifiers and size from
// [`STEPS`], and how many steps up (down when negative).
let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new();
ui.input_mut(|i| {
for (mods, size) in STEPS {
let up = i.count_and_consume_key(mods, Key::ArrowUp) as f64;
let down = i.count_and_consume_key(mods, Key::ArrowDown) as f64;
steps += size * (up - down);
}
if !wheel {
return;
}
i.events.retain(|e| {
let Event::MouseWheel { unit, delta, modifiers, .. } = e else { return true };
let Some((_, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true };
// Shift may turn the wheel sideways (the Mac does).
let d = if modifiers.shift { delta.x + delta.y } else { delta.y };
notches += match unit {
MouseWheelUnit::Point => d / line.max(1.0),
MouseWheelUnit::Line | MouseWheelUnit::Page => d,
let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true };
let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true };
let n = match e {
Event::Key { key: Key::ArrowUp, pressed: true, .. } => 1.0,
Event::Key { key: Key::ArrowDown, pressed: true, .. } => -1.0,
Event::MouseWheel { unit, delta, .. } if wheel => {
// Shift may turn the wheel sideways (the Mac does).
let d = if modifiers.shift { delta.x + delta.y } else { delta.y };
notches += match unit {
MouseWheelUnit::Point => d / line.max(1.0),
MouseWheelUnit::Line | MouseWheelUnit::Page => d,
};
let whole = notches.trunc();
notches -= whole;
f64::from(whole)
}
_ => return true,
};
let whole = notches.trunc();
notches -= whole;
steps += size * f64::from(whole);
if n != 0.0 {
steps.push((mods, size, n));
}
false
});
// The rest of a turn egui spreads over the next frames scrolls nothing either.
i.smooth_scroll_delta = egui::Vec2::ZERO;
if wheel {
// The rest of a turn egui spreads over the next frames scrolls nothing either.
i.smooth_scroll_delta = egui::Vec2::ZERO;
}
});
if wheel {
ui.data_mut(|d| d.insert_temp(notch_id, notches));
}
if steps == 0.0 {
if steps.is_empty() {
return None;
}
let mut v = read(buf)?;
for (mods, size, n) in steps {
v = if mods == egui::Modifiers::SHIFT { v + size * n } else { step_to_multiple(v, size, n) };
}
// Rounded as fields show it.
let v = round_to(read(buf)? + steps, STEP_DECIMALS);
let v = round_to(v, STEP_DECIMALS);
*buf = show(v);
ui.data_mut(|d| d.insert_temp(resp.id, buf.clone()));
select_all(ui, resp.id, buf);
@@ -2554,6 +2580,41 @@ mod spinner_modifier_tests {
}
}
#[cfg(test)]
mod step_to_multiple_tests {
use super::*;
/// #2881: the first step goes to the next multiple of its size in its direction, and each further
/// step adds or takes one size.
#[test]
fn a_step_goes_to_the_next_multiple_of_its_size() {
for (v, size, n, want) in [
(5.1, 1.0, 1.0, 6.0),
(5.1, 1.0, -1.0, 5.0),
(5.0, 1.0, 1.0, 6.0),
(5.0, 1.0, -1.0, 4.0),
(-2.5, 1.0, 1.0, -2.0),
(-2.5, 1.0, -1.0, -3.0),
(24.8, 1.0, 2.0, 26.0),
(24.8, 1.0, -2.0, 23.0),
(5.13, 0.1, 1.0, 5.2),
(5.13, 0.1, -1.0, 5.1),
(5.1, 1.0, 0.0, 5.1),
] {
assert!((step_to_multiple(v, size, n) - want).abs() < 1e-9, "{v} by {size} × {n}: {}", step_to_multiple(v, size, n));
}
}
/// A value a float error away from a multiple moves a whole step from it: 0.7 is 6.999… tenths.
#[test]
fn a_value_next_to_a_multiple_moves_a_whole_step() {
assert!((step_to_multiple(0.7, 0.1, 1.0) - 0.8).abs() < 1e-9);
assert!((step_to_multiple(0.3, 0.1, -1.0) - 0.2).abs() < 1e-9);
assert!((step_to_multiple(5.000_000_1, 1.0, 1.0) - 6.0).abs() < 1e-9);
assert!((step_to_multiple(4.999_999_9, 1.0, -1.0) - 4.0).abs() < 1e-9);
}
}
#[cfg(test)]
mod menu_room_tests {
use super::*;
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more