Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 904f0fe522 Release 0.13.1: retry stapler validate on Apple network errors
0.13.0's macOS release job failed twice at `stapler validate` right after a successful notarization
and staple: the CloudKit ticket lookup lost its connection (NSURLErrorDomain -1005, Error 68). Retry
the validate up to four times with a growing pause; a missing or bad ticket still fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 00:00:06 -04:00
Brandon ThomasandClaude Opus 5.5 5e6810655f Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:43:14 -04:00
skylar taylor-barrick 299a4b36ad Merge pull request #2777 from skylartaylor/open-original
Open originals and linked files only on this computer or the document's share
2026-10-17 15:45:22 -06:00
skylar taylor-barrick 6a83f6fea6 i18n: translate the new link messages into Simplified Chinese
The FreeBSD job failed complete_languages_translate_every_message: zh-hans was
missing the four Links and Attributes messages this branch adds.

The merge created a state neither side had. This branch predates main adding
zh-hans to COMPLETE_MESSAGES, so its new messages had no Simplified Chinese; the
other 17 languages conflicted and were resolved as a union, while zh-hans merged
cleanly and silently without them. da, id, nl and zh-hant are also missing them
but are not on the completeness list, so they are left as they are.

Terminology follows the file's own: 编辑原稿 for Edit Original, 浏览器 for
browser, 不是文件 after {path} 不是文件夹. These want a native speaker's eye
before release; the test only proves the strings exist.
2026-10-17 13:48:41 -06:00
BFlat e6aec0664c Merge pull request #2791 from storytold/pr/batch-1018
Docs: ROADMAP entry and parity-row cleanup for the 2026-10-18 community batch
2026-10-18 01:57:47 +07:00
bflatastic 87a5b4cc83 Docs: the ROADMAP records the community PRs landed on 2026-10-18 2026-10-18 01:57:27 +07:00
bflatastic d7a61f1c50 Docs: one M9 roadmap row and one Optical Margin Alignment row after the batch 2026-10-18 01:57:27 +07:00
BFlat 95e98d18de Merge pull request #2684 from dotCooCoo/fix/export-for-web-warns-undecodable-image
Save for Web: saving and Preview in Browser warn when an image couldn't be decoded
2026-10-18 01:57:12 +07:00
BFlat f4668bfb2b Merge pull request #2726 from dotCooCoo/fix/style-names-trimmed-on-create
Character and Paragraph Styles: a new style's name is trimmed as a rename trims it
2026-10-18 01:56:57 +07:00
BFlat 590f91574d Merge pull request #2742 from dotCooCoo/fix/tabs-panel-menu-items-enabled
Tabs: Clear All Tabs, Delete Tab and Repeat Tab can be chosen from the panel menu
2026-10-18 01:56:42 +07:00
BFlat facab88bcc Merge pull request #2746 from dotCooCoo/fix/brush-pattern-artboard-names-trimmed
Brushes, patterns and artboards: new and renamed items have their names trimmed
2026-10-18 01:56:27 +07:00
BFlat 4ffaeb1895 Merge pull request #2774 from dotCooCoo/fix/edit-undo-redo-step-names-translated
i18n: Edit › Undo and Redo name the step as the History panel does
2026-10-18 01:56:13 +07:00
BFlat 99b3917aaa Merge pull request #2715 from dotCooCoo/fix/issue-2054
Fix #2054: bounding-box resize handles snap to the grid, pixels, points and ruler guides
2026-10-18 01:56:02 +07:00
BFlat 2ef544cab7 Merge pull request #2717 from dotCooCoo/fix/linux-tablet-xwayland
Linux Display Server: XWayland when a pen is attached, where a drawing tablet's pen draws
2026-10-18 01:55:46 +07:00
BFlat 67673faea7 Merge pull request #2729 from dotCooCoo/fix/canvas-test-worker-deadlines
Tests: five tests that wait on another thread pass on a loaded machine
2026-10-18 01:55:29 +07:00
BFlat ec3a1d2a82 Merge pull request #2736 from dotCooCoo/feat/dataset-import-header-prefixes
Variables: Import Data Sets… defines the variables a file's header names
2026-10-18 01:55:15 +07:00
BFlat a96a6800c9 Merge pull request #2762 from dotCooCoo/fix/exports-keep-encoder-warnings
Export: Save for Office, CSS and Copy warn when an image couldn't be decoded or a font is missing
2026-10-18 01:55:00 +07:00
BFlat 2ca923b43a Merge pull request #2737 from dotCooCoo/fix/export-for-screens-keeps-warnings
Export for Screens: exports warn when an image couldn't be decoded or a font is missing
2026-10-18 01:54:45 +07:00
BFlat ef666b4bfd Merge pull request #2738 from dotCooCoo/fix/file-names-windows-characters
Export: names typed into file names follow Windows' naming rules
2026-10-18 01:54:31 +07:00
BFlat e26393c7d8 Merge pull request #2740 from dotCooCoo/fix/clippy-1-95-artistic-filters
Effects: vectorcraft-effects passes clippy on Rust 1.95, the minimum toolchain
2026-10-18 01:54:16 +07:00
BFlat ba69d744ae Merge pull request #2745 from dotCooCoo/fix/issue-2558-drag-preview
Part of #2558: a drag draws zoomed-in raster effects at a lower resolution until it ends
2026-10-18 01:54:01 +07:00
BFlat 879fb63248 Merge pull request #2747 from dotCooCoo/fix/batch-extension-from-format
Actions: Batch's Override Save and Export name files with the format's extension
2026-10-18 01:53:47 +07:00
BFlat 5b7411e9d1 Merge pull request #2760 from dotCooCoo/fix/ai-import-difference-note-counts-art
.ai import: the warning that the layers differ from the page gives the art's share or names the type
2026-10-18 01:53:32 +07:00
BFlat b5715f7509 Merge pull request #2761 from dotCooCoo/feat/artboards-select-all
Artboards: Ctrl/Cmd+A in the Artboards panel selects every artboard with the Artboard tool
2026-10-18 01:53:16 +07:00
BFlat 66cfc1dca6 Merge pull request #2764 from dotCooCoo/feat/history-panel-step-backward-forward
History: the panel menu has Step Backward, Step Forward and Set History Limit…
2026-10-18 01:53:00 +07:00
BFlat 74cf04fa3b Merge pull request #2767 from dotCooCoo/fix/preset-rename-writes-before-removing
PDF presets: a rename that can't be written keeps the preset under its old name
2026-10-18 01:52:45 +07:00
BFlat fda352cf7b Merge pull request #2766 from dotCooCoo/feat/place-docx
Place: Word .docx files place as area type with their formatting
2026-10-18 01:52:31 +07:00
BFlat 98fc8146dc Merge pull request #2770 from dotCooCoo/feat/tiff-ccitt-g3
TIFF: CCITT Group 3 compressed files open and place
2026-10-18 01:52:16 +07:00
BFlat 3988aafc1a Merge pull request #2772 from dotCooCoo/fix/marquee-skips-shaper-sources
Shaper: a marquee does not select a Shaper Group by the original shape of an erased face
2026-10-18 01:52:01 +07:00
BFlat 2433c44915 Merge pull request #2773 from dotCooCoo/feat/optical-margin-alignment-follows-thread
Type: Optical Margin Alignment is a setting of the whole thread
2026-10-18 01:51:45 +07:00
BFlat dfde775213 Merge pull request #2757 from dotCooCoo/fix/saved-text-sizes-in-range
Type: sizes, scales, leading, indents and spacing out of range in a file are read in range
2026-10-18 01:51:28 +07:00
BFlat 2fa0e979f3 Merge pull request #2728 from dotCooCoo/fix/saved-script-sizes-in-range
Type: superscript, subscript and small caps proportions out of range in a file are read in range
2026-10-18 01:51:15 +07:00
BFlat eba745e354 Merge pull request #2471 from VL420/feat/task-bar-more
G1: Contextual Task Bar: Join, Edit Path, Release and type conversion by selection
2026-10-18 01:50:59 +07:00
BFlat 64fda576b2 Merge pull request #2787 from storytold/pr/2706-colored-lights
3D effects: one to ten coloured lights (#2706)
2026-10-18 01:50:40 +07:00
BFlat 07bf1ddf8c Merge pull request #2395 from EricKuck/fix/ai-outer-glow-solid-silhouette
Shadows and glows: each is of the art with the ones before it in the stack, and an Outer Glow blurs a solid silhouette
2026-10-18 01:50:04 +07:00
BFlat cc5fba7577 Merge pull request #2643 from makesomethingshit/fix/2544-blend-spine-anchor-tool
Blend: edit spine points and handles with the Anchor Point tool (#2544)
2026-10-18 01:49:51 +07:00
BFlat 95427b71bf Merge pull request #2720 from MarekSpodBiedry/issue-2655
Magic Wand: only select objects inside the isolated group (#2655)
2026-10-18 01:49:34 +07:00
BFlat c9bebb92d6 Merge pull request #2721 from MarekSpodBiedry/issue-2673
Map the WMF window onto the placeable box (#2673)
2026-10-18 01:49:20 +07:00
BFlat eaa21d42d5 Merge pull request #2722 from MarekSpodBiedry/issue-2693
Write legacy .ai layer names in Windows Latin-1 and warn when a character is replaced (#2693)
2026-10-18 01:49:06 +07:00
BFlat d5cfb27989 Merge pull request #2730 from nvict-service/i18n-nl-complete
i18n(nl): complete the Dutch catalog
2026-10-18 01:48:56 +07:00
BFlat a7fded4584 Merge pull request #2732 from vangel76/fix/2491-fallback-glyph-style
Fonts: fallback glyphs keep bold/italic, Bold Italic of a missing family keeps the bold, CJK prefers an installed sans (#2491)
2026-10-18 01:48:39 +07:00
BFlat 3e09a469de Merge pull request #2733 from vangel76/fix/2521-variable-default-face
Fonts: a variable font's default face is named and weighed by its default instance (#2521)
2026-10-18 01:48:25 +07:00
BFlat 079cd4983e Merge pull request #2739 from guillehr2/fix/1876-artboard-options-xy
Fix #1876: Artboard Options has X and Y with a reference point
2026-10-18 01:48:09 +07:00
BFlat 1651e7bea7 Merge pull request #2775 from VL420/feat/roman-hanging
G4: Paragraph panel menu › Roman Hanging Punctuation
2026-10-18 01:47:57 +07:00
bflatastic e01fe134e9 Save for Web: the undecoded-image warning comes from the shared helper 2026-10-18 01:32:01 +07:00
bflatastic f5d61f6f27 Styles: a blank new style name says to give the style a name 2026-10-18 01:32:01 +07:00
bflatastic 7da58e35cc Tabs: Clear All Tabs reports a failure in the status bar 2026-10-18 01:32:01 +07:00
dotCooCoo a03583fc73 Save for Web: saving and Preview in Browser warn when an image couldn't be decoded
document.exportForWeb renders the artboard or the art and writes GIF,
JPEG, PNG-8 or PNG-24 files. When the art held an image that the
renderer could not decode, the renderer drew nothing for it, and the
files lacked the image with no message: the reply had no warnings at
all. A raster export of the same art warns that "an image couldn't be
decoded and is missing from the picture".

document.exportForWeb and document.exportForWeb.preview now compare
decode_failures before and after they render, as raster exports do,
and their replies carry that text in `warnings`. The files are still
written. Save, Download and Preview in Browser in the Save for Web
dialog show the first warning in the status bar as Save As shows a
save's notes.

Docs: docs/mcp.md (Save for Web), docs/control-protocol.md.
2026-10-18 01:32:01 +07:00
dotCooCoo 598f1de3cc Character and Paragraph Styles: a new style's name is trimmed as a rename trims it
charStyle.new and paraStyle.new kept the name they were given as it
was, while charStyle.rename and paraStyle.rename trim the white space
around the new name. So a script could make a style " Body" beside
"Body", and a blank name was refused with the message for a name that
is taken.

New Character Style and New Paragraph Style now trim the name as rename
does, compare the trimmed name with the styles there are, and refuse a
name that is then empty with rename's message. Duplicate names the copy
from the trimmed name. symbol.new and symbol.duplicate trim the name as
symbol.options trims newName, and a blank newName gives "<name> copy"
instead of a symbol named with the blank. library.use adds a library's
text style under its trimmed name, so a library file whose style is
named with spaces around it still applies.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:32:01 +07:00
dotCooCoo 3fe8aa7db9 Brushes, patterns and artboards: new and renamed items have their names trimmed
brush.new, brush.duplicate, object.pattern.make,
object.pattern.saveCopy, artboard.new and artboard.setProps kept the
name they were given as it was, and brush.options and pattern.options
kept the white space around a new name. So a script could make a brush
" Nib" beside "Nib", and a blank name made a brush, a pattern or an
artboard named with the blank. Artboard Options sends its Name field
as typed, so clearing it named the artboard "". The Brush Options
dialog, the Pattern Options panel and the Artboards panel trim the
names they send.

The eight commands now trim the name, and the brush and pattern
commands compare the trimmed name with the names there are. In
brush.new, brush.duplicate, object.pattern.make,
object.pattern.saveCopy and artboard.new, a blank name gives the name
the command gives when there is none: "New Calligraphic Brush" and the
other default brush names, "<name> copy", "New Pattern N",
"<pattern> copy" and "Artboard N". brush.options and pattern.options
keep the name when the new one is blank, and artboard.setProps gives a
blank one the artboard's default name, Artboard N for the Nth
artboard. Duplicate Brush and Save a Copy name the copy of a brush or
pattern whose saved name has white space around it from the trimmed
name (" Old " gives "Old copy").

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
bflatastic 015bab68a1 Undo/Redo: the menu names the step with the History panel's own function 2026-10-18 01:27:14 +07:00
dotCooCoo 23dd9a1f2a Fix #2054: bounding-box resize handles snap to the grid, pixels, points and ruler guides
A bounding-box handle dragged with the Selection or Free Transform tool
snapped only with Smart Guides on, and then only to the other objects
and the artboards. View > Snap to Grid, Snap to Pixel, Snap to Point
with Smart Guides off and the ruler guides did not move it, while a
move snaps to all of them.

The corner or side a handle moves now snaps as a dragged point does.
With Snap to Grid on it lines up with the nearest gridline, and with
Snap to Pixel on it lines up with whole pixels. With Smart Guides off,
Snap to Point lands it on the other objects' anchors and on ruler
guides within its distance. With Smart Guides and Snap to Point both
on, it lines up with the ruler guides as well as with the other
objects. Shift still keeps the proportions. Without Alt the opposite
side stays put, and with Alt the box still scales about its center.

Docs: docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 20590e778c Linux Display Server: XWayland when a pen is attached, where a drawing tablet's pen draws
On a Wayland session a drawing tablet's pen could not draw or click:
winit 0.30 does not bind the Wayland tablet protocol, and neither KWin
(since Plasma 6.3) nor GNOME's Mutter turns tablet input into pointer
input. Linux Display Server's X11 (XWayland) choice let the pen work as
a mouse, but it opened the window through XWayland on every start, also
without a tablet.

Linux Display Server has a third choice, X11 (XWayland) When a Pen Is
Attached (x11WithPen). It opens the window through XWayland on a
Wayland session only when /proc/bus/input/devices lists a pen as the
app starts: a device with the BTN_TOOL_PEN key and the ABS_X and ABS_Y
axes, the check PhotoCraft's tablet.rs makes. Automatic stays the
default. On Linux the Performance page shows a note for tablet pens
that don't draw.

README.md named KDE alone; the KWin commit's message says Mutter does
not emulate tablet events either, so README.md and docs/development.md
now name GNOME. The Pen pressure row of docs/hardware-parity.md said
Linux has pressure through XWayland; winit 0.30 reports none on X11
either.

Docs: README.md, docs/development.md, docs/hardware-parity.md,
docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 04e784a04f Tests: five tests that wait on another thread pass on a loaded machine
On a machine busy with other builds, five tests on main failed now and
then and passed when run alone. The canvas test for dragging an object
with raster effects gave the render worker 10 s for its frame, which
took 0.10 s alone and 5.8 to 9.4 s with four ui-egui test runs at
once. The three tests_sysclip_probe tests gave the clipboard probe's
thread 2 or 3 s. The control server's persistent-requests test sent
each request with writeln!, which sends the JSON to the socket in many
small writes, and a pause of more than the test's 120 ms line deadline
between two of them closed the connection.

The canvas test now waits up to 60 s for the worker, as another test in
canvas.rs does. The probe tests wait up to 60 s too. The control server
test sends each request line in one write and keeps its 120 ms line
deadline. Only test code changes.
2026-10-18 01:27:14 +07:00
dotCooCoo 1de20a2972 Tabs: Clear All Tabs, Delete Tab and Repeat Tab can be chosen from the panel menu
The Tabs panel menu passed false as each item's enabled argument and
its condition as the checked argument, so Clear All Tabs, Delete Tab
and Repeat Tab were always dimmed and, with a stop selected, showed a
check mark.

The three items are now enabled while the ruler shows tab stops and
show no check mark. Delete Tab and Repeat Tab act on the stop the ruler
shows selected. After Delete Tab removes the last stop, the ruler shows
the stop before it selected, and the next Delete Tab removes that one.
Clear All Tabs runs text.tabs.clear on the paragraphs the panel acts
on, so its undo step is named Clear All Tabs.

Docs: docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 35d70f5531 i18n: Edit › Undo and Redo name the step as the History panel does
Edit › Undo and Edit › Redo translated the step's name with its plain
catalog row only. Nine of the 22 catalogs have no plain row for
Feather, only the effect's menu row with an ellipsis, so in Japanese
Edit › Undo read "Featherを取り消す" after Feather while the History
panel listed the step as ぼかし.

The menu now names the step with panels::history::step_name, as the
History panel does, and keeps the name as recorded when the catalog
has no row for it. The in-window and native menus both show the new
label.

Docs: docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 2ccb147403 Variables: Import Data Sets… defines the variables a file's header names
dataset.import refused every header cell that wasn't the name of a
variable the document defines. A data source file could only fill
variables made beforehand, and `#` and `@` in a header were read as part
of a variable's name.

A header cell that names no variable now defines a new, unbound
variable in the import's undo step. `#Shown` defines a visibility
variable `Shown`, `@Photo` defines an image variable `Photo`, and a
cell without a prefix defines a text variable. A prefixed cell whose
name is already a variable of the prefix's kind names it. A cell that
is a variable's exact name keeps naming it, even when the name starts
with `#`, `@` or `%`, so files that dataset.export writes import back
as before. The import fails, naming the column, on a prefixed cell
whose name is a variable of another kind and on a `%` cell that names
no variable (graph data files). While the variables are locked, a
header that would define a variable fails the import with "variables
are locked", as New Variable does. The reply's `defined` lists the new
variables.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:27:13 +07:00
dotCooCoo a12f5b0f20 Export: Save for Office, CSS and Copy warn when an image couldn't be decoded or a font is missing
Save for Office Documents, the pictures that css.export rasterizes and
the PNG and PDF that Copy offers other apps were encoded with
fileio::encode, which drops the encoder's warnings. When the art held
an image that the renderer could not decode, the PNGs lacked it; type
in a font that is not installed came out in the fallback font in the
Office PNG and Copy's PNG and PDF. No reply or status message said so.
The app also dropped the warnings that document.exportSelection
returns.

document.exportForOffice, clipboard.exportPng and clipboard.exportPdf
now return `warnings`, and css.export adds the notes of its pictures to
its `warnings`, each once. Save for Office Documents and
document.exportSelection show the first note in the status bar in Save
As's message, and the CSS Properties panel's Export Selected CSS… and
Export All… show it in Export As's message. After Copy or Cut, once
the PNG and PDF are on the system clipboard, the status bar shows their
first note. The files are written as before.

Docs: docs/mcp.md (Clipboard, CSS Properties), docs/control-protocol.md.
2026-10-18 01:27:13 +07:00
bflatastic efb49169a3 Export for Screens: rustfmt the import list (#2737) 2026-10-18 01:27:13 +07:00
dotCooCoo 6b13dd776b Export: names typed into file names follow Windows' naming rules
Export for Screens, Batch over data sets, Save for Web and the
Libraries panel name files after text the person typed: a prefix, a
suffix, a name pattern, the name of an artboard, asset, layer, slice,
data set or library, or the document's title. The prefix, the suffix,
name patterns and Batch's data set names had only /, \ and : replaced,
so on Windows a name holding <, >, ", |, ?, * or a control character
failed to save, and Save for Web without a path named its files after
the document's title as it was. A row folder name could end in a
period, which Windows does not allow, and names such as CON, NUL, COM1
or LPT1, which Windows reserves for devices, were written as they
were.

file_part, which Export for Screens' prefix and suffix and Batch's
data set names use, now writes all of those characters as -, and name
patterns use it in place of their own copy. A new safe_file_name runs
a whole file or folder name through file_part, removes trailing
periods and spaces, and adds - after a device name that is the whole
name or comes before its first period (CON.png becomes CON-.png).
Export for Screens uses it for each file, folder and zip it names, and
keeps item names distinct after it. Save for Web uses it for slice
images and, without a path, for files named after the title. Library
files use it too. This happens on every platform. Other names are
written as before, except where a changed name now equals another
item's name, which then gets a number added.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:27:13 +07:00
dotCooCoo 26af00d108 Effects: vectorcraft-effects passes clippy on Rust 1.95, the minimum toolchain
With Rust 1.95, the workspace's rust-version, cargo clippy --workspace
--all-targets -- -D warnings stopped at vectorcraft-effects: clippy 1.95
reports nonminimal_bool on the parameter and pixel-size guards of
Cutout, Poster Edges and Neon Glow, and on the size check in
Rasterize::compose (Effect › Rasterize). Clippy 1.99 does not report
them.

Each check now has the form clippy suggests, one !(a && b && ...) in
place of !a || !(b && c) (!a || !b || !(c && d) in compose). The
operands and their order are unchanged, so the filters and compose
return early in the same cases as before.
New tests check that the three filters leave the art unchanged for a
pixel size of 0, -1, NaN or infinity and for non-finite parameters,
and that compose leaves the raster unchanged when a buffer's length
does not match its size or the image's area has no positive width or
height.
2026-10-18 01:27:13 +07:00
bflatastic 6e29afe648 Render: the drag budget for shadows returns None, as the cached-shadow path does since #2395 (#2745) 2026-10-18 01:27:13 +07:00
dotCooCoo 919970a467 Export for Screens: exports warn when an image couldn't be decoded or a font is missing
Export for Screens wrote each file with fileio::encode, which drops the
encoder's warnings, and its replies had no `warnings` key. When the art
held an image that the renderer could not decode, or type in a font
that is not installed, the files lacked the image or used the fallback
font, and neither the reply nor the status bar said so. document.export
returns the same notes in `warnings`, and Export As shows the first in
the status bar.

document.exportForScreens and assets.export now collect the warnings of
every file they export, each once, and every reply carries them in
`warnings`. The export still writes or returns every file. When there
are warnings, the Export for Screens dialog and the Asset Export panel
show the first in the status bar as Save As shows a save's notes.

Docs: docs/mcp.md (Export for Screens), docs/control-protocol.md.
2026-10-18 01:27:13 +07:00
dotCooCoo 7f858a7e59 Actions: Batch's Override Save and Export name files with the format's extension
With Override Save or Override Export on, action.batch named each
step's file in the destination folder with the step's format exactly as
written. The save and export commands read the format in any case and
with a leading dot, so a step with "format": ".svg" wrote
"Untitled_a..svg", "SVG" wrote ".SVG", "png8" wrote ".png8" and
"template" wrote ".template".

The file now ends in the format's extension: the extension as written,
in lower case and without the dot, when it is one of the format's
(".JPEG" gives ".jpeg"), and otherwise the format's first extension, the
one Export and Save use for a file name ("png8" gives ".png", "template"
gives ".vctemplate"). An unknown format ("xyz") keeps its spelling, and
the step fails with the command's error as before.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo dc48ce6c74 .ai import: the warning that the layers differ from the page gives the art's share or names the type
When a .ai or EPS opens from its editing data and its layers draw a
little differently from the file's own page, the warning gave the share
of all the pixels that differ, type included, and blamed brush strokes
and live effects. Type that the import lays out again from the file's
text, or draws in another font, raised it on its own: a line that the
page sets in pieces gave "about 1.5% of it (brush strokes, live effects
and the like ...)" with no art differing.

The comparison now also counts the pixels that differ away from type.
The warning gives that share, and when the art away from type does not
differ by more than the warning's limit, the warning names the type
instead. The warning appears in the same cases as before, and the
limits that make the file come in as its page are unchanged.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo ceddd8185f Artboards: Ctrl/Cmd+A in the Artboards panel selects every artboard with the Artboard tool
With the Artboard tool in use, Ctrl/Cmd+A after a click on a row of the
Artboards panel selected all the art. The panel now takes the keyboard
when one of its rows is clicked, as the Layers panel does for Delete,
and gives it up when the pointer is pressed outside its list or the
panel is not drawn.
While it has the keyboard and the Artboard tool is in use, Select All's
keys, and Select All in the macOS menu bar, select every artboard: the
active artboard stays active and the art's selection does not change.
Otherwise Select All still selects the art.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo aeb9314e2c Part of #2558: a drag draws zoomed-in raster effects at a lower resolution until it ends
When zoomed in, an object with a texture, a blur or a shadow was slow
to drag. Filtered content whose on-screen area passes the 8.4 million
pixel raster budget keeps no raster, and a shadow or glow larger than
4 million pixels keeps none either, so every frame of the drag ran the
effects again over up to 8.4 million pixels. A drag that scales or
rotates an object runs them again at any zoom.

The canvas now renders a tool's drag with RenderOptions::dragging. In
such a frame the renderer draws filtered content within 1,000,000
pixels, at a lower resolution where it would need more, unless it can
draw the content from an earlier frame's raster. On the multithreaded
pipeline it does the same for drop shadows, outer glows and inner
glows. No raster made that way is kept. They count as moved rasters,
so a raster made over them is made again after the drag, and the
desktop canvas renders the frame after the drag on the render worker.
That frame renders them as a still frame does. A dialog's preview,
exports and printing render as before.

zoomed_drag_perf times a drag's frames at 100% to 6400% with and
without dragging.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo 85fce6852e History: the panel menu has Step Backward, Step Forward and Set History Limit…
The History panel menu held only a disabled History Options… item. The
reference app's History panel menu has Step Backward and Step Forward,
which undo and redo a change, and Set History Limit, which sets the
number of History States.

The panel menu now has Step Backward and Step Forward. They run
edit.undo and edit.redo, and each is disabled when there is no step to
undo or redo, as Edit › Undo and Edit › Redo are. Set History Limit…
opens Preferences at Performance, where History States is set.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:34 +07:00
dotCooCoo 6a2bb03ebf PDF presets: a rename that can't be written keeps the preset under its old name
Renaming a saved PDF preset (pdf.preset.save with newName) removed
the old file from the PDF Presets folder before it wrote the new one.
When that write failed, for example on a full disk, the folder held no
file for the preset, and the preset was gone at the next start.

The store now writes the new file first and removes the old one only
when that write succeeded. A failed write leaves the preset in the
folder under its old name. A rename that changes only the case of the
name writes the old file and then renames it, and the old file gets
its content back when the rename fails. The command still succeeds
when the folder can't be written, and the failure is logged, as
before.
2026-10-18 01:24:34 +07:00
bflatastic a209996da2 Docs: file-format-parity counts Word .docx among the formats placed (#2766) 2026-10-18 01:24:34 +07:00
dotCooCoo 1c4ead3703 Place: Word .docx files place as area type with their formatting
File > Place could not place a Word document: a .docx file was "not a
format VectorCraft reads". The reference app's File > Place reads
Microsoft Word files as text.

File > Place now reads .docx files, written from ECMA-376 Part 1
(WordprocessingML, Transitional and Strict), and sets their text as
area type the way a text or RTF file is placed. The package is read
through its relationships. The formatting comes from the document
defaults, the paragraph style (else the default paragraph style), the
character style (else the default character style), the styles each
is based on, and direct formatting. Bold, italic, caps, small caps,
strikethrough and hidden text are toggle properties: a style that
turns one on reverses the state the styles before it give.

Each run keeps its font (theme fonts from the theme part), size, bold
and italic, underline, strikethrough, all caps, small caps,
superscript or subscript (in Document Setup's proportions), raised or
lowered text and color. Each paragraph keeps its alignment, start,
end, first-line and hanging indents, space before and after, and
direction; the start indent of a right-to-left paragraph is its right
indent. Table cells are separated by tabs and each table row is a
paragraph. Text in insertions, hyperlinks, content controls and
fields' results is read. Hidden and deleted text, field instructions,
pictures, text boxes, headers, footers, notes and list numbers are
left out.

A ZIP archive named .docx, or one that holds word/document.xml, is
read as a Word document. fileio/zip.rs gains a reader for stored and
deflated files that checks each file's CRC. A package with more than
10,000 files, a part that unpacks to more than 16 MiB or nests
elements more than 256 deep, and a document with more than 4 MiB of
text or 100,000 paragraphs or runs are refused with an error. The
Place dialog lists Word files, and file.place, file.place.info and
file.place.queue report the format `docx`. .doc files are still not
read.

The Word document tests in tests_textimport.rs fail without the
change, and text_import_fuzz.rs places random and damaged packages.

Docs: docs/file-format-parity.md, docs/mcp.md,
docs/target-app-parity.md.
2026-10-18 01:21:31 +07:00
dotCooCoo dd466d79e4 TIFF: CCITT Group 3 compressed files open and place
1-bit TIFFs compressed with CCITT Group 3 did not open, place or
relink: the image decoder reads Group 4 only and refuses Compression 2
(modified Huffman) and Compression 3 (ITU-T T.4).

They are now decoded with hayro-ccitt, the CCITT decoder that EPS
import uses. Compression 2 rows start at a byte and have no EOL codes.
Compression 3 rows follow an EOL code and are one-dimensional, or, with
T4Options bit 0, one- or two-dimensional as each row's tag bit says.
Fill bits, FillOrder 2, either PhotometricInterpretation, strips and
tiles are read.

A file whose pixels would take more than the decoding allowance as
RGBA is refused as too large to draw. A strip or tile whose data ends
before its last row is refused as damaged, and so is a tile larger
than the allowance. The strips and tiles together are read from at
most as many bytes as the file holds.

Part of #1466.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
2026-10-18 01:21:30 +07:00
dotCooCoo 8fbd515d6c Shaper: a marquee does not select a Shaper Group by the original shape of an erased face
A Shaper Group keeps its original shapes as a transparent first child.
Clicks skip them outside Construction mode, but a marquee did not: one
drawn over the outline of an original shape whose face the group had
erased selected the group, where nothing is shown. Group Selection's E
marquee selected an original shape that lay inside it, and Direct
Selection's marquee and clicks selected the anchor points of the
original shapes.

The marquee now skips a Shaper Group's original shapes with the same
check clicks use, which moves into shaper_hides, and Direct Selection's
walk over the art it can pick skips them too. In Construction mode, with
the original shapes isolated, the marquee still selects them and Direct
Selection still selects their anchor points.

Docs: docs/shaper.md, docs/target-app-parity.md.
2026-10-18 01:21:28 +07:00
dotCooCoo c44fb242aa Type: Optical Margin Alignment is a setting of the whole thread
Optical Margin Alignment was stored and set per text object, so the
frames of one thread could differ. Turning it on in the first frame of
a thread left the lines in the other frames flush with their margins.

Type › Optical Margin Alignment and type.opticalMarginAlignment now set
every frame of a selected frame's thread, in one undo step, and the
reply's ids list them all. After any edit to a thread, its frames take
the setting of the frame that the edit turned on or off, else the
first frame's. Threading frames gives the thread its first frame's
setting, and frames taken out of a thread keep it. A file whose frames
of one thread differ opens with the first frame's setting in all of
them. This holds for native files and for documents restored from the
editing copy that an EPS, SVG, PDF or .ai file carries.

Threading text from an out port into existing area type
(text.thread.add with to) now flows the story into that frame at once.
Before, the story reached it only at the next edit of one of the
thread's frames.

Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
2026-10-18 01:21:27 +07:00
dotCooCoo e5945c6795 Type: sizes, scales, leading, indents and spacing out of range in a file are read in range
A .vectorcraft file's type opened with the character and paragraph
values it held. With a size of 0, the characters were not drawn. With
a size, leading, horizontal scale, left indent or space before of
1.797e308, point type's bounds were infinite or over 1e308 wide or
high, and moving or scaling it failed with "result would exceed the
canvas".

These values are now read into range when a file opens, in the type of
the layers, symbols, graph designs, patterns and the pattern definition
that Cancel puts back in pattern editing mode, and in character and
paragraph styles. A size, horizontal scale or vertical scale of 0 or
less becomes 12 pt or 100%, and a negative leading becomes Auto. A
size, leading or scale past 4,000,000 (MAX_COORD) becomes 4,000,000,
and a baseline shift, indent or space before or after past 4,000,000
either way becomes 4,000,000 or -4,000,000. A tab stop is kept to the
0 to 100,000 pt that text.tabs.set takes. A leading of 0, which legacy
.ai import stores, and the sizes, scales and indents within these
limits that SVG, PDF and .ai import keep outside the panels' ranges
open as saved.

Docs: docs/mcp.md (Paragraph attributes per paragraph).
2026-10-18 01:21:25 +07:00
dotCooCoo 81574b1648 Type: superscript, subscript and small caps proportions out of range in a file are read in range
document.setup accepts a superscript and subscript size of 1 to 500%
and a position of -500 to 500%, and a small caps size of 1 to 200%.
text.setFormat copies these proportions into the characters it
formats. A .vectorcraft file's characters, character styles and graph
label styles opened with the proportions they held. With a superscript
size of 0, the superscript characters were not drawn, and with a
superscript or small caps size of 1.797e308, moving the type failed
with "result would exceed the canvas".

These proportions are now read as the nearer end of each range when a
file opens: in the type of the layers, symbols, graph designs, patterns
and the pattern definition that Cancel puts back in pattern editing
mode, in graph label styles and in character styles. Proportions in
range and the other character attributes open as saved.

Docs: docs/mcp.md (Small Caps, Superscript and Subscript).
2026-10-18 01:21:24 +07:00
skylar taylor-barrick d44dc983e6 Merge storytold/main into open-original
Conflicts were 17 i18n .tsv files where both sides appended new strings;
resolved as a union, verified to introduce no duplicate keys.
2026-10-17 12:15:10 -06:00
VictorandClaude Opus 5.5 f7382b2844 G1: Contextual Task Bar: Join, Edit Path, Release and type conversion by selection
The task bar offers more next steps for each kind of selection:
- an open path: Join (closes it) and Edit Path (the Direct Selection
  tool); a closed path: Edit Path;
- a compound path: Edit Path and Release Compound Path;
- a clipping group: Edit Clipping Path and Release Clipping Mask
  instead of Ungroup and Isolate;
- an image selected with vector art: Make Clipping Mask instead of Unite;
- point or area type: Convert To Area Type / Convert To Point Type
  beside Create Outlines.

Buttons may now carry command parameters. "Edit Path" is translated in
every catalog; the other labels reuse the menu's. cargo xtask ci passes
all 9 steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 01:11:39 +07:00
skylar taylor-barrick 39f9204d57 Build: take craft-docpath from the merged craft-libs rev
craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to
storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07,
which predates resolve_absolute and the Windows colon refusal.
2026-10-17 12:03:53 -06:00
aiongg 82e363c73b Build: use craft-docpath from craft-libs 2026-10-17 02:20:38 +00:00
aiongg b05043c759 i18n: translate the new link and file-name messages 2026-10-16 21:00:40 +00:00
aiongg ef5084f48b Links: follow linked file paths only on this computer or the document's share
Paths stored with linked images and placed documents, and the hrefs of
SVG images, go through fileio::document_path (craft-docpath): a relative
path is taken from the document's folder, and only local files or files
on the document's own network share are looked at. Opening a document,
Link Info, Package, the linked-file watch and SVG import all use it; the
watch stats files through fileio, so automation roots apply to it too.

A path that is not followed counts as missing; document.open,
links.check and links.info say why in `refused`.
2026-10-16 17:55:48 +00:00
aiongg 1ddfb56f66 Attributes: the Browser button opens http, https and mailto URLs only
attributes.openUrl refuses other URL schemes with a message. It and
links.editOriginal hand a URL or file to another app, so they answer a
control or MCP request (and synthetic input) with an error; a placed
document still opens in a tab.
2026-10-16 16:52:19 +00:00
aiongg b46ffd0059 Links: Edit Original opens only regular files of a format VectorCraft reads
A folder or app bundle, a link to another kind of file, or a file with
any other extension is refused with a message instead of being handed to
the system's default app. On Windows, a path containing a quotation mark
is refused before it reaches Explorer.
2026-10-16 16:39:46 +00:00
136 changed files with 6150 additions and 474 deletions

No files matched your search

Generated
+38 -28
View File
@@ -806,6 +806,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-pdf"
version = "0.1.0"
@@ -4507,7 +4512,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4542,7 +4547,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"crc32fast",
"flate2",
@@ -4553,7 +4558,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4565,7 +4570,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4581,7 +4586,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4603,7 +4608,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4615,8 +4620,9 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"hayro-ccitt",
"image",
"jpeg-encoder",
"kurbo 0.13.1",
@@ -4632,7 +4638,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4648,14 +4654,16 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"craft-docpath",
"crc32fast",
"flate2",
"image",
"kurbo 0.13.1",
"log",
"proptest",
"roxmltree 0.21.1",
"ruzstd",
"serde",
"serde_json",
@@ -4684,7 +4692,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"fax",
"flate2",
@@ -4707,7 +4715,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"flate2",
"proptest",
@@ -4722,7 +4730,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4734,7 +4742,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"log",
@@ -4753,7 +4761,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4767,7 +4775,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4779,7 +4787,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"aes",
"craft-pdf",
@@ -4809,7 +4817,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"serde",
"serde_json",
@@ -4823,7 +4831,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"flate2",
"gif",
@@ -4850,15 +4858,16 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"craft-docpath",
"flate2",
"image",
"kurbo 0.13.1",
@@ -4881,8 +4890,9 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"fax",
"image",
"kurbo 0.13.1",
"proptest",
@@ -4898,7 +4908,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4918,7 +4928,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -4926,7 +4936,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4942,7 +4952,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4954,7 +4964,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"craft-ui",
"egui",
@@ -4988,7 +4998,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"eframe",
"egui",
@@ -6098,7 +6108,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"serde_json",
]
+2 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.12.0"
version = "0.13.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -13,6 +13,7 @@ repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
+4 -2
View File
@@ -194,8 +194,10 @@ drag-and-drop, set **Preferences › Performance › Linux Display Server** to *
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
respond to it yet (#491). Open the window through XWayland instead, with the same preference or `WAYLAND_DISPLAY= vectorcraft` (see
On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft doesn't respond to a drawing tablet's
pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse: set the same
preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use XWayland only when a pen is
attached as VectorCraft starts, and restart; or start the app with `WAYLAND_DISPLAY= vectorcraft` (see
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
### Use it from Claude Code and other agents
+2 -1
View File
@@ -9,7 +9,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (about 40 community PRs of 2026-10-18: Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -134,6 +134,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-18 | About 40 community PRs landed in one batch through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
+4 -1
View File
@@ -383,7 +383,10 @@ mod tests {
});
let mut reader = BufReader::new(client.try_clone().unwrap());
for id in [1, 2] {
writeln!(client, "{}", json!({"id": id, "method": "inspect"})).unwrap();
// The client sends each line in one write. `writeln!` on the socket sends the JSON in many
// small writes, and when the machine is busy, a pause of more than the 120 ms line deadline
// between two of them closes the connection.
client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), json!({"id": id, "ok": true}));
+16 -3
View File
@@ -389,6 +389,7 @@ fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
fn reveal(path: &str) -> Result<(), String> {
explorer_safe(path)?;
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
}
@@ -559,8 +560,15 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
}
}
/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which
/// no Windows file name has) is refused rather than split into other arguments.
fn explorer_safe(path: &str) -> Result<(), String> {
if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) }
}
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
fn open_file(path: &str) -> Result<(), String> {
explorer_safe(path)?;
#[cfg(windows)]
let mut c = {
use std::os::windows::process::CommandExt as _;
@@ -788,14 +796,19 @@ fn main() -> std::process::ExitCode {
let custom_titlebar = custom_titlebar(saved.as_ref());
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151).
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
// and X11 (XWayland) When a Pen Is Attached does so when a pen is attached, where the pen draws (#2028).
#[cfg(target_os = "linux")]
let options = {
let mut options = options;
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
if xwayland::wanted(pref, |name| std::env::var_os(name)) {
if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) {
if xwayland::x11_libraries_found() {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
if pref == Some("x11WithPen") {
log::info!("pen found: opening the window through XWayland (Preferences › Performance › Linux Display Server)");
} else {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
}
use winit::platform::x11::EventLoopBuilderExtX11 as _;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_x11();
+129 -17
View File
@@ -1,11 +1,15 @@
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
//! #1151). Only when XWayland can run the window: an X display is set and the X11 libraries winit
//! loads at runtime are installed. Otherwise the window opens on Wayland, so a missing library
//! never keeps the app from starting.
//! #1151). X11 (XWayland) When a Pen Is Attached does so only when a pen is attached as the app
//! starts: winit 0.30 doesn't bind the Wayland tablet protocol, and XWayland hands the pen to X11
//! windows as a pointer (#2028). Only when XWayland can run the window: an X display is set and
//! the X11 libraries winit loads at runtime are installed. Otherwise the window opens on Wayland,
//! so a missing library never keeps the app from starting.
//!
//! The library check comes from PhotoCraft's `linux_libs.rs`: no `dlopen` (that needs `unsafe`),
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories.
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories. The pen
//! check comes from PhotoCraft's `tablet.rs`: the input devices the kernel lists in
//! `/proc/bus/input/devices`.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::ffi::OsString;
@@ -16,12 +20,50 @@ use std::path::{Path, PathBuf};
const X11_LIBS: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, a
/// Wayland session (winit picks Wayland whenever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set)
/// and an X display to open.
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>) -> bool {
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, or
/// `x11WithPen` chosen and `pen` finding a pen; a Wayland session (winit picks Wayland whenever
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only for
/// `x11WithPen`, after the session checks.
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>, pen: impl FnOnce() -> bool) -> bool {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
pref == Some("x11") && (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY")
let session = (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY");
match pref {
Some("x11") => session,
Some("x11WithPen") => session && pen(),
_ => false,
}
}
/// `BTN_TOOL_PEN` (linux/input-event-codes.h): the key that a pen tablet's stylus, a pen display's
/// pen or a pen-enabled touchscreen's pen reports.
const BTN_TOOL_PEN: u32 = 0x140;
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
const ABS_X: u32 = 0x00;
const ABS_Y: u32 = 0x01;
/// Is a pen attached? Reads `/proc/bus/input/devices`; a file that can't be read has no pen.
pub fn pen_attached() -> bool {
std::fs::read_to_string("/proc/bus/input/devices").is_ok_and(|devices| has_pen(&devices))
}
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` and whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`, as
/// libinput asks of a tablet tool. A virtual keyboard made with uinput can claim every key code,
/// `BTN_TOOL_PEN` included, but has no absolute axes.
fn has_pen(devices: &str) -> bool {
devices.split("\n\n").any(|device| {
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN))
&& bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
})
}
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
/// significant first. Short or malformed bitmaps have no bits set.
fn bit_set(bitmap: &str, bit: u32) -> bool {
let bits = usize::BITS;
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
}
/// Are all the libraries an X11 window needs found on this system? Libraries the check can't find
@@ -98,18 +140,88 @@ mod tests {
move |k| pairs.iter().find(|(name, _)| *name == k).map(|(_, v)| OsString::from(v))
}
fn no_probe() -> bool {
panic!("the pen check ran")
}
#[test]
fn xwayland_only_when_chosen_on_wayland_with_an_x_display() {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11"), &wayland));
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")])));
assert!(!wanted(Some("automatic"), &wayland));
assert!(!wanted(None, &wayland));
assert!(!wanted(Some("wayland"), &wayland));
assert!(wanted(Some("x11"), &wayland, no_probe));
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("automatic"), &wayland, no_probe));
assert!(!wanted(None, &wayland, no_probe));
assert!(!wanted(Some("wayland"), &wayland, no_probe));
// An X11 session already opens on X11; without an X display there is no XWayland to use.
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")])));
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
}
/// X11 (XWayland) When a Pen Is Attached (#2028): XWayland on a Wayland session with an X
/// display when a pen is found, the session's own display server otherwise. The pen check
/// runs only on such a session.
#[test]
fn the_pen_choice_opens_xwayland_only_with_a_pen() {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11WithPen"), &wayland, || true));
assert!(wanted(Some("x11WithPen"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true));
assert!(!wanted(Some("x11WithPen"), &wayland, || false));
assert!(!wanted(Some("x11WithPen"), env(&[("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
}
/// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the
/// pen's `KEY=1c03 0 0 0 0 0` has bit 0x140, `BTN_TOOL_PEN`), from PhotoCraft's tests.
const WITH_PEN: &str = "I: Bus=0011 Vendor=0001 Product=0001 Version=ab83\n\
N: Name=\"AT Translated Set 2 keyboard\"\n\
H: Handlers=sysrq kbd event3 leds\n\
B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe\n\
\n\
I: Bus=0018 Vendor=06cb Product=ce26 Version=0100\n\
N: Name=\"SYNA2B33:00 06CB:CE26 Touchpad\"\n\
H: Handlers=mouse1 event7\n\
B: KEY=e520 10000 0 0 0 0\n\
B: ABS=2e0800000000003\n\
\n\
I: Bus=0003 Vendor=056a Product=0357 Version=0110\n\
N: Name=\"Wacom Intuos Pro M Pen\"\n\
H: Handlers=mouse2 event10\n\
B: KEY=1c03 0 0 0 0 0\n\
B: ABS=1000f000003\n";
/// A uinput virtual keyboard that claims every key code, `BTN_TOOL_PEN` included, and has no
/// absolute axes (`EV=20013`, no `B: ABS=` line), from PhotoCraft's tests.
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
N: Name=\"Virtual Keyboard\"\n\
H: Handlers=sysrq rfkill kbd event256 leds \n\
B: EV=20013\n\
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n";
#[test]
fn a_pen_is_a_device_with_btn_tool_pen_and_absolute_axes() {
assert!(has_pen(WITH_PEN));
let without = WITH_PEN.split("\n\n").take(2).collect::<Vec<_>>().join("\n\n");
assert!(!has_pen(&without), "a touchpad's BTN_TOOL_FINGER (0x145) is not a pen");
// A pen display reports the pen with its own axes.
assert!(has_pen("N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n"));
// Axes without ABS_X or ABS_Y, or no axes at all, are no pen.
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
assert!(!has_pen(&pen_without_axes));
// The virtual keyboard claims the key but is no pen, and a real pen beside it is still found.
assert!(VIRTUAL_KEYBOARD.lines().find_map(|l| l.strip_prefix("B: KEY=")).is_some_and(|k| bit_set(k, BTN_TOOL_PEN)));
assert!(!has_pen(VIRTUAL_KEYBOARD));
assert!(has_pen(&format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}")));
// Short or malformed bitmaps are no pen, never a panic.
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
assert!(!has_pen(&format!("{keys}\nB: ABS=3")), "{keys:?}");
}
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
}
}
#[test]
+2
View File
@@ -14,6 +14,8 @@ vectorcraft-color = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
tiff = { workspace = true }
# 1-bit TIFFs compressed with CCITT Group 3 (Compression 2 and 3).
hayro-ccitt = { workspace = true }
zune-jpeg = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+156
View File
@@ -349,6 +349,162 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
image::RgbaImage::from_raw(w, h, out)
}
/// Why a TIFF compressed with CCITT Group 3 wasn't read ([`group3_tiff_rgba`]).
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum Group3Error {
/// Its pixels would take more than [`crate::MAX_RASTER_ALLOC`] as RGBA.
#[error("an image of {w} × {h} pixels is too large to draw")]
TooLarge { w: u32, h: u32 },
/// A strip or tile ends before its last row or can't be decoded (it holds a code the decoder
/// doesn't read, such as T.4's uncompressed mode, or a row of the wrong width), or a tile is
/// larger than [`crate::MAX_RASTER_ALLOC`] allows as RGBA.
#[error("could not decode image: {0}")]
Damaged(hayro_ccitt::DecodeError),
}
/// The pixels of a 1-bit TIFF compressed with CCITT Group 3, which the image decoder doesn't read,
/// as RGBA. Compression 2 is modified Huffman coding with each row starting at a byte and no EOL
/// codes. Compression 3 is ITU-T T.4 coding with an EOL code before each row: one-dimensional rows,
/// or with T4Options bit 0, rows that a tag bit marks as one-dimensional or as coded against the
/// row above. Fill bits before an EOL are skipped. Rows in T.4's uncompressed mode (T4Options
/// bit 1) can't be decoded. Each strip or tile is coded on its own. White runs are 0 bits and black
/// runs are 1 bits, as libtiff writes them, and PhotometricInterpretation 0 shows 0 bits as white
/// and 1 shows them as black. FillOrder 2 (each byte's lowest bit first) is read too. Together, the
/// strips and tiles are read from at most as many bytes as the file has. `None` for other images;
/// an error when the pixels would take more than [`crate::MAX_RASTER_ALLOC`] as RGBA, a tile is
/// larger than that allowance, or a strip or tile can't be decoded or ends before its last row.
pub fn group3_tiff_rgba(bytes: &[u8]) -> Option<Result<image::RgbaImage, Group3Error>> {
use hayro_ccitt::{DecodeError, DecodeSettings, DecoderContext, EncodingMode};
use tiff::tags::Tag;
let (kind, mut d) = tiff(bytes)?;
let compression = d.find_tag_unsigned::<u16>(Tag::Compression).ok().flatten()?;
let (w, h) = d.dimensions().ok()?;
if kind != tiff::ColorType::Gray(1) || !matches!(compression, 2 | 3) || w == 0 || h == 0 {
return None;
}
// The level of a 0 bit (a white run): white when white is zero (0), black when black is zero (1).
let zero = match d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten() {
Some(0) => u8::MAX,
Some(1) => 0,
_ => return None,
};
if u64::from(w) * u64::from(h) > crate::MAX_RASTER_ALLOC / 4 {
return Some(Err(Group3Error::TooLarge { w, h }));
}
let t4_options = d.find_tag_unsigned::<u32>(Tag::Unknown(292)).ok().flatten().unwrap_or(0);
let lsb_first = d.find_tag_unsigned::<u16>(Tag::FillOrder).ok().flatten() == Some(2);
let encoding = if compression == 3 && t4_options & 1 != 0 {
// Each row's tag bit says how it is coded.
EncodingMode::Group3_2D { k: 2 }
} else {
EncodingMode::Group3_1D
};
// The width and height of each strip or tile, and the tags of their offsets and byte counts.
let tiled = d.get_chunk_type() == tiff::decoder::ChunkType::Tile;
let (cw, ch, offsets, counts) = if tiled {
let tw = d.find_tag_unsigned::<u32>(Tag::TileWidth).ok().flatten().filter(|&v| v > 0)?;
let th = d.find_tag_unsigned::<u32>(Tag::TileLength).ok().flatten().filter(|&v| v > 0)?;
(tw, th, Tag::TileOffsets, Tag::TileByteCounts)
} else {
let rows = d.find_tag_unsigned::<u32>(Tag::RowsPerStrip).ok().flatten().filter(|&v| v > 0).map_or(h, |r| r.min(h));
(w, rows, Tag::StripOffsets, Tag::StripByteCounts)
};
let damaged = |e: DecodeError| Some(Err(Group3Error::Damaged(e)));
// A tile is held to the allowance the image is held to; a strip is no larger than the image.
if u64::from(cw) * u64::from(ch) > crate::MAX_RASTER_ALLOC / 4 {
return damaged(DecodeError::Overflow);
}
let offsets = d.find_tag_unsigned_vec::<u64>(offsets).ok().flatten().unwrap_or_default();
let counts = d.find_tag_unsigned_vec::<u64>(counts).ok().flatten().unwrap_or_default();
let across = w.div_ceil(cw);
let chunks = u64::from(across) * u64::from(h.div_ceil(ch));
if chunks > offsets.len().min(counts.len()) as u64 {
return damaged(DecodeError::UnexpectedEof);
}
let mut gray = vec![zero; usize::try_from(u64::from(w) * u64::from(h)).ok()?];
let mut budget = bytes.len();
for (i, (&offset, &count)) in (0..chunks).zip(offsets.iter().zip(&counts)) {
let (x0, y0) = (u32::try_from(i % u64::from(across)).ok()? * cw, u32::try_from(i / u64::from(across)).ok()? * ch);
let Some(start) = usize::try_from(offset).ok().filter(|&s| s <= bytes.len()) else {
return damaged(DecodeError::UnexpectedEof);
};
let len = usize::try_from(count).unwrap_or(usize::MAX).min(bytes.len() - start).min(budget);
budget -= len;
let data = bytes.get(start..start + len)?;
let data: std::borrow::Cow<[u8]> = if lsb_first { data.iter().map(|b| b.reverse_bits()).collect() } else { data.into() };
let settings = DecodeSettings {
columns: cw,
// A tile is coded whole; the last strip only has the image's remaining rows.
rows: if tiled { ch } else { ch.min(h - y0) },
end_of_block: compression == 3,
end_of_line: compression == 3,
rows_are_byte_aligned: compression == 2,
encoding,
invert_black: false,
};
let mut rows = Rows { out: &mut gray, width: w, height: h, x0, y0, x: 0, y: 0, zero };
if let Err(e) = hayro_ccitt::decode(&data, &mut rows, &mut DecoderContext::new(settings)) {
return damaged(e);
}
// The data ended, or six EOL codes came, before the strip's or tile's last row.
if rows.y < settings.rows {
return damaged(DecodeError::UnexpectedEof);
}
}
let gray = image::GrayImage::from_raw(w, h, gray)?;
Some(Ok(image::DynamicImage::ImageLuma8(gray).to_rgba8()))
}
/// Where a strip or tile of a Group 3 TIFF ([`group3_tiff_rgba`]) puts its decoded pixels: `out`
/// holds the image's levels row by row, `width` × `height`, and the strip or tile starts at (`x0`,
/// `y0`). Pixels past the image's right or bottom edge (a tile's padding) are dropped.
struct Rows<'a> {
out: &'a mut [u8],
width: u32,
height: u32,
x0: u32,
y0: u32,
/// The next pixel in the strip or tile.
x: u64,
y: u32,
/// The level of a white run.
zero: u8,
}
impl Rows<'_> {
/// The next `n` pixels of the row, a white run's or a black run's.
fn run(&mut self, white: bool, n: u64) {
let level = if white { self.zero } else { u8::MAX - self.zero };
let row = u64::from(self.y0) + u64::from(self.y);
let left = u64::from(self.x0) + self.x;
let right = left.saturating_add(n).min(u64::from(self.width));
if row < u64::from(self.height) && left < right {
let at = row * u64::from(self.width);
if let (Ok(a), Ok(b)) = (usize::try_from(at + left), usize::try_from(at + right))
&& let Some(px) = self.out.get_mut(a..b)
{
px.fill(level);
}
}
self.x = self.x.saturating_add(n);
}
}
impl hayro_ccitt::Decoder for Rows<'_> {
fn push_pixel(&mut self, white: bool) {
self.run(white, 1);
}
fn push_pixel_chunk(&mut self, white: bool, chunk_count: u32) {
self.run(white, u64::from(chunk_count) * 8);
}
fn next_line(&mut self) {
self.y = self.y.saturating_add(1);
self.x = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
+67 -16
View File
@@ -311,8 +311,7 @@ fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
/// isn't shown, or when `p` is outside its reach.
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
if shaper_hides(parent, i, opt.scope) {
return false;
}
// Read-only samples can read locked/template art; hidden art never participates.
@@ -321,10 +320,22 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
}
// (An envelope's content sits where it was, not where the envelope draws it.)
edits_contents(c)
|| c.extras().shaper.is_some() && source_isolated(c)
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
}
/// Is the first child of `n` the isolated container `scope`?
fn source_of(n: &Node, scope: Option<NodeId>) -> bool {
n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == scope)
}
/// Do clicks and marquees skip the child `i` of `parent` with `scope` isolated? For a Shaper
/// Group that is its original shapes (its first child), or, in Construction mode, when they are
/// isolated, the art the group shows.
fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
}
fn hit_children(
parent: &Node,
p: Point,
@@ -493,7 +504,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
let tops: Vec<(&std::sync::Arc<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {
Some(s) => match (s.children(), marquee_in(s, r)) {
(Some(c), Some(r)) => c.iter().map(|c| (c, r)).collect(),
(Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
(Some(_), None) => vec![],
// A single object isolated: only it.
(None, _) => doc
@@ -529,13 +540,14 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}
out
}
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
/// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip
/// ([`shaper_hides`]).
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
marquee_in(n, r).is_some_and(|r| children.iter().any(|c| c.visible && touches(c, s, r)))
}
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r)),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => marquee_in(n, r)
.is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))),
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)),
// A box with no width or no height (an instance of a symbol that draws a straight
// line) is touched where the marquee crosses it.
_ => n.geometric_bounds_with(s).is_some_and(|b| {
@@ -544,18 +556,18 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}),
}
}
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, out: &mut Vec<NodeId>) {
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
let Some(r) = marquee_in(n, r) else { return };
for c in children {
if c.visible && !c.locked {
collect_leaves(c, s, r, out);
for (i, c) in children.iter().enumerate() {
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
collect_leaves(c, s, r, scope, out);
}
}
}
_ => {
if touches(n, s, r) {
if touches(n, s, r, scope) {
out.push(n.id);
}
}
@@ -567,8 +579,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
continue;
}
if leaves {
collect_leaves(t, &symbols, r, &mut out);
} else if touches(t, &symbols, r) {
collect_leaves(t, &symbols, r, scope, &mut out);
} else if touches(t, &symbols, r, scope) {
out.push(t.id);
}
}
@@ -771,6 +783,45 @@ mod tests {
}
}
/// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over
/// the original shape of a face the group erased it selects nothing, with Group Selection's
/// leaves too, and over a face it shows it takes the group. With the original shapes isolated
/// (Construction mode) it takes the original shape it reaches.
#[test]
fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// Two original squares, 0..100 and 50..150; the group shows only the first one's face.
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
let b_id = b.id;
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
sources.name = Some(crate::shaper::SOURCES.into());
sources.opacity = 0.0;
let sources_id = sources.id;
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
let face_id = face.id;
let g = d.alloc_id();
let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]);
group.extras_mut().shaper = Some(Box::default());
d.insert(Some(l), 0, group).unwrap();
// This marquee crosses only the right edge of the second square, whose face is erased.
let erased = Rect::new(140.0, 40.0, 160.0, 60.0);
assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses");
assert!(marquee(&d, erased, None, false).is_empty());
assert!(marquee(&d, erased, None, true).is_empty());
assert!(marquee(&d, erased, Some(g), false).is_empty());
// This marquee crosses the shown face's left edge.
let shown = Rect::new(-10.0, 40.0, 10.0, 60.0);
assert_eq!(marquee(&d, shown, None, false), vec![g]);
assert_eq!(marquee(&d, shown, None, true), vec![face_id]);
assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]);
// Construction mode: the original shapes are the objects.
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]);
}
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
+1 -1
View File
@@ -430,7 +430,7 @@ fn art_id() -> crate::NodeId {
}
/// Visit `n` and every descendant (opacity-mask art included) mutably.
fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
pub(crate) fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
f(n);
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
each_mut(Arc::make_mut(&mut m.art), f);
+185
View File
@@ -179,6 +179,18 @@ pub struct ScriptMetrics {
impl ScriptMetrics {
pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 };
/// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to
/// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside
/// its range becomes the nearer end.
pub fn in_range(self) -> Self {
Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) }
}
}
/// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for
/// `smallCapsSize`. A size outside it becomes the nearer end.
fn small_caps_in_range(size: f64) -> f64 {
size.clamp(1.0, 200.0)
}
impl Default for ScriptMetrics {
@@ -215,6 +227,14 @@ impl CharPosition {
CharPosition::Subscript(_) => "subscript",
}
}
/// Returns this position with its proportions kept by [`ScriptMetrics::in_range`].
pub fn in_range(self) -> Self {
match self {
CharPosition::Normal => CharPosition::Normal,
CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()),
CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()),
}
}
}
fn ten() -> f64 {
@@ -273,6 +293,20 @@ impl CharStyle {
pub fn effective_leading(&self) -> f64 {
self.leading.unwrap_or(self.size * 1.2)
}
/// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size
/// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading
/// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way)
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
/// baseline shift that is not a number becomes 0.
pub fn keep_dimensions_in_range(&mut self) {
let default = Self::default();
let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or };
self.size = within(self.size, default.size);
self.h_scale = within(self.h_scale, default.h_scale);
self.v_scale = within(self.v_scale, default.v_scale);
self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD));
self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) };
}
/// Do the characters draw a stroke (a paint and a positive weight)?
pub fn has_stroke(&self) -> bool {
!self.stroke.is_none() && self.stroke_width > 0.0
@@ -333,6 +367,60 @@ impl CharStyle {
pub fn basic_appearance(&self) -> Appearance {
Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() }
}
/// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and
/// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`.
pub fn keep_scripts_in_range(&mut self) {
self.position = self.position.in_range();
self.small_caps = self.small_caps.map(small_caps_in_range);
}
}
impl crate::Document {
/// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup`
/// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
/// back (opacity-mask art included), in graphs' label styles and in the character styles. It
/// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`].
pub fn keep_scripts_in_range(&mut self) {
let mut fix = |n: &mut crate::Node| {
if let crate::NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
r.style.keep_scripts_in_range();
}
}
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
let l = &mut g.label_styles;
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
st.keep_scripts_in_range();
}
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
}
// Character styles store the attributes they set by their serialized names.
for def in self.char_styles.iter_mut() {
if let Some(v) = def.attrs.get_mut("position")
&& let Ok(p) = serde_json::from_value::<CharPosition>(v.clone())
&& p.in_range() != p
{
*v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null);
}
if let Some(v) = def.attrs.get_mut("small_caps")
&& let Some(size) = v.as_f64()
&& small_caps_in_range(size) != size
{
*v = small_caps_in_range(size).into();
}
}
}
}
/// Tab stop alignment (Tabs panel).
@@ -464,6 +552,22 @@ pub struct ParaStyle {
pub roman_hanging: bool,
}
impl ParaStyle {
/// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to
/// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range
/// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is
/// not a number becomes 0.
pub fn keep_spacing_in_range(&mut self) {
let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) };
for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] {
*v = within(*v, -crate::MAX_COORD, crate::MAX_COORD);
}
for t in &mut self.tabs {
t.position = within(t.position, 0.0, 100_000.0);
}
}
}
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
/// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto
/// read the alignment and ignore the flag, and builds with it read Auto back.
@@ -1371,6 +1475,87 @@ impl TextObject {
});
self.cached_baselines.iter().copied().chain(first)
}
/// Keeps every run's size, leading, baseline shift and scales in range
/// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab
/// stops ([`ParaStyle::keep_spacing_in_range`]).
pub fn keep_type_values_in_range(&mut self) {
for r in &mut self.runs {
r.style.keep_dimensions_in_range();
}
for p in self.para_styles_mut() {
p.keep_spacing_in_range();
}
}
}
impl crate::Document {
/// Keeps the character dimensions, indents, paragraph spacing and tab stops in range
/// ([`TextObject::keep_type_values_in_range`]) in the type of the layers, symbols, graph
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
/// back (opacity-mask art included), and the same attributes of the character and paragraph
/// styles. It recurses through the art: call it on a document no deeper than
/// [`crate::depth::MAX_DEPTH`].
pub fn keep_type_values_in_range(&mut self) {
let mut fix = |n: &mut crate::Node| {
if let crate::NodeKind::Text(t) = &mut n.kind {
t.keep_type_values_in_range();
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
}
// Styles store the attributes they set by their serialized names (a number, or null for
// Auto leading); a number is rewritten only when it changes.
for def in self.char_styles.iter_mut() {
let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64);
let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() };
st.size = num("size").unwrap_or(st.size);
st.h_scale = num("h_scale").unwrap_or(st.h_scale);
st.v_scale = num("v_scale").unwrap_or(st.v_scale);
st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift);
st.keep_dimensions_in_range();
let kept = [
("size", Some(st.size)),
("h_scale", Some(st.h_scale)),
("v_scale", Some(st.v_scale)),
("baseline_shift", Some(st.baseline_shift)),
("leading", st.leading),
];
for (k, v) in kept {
if let Some(old) = def.attrs.get_mut(k)
&& old.is_number()
&& old.as_f64() != v
{
*old = v.into();
}
}
}
for def in self.para_styles.iter_mut() {
for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] {
if let Some(v) = def.attrs.get_mut(k)
&& let Some(x) = v.as_f64()
&& x.abs() > crate::MAX_COORD
{
*v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into();
}
}
for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() {
if let Some(v) = t.get_mut("position")
&& let Some(x) = v.as_f64()
&& !(0.0..=100_000.0).contains(&x)
{
*v = x.clamp(0.0, 100_000.0).into();
}
}
}
}
}
#[cfg(test)]
+3 -3
View File
@@ -165,7 +165,7 @@ fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
/// colours keep their hue. Slivers (the blends along softened edges) take the nearest large
/// piece's colour, so the pieces meet with crisp edges. Coverage is kept.
pub(super) fn cutout(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, levels: f64, simplicity: f64, fidelity: f64) {
if ![levels, simplicity, fidelity].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
if !([levels, simplicity, fidelity].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let levels = levels.round().clamp(2.0, 8.0) as usize;
@@ -212,7 +212,7 @@ pub(super) fn cutout(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
/// channel, with black lines drawn on the edges of any of its colour channels and of its outline. `thickness` (0..10) widens the lines and
/// `intensity` (0..10) darkens them and lets fainter edges take a line. Coverage is kept.
pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, thickness: f64, intensity: f64, posterization: f64) {
if ![thickness, intensity, posterization].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
if !([thickness, intensity, posterization].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let steps = (posterization.round().clamp(0.0, 6.0) + 1.0) as f32;
@@ -248,7 +248,7 @@ pub(super) fn glow_spread(size: f64) -> f64 {
/// above 0) or from its dark areas (below 0), spread as far as |`size`| (0..24) says and as
/// strong as `brightness` (0..50). Coverage is kept.
pub(super) fn neon_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, brightness: f64, colour: [f32; 3]) {
if ![size, brightness].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
if !([size, brightness].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let strength = (brightness / 50.0).clamp(0.0, 1.0) as f32;
+43
View File
@@ -2133,6 +2133,49 @@ fn artistic_params_take_defaults_and_stay_in_range() {
}
}
#[test]
fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
let (w, h) = (16, 12);
let src = ramp(w, h);
let run = |f: &PixelFx, s: &PixelSpace| {
let mut out = src.clone();
f.apply(&mut out, w, h, s);
out
};
let colour = [0.3, 0.65, 1.0];
let filters = [
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 },
PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour },
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 },
];
for f in &filters {
assert_ne!(run(f, &space(w, h)), src, "{f:?} changes the art in a valid space");
}
let mut changed = Vec::new();
for f in &filters {
for px in [0.0, -1.0, f64::NAN, f64::INFINITY] {
if run(f, &PixelSpace { px, ..space(w, h) }) != src {
changed.push(format!("{f:?} at px {px}"));
}
}
}
for f in [
PixelFx::Cutout { levels: f64::NAN, simplicity: 4.0, fidelity: 2.0 },
PixelFx::Cutout { levels: 4.0, simplicity: f64::INFINITY, fidelity: 2.0 },
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: f64::NEG_INFINITY },
PixelFx::NeonGlow { size: f64::NAN, brightness: 15.0, colour },
PixelFx::NeonGlow { size: 5.0, brightness: f64::INFINITY, colour },
PixelFx::PosterEdges { thickness: f64::NAN, intensity: 1.0, posterization: 2.0 },
PixelFx::PosterEdges { thickness: 2.0, intensity: f64::INFINITY, posterization: 2.0 },
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: f64::NEG_INFINITY },
] {
if run(&f, &space(w, h)) != src {
changed.push(format!("{f:?}"));
}
}
assert!(changed.is_empty(), "directly constructed non-finite parameters and invalid spaces are harmless: {changed:?}");
}
#[test]
fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
let (w, h) = (64, 24);
+1 -1
View File
@@ -197,7 +197,7 @@ impl Rasterize {
let (px, rest) = data.as_chunks_mut::<4>();
let (src, src_rest) = image.as_chunks::<4>();
let fits = |n: usize, w: usize, h: usize, rest: usize| w > 0 && h > 0 && rest == 0 && w.checked_mul(h) == Some(n);
if !fits(px.len(), w, h, rest.len()) || !fits(src.len(), cols, rows, src_rest.len()) || !(rect.width() > 0.0 && rect.height() > 0.0) {
if !(fits(px.len(), w, h, rest.len()) && fits(src.len(), cols, rows, src_rest.len()) && rect.width() > 0.0 && rect.height() > 0.0) {
return;
}
let (sx, sy) = (cols as f64 / rect.width(), rows as f64 / rect.height());
+44
View File
@@ -834,6 +834,50 @@ fn drop_shadow_darkness_replaces_its_color_when_chosen() {
assert_eq!(darkness(json!({})), None);
}
#[test]
fn rasterize_compose_leaves_the_raster_alone_when_the_sizes_do_not_fit() {
let r = match raster_effects(&[fx(RASTERIZE, json!({}))]).as_slice() {
[RasterFx::Rasterize(r)] => *r,
other => panic!("{other:?}"),
};
let space = PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(1.0, 1.0),
channels: pixel::Channels::Rgb,
line: 1.0,
extent: std::f64::consts::SQRT_2,
};
let compose = |len: usize, w: usize, h: usize, image: &[u8], cols: usize, rows: usize, rect: Rect| {
let mut data = vec![7; len];
r.compose(&mut data, w, h, &space, image, cols, rows, rect);
data
};
// A 2 × 2 red image over a 2 × 2 raster of one-point pixels: each pixel takes the image's color.
let red = [255, 0, 0, 255].repeat(4);
let mut long = red.clone();
long.extend([255, 0]);
let (img, long, area) = (red.as_slice(), long.as_slice(), Rect::new(0.0, 0.0, 2.0, 2.0));
assert_eq!(compose(16, 2, 2, img, 2, 2, area), red);
let cases = [
("w × h is not the raster's pixel count", 16, 1, 2, img, 2, 2, area),
("the raster's length is not a multiple of 4", 18, 2, 2, img, 2, 2, area),
("cols × rows is not the image's pixel count", 16, 2, 2, img, 1, 2, area),
("the image's length is not a multiple of 4", 16, 2, 2, long, 2, 2, area),
("the image has no width", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 0.0, 2.0)),
("the image has no height", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 2.0, 0.0)),
("the image has a negative width", 16, 2, 2, img, 2, 2, Rect::new(2.0, 0.0, 0.0, 2.0)),
("the image has a NaN width", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, f64::NAN, 2.0)),
("the image has a NaN height", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 2.0, f64::NAN)),
];
let failures: Vec<_> = cases
.iter()
.filter(|&&(_, len, w, h, image, cols, rows, rect)| compose(len, w, h, image, cols, rows, rect) != vec![7; len])
.map(|c| c.0)
.collect();
assert!(failures.is_empty(), "{failures:?}");
}
#[test]
fn blend_mode_names() {
use vectorcraft_doc::color::BlendMode;
+4
View File
@@ -15,6 +15,8 @@ default = ["bundled-fonts"]
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
[dependencies]
# Which paths stored in documents may be opened.
craft-docpath = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
@@ -35,6 +37,8 @@ vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
# The XML parts of Word documents placed as type (place/docx.rs).
roxmltree = "0.21"
kurbo = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+19 -8
View File
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Brush…",
[],
None,
"{type: calligraphic|scatter|art|pattern|bristle, name?, params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
"{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New <Type> Brush), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
has_doc,
brush_new
),
@@ -75,13 +75,21 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
brush_delete
),
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} | {names: [name…]} → {name} | {names}", has_doc, brush_duplicate),
cmd!(
"brush.duplicate",
"Duplicate Brush",
[],
None,
"{name, newName? (trimmed; blank: <name> copy)} | {names: [name…]} → {name} | {names}",
has_doc,
brush_duplicate
),
cmd!(
"brush.options",
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName?} edit a brush definition (strokes using it update) → {name}",
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
@@ -393,7 +401,8 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
o => return Err(bad(C, format!("unknown brush type `{o}`"))),
};
let mut lib = brush::library(&s.doc()?.doc);
let base_name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]));
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string);
let name = brush::unique_name(&lib, &base_name);
let mut v = json!({"name": name, "type": ty});
if let Some(params) = p.get("params") {
@@ -445,7 +454,8 @@ fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = vec![];
for name in &names {
let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
let new_name = str_param(p, "newName").filter(|_| p.get("names").is_none()).map_or_else(|| format!("{name} copy"), str::to_string);
let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none());
let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string);
b.name = brush::unique_name(&lib, &new_name);
out.push(b.name.clone());
lib.push(b);
@@ -463,7 +473,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(params) = p.get("params") {
merge(&mut v, params);
}
let new_name = str_param(p, "newName").filter(|n| !n.trim().is_empty() && *n != name).map(str::to_string);
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
if let Some(nn) = &new_name {
if lib.iter().any(|b| &b.name == nn) {
return Err(bad(C, format!("a brush named `{nn}` exists")));
@@ -669,7 +679,7 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let (art, size, c) = normalize(art)?;
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol"));
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
let export = export_type_param(p, C)?.unwrap_or_default();
let nine = nine_slice_param(p, C, None)?.flatten();
@@ -918,7 +928,8 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, C)?.to_string();
let d = &s.doc()?.doc;
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty());
let new = d.unique_symbol_name(&given.map_or_else(|| format!("{} copy", name.trim()), str::to_string));
let size = natural_size(d, &name);
let reg = d.symbol_registration(&name);
let export = d.symbol_export_type(&name);
+45 -22
View File
@@ -130,7 +130,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clipboard as PNG",
[],
None,
"{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px)} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty)",
"{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px), warnings} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty); warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)",
always,
export_png
),
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clipboard as PDF",
[],
None,
"{} → {dataBase64} a one-page PDF of the copied objects, the page their visual bounds (null when the clipboard is empty)",
"{} → {dataBase64, warnings} a one-page PDF of the copied objects, the page their visual bounds (dataBase64 null when the clipboard is empty); warnings as for document.export",
always,
export_pdf
),
@@ -218,6 +218,9 @@ fn type_only(n: &Node) -> bool {
}
}
/// A PNG of the copied objects: (bytes, width, height in pixels, the encoder's warnings).
pub type Png = (Vec<u8>, u32, u32, Vec<String>);
impl Clipboard {
/// The copied type objects' text, one object per line (`None` unless it holds type only).
pub fn text(&self) -> Option<String> {
@@ -243,21 +246,21 @@ impl Clipboard {
Some((fileio::with_single_artboard(self.to_document(), b, "Clipboard"), b))
}
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height); `None` when
/// there are none.
pub fn png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height, the warnings
/// [`fileio::encode_with_warnings`] gives); `None` when there are none.
pub fn png(&self, scale: f64) -> Result<Option<Png>> {
const C: &str = "clipboard.exportPng";
let Some((doc, b)) = self.cropped() else { return Ok(None) };
let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?;
let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
Ok(Some((png, w, h)))
let (png, warnings) = fileio::encode_with_warnings(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
Ok(Some((png, w, h, warnings)))
}
/// A one-page PDF of the objects (without the native document inside); `None` when there are
/// none.
pub fn pdf(&self) -> Result<Option<Vec<u8>>> {
/// A one-page PDF of the objects (without the native document inside) → (bytes, the encoder's
/// warnings); `None` when there are none.
pub fn pdf(&self) -> Result<Option<(Vec<u8>, Vec<String>)>> {
let Some((doc, _)) = self.cropped() else { return Ok(None) };
fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
fileio::encode_with_warnings(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
}
/// The MIME types of [`Self::flavours`], without making them.
@@ -275,23 +278,38 @@ impl Clipboard {
/// (`pdf`) as given (see `clipboard.flavours`). A [`Flavour`] that can't be made (art too large
/// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side.
pub fn flavours(&self, svg: bool, pdf: bool) -> Vec<Flavour> {
self.flavours_with_warnings(svg, pdf).0
}
/// [`Self::flavours`], with the warnings of the PDF and PNG encoders, each once (an image that
/// couldn't be decoded, type in the fallback font).
pub fn flavours_with_warnings(&self, svg: bool, pdf: bool) -> (Vec<Flavour>, Vec<String>) {
let types = self.flavour_types(svg, pdf);
let svg = types.contains(&SVG).then(|| self.svg()).flatten();
let mut out = vec![];
let mut warnings: Vec<String> = vec![];
let mut keep = |(bytes, notes): (Vec<u8>, Vec<String>)| {
for w in notes {
if !warnings.contains(&w) {
warnings.push(w);
}
}
bytes
};
for mime in types {
let data = match mime {
TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes),
SVG => svg.clone().map(String::into_bytes),
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten().map(&mut keep),
_ => {
let side = self.bounds().map_or(1.0, |b| b.width().max(b.height()));
let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0);
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, _, _, notes)| keep((png, notes)))
}
};
out.extend(data.map(|data| Flavour { mime, data }));
}
out
(out, warnings)
}
}
@@ -302,15 +320,15 @@ impl Session {
self.clipboard.text()
}
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
/// when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height, the
/// encoder's warnings); `None` when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<Png>> {
self.clipboard.png(scale)
}
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
/// clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
/// A one-page PDF of the copied objects (without the native document inside) → (bytes, the
/// encoder's warnings); `None` when the clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<(Vec<u8>, Vec<String>)>> {
self.clipboard.pdf()
}
@@ -356,13 +374,18 @@ fn export_png(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("clipboard.exportPng", "scale must be a number above 0, at most 64"));
}
Ok(match s.clipboard_png(scale)? {
Some((png, w, h)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h }),
Some((png, w, h, warnings)) => {
json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h, "warnings": warnings })
}
None => json!({ "dataBase64": null }),
})
}
fn export_pdf(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "dataBase64": s.clipboard_pdf()?.map(|b| vectorcraft_format::base64_encode(&b)) }))
Ok(match s.clipboard_pdf()? {
Some((pdf, warnings)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&pdf), "warnings": warnings }),
None => json!({ "dataBase64": null }),
})
}
fn export_text(s: &mut Session, _: &Value) -> Result<Value> {
+13 -6
View File
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_svg::{CssOptions, CssSheet, css_rules};
use super::fileio::{default_name, encode, isolated, write_file};
use super::fileio::{default_name, encode_with_warnings, isolated, write_file};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export CSS",
[],
None,
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}",
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
has_doc,
export
),
@@ -100,12 +100,19 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
let css = format!("{}\n", sheet.text());
let doc = &s.doc()?.doc;
// The pictures of rasterized art.
// Encode the pictures of rasterized art, and add each of their warnings once.
let mut images = Vec::new();
let mut warnings = sheet.warnings.clone();
for r in &sheet.rules {
let Some(name) = &r.image else { continue };
let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?;
images.push((name, encode(&d, "png", &json!({}))?));
let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?;
images.push((name, png));
for w in notes {
if !warnings.contains(&w) {
warnings.push(w);
}
}
}
let n = sheet.rules.len();
match str_param(p, "path") {
@@ -118,12 +125,12 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
write_file(&at, bytes)?;
written.push(at);
}
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": sheet.warnings }))
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": warnings }))
}
None => {
let images: Vec<Value> =
images.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": sheet.warnings }))
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": warnings }))
}
}
}
+114 -5
View File
@@ -140,8 +140,9 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
}
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
// Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder
// destination uses the same name). `file_part` writes `/`, `\` and `:` in the data set's name as
// `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
// destination uses the same name). `file_part` writes the characters Windows does not allow in a
// file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) in the data set's
// name as `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name))));
let steps = filter_steps(run.steps, true, false);
let run = Run { steps: &steps, ..*run };
@@ -246,7 +247,8 @@ fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result
Ok(())
}
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on.
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on: `ext` is the
/// extension of the step's `format` ([`format_extension`]), else `default_ext`.
#[cfg(not(target_arch = "wasm32"))]
fn rewrite_paths(
steps: &[Value],
@@ -271,8 +273,10 @@ fn rewrite_paths(
}
let params = c.get("params").cloned().unwrap_or(json!({}));
let mut params = params.as_object().cloned().unwrap_or_default();
let ext = params.get("format").and_then(Value::as_str).unwrap_or(default_ext);
let ext = if ext.is_empty() { default_ext } else { ext };
let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
Some(f) => format_extension(f),
None => default_ext.to_string(),
};
let file = std::path::Path::new(folder).join(format!("{stem}.{ext}"));
params.insert("path".into(), json!(file.to_string_lossy()));
if let Some(o) = c.as_object_mut() {
@@ -283,6 +287,21 @@ fn rewrite_paths(
.collect()
}
/// The extension of the file a step writes in `format` (an id or extension, as [`super::format`]
/// reads it: any case, a leading dot allowed): the extension as written, in lower case and without
/// the dot, when it is one of the format's (`SVG` → `svg`, `.JPEG` → `jpeg`), else the format's
/// first (`png8` → `png`, `template` → `vctemplate`). An unknown format (`xyz`) keeps its spelling,
/// and the step fails with its command's error.
#[cfg(not(target_arch = "wasm32"))]
fn format_extension(format: &str) -> String {
let written = format.trim_start_matches('.').to_ascii_lowercase();
match super::format(format) {
Some(f) if f.extensions.contains(&written.as_str()) => written,
Some(f) => f.extensions.first().map_or(written, |e| (*e).to_string()),
None => format.to_string(),
}
}
/// Readable files under `folder` (and optionally its subfolders), capped.
#[cfg(not(target_arch = "wasm32"))]
fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result<Vec<String>> {
@@ -410,6 +429,70 @@ mod tests {
assert!(dest.join("only.svg").is_file());
}
/// Override Export names each file with the extension of the format the step names, however
/// the step spells it; a format given by one of its extensions keeps that extension.
#[test]
fn batch_override_export_names_files_with_the_format_s_extension() {
let src = tmp("ext-src");
write_svg(&src, "a.svg", "#f00");
let cases = [(".svg", "a.svg"), ("SVG", "a.svg"), ("svg", "a.svg"), ("png8", "a.png"), ("template", "a.vctemplate"), (".JPEG", "a.jpeg")];
for (i, (format, want)) in cases.into_iter().enumerate() {
let dest = tmp(&format!("ext-out-{i}"));
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.export", "params": {"format": format}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{format}: {r}");
assert_eq!(file_names(&dest), [want], "{format}: {r}");
}
// An unknown format keeps its spelling, and the step fails with the export's error.
let dest = tmp("ext-out-unknown");
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.export", "params": {"format": "xyz"}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 1, "{r}");
assert!(r["processed"][0]["error"].as_str().is_some_and(|e| e.contains("unknown format `xyz`")), "{r}");
assert!(file_names(&dest).is_empty(), "{r}");
}
/// Override Save names each file with the extension of the format the step names, also over
/// data sets.
#[test]
fn batch_override_save_names_files_with_the_format_s_extension() {
for (i, format) in [".svg", "SVG", ".PDF"].into_iter().enumerate() {
let dest = tmp(&format!("ext-save-{i}"));
let mut s = with_data_sets(&["a"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "file.saveAs", "params": {"format": format}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{format}: {r}");
let want = format!("Untitled_a.{}", format.trim_start_matches('.').to_ascii_lowercase());
assert_eq!(file_names(&dest), [want], "{format}: {r}");
}
}
#[test]
fn batch_datasets_applies_each_row() {
let dest = tmp("sets");
@@ -516,6 +599,32 @@ mod tests {
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
}
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name:
/// Batch writes them as `-` in a data set's name, on every platform.
#[test]
fn batch_saves_data_sets_named_with_characters_windows_does_not_allow() {
let dest = tmp("set-names-win");
let mut s = with_data_sets(&["a?b", "c*d<e>", "f\"g|h", "i\u{7}j"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"stopOnError": false,
"commands": [{"command": "object.unlockAll", "params": {}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(
file_names(&dest),
["Untitled_a-b.vectorcraft", "Untitled_c-d-e-.vectorcraft", "Untitled_f-g-h.vectorcraft", "Untitled_i-j.vectorcraft"],
"{r}"
);
}
#[test]
fn batch_gives_each_data_set_a_file_of_its_own() {
let dest = tmp("set-names-same");
+4 -5
View File
@@ -12,8 +12,7 @@ use crate::DocState;
use super::super::*;
use super::encode::single_artboard;
use super::{
ARTBOARD_PARAMS, Format, default_name, encode, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded,
write_or_return,
ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return,
};
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
@@ -224,7 +223,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
}
/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless
/// `transparent`.
/// `transparent`, with the encoder's warnings.
pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportForOffice";
if let Some(path) = str_param(p, "path") {
@@ -242,8 +241,8 @@ pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
}
let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" };
let opts = with_black_output(s, &json!({ "ppi": ppi, "background": background, "artboard": artboard }));
let bytes = encode(doc, "png", &opts)?;
let (bytes, warnings) = encode_with_warnings(doc, "png", &opts)?;
// The pixel size, from the PNG header.
let side = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map_or(0, u32::from_be_bytes);
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20) }))
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20), "warnings": warnings }))
}
+14 -2
View File
@@ -270,9 +270,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
{
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
}
// Threaded type read from a file flows through its frames.
// Threaded type read from a file flows through its frames (the threaded type of a restored
// document keeps its text as saved), and every frame of a thread takes its first frame's
// Optical Margin Alignment setting.
if !doc.text_threads.is_empty() && !restored {
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
} else if !doc.text_threads.is_empty() {
crate::cmd::threads::share_optical_margin(&mut doc);
}
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
@@ -293,8 +297,11 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document,
// The importer reads on a thread of its own (for its stack): what may be read is what may be
// read here.
let scope = crate::file_access::ScopeHandle::capture();
// Paths are checked as stored in a document inside `folder` (only the folder is used).
let document = folder.map(|f| std::path::Path::new(f).join("-").to_string_lossy().into_owned());
let read = |path: &str| {
scope.clone().run(|| {
let path = &super::document_path(path, document.as_deref()).ok()?;
// Only files (not folders or devices) are read.
file_stamp(path)?;
let bytes = read_file(path).ok()?;
@@ -470,6 +477,8 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
/// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]);
/// CMYK TIFFs with an alpha channel, which the decoder can't read, become RGBA in the active color
/// settings' CMYK, as do CMYK Photoshop documents, whose merged image is read ([`psdread`]).
/// 1-bit TIFFs compressed with CCITT Group 3, which the decoder can't read either, become RGBA
/// 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 TIFF with more strips or tiles than the decoder can count
@@ -504,7 +513,10 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
};
let img = match (f.id == "tiff").then(extra_samples).flatten() {
Some(img) => img,
None => reader.decode().map_err(err)?.to_rgba8(),
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(),
},
};
as_png(img, ppi)
}
+67 -13
View File
@@ -8,7 +8,7 @@
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
//! - `screens`: Export for Screens (`document.exportForScreens`, `document.exportSettings`); `zip`
//! packs its files as one download.
//! packs its files as one download, and reads the files of Word documents placed as type.
//! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the
//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
@@ -45,7 +45,7 @@ mod save;
mod screens;
mod svg;
mod text;
mod zip;
pub mod zip;
use serde_json::{Value, json};
@@ -78,7 +78,8 @@ pub use screens::{
builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows,
};
pub(crate) use screens::{
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings,
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, safe_file_name,
store_settings as store_screen_settings,
};
pub use svg::options_map as svg_options;
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
@@ -141,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
has_doc,
screens::export_for_screens
),
@@ -186,7 +187,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
[],
None,
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\` and `:` in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
always,
action_batch::action_batch
),
@@ -213,7 +214,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Office Documents…",
["File"],
None,
"{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height} (pixels); no path → {dataBase64, bytes, width, height}",
"{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height, warnings} (pixels); no path → {dataBase64, bytes, width, height, warnings}; warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)",
has_doc,
export::export_for_office
),
@@ -710,7 +711,11 @@ pub const TEXT_EXTS: &[&str] = &["txt"];
/// RTF files: File → Place sets them as area type with their formatting.
pub const RTF_EXTS: &[&str] = &["rtf"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`] and [`RTF_EXTS`].
/// Word documents: File → Place sets them as area type with their formatting.
pub const DOCX_EXTS: &[&str] = &["docx"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and
/// [`DOCX_EXTS`].
pub const PLACE_EXTS: &[&str] = &[
"vectorcraft",
"drawcraft",
@@ -741,6 +746,7 @@ pub const PLACE_EXTS: &[&str] = &[
"afpub",
"txt",
"rtf",
"docx",
];
/// One dialog filter per readable format.
@@ -764,13 +770,14 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text
/// and RTF.
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
/// RTF and Word.
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All placeable files", PLACE_EXTS))
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
.chain(std::iter::once(("Text", TEXT_EXTS)))
.chain(std::iter::once(("RTF", RTF_EXTS)))
.chain(std::iter::once(("Word", DOCX_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
@@ -843,9 +850,36 @@ fn formats(_: &mut Session, _: &Value) -> Result<Value> {
/// `path` checked for reading against the automation roots in force.
#[cfg(not(target_arch = "wasm32"))]
fn may_read(path: &str) -> Result<()> {
#[cfg(test)]
touch(path);
crate::file_access::check_read(path).map_err(EngineError::Other)
}
/// Paths read while [`touched`] runs, on any thread (the SVG importer reads on one of its own).
#[cfg(test)]
static TOUCHED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
#[cfg(test)]
static RECORDING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
fn touch(path: &str) {
if RECORDING.load(std::sync::atomic::Ordering::SeqCst) > 0 {
TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
}
}
/// Run `f` and list the paths read through this module meanwhile (by any test running then).
#[cfg(test)]
pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
use std::sync::atomic::Ordering::SeqCst;
let start = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len();
RECORDING.fetch_add(1, SeqCst);
let out = f();
RECORDING.fetch_sub(1, SeqCst);
let seen = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(start..).unwrap_or_default().to_vec();
(out, seen)
}
/// `path` checked for writing against the automation roots in force.
#[cfg(not(target_arch = "wasm32"))]
fn may_write(path: &str) -> Result<()> {
@@ -1003,8 +1037,7 @@ fn list_dir(_: &str, _: usize) -> Result<(Vec<Value>, bool)> {
/// keeps one); `None` when there is no file at `path` (or automation may not read it).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
may_read(path).ok()?;
let m = std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file)?;
let m = file_metadata(path).filter(std::fs::Metadata::is_file)?;
let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
Some((m.len(), modified))
}
@@ -1013,11 +1046,17 @@ pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
/// (and automation may read it).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn file_created(path: &str) -> Option<u64> {
may_read(path).ok()?;
let t = std::fs::metadata(path).ok()?.created().ok()?;
let t = file_metadata(path)?.created().ok()?;
t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)
}
/// The file system's metadata of `path`, following links (when automation may read it).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn file_metadata(path: &str) -> Option<std::fs::Metadata> {
may_read(path).ok()?;
std::fs::metadata(path).ok()
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn file_created(_: &str) -> Option<u64> {
None
@@ -1033,6 +1072,21 @@ pub(crate) fn absolute_path(path: &str) -> String {
std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned())
}
/// The file to open for `stored`, a path or `file:` URL stored in a document (a linked file, an
/// SVG image's href), or why it is not followed. `document` is the path of the document it is
/// 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.
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
let document = document.map(absolute_path);
let path = craft_docpath::resolve(stored, document.as_deref().map(std::path::Path::new))?;
if !path.is_absolute() {
return Err(craft_docpath::Refused::Invalid);
}
path.into_os_string().into_string().map_err(|_| craft_docpath::Refused::Invalid)
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn file_stamp(_: &str) -> Option<(u64, Option<u64>)> {
None
+12 -11
View File
@@ -12,7 +12,10 @@
//! (YYYYMMDD), `<time>` (hhmmss), `<YYYY>`, `<MM>`, `<DD>`, `<hh>`, `<mm>`, `<ss>`.
//!
//! A trailing `.ext` (or `.<ext>`, `.<format>`) is dropped: the extension comes from the format.
//! Anything that could start a folder (`/`, `\`, `:`) becomes `-`.
//! The characters Windows does not allow in a file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`,
//! `*` and control characters) become `-`, in the template's text and in what the tokens put in.
use super::screens::file_part;
/// What a template's tokens stand for, for one file.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -50,11 +53,6 @@ fn token(name: &str, p: &NameParts<'_>) -> Option<String> {
})
}
/// Text with `/`, `\` and `:` turned into `-`, so it can't start a folder.
fn clean(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
}
/// Does `template` place the row's suffix itself (so it isn't added at the end)?
pub fn has_suffix_token(template: &str) -> bool {
template.contains("<suffix>")
@@ -72,12 +70,12 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
let mut out = String::with_capacity(t.len() + 16);
let mut rest = t;
while let Some(c) = rest.chars().next() {
// Bracketed tokens; an unknown one stays as written.
// Bracketed tokens; an unknown one stays as written, with `-` for its brackets.
if c == '<'
&& let Some(end) = rest.find('>')
&& let Some(value) = rest.get(1..end).and_then(|name| token(name, p))
{
out.push_str(&clean(&value));
out.push_str(&value);
rest = rest.get(end + 1..).unwrap_or("");
continue;
}
@@ -94,10 +92,10 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
rest = rest.get(pat.len()..).unwrap_or("");
continue;
}
out.push(if matches!(c, '/' | '\\' | ':') { '-' } else { c });
out.push(c);
rest = rest.get(c.len_utf8()..).unwrap_or("");
}
out.trim().to_string()
file_part(out.trim())
}
/// `YYYY-MM-DD` (or `YYYYMMDD`) as `[year, month, day, 0, 0, 0]`; `None` when it isn't a date.
@@ -133,7 +131,7 @@ mod tests {
assert_eq!(render("YYYY-MM-DD YYYY", &p), "2026-10-16 2026");
// Words are left alone: only the date patterns and bracketed tokens are replaced.
assert_eq!(render("SUMMER-DD-MM", &p), "SUMMER-DD-MM");
assert_eq!(render("<unknown>-x", &p), "<unknown>-x");
assert_eq!(render("<unknown>-x", &p), "-unknown--x");
assert_eq!(render("logo.<format>", &p), "logo", "the extension comes from the format");
assert!(has_suffix_token("a<suffix>") && !has_suffix_token("a"));
}
@@ -143,6 +141,9 @@ mod tests {
let p = NameParts { name: "../../etc", ..parts() };
let n = render("a/b\\c:<name>", &p);
assert!(!n.contains('/') && !n.contains('\\') && !n.contains(':'), "{n}");
// Nor any other character Windows does not allow in a file name, typed or put in.
let p = NameParts { name: "<a>\"b|c?d*\u{0}\u{1f}", ..parts() };
assert_eq!(render("<name>|?*\u{1}", &p), "-a--b-c-d-------");
// A long counter is padded, a short run is not cut.
assert_eq!(render("########", &NameParts { counter: 123, ..parts() }), "00000123");
assert_eq!(render("#", &NameParts { counter: 12345, ..parts() }), "12345");
+54 -16
View File
@@ -21,7 +21,8 @@ use super::encode::single_artboard;
use super::export::{isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names, encode_with_warnings, unique_file_names,
with_black_output, writable, write_file,
};
const C: &str = "document.exportForScreens";
@@ -148,10 +149,29 @@ fn safe_name(s: &str) -> Option<String> {
(!n.trim_matches('.').is_empty()).then_some(n)
}
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch): path separators and
/// `:` become `-`, so the name cannot make a subfolder or point outside the folder the file is written to.
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch, a name pattern's text
/// and the names it puts in): the characters Windows does not allow in a file name (`/`, `\`, `:`,
/// `<`, `>`, `"`, `|`, `?`, `*` and the control characters U+0000 to U+001F,
/// <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>) become `-` on every
/// platform, so the name cannot make a subfolder or point outside the folder the file is written to.
pub(super) fn file_part(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*') || c < ' ' { '-' } else { c }).collect()
}
/// A whole file or folder name (`[email protected]`, a sub-folder) through [`file_part`], without
/// trailing periods and spaces, and with `-` after a Windows device name that is the whole name or
/// comes before its first period: `CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9` and `LPT1` to `LPT9`,
/// also with `¹`, `²` or `³` for the digit, in any case (`CON.png` becomes `CON-.png`). The same on
/// every platform.
pub(crate) fn safe_file_name(s: &str) -> String {
let name = file_part(s);
let name = name.trim_end_matches(['.', ' ']);
let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len()));
let upper = base.to_ascii_uppercase();
let numbered = |n: &str| n.chars().count() == 1 && n.chars().all(|c| matches!(c, '1'..='9' | '¹' | '²' | '³'));
let device = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| upper.strip_prefix("COM").or_else(|| upper.strip_prefix("LPT")).is_some_and(numbered);
if device { format!("{base}-{rest}") } else { name.to_string() }
}
/// Keys of a format row that aren't encoder options.
@@ -541,7 +561,8 @@ pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
/// so do different files the `name` pattern gives one name.
/// so do different files the `name` pattern gives one name. The reply's `warnings` lists every
/// warning the encoders give for the files, each once.
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
@@ -588,8 +609,18 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let crop = rows.iter().any(|r| r.format.id != "pdf");
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
let mut out = Output::default();
// The encoders' warnings for every file, each once.
let mut warnings: Vec<String> = Vec::new();
// Appearance of Black › Printing / Exporting for RGB raster rows.
let write = |doc: &Document, format: &str, options: &Value| encode(doc, format, &with_black_output(s, options));
let mut write = |doc: &Document, format: &str, options: &Value| -> Result<Vec<u8>> {
let (bytes, notes) = encode_with_warnings(doc, format, &with_black_output(s, options))?;
for w in notes {
if !warnings.contains(&w) {
warnings.push(w);
}
}
Ok(bytes)
};
// A whole-document copy of an artboard, for the formats that write documents to edit.
let mut board_docs: std::collections::HashMap<usize, Arc<Document>> = std::collections::HashMap::new();
let mut files = 0u64;
@@ -615,10 +646,10 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
None => format!("{prefix}{}{}", item.stem, r.suffix),
};
let file = format!("{stem}.{ext}");
let file = safe_file_name(&format!("{stem}.{ext}"));
let sub = match folders {
Folders::None => None,
Folders::Rows => Some(r.folder.clone()),
Folders::Rows => Some(safe_file_name(&r.folder)),
Folders::Format => Some(ext.to_string()),
Folders::Item => Some(item.stem.clone()),
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
@@ -644,20 +675,24 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
if o.zip {
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
let zip_name = format!("{prefix}{filename}.zip");
let zip_name = safe_file_name(&format!("{prefix}{filename}.zip"));
return match folder {
Some(dir) => {
let path = format!("{dir}/{zip_name}");
write_file(&path, &zip)?;
Ok(json!({ "path": path, "bytes": zip.len(), "files": names }))
Ok(json!({ "path": path, "bytes": zip.len(), "files": names, "warnings": warnings }))
}
None => Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names })),
None => Ok(
json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names, "warnings": warnings }),
),
};
}
Ok(match folder {
Some(_) => json!({ "files": out.written }),
Some(_) => json!({ "files": out.written, "warnings": warnings }),
None => {
json!({ "files": out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::<Vec<_>>() })
let files: Vec<Value> =
out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
json!({ "files": files, "warnings": warnings })
}
})
}
@@ -670,8 +705,10 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
};
// Each item's stem follows Windows' file-naming rules, and no two items share one.
let safe = |stems: Vec<String>| distinct_names(stems.iter().map(|s| safe_file_name(s)));
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
let stems = unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())));
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
pieces
.into_iter()
.zip(stems)
@@ -688,7 +725,8 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
(doc.clone(), Rect::ZERO)
};
let name = filename.to_string();
return Ok(vec![Item { name: name.clone(), stem: name, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
let stem = safe_file_name(&name);
return Ok(vec![Item { name, stem, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
}
if let Some(ids) = &o.assets {
let mut pieces = vec![];
@@ -742,7 +780,7 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
if boards.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let stems = artboard_file_names(doc, &boards);
let stems = safe(artboard_file_names(doc, &boards));
Ok(boards
.into_iter()
.zip(stems)
+107
View File
@@ -208,6 +208,113 @@ fn planar_cmyk_tiffs_open_and_place_with_their_inks() {
assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image");
}
/// A 70 × 7 picture, `true` for black: a white row and a black row (runs past 63 take a make-up
/// code), then runs of several lengths.
fn fax_picture() -> Vec<Vec<bool>> {
(0..7).map(|y| (0..70).map(|x| if y < 2 { y == 1 } else { (x * (y + 2) / 5 + y) % 3 == 0 }).collect()).collect()
}
/// The first `rows` rows of [`fax_picture`] as a TIFF compressed with CCITT Group 3 in one strip:
/// `compression` 2 or 3 with `t4_options`, white is zero, the highest bit of each byte first.
fn group3_tiff(compression: u16, t4_options: u32, rows: usize) -> Vec<u8> {
let rows = &fax_picture()[..rows];
let g =
vectorcraft_testkit::tiff::Group3 { rows, compression, t4_options, photometric: 0, fill_order: 1, rows_per_strip: 7, tile: None, rtc: false };
vectorcraft_testkit::tiff::group3(&g)
}
/// 1-bit TIFFs compressed with CCITT Group 3 open and place with their pixels (#1466): Compression
/// 2 (modified Huffman) and Compression 3 (ITU-T T.4) with one-dimensional rows or two-dimensional
/// ones (T4Options bit 0), with and without fill bits (bit 2) and six EOLs at the end, in one strip,
/// three strips or three 32 × 16 tiles, each byte's highest or lowest bit first (FillOrder 1 and 2),
/// white or black as zero.
#[test]
fn ccitt_group3_tiffs_open_and_place_with_their_pixels() {
let rows = fax_picture();
let mut s = Session::new();
for (compression, t4_options, rtc) in [(2, 0, false), (3, 0, false), (3, 4, true), (3, 1, false), (3, 5, true)] {
for (rows_per_strip, tile) in [(7, None), (3, None), (7, Some((32, 16)))] {
for fill_order in [1, 2] {
for photometric in [0, 1] {
let what = format!(
"Compression {compression}, T4Options {t4_options}, {rows_per_strip} rows a strip, tiles {tile:?}, FillOrder {fill_order}, photometric {photometric}"
);
let g = vectorcraft_testkit::tiff::Group3 {
rows: &rows,
compression,
t4_options,
photometric,
fill_order,
rows_per_strip,
tile,
rtc,
};
let file = vectorcraft_testkit::tiff::group3(&g);
assert!(image::load_from_memory(&file).is_err(), "{what}: the image decoder reads Group 3 now");
// A black run's pixels are 1 bits, which show as black when white is zero.
let want: Vec<u8> = rows.concat().iter().map(|&black| if black == (photometric == 0) { 0 } else { 255 }).collect();
let pixels = |s: &Session| {
let blob = &s.doc().unwrap().doc.images[&image_of(s).key];
image::load_from_memory(&blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_luma8().into_raw()
};
open(&mut s, "fax.tif", &file);
assert_eq!(pixels(&s), want, "{what}: Open");
let mut placed = session(100.0, 100.0, 1);
placed.execute("file.place", &json!({"name": "fax.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
assert_eq!(pixels(&placed), want, "{what}: Place");
}
}
}
}
}
/// A Group 3 TIFF whose pixels would take more than the decoding allowance as RGBA is refused as
/// too large to draw, without a panic at 2^31 × 2^31 pixels too. One whose data ends inside a row,
/// or after a whole row before the strip's last, is refused as damaged, and so is one whose tiles
/// are wider than the allowance.
#[test]
fn ccitt_group3_tiffs_too_large_or_cut_short_are_refused() {
// ImageWidth (the directory's first entry, its value at offset 18), ImageLength (the second, at
// 30) and RowsPerStrip (the eighth, at 102).
for (width, height) in [(70u32, 1u32 << 30), (1 << 31, 1 << 31)] {
let mut big = group3_tiff(3, 1, 7);
big[18..22].copy_from_slice(&width.to_le_bytes());
big[30..34].copy_from_slice(&height.to_le_bytes());
big[102..106].copy_from_slice(&height.to_le_bytes());
let e = raster_image(&big).err().expect("refused");
assert!(e.to_string().contains(&format!("an image of {width} × {height} pixels is too large to draw")), "{e}");
}
let mut short = group3_tiff(3, 0, 7);
short.truncate(short.len() - 6);
let e = raster_image(&short).err().expect("refused");
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
// Compression 2 rows start at a byte, so the strip of the first six rows is where the strip of
// seven starts. StripByteCounts (the ninth entry, its value at offset 114) gives the six rows'.
let (mut seven, six) = (group3_tiff(2, 0, 7), group3_tiff(2, 0, 6));
seven[114..118].copy_from_slice(&six[114..118]);
assert_eq!(seven[30..34], 7u32.to_le_bytes(), "ImageLength");
assert_eq!(seven[78..82], six[78..82], "StripOffsets (the sixth entry, at offset 78)");
let e = raster_image(&seven).err().expect("refused");
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
// A tile wider than the allowance: TileWidth (the eighth entry, its value at offset 102).
let rows = fax_picture();
let g = vectorcraft_testkit::tiff::Group3 {
rows: &rows,
compression: 3,
t4_options: 1,
photometric: 0,
fill_order: 1,
rows_per_strip: 7,
tile: Some((80, 16)),
rtc: false,
};
let mut wide = vectorcraft_testkit::tiff::group3(&g);
assert_eq!(wide[102..106], 80u32.to_le_bytes(), "TileWidth");
wide[102..106].copy_from_slice(&u32::MAX.to_le_bytes());
let e = raster_image(&wide).err().expect("refused");
assert!(e.to_string().contains("could not decode image: arithmetic overflow"), "{e}");
}
#[test]
fn content_beats_a_wrong_extension() {
let png = image_bytes(2, 2, image::ImageFormat::Png);
@@ -105,6 +105,17 @@ fn the_counter_counts_items_or_files_from_any_start() {
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
}
/// Export for Screens writes the characters Windows does not allow in a file name (`<`, `>`, `"`,
/// `|`, `?`, `*`, control characters) as `-` in a pattern's text and in the names its tokens stand
/// for, on every platform.
#[test]
fn a_pattern_writes_the_characters_windows_does_not_allow_as_dashes() {
let mut s = session(1);
s.execute("artboard.setProps", &json!({"index": 0, "name": "a|b?\"c\u{7}"})).unwrap();
let r = export(&mut s, json!({"name": "<name>*<x>", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["a-b--c---x-.svg"]);
}
#[test]
fn layers_and_the_selection_are_exported_by_name() {
let mut s = session(1);
@@ -110,6 +110,42 @@ fn density_buckets_go_into_sub_folders() {
assert_eq!(names(&r), ["Artboard-2-..-x-..-y.svg", "Artboard-2-ok.png"]);
}
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name
/// (<https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>): Export for Screens
/// writes them as `-` in a prefix and a suffix, on every platform.
#[test]
fn a_prefix_or_suffix_writes_the_characters_windows_does_not_allow_as_dashes() {
let mut s = session(1);
let r = export(&mut s, json!({"prefix": "a<b>c\"", "formats": [{"format": "svg", "suffix": "|d?e*f\u{1}\t"}]}));
assert_eq!(names(&r), ["a-b-c-Artboard-1-d-e-f--.svg"]);
}
/// A file or folder named after a Windows device (`CON`, `NUL`, `COM1`…, alone or before an
/// extension) gets a `-` after the device name, and a folder loses a trailing period, on every
/// platform.
#[test]
fn device_names_and_trailing_periods_follow_windows_naming_rules() {
let mut s = session(3);
for (index, name) in ["CON", "CON-", "lpt1"].iter().enumerate() {
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
}
let r = export(&mut s, json!({"formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["CON-.svg", "CON--2.svg", "lpt1-.svg"]);
let r =
export(&mut s, json!({"range": "1", "subfolders": true, "formats": [{"format": "svg", "folder": "aux"}, {"format": "pdf", "folder": "x."}]}));
assert_eq!(names(&r), ["aux-/CON-.svg", "x/CON-.pdf"]);
let r = export(&mut s, json!({"range": "3", "folders": "item", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["lpt1-/lpt1-.svg"]);
// A name pattern, with an extension after the device name, and the document's own name.
let r = export(&mut s, json!({"range": "1", "name": "<name>.tar", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["CON-.tar.svg"]);
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "nul"})).unwrap();
let r = export(&mut s, json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}));
assert_eq!(names(&r), ["nul-/nul-.pdf"]);
let r = s.execute("document.exportForScreens", &json!({"zip": true, "formats": [{"format": "svg"}]})).unwrap();
assert_eq!(r["name"], "nul-.zip");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn sub_folders_are_made_in_the_export_folder() {
@@ -164,6 +200,45 @@ fn a_zip_is_written_into_the_folder() {
let _ = std::fs::remove_dir_all(dir);
}
/// The warnings in `r` that contain `text`.
fn warned<'a>(r: &'a Value, text: &str) -> Vec<&'a str> {
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains(text)).collect()
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn every_reply_says_once_when_an_image_could_not_be_decoded() {
let mut s = session(1);
let rows = json!([{"format": "png"}, {"format": "jpg", "scale": "2x"}]);
assert_eq!(export(&mut s, json!({ "formats": rows }))["warnings"], json!([]));
// The renderer can't decode the placed image and draws nothing in its place.
crate::tests_menucmds::place_undecodable_png(&mut s);
let dir = std::env::temp_dir().join(format!("vc-screens-undecoded-{}", std::process::id()));
let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
for p in [
json!({ "formats": rows }),
json!({ "formats": rows, "zip": true }),
json!({ "formats": rows, "folder": folder }),
json!({ "formats": rows, "folder": folder, "zip": true }),
json!({ "formats": rows, "fullDocument": true }),
] {
let r = export(&mut s, p.clone());
assert_eq!(warned(&r, "couldn't be decoded").len(), 1, "{p}: {}", r["warnings"]);
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "the files are still written");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn type_in_a_missing_family_is_named_once_for_every_format() {
let mut s = session(2);
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
let r = export(&mut s, json!({"formats": [{"format": "png"}, {"format": "pdf"}, {"format": "eps"}]}));
let fallback = warned(&r, "fallback font");
assert_eq!(fallback.len(), 1, "{}", r["warnings"]);
assert!(fallback[0].contains("Nonexistent Sans Probe"), "{fallback:?}");
}
#[test]
fn full_document_gives_one_file_per_format() {
let mut s = session(3);
@@ -85,3 +85,19 @@ fn save_for_office_writes_a_png_at_the_resolution() {
let spec = crate::cmd::find_command("document.exportForOffice").unwrap();
assert_eq!((spec.label, spec.menu), ("Save for Office Documents…", &["File"][..]));
}
#[test]
fn save_for_office_says_when_an_image_or_a_font_is_missing() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
assert_eq!(s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap()["warnings"], json!([]));
// The renderer can't decode the placed image, and the type's family isn't installed.
crate::tests_menucmds::place_undecodable_png(&mut s);
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
let r = s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap();
let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(warnings.contains(&"an image couldn't be decoded and is missing from the picture"), "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("fallback font") && w.contains("Nonexistent Sans Probe")), "{warnings:?}");
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(200), Some(100)), "the PNG is still written");
}
+144
View File
@@ -1,5 +1,10 @@
//! A store-only (uncompressed) ZIP archive: several exported files as one download. The files are
//! already compressed (PNG, JPEG, PDF…), so storing them keeps the archive fast to write and read.
//!
//! [`Archive`] reads the files of a ZIP archive, stored or deflated, as PKWARE's APPNOTE.TXT
//! describes the format (<https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT>).
use std::io::Read as _;
use flate2::Crc;
@@ -65,6 +70,145 @@ pub fn store<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result<Vec<u8>,
Ok(out)
}
/// Does `bytes` start with a ZIP archive's first local file header?
pub fn is_zip(bytes: &[u8]) -> bool {
bytes.starts_with(&0x0403_4b50u32.to_le_bytes())
}
/// Why [`Archive`] can't read an archive or one of its files.
#[derive(Debug, PartialEq)]
pub enum ReadError {
/// Not a ZIP archive: no end of central directory record.
NotZip,
/// More files than the reader was asked to accept.
TooManyFiles,
/// The archive needs the 64-bit (ZIP64) records, which the reader does not read.
Zip64,
/// The file is encrypted.
Encrypted(String),
/// The file is compressed with a method other than stored (0) or deflated (8).
Method(String, u16),
/// The file unpacks to more bytes than the reader was asked to accept.
TooLarge(String),
/// The archive (empty name) or the file is truncated, inconsistent or fails its CRC.
Damaged(String),
}
/// One file of an archive, as its central directory record describes it.
struct Entry {
name: String,
flags: u16,
method: u16,
crc: u32,
packed: usize,
size: usize,
local: usize,
}
/// A ZIP archive's files, listed from its central directory.
pub struct Archive<'a> {
zip: &'a [u8],
entries: Vec<Entry>,
}
impl<'a> Archive<'a> {
/// The archive `zip`, with at most `max_files` files.
pub fn open(zip: &'a [u8], max_files: usize) -> Result<Self, ReadError> {
let damaged = || ReadError::Damaged(String::new());
let u16_at = |at: usize| at.checked_add(2).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
let u32_at = |at: usize| at.checked_add(4).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
// The end record is 22 bytes followed by a comment of at most 65535 bytes: the signature
// whose comment ends the archive, else the last signature (the archive has bytes after it).
let last = zip.len().checked_sub(22).ok_or(ReadError::NotZip)?;
let candidates = || (last.saturating_sub(0xFFFF)..=last).rev().filter(|&at| u32_at(at) == Some(0x0605_4b50));
let end =
candidates().find(|&at| u16_at(at + 20).map(usize::from) == Some(last - at)).or_else(|| candidates().next()).ok_or(ReadError::NotZip)?;
let count = u16_at(end + 10).ok_or_else(damaged)?;
let start = u32_at(end + 16).ok_or_else(damaged)?;
if count == 0xFFFF || start == 0xFFFF_FFFF || u32_at(end + 12) == Some(0xFFFF_FFFF) {
return Err(ReadError::Zip64);
}
let count = usize::from(count);
if count > max_files {
return Err(ReadError::TooManyFiles);
}
let mut at = start as usize;
let mut entries = Vec::with_capacity(count);
for _ in 0..count {
if u32_at(at) != Some(0x0201_4b50) {
return Err(damaged());
}
let short = |off: usize| at.checked_add(off).and_then(u16_at).ok_or_else(damaged);
let long = |off: usize| at.checked_add(off).and_then(u32_at).ok_or_else(damaged);
let (name_len, extra, comment) = (usize::from(short(28)?), usize::from(short(30)?), usize::from(short(32)?));
let name_at = at.checked_add(46).ok_or_else(damaged)?;
let name = name_at.checked_add(name_len).and_then(|end| zip.get(name_at..end)).ok_or_else(damaged)?;
entries.push(Entry {
name: String::from_utf8_lossy(name).into_owned(),
flags: short(8)?,
method: short(10)?,
crc: long(16)?,
packed: long(20)? as usize,
size: long(24)? as usize,
local: long(42)? as usize,
});
at = name_at.checked_add(name_len + extra + comment).ok_or_else(damaged)?;
}
Ok(Self { zip, entries })
}
/// The file `name`, matched in any ASCII case.
fn entry(&self, name: &str) -> Option<&Entry> {
self.entries.iter().find(|e| e.name.eq_ignore_ascii_case(name))
}
/// Does the archive hold the file `name` (in any ASCII case)?
pub fn contains(&self, name: &str) -> bool {
self.entry(name).is_some()
}
/// The bytes of the file `name` (matched in any ASCII case; None: the archive has no such
/// file), when it unpacks to at most `max` bytes and passes its CRC.
pub fn read(&self, name: &str, max: usize) -> Result<Option<Vec<u8>>, ReadError> {
let Some(e) = self.entry(name) else { return Ok(None) };
let damaged = || ReadError::Damaged(e.name.clone());
if e.flags & 1 != 0 {
return Err(ReadError::Encrypted(e.name.clone()));
}
if e.size > max {
return Err(ReadError::TooLarge(e.name.clone()));
}
// The local header: its signature, then its name and extra field lengths at 26 and 28.
let header = e.local.checked_add(30).and_then(|end| self.zip.get(e.local..end)).ok_or_else(damaged)?;
let (Some(sig), Some(name_len), Some(extra)) = (header.get(..4), header.get(26..28), header.get(28..30)) else { return Err(damaged()) };
if sig != 0x0403_4b50u32.to_le_bytes() {
return Err(damaged());
}
let len = |b: &[u8]| b.try_into().map(|b| usize::from(u16::from_le_bytes(b))).map_err(|_| damaged());
let start = e.local + 30 + len(name_len)? + len(extra)?;
let data = start.checked_add(e.packed).and_then(|end| self.zip.get(start..end)).ok_or_else(damaged)?;
let bytes = match e.method {
0 if e.packed != e.size => return Err(damaged()),
0 => data.to_vec(),
8 => {
let mut out = Vec::with_capacity(e.size);
flate2::read::DeflateDecoder::new(data).take((max as u64).saturating_add(1)).read_to_end(&mut out).map_err(|_| damaged())?;
if out.len() > max {
return Err(ReadError::TooLarge(e.name.clone()));
}
out
}
m => return Err(ReadError::Method(e.name.clone(), m)),
};
let mut crc = Crc::new();
crc.update(&bytes);
if bytes.len() != e.size || crc.sum() != e.crc {
return Err(damaged());
}
Ok(Some(bytes))
}
}
/// The entries of a store-only archive written by [`store`] (read through its central directory,
/// checking every CRC) → `(name, bytes)`.
#[cfg(test)]
+5 -3
View File
@@ -810,7 +810,8 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_from(p, def);
let idx = s.edit("New Artboard", |d, _| {
let id = d.next_artboard_id();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Artboard {}", d.artboards.len() + 1));
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string);
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false });
Ok(d.artboards.len() - 1)
})?;
@@ -864,8 +865,9 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Artboard Options", |d, _| {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = now;
if let Some(n) = str_param(p, "name") {
a.name = n.to_string();
if let Some(n) = str_param(p, "name").map(str::trim) {
// A blank name gives the artboard its default name, Artboard N for the Nth artboard.
a.name = if n.is_empty() { format!("Artboard {}", i + 1) } else { n.to_string() };
}
let id = a.id;
if let Some(xf) = transform {
+4 -3
View File
@@ -391,11 +391,12 @@ fn remove_file(_: &str) -> Result<()> {
}
/// Library `name` as a file name without its extension: letters, digits, spaces, `-` and `_`
/// kept, other characters as `-` ("Library" when nothing is left).
/// kept, other characters as `-`, and a `-` after a Windows device name such as `CON` or `COM1`
/// (`fileio::safe_file_name`); "Library" when nothing is left.
pub fn file_stem_for(name: &str) -> String {
let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
let safe = safe.trim();
if safe.is_empty() { "Library".to_string() } else { safe.to_string() }
if safe.is_empty() { "Library".to_string() } else { super::fileio::safe_file_name(safe) }
}
fn stem_of(path: &str) -> String {
@@ -884,7 +885,7 @@ fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
}
Kind::CharStyle | Kind::ParaStyle => {
let para = kind == Kind::ParaStyle;
let name = data.get("name").and_then(Value::as_str).unwrap_or("Style").to_string();
let name = data.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Style").to_string();
let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
let doc = &s.doc()?.doc;
// The document's style of that name when it is the same; else a new one.
+53 -39
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Check Links",
[],
None,
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}",
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}",
has_doc,
check
),
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Link Info",
[],
None,
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
has_doc,
info
),
@@ -245,21 +245,24 @@ fn found_at(path: String) -> Option<Found> {
Some(Found { path, size, modified, bytes: None })
}
/// Where the file of `link` is: at its path, else at its relative path or by name in the
/// document's folder `dir`.
fn locate(link: &LinkInfo, dir: Option<&Path>) -> Option<Found> {
let mut paths = vec![link.path.clone()];
if let Some(dir) = dir {
let near = link.relative.iter().map(|r| dir.join(r)).chain([dir.join(link.name())]);
paths.extend(near.map(|p| p.to_string_lossy().into_owned()));
}
paths.into_iter().find_map(found_at)
/// Where the file of `link` (stored in the document saved at `doc_path`) is: at its path, else at
/// its relative path or by name in the document's folder. Only paths
/// [`fileio::document_path`] follows are looked at.
fn locate(link: &LinkInfo, doc_path: Option<&str>) -> Option<Found> {
let stored = [Some(link.path.as_str()), link.relative.as_deref(), Some(link.name())];
stored.into_iter().flatten().filter_map(|p| fileio::document_path(p, doc_path).ok()).find_map(found_at)
}
/// Where the file of group `g` is and whether it still has the content shown. Its bytes are read
/// only when its size or modification time changed.
fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
let Some(mut f) = locate(&g.link, dir) else { return (Status::Missing, None) };
/// Why the path of `link` (stored in the document saved at `doc_path`) isn't followed, when it
/// names another computer, a device or a URL.
fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option<craft_docpath::Refused> {
fileio::document_path(&link.path, doc_path).err().filter(|r| *r != craft_docpath::Refused::Invalid)
}
/// Where the file of group `g` (of the document saved at `doc_path`) is and whether it still has
/// the content shown. Its bytes are read only when its size or modification time changed.
fn probe(g: &Group, doc_path: Option<&str>) -> (Status, Option<Found>) {
let Some(mut f) = locate(&g.link, doc_path) else { return (Status::Missing, None) };
let l = &g.link;
if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified {
return (Status::Ok, Some(f));
@@ -274,11 +277,6 @@ fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
(if same { Status::Ok } else { Status::Modified }, Some(f))
}
/// The folder of the document saved at `doc_path`.
fn folder(doc_path: Option<&str>) -> Option<&Path> {
doc_path.and_then(|p| Path::new(p).parent())
}
/// Is image `key` of `d` showing its preview (its file not read)?
fn previewing(d: &Document, key: &str) -> bool {
d.images.get(key).is_none_or(ImageBlob::is_proxy)
@@ -314,7 +312,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
.filter(|g| seen.insert(g.link.path.clone()))
.map(|g| {
let own = |b: &&ImageBlob| !b.is_proxy() && fileio::format_for_name(g.link.name()).is_some_and(|f| f.mime == b.mime);
let found = locate(&g.link, folder(doc_path));
let found = locate(&g.link, doc_path);
let found_path = found.as_ref().map(|f| f.path.clone());
let bytes = found.and_then(|mut f| f.read().ok()).or_else(|| d.images.get(&g.key).filter(own).map(|b| b.bytes.to_vec()));
LinkedFile { path: g.link.path.clone(), name: g.link.name().to_string(), found_path, bytes }
@@ -328,7 +326,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec<NodeId>)> {
groups(&st.doc, ids)
.into_iter()
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, folder(st.path.as_deref())).0 == Status::Missing))
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, st.path.as_deref()).0 == Status::Missing))
.map(|g| (g.link, g.ids))
.collect()
}
@@ -510,9 +508,23 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip))
}
/// Groups as `{name, path, ids}` rows (those with objects in the layers).
fn rows<'a>(gs: impl Iterator<Item = &'a Group>) -> Vec<Value> {
gs.filter(|g| !g.ids.is_empty()).map(|g| json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) })).collect()
/// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in
/// the layers), with `refused`: why the path isn't followed, when it isn't.
fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec<Value> {
gs.filter(|g| !g.ids.is_empty())
.map(|g| {
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) });
note_refused(&mut row, &g.link, doc_path);
row
})
.collect()
}
/// `row["refused"]`: why the path of `link` isn't followed, when it isn't ([`refused`]).
fn note_refused(row: &mut Value, link: &LinkInfo, doc_path: Option<&str>) {
if let Some(r) = refused(link, doc_path) {
row["refused"] = json!(r.to_string());
}
}
fn ids_json(ids: &[NodeId]) -> Vec<u64> {
@@ -545,9 +557,9 @@ impl Resolved {
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved {
let mut out = Resolved::default();
for g in groups(d, None) {
let row = rows(std::iter::once(&g));
let row = rows(std::iter::once(&g), doc_path);
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
match probe(&g, folder(doc_path)) {
match probe(&g, doc_path) {
(Status::Missing, _) => out.missing.extend(row),
(Status::Modified, Some(mut f)) if update || unread => {
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread.then_some(Preserve::Bounds))) {
@@ -714,10 +726,11 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
let (mut missing, mut modified) = (0, 0);
let mut links = vec![];
for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) {
let (status, found) = probe(g, folder(st.path.as_deref()));
let (status, found) = probe(g, st.path.as_deref());
missing += usize::from(status == Status::Missing);
modified += usize::from(status == Status::Modified);
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) });
note_refused(&mut row, &g.link, st.path.as_deref());
if let Some(f) = found.filter(|f| f.path != g.link.path) {
row["found"] = json!(f.path);
}
@@ -734,7 +747,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids");
let (mut reads, mut missing) = (vec![], vec![]);
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| !g.ids.is_empty()) {
match probe(&g, folder(st.path.as_deref())) {
match probe(&g, st.path.as_deref()) {
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
// Unchanged and showing the file: nothing to read unless asked for by id.
(Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {}
@@ -783,11 +796,9 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
None => {
let selected: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect();
match folder_param {
Some(_) if selected.is_empty() => groups(&st.doc, None)
.into_iter()
.filter(|g| probe(g, folder(st.path.as_deref())).0 == Status::Missing)
.flat_map(|g| g.ids)
.collect(),
Some(_) if selected.is_empty() => {
groups(&st.doc, None).into_iter().filter(|g| probe(g, st.path.as_deref()).0 == Status::Missing).flat_map(|g| g.ids).collect()
}
_ => selected,
}
}
@@ -969,7 +980,7 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option<String>, bool)> {
let mut out = BTreeMap::new();
for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) {
let (status, found) = probe(g, folder(st.path.as_deref()));
let (status, found) = probe(g, st.path.as_deref());
let found = found.map(|f| f.path).filter(|p| *p != g.link.path);
let preview = stale(&st.doc, g);
out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview))));
@@ -1110,7 +1121,7 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
done.extend(g.ids);
continue;
}
match probe(&g, folder(st.path.as_deref())) {
match probe(&g, st.path.as_deref()) {
(Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) {
Ok(read) => {
done.extend(&g.ids);
@@ -1190,10 +1201,13 @@ fn placement_json(o: PlacementOptions) -> Value {
/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`.
fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0);
let stamp = file_stamp(&l.path);
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0);
let doc_path = st.path.as_deref();
let path = fileio::document_path(&l.path, doc_path).ok();
let stamp = path.as_deref().and_then(file_stamp);
let folder_len = l.path.len().saturating_sub(l.name().len());
out["status"] = json!(status.as_str());
note_refused(out, l, doc_path);
out["path"] = json!(l.path);
out["fileName"] = json!(l.name());
out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\']));
@@ -1201,7 +1215,7 @@ fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value)
("page", l.page.map(u64::from)),
("fileSize", stamp.map(|s| s.0).or(l.size)),
("modified", stamp.and_then(|s| s.1).or(l.modified)),
("created", file_created(&l.path)),
("created", path.as_deref().and_then(file_created)),
] {
if let Some(v) = v {
out[k] = json!(v);
+7 -7
View File
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Pattern"],
None,
"{ids?, name?, tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}",
"{ids?, name? (trimmed; blank: New Pattern N), tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}",
has_selection,
pattern_make
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save a Copy",
[],
None,
"{name?} while editing, save the current tile as a new pattern swatch → {name}",
"{name? (trimmed; blank: <pattern> copy)} while editing, save the current tile as a new pattern swatch → {name}",
in_pattern_edit,
pattern_save_copy
),
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pattern Options",
["Window", "Pattern Options"],
None,
"{name? (default: the pattern being edited), newName?, tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
"{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
has_doc,
pattern_options
),
@@ -294,7 +294,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "width/height must be > 0"));
}
let edit = bool_or(p, "edit", true);
let wanted = str_param(p, "name").map(str::to_string);
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let name = s.edit("Make Pattern", |d, sel| {
let name = match &wanted {
Some(n) => unique_pattern_name(d, n),
@@ -388,12 +388,12 @@ fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
}
fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
let wanted = str_param(p, "name").map(str::to_string);
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let name = s.edit("Save a Copy", |d, _| {
let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?;
let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?;
let art: Vec<Arc<Node>> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default();
let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern)));
let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern.trim())));
def.name = name.clone();
def.art = copies(d, &art).into_iter().map(Arc::new).collect();
if def.size_tile_to_art {
@@ -419,7 +419,7 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("{k} must be > 0")));
}
}
let new_name = str_param(p, "newName").map(str::to_string).filter(|n| !n.is_empty() && *n != name);
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
if let Some(n) = &new_name
&& s.doc()?.doc.pattern(n).is_some()
{
+977
View File
@@ -0,0 +1,977 @@
//! Placing a Word document (`.docx`): its text set as area type with the character and paragraph
//! formatting VectorCraft has, read as ECMA-376 Part 1 describes WordprocessingML
//! (<https://ecma-international.org/publications-and-standards/standards/ecma-376/>), in its
//! Transitional and Strict namespaces.
//!
//! - The package is a ZIP archive ([`fileio::zip`]). `_rels/.rels` names the main document part
//! (else `word/document.xml`), and that part's relationships name its styles and theme parts
//! (else `styles.xml` and `theme/theme1.xml` next to it).
//! - Text: each paragraph (`w:p`) is a paragraph, and so is each table row, whose cells are
//! separated by tabs. A text-wrapping break (`w:br`) and a carriage return (`w:cr`) start a new
//! paragraph; page and column breaks are skipped. `w:tab` and `w:ptab` are tabs, and
//! `w:noBreakHyphen` and `w:softHyphen` are their hyphens. Text in insertions, moves, hyperlinks,
//! smart tags, content controls, custom XML and simple fields is read, and of a complex field,
//! its result.
//! - Character formatting: the font (`w:rFonts`: `w:asciiTheme`, `w:ascii`, `w:hAnsiTheme`, then
//! `w:hAnsi`; a theme font is the theme's major or minor Latin font), the size, bold, italic,
//! underline (every style but `none`), strikethrough and double strikethrough, all caps, small
//! caps, superscript and subscript (`w:vertAlign`), raised and lowered text (`w:position`) and
//! the color.
//! - Paragraph formatting: alignment (`w:jc`), start and end indents (`w:start` or `w:left`,
//! `w:end` or `w:right`: the right and left indents of a right-to-left paragraph), first-line and
//! hanging indents, space before and after, and direction (`w:bidi`).
//! - Styles (§17.7.2): the document defaults, then the paragraph style (else the default paragraph
//! style) and the styles it is based on, then the run's character style (else the default
//! character style) and the styles it is based on, then direct formatting. Bold, italic, caps,
//! small caps, strikethrough and hidden text are toggle properties (§17.7.3): a style that turns
//! one on reverses the state the levels before it give, and direct formatting sets it.
//!
//! Hidden text (`w:vanish`), deleted text, field instructions, drawings, pictures, objects, symbol
//! characters (`w:sym`), note and comment references, headers, footers, notes, comments and list
//! numbers are left out. A package with more than [`MAX_FILES`] files, a part that unpacks to more
//! than [`MAX_PART`] bytes or nests elements more than [`MAX_NESTING`] deep, and a document with
//! more than [`MAX_PARAGRAPHS`] paragraphs, [`MAX_RUNS`] runs of one formatting or
//! [`text::MAX_TEXT`] bytes of text are refused. Tables, content controls and other containers
//! nested more than [`MAX_DEPTH`] deep are skipped, and so are fields nested deeper.
use std::collections::HashMap;
use roxmltree::Node;
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
use super::text::{self, Story};
use super::*;
use crate::cmd::fileio::zip::{self, Archive, ReadError};
/// Word documents as `file.place` reports them (not a format `document.open` reads).
pub(super) const FORMAT: fileio::Format = fileio::Format {
id: "docx",
label: "Word",
extensions: fileio::DOCX_EXTS,
mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
read: true,
write: false,
raster: false,
options: &[],
};
/// The most files a Word document's package may hold.
const MAX_FILES: usize = 10_000;
/// The most bytes a part read from the package may unpack to.
const MAX_PART: usize = 16 << 20;
/// The most XML nodes a part read from the package may have.
const MAX_NODES: u32 = 2_000_000;
/// The deepest nesting of XML elements a part read from the package may have.
const MAX_NESTING: usize = 256;
/// The deepest nesting of tables, content controls and other containers read; deeper ones are
/// skipped, and so are fields nested deeper.
const MAX_DEPTH: usize = 64;
/// The most styles read from the styles part.
const MAX_STYLES: usize = 4096;
/// The most styles one style's `w:basedOn` chain takes in.
const MAX_BASED_ON: usize = 32;
/// The most fonts the document's formatting names.
const MAX_FONTS: usize = 4096;
/// The most bytes of a font name kept.
const MAX_NAME: usize = 256;
/// The most paragraphs placed type takes from one Word document.
const MAX_PARAGRAPHS: usize = 100_000;
/// The most runs of one character formatting placed type takes from one Word document.
const MAX_RUNS: usize = 100_000;
/// WordprocessingML's namespaces: Transitional, then Strict.
const W: [&str; 2] = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"];
/// Is the file `name` with `bytes` a Word document: a ZIP archive named `.docx`, or one that holds
/// `word/document.xml`?
pub(super) fn is_docx(name: &str, bytes: &[u8]) -> bool {
zip::is_zip(bytes)
&& (fileio::DOCX_EXTS.contains(&fileio::extension(name).as_str())
|| Archive::open(bytes, MAX_FILES).is_ok_and(|z| z.contains("word/document.xml")))
}
/// The text of a Word document's `bytes` (a ZIP package) with its character and paragraph
/// formatting.
pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> {
let zip = Archive::open(bytes, MAX_FILES).map_err(|e| package_error(cmd, e))?;
let main = relationship(&zip, "", "officeDocument", cmd)?.unwrap_or_else(|| "word/document.xml".into());
let Some(xml) = part(&zip, &main, cmd)? else { return Err(bad(cmd, "not a Word document: its package has no main document part")) };
let doc = parse(&xml, &main, cmd)?;
if local(doc.root_element()) != Some("document") {
return Err(bad(cmd, "not a Word document: its main part is not a WordprocessingML document"));
}
let folder = main.rfind('/').and_then(|i| main.get(..=i)).unwrap_or_default();
let mut r = Reader::default();
let theme = relationship(&zip, &main, "theme", cmd)?.unwrap_or_else(|| format!("{folder}theme/theme1.xml"));
if let Some(xml) = part(&zip, &theme, cmd)? {
r.theme_fonts(&parse(&xml, &theme, cmd)?);
}
let styles = relationship(&zip, &main, "styles", cmd)?.unwrap_or_else(|| format!("{folder}styles.xml"));
if let Some(xml) = part(&zip, &styles, cmd)? {
r.styles(&parse(&xml, &styles, cmd)?);
}
if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) {
r.block(body, 0);
}
r.finish(cmd)
}
/// The error for a package (or a part of it) the ZIP reader can't read.
fn package_error(cmd: &str, e: ReadError) -> EngineError {
bad(
cmd,
match e {
ReadError::NotZip => "not a Word document: it isn't a ZIP package".to_string(),
ReadError::TooManyFiles => format!("the Word document's package has more than {MAX_FILES} files"),
ReadError::Zip64 => "the Word document's package uses ZIP64 records, which VectorCraft doesn't read".to_string(),
ReadError::Encrypted(name) => format!("`{name}` in the Word document is encrypted"),
ReadError::Method(name, m) => format!("`{name}` in the Word document uses compression method {m}, which VectorCraft doesn't read"),
ReadError::TooLarge(name) => format!("`{name}` in the Word document is too large to place (at most {} MB unpacked)", MAX_PART >> 20),
ReadError::Damaged(name) if name.is_empty() => "the Word document's package is damaged".to_string(),
ReadError::Damaged(name) => format!("the Word document's package is damaged: `{name}` can't be read"),
},
)
}
/// The part `name` of the package as text (UTF-8, or UTF-16 with a byte-order mark); None: the
/// package has no such part.
fn part(zip: &Archive, name: &str, cmd: &str) -> Result<Option<String>> {
let Some(bytes) = zip.read(name, MAX_PART).map_err(|e| package_error(cmd, e))? else { return Ok(None) };
let utf16 = |rest: &[u8], le: bool| -> String {
let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
};
let text = match &bytes[..] {
[0xEF, 0xBB, 0xBF, rest @ ..] => std::str::from_utf8(rest).map(str::to_string),
[0xFF, 0xFE, rest @ ..] => Ok(utf16(rest, true)),
[0xFE, 0xFF, rest @ ..] => Ok(utf16(rest, false)),
_ => std::str::from_utf8(&bytes).map(str::to_string),
};
text.map(Some).map_err(|_| bad(cmd, format!("`{name}` in the Word document is not UTF-8 or UTF-16 text")))
}
/// The XML of the part `name`.
fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result<roxmltree::Document<'a>> {
// roxmltree parses nested elements recursively.
if nesting(xml) > MAX_NESTING {
return Err(bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")));
}
let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() };
roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")))
}
/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments,
/// CDATA sections, processing instructions, declarations and quoted attribute values.
fn nesting(xml: &str) -> usize {
let b = xml.as_bytes();
let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize);
// The index just past the first `end` at or after `from` (the end of the text if none).
let past = |from: usize, end: &[u8]| {
b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len())
};
while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) {
let start = i + at;
let rest = b.get(start..).unwrap_or_default();
if rest.starts_with(b"<!--") {
i = past(start + 4, b"-->");
} else if rest.starts_with(b"<![CDATA[") {
i = past(start + 9, b"]]>");
} else if rest.starts_with(b"<?") {
i = past(start + 2, b"?>");
} else if rest.starts_with(b"<!") || rest.starts_with(b"</") {
depth = depth.saturating_sub(usize::from(rest.starts_with(b"</")));
i = past(start + 2, b">");
} else {
// A start tag: up to its `>` outside quotes; `/>` closes it at once.
let mut j = start + 1;
let mut quote = None;
while let Some(&c) = b.get(j) {
match (quote, c) {
(None, b'"' | b'\'') => quote = Some(c),
(Some(q), _) if c == q => quote = None,
(None, b'>') => break,
_ => {}
}
j += 1;
}
if b.get(j.saturating_sub(1)) != Some(&b'/') {
depth += 1;
most = most.max(depth);
}
i = j + 1;
}
}
most
}
/// The part that the first relationship of part `source` (`""`: the package) whose type ends in
/// `/{kind}` points to; None when the package has no relationships part for `source` or it names
/// no such part.
fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result<Option<String>> {
let (folder, file) = source.rfind('/').map_or(("", source), |i| (source.get(..=i).unwrap_or_default(), source.get(i + 1..).unwrap_or_default()));
let rels = format!("{folder}_rels/{file}.rels");
let Some(xml) = part(zip, &rels, cmd)? else { return Ok(None) };
let doc = parse(&xml, &rels, cmd)?;
let target = doc
.root_element()
.children()
.filter(|n| n.tag_name().name() == "Relationship" && n.attribute("TargetMode") != Some("External"))
.find(|n| n.attribute("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind)))
.and_then(|n| n.attribute("Target"));
Ok(target.map(|t| resolve(folder, t)))
}
/// The part name `target` gives, relative to the folder `folder` (`""` or ending in `/`).
fn resolve(folder: &str, target: &str) -> String {
let joined = match target.strip_prefix('/') {
Some(absolute) => absolute.to_string(),
None => format!("{folder}{target}"),
};
let mut parts: Vec<&str> = vec![];
for segment in joined.split('/') {
match segment {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
/// `n`'s local name when it is a WordprocessingML element.
fn local<'a>(n: Node<'a, '_>) -> Option<&'a str> {
(n.is_element() && n.tag_name().namespace().is_some_and(|ns| W.contains(&ns))).then(|| n.tag_name().name())
}
/// `n`'s first WordprocessingML child element named `name`.
fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option<Node<'a, 'i>> {
n.children().find(|c| local(*c) == Some(name))
}
/// `n`'s WordprocessingML attribute `name`.
fn attr<'a>(n: Node<'a, '_>, name: &str) -> Option<&'a str> {
n.attributes().find(|a| a.name() == name && a.namespace().is_some_and(|ns| W.contains(&ns))).map(|a| a.value())
}
/// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`.
fn on(n: Node) -> bool {
!matches!(attr(n, "val"), Some("false" | "0" | "off"))
}
/// A measure `v` in units of `1 / per_pt` point: a number in those units, or a number with a unit
/// (`mm`, `cm`, `in`, `pt`, `pc`, `pi`).
fn measure(v: &str, per_pt: f64) -> Option<f64> {
const UNITS: [(&str, f64); 6] = [("mm", 72.0 / 25.4), ("cm", 72.0 / 2.54), ("in", 72.0), ("pt", 1.0), ("pc", 12.0), ("pi", 12.0)];
let v = v.trim();
let n = match UNITS.iter().find_map(|(unit, pt)| v.strip_suffix(unit).map(|n| (n, pt))) {
Some((n, pt)) => n.trim().parse::<f64>().ok()? * pt * per_pt,
None => v.parse::<f64>().ok()?,
};
n.is_finite().then_some(n)
}
/// Twentieths of a point.
fn twips(n: Node, name: &str) -> Option<f64> {
attr(n, name).and_then(|v| measure(v, 20.0))
}
/// Half points.
fn half_points(n: Node) -> Option<f64> {
attr(n, "val").and_then(|v| measure(v, 2.0))
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Script {
#[default]
Normal,
Super,
Sub,
}
/// The run properties one level of the style hierarchy sets (None: it doesn't set that one).
#[derive(Clone, Copy, Debug, Default)]
struct RunProps {
/// The font's number in [`Reader::fonts`].
font: Option<usize>,
half_points: Option<f64>,
bold: Option<bool>,
italic: Option<bool>,
caps: Option<bool>,
small_caps: Option<bool>,
strike: Option<bool>,
dstrike: Option<bool>,
hidden: Option<bool>,
underline: Option<bool>,
script: Option<Script>,
/// Raised (positive) or lowered text in half points.
shift: Option<f64>,
/// Some(None): automatic (the default color).
color: Option<Option<[u8; 3]>>,
}
/// Character formatting.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Chars {
font: Option<usize>,
/// None: VectorCraft's default size.
half_points: Option<f64>,
bold: bool,
italic: bool,
caps: bool,
small_caps: bool,
strike: bool,
dstrike: bool,
hidden: bool,
underline: bool,
script: Script,
shift: f64,
color: Option<[u8; 3]>,
}
impl Chars {
/// `p` applied over this formatting: as a style's (a toggle property turned on reverses its
/// state, turned off leaves it) or as direct formatting (it sets each property).
fn apply(&mut self, p: &RunProps, style: bool) {
let toggle = |state: &mut bool, v: Option<bool>| match v {
Some(true) if style => *state = !*state,
Some(v) if !style => *state = v,
_ => {}
};
toggle(&mut self.bold, p.bold);
toggle(&mut self.italic, p.italic);
toggle(&mut self.caps, p.caps);
toggle(&mut self.small_caps, p.small_caps);
toggle(&mut self.strike, p.strike);
toggle(&mut self.hidden, p.hidden);
self.font = p.font.or(self.font);
self.half_points = p.half_points.or(self.half_points);
self.dstrike = p.dstrike.unwrap_or(self.dstrike);
self.underline = p.underline.unwrap_or(self.underline);
self.script = p.script.unwrap_or(self.script);
self.shift = p.shift.unwrap_or(self.shift);
self.color = p.color.unwrap_or(self.color);
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Align {
#[default]
Left,
Center,
Right,
/// Justified, the last line at the start (the right of a right-to-left paragraph).
Both,
/// Justified, the last line too.
Distribute,
Start,
End,
}
/// The paragraph properties one level of the style hierarchy sets; lengths in twips.
#[derive(Clone, Copy, Debug, Default)]
struct ParaProps {
align: Option<Align>,
/// The indent at the paragraph's start (the left of a left-to-right paragraph).
start: Option<f64>,
/// The indent at the paragraph's end.
end: Option<f64>,
first: Option<f64>,
before: Option<f64>,
after: Option<f64>,
bidi: Option<bool>,
}
/// Paragraph formatting; lengths in twips.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Para {
align: Align,
start: f64,
end: f64,
first: f64,
before: f64,
after: f64,
rtl: Option<bool>,
}
impl Para {
fn apply(&mut self, p: &ParaProps) {
self.align = p.align.unwrap_or(self.align);
self.start = p.start.unwrap_or(self.start);
self.end = p.end.unwrap_or(self.end);
self.first = p.first.unwrap_or(self.first);
self.before = p.before.unwrap_or(self.before);
self.after = p.after.unwrap_or(self.after);
self.rtl = p.bidi.or(self.rtl);
}
}
/// A style of the styles part.
struct Style {
based_on: Option<String>,
run: RunProps,
para: ParaProps,
}
#[derive(Default)]
struct Reader {
/// The theme's major and minor Latin fonts.
theme: [Option<String>; 2],
fonts: Vec<String>,
font_numbers: HashMap<String, usize>,
styles: HashMap<String, Style>,
default_run: RunProps,
default_para: ParaProps,
/// The default paragraph style's id.
default_style: Option<String>,
/// The default character style's id.
default_char_style: Option<String>,
/// The paragraph being read: its formatting, and its run formatting before character styles
/// and direct formatting.
para: Para,
base: Chars,
/// A paragraph that has ended (its formatting and its mark's): its break goes before the
/// next text.
pending: Option<(Para, Chars)>,
/// The last cell's paragraph, which its row's break ends.
cell_end: Option<(Para, Chars)>,
/// A cell just ended: a tab goes before the next character of its row.
cell_tab: bool,
/// The complex fields open: true while their instructions are being read.
fields: Vec<bool>,
/// Fields begun past [`MAX_DEPTH`] and not yet ended.
field_overflow: usize,
/// The text, in runs of one character formatting; paragraphs end with `\n`.
spans: Vec<(String, Chars)>,
/// The bytes of text in `spans`.
len: usize,
/// The formatting of each paragraph that has ended.
paras: Vec<Para>,
/// Past [`MAX_PARAGRAPHS`], [`MAX_RUNS`] or [`text::MAX_TEXT`].
full: bool,
}
impl Reader {
/// The major and minor fonts of a theme part's font scheme.
fn theme_fonts(&mut self, doc: &roxmltree::Document) {
for (slot, scheme) in self.theme.iter_mut().zip(["majorFont", "minorFont"]) {
*slot = doc
.descendants()
.find(|n| n.tag_name().name() == scheme)
.and_then(|n| n.children().find(|c| c.tag_name().name() == "latin"))
.and_then(|n| n.attribute("typeface"))
.map(font_name)
.filter(|f| !f.is_empty());
}
}
/// The document defaults and the styles of the styles part.
fn styles(&mut self, doc: &roxmltree::Document) {
for n in doc.root_element().children() {
match local(n) {
Some("docDefaults") => {
let props = |outer: &str, inner: &str| child(n, outer).and_then(|d| child(d, inner));
if let Some(rpr) = props("rPrDefault", "rPr") {
self.default_run = self.run_props(rpr);
}
if let Some(ppr) = props("pPrDefault", "pPr") {
self.default_para = para_props(ppr);
}
}
Some("style") if self.styles.len() < MAX_STYLES => {
let Some(id) = attr(n, "styleId") else { continue };
let default = match attr(n, "type") {
Some("paragraph") => Some(&mut self.default_style),
Some("character") => Some(&mut self.default_char_style),
_ => None,
};
if let Some(default) = default.filter(|_| attr(n, "default").is_some_and(|v| matches!(v, "1" | "true" | "on"))) {
default.get_or_insert_with(|| id.to_string());
}
let run = child(n, "rPr").map(|r| self.run_props(r)).unwrap_or_default();
let para = child(n, "pPr").map(para_props).unwrap_or_default();
let based_on = child(n, "basedOn").and_then(|b| attr(b, "val")).map(str::to_string);
self.styles.entry(id.to_string()).or_insert(Style { based_on, run, para });
}
_ => {}
}
}
}
/// Style `id` and the styles it is based on, the first of the chain first.
fn chain(&self, id: Option<&str>) -> Vec<&Style> {
let mut ids: Vec<&str> = vec![];
let mut chain = vec![];
let mut next = id;
while let Some(id) = next {
if ids.contains(&id) || ids.len() >= MAX_BASED_ON {
break;
}
let Some(style) = self.styles.get(id) else { break };
ids.push(id);
chain.push(style);
next = style.based_on.as_deref();
}
chain.reverse();
chain
}
/// The number of the font a `w:rFonts` element names.
fn font(&mut self, n: Node) -> Option<usize> {
let theme = |v: &str| self.theme.get(usize::from(v.starts_with("minor"))).and_then(Option::as_deref);
let name = attr(n, "asciiTheme")
.and_then(theme)
.or_else(|| attr(n, "ascii"))
.or_else(|| attr(n, "hAnsiTheme").and_then(theme))
.or_else(|| attr(n, "hAnsi"))
.map(font_name)?;
if name.is_empty() {
return None;
}
if let Some(&i) = self.font_numbers.get(&name) {
return Some(i);
}
if self.fonts.len() >= MAX_FONTS {
return None;
}
self.fonts.push(name.clone());
self.font_numbers.insert(name, self.fonts.len() - 1);
Some(self.fonts.len() - 1)
}
/// The run properties of a `w:rPr` element.
fn run_props(&mut self, rpr: Node) -> RunProps {
let mut p = RunProps::default();
for n in rpr.children() {
match local(n) {
Some("rFonts") => p.font = self.font(n),
Some("b") => p.bold = Some(on(n)),
Some("i") => p.italic = Some(on(n)),
Some("caps") => p.caps = Some(on(n)),
Some("smallCaps") => p.small_caps = Some(on(n)),
Some("strike") => p.strike = Some(on(n)),
Some("dstrike") => p.dstrike = Some(on(n)),
Some("vanish") => p.hidden = Some(on(n)),
Some("u") => p.underline = Some(attr(n, "val") != Some("none")),
Some("vertAlign") => {
p.script = match attr(n, "val") {
Some("superscript") => Some(Script::Super),
Some("subscript") => Some(Script::Sub),
Some("baseline") => Some(Script::Normal),
_ => p.script,
}
}
Some("position") => p.shift = half_points(n).or(p.shift),
Some("sz") => p.half_points = half_points(n).filter(|v| *v > 0.0).or(p.half_points),
Some("color") => {
let hex = |v: &str| -> Option<[u8; 3]> {
let v = v.trim();
if v.len() != 6 || !v.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let byte = |i: usize| v.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
Some([byte(0)?, byte(2)?, byte(4)?])
};
p.color = match attr(n, "val") {
Some("auto") => Some(None),
Some(v) => hex(v).map(Some).or(p.color),
None => p.color,
}
}
_ => {}
}
}
p
}
/// The paragraphs, tables and containers of them that are `n`'s children.
fn block(&mut self, n: Node, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in n.children() {
if self.full {
return;
}
match local(c) {
Some("p") => self.paragraph(c, depth),
Some("tbl") => self.table(c, depth),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
self.block(content, depth + 1);
}
}
Some("customXml" | "ins" | "moveTo") => self.block(c, depth + 1),
_ => {}
}
}
}
/// A table: each row a paragraph, its cells separated by tabs.
fn table(&mut self, tbl: Node, depth: usize) {
for row in items(tbl, "tr", depth) {
for cell in items(row, "tc", depth) {
self.flush();
self.block(cell, depth + 1);
// The cell's last paragraph goes on in the next cell, after a tab.
let end = self.pending.take();
if self.cell_tab
&& let Some((_, mark)) = end
{
self.push('\t', mark);
}
self.cell_end = end.or(self.cell_end);
self.cell_tab = true;
}
self.cell_tab = false;
self.pending = self.cell_end.take();
}
}
/// A paragraph.
fn paragraph(&mut self, p: Node, depth: usize) {
self.flush();
let ppr = child(p, "pPr");
let style = ppr.and_then(|n| child(n, "pStyle")).and_then(|n| attr(n, "val")).map(str::to_string).or_else(|| self.default_style.clone());
let mut para = Para::default();
para.apply(&self.default_para);
let mut base = Chars::default();
base.apply(&self.default_run, true);
for s in self.chain(style.as_deref()) {
para.apply(&s.para);
base.apply(&s.run, true);
}
if let Some(n) = ppr {
para.apply(&para_props(n));
}
(self.para, self.base) = (para, base);
self.inline(p, depth + 1);
// The paragraph mark's run properties are direct formatting over the paragraph style's.
let mut mark = base;
if let Some(rpr) = ppr.and_then(|n| child(n, "rPr")) {
let props = self.run_props(rpr);
mark.apply(&props, false);
}
// A line break at the paragraph's end is written before the paragraph ends, and a hidden
// paragraph mark joins the paragraph to the next.
if !mark.hidden {
self.flush();
self.pending = Some((para, mark));
}
}
/// The runs of a paragraph, and the containers of runs in it.
fn inline(&mut self, n: Node, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in n.children() {
match local(c) {
Some("r") => self.run(c),
Some("hyperlink" | "ins" | "moveTo" | "smartTag" | "customXml" | "fldSimple" | "dir" | "bdo") => self.inline(c, depth + 1),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
self.inline(content, depth + 1);
}
}
_ => {}
}
}
}
/// A run.
fn run(&mut self, r: Node) {
let rpr = child(r, "rPr");
let mut chars = self.base;
let style = rpr.and_then(|n| child(n, "rStyle")).and_then(|n| attr(n, "val")).or(self.default_char_style.as_deref());
for s in self.chain(style) {
chars.apply(&s.run, true);
}
if let Some(n) = rpr {
let props = self.run_props(n);
chars.apply(&props, false);
}
for c in r.children() {
match local(c) {
Some("t") => {
// Comments and processing instructions may split the text.
for ch in c.children().filter(|n| n.is_text()).filter_map(|n| n.text()).flat_map(str::chars) {
self.put(ch, chars);
}
}
Some("tab" | "ptab") => self.put('\t', chars),
Some("noBreakHyphen") => self.put('\u{2011}', chars),
Some("softHyphen") => self.put('\u{AD}', chars),
Some("br") if matches!(attr(c, "type"), None | Some("textWrapping")) => self.line_break(chars),
Some("cr") => self.line_break(chars),
Some("fldChar") => match attr(c, "fldCharType") {
Some("begin") if self.fields.len() < MAX_DEPTH => self.fields.push(true),
Some("begin") => self.field_overflow += 1,
Some("separate") if self.field_overflow == 0 => {
if let Some(instructions) = self.fields.last_mut() {
*instructions = false;
}
}
Some("end") if self.field_overflow > 0 => self.field_overflow -= 1,
Some("end") => {
self.fields.pop();
}
_ => {}
},
_ => {}
}
}
}
/// Text is not read: hidden, or a field's instructions.
fn skipped(&self, chars: &Chars) -> bool {
chars.hidden || self.field_overflow > 0 || self.fields.contains(&true)
}
/// Character `c` of the document's text in formatting `chars`.
fn put(&mut self, c: char, chars: Chars) {
if self.skipped(&chars) {
return;
}
self.flush();
if std::mem::take(&mut self.cell_tab) {
self.push('\t', chars);
}
self.push(c, chars);
}
/// A line break: the rest of the paragraph is a new paragraph in the same formatting.
fn line_break(&mut self, chars: Chars) {
if self.skipped(&chars) {
return;
}
self.flush();
self.pending = Some((self.para, chars));
}
/// Writes the break of the paragraph that has ended.
fn flush(&mut self) {
let Some((para, mark)) = self.pending.take() else { return };
if self.paras.len() >= MAX_PARAGRAPHS {
self.full = true;
return;
}
self.paras.push(para);
// A cell that starts with a break is still separated from the cell before it.
if std::mem::take(&mut self.cell_tab) {
self.push('\t', mark);
}
self.push('\n', mark);
}
/// Adds `c` to the text in formatting `chars`.
fn push(&mut self, c: char, chars: Chars) {
self.len += c.len_utf8();
if self.len > text::MAX_TEXT {
self.full = true;
return;
}
let runs = self.spans.len();
match self.spans.last_mut() {
Some((s, f)) if *f == chars => s.push(c),
_ if runs >= MAX_RUNS => self.full = true,
_ => self.spans.push((c.to_string(), chars)),
}
}
/// The character attributes of formatting `c`.
fn style(&self, c: &Chars) -> CharStyle {
let mut s = CharStyle::default();
if let Some(name) = c.font.and_then(|i| self.fonts.get(i)) {
s.font_family.clone_from(name);
}
s.font_style = match (c.bold, c.italic) {
(true, true) => "Bold Italic",
(true, false) => "Bold",
(false, true) => "Italic",
(false, false) => "Regular",
}
.into();
if let Some(v) = c.half_points {
s.size = (v / 2.0).clamp(0.1, 1296.0);
}
s.baseline_shift = (c.shift / 2.0).clamp(-1296.0, 1296.0);
if let Some([r, g, b]) = c.color {
s.fill = Paint::solid(vectorcraft_color::Color::rgb8(r, g, b));
}
s.underline = c.underline;
s.strikethrough = c.strike || c.dstrike;
s.all_caps = c.caps;
// Document Setup's proportions replace these when the type is placed.
s.small_caps = c.small_caps.then_some(70.0);
s.position = match c.script {
Script::Normal => CharPosition::Normal,
Script::Super => CharPosition::Superscript(ScriptMetrics::DEFAULT),
Script::Sub => CharPosition::Subscript(ScriptMetrics::DEFAULT),
};
s
}
/// The text read, without blank paragraphs at its end, and each paragraph's attributes.
fn finish(mut self, cmd: &str) -> Result<Story> {
if self.full {
return Err(bad(
cmd,
format!(
"the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {} MB of text)",
text::MAX_TEXT >> 20
),
));
}
let last = self.pending.take().map_or(self.para, |(para, _)| para);
self.paras.push(last);
let mut all: String = self.spans.iter().map(|(s, _)| s.as_str()).collect();
while self.paras.len() > 1
&& let Some(at) = all.rfind('\n')
&& all.get(at + 1..).is_some_and(|tail| tail.trim().is_empty())
{
all.truncate(at);
self.paras.pop();
}
let mut keep = all.len();
let mut runs: Vec<TextRun> = vec![];
for (s, chars) in &self.spans {
let piece = s.get(..keep.min(s.len())).unwrap_or_default();
keep -= piece.len();
if piece.is_empty() {
continue;
}
let style = self.style(chars);
match runs.last_mut() {
Some(r) if r.style == style => r.text.push_str(piece),
_ => runs.push(TextRun::new(piece, style)),
}
}
let points = |v: f64| (v / 20.0).clamp(-1296.0, 1296.0);
let paras = self
.paras
.iter()
.map(|p| {
let rtl = p.rtl == Some(true);
let (left, right) = if rtl { (p.end, p.start) } else { (p.start, p.end) };
ParaStyle {
justify: match p.align {
Align::Left => Justify::Left,
Align::Center => Justify::Center,
Align::Right => Justify::Right,
Align::Both if rtl => Justify::JustifyRight,
Align::Both => Justify::JustifyLeft,
Align::Distribute => Justify::JustifyAll,
Align::Start => Justify::Auto,
Align::End if rtl => Justify::Left,
Align::End => Justify::Right,
},
left_indent: points(left),
right_indent: points(right),
first_line_indent: points(p.first),
space_before: points(p.before),
space_after: points(p.after),
direction: p.rtl.map(|rtl| if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }),
..ParaStyle::default()
}
})
.collect();
Ok(Story { runs, paras })
}
}
/// Font name `name` trimmed, and cut at the last character that ends within [`MAX_NAME`] bytes.
fn font_name(name: &str) -> String {
let name = name.trim();
let end = (0..=MAX_NAME.min(name.len())).rev().find(|&i| name.is_char_boundary(i)).unwrap_or_default();
name.get(..end).unwrap_or_default().to_string()
}
/// The paragraph properties of a `w:pPr` element.
fn para_props(ppr: Node) -> ParaProps {
let mut p = ParaProps::default();
for n in ppr.children() {
match local(n) {
Some("jc") => {
p.align = match attr(n, "val") {
Some("left") => Some(Align::Left),
Some("center") => Some(Align::Center),
Some("right") => Some(Align::Right),
Some("both" | "lowKashida" | "mediumKashida" | "highKashida") => Some(Align::Both),
Some("distribute" | "thaiDistribute") => Some(Align::Distribute),
Some("start") => Some(Align::Start),
Some("end") => Some(Align::End),
_ => p.align,
}
}
Some("ind") => {
// `w:left` and `w:right` (Transitional) are the start and end indents too.
p.start = twips(n, "start").or_else(|| twips(n, "left")).or(p.start);
p.end = twips(n, "end").or_else(|| twips(n, "right")).or(p.end);
// A hanging indent wins over a first-line indent.
p.first = twips(n, "hanging").map(|v| -v).or_else(|| twips(n, "firstLine")).or(p.first);
}
Some("spacing") => {
p.before = twips(n, "before").or(p.before);
p.after = twips(n, "after").or(p.after);
}
Some("bidi") => p.bidi = Some(on(n)),
_ => {}
}
}
p
}
/// `n`'s children named `name`, and those inside content controls and custom XML among them.
fn items<'a, 'i>(n: Node<'a, 'i>, name: &str, depth: usize) -> Vec<Node<'a, 'i>> {
let mut out = vec![];
if depth > MAX_DEPTH {
return out;
}
for c in n.children() {
match local(c) {
Some(l) if l == name => out.push(c),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
out.extend(items(content, name, depth + 1));
}
}
Some("customXml") => out.extend(items(c, name, depth + 1)),
_ => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measures_read_numbers_and_units() {
assert_eq!(measure("240", 20.0), Some(240.0));
assert_eq!(measure("12pt", 20.0), Some(240.0));
assert_eq!(measure("1in", 2.0), Some(144.0));
assert_eq!(measure("2.54cm", 1.0).map(|v| (v * 1e9).round() / 1e9), Some(72.0));
assert_eq!(measure("1e999", 20.0), None);
assert_eq!(measure("wide", 20.0), None);
}
#[test]
fn nesting_counts_elements_outside_markup_that_is_not_an_element() {
assert_eq!(nesting(r#"<?xml version="1.0"?><a><b x="1>2" y='</c>'><c/></b><!-- <d><e> --><![CDATA[<f><g>]]></a>"#), 2);
assert_eq!(nesting("<a><b><c>"), 3, "unclosed elements count");
assert_eq!(nesting("</a></b><a>"), 1);
assert_eq!(nesting("<a x=\"unterminated"), 1);
assert_eq!(nesting(""), 0);
}
#[test]
fn targets_resolve_against_their_folder() {
assert_eq!(resolve("", "word/document.xml"), "word/document.xml");
assert_eq!(resolve("word/", "styles.xml"), "word/styles.xml");
assert_eq!(resolve("word/", "../customXml/item1.xml"), "customXml/item1.xml");
assert_eq!(resolve("word/", "/word/theme/./theme1.xml"), "word/theme/theme1.xml");
}
}
+13 -1
View File
@@ -17,6 +17,8 @@
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
//! [`text`]).
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
//! - A Word document (`.docx`) becomes area type with the character and paragraph formatting its
//! styles and direct formatting give ([`docx`]).
//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document:
//! one locked object showing the artboard `page`, linked to the file and read again when it
//! changes ([`document`]); without Link, an editable copy of its art.
@@ -28,6 +30,7 @@
pub(crate) mod adopt;
pub(crate) mod document;
mod docx;
mod rtf;
mod text;
@@ -98,7 +101,8 @@ pub fn specs() -> Vec<CommandSpec> {
/// A file's art at its natural size, before it joins a document.
enum Art {
Image(RasterImage),
/// A text or RTF file's text, placed as area type filling the natural box.
/// A text file's, an RTF file's or a Word document's text, placed as area type filling the
/// natural box.
Text(text::Story),
/// A VectorCraft document placed linked: the file read, the link, and whether the art's
/// bounds (not the artboard) are shown.
@@ -155,6 +159,14 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
}
return Ok(Loaded { name, format: &rtf::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
}
// A Word document is recognized by its name or by its package's `word/document.xml`.
if docx::is_docx(src.name, &src.bytes) {
let story = docx::import(&src.bytes, cmd)?;
if story.plain_text().trim().is_empty() {
return Err(bad(cmd, format!("`{name}` has no text to place")));
}
return Ok(Loaded { name, format: &docx::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
}
let format = fileio::detect(src.name, &src.bytes)
.ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?;
let page = match p.get("page") {
+3 -2
View File
@@ -17,7 +17,8 @@ pub(super) const FORMAT: fileio::Format = fileio::Format {
options: &[],
};
/// The most bytes a text or RTF file placed as type may have.
/// The most bytes a text or RTF file placed as type may have, and the most bytes of text a Word
/// document may give.
pub(super) const MAX_TEXT: usize = 4 << 20;
/// Text Import Options (`file.place`'s `text` param).
@@ -190,7 +191,7 @@ fn tabs_for_spaces(s: &str, n: usize) -> String {
out
}
/// The text placed type gets from a text or RTF file.
/// The text placed type gets from a text file, an RTF file or a Word document.
pub(super) struct Story {
/// The text in runs of one character formatting; paragraphs are separated by `\n`.
pub(super) runs: Vec<vectorcraft_doc::TextRun>,
+4 -2
View File
@@ -89,8 +89,10 @@ pub const GPU_PREFERENCES: &[(&str, &str)] =
&[("automatic", "Automatic"), ("lowPower", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
/// Performance › Linux Display Server (`linuxDisplayServer`), read by the desktop app at startup:
/// `x11` opens the window through XWayland on a Wayland session, where dropped files arrive
/// (winit 0.30 has no Wayland drag and drop, #1151).
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] = &[("automatic", "Automatic"), ("x11", "X11 (XWayland)")];
/// (winit 0.30 has no Wayland drag and drop, #1151); `x11WithPen` does the same only when a pen is
/// attached as the app starts: under XWayland a drawing tablet's pen draws as a mouse (#2028).
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] =
&[("automatic", "Automatic"), ("x11", "X11 (XWayland)"), ("x11WithPen", "X11 (XWayland) When a Pen Is Attached")];
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
+33 -5
View File
@@ -58,7 +58,7 @@ macro_rules! style_cmds {
concat!($p, ".new"),
concat!("New ", $what, " Style"),
&["Window", "Type", $menu],
"{name?, attrs?: {…} (default: selected text), id?, start?: byte, end?: byte} → {name}; a nonempty character range samples its first selected character; start alone keeps insertion affinity; paragraph styles sample start",
"{name? (trimmed, as rename trims `to`), attrs?: {…} (default: selected text), id?, start?: byte, end?: byte} → {name}; a nonempty character range samples its first selected character; start alone keeps insertion affinity; paragraph styles sample start",
|s, p| new(s, p, $kind),
true,
),
@@ -432,10 +432,13 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let d = &s.doc()?.doc;
let base = if kind == Kind::Char { "Character Style" } else { "Paragraph Style" };
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
Some(n) => n.trim().to_string(),
None => (1..).map(|i| format!("{base} {i}")).find(|n| kind.attrs(d, n).is_none()).unwrap_or_else(|| base.to_string()),
};
if name.trim().is_empty() || kind.attrs(d, &name).is_some() {
if name.is_empty() {
return Err(bad(&c, "give the style a name"));
}
if kind.attrs(d, &name).is_some() {
return Err(bad(&c, format!("a style named `{name}` already exists")));
}
let n2 = name.clone();
@@ -552,10 +555,12 @@ fn duplicate(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let name = name_param(p, kind, "duplicate")?.to_string();
let d = &s.doc()?.doc;
let attrs = kind.attrs(d, &name).ok_or_else(|| bad(&kind.cmd("duplicate"), format!("no style `{name}`")))?;
// `new` trims the name, so the copy is named from the trimmed one.
let stem = name.trim();
let copy = (1..)
.map(|i| if i == 1 { format!("{name} copy") } else { format!("{name} copy {i}") })
.map(|i| if i == 1 { format!("{stem} copy") } else { format!("{stem} copy {i}") })
.find(|n| kind.attrs(d, n).is_none())
.unwrap_or_else(|| format!("{name} copy"));
.unwrap_or_else(|| format!("{stem} copy"));
new(s, &json!({ "name": copy, "attrs": attrs }), kind)
}
@@ -688,6 +693,29 @@ mod tests {
assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill);
}
/// New Character Style and New Paragraph Style trim the name they are given, as a rename
/// trims `to`; the trimmed name is the one compared with the styles there are.
#[test]
fn new_styles_trim_their_names_as_a_rename_does() {
let (mut s, _) = session_with_text("Body text");
for p in ["charStyle", "paraStyle"] {
assert_eq!(s.execute(&format!("{p}.new"), &json!({"name": " Body \t"})).unwrap()["name"], "Body", "{p}");
let list = s.execute(&format!("{p}.list"), &json!({})).unwrap();
let names: Vec<&str> = list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).collect();
assert_eq!(names[1..], ["Body"], "{p}");
let e = s.execute(&format!("{p}.new"), &json!({"name": " Body"})).unwrap_err().to_string();
assert!(e.contains("a style named `Body` already exists"), "{p}: {e}");
let e = s.execute(&format!("{p}.new"), &json!({"name": " \t "})).unwrap_err().to_string();
assert!(e.contains("give the style a name"), "{p}: {e}");
assert_eq!(s.execute(&format!("{p}.duplicate"), &json!({"name": "Body"})).unwrap()["name"], "Body copy", "{p}");
}
// A style whose name has spaces around it, as a saved file can hold, is copied under the
// trimmed name, numbered past the copies there are.
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: " Old ".into(), attrs: Map::new() });
s.execute("charStyle.new", &json!({"name": "Old copy"})).unwrap();
assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2");
}
/// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no
/// mojikumi) are still attributes a style can set, and a style captured with one applies to
/// text at the default, is duplicated and comes back out of a library.
+165 -2
View File
@@ -137,6 +137,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
}
}
}
let optical = if live.len() >= 2 { optical_margin(before, doc, &live) } else { None };
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
let refs: Vec<&TextObject> = frames.iter().collect();
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, &paras);
@@ -145,10 +146,12 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
let mut next = (**t).clone();
next.runs = part.runs;
next.set_paragraph_styles(part.paras);
if next.runs != t.runs || next.para != t.para || next.paras != t.paras {
let optical = optical.unwrap_or(t.optical_margin);
if next.runs != t.runs || next.para != t.para || next.paras != t.paras || optical != t.optical_margin {
t.runs = next.runs;
t.para = next.para;
t.paras = next.paras;
t.optical_margin = optical;
refresh_bounds(t);
}
}
@@ -160,6 +163,44 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
doc.text_threads = kept;
}
/// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes:
/// that of the first frame whose setting changed between `before` and `doc`, else the first frame's.
fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option<bool> {
let now = |id: &NodeId| text_of(doc, *id).map(|t| t.optical_margin);
let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.optical_margin) != now(id)));
changed.or(live.first()).and_then(now)
}
/// Give every frame of each thread the first frame's Optical Margin Alignment setting, without
/// re-flowing the story: for a document restored from a file as it was saved.
pub(crate) fn share_optical_margin(doc: &mut Document) {
for thread in doc.text_threads.clone() {
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
let Some(on) = optical_margin(doc, doc, &live) else { continue };
for id in &live {
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
&& t.optical_margin != on
{
t.optical_margin = on;
refresh_bounds(t);
}
}
}
}
/// `ids` followed by the other frames of the threads they are in, each once, in thread order.
pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = ids.to_vec();
for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| ids.contains(id))) {
for id in thread {
if !out.contains(id) && text_of(d, *id).is_some() {
out.push(*id);
}
}
}
out
}
/// A closed path converted to an empty area-type frame with `style`.
fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle) -> Option<Node> {
let NodeKind::Path { path, .. } = &n.kind else { return None };
@@ -280,7 +321,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
None => d.text_threads.push(vec![from, to]),
}
// `to` is new or changed, so [`reflow`] flows the story into it after this edit.
// Force a re-flow of the thread on this edit: the edit leaves existing area type `to`
// unchanged.
touch(d, to);
sel.set([from, to]);
Ok(to)
})?;
@@ -550,6 +593,17 @@ mod tests {
assert_eq!(threads(&s), [[a, c, d]]);
}
/// Area type threaded into from an out port takes the overflow at once, before its own text.
#[test]
fn add_into_area_type_flows_the_story_into_it() {
let (mut s, a, _) = setup();
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap());
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
assert_eq!(threads(&s), [[a, c]]);
assert_eq!(format!("{}{}", text(&s, a), text(&s, c)), format!("{}Own text.", TEXT.repeat(4)));
assert!(text(&s, c).len() > "Own text.".len(), "the overflow flowed into the frame: {:?}", text(&s, c));
}
#[test]
fn add_refuses_bad_params_and_leaves_the_document_alone() {
let (mut s, a, b) = setup();
@@ -595,4 +649,113 @@ mod tests {
assert_eq!(text(&s, b), "");
assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame");
}
/// A story of one-line paragraphs that each start with a quotation mark, threaded from area
/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for.
fn quoted_thread() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let story = "“Quoted.”\n".repeat(20);
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert!(text(&s, b).starts_with('“'));
(s, a, b)
}
/// Frame `id`'s Optical Margin Alignment setting, and whether its glyphs start left of its frame
/// (its lines' opening quotation marks hang).
fn optical(s: &Session, id: u64) -> (bool, bool) {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
(t.optical_margin, t.cached_bounds.unwrap().x0 < frame.bounds().unwrap().x0 - 1.0)
}
/// Optical Margin Alignment is a setting of the whole thread: Type › Optical Margin Alignment
/// in any frame sets it in every frame of the thread, in one undo step, and the lines in every
/// frame hang. Typing keeps it, and frames taken out of the thread keep it.
#[test]
fn optical_margin_alignment_is_set_for_every_frame_of_a_thread() {
let (mut s, a, b) = quoted_thread();
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let r = s.execute("type.opticalMarginAlignment", &json!({})).unwrap();
assert_eq!(r, json!({"on": true, "ids": [a, b]}));
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)), "the second frame's lines hang too");
// Turned off from the second frame, it is off in both, and one undo turns it back on in both.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false);
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
// Typing in the first frame re-flows the story and keeps the setting.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "“New.”\n"})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
// Any edit that sets one frame sets the thread.
s.edit("Set", |d, _| {
let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") };
t.optical_margin = false;
refresh_bounds(t);
Ok(())
})
.unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("edit.undo", &json!({})).unwrap();
// Released or unthreaded, each frame keeps it.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, true));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("text.thread.remove", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
}
/// Threading type into a thread gives the joined thread the setting of its first frame.
#[test]
fn a_thread_takes_its_first_frames_optical_margin_alignment() {
let (mut s, a, b) = quoted_thread();
s.execute("text.thread.remove", &json!({})).unwrap();
s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false));
// Threaded again in stacking order: the second frame takes the first one's setting.
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false));
// From `b`'s out port into `a`: `a` joins `b`'s thread after it and takes its setting.
s.execute("text.thread.add", &json!({"from": b, "to": a})).unwrap();
assert_eq!(threads(&s), [[b, a]]);
assert_eq!((optical(&s, a).0, optical(&s, b).0), (false, false));
// A new frame threaded from a frame takes the thread's setting.
s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap();
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 10]})).unwrap());
assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]);
}
/// A file saved with the frames of one thread set differently opens with the first frame's
/// setting in every frame: a native file, and an EPS file restored from the document it carries.
#[test]
fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() {
let (mut s, a, b) = quoted_thread();
for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] {
for (id, on) in [(a, first), (b, !first)] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.optical_margin = on;
}
let bytes = if name.ends_with(".eps") {
let r = s.execute("document.exportEps", &json!({})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
} else {
vectorcraft_format::save(&s.doc().unwrap().doc, false)
};
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
assert_eq!(threads(&s), [[a, b]], "{name}");
assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}");
}
}
}
+3 -3
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Optical Margin Alignment",
["Type"],
None,
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both → {on, ids}",
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both → {on, ids}",
has_selection,
optical_margin_alignment
),
@@ -107,9 +107,9 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
}
/// Type › Optical Margin Alignment: set `on` (without it, the opposite of the first object's
/// setting) on every selected text object, in one undo step.
/// setting) on every selected text object and every frame of their threads, in one undo step.
fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result<Value> {
let ids = text_targets(s, p, "Optical Margin Alignment")?;
let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Optical Margin Alignment")?);
let first = ids.first().and_then(|id| match s.doc().ok()?.doc.node(*id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.optical_margin),
_ => None,
+64 -13
View File
@@ -5,8 +5,9 @@
//! variable, or a graph root to a `graph` variable (CSV data), put a row of values in
//! each dataset, and `dataset.select` swaps the whole document to that row in one undo
//! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name`
//! then variable names); import also reads tab-separated files and files without the
//! `name` column. Agents reach every command through `run_command`, like all engine
//! then variable names); import also reads tab-separated files, files without the
//! `name` column, and header cells that define new variables (`#` for visibility, `@` for
//! image, no prefix for text). Agents reach every command through `run_command`, like all engine
//! commands.
use std::collections::{BTreeMap, BTreeSet};
@@ -29,8 +30,9 @@ const MAX_CSV_BYTES: usize = 1_048_576;
const MAX_CSV_ROWS: usize = 1_000;
const MAX_CSV_COLS: usize = 256;
/// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options) and
/// Make … Dynamic; binding, unbinding and the dataset commands still run.
/// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options),
/// Make … Dynamic and an import whose header would define a variable; binding, unbinding and the
/// other dataset commands still run.
fn variables_unlocked(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|st| st.doc.variables.locked) { Err("variables are locked".into()) } else { Ok(()) }
@@ -710,10 +712,52 @@ fn datasets_to_csv(vars: &Variables) -> String {
out
}
/// The variable that a datasets CSV header cell (trimmed, not empty) in 1-based `column` names,
/// and its kind. A cell that is the name of a variable in `kinds` or in `defined` (the variables
/// that earlier columns define) names that variable. Otherwise `#` before the name makes it a
/// visibility variable, `@` makes it an image variable, and a cell without a prefix names a text
/// variable. A prefixed name that is already a variable must be one of the prefix's kind; a new
/// name is added to `defined`. A `%` cell that names no variable (graph data files) is refused.
fn header_variable(
cell: &str,
column: usize,
kinds: &BTreeMap<String, VariableKind>,
defined: &mut Vec<Variable>,
cmd: &str,
) -> Result<(String, VariableKind)> {
let known = |name: &str, defined: &[Variable]| kinds.get(name).copied().or_else(|| defined.iter().find(|v| v.name == name).map(|v| v.kind));
if let Some(kind) = known(cell, defined) {
return Ok((cell.to_string(), kind));
}
let (name, kind) = if let Some(rest) = cell.strip_prefix('#') {
(rest.trim(), VariableKind::Visibility)
} else if let Some(rest) = cell.strip_prefix('@') {
(rest.trim(), VariableKind::Image)
} else if cell.starts_with('%') {
return Err(bad(cmd, format!("column {column}: `{cell}` is a `%` column of graph data files, which are not read")));
} else {
(cell, VariableKind::Text)
};
if name.is_empty() {
return Err(bad(cmd, format!("column {column}: `{cell}` names no variable")));
}
match known(name, defined) {
Some(k) if k == kind => Ok((name.to_string(), kind)),
Some(k) => Err(bad(cmd, format!("column {column}: `{cell}` names `{name}`, which is a {} variable", k.label()))),
None if name.chars().count() > MAX_NAME => Err(bad(cmd, format!("column {column}: the variable name is longer than {MAX_NAME} characters"))),
None => {
defined.push(Variable { name: name.to_string(), kind });
Ok((name.to_string(), kind))
}
}
}
/// Import datasets from CSV. A first header cell `name` makes the first column the dataset
/// names; the other headers are variable names. Without it, every header is a variable name
/// and the rows take the first free names "Data Set 1", "Data Set 2" and so on. Existing rows
/// of the same name are replaced; new names are appended. One undo step.
/// names; the other headers name variables, as [`header_variable`] reads them, and the
/// variables the document doesn't define yet are defined, unbound. Without the `name` column,
/// every header names a variable and the rows take the first free names "Data Set 1", "Data
/// Set 2" and so on. Existing rows of the same name are replaced; new names are appended. One
/// undo step.
fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "dataset.import";
let bytes = datasets_csv_bytes(p, C)?;
@@ -730,13 +774,18 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
(kinds, existing)
};
let mut columns: Vec<(usize, String, VariableKind)> = Vec::new();
let mut defined: Vec<Variable> = Vec::new();
for (i, h) in header.iter().enumerate().skip(usize::from(name_column)) {
let name = h.trim();
if name.is_empty() {
let cell = h.trim();
if cell.is_empty() {
return Err(bad(C, format!("empty header in column {}", i + 1)));
}
let kind = kinds.get(name).copied().ok_or_else(|| bad(C, format!("no variable named `{name}`")))?;
columns.push((i, name.to_string(), kind));
let (name, kind) = header_variable(cell, i + 1, &kinds, &mut defined, C)?;
columns.push((i, name, kind));
}
// Lock Variables refuses New Variable, so the import is refused when a header would define one.
if !defined.is_empty() {
variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?;
}
if rows.len() == 1 {
return Err(bad(C, "CSV has a header but no data rows"));
@@ -777,7 +826,9 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
parsed.push(DataSet { name, values });
}
let names: Vec<String> = parsed.iter().map(|d| d.name.clone()).collect();
let new_variables: Vec<String> = defined.iter().map(|v| v.name.clone()).collect();
let (imported, updated) = s.edit("Import Data Sets", |d, _| {
d.variables.variables.extend(defined);
let mut imported = 0usize;
let mut updated = 0usize;
for row in parsed {
@@ -791,7 +842,7 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((imported, updated))
})?;
Ok(json!({"imported": imported, "updated": updated, "names": names}))
Ok(json!({"imported": imported, "updated": updated, "names": names, "defined": new_variables}))
}
/// Export every dataset as CSV (`name` then variables). With `path`, write the file; else return `{csv}`.
@@ -1003,7 +1054,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Data Sets…",
["Window", "Variables"],
None,
"{path? | data? | dataBase64? | csv?} load datasets from CSV, comma- or tab-separated (header: name, then variable names; one row per dataset; without the name column every header is a variable and the rows take the first free names Data Set 1, 2…). Rows whose name already exists are replaced → {imported, updated, names}",
"{path? | data? | dataBase64? | csv?} load datasets from CSV, comma- or tab-separated (header: name, then variable names; one row per dataset; without the name column every header is a variable and the rows take the first free names Data Set 1, 2…). A header that names no variable defines one, unbound: `#Name` defines a visibility variable, `@Name` defines an image variable, and a header without a prefix defines a text variable. A prefixed header whose name is a variable of another kind, and a `%` header that isn't a variable's exact name (graph data files), are refused, and so is a header that would define a variable while the variables are locked. Rows whose name already exists are replaced → {imported, updated, names, defined}",
has_doc,
dataset_import
),
+23 -9
View File
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Web",
[],
None,
"{path?, preset?: a webExport.presets name (its settings, then these keys), format?: gif (default)|jpg|png8|png24, reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray (gif/png8, as the next five), colors?: 128 (2–256), dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, webSnap?: 0 (0–100 %: colours that close to a web-safe one become it), lossy?: 0 (0–100, gif), colorTable?: {locked?: [\"#rrggbb\"…] (kept when reducing), transparent?: [\"#rrggbb\"…] (become transparent), webShift?: [\"#rrggbb\"…] (become web-safe), sort?: none|hue|luminance|popularity} (colours as the preview's colors[].source), transparency?: true (gif/png8/png24), matte?: white (default)|black|none|\"#rrggbb\" (what edges blend into; jpg and opaque png24: the background), interlaced?: false (gif/png8/png24), quality?: 60 (jpg: 0–100), progressive?: false (jpg), optimized?: true (jpg), embedProfile?: false (jpg: sRGB profile), width?: px | height?: px | percent?: 100 (the image size, proportional; width wins over height, height over percent), antiAlias?: none|art (default)|type, clipToArtboard?: true (the artboard; false: the art and the slices), artboard?: 0 (0-based), convertToSrgb?: true (PNG files say they are sRGB), metadata?: none|copyright (default)|contact|all (File Info written as PNG text, a GIF or JPEG comment), slices?: all (default)|selected|none (one image per slice: images/<slice name>.<ext>), output?: images (default)|html (an HTML page placing the image or slices, <stem>.html)} → {path, files: [path…], bytes (total), width, height}; no path → {files: [{name, dataBase64}], bytes, width, height} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself",
"{path?, preset?: a webExport.presets name (its settings, then these keys), format?: gif (default)|jpg|png8|png24, reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray (gif/png8, as the next five), colors?: 128 (2–256), dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, webSnap?: 0 (0–100 %: colours that close to a web-safe one become it), lossy?: 0 (0–100, gif), colorTable?: {locked?: [\"#rrggbb\"…] (kept when reducing), transparent?: [\"#rrggbb\"…] (become transparent), webShift?: [\"#rrggbb\"…] (become web-safe), sort?: none|hue|luminance|popularity} (colours as the preview's colors[].source), transparency?: true (gif/png8/png24), matte?: white (default)|black|none|\"#rrggbb\" (what edges blend into; jpg and opaque png24: the background), interlaced?: false (gif/png8/png24), quality?: 60 (jpg: 0–100), progressive?: false (jpg), optimized?: true (jpg), embedProfile?: false (jpg: sRGB profile), width?: px | height?: px | percent?: 100 (the image size, proportional; width wins over height, height over percent), antiAlias?: none|art (default)|type, clipToArtboard?: true (the artboard; false: the art and the slices), artboard?: 0 (0-based), convertToSrgb?: true (PNG files say they are sRGB), metadata?: none|copyright (default)|contact|all (File Info written as PNG text, a GIF or JPEG comment), slices?: all (default)|selected|none (one image per slice: images/<slice name>.<ext>), output?: images (default)|html (an HTML page placing the image or slices, <stem>.html)} → {path, files: [path…], bytes (total), width, height, warnings: [] or [a note that an image couldn't be decoded and is missing from the image]}; no path → {files: [{name, dataBase64}], bytes, width, height, warnings} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself",
has_doc,
export
),
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Web Preview",
[],
None,
"{…the settings of document.exportForWeb (preset?, format?…), slice?: n (1-based, from slice.list; default the whole image), kbps?: 56.6 (connection speed for the download time), image?: false (also the optimised file as dataBase64)} what Save writes for the whole image (or one slice) → {format, width, height, bytes (exactly the file's size), seconds (to download at kbps), kbps, colors?: [{color: \"#rrggbb\", source: \"#rrggbb\" (the colour the reduction made, as colorTable lists it), transparent, locked, webShifted, webSafe}] (gif/png8: the colour table in file order), mappedToTransparent?: [\"#rrggbb\"…], slices: images an export writes, dataBase64?}",
"{…the settings of document.exportForWeb (preset?, format?…), slice?: n (1-based, from slice.list; default the whole image), kbps?: 56.6 (connection speed for the download time), image?: false (also the optimised file as dataBase64)} what Save writes for the whole image (or one slice) → {format, width, height, bytes (exactly the file's size), seconds (to download at kbps), kbps, colors?: [{color: \"#rrggbb\", source: \"#rrggbb\" (the colour the reduction made, as colorTable lists it), transparent, locked, webShifted, webSafe}] (gif/png8: the colour table in file order), mappedToTransparent?: [\"#rrggbb\"…], slices: images an export writes, warnings (as document.exportForWeb's), dataBase64?}",
has_doc,
preview
),
@@ -873,9 +873,11 @@ pub fn export_files(doc: &Document, selected: &[NodeId], s: &WebSettings, stem:
continue;
}
let base: String = doc.slice_name(c.area).chars().map(|ch| if ch.is_alphanumeric() || "-_.".contains(ch) { ch } else { '-' }).collect();
let base = unique_name(&base, |n| taken.contains(&n.to_lowercase()));
taken.insert(base.to_lowercase());
let name = format!("{IMAGES}/{base}.{ext}");
// A slice named after a Windows device (`aux`, `NUL`…) gets a `-` after the device name.
let file = |base: &str| super::fileio::safe_file_name(&format!("{base}.{ext}"));
let base = unique_name(&base, |n| taken.contains(&file(n).to_lowercase()));
taken.insert(file(&base).to_lowercase());
let name = format!("{IMAGES}/{}", file(&base));
let [x0, y0, x1, y1] = c.px;
images.push(WebFile { name: name.clone(), bytes: optimize(doc, &crop(&r, c.px), x1 - x0, y1 - y0, s)?.bytes });
placed.push((Some(c), Some(name)));
@@ -967,14 +969,17 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").filter(|p| !p.trim().is_empty());
let stem = match path {
Some(p) => super::fileio::file_stem(p),
None => super::fileio::file_stem(&st.doc.title),
// Without a path the files are named after the title, following Windows' file-naming rules.
None => super::fileio::safe_file_name(&super::fileio::file_stem(&st.doc.title)),
};
let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem };
let failed = vectorcraft_render::decode_failures();
let (files, (width, height)) = export_files(&st.doc, &selected, &settings, &stem).map_err(|e| bad(C, e))?;
let warnings = undecoded(failed);
let total: usize = files.iter().map(|f| f.bytes.len()).sum();
let Some(path) = path else {
let rows: Vec<Value> = files.iter().map(|f| json!({ "name": f.name, "dataBase64": vectorcraft_format::base64_encode(&f.bytes) })).collect();
return Ok(json!({ "files": rows, "bytes": total, "width": width, "height": height }));
return Ok(json!({ "files": rows, "bytes": total, "width": width, "height": height, "warnings": warnings }));
};
// Siblings keep the path's own separators.
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
@@ -988,7 +993,14 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
super::fileio::write_file(&target, &f.bytes)?;
written.push(target);
}
Ok(json!({ "path": written.first(), "files": written, "bytes": total, "width": width, "height": height }))
Ok(json!({ "path": written.first(), "files": written, "bytes": total, "width": width, "height": height, "warnings": warnings }))
}
/// The warnings of a rendering that started when this thread's decode failure count was `failed`:
/// the note the raster exports add (`fileio::encode::encode_all_of`) when an image has failed to
/// decode since then and so is missing from the image.
fn undecoded(failed: u64) -> Vec<&'static str> {
super::rasterfx::undecoded_warning(failed).into_iter().collect()
}
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1000,7 +1012,9 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
}
let st = s.doc()?;
let doc = &st.doc;
let failed = vectorcraft_render::decode_failures();
let r = render(doc, &settings).map_err(|e| bad(P, e))?;
let warnings = undecoded(failed);
let areas = doc.slice_layout();
let selected = super::slices::selected_slices(doc, &st.selection);
let cells = cells(&areas, &selected, &settings, &r).map_err(|e| bad(P, e))?;
@@ -1016,7 +1030,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
.map_err(|e| bad(P, e))?;
let mut out = json!({
"format": settings.format.id(), "width": o.width, "height": o.height, "bytes": o.bytes.len(),
"seconds": download_seconds(o.bytes.len(), kbps), "kbps": kbps, "slices": images,
"seconds": download_seconds(o.bytes.len(), kbps), "kbps": kbps, "slices": images, "warnings": warnings,
});
if settings.format.palette() {
out["colors"] = o
+4 -1
View File
@@ -918,7 +918,8 @@ pub struct Prefs {
/// Rasterizer worker threads; -1 = automatic.
pub render_threads: i32,
/// Linux: `automatic` opens the window on the session's display server; `x11` opens it through
/// XWayland on a Wayland session, where dropped files arrive (#1151). Read when the app starts.
/// XWayland on a Wayland session, where dropped files arrive (#1151); `x11WithPen` opens it
/// through XWayland only when a pen is attached (#2028). Read when the app starts.
pub linux_display_server: String,
// File Handling
pub background_save: bool,
@@ -2101,6 +2102,8 @@ mod tests_legacy_ai;
#[cfg(test)]
mod tests_library;
#[cfg(test)]
mod tests_link_paths;
#[cfg(test)]
mod tests_linked_stops;
#[cfg(test)]
mod tests_linkfiles;
+11 -6
View File
@@ -30,15 +30,19 @@ pub type Stamp = (u64, u128);
/// object ids, stamp)]).
pub type LinkScan = Arc<Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>>;
/// The stamp of the linked file stored as `path` in the document saved at `doc_path`; `None` when
/// there is no file, its path isn't followed ([`crate::cmd::fileio::document_path`]) or
/// automation may not read it.
#[cfg(not(target_arch = "wasm32"))]
fn stamp(path: &str) -> Option<Stamp> {
let meta = std::fs::metadata(path).ok()?;
fn stamp(path: &str, doc_path: Option<&str>) -> Option<Stamp> {
let path = crate::cmd::fileio::document_path(path, doc_path).ok()?;
let meta = crate::cmd::fileio::file_metadata(&path)?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some((meta.len(), modified.as_nanos()))
}
#[cfg(target_arch = "wasm32")]
fn stamp(_: &str) -> Option<Stamp> {
fn stamp(_: &str, _: Option<&str>) -> Option<Stamp> {
None
}
@@ -52,7 +56,7 @@ pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, st.path.as_deref()))).collect();
s.apply_link_stamps(uid, now, true)
}
@@ -64,14 +68,15 @@ impl Session {
return;
}
let Some(st) = self.active() else { return };
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
let (uid, files, doc_path) = (st.uid, crate::cmd::links::linked_groups(&st.doc), st.path.clone());
if files.is_empty() {
return;
}
let slot: LinkScan = Arc::default();
let out = slot.clone();
let scope = crate::file_access::ScopeHandle::capture();
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
let stamps = scope.run(|| files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, doc_path.as_deref()))).collect());
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
});
if spawned.is_ok() {
+33 -9
View File
@@ -133,22 +133,46 @@ impl Session {
}
/// Write the saved preset `preset` to the store, which held it as `old_name` when that is set
/// (a rename). Without a store, nothing happens.
/// (a rename). A rename writes the new file first and removes the old one only when that write
/// succeeded: after a failed write the store still holds the preset under its old name. When
/// the two names differ only in case, the old file is written and then renamed to the new name;
/// when that rename fails, the old file gets its content back. Without a store, nothing happens.
pub(crate) fn store_pdf_preset(&self, old_name: Option<&str>, preset: &PdfPreset) {
let Some(dir) = &self.pdf_preset_dir else { return };
let mut clean = preset.clone();
clean.settings.clear_passwords();
let file = format!("{}.{PRESET_FORMAT}", file_stem(&clean.name));
if let Some(old) = old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o)))
&& old != file
{
take(dir, &old);
}
match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) {
Ok(bytes) => {
let bytes = match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) {
Ok(bytes) => bytes,
Err(e) => {
log::warn!("can't save the PDF preset `{}`: {e}", preset.name);
return;
}
};
match old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o))).filter(|old| *old != file) {
// Names equal in any case get file names that differ only in case. A file system that
// ignores case holds them as one file, which removing the old name would delete.
Some(old) if old.to_lowercase() == file.to_lowercase() => match read_capped(&dir.join(&old), MAX_PRESET_FILE) {
Ok(before) => {
if put(dir, &old, &bytes)
&& let Err(e) = std::fs::rename(dir.join(&old), dir.join(&file))
{
log::warn!("can't rename {} to {file}: {e}", dir.join(&old).display());
put(dir, &old, &before);
}
}
Err(_) => {
put(dir, &file, &bytes);
}
},
Some(old) => {
if put(dir, &file, &bytes) {
take(dir, &old);
}
}
None => {
put(dir, &file, &bytes);
}
Err(e) => log::warn!("can't save the PDF preset `{}`: {e}", preset.name),
}
}
+21
View File
@@ -1708,6 +1708,27 @@ fn direct_selection_snaps_anchors_to_the_grid() {
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(201.0, 101.0));
}
/// #2054: with Snap to Grid on, a bounding-box handle dragged with the Selection tool or the Free
/// Transform tool lands the corner it moves on the grid, even with Snap to Pixel left on too; the
/// opposite corner stays put.
#[test]
fn bounding_box_handles_snap_to_the_grid() {
for tool in ["selection", "freeTransform"] {
let mut s = session();
let a = rect(&mut s, 101.0, 101.0, 100.0, 100.0);
let step = s.doc().unwrap().doc.grid.spacing / f64::from(s.doc().unwrap().doc.grid.subdivisions.max(1));
let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9;
let v = ViewInfo { snap_to_grid: true, snap_to_pixel: true, ..ViewInfo::default() };
s.select_tool(tool, v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 201.0, 201.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 230.0, 214.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 230.0, 214.0), v).unwrap();
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert!(on_grid(b.x1) && on_grid(b.y1), "{tool}: {b:?}, grid step {step}");
assert!((b.x0 - 101.0).abs() < 1e-9 && (b.y0 - 101.0).abs() < 1e-9, "{tool}: {b:?}");
}
}
/// Follow-up to #1254: toggling or adding anchors of a path selected as a whole starts from all
/// its anchors, so toggling one deselects it alone and adding one keeps them all.
#[test]
+43
View File
@@ -140,6 +140,32 @@ fn brush_options_rename_duplicate_and_delete_update_strokes() {
assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err());
}
/// New Brush, Duplicate Brush and Brush Options trim the name they are given, as the Brush Options
/// dialog does; the trimmed name is the one compared with the brushes there are, and a blank name
/// gives the name the command gives when there is none.
#[test]
fn new_duplicate_and_renamed_brushes_trim_their_names() {
let mut s = session();
let new = |s: &mut Session, name: &str| s.execute("brush.new", &json!({"type": "calligraphic", "name": name})).unwrap()["name"].clone();
assert_eq!(new(&mut s, " Nib \t"), "Nib");
assert_eq!(new(&mut s, " Nib"), "Nib 2");
assert_eq!(new(&mut s, " \t "), "New Calligraphic Brush");
assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " Quill\t"})).unwrap()["name"], "Quill");
assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " "})).unwrap()["name"], "Nib copy");
assert_eq!(s.execute("brush.options", &json!({"name": "Nib 2", "newName": " Pen "})).unwrap()["name"], "Pen");
assert_eq!(s.execute("brush.options", &json!({"name": "Pen", "newName": " "})).unwrap()["name"], "Pen");
let e = s.execute("brush.options", &json!({"name": "Pen", "newName": " Quill"})).unwrap_err().to_string();
assert!(e.contains("a brush named `Quill` exists"), "{e}");
// Duplicate Brush names the copy of a brush whose saved name has white space around it, as a
// saved file can hold, from the trimmed name (" Old " gives "Old copy").
let mut lib = vectorcraft_brush::library(&s.doc().unwrap().doc);
lib.iter_mut().filter(|b| b.name == "Pen").for_each(|b| b.name = " Old ".into());
vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &lib);
assert_eq!(s.execute("brush.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy");
let names: Vec<String> = vectorcraft_brush::library(&s.doc().unwrap().doc).into_iter().map(|b| b.name).collect();
assert_eq!(names[names.len() - 6..], ["Nib", " Old ", "New Calligraphic Brush", "Quill", "Nib copy", "Old copy"]);
}
/// The Brushes panel's multi-selection: `names` deletes or duplicates several brushes in one undo
/// step, and an unknown name changes nothing.
#[test]
@@ -541,6 +567,23 @@ fn delete_duplicate_and_replace_symbols() {
assert!(s.execute("symbol.delete", &json!({"name": "Small"})).is_err());
}
/// New Symbol and Duplicate Symbol trim the name they are given, as Symbol Options trims
/// `newName`; the trimmed name is the one compared with the symbols there are.
#[test]
fn new_and_duplicate_symbols_trim_their_names_as_a_rename_does() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert_eq!(s.execute("symbol.new", &json!({"name": " Box \t"})).unwrap()["name"], "Box");
rect(&mut s, 20.0, 0.0, 10.0, 10.0);
assert_eq!(s.execute("symbol.new", &json!({"name": " Box"})).unwrap()["name"], "Box 2");
rect(&mut s, 40.0, 0.0, 10.0, 10.0);
assert_eq!(s.execute("symbol.new", &json!({"name": " "})).unwrap()["name"], "New Symbol");
assert_eq!(s.execute("symbol.duplicate", &json!({"name": "Box", "newName": " Logo\t"})).unwrap()["name"], "Logo");
assert_eq!(s.execute("symbol.duplicate", &json!({"name": "Box", "newName": " "})).unwrap()["name"], "Box copy");
let names: Vec<String> = s.doc().unwrap().doc.symbols.iter().map(|x| x.name.clone()).collect();
assert_eq!(names, ["Box", "Box 2", "New Symbol", "Logo", "Box copy"]);
}
#[test]
fn symbol_export_type_defaults_to_movie_clip_and_round_trips() {
let mut s = session();
+32
View File
@@ -97,6 +97,38 @@ fn exported_pdf_pastes_back_as_vectors() {
assert!(t.execute("clipboard.importPdf", &json!({})).is_err());
}
#[test]
fn the_png_and_pdf_say_when_an_image_or_a_font_is_missing() {
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
let warnings = |r: &Value| -> Vec<String> { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() };
let mut s = session();
copy_red_square(&mut s);
assert_eq!(run(&mut s, "clipboard.exportPng", json!({}))["warnings"], json!([]));
assert_eq!(run(&mut s, "clipboard.exportPdf", json!({}))["warnings"], json!([]));
// Type in a family that isn't installed: the PNG and the PDF each name it.
let text = run(&mut s, "text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [text]}));
run(&mut s, "edit.copy", json!({}));
let png = warnings(&run(&mut s, "clipboard.exportPng", json!({})));
assert!(png.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{png:?}");
let pdf = warnings(&run(&mut s, "clipboard.exportPdf", json!({})));
assert!(pdf.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{pdf:?}");
// Copy's formats carry the notes of both, each once.
let (flavours, notes) = s.clipboard.flavours_with_warnings(false, true);
assert_eq!(flavours.iter().map(|f| f.mime).collect::<Vec<_>>(), [TEXT, PDF, PNG]);
for w in png.iter().chain(&pdf) {
assert_eq!(notes.iter().filter(|n| *n == w).count(), 1, "{w} in {notes:?}");
}
// An image the renderer can't decode: the PNG leaves it out and says so.
let image = crate::tests_menucmds::place_undecodable_png(&mut s);
run(&mut s, "select.set", json!({"ids": [image.0]}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(warnings(&run(&mut s, "clipboard.exportPng", json!({}))), [NOTE]);
let (flavours, notes) = s.clipboard.flavours_with_warnings(false, false);
assert_eq!(flavours.iter().map(|f| f.mime).collect::<Vec<_>>(), [PNG]);
assert_eq!(notes, [NOTE]);
}
#[test]
fn import_image_then_paste_gives_an_image() {
let mut s = session();
+22
View File
@@ -115,3 +115,25 @@ fn export_to_a_path_puts_the_pictures_next_to_the_sheet() {
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn export_says_once_when_a_rasterized_image_could_not_be_decoded() {
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
let notes = |r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str() == Some(NOTE)).count();
let mut s = session();
run(&mut s, "shape.star", json!({"cx": 100, "cy": 100, "radius1": 40, "radius2": 20}));
assert_eq!(notes(&run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}))), 0);
// Two images the renderer can't decode, each rasterized to a picture of its own.
crate::tests_menucmds::place_undecodable_png(&mut s);
crate::tests_menucmds::place_undecodable_png(&mut s);
let r = run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}));
assert_eq!(r["images"].as_array().unwrap().len(), 3, "{r}");
assert_eq!(notes(&r), 1, "{}", r["warnings"]);
let dir = std::env::temp_dir().join(format!("vc-css-undecoded-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let r = run(&mut s, "css.export", json!({"path": dir.join("site.css").to_string_lossy(), "scope": "all", "rasterize": true}));
assert_eq!(r["images"].as_array().unwrap().len(), 3, "the pictures are still written");
assert_eq!(notes(&r), 1, "{}", r["warnings"]);
std::fs::remove_dir_all(&dir).ok();
}
+75
View File
@@ -199,6 +199,81 @@ fn character_styles_follow_the_setup_sizes() {
assert_eq!(def.attrs["small_caps"], json!(80.0));
}
/// A file whose type, symbols, graph label styles, character styles or pattern definition saved in
/// pattern editing mode hold superscript, subscript or small caps proportions outside the ranges
/// `document.setup` takes opens with each at the nearer end of its range; the other character
/// attributes open as saved.
#[test]
fn a_file_with_script_proportions_out_of_range_opens_with_them_in_range() {
let mut s = session();
let id = create_text(&mut s, "E=mc2");
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 4, "end": 5, "position": "superscript"})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id.0], "smallCaps": true})).unwrap();
let water = create_text(&mut s, "H2O");
s.execute("text.setRangeStyle", &json!({"id": water.0, "start": 1, "end": 2, "position": "subscript"})).unwrap();
let csv = ",2024\nQ1,3\nQ2,5";
let graph = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 150, "width": 200, "height": 100, "csv": csv})).unwrap();
let graph = NodeId(graph["id"].as_u64().unwrap());
let exponent = create_text(&mut s, "x2");
s.execute("text.setRangeStyle", &json!({"id": exponent.0, "start": 1, "end": 2, "position": "superscript"})).unwrap();
s.execute("select.set", &json!({"ids": [exponent.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Exponent", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
// Saved in pattern editing mode: Cancel puts back the definition from before the edit.
s.execute("object.pattern.edit", &json!({"name": "Exponent"})).unwrap();
let mut d = (*s.doc().unwrap().doc).clone();
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.runs[0].style.small_caps = Some(-5.0);
let two = &mut t.runs[1].style;
two.position = CharPosition::Superscript(ScriptMetrics { size: 0.0, position: f64::MAX });
two.small_caps = Some(f64::MAX);
}
if let Some(NodeKind::Text(t)) = d.node_mut(water).map(|n| &mut n.kind) {
t.runs[1].style.position = CharPosition::Subscript(ScriptMetrics { size: f64::MAX, position: -1000.0 });
t.runs[1].style.tracking = -5000.0;
}
let mut art = d.node(id).unwrap().clone();
art.id = NodeId(9000);
d.symbols.push(vectorcraft_doc::Symbol { name: "Formula".into(), art: Arc::new(art) });
if let Some(g) = d.node_mut(graph).and_then(|n| n.extras_if_any()).and_then(|e| e.graph.as_deref_mut()) {
let position = CharPosition::Subscript(ScriptMetrics { size: 0.0, position: 1000.0 });
g.label_styles.legend = Some(vectorcraft_doc::CharStyle { position, small_caps: Some(0.0), ..vectorcraft_doc::CharStyle::default() });
}
let original = d.pattern_edit.as_mut().and_then(|pe| pe.original.as_mut()).and_then(|p| p.art.first_mut());
if let Some(NodeKind::Text(t)) = original.map(|a| &mut Arc::make_mut(a).kind) {
t.runs[1].style.position = CharPosition::Superscript(ScriptMetrics { size: 0.0, position: f64::MAX });
}
let attrs = json!({"position": {"kind": "superscript", "size": 1000.0, "position": -1000.0}, "small_caps": 0.0, "tracking": -5000.0});
d.char_styles.push(TextStyleDef { name: "Note".into(), attrs: attrs.as_object().unwrap().clone() });
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
s.execute("document.open", &json!({"name": "scripts.vectorcraft", "dataBase64": data})).unwrap();
let raised = CharPosition::Superscript(ScriptMetrics { size: 1.0, position: 500.0 });
let t = text(&s, id);
assert_eq!((t.runs[0].style.small_caps, t.runs[1].style.small_caps), (Some(1.0), Some(200.0)));
assert_eq!(t.runs[1].style.position, raised);
let t = text(&s, water);
assert_eq!(t.runs[1].style.position, CharPosition::Subscript(ScriptMetrics { size: 500.0, position: -500.0 }));
assert_eq!(t.runs[1].style.tracking, -5000.0, "other attributes open as saved");
let doc = &s.doc().unwrap().doc;
match &doc.symbols.iter().find(|s| s.name == "Formula").unwrap().art.kind {
NodeKind::Text(t) => assert_eq!(t.runs[1].style.position, raised),
_ => panic!("the symbol's art is not text"),
}
let legend = doc.node(graph).unwrap().extras().graph.as_ref().unwrap().label_styles.legend.clone().unwrap();
assert_eq!((legend.position, legend.small_caps), (CharPosition::Subscript(ScriptMetrics { size: 1.0, position: 500.0 }), Some(1.0)));
let def = doc.char_styles.iter().find(|d| d.name == "Note").unwrap();
assert_eq!(def.attrs["position"], json!({"kind": "superscript", "size": 500.0, "position": -500.0}));
assert_eq!((&def.attrs["small_caps"], &def.attrs["tracking"]), (&json!(1.0), &json!(-5000.0)));
s.execute("object.pattern.cancel", &json!({})).unwrap();
match &s.doc().unwrap().doc.pattern("Exponent").unwrap().art[0].kind {
NodeKind::Text(t) => assert_eq!(t.runs[1].style.position, raised),
_ => panic!("the pattern's art is not text"),
}
// With the file's proportions kept as saved, the move failed with "result would exceed the canvas".
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
}
#[test]
fn setup_survives_save_and_load() {
let mut s = session();
+31
View File
@@ -43,6 +43,18 @@ fn libraries_are_made_renamed_and_deleted() {
assert!(s.execute("library.get", &json!({"library": "Client"})).is_err());
}
/// A library's file is named after it, and a Windows device name (`CON`, `NUL`, `COM1`…) gets a
/// `-` after it, on every platform.
#[test]
fn a_library_named_after_a_windows_device_gets_a_dash() {
let mut s = session();
let ids: Vec<String> = ["CON", "nul", "Com1", "LPT³", "Console", "COM10"]
.iter()
.map(|name| run(&mut s, "library.create", json!({"name": name}))["id"].as_str().unwrap().to_string())
.collect();
assert_eq!(ids, ["CON-", "nul-", "Com1-", "LPT³-", "Console", "COM10"]);
}
#[test]
fn a_graphic_goes_into_a_library_and_comes_out_in_another_document() {
let mut s = session();
@@ -131,6 +143,25 @@ fn text_styles_come_from_the_selected_type_and_apply_in_one_step() {
assert_eq!(r["style"], format!("{name} 2"));
}
/// A text style that a library file names with spaces around it comes into the document with the
/// name trimmed, as New Character Style and New Paragraph Style trim a name, and applies.
#[test]
fn a_library_style_named_with_spaces_comes_in_trimmed() {
let mut s = session();
let style = |attrs: Value| json!([{"name": " Body ", "attrs": attrs}]);
let data = json!({"name": "Spaced", "charStyles": style(json!({"size": 31.0})), "paraStyles": style(json!({}))});
let lib = run(&mut s, "library.import", json!({"data": data.to_string()}))["id"].as_str().unwrap().to_string();
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [t]}));
for kind in ["charStyle", "paraStyle"] {
assert_eq!(run(&mut s, "library.use", json!({"library": lib, "kind": kind, "item": " Body "}))["style"], "Body", "{kind}");
}
let d = &s.doc().unwrap().doc;
assert_eq!((d.char_styles[0].name.as_str(), d.para_styles[0].name.as_str()), ("Body", "Body"));
let vectorcraft_doc::NodeKind::Text(text) = &d.node(NodeId(t.as_u64().unwrap())).unwrap().kind else { panic!("not text") };
assert_eq!((text.runs[0].style.size, text.runs[0].style.style_name.as_deref()), (31.0, Some("Body")));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn libraries_are_kept_in_files_across_sessions() {
+120
View File
@@ -0,0 +1,120 @@
//! Paths stored with linked files and SVG images are followed only on this computer or on the
//! document's own share (`fileio::document_path`): opening a document, Link Info, watching linked
//! files, and the images an SVG file links to.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use super::*;
use crate::cmd::fileio::touched;
use crate::tests_links::{Folder, RED, place, png, session};
/// Paths to other computers (and, on Unix, a local file written as one: `//tmp/…`, which Linux
/// reads as `/tmp/…`).
fn far(dir: &Folder) -> Vec<String> {
let mut out = vec![r"\\192.0.2.1\share\x.png".to_string(), "file://192.0.2.1/s/x.png".to_string()];
if cfg!(unix) {
out.push(format!("/{}", dir.file("far/far.png")));
}
out
}
/// Did any path read reach another computer (or the local file written as one)?
fn far_reads(seen: &[String]) -> Vec<&String> {
seen.iter().filter(|p| p.contains("192.0.2.1") || p.starts_with("//") || p.starts_with(r"\\")).collect()
}
/// A document saved at `dir/doc.vectorcraft` with one linked image per path in `paths`, each
/// showing `dir/pic.png` until its link is set → the document's path.
fn save_linked(dir: &Folder, paths: &[String]) -> String {
let pic = dir.file("pic.png");
std::fs::write(&pic, png(8, 8, RED)).unwrap();
let mut s = session();
let ids: Vec<NodeId> = paths.iter().map(|_| place(&mut s, &pic)).collect();
let mut doc = (*s.doc().unwrap().doc).clone();
for (id, path) in ids.iter().zip(paths) {
let Some(NodeKind::Image(im)) = doc.node_mut(*id).map(|n| &mut n.kind) else { panic!("not an image") };
im.link = Some(vectorcraft_doc::LinkInfo::new(path.clone()));
}
let path = dir.file("doc.vectorcraft");
std::fs::write(&path, vectorcraft_format::save(&doc, false)).unwrap();
path
}
fn names(rows: &Value) -> Vec<&str> {
rows.as_array().unwrap().iter().map(|r| r["path"].as_str().unwrap()).collect()
}
#[test]
fn linked_files_on_other_computers_are_never_read() {
let dir = Folder::new("docpath-open");
std::fs::create_dir_all(dir.0.join("far")).unwrap();
std::fs::write(dir.file("far/far.png"), png(8, 8, RED)).unwrap();
std::fs::write(dir.file("near.png"), png(8, 8, RED)).unwrap();
let far = far(&dir);
let mut paths = far.clone();
paths.push("near.png".into());
let doc = save_linked(&dir, &paths);
let mut s = Session::new();
let (r, seen) = touched(|| s.execute("document.open", &json!({"path": doc})).unwrap());
assert_eq!(far_reads(&seen), Vec::<&String>::new());
// The far paths are missing, saying why; the file beside the document is found.
assert_eq!(names(&r["missingLinks"]), far.iter().map(String::as_str).collect::<Vec<_>>(), "{r}");
for row in r["missingLinks"].as_array().unwrap() {
assert_eq!(row["refused"], "the path is on another computer", "{row}");
}
let check = s.execute("links.check", &json!({})).unwrap();
let near = check["links"].as_array().unwrap().iter().find(|l| l["name"] == "near.png").unwrap();
assert_ne!(near["status"], "missing", "{check}");
// Link Info and the linked-file watch don't look at them either.
let ids: Vec<u64> = r["missingLinks"].as_array().unwrap().iter().map(|row| row["ids"][0].as_u64().unwrap()).collect();
let (_, seen) = touched(|| {
for id in &ids {
let info = s.execute("links.info", &json!({"id": id})).unwrap();
assert_eq!(info["refused"], "the path is on another computer", "{info}");
}
s.execute("links.updateChanged", &json!({})).unwrap();
s.start_link_scan();
let t0 = std::time::Instant::now();
while s.poll_link_scan().is_none() {
assert!(t0.elapsed().as_secs() < 10, "the look never finished");
std::thread::yield_now();
}
});
assert_eq!(far_reads(&seen), Vec::<&String>::new());
// The watch kept no stamp of them (`//tmp/…` would have one had it been looked at).
assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys());
}
#[test]
fn svg_images_on_other_computers_are_never_read() {
let dir = Folder::new("docpath-svg");
std::fs::create_dir_all(dir.0.join("far")).unwrap();
std::fs::write(dir.file("far/far.png"), png(8, 8, RED)).unwrap();
std::fs::write(dir.file("near.png"), png(8, 8, RED)).unwrap();
let mut hrefs = vec![r"\\192.0.2.1\share\x.png".to_string(), "file:////192.0.2.1/s/x.png".to_string(), "file://192.0.2.1/s/x.png".to_string()];
if cfg!(unix) {
hrefs.push(format!("/{}", dir.file("far/far.png")));
}
hrefs.push("near.png".into());
let images: String = hrefs.iter().map(|h| format!("<image width=\"8\" height=\"8\" href=\"{h}\"/>")).collect();
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\">{images}</svg>");
let path = dir.file("art.svg");
std::fs::write(&path, &svg).unwrap();
let mut s = Session::new();
let (r, seen) = touched(|| s.execute("document.open", &json!({"path": path})).unwrap());
assert_eq!(far_reads(&seen), Vec::<&String>::new());
// Every far image is a placeholder; only the one beside the SVG is read.
let warnings = r["warnings"].as_array().unwrap();
assert_eq!(warnings.iter().filter(|w| w.as_str().unwrap().contains("a placeholder keeps its place")).count(), hrefs.len() - 1, "{r}");
let near = |p: &&String| p.contains("docpath-svg") && p.ends_with("near.png");
assert!(seen.iter().any(|p| near(&p)), "{seen:?}");
// Pasted SVG text has no folder: only absolute local paths are read.
let (_, seen) = touched(|| crate::cmd::fileio::import_svg(&svg, None).unwrap());
assert_eq!(far_reads(&seen), Vec::<&String>::new());
assert!(!seen.iter().any(|p| near(&p)), "{seen:?}");
}
+17
View File
@@ -130,6 +130,23 @@ fn duplicate_artboard_copies_its_art() {
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1);
}
/// New Artboard and Artboard Options trim the name they are given, as the Artboards panel's rename
/// does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard.
#[test]
fn new_and_renamed_artboards_trim_their_names() {
let mut s = session();
let new = |s: &mut Session, name: &str| s.execute("artboard.new", &json!({"name": name})).unwrap();
new(&mut s, " Cover \t");
new(&mut s, " \t ");
new(&mut s, "");
let names = |s: &Session| boards(s).into_iter().map(|(_, n)| n).collect::<Vec<_>>();
assert_eq!(names(&s), ["Artboard 1", "Cover", "Artboard 3", "Artboard 4"]);
s.execute("artboard.setProps", &json!({"index": 0, "name": " Back\t"})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "name": " "})).unwrap();
s.execute("artboard.setProps", &json!({"index": 3, "name": ""})).unwrap();
assert_eq!(names(&s), ["Back", "Artboard 2", "Artboard 3", "Artboard 4"]);
}
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an
/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the
/// artboards, the art and the clipboard stay as they were.
+33
View File
@@ -66,6 +66,39 @@ fn ranged_paragraph_commands_touch_only_their_paragraphs() {
assert_eq!(text(&s, id).para_at(2).space_after, 1.0, "past the end: the last paragraph");
}
/// A file whose paragraphs hold an indent or a space before or after past `MAX_COORD`, or a tab
/// stop outside 0 to 100,000 pt, opens with the nearer end of each range, in the type and in
/// paragraph styles. Values past the Paragraph panel's 1296 pt and within those ranges stay.
#[test]
fn a_file_with_paragraph_spacing_out_of_range_opens_with_it_in_range() {
let mut s = session();
let id = s.execute("text.create", &json!({"x": 40, "y": 60, "size": 20, "text": "Line\tone\nLine two"})).unwrap()["id"].as_u64().unwrap();
let mut d = (*s.doc().unwrap().doc).clone();
let Some(NodeKind::Text(t)) = d.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
let tab = |position| vectorcraft_doc::TabStop { position, align: vectorcraft_doc::TabAlign::Left, leader: String::new(), align_on: '.' };
let mut first = t.para.clone();
(first.left_indent, first.first_line_indent, first.space_after) = (f64::MAX, -1e7, 5000.0);
first.tabs = vec![tab(-5.0), tab(f64::MAX)];
let mut second = t.para.clone();
(second.right_indent, second.space_before) = (-f64::MAX, 1e7);
t.set_paragraph_styles(vec![first, second]);
let attrs = json!({"left_indent": 1e9, "space_after": 2000, "tabs": [serde_json::to_value(tab(-5.0)).unwrap()]});
d.para_styles.push(vectorcraft_doc::TextStyleDef { name: "Wide".into(), attrs: attrs.as_object().unwrap().clone() });
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
s.execute("document.open", &json!({"name": "spacing.vectorcraft", "dataBase64": data})).unwrap();
let max = vectorcraft_doc::MAX_COORD;
let t = text(&s, id);
let (p0, p1) = (t.para_at(0), t.para_at(1));
assert_eq!((p0.left_indent, p0.first_line_indent, p0.space_after), (max, -max, 5000.0));
assert_eq!(p0.tabs.iter().map(|t| t.position).collect::<Vec<_>>(), [0.0, 100_000.0]);
assert_eq!((p1.right_indent, p1.space_before), (-max, max));
let def = &s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Wide").unwrap().attrs;
assert_eq!((&def["left_indent"], &def["space_after"], &def["tabs"][0]["position"]), (&json!(max), &json!(2000), &json!(0.0)));
// With the spacing kept as saved, the type's bounds were infinite.
let b = s.doc().unwrap().doc.bounds_of(&[NodeId(id)], false).unwrap();
assert!(b.width().is_finite() && b.height().is_finite(), "{b:?}");
}
#[test]
fn editing_splits_and_merges_paragraph_styles() {
let mut s = session();
+35
View File
@@ -267,6 +267,41 @@ fn save_a_copy_while_editing() {
assert!(doc(&s).pattern("Dots copy").is_some());
}
/// Make, Save a Copy and Pattern Options trim the name they are given, as the Pattern Options
/// panel does; the trimmed name is the one compared with the names there are, and a blank name
/// gives the name the command gives when there is none.
#[test]
fn made_copied_and_renamed_patterns_trim_their_names() {
let mut s = session();
make_dots(&mut s);
let make = |s: &mut Session, name: &str| {
let r = rect(s, 0.0, 0.0, 10.0, 10.0);
s.execute("object.pattern.make", &json!({"ids": [r.0], "name": name, "edit": false})).unwrap()["name"].clone()
};
assert_eq!(make(&mut s, " Grid \t"), "Grid");
assert_eq!(make(&mut s, " Dots"), "Dots 2");
assert_eq!(make(&mut s, ""), "New Pattern 1");
assert_eq!(make(&mut s, " \t "), "New Pattern 2");
s.execute("object.pattern.edit", &json!({"name": "Grid"})).unwrap();
assert_eq!(s.execute("object.pattern.saveCopy", &json!({"name": " Lines\t"})).unwrap()["name"], "Lines");
assert_eq!(s.execute("object.pattern.saveCopy", &json!({"name": " "})).unwrap()["name"], "Grid copy");
s.execute("object.pattern.done", &json!({})).unwrap();
assert_eq!(s.execute("pattern.options", &json!({"name": "Dots 2", "newName": " Spots "})).unwrap()["name"], "Spots");
assert_eq!(s.execute("pattern.options", &json!({"name": "Spots", "newName": " "})).unwrap()["name"], "Spots");
let e = s.execute("pattern.options", &json!({"name": "Spots", "newName": " Lines"})).unwrap_err().to_string();
assert!(e.contains("a pattern named `Lines` exists"), "{e}");
// Save a Copy names the copy of a pattern whose saved name has white space around it, as a
// saved file can hold, from the trimmed name (" Old " gives "Old copy").
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
d.patterns.iter_mut().filter(|p| p.name == "Grid copy").for_each(|p| p.name = " Old ".into());
s.execute("object.pattern.edit", &json!({"name": " Old "})).unwrap();
assert_eq!(s.execute("object.pattern.saveCopy", &json!({})).unwrap()["name"], "Old copy");
s.execute("object.pattern.cancel", &json!({})).unwrap();
let names: Vec<&str> = doc(&s).patterns.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["Dots", "Grid", "Spots", "New Pattern 1", "New Pattern 2", "Lines", " Old ", "Old copy"]);
assert!(doc(&s).swatch("Spots").is_some() && doc(&s).swatch(" Spots ").is_none());
}
#[test]
fn undo_make_pattern() {
let mut s = session();
+14
View File
@@ -240,6 +240,20 @@ fn linux_display_server_defaults_to_automatic_and_validates() {
assert_eq!(s.prefs.linux_display_server, "automatic");
}
/// Linux Display Server's pen choice (#2028): set by value or by label, saved, and off by default.
#[test]
fn linux_display_server_has_a_choice_for_an_attached_pen() {
let mut s = Session::new();
s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": "x11WithPen"})).unwrap();
assert_eq!(s.prefs.linux_display_server, "x11WithPen");
assert_eq!(s.prefs.to_json()["linuxDisplayServer"], "x11WithPen");
s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": "automatic"})).unwrap();
s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": "X11 (XWayland) When a Pen Is Attached"})).unwrap();
assert_eq!(s.prefs.linux_display_server, "x11WithPen");
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
assert_eq!(s.prefs.linux_display_server, "automatic");
}
/// Selection & Anchor Display › Tolerance, Object Selection by Path Only and Command Click to
/// Select Objects Behind, and General › Double Click To Isolate, as the Selection tool sees them
/// (#394).
+51
View File
@@ -387,6 +387,57 @@ fn shaper_tool_trims_both_diagonal_ends_and_only_construction_can_pick_them() {
assert_eq!(s.doc().unwrap().selection.objects, vec![line]);
}
/// The Selection tool's marquee, Group Selection's E marquee and Direct Selection's marquee and
/// clicks reach a Shaper Group only by the art it shows: none of them selects the original shape of a
/// face the group erased, or its anchor points. In Construction mode the Selection marquee selects
/// them, and Direct Selection's marquee and clicks select their anchor points.
#[test]
fn shaper_marquees_leave_the_original_shape_of_an_erased_face() {
let mut s = session();
rectangle(&mut s, 0.0, "#ff0000");
let b = rectangle(&mut s, 50.0, "#0000ff");
let g = scribble(&mut s, &[[110.0, 20.0], [140.0, 35.0], [110.0, 50.0], [140.0, 65.0], [200.0, 70.0]]);
assert_eq!(fill(&s, g, 125.0, 50.0), None, "the face is erased");
let v = ViewInfo { smart_guides: false, ..Default::default() };
let drag = |s: &mut Session, tool: &str, from: [f64; 2], to: [f64; 2]| {
s.select_tool(tool, v).unwrap();
for (kind, [x, y]) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
}
s.doc().unwrap().selection.clone()
};
// Each marquee crosses the second rectangle's right side, where nothing is shown.
assert!(drag(&mut s, "selection", [140.0, 40.0], [160.0, 60.0]).objects.is_empty());
let sel = drag(&mut s, "directSelection", [140.0, -10.0], [160.0, 10.0]);
assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}");
// Group Selection's E marquee around the whole of that rectangle leaves it out.
s.select_tool("groupSelection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 155.0, -10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 45.0, 110.0), v).unwrap();
s.tool_key(vectorcraft_tools::ToolKey::Letter('e'), Mods::default(), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 45.0, 110.0), v).unwrap();
let sel = s.doc().unwrap().selection.clone();
assert!(!sel.objects.contains(&b) && !sel.anchors.contains_key(&b), "{sel:?}");
// A Direct Selection click on that rectangle's corner selects nothing either.
s.execute("select.none", &json!({})).unwrap();
let sel = drag(&mut s, "directSelection", [150.0, 0.0], [150.0, 0.0]);
assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}");
// Over the shown face's left side the marquees take the group and the face's anchors.
assert_eq!(drag(&mut s, "selection", [-10.0, 40.0], [10.0, 60.0]).objects, vec![g]);
let sources = node(&s, g).children().unwrap()[0].id;
let sel = drag(&mut s, "directSelection", [-10.0, -10.0], [10.0, 10.0]);
assert!(!sel.anchors.is_empty());
assert!(sel.anchors.keys().all(|id| s.doc().unwrap().doc.parent_of(*id) == Some(g)), "{sel:?}");
// Construction mode: the original shapes are what the marquees select. These marquees start
// outside them and cross the second rectangle's top right corner.
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
assert_eq!(s.doc().unwrap().isolation, Some(sources));
assert_eq!(drag(&mut s, "selection", [160.0, -10.0], [140.0, 10.0]).objects, vec![b]);
assert!(drag(&mut s, "directSelection", [160.0, -10.0], [140.0, 10.0]).anchors.contains_key(&b));
s.execute("select.none", &json!({})).unwrap();
assert!(drag(&mut s, "directSelection", [150.0, 0.0], [150.0, 0.0]).anchors.contains_key(&b));
}
#[test]
fn shaper_three_circles_erase_a_visible_fill_and_keep_the_complete_shared_curve() {
for exits in [false, true] {
+76
View File
@@ -40,6 +40,82 @@ fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow()
assert!(!dir.join(format!("{}.vcpdfpresets", file_stem("Old"))).exists());
}
#[test]
fn a_rename_that_cannot_be_written_keeps_the_preset_under_its_old_name() {
let dir = fresh_dir("rename-fails").join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
// The test puts a folder where the new name's file goes, so the write fails as it would on a full disk.
std::fs::create_dir(dir.join(format!("{}.vcpdfpresets", file_stem("Screen")))).unwrap();
a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "Screen"})).unwrap();
let mut b = Session::new();
b.attach_pdf_preset_store(dir.clone());
assert_eq!(saved_names(&mut b), ["Web"]);
assert_eq!(b.prefs.pdf_presets[0].description, "for screens");
assert!(dir.join(format!("{}.vcpdfpresets", file_stem("Web"))).is_file());
}
#[test]
fn a_rename_that_only_changes_case_keeps_one_file() {
let dir = fresh_dir("rename-case").join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap();
a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "WEB", "description": "renamed"})).unwrap();
let mut b = Session::new();
b.attach_pdf_preset_store(dir.clone());
assert_eq!(saved_names(&mut b), ["WEB"]);
assert_eq!(b.prefs.pdf_presets[0].description, "renamed");
assert_eq!(file_names(&dir), [format!("{}.vcpdfpresets", file_stem("WEB"))]);
}
fn file_names(dir: &std::path::Path) -> Vec<String> {
std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect()
}
/// Windows does not allow replacing a read-only file or writing into it, so the write fails.
#[cfg(windows)]
#[test]
fn a_rename_that_only_changes_case_and_cannot_be_written_keeps_the_old_file() {
let dir = fresh_dir("rename-case-fails").join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
let old = dir.join(format!("{}.vcpdfpresets", file_stem("Web")));
let writable = std::fs::metadata(&old).unwrap().permissions();
let mut read_only = writable.clone();
read_only.set_readonly(true);
std::fs::set_permissions(&old, read_only).unwrap();
a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "WEB"})).unwrap();
std::fs::set_permissions(&old, writable).unwrap();
let mut b = Session::new();
b.attach_pdf_preset_store(dir.clone());
assert_eq!(saved_names(&mut b), ["Web"]);
assert_eq!(b.prefs.pdf_presets[0].description, "for screens");
assert_eq!(file_names(&dir), [format!("{}.vcpdfpresets", file_stem("Web"))]);
}
/// Where file names keep case apart, a folder named like the new file makes the rename fail.
#[cfg(not(any(windows, target_vendor = "apple")))]
#[test]
fn a_rename_that_only_changes_case_and_cannot_be_moved_keeps_the_old_preset() {
let dir = fresh_dir("rename-case-blocked").join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
std::fs::create_dir(dir.join(format!("{}.vcpdfpresets", file_stem("WEB")))).unwrap();
a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "WEB", "description": "renamed"})).unwrap();
let mut b = Session::new();
b.attach_pdf_preset_store(dir.clone());
assert_eq!(saved_names(&mut b), ["Web"]);
assert_eq!(b.prefs.pdf_presets[0].description, "for screens");
}
#[test]
fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() {
let dir = fresh_dir("seed").join("PDF Presets");
+273 -6
View File
@@ -1,4 +1,5 @@
//! File → Place of a text file (Text Import Options) or an RTF file: the text set as area type.
//! File → Place of a text file (Text Import Options), an RTF file or a Word document: the text set
//! as area type.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeKind, TextKind};
@@ -162,12 +163,278 @@ fn malformed_rtf_fails_cleanly() {
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "failed places leave no undo step");
}
/// A ZIP archive of `files`, each deflated, its headers declaring each file's size, or `declared`
/// for every file.
fn deflated_zip(files: &[(&str, &[u8])], declared: Option<u32>) -> Vec<u8> {
let (mut out, mut central) = (vec![], vec![]);
for (name, data) in files {
let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default());
std::io::Write::write_all(&mut e, data).unwrap();
let packed = e.finish().unwrap();
// Flags, method (8: deflated), time, date, CRC, sizes and the name's length.
let mut shared = vec![];
for v in [0u16, 8, 0, 0] {
shared.extend(v.to_le_bytes());
}
for v in [crc32fast::hash(data), packed.len() as u32, declared.unwrap_or(data.len() as u32)] {
shared.extend(v.to_le_bytes());
}
shared.extend((name.len() as u16).to_le_bytes());
let offset = out.len() as u32;
out.extend(0x0403_4b50u32.to_le_bytes());
out.extend(20u16.to_le_bytes());
out.extend(&shared);
out.extend(0u16.to_le_bytes());
out.extend(name.as_bytes());
out.extend(&packed);
central.extend(0x0201_4b50u32.to_le_bytes());
central.extend([20u16, 20].map(u16::to_le_bytes).concat());
central.extend(&shared);
central.extend([0u8; 12]);
central.extend(offset.to_le_bytes());
central.extend(name.as_bytes());
}
let start = out.len() as u32;
out.extend(&central);
out.extend(0x0605_4b50u32.to_le_bytes());
out.extend([0u8; 4]);
out.extend([files.len() as u16; 2].map(u16::to_le_bytes).concat());
out.extend((central.len() as u32).to_le_bytes());
out.extend(start.to_le_bytes());
out.extend(0u16.to_le_bytes());
out
}
const W_NS: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
/// A Word document's package: its main part `body` (the inside of `w:body`), with a styles part
/// and a theme part named through the main part's relationships.
fn docx(body: &str, styles: &str) -> Vec<u8> {
let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let rels = "http://schemas.openxmlformats.org/package/2006/relationships";
let types = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/></Types>"#;
let root =
format!(r#"<Relationships xmlns="{rels}"><Relationship Id="rId1" Type="{rel}/officeDocument" Target="word/document.xml"/></Relationships>"#);
let doc_rels = format!(
r#"<Relationships xmlns="{rels}"><Relationship Id="rId1" Type="{rel}/styles" Target="styles.xml"/><Relationship Id="rId2" Type="{rel}/theme" Target="theme/theme1.xml"/><Relationship Id="rId3" Type="{rel}/hyperlink" Target="https://example.com/" TargetMode="External"/></Relationships>"#
);
let document = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W_NS}"><w:body>{body}<w:sectPr/></w:body></w:document>"#
);
let styles = format!(r#"<w:styles xmlns:w="{W_NS}">{styles}</w:styles>"#);
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Test"><a:themeElements><a:fontScheme name="Test"><a:majorFont><a:latin typeface="Arial"/></a:majorFont><a:minorFont><a:latin typeface="Georgia"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
let files: [(&str, &[u8]); 6] = [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", root.as_bytes()),
("word/document.xml", document.as_bytes()),
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
("word/styles.xml", styles.as_bytes()),
("word/theme/theme1.xml", theme.as_bytes()),
];
deflated_zip(&files, None)
}
/// Document defaults in the theme's minor font at 11 pt with 8 pt after each paragraph, a default
/// Normal style, a centered, bold Heading 1 in the theme's major font at 16 pt and in a color, a
/// bold character style and a default character style in all caps.
const STYLES: &str = concat!(
r#"<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi"/><w:sz w:val="22"/></w:rPr></w:rPrDefault>"#,
r#"<w:pPrDefault><w:pPr><w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>"#,
r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>"#,
r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:basedOn w:val="Normal"/><w:pPr><w:jc w:val="center"/><w:spacing w:before="240" w:after="0"/></w:pPr>"#,
r#"<w:rPr><w:rFonts w:asciiTheme="majorHAnsi"/><w:b/><w:sz w:val="32"/><w:color w:val="1F3864"/></w:rPr></w:style>"#,
r#"<w:style w:type="character" w:styleId="Strong"><w:rPr><w:b/></w:rPr></w:style>"#,
r#"<w:style w:type="character" w:default="1" w:styleId="Plain"><w:rPr><w:caps/></w:rPr></w:style>"#,
);
#[test]
fn docx_places_as_area_type_with_its_styles_and_formatting() {
let body = concat!(
r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t xml:space="preserve">Report </w:t></w:r>"#,
r#"<w:r><w:rPr><w:rStyle w:val="Strong"/></w:rPr><w:t>notes</w:t></w:r></w:p>"#,
r#"<w:p><w:pPr><w:ind w:left="720" w:hanging="360"/><w:jc w:val="both"/></w:pPr><w:r><w:t xml:space="preserve">Plain and </w:t></w:r>"#,
r#"<w:r><w:rPr><w:rFonts w:ascii="Courier New"/><w:i/><w:u w:val="single"/></w:rPr><w:t>styled</w:t></w:r>"#,
r#"<w:r><w:rPr><w:vertAlign w:val="superscript"/></w:rPr><w:t>2</w:t></w:r><w:r><w:rPr><w:vanish/></w:rPr><w:t>hidden</w:t></w:r>"#,
r#"<w:del w:id="1" w:author="A"><w:r><w:delText>gone</w:delText></w:r></w:del><w:ins w:id="2" w:author="A"><w:r><w:t xml:space="preserve"> page </w:t></w:r></w:ins>"#,
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>7</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
);
let mut s = session();
s.execute("document.setup", &json!({"superscript": {"size": 50, "position": 40}})).unwrap();
let file = json!({"name": "report.docx", "dataBase64": vectorcraft_format::base64_encode(&docx(body, STYLES))});
let r = s.execute("file.place", &file).unwrap();
assert_eq!((r["format"].as_str(), r["name"].as_str()), (Some("docx"), Some("report.docx")));
let (text, frame) = placed(&s, &r);
assert_eq!(text, "Report notes\nPlain and styled2 page 7", "hidden and deleted text and the field's instructions are left out");
assert!(close(frame, Rect::new(36.0, 36.0, 364.0, 264.0)), "the artboard less a 36 pt margin: {frame:?}");
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
let runs: Vec<_> = t.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str(), r.style.font_style.as_str(), r.style.size)).collect();
assert_eq!(
runs,
[
("Report ", "Arial", "Bold", 16.0),
// Bold in the character style reverses the paragraph style's bold.
("notes", "Arial", "Regular", 16.0),
("\n", "Arial", "Bold", 16.0),
("Plain and ", "Georgia", "Regular", 11.0),
("styled", "Courier New", "Italic", 11.0),
("2", "Georgia", "Regular", 11.0),
(" page 7", "Georgia", "Regular", 11.0),
]
);
let navy = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0x1F, 0x38, 0x64));
assert_eq!((t.runs[0].style.fill == navy, t.runs[1].style.fill == navy, t.runs[3].style.fill == navy), (true, true, false));
assert_eq!((t.runs[3].style.underline, t.runs[4].style.underline), (false, true));
let caps: Vec<_> = t.runs.iter().map(|r| r.style.all_caps).collect();
assert_eq!(caps, [true, false, false, true, true, true, true], "runs without a character style take the default one");
let superscript = vectorcraft_doc::ScriptMetrics { size: 50.0, position: 40.0 };
assert_eq!(t.runs[5].style.position, vectorcraft_doc::CharPosition::Superscript(superscript), "in Document Setup's proportions");
let paras = t.paragraph_styles();
let summary: Vec<_> = paras.iter().map(|p| (p.justify, p.left_indent, p.first_line_indent, p.space_before, p.space_after)).collect();
assert_eq!(summary, [(vectorcraft_doc::Justify::Center, 0.0, 0.0, 12.0, 0.0), (vectorcraft_doc::Justify::JustifyLeft, 36.0, -18.0, 0.0, 8.0)]);
let st = s.doc().unwrap();
assert_eq!((st.history.undo.len(), st.selection.objects.len()), (2, 1), "Document Setup and Place, each one undo step");
// Info and the place cursor report it as a Word document.
let info = s.execute("file.place.info", &file).unwrap();
assert_eq!((info["format"].as_str(), info["width"].as_f64()), (Some("docx"), Some(468.0)));
let q = s.execute("file.place.queue", &json!({"files": [file]})).unwrap();
assert_eq!((q["count"].as_u64(), q["files"][0]["format"].as_str()), (Some(1), Some("docx")));
}
#[test]
fn docx_double_strikethrough_is_not_a_toggle_property() {
// A paragraph style and a character style that both set double strikethrough, and a run that
// turns it off directly.
let styles = concat!(
r#"<w:style w:type="paragraph" w:styleId="Struck"><w:rPr><w:dstrike/></w:rPr></w:style>"#,
r#"<w:style w:type="character" w:styleId="AlsoStruck"><w:rPr><w:dstrike/></w:rPr></w:style>"#,
);
let body = concat!(
r#"<w:p><w:pPr><w:pStyle w:val="Struck"/></w:pPr><w:r><w:t xml:space="preserve">one </w:t></w:r>"#,
r#"<w:r><w:rPr><w:rStyle w:val="AlsoStruck"/></w:rPr><w:t xml:space="preserve">two </w:t></w:r>"#,
r#"<w:r><w:rPr><w:rStyle w:val="AlsoStruck"/><w:dstrike w:val="0"/></w:rPr><w:t>three</w:t></w:r></w:p>"#,
);
let mut s = session();
let r = s.execute("file.place", &json!({"name": "struck.docx", "dataBase64": vectorcraft_format::base64_encode(&docx(body, styles))})).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
let struck: Vec<_> = t.runs.iter().map(|r| (r.text.as_str(), r.style.strikethrough)).collect();
assert_eq!(struck, [("one two ", true), ("three", false)], "the character style keeps the paragraph style's double strikethrough");
}
#[test]
fn docx_tables_breaks_and_fields_place_as_paragraphs_and_tabs() {
// The Strict namespace, a package without relationships or styles, stored without compression
// and named without `.docx`.
let document = concat!(
r#"<w:document xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main"><w:body>"#,
r#"<w:p><w:r><w:t>Line one</w:t><w:br/><w:t>line two</w:t><w:br/><w:br w:type="page"/></w:r></w:p>"#,
r#"<w:tbl><w:tblPr/><w:tr><w:tc><w:p><w:r><w:t>a</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>b</w:t></w:r></w:p></w:tc></w:tr>"#,
r#"<w:tr><w:tc><w:p><w:r><w:t>c</w:t></w:r></w:p></w:tc><w:tc><w:p/></w:tc>"#,
r#"<w:sdt><w:sdtContent><w:tc><w:p><w:hyperlink><w:r><w:t>d</w:t></w:r></w:hyperlink></w:p></w:tc></w:sdtContent></w:sdt></w:tr>"#,
r#"<w:tr><w:tc><w:p><w:r><w:t>e</w:t></w:r></w:p></w:tc><w:tc><w:p/><w:p><w:r><w:t>f</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"#,
r#"<w:p><w:fldSimple w:instr=" DATE "><w:r><w:t>today</w:t></w:r></w:fldSimple><w:r><w:tab/><w:t>end</w:t></w:r>"#,
r#"<w:r><w:drawing><w:t>not text</w:t></w:drawing></w:r></w:p>"#,
r#"<w:p><w:pPr><w:bidi/><w:jc w:val="both"/></w:pPr><w:r><w:t><!-- a comment -->justi<?pi x?>fied</w:t></w:r></w:p>"#,
r#"<w:p><w:pPr><w:bidi/><w:jc w:val="end"/><w:ind w:start="400"/></w:pPr><w:r><w:t>rtl</w:t></w:r></w:p><w:p/><w:p/>"#,
"</w:body></w:document>"
);
let mut zip = cmd::fileio::zip::store(&[("word/document.xml", document)]).unwrap();
// An archive comment holding the end record's signature.
let comment = b"note PK\x05\x06 and 22 more bytes after it, at least";
let length = zip.len() - 2;
zip[length..].copy_from_slice(&(comment.len() as u16).to_le_bytes());
zip.extend(comment);
let mut s = session();
let r = s.execute("file.place", &json!({"name": "notes.bin", "dataBase64": vectorcraft_format::base64_encode(&zip)})).unwrap();
assert_eq!(r["format"].as_str(), Some("docx"));
assert_eq!(
placed(&s, &r).0,
"Line one\nline two\n\na\tb\nc\t\td\ne\t\nf\ntoday\tend\njustified\nrtl",
"blank paragraphs at the end are left out"
);
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
let paras = t.paragraph_styles();
assert_eq!(paras[paras.len() - 2].justify, vectorcraft_doc::Justify::JustifyRight, "the last line of right-to-left justified text at the right");
let last = paras.last().cloned().unwrap();
// The start indent of a right-to-left paragraph is its right indent.
assert_eq!(
(last.justify, last.left_indent, last.right_indent, last.direction),
(vectorcraft_doc::Justify::Left, 0.0, 20.0, Some(vectorcraft_doc::ParaDirection::RightToLeft))
);
}
#[test]
fn malformed_docx_fails_cleanly() {
let mut s = session();
let place = |s: &mut Session, name: &str, bytes: &[u8]| {
s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).map_err(|e| e.to_string())
};
let store = |files: &[(&str, &[u8])]| cmd::fileio::zip::store(files).unwrap();
let document = |body: &str| format!(r#"<w:document xmlns:w="{W_NS}"><w:body>{body}</w:body></w:document>"#);
assert!(place(&mut s, "a.docx", b"Plain words, not a package").is_err(), "a .docx file that isn't a ZIP archive");
let e = place(&mut s, "a.docx", &store(&[("readme.txt", b"x")])).unwrap_err();
assert!(e.contains("no main document part"), "{e}");
let e = place(&mut s, "a.zip", &store(&[("readme.txt", b"x")])).unwrap_err();
assert!(e.contains("not a format VectorCraft reads"), "a ZIP archive of something else isn't read as a Word document: {e}");
let sheet = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"/>"#;
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", sheet.as_bytes())])).unwrap_err();
assert!(e.contains("not a WordprocessingML document"), "{e}");
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", b"<w:document><w:body>")])).unwrap_err();
assert!(e.contains("not well-formed XML"), "{e}");
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", document("<w:p/>").as_bytes())])).unwrap_err();
assert!(e.contains("no text to place"), "{e}");
// A file whose headers understate what it unpacks to, and one whose headers say it is too large.
let bomb = vec![b' '; (16 << 20) + 1];
let e = place(&mut s, "a.docx", &deflated_zip(&[("word/document.xml", &bomb)], Some(100))).unwrap_err();
assert!(e.contains("too large to place"), "{e}");
let e = place(&mut s, "a.docx", &deflated_zip(&[("word/document.xml", b"<w:document/>")], Some(17 << 20))).unwrap_err();
assert!(e.contains("too large to place"), "{e}");
let many: Vec<(String, &[u8])> = (0..10_001).map(|i| (format!("f{i}"), &b""[..])).collect();
let e = place(&mut s, "a.docx", &store(&many.iter().map(|(n, b)| (n.as_str(), *b)).collect::<Vec<_>>())).unwrap_err();
assert!(e.contains("more than 10000 files"), "{e}");
// A changed byte fails the file's CRC.
let mut changed = store(&[("word/document.xml", document("<w:p><w:r><w:t>words</w:t></w:r></w:p>").as_bytes())]);
let at = changed.windows(5).position(|w| w == b"words").unwrap();
changed[at] = b'W';
let e = place(&mut s, "a.docx", &changed).unwrap_err();
assert!(e.contains("damaged"), "{e}");
// A stored file whose directory record gives more packed bytes than unpacked ones.
let mut longer = store(&[("word/document.xml", document("<w:p><w:r><w:t>words</w:t></w:r></w:p>").as_bytes()), ("pad", &[0; 64])]);
let central = longer.windows(4).position(|w| w == 0x0201_4b50u32.to_le_bytes()).unwrap();
let packed = u32::from_le_bytes(longer[central + 20..central + 24].try_into().unwrap()) + 64;
longer[central + 20..central + 24].copy_from_slice(&packed.to_le_bytes());
let e = place(&mut s, "a.docx", &longer).unwrap_err();
assert!(e.contains("damaged"), "{e}");
// Containers nested past the depth read are skipped, and a part nested deeper still is refused.
let nested = |n: usize| {
let deep = format!("{}<w:p><w:r><w:t>deep</w:t></w:r></w:p>{}", "<w:sdt><w:sdtContent>".repeat(n), "</w:sdtContent></w:sdt>".repeat(n));
store(&[("word/document.xml", document(&format!("{deep}<w:p><w:r><w:t>shallow</w:t></w:r></w:p>")).as_bytes())])
};
let r = place(&mut s, "a.docx", &nested(100)).unwrap();
assert_eq!(placed(&s, &r).0, "shallow");
let e = place(&mut s, "a.docx", &nested(5000)).unwrap_err();
assert!(e.contains("nests elements more than 256 deep"), "{e}");
// A font name is cut to 256 bytes.
let long = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{}"/></w:rPr><w:t>font</w:t></w:r></w:p>"#, "é".repeat(1000));
let r = place(&mut s, "a.docx", &store(&[("word/document.xml", document(&long).as_bytes())])).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
assert_eq!(t.runs[0].style.font_family, "é".repeat(128));
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "failed places leave no undo step");
}
#[test]
fn place_reads_what_open_reads_and_text_files() {
use cmd::fileio::{OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS};
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS, RTF_EXTS].concat());
use cmd::fileio::{DOCX_EXTS, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS};
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS, RTF_EXTS, DOCX_EXTS].concat());
let filters: Vec<_> = cmd::fileio::place_filters().collect();
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("RTF", RTF_EXTS))));
assert_eq!(filters.get(filters.len() - 2), Some(&("Text", TEXT_EXTS)));
assert!(!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt") || exts.contains(&"rtf")), "Open doesn't read text or RTF files");
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("Word", DOCX_EXTS))));
assert_eq!(filters.get(filters.len() - 3..), Some(&[("Text", TEXT_EXTS), ("RTF", RTF_EXTS), ("Word", DOCX_EXTS)][..]));
assert!(
!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt") || exts.contains(&"rtf") || exts.contains(&"docx")),
"Open doesn't read text, RTF or Word files"
);
}
+73
View File
@@ -227,3 +227,76 @@ fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() {
assert_eq!(text(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
/// A file whose type holds a size or scale of 0 or less or a negative leading opens with the
/// default size and scale and Auto leading, and a size, leading, baseline shift or scale past
/// `MAX_COORD` opens with `MAX_COORD`, in the layers, in symbols and in character styles. A
/// leading of 0, a size below 0.1 pt and scales below 1% stay as saved.
#[test]
fn a_file_with_type_sizes_out_of_range_opens_with_them_in_range() {
let mut s = session();
let p = json!({"x": 40, "y": 60, "size": 20, "text": "Line one\nLine two\nLine three"});
let id = NodeId(s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap());
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 9, "end": 18, "size": 30})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 18, "end": 28, "size": 40})).unwrap();
let mut d = (*s.doc().unwrap().doc).clone();
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") };
let [zero, huge, kept] = t.runs.as_mut_slice() else { panic!("not three runs") };
let (z, h, k) = (&mut zero.style, &mut huge.style, &mut kept.style);
(z.size, z.h_scale, z.v_scale, z.baseline_shift, z.leading) = (0.0, -100.0, 0.0, -f64::MAX, Some(-50.0));
(h.size, h.h_scale, h.v_scale, h.baseline_shift, h.leading) = (f64::MAX, f64::MAX, f64::MAX, f64::MAX, Some(f64::MAX));
(k.size, k.h_scale, k.v_scale, k.baseline_shift, k.leading) = (0.001, 0.5, 0.5, 5000.0, Some(0.0));
let art = d.node(id).unwrap().clone();
d.symbols.push(vectorcraft_doc::Symbol { name: "Lines".into(), art: Arc::new(art) });
let attrs = json!({"size": 0, "h_scale": 1e9, "v_scale": -1, "baseline_shift": -1e9, "leading": -1});
d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Broken".into(), attrs: attrs.as_object().unwrap().clone() });
let attrs = json!({"size": 40, "leading": null});
d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Auto".into(), attrs: attrs.as_object().unwrap().clone() });
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
s.execute("document.open", &json!({"name": "sizes.vectorcraft", "dataBase64": data})).unwrap();
let dims = |t: &TextObject| {
t.runs.iter().map(|r| (r.style.size, r.style.h_scale, r.style.v_scale, r.style.baseline_shift, r.style.leading)).collect::<Vec<_>>()
};
let max = vectorcraft_doc::MAX_COORD;
let want = vec![(12.0, 100.0, 100.0, -max, None), (max, max, max, max, Some(max)), (0.001, 0.5, 0.5, 5000.0, Some(0.0))];
assert_eq!(dims(&text(&s, id)), want);
let doc = &s.doc().unwrap().doc;
let NodeKind::Text(art) = &doc.symbols[0].art.kind else { panic!("not text") };
assert_eq!(dims(art), want);
let fixed = json!({"size": 12.0, "h_scale": max, "v_scale": 100.0, "baseline_shift": -max, "leading": null});
assert_eq!(doc.char_styles[0].attrs, *fixed.as_object().unwrap());
assert_eq!(doc.char_styles[1].attrs, *json!({"size": 40, "leading": null}).as_object().unwrap());
// With the sizes kept as saved, the type's bounds were infinite.
let b = doc.bounds_of(&[id], false).unwrap();
assert!(b.width().is_finite() && b.height().is_finite(), "{b:?}");
}
/// SVG import keeps type sizes outside the Character panel's 0.1 to 1296 pt, and it gives a line
/// aligned differently from the one before it an indent, here past the Paragraph panel's 1296 pt.
/// A native file of the result opens with the same type.
#[test]
fn imported_type_with_sizes_outside_the_panels_range_opens_unchanged() {
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="3000">
<text x="10" y="2000" font-size="2000">Big</text>
<text x="10" y="2400" font-size="0.05">tiny</text>
<text x="200" y="2600" font-size="12" text-anchor="middle"><tspan x="200" y="2600">One</tspan><tspan x="2900" y="2614.4" text-anchor="start">Two</tspan></text>
</svg>"#;
let mut s = Session::new();
s.execute("document.open", &json!({"name": "sizes.svg", "dataBase64": vectorcraft_format::base64_encode(SVG.as_bytes())})).unwrap();
let texts = |s: &Session| {
let mut out = vec![];
s.doc().unwrap().doc.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
out.push((**t).clone());
}
});
out
};
let imported = texts(&s);
let sizes: Vec<f64> = imported.iter().flat_map(|t| t.runs.iter().map(|r| r.style.size)).collect();
assert!(sizes.iter().any(|v| *v > 1296.0) && sizes.iter().any(|v| *v < 0.1), "{sizes:?}");
assert!(imported.iter().flat_map(|t| t.para_styles()).any(|p| p.left_indent > 1296.0), "an indent past the panel's");
let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
s.execute("document.open", &json!({"name": "sizes.vectorcraft", "dataBase64": saved})).unwrap();
assert_eq!(texts(&s), imported);
}
+110 -3
View File
@@ -1149,6 +1149,114 @@ fn datasets_csv_row_errors_name_the_file_line() {
assert!(long.contains("line 3:") && long.contains("longer than 255"), "{long}");
}
/// A header cell that names no variable defines one, unbound, in the import's undo step: `#`
/// defines a visibility variable, `@` defines an image variable, and a cell without a prefix
/// defines a text variable. Importing the file again finds them; a prefix of the other kind, a
/// bare prefix and a long name fail and define nothing.
#[test]
fn datasets_csv_header_prefixes_define_variables() {
let mut s = session();
let csv = "name,#Shown,@Photo,Title\nA,true,a.png,x";
let imp = s.execute("dataset.import", &json!({"csv": csv})).unwrap();
assert_eq!(imp["defined"], json!(["Shown", "Photo", "Title"]));
assert_eq!((imp["imported"].as_u64(), imp["names"].clone()), (Some(1), json!(["A"])));
let listed = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(
listed["variables"],
json!([
{"name": "Shown", "kind": "visibility", "bindings": 0},
{"name": "Photo", "kind": "image", "bindings": 0},
{"name": "Title", "kind": "text", "bindings": 0},
])
);
let value = |s: &Session, var: &str| s.doc().unwrap().doc.variables.dataset("A").and_then(|d| d.values.get(var)).cloned();
assert_eq!(value(&s, "Shown"), Some(vectorcraft_doc::DataValue::Visible(true)));
assert_eq!(value(&s, "Photo"), Some(vectorcraft_doc::DataValue::Image("a.png".into())));
assert_eq!(value(&s, "Title"), Some(vectorcraft_doc::DataValue::Text("x".into())));
let again = s.execute("dataset.import", &json!({"csv": "name\t#Shown\t@Photo\tTitle\nA\tfalse\tb.png\ty"})).unwrap();
assert_eq!((again["updated"].as_u64(), again["defined"].clone()), (Some(1), json!([])));
assert_eq!(value(&s, "Shown"), Some(vectorcraft_doc::DataValue::Visible(false)));
let error = |s: &mut Session, csv: &str| s.execute("dataset.import", &json!({"csv": csv})).unwrap_err().to_string();
let other_kind = error(&mut s, "name,New,#Title\nB,n,true");
assert!(other_kind.contains("column 3:") && other_kind.contains("text variable"), "{other_kind}");
let twice = error(&mut s, "Fresh,@Fresh\nn,f.png");
assert!(twice.contains("column 2:") && twice.contains("text variable"), "{twice}");
let bare = error(&mut s, "name,#\nB,true");
assert!(bare.contains("column 2:") && bare.contains("names no variable"), "{bare}");
let long = error(&mut s, &format!("name,@{}\nB,b.png", "n".repeat(256)));
assert!(long.contains("column 2:") && long.contains("longer than 255"), "{long}");
assert_eq!(s.doc().unwrap().doc.variables.variables.len(), 3, "a failed import defines nothing");
// Each import is one undo step, which takes back the variables it defined with its rows.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(value(&s, "Shown"), Some(vectorcraft_doc::DataValue::Visible(true)));
assert_eq!(s.doc().unwrap().doc.variables.variables.len(), 3);
s.execute("edit.undo", &json!({})).unwrap();
let vars = &s.doc().unwrap().doc.variables;
assert!(vars.variables.is_empty() && vars.datasets.is_empty());
}
/// A header cell that is a variable's exact name names it, even when the name starts with `#`,
/// `@` or `%`, so a file `dataset.export` wrote imports back without defining anything.
#[test]
fn datasets_csv_exact_names_win_over_prefixes() {
let mut s = session();
for (name, kind) in [("#Shown", "text"), ("@Photo", "visibility"), ("%Chart", "graph"), ("Shown", "visibility")] {
s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap();
}
let values = json!({"#Shown": "hello", "@Photo": true, "%Chart": ",Q\nX,3", "Shown": false});
s.execute("dataset.new", &json!({"name": "A", "values": values})).unwrap();
let csv = s.execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string();
assert!(csv.starts_with("name,#Shown,@Photo,%Chart,Shown\n"), "{csv}");
s.execute("dataset.delete", &json!({"names": ["A"]})).unwrap();
let imp = s.execute("dataset.import", &json!({"csv": csv})).unwrap();
assert_eq!((imp["imported"].as_u64(), imp["defined"].clone()), (Some(1), json!([])));
let value = |var: &str| s.doc().unwrap().doc.variables.dataset("A").and_then(|d| d.values.get(var)).cloned();
assert_eq!(value("#Shown"), Some(vectorcraft_doc::DataValue::Text("hello".into())));
assert_eq!(value("@Photo"), Some(vectorcraft_doc::DataValue::Visible(true)));
assert_eq!(value("%Chart"), Some(vectorcraft_doc::DataValue::Graph(",Q\nX,3".into())));
assert_eq!(value("Shown"), Some(vectorcraft_doc::DataValue::Visible(false)));
assert_eq!(s.doc().unwrap().doc.variables.variables.len(), 4);
}
/// A `%` header cell that isn't a variable's name (graph data files) is refused with its column,
/// and the import defines nothing.
#[test]
fn datasets_csv_refuses_graph_data_file_columns() {
let mut s = session();
let error = |s: &mut Session, csv: &str| s.execute("dataset.import", &json!({"csv": csv})).unwrap_err().to_string();
let named = error(&mut s, "name,Title,%Sales\nA,x,sales.txt");
assert!(named.contains("column 3:") && named.contains("`%Sales`"), "{named}");
let unnamed = error(&mut s, "%Sales\tTitle\nsales.txt\tx");
assert!(unnamed.contains("column 1:") && unnamed.contains("`%Sales`"), "{unnamed}");
let vars = &s.doc().unwrap().doc.variables;
assert!(vars.variables.is_empty() && vars.datasets.is_empty());
}
/// While the variables are locked, an import is refused when a header cell would define a
/// variable, as New Variable is, and a file whose headers all name variables imports.
#[test]
fn locked_variables_refuse_header_cells_that_define_variables() {
let mut s = session();
s.execute("variable.define", &json!({"name": "Shown", "kind": "visibility"})).unwrap();
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("variable.lock", &json!({"locked": true})).unwrap();
for csv in ["name,#Shown,Title,New\nA,true,x,n", "@Photo\nb.png", "name,#Fresh\nA,true"] {
let e = s.execute("dataset.import", &json!({"csv": csv})).unwrap_err();
assert!(matches!(&e, EngineError::Disabled(cmd, why) if cmd == "dataset.import" && why == "variables are locked"), "{csv}: {e}");
}
let vars = &s.doc().unwrap().doc.variables;
assert!(vars.variables.len() == 2 && vars.datasets.is_empty(), "a refused import defines and imports nothing");
let imp = s.execute("dataset.import", &json!({"csv": "name,#Shown,Title\nA,true,x"})).unwrap();
assert_eq!((imp["imported"].as_u64(), imp["defined"].clone()), (Some(1), json!([])));
s.execute("variable.lock", &json!({"locked": false})).unwrap();
let imp = s.execute("dataset.import", &json!({"csv": "name,New\nB,n"})).unwrap();
assert_eq!(imp["defined"], json!(["New"]));
}
/// Import / export datasets as CSV: header `name` then variables; round-trip keeps values.
#[test]
fn datasets_csv_round_trips_and_rejects_bad_input() {
@@ -1185,12 +1293,11 @@ fn datasets_csv_round_trips_and_rejects_bad_input() {
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
assert_eq!(image(&s, img).link.as_ref().map(|l| l.path.as_str()), Some(blue_path.as_str()));
// Re-import updates in place; unknown variable and bad visibility are errors.
// Re-import updates in place and defines nothing; a bad visibility value is an error.
let again = s.execute("dataset.import", &json!({"csv": csv})).unwrap();
assert_eq!((again["imported"].as_u64(), again["updated"].as_u64()), (Some(0), Some(2)));
assert!(s.execute("dataset.import", &json!({"csv": "name,Ghost\nX,y"})).is_err());
assert_eq!(again["defined"], json!([]));
assert!(s.execute("dataset.import", &json!({"csv": "name,Show\nX,maybe"})).is_err());
assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "no variable named Title");
let out = dir.file("sets.csv");
s.execute("dataset.export", &json!({"path": out})).unwrap();
+64
View File
@@ -262,6 +262,36 @@ fn slices_give_one_image_each_and_a_page() {
assert_eq!((one_slice["width"].as_f64(), one_slice["height"].as_f64()), (layout[0]["width"].as_f64(), layout[0]["height"].as_f64()));
}
/// Save for Web writes the image of a slice named after a Windows device (`aux`, `NUL`, `COM1`…)
/// with a `-` after the device name, on every platform.
#[test]
fn a_slice_named_after_a_windows_device_gets_a_dash() {
let mut s = session(300.0, 200.0);
let a = rect(&mut s, [20.0, 20.0, 100.0, 60.0], "#ff8800");
run(&mut s, "object.slice.make", json!({"ids": [a]}));
run(&mut s, "object.slice.options", json!({"name": "aux"}));
let b = run(&mut s, "object.slice.create", json!({"x": 150, "y": 100, "width": 100, "height": 50}))["id"].as_u64().unwrap();
run(&mut s, "object.slice.select", json!({"slices": [b]}));
run(&mut s, "object.slice.options", json!({"name": "aux-"}));
run(&mut s, "object.slice.select", json!({"slices": [a, b]}));
let mut names: Vec<String> = files(&run(&mut s, "document.exportForWeb", json!({"slices": "selected"}))).into_iter().map(|(n, _)| n).collect();
names.sort();
assert_eq!(names, ["images/aux- 2.gif", "images/aux-.gif"]);
}
/// Without a path the files are named after the document's title, with the characters Windows does
/// not allow in a file name as `-` and a `-` after a Windows device name, on every platform.
#[test]
fn files_named_after_the_title_follow_windows_naming_rules() {
for (title, page, image) in [("a|b?c*\"<d>", "a-b-c---d-.html", "images/a-b-c---d-.gif"), ("nul", "nul-.html", "images/nul-.gif")] {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 40, "height": 30, "name": title}));
rect(&mut s, [0.0, 0.0, 20.0, 30.0], "#ff0000");
let names: Vec<String> = files(&run(&mut s, "document.exportForWeb", json!({"output": "html"}))).into_iter().map(|(n, _)| n).collect();
assert_eq!(names, [page, image], "{title}");
}
}
#[test]
fn files_are_written_beside_the_path() {
let dir = vectorcraft_testkit::temp_dir("webexport");
@@ -278,6 +308,40 @@ fn files_are_written_beside_the_path() {
assert_eq!(r["bytes"].as_u64().unwrap(), std::fs::metadata(&page).unwrap().len() + std::fs::metadata(dir.join("images/page.png")).unwrap().len());
}
/// A 64×32 PNG whose header reads (so it places at its size) but whose pixel data is cut short.
fn cut_png() -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
png.truncate(idat + 8);
png
}
#[test]
fn an_export_says_when_an_image_could_not_be_decoded() {
let undecoded =
|r: &Value| r["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))));
let mut s = two_colours();
let r = run(&mut s, "document.exportForWeb", json!({}));
assert_eq!(r["warnings"], json!([]), "{r}");
assert_eq!(run(&mut s, "document.exportForWeb.preview", json!({}))["warnings"], json!([]));
// The renderer can't decode the placed image and draws nothing in its place.
run(&mut s, "file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&cut_png())}));
for format in ["gif", "jpg", "png8", "png24"] {
let r = run(&mut s, "document.exportForWeb", json!({"format": format}));
assert!(undecoded(&r), "{format}: {}", r["warnings"]);
let r = run(&mut s, "document.exportForWeb.preview", json!({"format": format}));
assert!(undecoded(&r), "{format} preview: {}", r["warnings"]);
}
let dir = vectorcraft_testkit::temp_dir("webexport-undecoded");
let page = dir.join("page.html").to_string_lossy().to_string();
let r = run(&mut s, "document.exportForWeb", json!({"path": page, "output": "html"}));
assert!(undecoded(&r), "{r}");
assert!(dir.join("images").join("page.gif").exists(), "the files are still written");
}
#[test]
fn image_size_region_and_metadata() {
let mut s = two_colours();
+60 -1
View File
@@ -1212,10 +1212,21 @@ proptest! {
#![proptest_config(config())]
#[test]
fn dataset_csv_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "name,Title,Show,Chart\n", "Title\tShow\tChart\n", "name\tTitle\n", "Show,Chart\n"])) {
fn dataset_csv_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "name,Title,Show,Chart\n", "Title\tShow\tChart\n", "name\tTitle\n", "Show,Chart\n", "name,#Show,@Photo,New\n", "#Shown\t@Title\tChart\n", "name,%Chart,#\n"])) {
datasets("data set CSV garbage", json!({"csv": format!("{head}{s}")}))?;
}
#[test]
fn prefixed_dataset_headers_never_panic(
cells in prop::collection::vec((prop::sample::select(vec!["", "#", "@", "%", "#@", " # "]), "[A-Za-z #@%\"]{0,8}"), 1..8),
tabs in any::<bool>(),
body in ".{0,200}",
) {
let sep = if tabs { "\t" } else { "," };
let header: Vec<String> = cells.iter().map(|(prefix, name)| format!("{prefix}{name}")).collect();
datasets("prefixed data set CSV header", json!({"csv": format!("{}\n{body}", header.join(sep))}))?;
}
#[test]
fn dataset_csv_bytes_never_panic(bytes in prop::collection::vec(any::<u8>(), 0..300)) {
datasets("data set CSV bytes", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)}))?;
@@ -1366,6 +1377,54 @@ proptest! {
}
}
/// 1-bit TIFFs compressed with CCITT Group 3: modified Huffman (Compression 2) in three strips, T.4
/// (Compression 3) with two-dimensional rows, fill bits, six EOLs at the end and each byte's lowest
/// bit first, and T.4 two-dimensional rows in 16 × 16 tiles.
fn group3_samples() -> Vec<Vec<u8>> {
let rows: Vec<Vec<bool>> = (0..6).map(|y| (0..40).map(|x| (x * (y + 1) / 3 + y) % 4 < 2).collect()).collect();
let tiff = |compression, t4_options, fill_order, rows_per_strip, tile| {
let rtc = compression == 3;
let g = vectorcraft_testkit::tiff::Group3 { rows: &rows, compression, t4_options, photometric: 0, fill_order, rows_per_strip, tile, rtc };
vectorcraft_testkit::tiff::group3(&g)
};
vec![tiff(2, 0, 1, 2, None), tiff(3, 5, 2, 6, None), tiff(3, 1, 1, 6, Some((16, 16)))]
}
/// [`group3_samples`] place as images.
#[test]
fn the_group3_samples_place() {
for bytes in group3_samples() {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("file.place", &json!({"name": "fax.tif", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
}
}
proptest! {
#![proptest_config(config())]
/// Mutated Group 3 TIFFs (directory values, strip offsets and counts, the coded rows) never
/// crash reading or placing them.
#[test]
fn mutated_group3_tiffs_place_without_panics(which in 0usize..3, cut in 0usize..400, edits in prop::collection::vec((0usize..400, any::<u8>()), 0..12)) {
let mut bytes = group3_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": "fax.tif", "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));
});
prop_assert!(r.is_ok(), "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.
+119 -1
View File
@@ -1,5 +1,6 @@
//! Untrusted text files placed with File → Place never crash: any bytes, with any Text Import
//! Options, place as area type or fail with an error, and so do hostile RTF files.
//! Options, place as area type or fail with an error, and so do hostile RTF files and Word
//! documents.
//!
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test text_import_fuzz` runs a deeper search.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
@@ -59,6 +60,123 @@ proptest! {
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
#[test]
fn any_docx_places_without_panics(
body in prop_oneof![1 => prop::collection::vec(arb_docx_token(), 0..120), 3 => prop::collection::vec(arb_docx_paragraph(), 0..12)],
styles in prop_oneof![1 => prop::collection::vec(arb_docx_token(), 0..40), 3 => prop::collection::vec(arb_docx_style(), 0..6)],
changes in prop_oneof![
3 => Just(vec![]),
1 => prop::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..3),
],
name in prop::sample::select(vec!["fuzz.docx", "fuzz.bin"]),
) {
vectorcraft_text::FontDb::global().set_system_fallback(false);
let w = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"";
let document = format!("<w:document {w}><w:body>{}</w:body></w:document>", body.concat());
let styles = format!("<w:styles {w}>{}</w:styles>", styles.concat());
let files = [("word/document.xml", document.as_bytes()), ("word/styles.xml", styles.as_bytes())];
let mut data = vectorcraft_engine::cmd::fileio::zip::store(&files).unwrap();
// A changed byte anywhere: in a header, a size, the directory or the XML.
for (at, byte) in changes {
let i = at.index(data.len());
data[i] = byte;
}
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(&data), "thumbnail": 8});
let _ = s.execute("file.place.info", &p);
let _ = s.execute("file.place", &p);
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
}
/// A well-formed paragraph, or a table row of two such paragraphs.
fn arb_docx_paragraph() -> impl Strategy<Value = String> {
prop_oneof![
3 => arb_docx_para(),
1 => (arb_docx_para(), arb_docx_para()).prop_map(|(a, b)| format!("<w:tbl><w:tr><w:tc>{a}</w:tc><w:tc>{b}</w:tc></w:tr></w:tbl>")),
]
}
/// A well-formed paragraph: properties and runs of empty elements and text.
fn arb_docx_para() -> impl Strategy<Value = String> {
let leaves = || prop::collection::vec(arb_docx_leaf(), 0..4);
(leaves(), prop::collection::vec((leaves(), arb_docx_leaf()), 0..6)).prop_map(|(ppr, runs)| {
let runs: String = runs.iter().map(|(rpr, t)| format!("<w:r><w:rPr>{}</w:rPr><w:t>{t}</w:t></w:r>", rpr.concat())).collect();
format!("<w:p><w:pPr>{}</w:pPr>{runs}</w:p>", ppr.concat())
})
}
/// Elements the Word document reader knows that hold others, opened or closed by
/// [`arb_docx_token`].
const DOCX_TAGS: &str = "p r t pPr rPr tbl tr tc sdt sdtContent hyperlink del ins style docDefaults rPrDefault pPrDefault";
/// Empty elements the Word document reader knows.
const DOCX_EMPTY: &str = "b i caps smallCaps strike dstrike vanish tab ptab br cr noBreakHyphen softHyphen bidi p";
/// Well-formed document defaults, or a paragraph or character style `S` or `C` (based on `S` or
/// not), with properties of empty elements.
fn arb_docx_style() -> impl Strategy<Value = String> {
let leaves = || prop::collection::vec(arb_docx_leaf(), 0..4).prop_map(|l| l.concat());
let kind = prop::sample::select(vec![("paragraph", "S"), ("character", "C"), ("paragraph", "Normal\" w:default=\"1")]);
prop_oneof![
(leaves(), leaves()).prop_map(|(r, p)| format!(
"<w:docDefaults><w:rPrDefault><w:rPr>{r}</w:rPr></w:rPrDefault><w:pPrDefault><w:pPr>{p}</w:pPr></w:pPrDefault></w:docDefaults>"
)),
(kind, any::<bool>(), leaves(), leaves()).prop_map(|((t, id), based, r, p)| {
let based = if based { "<w:basedOn w:val=\"S\"/>" } else { "" };
format!("<w:style w:type=\"{t}\" w:styleId=\"{id}\">{based}<w:rPr>{r}</w:rPr><w:pPr>{p}</w:pPr></w:style>")
}),
]
}
/// Pieces of WordprocessingML the Word document reader knows (paragraphs, runs, properties,
/// tables, fields, styles), a cut-off tag, entities and text.
fn arb_docx_token() -> impl Strategy<Value = String> {
let tags = prop::sample::select(DOCX_TAGS.split_whitespace().collect::<Vec<_>>());
let open = (tags, any::<bool>()).prop_map(|(t, end)| format!("<{}w:{t}>", if end { "/" } else { "" }));
let styles = prop::sample::select(vec!["<w:style w:type=\"paragraph\" w:styleId=\"S\">", "<w:style w:type=\"character\" w:styleId=\"C\">"]);
prop_oneof![open, styles.prop_map(str::to_string), arb_docx_leaf(), Just("<w:t".to_string())]
}
/// An empty WordprocessingML element the reader knows, with or without a value in or out of
/// range, entities or text.
fn arb_docx_leaf() -> impl Strategy<Value = String> {
let empty = prop::sample::select(DOCX_EMPTY.split_whitespace().collect::<Vec<_>>());
let valued = prop::sample::select(vec![
("pStyle", "val", "S"),
("rStyle", "val", "C"),
("basedOn", "val", "S"),
("sz", "val", "-7"),
("sz", "val", "1e400"),
("sz", "val", "12pt"),
("position", "val", "99999999999"),
("color", "val", "FF00"),
("color", "val", "auto"),
("u", "val", "none"),
("vertAlign", "val", "superscript"),
("jc", "val", "distribute"),
("jc", "val", "end"),
("ind", "hanging", "-360"),
("ind", "left", "3in"),
("spacing", "after", "x"),
("fldChar", "fldCharType", "begin"),
("fldChar", "fldCharType", "separate"),
("fldChar", "fldCharType", "end"),
("rFonts", "asciiTheme", "majorHAnsi"),
("rFonts", "ascii", ""),
("br", "type", "page"),
("b", "val", "0"),
]);
prop_oneof![
empty.prop_map(|e| format!("<w:{e}/>")),
valued.prop_map(|(e, a, v)| format!("<w:{e} w:{a}=\"{v}\"/>")),
Just("&amp;&#x1F600;&lt;".to_string()),
"[a-z ]{0,6}",
]
}
/// Control words the RTF reader knows, and one it doesn't.
+22 -10
View File
@@ -740,13 +740,14 @@ fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool {
p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR)
}
/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, and of
/// those whose neighbours all differ too, away from type (where one draws an object that the other
/// doesn't, not the edges of one drawn a little differently, or type laid out a little
/// differently: a line the page sets in pieces is laid out afresh as one).
/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, of those
/// that differ away from type, and of those whose neighbors all differ too, away from type (where
/// one draws an object that the other doesn't, not the edges of one drawn a little differently, or
/// type laid out a little differently: a line the page sets in pieces is laid out afresh as one).
#[derive(Clone, Copy)]
struct Difference {
any: f64,
art: f64,
solid: f64,
}
@@ -866,19 +867,20 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference {
/// and what the comparison looks for is art that is missing or out of place.
fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference {
if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height {
return Difference { any: 1.0, solid: 1.0 };
return Difference { any: 1.0, art: 1.0, solid: 1.0 };
}
let differs: Vec<bool> = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect();
let (w, h) = (x.width as usize, x.height as usize);
let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false);
let typed = in_areas(areas, w, h);
let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count();
// A pixel on the region's edge has neighbours outside it, which don't count as differing.
let solid = (1..h.saturating_sub(1))
.flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j)))
.filter(|&(i, j)| !typed.get(j * w + i).copied().unwrap_or(true) && (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n))))
.count();
let area = (w as f64 * h as f64).max(1.0);
Difference { any: differs.iter().filter(|d| **d).count() as f64 / area, solid: solid as f64 / area }
Difference { any: differs.iter().filter(|d| **d).count() as f64 / area, art: art as f64 / area, solid: solid as f64 / area }
}
/// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin
@@ -1243,7 +1245,9 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
const PAST_THE_PAGE: f64 = 1.0;
/// Do the layers' art and the page's own look alike (where the page holds all the art that prints,
/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little.
/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little:
/// with the share of the pixels that differ away from type when that is notable, otherwise with the
/// share of those that differ in the type's areas.
fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result<Option<String>, String> {
let mut diff = view.difference(layered, frame.rect);
if frame.whole
@@ -1254,15 +1258,23 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result
let all = frame.rect.union(art);
let (dx, dy) = (frame.page_rect.x0 - frame.rect.x0, frame.page_rect.y0 - frame.rect.y0);
let round = difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all);
diff = Difference { any: diff.any.max(round.any), solid: diff.solid.max(round.solid) };
diff = Difference { any: diff.any.max(round.any), art: diff.art.max(round.art), solid: diff.solid.max(round.solid) };
}
if diff.any > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE {
return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.any * 100.0));
}
Ok((diff.any > NOTABLE_DIFFERENCE).then(|| {
if diff.any <= NOTABLE_DIFFERENCE {
return Ok(None);
}
Ok(Some(if diff.art > NOTABLE_DIFFERENCE {
format!(
"the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)",
diff.any * 100.0
diff.art * 100.0
)
} else {
format!(
"the type on the layers differs from the file's own page in about {:.1}% of it (type laid out again from the file's text, or drawn in another font)",
(diff.any - diff.art) * 100.0
)
}))
}
+28
View File
@@ -907,6 +907,34 @@ fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() {
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("differs from the page's")), "{:?}", r.warnings);
}
/// The page sets "Hi there" in pieces, and the layers lay the line out again as one: the page and
/// the layers differ only where the type is.
fn page_in_pieces_and_layers(art: &str) -> Vec<u8> {
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show";
eps(page, &native_with_text(&layer("Art", true, &format!("{art}{}", text_object(0))), &doc))
}
#[test]
fn type_laid_out_again_is_noted_as_type_and_not_as_art_the_layers_dont_describe() {
let r = import(&page_in_pieces_and_layers("")).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.starts_with("the type on the layers differs from the file's own page")), "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("brush strokes")), "{:?}", r.warnings);
}
#[test]
fn art_the_page_doesnt_draw_is_noted_with_its_own_share_and_not_the_types() {
// A bar of 0.5 × 80 points that the page doesn't draw, too thin to be refused as an object: 0.4% of
// the page, away from the type.
let bar = "0 0 0 1 k\n20 10 m\n20.5 10 L\n20.5 90 L\n20 90 L\nf\n";
let r = import(&page_in_pieces_and_layers(bar)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let notes: Vec<&String> = r.warnings.iter().filter(|w| w.contains("differs from the file's own page")).collect();
assert_eq!(notes.len(), 1, "{:?}", r.warnings);
assert!(notes[0].starts_with("the art on the layers differs from the file's own page in about 0.4% of it (brush strokes"), "{notes:?}");
}
/// An EPS whose page is its art's box `[x0 y0 x1 y1]` (not its artboard, 0 0 100 100), drawing
/// `page`, with the editing copy of `layers`.
fn eps_of_art(art_box: [u32; 4], page: &str, layers: &str) -> Vec<u8> {
+7
View File
@@ -557,8 +557,15 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND);
let mut depth_notes = vec![];
doc.limit_depth_noting(&mut depth_notes);
// Untrusted: the type's sizes, leading, baseline shifts and scales, its indents and paragraph
// spacing, and its tab stops, each within its range (after the depth limit: the walk recurses).
doc.keep_type_values_in_range();
// Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite).
doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
// Untrusted: the superscript, subscript and small caps proportions of the type, the graph
// labels and the character styles, within the ranges `document.setup` takes (after the depth
// limit: the walk recurses).
doc.keep_scripts_in_range();
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect();
Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
}
+188 -2
View File
@@ -28,6 +28,11 @@
//! cached shadows, glows and filtered content at its new place instead of running the effects
//! again ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]).
//!
//! While [`crate::RenderOptions::dragging`] is set, the renderer draws filtered content that runs
//! its effects within [`DRAG_PIXEL_BUDGET`] pixels, at a lower resolution where it would need more,
//! and keeps no raster made that way. On the multithreaded pipeline it does the same for shadows
//! and glows. The frame after the drag renders them as a still frame does.
//!
//! A cached raster is made again when something its content reads from outside the object
//! changes ([`Inputs`]): the pattern definitions, symbols, images and brushes it uses, the
//! document's raster resolution, color mode and substitution highlights, the knockout its object
@@ -641,6 +646,19 @@ fn linear_key(view: Affine) -> [u64; 4] {
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
}
/// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while
/// [`crate::RenderOptions::dragging`] (a 1000 × 1000 raster's), when the frame does not draw a raster
/// kept from an earlier frame instead (#2558).
const DRAG_PIXEL_BUDGET: f64 = 1.0e6;
/// The scale (raster pixels a device pixel) for content of `rw` × `rh` device pixels that would
/// render at scale `k`, when a drag lowers it to stay within [`DRAG_PIXEL_BUDGET`]; `None` when it
/// does not (`dragging` off, or content within the budget at `k`).
fn drag_scale(dragging: bool, (rw, rh): (f64, f64), k: f64) -> Option<f64> {
let d = (DRAG_PIXEL_BUDGET / (rw * rh)).sqrt();
(dragging && d < k).then_some(d)
}
/// The raster a shadow or glow blurred by `blur` device pixels is filtered in, for content
/// reaching `reach` (device pixels) in a view of `screen` pixels: (left, top, width, height,
/// scale). It covers what of the reach can show in the view, the blur's spread around it
@@ -648,6 +666,11 @@ fn linear_key(view: Affine) -> [u64; 4] {
/// [`PIXEL_BUDGET`]. At a high zoom the spread of a wide blur is tens of thousands of pixels a
/// side, whatever the size of the view (#1706). `None`: nothing to draw.
fn filter_region(reach: Rect, screen: (u16, u16), blur: f64) -> Option<(f64, f64, u16, u16, f64)> {
filter_region_within(reach, screen, blur, PIXEL_BUDGET)
}
/// The region [`filter_region`] returns, within `budget` pixels instead of [`PIXEL_BUDGET`].
fn filter_region_within(reach: Rect, screen: (u16, u16), blur: f64, budget: f64) -> Option<(f64, f64, u16, u16, f64)> {
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
let spread = blur.max(0.0) * 1.5 + 2.0;
let screen = Rect::new(0.0, 0.0, f64::from(screen.0), f64::from(screen.1)).inflate(spread, spread);
@@ -659,7 +682,7 @@ fn filter_region(reach: Rect, screen: (u16, u16), blur: f64) -> Option<(f64, f64
return None;
}
let side = f64::from(u16::MAX);
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
let k = (budget / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
let (w, h) = ((rw * k).floor().clamp(1.0, side), (rh * k).floor().clamp(1.0, side));
Some((x0, y0, w as u16, h as u16, k))
}
@@ -989,6 +1012,8 @@ impl Renderer {
/// unchanged (pans only move it), and while interacting after the object only moved
/// ([`Self::move_shadow`]). `None` when the effect isn't a shadow/glow or the raster would be
/// too large (caller falls back to [`Self::draw_shadow_filter`]).
/// While dragging on a multithreaded context, a raster that has to be made again counts as too
/// large when it would hold more than [`DRAG_PIXEL_BUDGET`] pixels.
#[allow(clippy::too_many_arguments)]
fn shadow_raster(
&mut self,
@@ -1041,6 +1066,11 @@ impl Renderer {
if !(1.0..=8192.0).contains(&w) || !(1.0..=8192.0).contains(&h) || w * h > 4.0e6 {
return None;
}
// During a drag, a larger shadow on a multithreaded context is filtered within the
// drag's budget (`with_filters`), and no raster is kept.
if f.opts.dragging && f.mt && w * h > DRAG_PIXEL_BUDGET {
return None;
}
let (w, h) = (w as u16, h as u16);
// Silhouette (the object's painted alpha, and its colors under Darkness), offset by
// the shadow distance.
@@ -1618,6 +1648,8 @@ impl Renderer {
/// resolution. A whole raster at full resolution is cached while the object, the
/// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and
/// while interacting after the content only moved ([`Self::move_pixel_content`]).
/// While dragging, content without such a raster to draw renders within [`DRAG_PIXEL_BUDGET`]
/// pixels and is not cached.
fn pixel_content(
&mut self,
ctx: &mut RenderContext,
@@ -1702,6 +1734,16 @@ impl Renderer {
draw_scaled(ctx, image, at, k);
return;
}
// During a drag, content without a raster to draw renders within the drag's budget and keeps
// no raster (#2558).
let (k, w, h, key) = match drag_scale(f.opts.dragging, (rw, rh), k) {
Some(d) => {
self.stats.moved += 1;
self.stats.previewed += 1;
(d, (rw * d).ceil().clamp(1.0, side), (rh * d).ceil().clamp(1.0, side), None)
}
None => (k, w, h, key),
};
let (wu, hu) = (w as u16, h as u16);
let view = Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view;
let to_doc = view.inverse();
@@ -2040,6 +2082,7 @@ impl Renderer {
/// applies `composite` (blend mode, opacity) on its filter layer. On a multithreaded context
/// the layer is drawn into an offscreen single-threaded context covering `reach` (document
/// space) plus the blur's spread, then composited back with `composite`.
/// While dragging, that context holds at most [`DRAG_PIXEL_BUDGET`] pixels.
fn with_filters(
&mut self,
ctx: &mut RenderContext,
@@ -2053,6 +2096,16 @@ impl Renderer {
return self.inside_layer(|r| draw(r, ctx, f, composite));
}
let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return };
// During a drag, the region holds at most `DRAG_PIXEL_BUDGET` pixels (#2558).
let drag = || filter_region_within(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px, DRAG_PIXEL_BUDGET);
let (x0, y0, w, h, k) = match f.opts.dragging.then(drag).flatten().filter(|r| r.4 < k) {
Some(r) => {
self.stats.moved += 1;
self.stats.previewed += 1;
r
}
None => (x0, y0, w, h, k),
};
let mut off = f.offscreen_context(w, h);
let shifted = Frame { mt: false, view: Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view, px: f.px / k, ..*f };
self.inside_layer(|r| draw(r, &mut off, &shifted, None));
@@ -2174,7 +2227,7 @@ mod tests {
use vectorcraft_doc::{Appearance, Document, Effect, Node};
use vectorcraft_geom::{Affine, Rect, shapes};
use super::{Drawn, PIXEL_BUDGET, filter_region};
use super::{DRAG_PIXEL_BUDGET, Drawn, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within};
use crate::tests_fontchange::fonts_steady;
use crate::{RenderOptions, Rendered, Renderer};
@@ -2197,6 +2250,28 @@ mod tests {
assert_eq!(filter_region(Rect::new(0.0, 0.0, 100.0, 100.0), (1600, 1000), f64::NAN).map(|r| r.4), Some(1.0));
}
/// #2558: while dragging, content and a shadow's region over the drag's budget at their scale
/// render at a lower scale, within the budget; otherwise their scale stays as it is.
#[test]
fn a_drag_lowers_the_scale_of_rasters_over_its_budget() {
let d = drag_scale(true, (2560.0, 1440.0), 1.0).unwrap();
assert!(d < 1.0 && (2560.0 * d) * (1440.0 * d) <= DRAG_PIXEL_BUDGET * (1.0 + 1e-9), "{d}");
assert_eq!(drag_scale(false, (2560.0, 1440.0), 1.0), None, "not dragging");
assert_eq!(drag_scale(true, (1000.0, 800.0), 1.0), None, "within the budget");
// At a scale already lowered to 0.5, 1280 × 720 raster pixels: within the budget.
assert_eq!(drag_scale(true, (2560.0, 1440.0), 0.5), None);
// A 2000 × 1500 pixel square blurred by 20 pixels in a 1600 × 1000 view.
let reach = Rect::new(-200.0, -200.0, 1800.0, 1300.0);
let (x0, y0, w, h, k) = filter_region_within(reach, (1600, 1000), 20.0, DRAG_PIXEL_BUDGET).unwrap();
assert!(f64::from(w) * f64::from(h) <= DRAG_PIXEL_BUDGET && k < 1.0, "{w} × {h} at {k}");
// It covers the same pixels as the full-size region, at that scale.
let full = filter_region(reach, (1600, 1000), 20.0).unwrap();
assert_eq!((x0, y0, full.4), (full.0, full.1, 1.0));
assert!((f64::from(w) / k - f64::from(full.2)).abs() <= 1.0 / k && (f64::from(h) / k - f64::from(full.3)).abs() <= 1.0 / k);
let small = Rect::new(10.0, 20.0, 110.0, 70.0);
assert_eq!(filter_region_within(small, (1600, 1000), 4.0, DRAG_PIXEL_BUDGET), filter_region(small, (1600, 1000), 4.0));
}
fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
@@ -2720,6 +2795,56 @@ mod tests {
assert!(after != last, "6.3 is not on a whole pixel");
}
/// While dragging (#2558), filtered content and a drop shadow that the frame does not draw from a
/// raster of an earlier frame render within the drag's budget and keep no raster. At 6400% a
/// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so no raster is
/// kept for either, and a 1600 × 1000 view near its corner needs more than the drag's budget. The
/// frame after the drag, of the same document, is what a fresh renderer draws, as is a frame
/// rendered while interacting without a drag (a dialog's preview).
#[test]
fn a_drag_renders_raster_effects_within_the_drag_budget() {
let effects = vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 7, "opacity": 80})), ("texture.texturizer", json!({}))];
let base = doc_with(Rect::new(20.0, 20.0, 80.0, 76.0), Color::rgb(0.9, 0.4, 0.1), effects);
let id = base.layers[0].children().unwrap()[0].id;
let mut d = base.clone();
d.node_mut(id).unwrap().transform(Affine::translate((0.3, 0.2)), false);
// The view is centered 10 pt inside the rectangle's right and bottom edges, and shows the
// shadow beyond the right edge.
let view = Affine::translate((800.0, 500.0)) * Affine::scale(64.0) * Affine::translate((-70.0, -66.0));
let still = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, ..still.clone() };
let drag = RenderOptions { dragging: true, ..live.clone() };
let fresh = Renderer::new().render(&d, 1600, 1000, view, &still).pixels;
let mut r = Renderer::new();
let _ = r.render(&base, 1600, 1000, view, &still);
assert_eq!(r.stats.previewed, 0);
let dragged = r.render(&d, 1600, 1000, view, &drag).pixels;
assert_eq!(r.stats.previewed, 2, "the shadow and the filtered content");
assert_eq!(r.stats.moved, 2, "counted as moved: the frame after the drag renders them again");
assert!(r.pixel_fx.is_empty() && r.shadows.is_empty(), "no raster kept");
assert!(dragged != fresh, "at the drag's lower resolution");
let after = r.render(&d, 1600, 1000, view, &still).pixels;
assert_eq!((r.stats.previewed, r.stats.moved), (0, 0));
assert!(after == fresh, "the frame after the drag");
assert!(r.render(&d, 1600, 1000, view, &live).pixels == fresh, "interacting without a drag");
assert_eq!(r.stats.previewed, 0);
// A drop shadow of about 2 million pixels at 4000%, within the shadow cache's limit: scaled
// by a drag, it can't take its raster along, and renders within the drag's budget.
let shadowed = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"blur": 2}))]);
let mut scaled = shadowed.clone();
let id = scaled.layers[0].children().unwrap()[0].id;
scaled.node_mut(id).unwrap().transform(Affine::scale_about(1.05, vectorcraft_geom::Point::new(35.0, 33.0)), false);
let view = Affine::translate((800.0, 500.0)) * Affine::scale(40.0) * Affine::translate((-35.0, -33.0));
let mut r = Renderer::new();
let _ = r.render(&shadowed, 1600, 1000, view, &still);
let kept = r.shadows.len();
assert_eq!((kept, r.stats.previewed), (1, 0), "a still frame keeps the shadow's raster");
let _ = r.render(&scaled, 1600, 1000, view, &drag);
assert_eq!((r.shadows.len(), r.stats.previewed), (kept, 1), "the scaled shadow");
let after = r.render(&scaled, 1600, 1000, view, &still).pixels;
assert!(after == Renderer::new().render(&scaled, 1600, 1000, view, &still).pixels, "the frame after the drag");
}
/// While interacting, a group holding a symbol instance whose art lies outside the 20 × 20
/// square around the origin draws its drop shadow from the raster of an earlier frame. The
/// shadow is made from the instance's art, which the renderer measures the group's content by.
@@ -2939,6 +3064,67 @@ mod tests {
}
}
/// Reproduce #2558's drag of a textured or blurred rectangle when zoomed in, in a 2560 × 1440
/// view centered on the rectangle: the first frame renders as before a drag, and the next eight
/// render as a drag's frames, once with `interacting` alone and once with `dragging` as well (as
/// the canvas renders them). Then the eighth position renders as the canvas renders the frame
/// after the drag. The hashes of that frame and of a fresh renderer's frame compare builds.
/// Run with `cargo test -p vectorcraft-render zoomed_drag_perf -- --ignored --nocapture`.
#[test]
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
fn zoomed_drag_perf() {
let sets: [(&str, Vec<(&str, serde_json::Value)>); 3] = [
("Texturizer", vec![("texture.texturizer", json!({}))]),
("Gaussian Blur 8 pt", vec![("blur.gaussian", json!({"radius": 8}))]),
("Drop Shadow and Craquelure", vec![("stylize.dropShadow", json!({"blur": 7})), ("texture.craquelure", json!({}))]),
];
let hash = |px: &[u8]| {
use std::hash::{Hash, Hasher};
let mut h = std::hash::DefaultHasher::new();
px.hash(&mut h);
h.finish()
};
for (name, effects) in sets {
for zoom in [1.0, 4.0, 16.0, 64.0] {
let base = doc_with(Rect::new(100.0, 100.0, 300.0, 250.0), Color::rgb(0.9, 0.4, 0.1), effects.clone());
let id = base.layers[0].children().unwrap()[0].id;
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let view = Affine::translate((1280.0, 720.0)) * Affine::scale(zoom) * Affine::translate((-200.0, -175.0));
for dragging in [false, true] {
let live = RenderOptions { interacting: true, dragging, ..opts.clone() };
let mut r = Renderer::new();
r.threads = crate::default_threads();
let (mut ms, mut doc) = (vec![], base.clone());
for frame in 0..9 {
doc = base.clone();
let by = (f64::from(frame) * 0.75 / zoom, f64::from(frame) * 0.5 / zoom);
doc.node_mut(id).unwrap().transform(Affine::translate(by), false);
let start = std::time::Instant::now();
std::hint::black_box(r.render(&doc, 2560, 1440, view, if frame > 0 { &live } else { &opts }));
if frame > 0 {
ms.push(start.elapsed().as_secs_f64() * 1000.0);
}
}
let previewed = r.stats.previewed;
let start = std::time::Instant::now();
let still = r.render(&doc, 2560, 1440, view, &opts);
let after = start.elapsed().as_secs_f64() * 1000.0;
let fresh = Renderer::new().render(&doc, 2560, 1440, view, &opts);
ms.sort_by(f64::total_cmp);
println!(
"{name} at {:.0}%, dragging {dragging}: drag frames median {:.1} ms (min {:.1}, max {:.1}), {previewed} previewed; the frame after the drag {after:.1} ms; hashes {:016x} {:016x}",
zoom * 100.0,
(ms[3] + ms[4]) * 0.5,
ms[0],
ms[7],
hash(&still.pixels),
hash(&fresh.pixels)
);
}
}
}
}
#[test]
fn photoshop_filters_on_content_larger_than_the_budget() {
// Zoomed in 200×: the 40 pt square covers 8000 × 8000 px around a 200 × 200 view.
+12 -1
View File
@@ -173,6 +173,12 @@ pub struct RenderOptions {
/// running the effects again (see `fx`). Off, no raster made that way, or made over one, is
/// drawn: the effects run again.
pub interacting: bool,
/// Screen views during a tool's drag on the canvas: the renderer draws filtered content within
/// `fx::DRAG_PIXEL_BUDGET` pixels, at a lower resolution where it would need more, unless it
/// can draw the content from a raster kept from an earlier frame. With [`Renderer::threads`]
/// above 0 it does the same for drop shadows, outer glows and inner glows. No raster made that
/// way is kept (see `fx`). Off, they render as in a still frame.
pub dragging: bool,
/// Appearance of Black's Rich Black: pure CMYK black (black ink alone at 100%) shows as RGB
/// black. Off, it shows through the CMYK profile, a dark grey, as other colours do.
pub rich_black: bool,
@@ -254,6 +260,7 @@ impl Default for RenderOptions {
supersample: false,
image_mips: false,
interacting: false,
dragging: false,
rich_black: false,
}
}
@@ -545,8 +552,12 @@ pub struct FrameStats {
pub drawn: usize,
pub culled: usize,
/// The drop shadows, outer glows and filtered contents the frame drew from rasters made before
/// their object moved, or made over such rasters ([`RenderOptions::interacting`]).
/// their object moved, or made over such rasters ([`RenderOptions::interacting`]), and those it
/// drew at a drag's lower resolution ([`Self::previewed`]).
pub moved: usize,
/// The filtered contents, shadows and glows the frame drew at a drag's lower resolution
/// ([`RenderOptions::dragging`]). [`Self::moved`] counts them too.
pub previewed: usize,
/// How many threads composed the frame from its two ink planes, in a CMYK document that shows
/// transparency; 0 for other frames.
pub ink_threads: usize,
+2
View File
@@ -9,6 +9,8 @@ description = "VectorCraft SVG import and export"
workspace = true
[dependencies]
# Which paths stored in documents may be opened.
craft-docpath = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
+43 -31
View File
@@ -79,33 +79,25 @@ fn percent_decoded(s: &str) -> String {
String::from_utf8_lossy(&out).into_owned()
}
/// Is `p` an absolute path here or on another system (`/…`, `C:/…`, `C:\…`)?
fn is_absolute(p: &str) -> bool {
Path::new(p).is_absolute() || p.starts_with('/') || matches!(p.as_bytes(), [d, b':', b'/' | b'\\', ..] if d.is_ascii_alphabetic())
}
/// Where href `h` points (a path, made absolute from `folder`, or a URL) and whether it is a
/// file that can be read.
/// Where href `h` points (a path, or a URL) and whether it is a file that may be read: a local
/// file, or one on the network share the SVG is on. A relative path is taken from `folder`, the
/// SVG's own; without it, only absolute paths are read (see `craft_docpath`).
fn target(h: &str, folder: Option<&str>) -> (String, bool) {
let h = percent_decoded(h.trim());
let path = match h.strip_prefix("file://") {
Some(rest) => {
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
// `file:///C:/art.png` → `C:/art.png`.
match rest.as_bytes() {
[b'/', d, b':', ..] if d.is_ascii_alphabetic() => rest.get(1..).unwrap_or(rest).to_string(),
_ => rest.to_string(),
}
}
None if h.contains("://") => return (h, false),
None => h,
};
if is_absolute(&path) {
return (path, true);
let h = h.trim();
// `craft_docpath` decodes a `file:` URL itself; any other href is decoded here, once.
let file_url = h.get(..5).is_some_and(|s| s.eq_ignore_ascii_case("file:"));
let stored = if file_url { h.to_string() } else { percent_decoded(h) };
if !file_url && stored.contains("://") {
return (stored, false);
}
match folder {
Some(f) => (Path::new(f).join(&path).to_string_lossy().into_owned(), true),
None => (path, false),
// Resolved as a document inside `folder`: only the folder is used.
let document = folder.map(|f| Path::new(f).join("-"));
match craft_docpath::resolve(&stored, document.as_deref()) {
Ok(path) => {
let local = path.is_absolute();
(path.to_string_lossy().into_owned(), local)
}
Err(_) => (stored, false),
}
}
@@ -235,12 +227,32 @@ mod tests {
#[test]
fn hrefs_resolve_against_the_folder() {
let sep = std::path::MAIN_SEPARATOR;
assert_eq!(target("photo%20one.png", Some("/art")), (format!("/art{sep}photo one.png"), true));
assert_eq!(target("photo.png", None), ("photo.png".into(), false));
assert_eq!(target("/abs/photo.png", Some("/art")), ("/abs/photo.png".into(), true));
assert_eq!(target("file:///C:/art/a.png", None), ("C:/art/a.png".into(), true));
assert_eq!(target("file:///home/a.png", None), ("/home/a.png".into(), true));
assert_eq!(target("https://example.com/a.png", Some("/art")), ("https://example.com/a.png".into(), false));
let art = std::env::temp_dir().join("art");
let art = art.to_str().unwrap();
assert_eq!(target("photo%20one.png", Some(art)), (format!("{art}{sep}photo one.png"), true));
assert!(!target("photo.png", None).1);
assert_eq!(target("file:///C:/art/a.png", None), (r"C:\art\a.png".into(), cfg!(windows)));
assert_eq!(target("https://example.com/a.png", Some(art)), ("https://example.com/a.png".into(), false));
#[cfg(unix)]
{
assert_eq!(target("/abs/photo.png", Some("/art")), ("/abs/photo.png".into(), true));
assert_eq!(target("file:///home/a.png", None), ("/home/a.png".into(), true));
}
}
#[test]
fn hrefs_to_other_computers_are_not_read() {
let art = std::env::temp_dir().join("art");
for h in [
r"\\192.0.2.1\share\x.png",
"//192.0.2.1/share/x.png",
"file:////192.0.2.1/share/x.png",
"file://192.0.2.1/share/x.png",
"%5C%5C192.0.2.1%5Cshare%5Cx.png",
] {
assert!(!target(h, art.to_str()).1, "{h}");
assert!(!target(h, None).1, "{h}");
}
}
#[test]
+2
View File
@@ -21,3 +21,5 @@ kurbo = { workspace = true }
serde_json = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
# A Group 4 encoder and the T.4 run codes, to write CCITT Group 3 TIFFs (`tiff::group3`).
fax = "0.2"
+2 -1
View File
@@ -12,7 +12,8 @@
//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests.
//! - [`ai`]: hand-written Illustrator editing data, and the EPS and `.ai` files that carry it.
//! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library tests.
//! - [`tiff`]: hand-written TIFF files, chunky or planar, for image import tests.
//! - [`tiff`]: hand-written TIFF files, chunky or planar, or compressed with CCITT Group 3, for
//! image import tests.
//! - [`fonts`]: font files made from the bundled fonts (a renamed family) for font tests.
//!
//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`).
+172 -3
View File
@@ -1,6 +1,13 @@
//! Hand-written TIFF files for import tests: little-endian, uncompressed, 8 or 16 bits a sample,
//! with each pixel's samples stored together (chunky) or one plane per sample (planar,
//! PlanarConfiguration 2) (TIFF 6.0, sections 2 and 8).
//! PlanarConfiguration 2) (TIFF 6.0, sections 2 and 8); and 1-bit files compressed with CCITT
//! Group 3 (ITU-T T.4; Compression 2 and 3, T4Options and FillOrder as libtiff's `tiff.h` defines
//! them), coded by [`group3`].
use std::cell::RefCell;
use std::rc::Rc;
use fax::{BitWriter, Bits};
/// One image: its channels in the order the file stores them, color first, extra samples after.
#[derive(Clone, Debug, Default)]
@@ -32,6 +39,16 @@ fn align(out: &mut Vec<u8>) {
/// A directory entry: its tag, field type, count and value bytes.
type Entry = (u16, u16, usize, Vec<u8>);
/// An entry of SHORT values.
fn shorts(tag: u16, v: &[u16]) -> Entry {
(tag, 3, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect())
}
/// An entry of LONG values.
fn longs(tag: u16, v: &[u32]) -> Entry {
(tag, 4, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect())
}
/// The TIFF file of `t`: the header, the directory at offset 8, the values longer than four
/// bytes, then the image in one strip a plane.
pub fn tiff(t: &Tiff) -> Vec<u8> {
@@ -50,8 +67,6 @@ pub fn tiff(t: &Tiff) -> Vec<u8> {
let samples = u16::try_from(t.channels.len()).unwrap();
let counts: Vec<u32> = strips.iter().map(|s| u32::try_from(s.len()).unwrap()).collect();
let entries = |offsets: &[u32]| -> Vec<Entry> {
let shorts = |tag, v: &[u16]| (tag, 3, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect());
let longs = |tag, v: &[u32]| (tag, 4, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect());
let rational = |tag, n: u32| (tag, 5, 1, [n.to_le_bytes(), 1u32.to_le_bytes()].concat());
// In ascending tag order.
let mut e = vec![
@@ -77,6 +92,12 @@ pub fn tiff(t: &Tiff) -> Vec<u8> {
}
e
};
file(entries, strips)
}
/// The TIFF file of the directory `entries` gives for the strips' offsets, and `strips`: the
/// header, the directory at offset 8, the values longer than four bytes, then the strips.
fn file(entries: impl Fn(&[u32]) -> Vec<Entry>, strips: Vec<Vec<u8>>) -> Vec<u8> {
// The strips' offsets follow from the sizes of the rest, which don't depend on them.
let padded = |n: usize| n + n % 2;
let sized = entries(&vec![0; strips.len()]);
@@ -114,3 +135,151 @@ pub fn tiff(t: &Tiff) -> Vec<u8> {
assert_eq!(out.len(), at, "the strips sit where the directory says");
out
}
/// A 1-bit image compressed with CCITT Group 3, in strips of `rows_per_strip` rows or in tiles,
/// each coded on its own.
#[derive(Clone, Debug)]
pub struct Group3<'a> {
/// The pixels, row by row, `true` for a black run's.
pub rows: &'a [Vec<bool>],
/// Compression: 2 (modified Huffman: each row starts at a byte, no EOL codes) or 3 (T.4: an
/// EOL code before each row).
pub compression: u16,
/// T4Options, written with Compression 3: bit 0 codes each row after a strip's first against
/// the row above (two-dimensional), after a tag bit; bit 2 adds fill bits so that each EOL
/// code ends at a byte.
pub t4_options: u32,
/// PhotometricInterpretation: 0 white is zero, 1 black is zero.
pub photometric: u16,
/// FillOrder: 1 (written as no tag) or 2 (each byte's lowest bit first).
pub fill_order: u16,
pub rows_per_strip: u32,
/// Tiles of this width and height instead of strips (TileWidth, TileLength, TileOffsets and
/// TileByteCounts), left to right and top to bottom; their pixels past the image are white.
pub tile: Option<(u32, u32)>,
/// With Compression 3, the last strip or tile ends with six EOL codes (T.4's return to
/// control).
pub rtc: bool,
}
/// The bits a fax encoder writes, in order.
#[derive(Clone, Default)]
struct Written(Rc<RefCell<Vec<Bits>>>);
impl BitWriter for Written {
type Error = std::convert::Infallible;
fn write(&mut self, bits: Bits) -> Result<(), Self::Error> {
self.0.borrow_mut().push(bits);
Ok(())
}
}
/// `row` as one-dimensional runs (modified Huffman), white and black in turn from a white one
/// (ITU-T T.4 §4.1.1); runs of 64 to 1791 pixels take a make-up code.
fn runs(row: &[bool]) -> Vec<Bits> {
let mut out = vec![];
let (mut x, mut black) = (0, false);
while x < row.len() {
let n = row[x..].iter().take_while(|b| **b == black).count();
let code = |n: usize| if black { fax::maps::black::encode(n as u16) } else { fax::maps::white::encode(n as u16) }.unwrap();
if n >= 64 {
out.push(code(n & !63));
}
out.push(code(n % 64));
(x, black) = (x + n, !black);
}
out
}
/// The coded data of the strip or tile of `g` that holds `rows`; the `last` one carries the six
/// EOL codes [`Group3::rtc`] asks for.
fn group3_strip(g: &Group3, rows: &[Vec<bool>], last: bool) -> Vec<u8> {
let two_d = g.compression == 3 && g.t4_options & 1 != 0;
let fill = g.compression == 3 && g.t4_options & 4 != 0;
let written = Written::default();
let mut encoder = fax::encoder::Encoder::new(written.clone());
let mut bits: Vec<Bits> = vec![];
let used = |bits: &[Bits]| bits.iter().map(|b| usize::from(b.len)).sum::<usize>();
let zeros = |n: usize| Bits { data: 0, len: n as u8 };
let eol = |bits: &mut Vec<Bits>, tag: Option<bool>| {
// Fill bits so that the EOL code (12 bits) ends at a byte.
let pad = (8 - (used(bits) + 12) % 8) % 8;
if fill && pad > 0 {
bits.push(zeros(pad));
}
bits.push(fax::maps::EOL);
if let Some(one_d) = tag {
bits.push(Bits { data: u16::from(one_d), len: 1 });
}
};
for (i, row) in rows.iter().enumerate() {
// The encoder codes each row against the one above (as T.6 and T.4's two-dimensional rows
// are coded), the first against a white row.
let at = written.0.borrow().len();
encoder.encode_line(row.iter().map(|b| if *b { fax::Color::Black } else { fax::Color::White }), row.len() as u16).unwrap();
let coded = written.0.borrow()[at..].to_vec();
if g.compression == 2 {
let pad = (8 - used(&bits) % 8) % 8;
if pad > 0 {
bits.push(zeros(pad));
}
} else {
eol(&mut bits, two_d.then_some(i == 0));
}
bits.extend(if two_d && i > 0 { coded } else { runs(row) });
}
if g.compression == 3 && g.rtc && last {
for _ in 0..6 {
eol(&mut bits, two_d.then_some(true));
}
}
let mut w = fax::VecWriter::new();
for b in bits {
w.write(b).unwrap();
}
let data = w.finish();
if g.fill_order == 2 { data.iter().map(|b| b.reverse_bits()).collect() } else { data }
}
/// The TIFF file of `g`, its rows coded by [`group3_strip`].
pub fn group3(g: &Group3) -> Vec<u8> {
let height = u32::try_from(g.rows.len()).unwrap();
let width = u32::try_from(g.rows[0].len()).unwrap();
assert!(g.rows.iter().all(|r| r.len() == g.rows[0].len()), "every row has the same width");
assert!(matches!(g.compression, 2 | 3), "Compression 2 or 3");
let chunks: Vec<Vec<Vec<bool>>> = match g.tile {
Some((tw, th)) => {
let (across, down) = (width.div_ceil(tw), height.div_ceil(th));
let px = |x: u32, y: u32| g.rows.get(y as usize).and_then(|r| r.get(x as usize)).copied().unwrap_or(false);
let tile = |tx: u32, ty: u32| (0..th).map(|r| (0..tw).map(|c| px(tx * tw + c, ty * th + r)).collect()).collect();
(0..down).flat_map(|ty| (0..across).map(move |tx| (tx, ty))).map(|(tx, ty)| tile(tx, ty)).collect()
}
None => g.rows.chunks(usize::try_from(g.rows_per_strip).unwrap()).map(<[Vec<bool>]>::to_vec).collect(),
};
let strips: Vec<Vec<u8>> = chunks.iter().enumerate().map(|(i, rows)| group3_strip(g, rows, i + 1 == chunks.len())).collect();
let counts: Vec<u32> = strips.iter().map(|s| u32::try_from(s.len()).unwrap()).collect();
let entries = |offsets: &[u32]| -> Vec<Entry> {
// In ascending tag order.
let mut e =
vec![longs(256, &[width]), longs(257, &[height]), shorts(258, &[1]), shorts(259, &[g.compression]), shorts(262, &[g.photometric])];
if g.fill_order != 1 {
e.push(shorts(266, &[g.fill_order]));
}
if g.tile.is_none() {
e.push(longs(273, offsets));
}
e.push(shorts(277, &[1]));
if g.tile.is_none() {
e.extend([longs(278, &[g.rows_per_strip]), longs(279, &counts)]);
}
if g.compression == 3 {
e.push(longs(292, &[g.t4_options]));
}
if let Some((tw, th)) = g.tile {
e.extend([longs(322, &[tw]), longs(323, &[th]), longs(324, offsets), longs(325, &counts)]);
}
e
};
file(entries, strips)
}
+13 -3
View File
@@ -197,14 +197,24 @@ fn in_scope(doc: &vectorcraft_doc::Document, scope: Option<NodeId>, id: NodeId)
}
/// Visit the nodes Direct Selection may pick. In isolation mode that is the isolated object and
/// its contents; art around it is locked and the walk does not reach it.
/// its contents; art around it is locked and the walk does not reach it. A Shaper Group's original
/// shapes, its first child, are left out unless they are the isolated object (Construction mode),
/// as clicks leave them out.
fn walk_pickable<'a>(doc: &'a vectorcraft_doc::Document, scope: Option<NodeId>, mut f: impl FnMut(&'a Node)) {
fn walk<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
f(n);
for c in n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())) {
walk(c, f);
}
}
if let Some(id) = scope
&& let Some(n) = doc.node(id)
{
n.walk(&mut f);
walk(n, &mut f);
} else if scope.is_none() {
doc.walk(f);
for l in &doc.layers {
walk(l, &mut f);
}
}
}
+61 -7
View File
@@ -424,6 +424,22 @@ impl Targets {
(cx.snap_to_point && !cx.smart_guides).then(|| Self::collect(cx.doc, exclude, None).for_snap_to_point(cx))
}
/// The gridlines (one every `step` from the page's origin, as
/// [`vectorcraft_geom::snap::snap_to_grid`] places them) at and either side of the nearest one
/// to each of `points`, as lines to line up with.
fn grid_lines(points: impl IntoIterator<Item = Point>, step: f64) -> Self {
let mut t = Targets::default();
let line = |v: f64, k: i32| vectorcraft_geom::snap::snap_to_grid(v, step, 0.0) + f64::from(k) * step;
for p in points {
for k in -1..=1 {
let (x, y) = (line(p.x, k), line(p.y, k));
t.xs.push((x, Point::new(x, p.y), Kind::Guide));
t.ys.push((y, Point::new(p.x, y), Kind::Guide));
}
}
t.index()
}
/// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds
/// reach into `near`.
fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self {
@@ -799,8 +815,8 @@ impl Targets {
/// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own
/// frame, and so is the result. The handle lands on an anchor or centre within `tol` on the page
/// (labelled as [`Self::snap_point`] does), else the corners and the side it moves line up with
/// the nearest target ("align"), whatever the box's angle. A handle with one way to go (a side,
/// or a proportional corner) slides along it until one of them is on a target.
/// the nearest target or ruler guide ("align"), whatever the box's angle. A handle with one way
/// to go (a side, or a proportional corner) slides along it until one of them is on a target.
pub fn snap_scale(&self, bx: &OrientedBox, handle: Handle, a: Affine, proportional: bool, from_center: bool, tol: f64) -> (Affine, Vec<Overlay>) {
type Hit = Option<(f64, Point)>;
// Where a snap lands: in line with a target's x and/or y, or on an anchor or centre.
@@ -811,10 +827,16 @@ impl Targets {
let (to_doc, to_local) = (bx.to_doc(), bx.to_doc().inverse());
// A scale that collapses or flips the box is no snap.
let valid = |n: f64, s: f64| n.is_finite() && n * s > 0.0;
let nearest = |v: f64, ts: &[(f64, Point, Kind)]| -> Hit {
// The target line along the x axis (`x`: a vertical line) or the y axis nearest `p`, the
// ruler guides running past it included.
let nearest = |p: Point, x: bool| -> Hit {
let (v, ts) = if x { (p.x, &self.xs) } else { (p.y, &self.ys) };
let rulers = self.rulers.iter().filter(|r| r.vertical == x).filter_map(|r| r.line_at(p, tol));
lines_near(ts, v, tol)
.iter()
.map(|(t, from, _)| (*t, *from))
.copied()
.chain(rulers)
.map(|(t, from, _)| (t, from))
.filter(|(t, _)| (t - v).abs() <= tol)
.min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs()))
};
@@ -824,7 +846,7 @@ impl Targets {
// The corner goes anywhere: onto an anchor or centre, else it takes the target's x, its
// y, or both.
let p = to_doc * (a * handle.pos(r));
let (hx, hy) = (nearest(p.x, &self.xs), nearest(p.y, &self.ys));
let (hx, hy) = (nearest(p, true), nearest(p, false));
let on = self.nearest_point(p, tol).map(|(q, k)| (Some((q.x, q)), Some((q.y, q)), Some((q, k))));
for (tx, ty, at) in on.into_iter().chain([(hx, hy, None), (hx, None, None), (None, hy, None)]) {
if tx.is_none() && ty.is_none() {
@@ -878,11 +900,11 @@ impl Targets {
{
consider((q - base).dot(dir) / l2, false, (None, None, Some((q, k))));
}
for (is_x, at, b, d, ts) in [(true, p.x, base.x, dir.x, &self.xs), (false, p.y, base.y, dir.y, &self.ys)] {
for (is_x, b, d) in [(true, base.x, dir.x), (false, base.y, dir.y)] {
if d.abs() <= 1e-9 {
continue;
}
let Some(hit) = nearest(at, ts) else { continue };
let Some(hit) = nearest(p, is_x) else { continue };
consider((hit.0 - b) / d, true, if is_x { (Some(hit), None, None) } else { (None, Some(hit), None) });
}
}
@@ -980,6 +1002,38 @@ impl PointSnap {
None => (keep(p), vec![]),
}
}
/// Snap a bounding-box resize ([`Targets::snap_scale`]) as [`Self::snap`] snaps a point. With
/// Snap to Pixel on, the corner or side the handle moves lines up with whole pixels, and with
/// Snap to Grid on it lines up with the nearest gridline, however far that is; neither shows
/// guides. Otherwise the handle snaps to the targets [`Self::new`] gathered.
pub fn snap_scale(
&self,
cx: &ToolContext,
bx: &OrientedBox,
handle: Handle,
a: Affine,
proportional: bool,
from_center: bool,
) -> (Affine, Vec<Overlay>) {
let step = if cx.snap_to_pixel {
Some(1.0)
} else if cx.snap_to_grid {
Some(cx.grid_step()).filter(|s| s.is_finite() && *s > 0.0)
} else {
None
};
if let Some(step) = step {
let moved = Handle::ALL.map(|h| bx.to_doc() * (a * h.pos(bx.rect)));
// Every point the handle moves is within half a step of a gridline, so a tolerance of
// one step always finds one.
return (Targets::grid_lines(moved, step).snap_scale(bx, handle, a, proportional, from_center, step).0, vec![]);
}
match &self.targets {
Some((t, tol)) => t.snap_scale(bx, handle, a, proportional, from_center, cx.tol(*tol)),
None => (a, vec![]),
}
}
}
/// The Construction Guides angle set new preferences start with.
+100 -6
View File
@@ -1,7 +1,8 @@
//! The Selection tool (V): click/shift-click, marquee (Shift-drag toggles the objects it reaches:
//! the selected ones are deselected, the others selected), move (Alt copies, Shift constrains; Smart
//! Guides, or with them off View › Snap to Point, snap it),
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
//! bounding-box scale (Shift proportional, Alt from center; Snap to Grid, Snap to Pixel, Smart
//! Guides and Snap to Point snap what the handle moves) and rotate (outside corners, Shift 45°),
//! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind,
//! double-click opens the Corners dialog), drag a live ellipse's pie widgets to set its pie angles
//! (double-click makes it whole again: [`crate::pie`]), double-click a group or an object to enter isolation
@@ -28,7 +29,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
use crate::corners::{self, CornerDrag, over_widget};
use crate::guides::Targets;
use crate::guides::{PointSnap, Targets};
use crate::pathtype::{self, BracketDrag, over_bracket};
use crate::pie::{self, PieDrag};
use crate::rulerguide::GuideEdit;
@@ -91,7 +92,7 @@ pub struct SelectionTool {
measure: Option<(Point, String)>,
guides: Vec<Overlay>,
/// What a bounding-box handle being dragged snaps to.
targets: Option<Targets>,
targets: Option<PointSnap>,
moving: Option<MoveSnap>,
/// The move snapped to a point or a guide at its last step: the arrow turns hollow.
snapped: bool,
@@ -436,8 +437,10 @@ impl Tool for SelectionTool {
let areas = cx.selection.objects.iter().any(is_area);
// Only area type: the step resizes type areas; anything else scales.
let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" };
// Smart Guides align what the handle moves with the other objects.
self.targets = cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx));
// What the handle moves snaps as a dragged point does: to pixels or the
// grid, else with Smart Guides to the other objects, else with Snap to
// Point to their anchors; the ruler guides with Snap to Point.
self.targets = Some(PointSnap::new(cx, || Targets::collect(cx.doc, &cx.selection.objects, None)));
self.state = State::Scaling { handle, bx, areas };
return vec![Action::Begin(label.into())];
}
@@ -514,7 +517,7 @@ impl Tool for SelectionTool {
let mut a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
self.guides.clear();
if let Some(t) = &self.targets {
(a, self.guides) = t.snap_scale(&bx, handle, a, m.shift, m.alt, cx.snap_tol());
(a, self.guides) = t.snap_scale(cx, &bx, handle, a, m.shift, m.alt);
}
let nr = a.transform_rect_bbox(bx.rect);
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
@@ -1451,6 +1454,97 @@ mod tests {
assert!((height(&a) - 97.0).abs() < 1e-9, "{a:?}");
}
/// The box the 100–200 rectangle's bounds become under the scale a handle drag previews.
fn scaled(a: &[Action]) -> Rect {
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]).transform_rect_bbox(Rect::new(100.0, 100.0, 200.0, 200.0))
}
fn near(a: Rect, b: Rect) -> bool {
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9)
}
/// #2054: with Snap to Grid on, a handle drag lands what the handle moves on the nearest
/// gridline (every 9 pt by default) while the opposite side stays put; Shift keeps the
/// proportions, and Alt keeps the center.
#[test]
fn handle_drag_lands_what_it_moves_on_the_grid() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
let drag = |from: (f64, f64), to: (f64, f64), mods: Mods| {
let mut t = SelectionTool::default();
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(mods)), vec![Action::Begin("Scale".into())]);
scaled(&t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(mods)))
};
let none = Mods::default();
// The right side, dragged to x = 213, lands on the gridline at 216.
let r = drag((200.0, 150.0), (213.0, 150.0), none);
assert!(near(r, Rect::new(100.0, 100.0, 216.0, 200.0)), "{r:?}");
// The bottom-right corner, dragged to (213, 204), lands on (216, 207).
let r = drag((200.0, 200.0), (213.0, 204.0), none);
assert!(near(r, Rect::new(100.0, 100.0, 216.0, 207.0)), "{r:?}");
// Shift: the corner, at (213, 213) in proportion, lands on (216, 216), still square.
let r = drag((200.0, 200.0), (213.0, 204.0), Mods { shift: true, ..none });
assert!(near(r, Rect::new(100.0, 100.0, 216.0, 216.0)), "{r:?}");
// Alt: the right side lands on 216 and the left side mirrors it about the center.
let r = drag((200.0, 150.0), (213.0, 150.0), Mods { alt: true, ..none });
assert!(near(r, Rect::new(84.0, 100.0, 216.0, 200.0)), "{r:?}");
}
/// #2054: with Snap to Pixel on, what a handle drag moves lands on whole pixels.
#[test]
fn handle_drag_lands_what_it_moves_on_whole_pixels() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let c = ToolContext { snap_to_pixel: true, ..cx(&d, &s, &p) };
let mut t = SelectionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 200.0, 200.0));
let r = scaled(&t.pointer(&c, &ev(PointerKind::Drag, 212.4, 230.7)));
assert!(near(r, Rect::new(100.0, 100.0, 212.0, 231.0)), "{r:?}");
}
/// #2054: with Smart Guides off, View › Snap to Point lands a dragged handle on another
/// object's anchor or a ruler guide within its distance (2 px); with Smart Guides on, the
/// ruler guides still pull it within the Snapping Tolerance (4 px), as a move does (#1534).
#[test]
fn handle_drag_snaps_to_anchors_and_ruler_guides_with_snap_to_point() {
use vectorcraft_doc::{Appearance, Node};
let (mut d, id) = doc_with_rect();
let l = d.layers[0].id;
let other = d.alloc_id();
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 300.0, 300.0));
d.insert(Some(l), 1, Node::path(other, sq, Appearance::default_art())).unwrap();
d.guides = vec![vectorcraft_doc::Guide::new(true, 230.5)];
let mut s = Selection::default();
s.add(id);
let p = paint();
let drag = |c: &ToolContext, from: (f64, f64), to: (f64, f64)| {
let mut t = SelectionTool::default();
t.pointer(c, &ev(PointerKind::Down, from.0, from.1));
scaled(&t.pointer(c, &ev(PointerKind::Drag, to.0, to.1)))
};
let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) };
assert!(off.snap_to_point);
// The corner, 1.4 px from the other square's corner, lands on it.
let r = drag(&off, (200.0, 200.0), (249.0, 251.0));
assert!(near(r, Rect::new(100.0, 100.0, 250.0, 250.0)), "{r:?}");
// The right side, 1 px from the ruler guide, lands on it.
let r = drag(&off, (200.0, 150.0), (231.5, 150.0));
assert!(near(r, Rect::new(100.0, 100.0, 230.5, 200.0)), "{r:?}");
// Snap to Point off: the side follows the pointer.
let r = drag(&ToolContext { snap_to_point: false, ..off }, (200.0, 150.0), (231.5, 150.0));
assert!(near(r, Rect::new(100.0, 100.0, 231.5, 200.0)), "{r:?}");
// Smart Guides on: the ruler guide, 2.5 px away, pulls the side too.
let r = drag(&cx(&d, &s, &p), (200.0, 150.0), (233.0, 150.0));
assert!(near(r, Rect::new(100.0, 100.0, 230.5, 200.0)), "{r:?}");
}
/// Smart Guides › Measurement Labels, Transform Tools and Snapping Tolerance (#394): the
/// readouts while moving and while scaling each follow their option; the tolerance sets how
/// far a dragged handle is pulled.
+29 -6
View File
@@ -2,7 +2,7 @@
//!
//! Shows the bounding box with handles. In the default mode handles scale (Shift proportional,
//! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle
//! shears along that side. The Perspective Distort and Free Distort modes (tool option `mode`, or
//! shears along that side. A scale snaps as the Selection tool's does. The Perspective Distort and Free Distort modes (tool option `mode`, or
//! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the
//! box and preview `object.distort`.
@@ -11,6 +11,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use super::{BLUE, polygon, rect_corners};
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
use crate::guides::{PointSnap, Targets};
use crate::select::{matrix_json, selection_bounds};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
@@ -82,8 +83,10 @@ pub struct FreeTransformTool {
/// The current (distorted) quad while dragging corners.
quad: Option<[Point; 4]>,
measure: Option<(Point, String)>,
/// Smart Guides for a scale: the other objects' edges, and the lines of the current snap.
targets: Option<crate::guides::Targets>,
/// What a scale snaps to ([`PointSnap::snap_scale`]): pixels, the grid, the other objects with
/// Smart Guides, or their anchors with Snap to Point.
targets: Option<PointSnap>,
/// The lines of the current snap.
guides: Vec<Overlay>,
}
@@ -198,7 +201,7 @@ impl FreeTransformTool {
Op::Scale(h) => {
let mut a = scale_for_drag(d.rect, h, p, shift, m.alt);
if let Some(t) = &self.targets {
(a, self.guides) = t.snap_scale(&vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt, cx.snap_tol());
(a, self.guides) = t.snap_scale(cx, &vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt);
}
let nr = a.transform_rect_bbox(d.rect);
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
@@ -232,8 +235,7 @@ impl Tool for FreeTransformTool {
self.drag = None;
let Some(r) = selection_bounds(cx) else { return vec![] };
if let Some(op) = self.classify(cx, r, p, ev.mods) {
self.targets = (cx.smart_guides && op.handle().is_some())
.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx));
self.targets = op.handle().is_some().then(|| PointSnap::new(cx, || Targets::collect(cx.doc, &cx.selection.objects, None)));
self.drag = Some(Drag { op, rect: r, start: p, began: false });
}
vec![]
@@ -384,6 +386,27 @@ mod tests {
assert!(matches!(&a[1], Action::Preview(_, v) if v["matrix"][4] == 10.0 && v["matrix"][5] == 20.0));
}
/// #2054: with Snap to Grid on, a handle drag lands the side it moves on the nearest gridline
/// (every 9 pt by default) while the opposite side stays put.
#[test]
fn handle_drag_snaps_to_the_grid() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
let matrix = |a: &[Action]| {
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]])
};
let mut t = FreeTransformTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 150.0));
let r = matrix(&t.pointer(&cx, &ev(PointerKind::Drag, 213.0, 150.0))).transform_rect_bbox(Rect::new(100.0, 100.0, 200.0, 200.0));
assert!((r.x1 - 216.0).abs() < 1e-9 && (r.x0 - 100.0).abs() < 1e-9, "{r:?}");
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 213.0, 150.0)), vec![Action::Commit]);
}
/// In a view turned 90° the handles' cursors turn with the view.
#[test]
fn handle_cursors_turn_with_the_view() {
+130 -29
View File
@@ -3,6 +3,7 @@
use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
use serde_json::json;
use vectorcraft_doc::text::TextKind;
use vectorcraft_doc::{Node, NodeKind, SymbolBoxes, Unit};
use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect};
use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
@@ -315,6 +316,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
isolated: st.isolation.map(|id| id.0),
// Appearance of Black › On Screen: Display All Blacks as Rich Black.
rich_black: app.session.prefs.black_on_screen == "rich",
// A tool's drag on the canvas, during an interaction (not a dialog's preview).
dragging: app.session.in_interaction() && ui.data(|d| d.get_temp::<Drag>(drag_id())) == Some(Drag::Tool),
};
// Placed documents' bitmaps are being made: draw again when they are ready.
if vectorcraft_render::placed_document::busy() {
@@ -376,6 +379,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// During a drag or a dialog's preview, objects that only moved draw their raster
// effects from the cached rasters of an earlier frame (#1257).
interacting: key.interacting,
// During a tool's drag, raster effects without a cached raster to draw render at a
// lower resolution (#2558); the frame after the drag renders them as a still frame does.
dragging: key.dragging,
rich_black: key.rich_black,
..opts
};
@@ -2854,30 +2860,44 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
let n = st.selection.len();
let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned();
let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. }));
let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_)));
let is_image = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Image(_)));
let is_clip_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { clip: true, .. }));
let is_compound = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Compound { .. }));
let open_path = first.as_ref().is_some_and(|f| matches!(&f.kind, NodeKind::Path { path, .. } if !path.is_closed()));
let text = first.as_ref().and_then(|f| match &f.kind {
NodeKind::Text(t) => Some(&t.kind),
_ => None,
});
// Images and vector art selected together: a clipping mask is the likely next step.
let mixed = n > 1 && {
let images = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).filter(|o| matches!(o.kind, NodeKind::Image(_))).count();
images > 0 && images < n
};
// An Image Trace object: the group the engine names "Image Trace", its source image first.
let is_trace = is_group
&& first.as_ref().is_some_and(|f| {
f.name.as_deref() == Some("Image Trace") && f.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
});
let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command)
let mut items: Vec<(&str, &str, &str, serde_json::Value)> = vec![]; // (label, icon, command, params)
let none = || json!({});
if on_artboards {
// As in the reference app, the Artboard tool offers to resize, duplicate and export the
// artboards (<https://community.adobe.com/questions-652/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>),
// and to rearrange them (<https://creativepro.com/managing-artboards-in-illustrator/>).
items.push((tl!("Resize"), "move-diagonal-2", TASK_BAR_ARTBOARD_OPTIONS));
items.push((tl!("Duplicate"), "copy", TASK_BAR_DUPLICATE_ARTBOARDS));
items.push((tl!("Resize"), "move-diagonal-2", TASK_BAR_ARTBOARD_OPTIONS, none()));
items.push((tl!("Duplicate"), "copy", TASK_BAR_DUPLICATE_ARTBOARDS, none()));
if st.doc.artboards.len() > 1 {
items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS));
items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none()));
}
items.push((tl!("Export"), "share-2", "file.exportForScreens"));
items.push((tl!("Export"), "share-2", "file.exportForScreens", none()));
} else {
// Edit Path: the Direct Selection tool, to edit the path's anchors.
let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"}));
// As in the reference app, an image offers Image Trace and Crop Image, and traced art offers
// Expand (<https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>).
if n == 1 && is_image {
items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE));
items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage"));
items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE, none()));
items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage", none()));
}
// Direct-selected anchors take the place of a path's Offset Path and Simplify. For them the bar
// offers Remove, Connect, Cut and Convert to corner or smooth, as the reference app does
@@ -2885,29 +2905,52 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
// These run the same commands as the Control bar's anchor buttons.
let anchors = !st.selection.anchors.is_empty();
if anchors {
items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors"));
items.push((tl!("Connect"), "dc-join", "path.join"));
items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors"));
items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER));
items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH));
items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors", none()));
items.push((tl!("Connect"), "dc-join", "path.join", none()));
items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors", none()));
items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none()));
items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none()));
}
// As in the reference app, open paths offer Join and Edit Path, compound paths and clipping
// masks their Release, images selected with vector art Make Clipping Mask, and type converts
// between point and area type (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>).
if n > 1 {
items.push((tl!("Group"), "group", "object.group"));
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite"));
items.push((tl!("Group"), "group", "object.group", none()));
if mixed {
items.push((tl!("Make Clipping Mask"), "dc-pf-crop", "object.clippingMask.make", none()));
} else {
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite", none()));
}
} else if is_trace {
items.push((tl!("Expand"), "maximize-2", "imageTrace.expand"));
items.push((tl!("Expand"), "maximize-2", "imageTrace.expand", none()));
} else if is_clip_group {
items.push((tl!("Edit Clipping Path"), "dc-direct", "object.clippingMask.editMask", none()));
items.push((tl!("Release Clipping Mask"), "ungroup", "object.clippingMask.release", none()));
} else if is_group {
items.push((tl!("Ungroup"), "ungroup", "object.ungroup"));
items.push((tl!("Isolate"), "square-dashed", "object.isolate"));
} else if is_text {
items.push((tl!("Create Outlines"), "type", "type.createOutlines"));
items.push((tl!("Ungroup"), "ungroup", "object.ungroup", none()));
items.push((tl!("Isolate"), "square-dashed", "object.isolate", none()));
} else if is_compound {
items.push(edit_path());
items.push((tl!("Release Compound Path"), "ungroup", "object.compoundPath.release", none()));
} else if let Some(kind) = text {
items.push((tl!("Create Outlines"), "type", "type.createOutlines", none()));
// Point type converts to area type and back; type on a path doesn't.
match kind {
TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())),
TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())),
TextKind::OnPath { .. } => {}
}
} else if is_image {
// Offset Path and Simplify don't apply to an image.
} else if !anchors {
items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath"));
items.push((tl!("Simplify"), "spline", "object.path.simplify"));
if open_path {
items.push((tl!("Join"), "dc-join", "path.join", none()));
}
items.push(edit_path());
items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath", none()));
items.push((tl!("Simplify"), "spline", "object.path.simplify", none()));
}
items.push((tl!("Duplicate"), "copy", "edit.duplicate"));
items.push((tl!("Duplicate"), "copy", "edit.duplicate", none()));
}
let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default();
let colored = has_colors(&st.doc, &st.selection.objects);
@@ -2916,7 +2959,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
// rotated).
let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
let anchor = pos2(xf.to_screen(b.center()).x, lowest);
let est_w = 118.0 + items.iter().map(|(l, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
let est_w = 118.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
// Its place under the selection, then where the handle moved it, kept on the canvas (with the
// bar's size last frame, the estimate before it first shows).
let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0);
@@ -2962,7 +3005,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
} else if grip.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
for (label, icon, cmd) in &items {
for (label, icon, cmd, params) in &items {
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong);
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click());
if resp.hovered() {
@@ -2972,7 +3015,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon);
ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong);
if resp.clicked() {
run = Some((*cmd, json!({})));
run = Some((*cmd, params.clone()));
}
}
// The selected objects' Fill, Recolor Artwork and Lock, left out with the Artboard tool.
@@ -4368,7 +4411,7 @@ mod tests {
app.session.pointer(&PointerEvent::new(PointerKind::Up, 181.9, 131.3), view).unwrap();
assert!(upload(&mut app).is_none(), "not rendered on this thread");
assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| !k.interacting), "sent to the worker");
let until = std::time::Instant::now() + std::time::Duration::from_secs(10);
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
let mut after = None;
while after.is_none() && std::time::Instant::now() < until {
std::thread::sleep(std::time::Duration::from_millis(5));
@@ -4383,6 +4426,64 @@ mod tests {
assert!(upload(&mut app) == Some(after), "the frame after the drag is what a fresh renderer draws");
}
/// A tool's drag on the canvas renders with `dragging` (#2558). At 3200% a rectangle with
/// Texturizer covers more than the raster budget, so no raster is kept for it, and each frame
/// of a Selection tool drag draws its filtered content at the drag's lower resolution. The
/// frame after the drag draws what a fresh renderer draws. A dialog's preview, an interaction
/// without a drag, renders without `dragging` and draws the same.
#[test]
fn a_drag_on_the_canvas_draws_raster_effects_at_a_lower_resolution() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.canvas.worker_started = true; // render on this thread
let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150})).unwrap()["id"].clone();
app.run("effect.apply", json!({"effect": "texture.texturizer", "ids": [id]})).unwrap();
app.select_tool("selection");
let ctx = egui::Context::default();
// One headless frame on a 1920 × 1200 window (a view larger than the drag's budget) with
// `events` → the art's raster it uploaded, None when it was not re-rendered.
let run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| -> Option<Vec<Color32>> {
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1920.0, 1200.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
let art = app.canvas.texture.as_ref().map(|t| t.id());
let px = out.textures_delta.set.iter().find(|e| Some(*e.0) == art).and_then(|e| e.1.last()).map(|d| {
let egui::ImageData::Color(img) = &d.image;
img.pixels.clone()
});
out.textures_delta.clear();
px
};
run(&mut app, vec![]);
if let Some(vm) = app.view_mut() {
(vm.zoom, vm.center) = (32.0, Point::new(200.0, 155.0));
}
let still = run(&mut app, vec![]).expect("the zoomed view renders");
assert_eq!(app.canvas.renderer.stats.previewed, 0, "a still frame");
let at = app.canvas_rect.unwrap().center();
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
run(&mut app, vec![egui::Event::PointerMoved(at)]);
run(&mut app, vec![button(at, true)]);
for step in [1.0, 2.0] {
let drawn = run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]).expect("each frame of the drag renders");
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}");
assert_eq!(app.canvas.renderer.stats.previewed, 1, "the filtered content, step {step}");
assert!(app.canvas.moved);
assert!(drawn != still, "the rectangle moved, step {step}");
}
let after = run(&mut app, vec![button(at + vec2(26.0, 14.0), false)]).expect("the frame after the drag renders");
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting && !k.dragging));
assert_eq!(app.canvas.renderer.stats.previewed, 0, "the frame after the drag");
assert!(!app.canvas.moved);
app.canvas.renderer = vectorcraft_render::Renderer::new();
app.canvas.key = None;
assert!(run(&mut app, vec![]) == Some(after.clone()), "the frame after the drag is what a fresh renderer draws");
// A dialog's preview: an interaction, and no drag.
app.session.begin_interaction("Texturizer").unwrap();
assert!(run(&mut app, vec![]) == Some(after), "a dialog's preview draws the effect at full resolution");
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && !k.dragging));
assert_eq!(app.canvas.renderer.stats.previewed, 0);
}
/// The frame after an interaction renders without `interacting` also when the interaction
/// leaves the document as it was (#1257): here a drag back to where it started.
#[test]
@@ -6573,7 +6674,7 @@ mod tests {
// A selected object with the Selection tool: its own actions, none of the artboards'.
app.select_tool("selection");
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
assert_eq!(names(&mut app), ["Offset Path", "Simplify", "Duplicate"]);
assert_eq!(names(&mut app), ["Edit Path", "Offset Path", "Simplify", "Duplicate"]);
// One artboard: nothing to rearrange.
app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
@@ -6675,7 +6776,7 @@ mod tests {
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let mut square =
|x: f64, y: f64| app.session.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 40})).unwrap()["id"].clone();
let (a, b) = (square(120.0, 40.0), square(220.0, 150.0));
let (a, b) = (square(120.0, 40.0), square(160.0, 150.0));
app.select_tool("selection");
let ctx = egui::Context::default();
let bar = || task_bar_rect(&ctx).expect("the task bar shows");
+25 -7
View File
@@ -131,7 +131,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
/// `file.saveForWeb` with settings: write the files (`path`, else a picked file: the page with HTML
/// output, else the image); the web downloads them.
/// output, else the image); the web downloads them. The status bar shows the export's first
/// warning.
pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, String> {
// Bad settings never open a save panel.
let s = app.session.web_settings(&p)?;
@@ -145,8 +146,11 @@ pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, Stri
io::write_to(&mut app.services, name, &bytes)?;
names.push(json!(name));
}
app.status(format!("Downloaded {} file(s)", names.len()));
return Ok(json!({ "files": names, "bytes": r["bytes"] }));
match names.first().and_then(Value::as_str).filter(|_| has_warnings(&r)) {
Some(first) => io::report_saved(app, first, &r),
None => app.status(format!("Downloaded {} file(s)", names.len())),
}
return Ok(json!({ "files": names, "bytes": r["bytes"], "warnings": r["warnings"] }));
}
let ext = if s.output == WebOutput::Html { "html" } else { s.format.ext() };
let path = io::target_path(app, path, ext)?;
@@ -155,12 +159,23 @@ pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, Stri
}
let r = app.run("document.exportForWeb", p)?;
let n = r["files"].as_array().map_or(0, Vec::len);
app.status(if n > 1 { format!("Saved {n} files beside {path}") } else { format!("Saved {path}") });
if n > 1 && !has_warnings(&r) {
app.status(format!("Saved {n} files beside {path}"));
} else {
io::report_saved(app, &path, &r);
}
Ok(r)
}
/// Whether an export's reply `r` has warnings (an image that couldn't be decoded):
/// [`io::report_saved`] puts the first in the status bar as a save's note.
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
/// default browser (desktop).
/// 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> {
if io::is_web(app) {
return Err("Preview in Browser needs the desktop app (the web downloads files instead)".into());
@@ -173,9 +188,12 @@ pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Val
o.insert("path".into(), json!(path));
o.insert("output".into(), json!("html"));
}
app.run("document.exportForWeb", q)?;
let r = app.run("document.exportForWeb", q)?;
app.open_url(&io::file_url(&path));
Ok(json!({ "path": path }))
if has_warnings(&r) {
io::report_saved(app, &path, &r);
}
Ok(json!({ "path": path, "warnings": r["warnings"] }))
}
/// The folder browser previews go to.
@@ -133,3 +133,25 @@ fn save_for_office_documents_picks_the_resolution() {
let img = image::load_from_memory(&written.borrow()[1].1).unwrap().to_rgba8();
assert_eq!((img.dimensions(), img.get_pixel(0, 0).0), ((60, 40), [255, 255, 255, 255]));
}
#[test]
fn save_for_office_and_export_selection_say_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";
let (mut app, written) = app(1);
app.run("document.exportForOffice", json!({"path": "/x/o.png", "ppi": 72})).unwrap();
assert_eq!(app.ui.status, "Saved /x/o.png");
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short.
let mut png = Vec::new();
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
png.truncate(idat + 8);
app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [10, 10]})).unwrap();
app.run("document.exportForOffice", json!({"path": "/x/o.png", "ppi": 72})).unwrap();
assert_eq!(app.ui.status, format!("Saved /x/o.png {NOTE}"));
// Export Selection of the placed image, which stays selected.
app.run("document.exportSelection", json!({"path": "/x/cut.png"})).unwrap();
assert_eq!(app.ui.status, format!("Saved /x/cut.png {NOTE}"));
assert_eq!(written.borrow().len(), 3, "the files are still written");
}
@@ -141,6 +141,38 @@ fn preview_in_browser_opens_the_page() {
assert!(log.last().unwrap().starts_with("open file://") && log.last().unwrap().ends_with("Untitled-1.html"), "{log:?}");
}
#[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";
// A PNG whose header reads but whose pixel data is cut short: the renderer draws nothing for it.
let mut png = Vec::new();
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
png.truncate(idat + 8);
let place = |app: &mut VectorcraftApp| {
app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [30, 20]})).unwrap()
};
let dir = vectorcraft_testkit::temp_dir("sfw-undecoded");
let (mut desktop, _) = app(&dir, false);
desktop.run("file.saveForWeb", json!({"format": "png24"})).unwrap();
assert!(!desktop.ui.status.contains("note"), "{}", desktop.ui.status);
place(&mut desktop);
for p in [json!({"format": "png24"}), json!({"format": "jpg", "output": "html"})] {
desktop.status("");
desktop.run("file.saveForWeb", p.clone()).unwrap();
assert!(desktop.ui.status.starts_with("Saved ") && desktop.ui.status.ends_with(NOTE), "{p}: {}", desktop.ui.status);
}
desktop.status("");
desktop.run("file.saveForWeb.browser", json!({"format": "gif"})).unwrap();
assert!(desktop.ui.status.ends_with(NOTE), "Preview in Browser: {}", desktop.ui.status);
let (mut web, _) = app(&dir, true);
place(&mut web);
web.run("file.saveForWeb", json!({"format": "png8"})).unwrap();
assert!(web.ui.status.ends_with(NOTE), "download: {}", web.ui.status);
}
#[test]
fn colour_table_buttons_edit_the_lists() {
let mut d = Dialog::new(save_for_web::KIND, json!({}));
@@ -249,6 +249,43 @@ fn the_web_downloads_one_zip_or_the_file() {
assert_eq!(image::load_from_memory(&log[0].1).unwrap().to_rgba8().get_pixel(1, 1).0, [0, 0, 0, 255], "the PNG settings' background");
}
#[test]
fn the_status_bar_says_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";
// A PNG whose header reads but whose pixel data is cut short: the renderer draws nothing for it.
let mut png = Vec::new();
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
png.truncate(idat + 8);
let place = |app: &mut VectorcraftApp| {
app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [10, 10]})).unwrap()
};
let rows = json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "jpg", "scale": "2x", "suffix": "@2x"}]);
let dir = temp_folder("undecoded");
let folder = dir.to_string_lossy().replace('\\', "/");
let (mut desktop, log) = app(1, false);
desktop.run("file.exportForScreens", json!({"folder": folder, "formats": rows})).unwrap();
assert_eq!(desktop.ui.status, format!("Exported 2 file(s) to {folder}"));
place(&mut desktop);
desktop.run("file.exportForScreens", json!({"folder": folder, "formats": rows, "openLocation": true})).unwrap();
assert_eq!(desktop.ui.status, format!("Saved {folder} {NOTE}"));
assert_eq!(log.borrow().len(), 1, "Open Location still shows the first file");
let r = desktop.run("file.exportForScreens", json!({"folder": folder, "formats": rows, "zip": true})).unwrap();
assert_eq!(desktop.ui.status, format!("Saved {} {NOTE}", r["path"].as_str().unwrap()));
// The Asset Export panel's export goes the same way.
desktop.run("select.all", json!({})).unwrap();
desktop.run("assets.add", json!({"multiple": false})).unwrap();
crate::io::export_files(&mut desktop, "assets.export", json!({"folder": folder, "formats": rows})).unwrap();
assert_eq!(desktop.ui.status, format!("Saved {folder} {NOTE}"), "assets.export");
let (mut web, _) = app(1, true);
place(&mut web);
web.run("file.exportForScreens", json!({"zip": true, "formats": rows})).unwrap();
assert_eq!(web.ui.status, format!("Saved Untitled-1.zip {NOTE}"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_preset_export_reopens_on_its_rows() {
let (mut app, _) = app(1, true);
+11
View File
@@ -628,6 +628,7 @@
Created: Създадено:
Crop Изрязване
Crop Image Изрязване на изображение
Edit Path Редактиране на контура
Mask Маска
Crop Marks Знаци за изрязване
Crop To Изрязване до
@@ -1320,6 +1321,9 @@
Highlight Substituted Glyphs Маркиране на заменени глифи
History История
History Options… Опции за историята…
Step Backward Стъпка назад
Step Forward Стъпка напред
Set History Limit… Задаване на ограничение на историята…
History States Състояния на историята
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set Прилагане на набор от данни
@@ -4458,6 +4462,8 @@ lighting Light Светлина
@msg missing panel layout липсващо оформление на панели
@msg unsupported native docking operation неподдържана вградена операция за скачване
Linux Display Server Дисплейен сървър за Linux
X11 (XWayland) When a Pen Is Attached X11 (XWayland) при свързана писалка
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Ако писалката на графичния таблет не рисува, изберете опция X11 (XWayland).
X11 (XWayland) X11 (XWayland)
Ruler Origin Начало на линийката
Reset Ruler Origin Нулиране началото на линийката
@@ -5132,6 +5138,11 @@ theme Appearance Външен вид
Movie Clip Филмов клип
@msg name is empty името е празно
@msg no symbol named `{n}` няма символ с име `{n}`
# 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}” в браузъра се отварят само URL адреси с http, https и mailto, не „{url}“
@msg {id} opens another app: only the person at the keyboard can run it {id} отваря друга програма: само човекът пред клавиатурата може да изпълни командата
@msg “{name}” is not a file „{name}“ не е файл
Enable Guides for 9-Slice Scaling Включване на водачи за 9-slice мащабиране
Persistent Постоянна
Library Panel Options Опции на панела с библиотека
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -188,6 +188,7 @@
Expand… Rozložit…
Expand Appearance Rozložit vzhled
Crop Image Oříznout obraz
Edit Path Upravit cestu
Mask Maska
Rasterize… Rastrovat…
Create Gradient Mesh… Vytvořit přechodovou síť…
@@ -1513,6 +1514,9 @@ lighting Light Světlo
Use Text for Previews Náhledy na textu
{percent}% opacity krytí {percent} %
History Options… Možnosti historie…
Step Backward Krok zpět
Step Forward Krok vpřed
Set History Limit… Nastavit limit historie…
Open a document to see its history. Otevřete dokument, abyste viděli jeho historii.
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set Použít sadu dat
@@ -3603,6 +3607,8 @@ axis Both Obě
Star Hvězda
Power Saving (integrated) Úsporný režim (integrovaná)
Target Cíl
X11 (XWayland) When a Pen Is Attached X11 (XWayland) při připojeném peru
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Pokud pero grafického tabletu nekreslí, zvolte možnost X11 (XWayland).
X11 (XWayland) X11 (XWayland)
Print as Bitmap Tisknout jako bitmapu
Draft Koncept
@@ -5150,6 +5156,11 @@ mark-style Japanese Japonské
Movie Clip Filmový klip
@msg name is empty název je prázdný
@msg no symbol named `{n}` žádný symbol s názvem `{n}`
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one Upravit originál otevírá pouze soubory obrázků a dokumentů; „{name}“ mezi ně nepatří
@msg only http, https and mailto URLs open in the browser, not “{url}” v prohlížeči se otevírají pouze adresy URL http, https a mailto, ne „{url}“
@msg {id} opens another app: only the person at the keyboard can run it {id} otevírá jinou aplikaci: spustit ho může pouze člověk u klávesnice
@msg “{name}” is not a file „{name}“ není soubor
Enable Guides for 9-Slice Scaling Povolit vodítka pro 9-slice škálování
@msg a mask has more than one art block maska má více než jeden blok kresby
@msg mask art ends outside a mask dictionary kresba masky končí mimo slovník masky
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -173,6 +173,7 @@
Expand… Udvid…
Expand Appearance Udvid udseende
Crop Image Beskær billede
Edit Path Redigér kurve
Mask Maskér
Rasterize… Rasterisér…
Create Gradient Mesh… Opret gradientnet…
@@ -1434,6 +1435,9 @@ lighting Light Lys
Use Text for Previews Brug tekst til forhåndsvisninger
{percent}% opacity {percent}% opacitet
History Options… Indstillinger for historik…
Step Backward Et trin tilbage
Step Forward Et trin frem
Set History Limit… Angiv historikgrænse…
Open a document to see its history. Åbn et dokument for at se dets historik.
Abutting Stødende
Black and White Sort-hvid
@@ -3373,6 +3377,8 @@ axis Both Begge
Star Stjerne
Power Saving (integrated) Strømbesparende (integreret)
Target Mål
X11 (XWayland) When a Pen Is Attached X11 (XWayland), når en pen er tilsluttet
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Hvis pennen til en tegneplade ikke tegner, skal du vælge en X11 (XWayland)-indstilling.
X11 (XWayland) X11 (XWayland)
Print as Bitmap Udskriv som bitmap
Draft Udkast
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -628,6 +628,7 @@
Created: Erstellt:
Crop Schnittmengenfläche
Crop Image Bild beschneiden
Edit Path Pfad bearbeiten
Mask Maske
Crop Marks Schnittmarken
Crop To Beschneiden auf
@@ -1338,6 +1339,9 @@ file-filter Templates Vorlagen
Highlight Substituted Glyphs Ersetzte Glyphen hervorheben
History Protokoll
History Options… Protokolloptionen…
Step Backward Schritt zurück
Step Forward Schritt vorwärts
Set History Limit… Protokollgrenze festlegen…
History States Protokollschritte
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set Datensatz anwenden
@@ -4521,6 +4525,8 @@ lighting Light Licht
@msg missing panel layout Bedienfeldanordnung fehlt
@msg unsupported native docking operation Nicht unterstützte native Docking-Operation
Linux Display Server Linux-Anzeigeserver
X11 (XWayland) When a Pen Is Attached X11 (XWayland), wenn ein Stift angeschlossen ist
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Wenn der Stift eines Grafiktabletts nicht zeichnet, wählen Sie eine Option mit X11 (XWayland).
X11 (XWayland) X11 (XWayland)
Ruler Origin Linealursprung
Reset Ruler Origin Linealursprung zurücksetzen
@@ -4725,6 +4731,11 @@ effect Regular Regelmäßig
Movie Clip Movieclip
@msg name is empty Name ist leer
@msg no symbol named `{n}` kein Symbol namens `{n}`
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one Original bearbeiten öffnet nur Bild- und Dokumentdateien; „{name}“ gehört nicht dazu
@msg only http, https and mailto URLs open in the browser, not “{url}” nur http-, https- und mailto-URLs werden im Browser geöffnet, nicht „{url}“
@msg {id} opens another app: only the person at the keyboard can run it {id} öffnet eine andere Anwendung: Nur die Person an der Tastatur kann den Befehl ausführen
@msg “{name}” is not a file „{name}“ ist keine Datei
Enable Guides for 9-Slice Scaling Hilfslinien für 9-Slice-Skalierung aktivieren
@msg a mask has more than one art block eine Maske enthält mehr als einen Grafikblock
@msg mask art ends outside a mask dictionary die Maskengrafik endet außerhalb eines Maskenwörterbuchs
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -602,6 +602,7 @@
Created: Δημιουργήθηκε:
Crop Περικοπή
Crop Image Περικοπή εικόνας
Edit Path Επεξεργασία διαδρομής
Mask Μάσκα
Crop Marks Σημάδια ξακρίσματος
Crop To Περικοπή ως προς
@@ -1283,6 +1284,9 @@
Highlight Substituted Glyphs Επισήμανση υποκατεστημένων γλύφων
History Ιστορικό
History Options… Επιλογές ιστορικού…
Step Backward Ένα βήμα πίσω
Step Forward Ένα βήμα εμπρός
Set History Limit… Ορισμός ορίου ιστορικού…
History States Καταστάσεις ιστορικού
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set Εφαρμογή συνόλου δεδομένων
@@ -4947,6 +4951,8 @@ effect Points Σημεία
Unicode Unicode
Unicode (UTF-16) Unicode (UTF-16)
Unicode (UTF-8) Unicode (UTF-8)
X11 (XWayland) When a Pen Is Attached X11 (XWayland) όταν είναι συνδεδεμένη πένα
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Αν η πένα μιας ταμπλέτας σχεδίασης δεν σχεδιάζει, επιλέξτε μία από τις ρυθμίσεις X11 (XWayland).
X11 (XWayland) X11 (XWayland)
Mac Mac
Phone 360×800 Phone 360×800
@@ -5102,6 +5108,11 @@ effect Points Σημεία
Movie Clip Κλιπ ταινίας
@msg name is empty το όνομα είναι κενό
@msg no symbol named `{n}` κανένα σύμβολο με όνομα `{n}`
# 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}” στο πρόγραμμα περιήγησης ανοίγουν μόνο URL http, https και mailto, όχι το «{url}»
@msg {id} opens another app: only the person at the keyboard can run it η εντολή {id} ανοίγει άλλη εφαρμογή: μόνο όποιος βρίσκεται στο πληκτρολόγιο μπορεί να την εκτελέσει
@msg “{name}” is not a file το «{name}» δεν είναι αρχείο
Enable Guides for 9-Slice Scaling Ενεργοποίηση οδηγών για κλιμάκωση 9-slice
@msg a mask has more than one art block η μάσκα έχει περισσότερα από ένα μπλοκ γραφικών
@msg mask art ends outside a mask dictionary τα γραφικά της μάσκας τελειώνουν έξω από το λεξικό της μάσκας
Can't render this file because it contains an unexpected character in line 2000 and column 19.
+11
View File
@@ -642,6 +642,7 @@
Created: Creado:
Crop Recortar
Crop Image Recortar imagen
Edit Path Editar trazado
Mask Máscara
Crop Marks Marcas de corte
Crop To Recortar a
@@ -1346,6 +1347,9 @@
Highlight Substituted Glyphs Resaltar glifos sustituidos
History Historial
History Options… Opciones de historial…
Step Backward Paso atrás
Step Forward Paso adelante
Set History Limit… Definir límite de historial…
History States Estados de historial
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set Aplicar conjunto de datos
@@ -4525,6 +4529,8 @@ lighting Light Luz
@msg missing panel layout Falta la distribución de paneles
@msg unsupported native docking operation Operación nativa de anclaje no admitida
Linux Display Server Servidor de pantalla de Linux
X11 (XWayland) When a Pen Is Attached X11 (XWayland) cuando hay un lápiz conectado
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Si el lápiz de una tableta digitalizadora no dibuja, elige una opción X11 (XWayland).
X11 (XWayland) X11 (XWayland)
Ruler Origin Origen de las reglas
Reset Ruler Origin Restablecer el origen de las reglas
@@ -5092,6 +5098,11 @@ mark-style Japanese Japonesas
Movie Clip Clip de película
@msg name is empty el nombre está vacío
@msg no symbol named `{n}` no hay ningún símbolo llamado `{n}`
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one Editar original solo abre archivos de imagen y de documento; “{name}” no lo es
@msg only http, https and mailto URLs open in the browser, not “{url}” solo se abren en el navegador las URL http, https y mailto, no “{url}”
@msg {id} opens another app: only the person at the keyboard can run it {id} abre otra aplicación: solo puede ejecutarlo la persona que está frente al teclado
@msg “{name}” is not a file “{name}” no es un archivo
Enable Guides for 9-Slice Scaling Activar guías para escalado de 9 sectores
@msg a mask has more than one art block una máscara tiene más de un bloque de ilustración
@msg mask art ends outside a mask dictionary la ilustración de la máscara termina fuera de un diccionario de máscara
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -195,6 +195,7 @@
Expand… Laajenna…
Expand Appearance Laajenna ulkoasu
Crop Image Rajaa kuva
Edit Path Muokkaa polkua
Mask Maski
Rasterize… Rasteroi…
Create Gradient Mesh… Luo liukuväriverkko…
@@ -1487,6 +1488,9 @@ lighting Light Valo
Use Text for Previews Käytä tekstiä esikatseluissa
{percent}% opacity {percent}% peittävyys
History Options… Historian asetukset…
Step Backward Askel taaksepäin
Step Forward Askel eteenpäin
Set History Limit… Aseta historian raja…
Open a document to see its history. Avaa asiakirja, niin näet sen historian.
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set Käytä tietojoukkoa
@@ -3538,6 +3542,8 @@ axis Both Molemmat
Star Tähti
Power Saving (integrated) Virransäästö (integroitu)
Target Kohde
X11 (XWayland) When a Pen Is Attached X11 (XWayland), kun kynä on liitetty
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Jos piirtopöydän kynä ei piirrä, valitse jokin X11 (XWayland) -vaihtoehto.
X11 (XWayland) X11 (XWayland)
Print as Bitmap Tulosta bittikarttana
Draft Vedos
@@ -5114,6 +5120,11 @@ mark-style Japanese Japanilainen
Movie Clip Elokuvaleike
@msg name is empty nimi on tyhjä
@msg no symbol named `{n}` symbolia nimeltä `{n}` ei ole
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one Muokkaa alkuperäistä avaa vain kuva- ja asiakirjatiedostoja; “{name}” ei ole sellainen
@msg only http, https and mailto URLs open in the browser, not “{url}” selaimessa avataan vain http-, https- ja mailto-URL-osoitteet, ei “{url}”
@msg {id} opens another app: only the person at the keyboard can run it {id} avaa toisen sovelluksen: vain näppäimistön ääressä oleva henkilö voi suorittaa sen
@msg “{name}” is not a file “{name}” ei ole tiedosto
Enable Guides for 9-Slice Scaling Ota käyttöön 9-slice-skaalauksen apuviivat
@msg a mask has more than one art block maskissa on useampi kuin yksi grafiikkalohko
@msg mask art ends outside a mask dictionary maskin grafiikka päättyy maskin sanakirjan ulkopuolelle
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -644,6 +644,7 @@
Created: Création :
Crop Recadrer
Crop Image Recadrer l’image
Edit Path Modifier le tracé
Mask Masque
Crop Marks Traits de coupe
Crop To Recadrer selon
@@ -1345,6 +1346,9 @@
Highlight Substituted Glyphs Surligner les glyphes remplacés
History Historique
History Options… Options d’historique…
Step Backward Pas en arrière
Step Forward Pas en avant
Set History Limit… Définir la limite de l’historique…
History States États de l’historique
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set Appliquer le jeu de données
@@ -4535,6 +4539,8 @@ lighting Light Lumière
@msg missing panel layout Disposition des panneaux manquante
@msg unsupported native docking operation Opération native d’ancrage non prise en charge
Linux Display Server Serveur d’affichage Linux
X11 (XWayland) When a Pen Is Attached X11 (XWayland) quand un stylet est branché
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Si le stylet d’une tablette graphique ne dessine pas, choisissez une option X11 (XWayland).
X11 (XWayland) X11 (XWayland)
Ruler Origin Origine des règles
Reset Ruler Origin Réinitialiser l’origine des règles
@@ -5149,6 +5155,11 @@ theme Appearance Apparence
Movie Clip Clip
@msg name is empty le nom est vide
@msg no symbol named `{n}` aucun symbole nommé `{n}`
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one Modifier l’original n’ouvre que les fichiers d’image et de document ; « {name} » n’en est pas un
@msg only http, https and mailto URLs open in the browser, not “{url}” seules les URL http, https et mailto s’ouvrent dans le navigateur, pas « {url} »
@msg {id} opens another app: only the person at the keyboard can run it {id} ouvre une autre application : seule la personne devant le clavier peut l’exécuter
@msg “{name}” is not a file « {name} » n’est pas un fichier
Enable Guides for 9-Slice Scaling Activer les repères pour la mise à l’échelle 9-slice
@msg a mask has more than one art block un masque contient plusieurs blocs graphiques
@msg mask art ends outside a mask dictionary les éléments graphiques du masque se terminent en dehors d’un dictionnaire de masque
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -840,6 +840,7 @@
CRLF (Windows) CRLF (Windows)
Crop Körülvágás
Crop Image Kép körülvágása
Edit Path Útvonal szerkesztése
Crop Marks Vágójelek
Crop To Körülvágás
Cross Kereszt
@@ -1719,6 +1720,9 @@ file-filter Templates Sablonok
Hindi Hindi
History Előzmények
History Options… Előzmények beállításai…
Step Backward Lépés vissza
Step Forward Lépés előre
Set History Limit… Előzménykorlát beállítása…
History States Előzmények lépésszáma
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set Adatkészlet alkalmazása
@@ -4208,6 +4212,8 @@ mark-style Japanese Japán
Wrinkle Tool Gyűrés
Write Layers Rétegek írása
x Height x-magasság
X11 (XWayland) When a Pen Is Attached X11 (XWayland), ha toll csatlakozik
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Ha a grafikus tábla tolla nem rajzol, válassz egy X11 (XWayland) beállítást.
X11 (XWayland) X11 (XWayland)
X: X:
XML ID XML-azonosító
@@ -5136,6 +5142,11 @@ imageTrace Outline Körvonal
Movie Clip Mozgóklip
@msg name is empty a név üres
@msg no symbol named `{n}` nincs `{n}` nevű szimbólum
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one az Eredeti szerkesztése csak kép- és dokumentumfájlokat nyit meg; ez nem ilyen: „{name}”
@msg only http, https and mailto URLs open in the browser, not “{url}” a böngészőben csak a http-, https- és mailto-URL-ek nyílnak meg; ez nem ilyen: „{url}”
@msg {id} opens another app: only the person at the keyboard can run it {id}: másik alkalmazást nyit meg, ezért csak a billentyűzetnél ülő felhasználó futtathatja
@msg “{name}” is not a file nem fájl: „{name}”
Enable Guides for 9-Slice Scaling Segédvonalak engedélyezése a 9-slice méretezéshez
@msg a mask has more than one art block egy maszk egynél több grafikai blokkot tartalmaz
@msg mask art ends outside a mask dictionary a maszk grafikája a maszk szótárán kívül ér véget
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -173,6 +173,7 @@
Expand… Perluas…
Expand Appearance Perluas tampilan
Crop Image Pangkas gambar
Edit Path Edit jalur
Mask Masker
Rasterize… Rasterisasi…
Create Gradient Mesh… Buat mesh gradien…
@@ -1434,6 +1435,9 @@ lighting Light Cahaya
Use Text for Previews Gunakan teks untuk pratinjau
{percent}% opacity Opasitas {percent}%
History Options… Opsi riwayat…
Step Backward Mundur satu langkah
Step Forward Maju satu langkah
Set History Limit… Atur batas riwayat…
Open a document to see its history. Buka dokumen untuk melihat riwayatnya.
Abutting Bersinggungan
Black and White Hitam dan putih
@@ -3373,6 +3377,8 @@ axis Both Keduanya
Star Bintang
Power Saving (integrated) Hemat daya (terintegrasi)
Target Target
X11 (XWayland) When a Pen Is Attached X11 (XWayland) saat pena terpasang
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Jika pena tablet gambar tidak menggambar, pilih opsi X11 (XWayland).
X11 (XWayland) X11 (XWayland)
Print as Bitmap Cetak sebagai bitmap
Draft Draf
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
173
174
175
176
177
178
179
1435
1436
1437
1438
1439
1440
1441
1442
1443
3377
3378
3379
3380
3381
3382
3383
3384
+11
View File
@@ -642,6 +642,7 @@
Created: Creato:
Crop Ritaglia
Crop Image Ritaglia immagine
Edit Path Modifica tracciato
Mask Maschera
Crop Marks Segni di ritaglio
Crop To Ritaglia in base a
@@ -1355,6 +1356,9 @@ file-filter Templates Modelli
Highlight Substituted Glyphs Evidenzia glifi sostituiti
History Cronologia
History Options… Opzioni cronologia…
Step Backward Passo indietro
Step Forward Passo avanti
Set History Limit… Imposta limite cronologia…
History States Stati cronologia
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set Applica set di dati
@@ -4566,6 +4570,8 @@ lighting Light Luce
@msg missing panel layout Disposizione dei pannelli mancante
@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata
Linux Display Server Server di visualizzazione Linux
X11 (XWayland) When a Pen Is Attached X11 (XWayland) quando è collegata una penna
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Se la penna di una tavoletta grafica non disegna, scegli un'opzione X11 (XWayland).
X11 (XWayland) X11 (XWayland)
Ruler Origin Origine dei righelli
Reset Ruler Origin Ripristina origine dei righelli
@@ -4763,6 +4769,11 @@ lighting Light Luce
Movie Clip Clip filmato
@msg name is empty il nome è vuoto
@msg no symbol named `{n}` nessun simbolo denominato `{n}`
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one Modifica originale apre solo file di immagini e documenti; “{name}” non lo è
@msg only http, https and mailto URLs open in the browser, not “{url}” nel browser si aprono solo URL http, https e mailto, non “{url}”
@msg {id} opens another app: only the person at the keyboard can run it {id} apre un'altra applicazione: solo chi è alla tastiera può eseguirlo
@msg “{name}” is not a file “{name}” non è un file
Enable Guides for 9-Slice Scaling Abilita guide per il ridimensionamento 9-slice
@msg a mask has more than one art block una maschera contiene più di un blocco grafico
@msg mask art ends outside a mask dictionary la grafica della maschera termina fuori da un dizionario di maschera
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -188,6 +188,7 @@
Expand… 分割・拡張…
Expand Appearance アピアランスを分割
Crop Image 画像の切り抜き
Edit Path パスを編集
Mask マスク
Rasterize… ラスタライズ…
Create Gradient Mesh… グラデーションメッシュを作成…
@@ -1600,6 +1601,9 @@
Highlight Substituted Glyphs 置換字形をハイライト
Highlight anchors on mouse over マウスオーバー時にアンカーをハイライト
History Options… ヒストリーオプション…
Step Backward 一段階戻る
Step Forward 一段階進む
Set History Limit… ヒストリーの上限を設定…
History States ヒストリー数
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set データセットを適用
@@ -4606,6 +4610,8 @@ lighting Light ライト
@msg missing panel layout パネル配置がありません
@msg unsupported native docking operation ネイティブドッキング操作は未対応です
Linux Display Server Linux ディスプレイサーバー
X11 (XWayland) When a Pen Is Attached ペン接続時は X11 (XWayland)
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. ペンタブレットのペンで描画できない場合は、X11 (XWayland) のいずれかを選択してください。
X11 (XWayland) X11 (XWayland)
Ruler Origin 定規の原点
Reset Ruler Origin 定規の原点をリセット
@@ -4804,6 +4810,11 @@ lighting Light ライト
Movie Clip ムービークリップ
@msg name is empty 名前が空です
@msg no symbol named `{n}` `{n}` という名前のシンボルはありません
# 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}」はファイルではありません
Enable Guides for 9-Slice Scaling 9スライス拡大・縮小用ガイドを有効にする
@msg a mask has more than one art block マスクに複数のアートブロックがあります
@msg mask art ends outside a mask dictionary マスクのアートがマスク辞書の外で終わっています
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -184,6 +184,7 @@
Expand… 확장…
Expand Appearance 모양새 확장
Crop Image 이미지 자르기
Edit Path 패스 편집
Mask 마스크
Rasterize… 래스터화…
Create Gradient Mesh… 그레이디언트 메시 만들기…
@@ -1595,6 +1596,9 @@
Highlight Substituted Glyphs 대체된 글리프 강조 표시
Highlight anchors on mouse over 마우스를 올리면 앵커 강조 표시
History Options… 히스토리 옵션…
Step Backward 한 단계 뒤로
Step Forward 한 단계 앞으로
Set History Limit… 히스토리 한도 설정…
History States 히스토리 상태
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set 데이터 세트 적용
@@ -4532,6 +4536,8 @@ lighting Light 광원
@msg missing panel layout 패널 레이아웃이 없습니다
@msg unsupported native docking operation 지원되지 않는 기본 도킹 작업
Linux Display Server Linux 디스플레이 서버
X11 (XWayland) When a Pen Is Attached 펜 연결 시 X11 (XWayland)
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. 드로잉 태블릿의 펜으로 그려지지 않으면 X11 (XWayland) 옵션을 선택하세요.
X11 (XWayland) X11 (XWayland)
Artistic 예술 효과
Film Grain… 필름 그레인…
@@ -4727,6 +4733,11 @@ lighting Light 광원
Movie Clip 무비 클립
@msg name is empty 이름이 비어 있습니다
@msg no symbol named `{n}` 이름이 `{n}`인 심볼이 없습니다
# 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}”은(는) 파일이 아닙니다
Enable Guides for 9-Slice Scaling 9-슬라이스 크기 조절용 안내선 사용
@msg a mask has more than one art block 마스크에 아트 블록이 두 개 이상 있습니다
@msg mask art ends outside a mask dictionary 마스크 아트가 마스크 사전 밖에서 끝납니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -187,6 +187,7 @@
Expand… Uitbreiden…
Expand Appearance Uiterlijk uitbreiden
Crop Image Afbeelding bijsnijden
Edit Path Pad bewerken
Mask Masker
Rasterize… Rasteriseren…
Create Gradient Mesh… Verloopmesh maken…
@@ -1448,6 +1449,9 @@ lighting Light Licht
Use Text for Previews Tekst gebruiken voor voorbeelden
{percent}% opacity {percent}% dekking
History Options… Historieopties…
Step Backward Stap terug
Step Forward Stap vooruit
Set History Limit… Historielimiet instellen…
Open a document to see its history. Open een document om de historie te zien.
Abutting Aaneengesloten
Black and White Zwart-wit
@@ -3387,6 +3391,8 @@ axis Both Beide
Star Ster
Power Saving (integrated) Energiebesparend (geïntegreerd)
Target Doel
X11 (XWayland) When a Pen Is Attached X11 (XWayland) als er een pen is aangesloten
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Als de pen van een tekentablet niet tekent, kies dan een X11 (XWayland)-optie.
X11 (XWayland) X11 (XWayland)
Print as Bitmap Afdrukken als bitmap
Draft Concept
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -153,6 +153,7 @@
Expand… Rozszerz…
Expand Appearance Rozszerz wygląd
Crop Image Kadruj obraz
Edit Path Edytuj ścieżkę
Mask Maska
Rasterize… Rasteryzuj…
Create Gradient Mesh… Utwórz siatkę gradientową…
@@ -1427,6 +1428,9 @@ lighting Light Światło
Use Text for Previews Użyj tekstu w podglądach
{percent}% opacity Krycie {percent}%
History Options… Opcje historii…
Step Backward Krok wstecz
Step Forward Krok naprzód
Set History Limit… Ustaw limit historii…
Open a document to see its history. Otwórz dokument, aby zobaczyć jego historię.
# History panel: the names of undo steps (`every_undo_label_is_translated`).
Apply Data Set Zastosuj zestaw danych
@@ -3445,6 +3449,8 @@ axis Both Obie
Star Gwiazda
Power Saving (integrated) Oszczędzanie energii (zintegrowany)
Target Cel
X11 (XWayland) When a Pen Is Attached X11 (XWayland), gdy podłączone jest pióro
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Jeśli pióro tabletu graficznego nie rysuje, wybierz opcję X11 (XWayland).
X11 (XWayland) X11 (XWayland)
Print as Bitmap Drukuj jako bitmapę
Draft Robocza
@@ -5002,6 +5008,11 @@ mark-style Japanese Japońskie
Movie Clip Klip filmowy
@msg name is empty nazwa jest pusta
@msg no symbol named `{n}` brak symbolu o nazwie `{n}`
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one Edytuj oryginał otwiera tylko pliki obrazów i dokumentów; „{name}” do nich nie należy
@msg only http, https and mailto URLs open in the browser, not “{url}” w przeglądarce otwierają się tylko adresy URL http, https i mailto, a nie „{url}”
@msg {id} opens another app: only the person at the keyboard can run it {id} otwiera inną aplikację: uruchomić to polecenie może tylko osoba przy klawiaturze
@msg “{name}” is not a file „{name}” nie jest plikiem
Enable Guides for 9-Slice Scaling Włącz prowadnice skalowania 9-slice
@msg a mask has more than one art block maska ma więcej niż jeden blok grafiki
@msg mask art ends outside a mask dictionary grafika maski kończy się poza słownikiem maski
Can't render this file because it contains an unexpected character in line 2 and column 73.
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more