Compare commits

...
Author SHA1 Message Date
BFlat fd99375b1b Merge pull request #166 from theironchef/fix/layers-rename-commit
Layers: renaming a layer or object commits on Enter or a click elsewhere
2026-10-06 01:04:22 +07:00
BFlat f24198ec7e Merge pull request #160 from theironchef/fix/undo-during-interaction
Edit: Undo during typing or a drag takes back only that, and Redo returns it
2026-10-06 01:04:16 +07:00
theironchefandClaude Opus 5.5 8c8ac37300 Layers: an open rename stays with its document
Node ids are per document, so a rename left open while another document
came to the front renamed that document's node with the same id. The
panel drops an open rename when the document it shows changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 10:58:54 -07:00
BFlat 5e901c9c17 Merge pull request #172 from storytold/fix/issue-81
Fix #81: Chinese, Japanese and Korean names show in the UI, not boxes
2026-10-06 00:57:08 +07:00
BFlat b71e843c96 Merge pull request #171 from storytold/fix/issue-36
Fix #36: the font menus list the installed fonts, with a search field
2026-10-06 00:56:54 +07:00
BFlat 2d740acbd1 Merge pull request #170 from storytold/fix/issue-130
Fix #130: installed fonts are found by name whatever ran before
2026-10-06 00:56:40 +07:00
BFlat 242e8a5fd5 Merge pull request #169 from storytold/fix/issue-122-halftone
Fix #122: Object > Vector Halftone
2026-10-06 00:56:27 +07:00
BFlat d0aeeb0076 Merge pull request #168 from storytold/fix/issue-122-color-adjust
Fix #122: colour adjustment live effects (Effect > Color Adjustments)
2026-10-06 00:56:14 +07:00
BFlat 39ac89fc26 Merge pull request #167 from storytold/fix/issue-122-cut-tools
Fix #122: Mirror & Cut, Line Cut and Rectangle Cut tools
2026-10-06 00:55:58 +07:00
theironchefandClaude Opus 5.5 11740c9731 Layers: renaming a layer or object commits on Enter or a click elsewhere
The name field asked for focus every frame, which took it back from
Enter, Escape or a click elsewhere, so lost_focus never fired and the
rename was never committed. It now asks only while opening, as the
Artboards panel's name field does.

Fixes #165

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 10:51:46 -07:00
bflatasticandClaude Opus 5.5 c4b87e36b2 Fix #81: Chinese, Japanese and Korean names show in the UI, not boxes
Layer and object names, document tabs and any other UI text in scripts the
UI fonts lack now draw in an installed font that covers them, as the canvas
already did.

Root cause: egui only had the bundled UI fonts (Source Sans 3, JetBrains
Mono) and its defaults, none with CJK glyphs. After each frame the UI now
looks at the text it painted; the first time a character the UI fonts lack
appears, a thread finds an installed font covering it through the font
database (the canvas's fallback search, so a font the canvas loaded is
reused, its bytes shared rather than copied) and adds it to every egui
family as the last fallback. Nothing is bundled, Latin-only UIs load no
extra font, and on the web, without system fonts, the boxes remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:49:22 +07:00
bflatasticandClaude Opus 5.5 f12428264c Fix #36: the font menus list the installed fonts, with a search field
Type > Font, the Character, Properties and Glyphs panels and Find Font list
every installed font by default, as the reference app does, instead of the
four bundled families. The family dropdowns get a search field that filters
the list as you type (Enter picks the first match) and open on the current
font. Show System Fonts, which no longer means anything, becomes Refresh Font
List (text.rescanFonts): it picks up fonts installed since the app started and
redraws type set in them without editing the document. text.fontList lets MCP
agents list the families and a family's styles.

Root cause: the font lists came from FontDb::families(), which only held the
bundled fonts until something scanned the system fonts (fixed with #130). The
Type > Font menu, rebuilt every frame, now builds its items once per font
change, and renderers lay type out again when the fonts change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:49:22 +07:00
bflatasticandClaude Opus 5.5 49e8413ebe Fix #130: installed fonts are found by name whatever ran before
Opening, placing, pasting or importing a file, the font menus and MCP now find
the installed system fonts (Arial, Times New Roman...) in a fresh session, so a
file renders the same whatever happened earlier, and PDF import keeps the real
family names (Microsoft YaHei, not "Microsoft Ya Hei").

Root cause: FontDb only consulted the system font catalog, which stayed empty
until a CJK fallback or Character panel > Show System Fonts happened to scan
the font folders. Every lookup by family name (face, families, styles,
has_family) now waits for one cataloging scan per session: the app starts it
in the background, other processes run it on first use. The scan reads only
each file's table directory and name table (about 70 ms for 780 faces here,
instead of reading every font file whole), the catalog is indexed by family,
the family list is cached, and an installed style of a loaded family (a
bundled family also installed with more weights) loads when asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:49:06 +07:00
bflatasticandClaude Opus 5.5 285d94a3e6 Fix #122: Object > Vector Halftone
The issue asked for a vector halftone that turns art or an image into
vector dots, lines or shapes by tone, with angle, frequency, shape, mono
or CMYK separations and clipping to the object. Nothing like it existed.

object.vectorHalftone renders the selection offscreen, samples its tone
once per cell of a grid turned by the screen angle and places a shape
whose area follows the tone (circle, ellipse, square, diamond or line).
Mono screens follow lightness in one colour; CMYK screens follow the ink
amounts the CMYK output separates, one screen per ink at the classic
angles, multiplied over each other. Dots are clipped to the art's
outline as real geometry (only the dots crossing it go through a
boolean). It is a command rather than a live effect: the live pipeline
can't sample rendered tone when exporting. Its dialog previews live and
OK keeps it as one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:42:26 +07:00
bflatasticandClaude Opus 5.5 9920156bb6 Fix #122: colour adjustment live effects (Effect > Color Adjustments)
The issue asked for non-destructive colour adjustments in the Effect menu
and the Appearance panel that work on vector paints, live type and
embedded images. Only the destructive Edit Colors commands existed, and
the live-effect pipeline only knew geometry and raster effects.

Effect > Color Adjustments adds six effects (adjust.*). They recolour,
in stack order, everything an object paints with: fills and strokes
(gradient stops too), type, gradient meshes, embedded images and a
group's members, each colour keeping its model. The Edit Colors walk over
an object's colours moved to vectorcraft_doc::recolor so the commands
and the live effects share it. The renderer draws adjusted art through
its object-effect path (recoloured images cached within a memory
budget), exports bake the adjusted colours (images as recoloured
copies), and Expand Appearance makes them permanent. The effect dialog
shows sliders, a channel menu and a draggable Curves graph, all previewed
live as one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:42:26 +07:00
bflatasticandClaude Opus 5.5 a844503e4c Fix #122: Mirror & Cut, Line Cut and Rectangle Cut tools
The issue asked for one-step symmetric shapes, splitting all selected art
along a line and cropping art to a rectangle as real geometry. Only the
freehand Knife and the Scissors existed, and neither works on compound
paths as one shape.

New commands path.mirrorCut, path.lineCut and path.rectCut cut the
selected paths and compound paths (inside groups too) with pathops
booleans against a half-plane or rectangle, and split open paths with
the Knife/Path Eraser helpers. Mirror & Cut reflects the kept half and
unites it with itself (open halves are joined on the axis). The draw2
piece helpers now rebuild compound paths too. Three tools (Eraser group,
original icons) preview the cut live while dragging and commit it as one
undo step; Mirror & Cut's axis and kept side are tool options shown in
the Control bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:42:26 +07:00
BFlat 1c46c4c654 Merge pull request #164 from storytold/fix/issue-31
Fix #31: the Save Changes dialog fits its content and the window at every UI scale
2026-10-06 00:41:25 +07:00
BFlat d396bc23af Merge pull request #163 from storytold/fix/issue-44
Fix #44: right-clicking a grouped tool opens its sub-tools flyout
2026-10-06 00:41:10 +07:00
BFlat 9106e344f8 Merge pull request #162 from storytold/fix/issue-84
Fix #84: the toolbar and the panel icon column scroll in short windows
2026-10-06 00:40:57 +07:00
BFlat 52d6b9a2ba Merge pull request #161 from storytold/fix/issue-82
Fix #82: the window fits the screen and comes back where it was
2026-10-06 00:40:44 +07:00
theironchefandClaude Opus 5.5 2076f82b47 Edit: Undo during typing or a drag takes back only that, and Redo returns it
edit.undo cancelled an open interaction (rolling back the typing session
or drag preview without recording it) and then undid the step before it:
one Undo removed two steps, and the typing could not be redone. Undo now
commits the interaction first, so it is the step taken back and Redo can
restore it; commit_interaction does nothing when nothing is open or
nothing changed.

Fixes #159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 10:39:53 -07:00
bflatasticandClaude Opus 5.5 a52c030963 Fix #31: the Save Changes dialog fits its content and the window at every UI scale
The Save / Don't Save / Cancel prompt (and every dialog in the shared
dialog frame) is as tall as its text and buttons at every UI Scaling value,
including when the scale changes while it is open, and never wider than the
window: at 2x in a small window the question wraps instead of running off
the left edge.

Root cause: the tall, mostly empty prompt in the report was the shared
frame's button row taking all the window's height, fixed on main by
d3df1bce (M14.1). What remained was the width: the frame's minimum and
maximum widths are in points, and at a large UI scale a small window has
fewer points than the dialog's 466 pt, so it overflowed the window. The
frame now caps both widths to the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
bflatasticandClaude Opus 5.5 25a194cc00 Fix #44: right-clicking a grouped tool opens its sub-tools flyout
A right-click on a toolbar button with sub-tools (the small triangle) opens
the flyout and it stays open until a tool in it is chosen or the user clicks
elsewhere. A long press opens it too, and releasing that press no longer
closes it or picks the button's tool.

Root cause: the right-click did open the flyout, but the flyout closes on a
click outside itself, and the release of that same right-click (on the tool
button, outside the flyout) closed it in the same frame. The release of a
long press shorter than egui's click time did the same, and also selected
the button's tool. Clicks on the flyout's own tool button now leave it to
that button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
bflatasticandClaude Opus 5.5 831d916d3d Fix #84: the toolbar and the panel icon column scroll in short windows
In a window too short for them, the toolbar (tools, Fill/Stroke, colour
chips, drawing modes, Screen Mode, Edit Toolbar) and the dock's panel icon
column scroll with the mouse wheel, or with the scroll bar that shows while
the pointer is over them, instead of being cut off at the hint bar.

Root cause: both were drawn straight into their side panels with no scroll
area, and the toolbar's only fallback (two columns) runs out below about
760 pt. They now draw inside widgets::strip_scroll, a vertical ScrollArea
next to the menus' widgets::menu_scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
bflatasticandClaude Opus 5.5 3d890203dd Fix #82: the window fits the screen and comes back where it was
The first window opens at 1440 x 900 when the screen has room for it around a
taskbar or Dock, and maximized (with a size that fits once un-maximized)
when it doesn't, as on 1600 x 900, 1366 x 768 or 150% scaled 1080p screens.
The window's size, position and maximized state are saved with the UI
preferences (UiState::window) and restored at the next launch, moved fully
onto a monitor that is still connected and maximized if it no longer fits.

Root cause: main.rs created the window at a fixed 1440 x 900 at the system's
default position, never checked it against the monitor and never remembered
it. The new apps/vectorcraft/src/window.rs places the still-hidden window
from the monitors winit reports (CreationContext::winit_window) and tracks
its geometry every frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
BFlat 679191b4ca Merge pull request #158 from storytold/fix/issue-121
Part of #121: sandboxed WebAssembly plug-ins (object filters and live effects)
2026-10-06 00:30:34 +07:00
bflatasticandClaude Opus 5.5 811ad1efd8 Fix #121: sandboxed WebAssembly plug-ins: object filters and live effects
VectorCraft had an Additional Plug-ins Folder preference but nothing loaded from it. Port
PhotoCraft's wasmi plug-in host (same sandbox, manifest and parameter schema) as the new
vectorcraft-plugins crate (L2), extended for vector documents:

- Object filters read the selected paths and compound paths as JSON (native PathData and
  Paint forms: geometry, fills, strokes, gradients, opacity) and return the objects to keep,
  change, add or delete; the output is validated in full and applied as one undo step
  (plugin.run). A failing plug-in leaves the document untouched.
- Live-effect plug-ins are geometry effects in the appearance stack (effect id
  plugin.<id>), re-evaluated (and cached) when the object or parameters change, editable,
  exported and baked by Expand Appearance; a missing plug-in leaves the geometry as is.
- plugin.list / info / run / install / remove / reload; the preference folder installs
  every *.wasm at startup and when it changes; File > Open of a .wasm installs it (web).
- Object > Plug-ins and Effect > Plug-ins menus with dialogs generated from the parameter
  schema (live preview), the Appearance panel's fx menu, docs/plugins.md, an original
  example plug-in (Desaturate, a Rust crate outside the workspace) and fuzzing of the
  plug-in boundary (engine/tests/plugin_fuzz.rs).

Tool plug-ins and paint-changing live effects are left for later (docs/plugins.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:19:41 +07:00
BFlat cccdf96cc1 Merge pull request #157 from storytold/fix/issue-37
Fix #37: a name typed without an extension gets the chosen format's on Export too
2026-10-06 00:18:54 +07:00
BFlat 7228275f1c Merge pull request #156 from storytold/fix/issue-40
Fix #40: W/H constrain-proportions link in the Properties panel and Control bar
2026-10-06 00:18:34 +07:00
BFlat 649be860e2 Merge pull request #155 from storytold/fix/issue-33
Fix #33: position and size fields evaluate math typed before the unit suffix
2026-10-06 00:18:15 +07:00
BFlat 4f01bc13b2 Merge pull request #154 from storytold/fix/issue-85
Fix #85: empty point type placed by a click is discarded when editing ends
2026-10-06 00:17:55 +07:00
bflatasticandClaude Opus 5.5 9c707b37fc Fix #37: a name typed without an extension gets the chosen format's on Export too
Save, Save As and Save a Copy already added the save format's extension to a
name typed in the save panel, but every other write through it (Export,
Export Selection, Save as PDF, text and preset exports, Document Info) wrote
the name as typed, so "art" became an extensionless file.

The save panel's answer now goes through one rule (io::with_extension): kept
when it ends in the suggested name's extension or names a known format, else
that extension is added. The web already downloads under the suggested name
or adds the format's extension in its Save As dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:34 +07:00
bflatasticandClaude Opus 5.5 6992c843b4 Fix #40: W/H constrain-proportions link in the Properties panel and Control bar
The Transform panel had the link, but kept it in panel-local egui memory;
the Properties panel's Transform section and the Control bar had none, so
size changes there were never proportional.

The link is now the constrainProportions preference (prefs.get/prefs.set),
one toggle shared by the three places (transform::constrain_link), and every
W/H field passes it to object.setBounds as `proportional`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:34 +07:00
bflatasticandClaude Opus 5.5 6780ffae60 Fix #33: position and size fields evaluate math typed before the unit suffix
A `*` or `/` operand had to be a bare number, so `1080/2 px` (the number
replaced by an expression, the field's suffix still there) read as nothing.
The unit after a factor or divisor now measures the whole expression, in
every unit (`Unit::parse`, shared by every unit field). Numeric fields
(num_field, opt_field, plain/mixed fields) select their whole text, unit
included, when they gain focus or are double-clicked, so typing replaces it;
unitless fields gain the same arithmetic (`parse_number`, `45*2°`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:10 +07:00
bflatasticandClaude Opus 5.5 1499834de7 Fix #85: empty point type placed by a click is discarded when editing ends
Clicking with the Type tool runs text.create at once, and ending the edit
(Escape, another tool, a click elsewhere) only stopped editing, so every stray
click left an invisible empty text object and a "Type" history step.

The Type tool now remembers the point type its click placed and, when editing
ends with it still empty, runs the new text.discardEmpty command: it removes
the text and, when nothing else changed since the step that created it, drops
those steps so neither the document nor the history keeps a trace. A command
from outside the tool that takes the edited text out of the selection
(Deselect, a Layers panel pick) or removes it now ends the edit too
(Tool::after_command). Dragged area-type frames and type in or on a path keep
their frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:09 +07:00
BFlat 8adc629055 Merge pull request #153 from storytold/fix/issue-39
Fix #39: rotated objects keep their angle and a rotated bounding box
2026-10-06 00:03:20 +07:00
BFlat 871474f1a9 Merge pull request #152 from storytold/fix/issue-34
Fix #34: Ctrl/Cmd+Shift+V pastes in place
2026-10-06 00:03:04 +07:00
BFlat b4c39b8cb9 Merge pull request #151 from storytold/fix/issue-86
Fix #86: modifiers sent with ui.key, ui.click and ui.drag reach every handler
2026-10-06 00:02:50 +07:00
BFlat 8939caa284 Merge pull request #150 from storytold/fix/issue-80
Fix #80: the hint bar names keys as the menus do (Alt and Ctrl off macOS)
2026-10-06 00:02:32 +07:00
BFlat dd69402f8b Merge pull request #149 from storytold/fix/issue-83
Fix #83: Ctrl and + zooms in however + is typed (numpad, Shift+=, a + key)
2026-10-06 00:02:03 +07:00
bflatasticandClaude Opus 5.5 96604acffb Fix #39: rotated objects keep their angle and a rotated bounding box
Rotating an object baked the rotation into its geometry and kept nothing
else, so the bounding box, the handles and the Transform/Properties
panels fell back to the page-aligned box (the Rotate field went back to 0).

Root cause: nodes carried no record of their own axes. Each node now
keeps `bbox_angle` (append-only, written only when non-zero), which every
transform turns with the object (mirroring transforms keep the nearer of
the two possible boxes). The geometry is still baked. The selection's box
(`Document::oriented_bounds`) stands at the angle the selected objects
share. The Selection tool hit-tests and scales its handles in that frame,
and the canvas draws it rotated. `object.setBounds` measures W/H and the
reference point along it. `object.rotate {absolute}` turns the selection
to an angle, which the panels' Rotate field now shows and sets. Object >
Transform > Reset Bounding Box (previously a no-op) clears the angle in
one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:36 +07:00
bflatasticandClaude Opus 5.5 d3d6cce917 Fix #34: Ctrl/Cmd+Shift+V pastes in place
Paste in Place is Cmd+Shift+V, but egui reports every Cmd+V chord as a
Paste event (or, for what isn't text, only the key's release), never as
the key, and the handler ran Paste for every Paste event. A paste now runs
the paste command whose V chord is held (Paste in Place, Paste on All
Artboards, Paste without Formatting; user overrides included), Paste for
the Paste key and Shift+Insert. Envelope Distort > Edit Contents also had
Cmd+Shift+V; it loses the duplicate, and a test checks that no two
commands, tools or panels share a default shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
bflatasticandClaude Opus 5.5 72be324732 Fix #86: modifiers sent with ui.key, ui.click and ui.drag reach every handler
The control channel attached its modifiers only to the synthetic key and
button events, while most handlers read egui's held modifiers
(i.modifiers), which only the keyboard set. The raw input hook now sets
egui's modifiers (a ModifiersChanged event) for the frames synthetic input
spans: a key's press, text and release, a click, and a drag from press
through its moves to release; the keyboard's modifiers, tracked from the
host's events, come back after. ui.key's text now comes with the press.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
bflatasticandClaude Opus 5.5 638edec175 Fix #80: the hint bar names keys as the menus do (Alt and Ctrl off macOS)
The hint bar's segments were hard-coded strings with macOS names (Option,
Cmd) next to one Alt. The hints now write keys as shortcut strings
("Alt+Click") and show them through the menus' pretty_shortcut, and the
fallback hint shows the effective Search Commands shortcut (or the menu
item when it has none).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
bflatasticandClaude Opus 5.5 03ea4079f9 Fix #83: Ctrl and + zooms in however + is typed (numpad, Shift+=, a + key)
The shortcut parser mapped "=" to Key::Equals and the matcher only took
that exact key, while egui reports the numpad plus, and + on layouts that
give it its own key (and often Shift+=), as Key::Plus. "+" and "=" now
parse to one chord key, a recorded + is stored as =, and the matcher lets
a = chord take Key::Plus too (also next to the native macOS menu, which
handles only the chord as written).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
BFlat e94dfed775 Merge pull request #148 from storytold/fix/issue-136
Fix #136: SVG export and CSS write each style's numeric font-weight
2026-10-05 23:48:42 +07:00
BFlat 2f75b6cd29 Merge pull request #147 from storytold/fix/issue-139
Fix #139: SVG import paints paint-order="stroke" text strokes under the letters
2026-10-05 23:48:24 +07:00
BFlat 69dc539db5 Merge pull request #146 from storytold/fix/issue-131
Fix #131: SVG import keeps non-scaling strokes at their screen width
2026-10-05 23:47:51 +07:00
bflatasticandClaude Opus 5.5 30550f038a Fix #136: SVG export and CSS write each style's numeric font-weight
font_props wrote `bold` for any style whose name contained bold, black
or heavy and nothing for the others unless fonts were embedded, so
Semibold came back Bold and Light or Medium came back Regular. It now
writes the style's numeric weight (font_descriptor, from style_weight)
for every writer: 600 for Semibold, 300 for Light, 900 for Black, `bold`
for 700 and nothing for 400. The SVG import already reads these numbers
back to the matching face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:36:29 +07:00
bflatasticandClaude Opus 5.5 535220cf34 Fix #139: SVG import paints paint-order="stroke" text strokes under the letters
The text import reads <text> from the XML and never looked at
paint-order, so character strokes always painted over the fill. A text
whose characters all share one stroke painted first now gets that
stroke as the object's own stroke below the Characters row (as the
canvas, SVG and PDF export already draw it), with its gradient or
pattern resolved in the document; characters with differing strokes
keep them and the import warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:36:29 +07:00
bflatasticandClaude Opus 5.5 65dd757d59 Fix #131: SVG import keeps non-scaling strokes at their screen width
usvg knows vector-effect but drops it from the tree it returns, so every
stroke was scaled by its transforms. The importer now records the shapes
that ask for vector-effect="non-scaling-stroke" in its XML prepass (by id)
and gives their strokes and dashes the width they have on screen; type
does the same on its character strokes. Where the width can't be kept
(art in patterns, markers or <use> copies, stretched or skewed type) the
import warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:36:29 +07:00
BFlat 088ffc844b Merge pull request #144 from storytold/fix/issue-87-rust-version
Fix #87: rust-version 1.95 and clippy 1.95
2026-10-05 23:05:47 +07:00
bflatasticandClaude Opus 5.5 885b7acac1 Fix #87: rust-version is 1.95, and clippy 1.95 passes
egui 0.36 needs rustc 1.95 (hayro and krilla 1.92), so the declared 1.90 let
cargo fail on dependency versions instead of naming the toolchain. Clippy 1.95
also flagged four collapsible matches that newer clippy no longer does; each is
rewritten without changing behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:05:32 +07:00
BFlat 63bf2c4598 Merge pull request #143 from storytold/pr/scroll-long-menus
M14.1, M14.3, M14.7: scrolling menus, Home button, Cmd+N opens New Document
2026-10-05 22:44:21 +07:00
bflatasticandClaude Opus 5.5 d0d934bfda M14.7: Cmd/Ctrl+N opens the New Document dialog
The engine's file.new also claimed Cmd+N and engine commands win shortcut
lookups, so the shortcut made a blank document. file.newDialog now owns it,
as the docs already said; file.new stays the headless command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:43:48 +07:00
bflatasticandClaude Opus 5.5 258337cf7c M14.3: the Home button shows the Home screen over open documents
It used to open the New Document dialog. Now it runs the new app.home UI command:
the Home screen replaces the canvas while the documents stay open as tabs;
choosing a tab, or a document opening, closing or becoming active, returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:43:48 +07:00
bflatasticandClaude Opus 5.5 d4c4361b4a M14.1: long menus scroll inside the window and use all the height they have
Menu bar menus, their submenus, panel menus, the effects menu groups and library
submenus are as tall as their items down to the window's bottom edge, and scroll
only past that (widgets::menu_scroll), instead of running off screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:43:48 +07:00
Brandon Thomas 36a6a8ae49 Merge pull request #129 from storytold/pr/freebsd-ci-sync
FreeBSD CI: match the shared template
2026-10-05 10:04:50 -04:00
Brandon ThomasandClaude Opus 5.5 2b15d3234f FreeBSD CI: match the shared template (git + safe.directory, 4 vCPUs, pinned vmactions/freebsd-vm)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:45:18 -04:00
Brandon Thomas a867c1e020 Merge pull request #126 from storytold/pr/freebsd
FreeBSD: build and test the workspace in a FreeBSD VM
2026-10-05 08:50:50 -04:00
Brandon ThomasandClaude Opus 5.5 7c0a253b50 docs: point agents to the shared test corpora (#125)
Real-file test oracles live in storytold/photocraft-corpus, explained in
craftrules standards/test-corpora.md. Never commit large binary fixtures
here; fetch them pinned and sha256-verified.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:24:28 -04:00
BFlat 7a861ae064 Merge pull request #127 from storytold/pr/release-0.3.0
Release: VectorCraft v0.3.0
2026-10-05 19:23:31 +07:00
Brandon ThomasandClaude Opus 5.5 466f863d00 FreeBSD: build and test the workspace in a FreeBSD VM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:23:19 -04:00
bflatasticandClaude Opus 5.5 a1deca3d13 Release: VectorCraft v0.3.0
Bump the workspace version 0.2.0 -> 0.3.0 (cargo xtask version set 0.3.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:23:19 +07:00
BFlat cd8973a520 Merge pull request #124 from storytold/pr/m4-pdf-thumbs-linearize-13
M4.86–M4.87: PDF thumbnails, fast web view and PDF 1.3 (M4 done)
2026-10-05 18:06:10 +07:00
bflatasticandClaude Opus 5.5 79581c7ff5 M4.87: PDF 1.3 compatibility: transparency flattened with the chosen preset
compatibility "1.3" writes a %PDF-1.3 file (its XMP metadata says 1.3
too) without transparency: a copy of the document is flattened first
with flattenerPreset (stored with PDF presets; empty is High Resolution)
and the flattener options over it, see-through image pixels counting as
transparency and rasterized areas clipped to their regions so no soft
mask is needed. Page groups are left out and the writer refuses a file
that would still have transparency. PDF/X-1a and PDF/X-3 share this
path (their version is now PDF 1.3; 1.4 is still accepted and written
as 1.3), and with it the preset, the header and metadata code and the
transparency check. Passwords at PDF 1.3 encrypt with 40-bit RC4
(revision 2). The Save PDF dialog lists PDF 1.3 and picks the flattener
preset under Advanced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:05:36 +07:00
bflatasticandClaude Opus 5.5 8961824876 M4.86: PDF page thumbnails and fast web view (linearised files)
Embed Page Thumbnails gives every page a /Thumb image: the page drawn on
white, 106 px on its long side, without the layers the page leaves out
(the engine draws them; vectorcraft_pdf::page_areas says what each page
shows; PDF/X files get grey ones). Optimize for Fast Web View writes a
linearised file: the objects are put in linearised order and numbering,
encrypted when a password asks (the encryption dictionary goes with the
catalog, PDF layers' groups too), then laid out with the linearization
dictionary, both cross-reference tables and the page offset and shared
object hint tables (the hint stream encrypted like any other). Their
"not applied yet" warnings are gone. Reading the objects back uses the
lexer of encrypt.rs (syntax.rs adds what rewriting needs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:05:36 +07:00
BFlat 87df4a0648 Merge pull request #123 from storytold/pr/m4-pdf-layers-overprint
M4.82–M4.83: PDF layers and overprint
2026-10-05 17:40:04 +07:00
bflatasticandClaude Opus 5.5 a09cbe58c8 M4.83: overprinting fills and strokes overprint in PDF
Advanced > Overprint: Preserve (the default; document.exportPdf
advanced.overprint) writes each overprinting fill and stroke (an object's
own, a character's, a type object's) with a graphics state of /OP true
/op true /OPM 1 (/OPM 0 in PDF/A); Discard writes none. White ones knock out
when Document Setup's Discard White Overprint is on, as in print. Mask art
never overprints. File > Print's composite PDF honours Print > Advanced >
Overprints: Preserve the same way (separations keep multiplying plates).

Each overprinting fill or stroke is drawn as a form marked to overprint
(crates/pdf/src/forms.rs); after writing, its content starts by setting the
overprinting graphics state, and the PDF/X self-check runs on the result.
The engine's "overprint is not written to PDF yet" warning and print's
"overprints print as knockouts in composite output" warning are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:39:28 +07:00
bflatasticandClaude Opus 5.5 0e4d3f6871 M4.82: PDF export writes top-level layers as PDF layers
Create PDF layers from top-level layers (document.exportPdf createLayers, the
Save PDF dialog's General option) now writes an optional content group per
top-level layer (template layers are left out): named as the layer, off when
the layer is hidden, /PrintState /OFF when its Print option is off, locked
when it is locked, listed top first, with a print auto-state (not under a
standard). The file reopens with those layers.

The PDF writer has no optional content, so each layer is drawn as a form of
its own (no transparency group, so it draws as in place) starting with a mark;
after writing, the uses of the layers' forms are wrapped in /OC ... BDC/EMC
and the catalog gets /OCProperties (crates/pdf/src/forms.rs, through the
shared patch writer and the PDF syntax reader of encrypt.rs). PDF 1.4 and a
knockout page group write plain page content with a warning; the dialog greys
the option at 1.4. The "not written yet" warning is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:39:28 +07:00
BFlat d7e9f2a47a Merge pull request #120 from storytold/pr/m4-print-tiling
M4.80–M4.81: Print Tiling and advanced print options
2026-10-05 17:27:45 +07:00
bflatasticandClaude Opus 5.5 1f922fcbbc M4.81: Print advanced options: overprints, flattener, printer profile, bitmap
The pdf crate's PrintSettings gains advanced {printAsBitmap,
overprints: preserve|discard|simulate, flattenerPreset} and
color.profile, all defaulting so saved print_setup values still load.
Composite output discards overprints (no /OP) or simulates them as
Overprint Preview draws them; separations keep them. A printer profile
converts composite colours with the chosen intent (a CMYK one writes
device CMYK) and separations separate with a CMYK one. The engine
flattens transparency with the preset, or renders each printed region
as one image for Print as Bitmap, before PDF or PostScript layout.
The Print dialog's Advanced and Color Management sections show them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:27:00 +07:00
bflatasticandClaude Opus 5.5 1b9b37454b M4.80: Print Tiling tool and View > Show Print Tiling
View > Show Print Tiling (view.printTiling) draws the pages of the
document's print settings on the canvas, dashed and numbered, from
print.preview's own layout (print.tiling), so the canvas and the
printed job agree. The Print Tiling tool replaces the no-op tool: a
drag places the first page's imageable corner (print.tiling.set,
saved as print_setup.tileOrigin, one undo step); a double click on
the canvas or the tool button resets it. Tiles start at the origin
and extend to cover the art; the Print dialog shows the tool's
placement with a Reset button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:26:59 +07:00
BFlat 9779d21962 Merge pull request #119 from storytold/pr/m4-pdfx
M4.84: PDF/X-1a, PDF/X-3 and PDF/X-4
2026-10-05 17:19:55 +07:00
bflatasticandClaude Opus 5.5 a825aed763 M4.84: PDF/X-1a, PDF/X-3 and PDF/X-4 files written as their standards say
document.exportPdf {standard: pdfX1a|pdfX3|pdfX4} and the PDF/X presets now
write conforming files instead of being refused:

- every PDF/X file gets a /GTS_PDFX output intent (by default the CMYK
  profile in effect, embedded), TrimBox, Trapped, GTS_PDFXVersion (and
  GTS_PDFXConformance for X-1a), a title and dates; editing data,
  passwords and, in X-1a/X-3, PDF layers are refused
- PDF/X-1a converts every colour and image to a CMYK destination,
  untagged, with CMYK spot alternates; X-3 and X-4 tag colours with ICC
  profiles
- X-1a and X-3 are written as PDF 1.3 and their transparency is flattened
  first (High Resolution preset; images with see-through pixels count,
  rasters stay opaque); X-4 keeps transparency, is PDF 1.6 at most and
  names the standard in its XMP metadata
- the written file is checked against its standard (soft masks,
  transparency groups, opacity, blend modes, RGB in X-1a, fonts not
  embedded) and refused rather than mislabelled
- choosing a standard sets the latest compatibility it allows and turns
  off what it forbids (engine and Save PDF dialog)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:19:11 +07:00
Brandon Thomas 4e72f8c0ec Merge pull request #118 from storytold/docs/honest-assessment
Roadmap: honest assessment, the gap list and where we're going
2026-10-05 06:11:31 -04:00
192 changed files with 15802 additions and 990 deletions

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
+1
View File
@@ -37,6 +37,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
+3
View File
@@ -143,6 +143,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-join-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-join.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-knife.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-line-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-line.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-list-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-live-bucket.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -150,6 +151,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-mask-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-measure.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-mesh.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-mirror-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -182,6 +184,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-pucker.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-puppet.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rearrange.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rect-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rect-grid.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-reference-point.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-remove-brush.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+1
View File
@@ -37,6 +37,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
Generated
+178 -28
View File
@@ -1231,6 +1231,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1439,7 +1445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1455,7 +1461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1603,6 +1609,15 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1611,7 +1626,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1620,7 +1635,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -2059,6 +2074,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.189"
@@ -3667,6 +3688,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spirv"
version = "0.4.0+sdk-1.4.341.0"
@@ -3697,6 +3724,16 @@ dependencies = [
"float-cmp",
]
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.5",
"serde",
]
[[package]]
name = "subsetter"
version = "0.2.6"
@@ -4174,7 +4211,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"arboard",
"clipboard-win",
@@ -4188,12 +4225,13 @@ dependencies = [
"vectorcraft-engine",
"vectorcraft-ui-egui",
"webbrowser",
"winit",
"winresource",
]
[[package]]
name = "vectorcraft-brush"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4205,7 +4243,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4221,7 +4259,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"serde_json",
"vectorcraft-color",
@@ -4237,7 +4275,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4249,7 +4287,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4263,7 +4301,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4271,12 +4309,13 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-plugins",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-engine"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"flate2",
"image",
@@ -4296,17 +4335,19 @@ dependencies = [
"vectorcraft-metafile",
"vectorcraft-pathops",
"vectorcraft-pdf",
"vectorcraft-plugins",
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"vectorcraft-tools",
"vectorcraft-trace",
"wat",
]
[[package]]
name = "vectorcraft-eps"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"flate2",
"image",
@@ -4323,7 +4364,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"flate2",
"proptest",
@@ -4337,7 +4378,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4349,7 +4390,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"image",
"log",
@@ -4368,7 +4409,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4382,7 +4423,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4394,7 +4435,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"aes",
"flate2",
@@ -4419,9 +4460,23 @@ dependencies = [
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-plugins"
version = "0.3.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-geom",
"wasmi",
"wat",
]
[[package]]
name = "vectorcraft-render"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"flate2",
"gif",
@@ -4446,7 +4501,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"flate2",
"image",
@@ -4470,7 +4525,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4487,7 +4542,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
@@ -4500,7 +4555,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4515,7 +4570,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4528,7 +4583,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"egui",
"egui_extras",
@@ -4545,16 +4600,18 @@ dependencies = [
"vectorcraft-format",
"vectorcraft-geom",
"vectorcraft-pdf",
"vectorcraft-plugins",
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"vectorcraft-tools",
"wat",
]
[[package]]
name = "vectorcraft-web"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"eframe",
"egui",
@@ -4719,6 +4776,99 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
dependencies = [
"leb128fmt",
"wasmparser 0.261.0",
]
[[package]]
name = "wasmi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
dependencies = [
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser 0.228.0",
]
[[package]]
name = "wasmi_collections"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
dependencies = [
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
dependencies = [
"libm",
]
[[package]]
name = "wasmi_ir"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
dependencies = [
"wasmi_core",
]
[[package]]
name = "wasmparser"
version = "0.228.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
dependencies = [
"bitflags 2.13.2",
"indexmap",
]
[[package]]
name = "wasmparser"
version = "0.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
dependencies = [
"bitflags 2.13.2",
"indexmap",
"semver",
]
[[package]]
name = "wast"
version = "261.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder",
]
[[package]]
name = "wat"
version = "1.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
dependencies = [
"wast",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
@@ -5535,7 +5685,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"serde_json",
]
+9 -2
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.2.0"
version = "0.3.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
@@ -32,6 +32,7 @@ vectorcraft-testkit = { path = "crates/testkit" }
vectorcraft-cad = { path = "crates/cad" }
vectorcraft-eps = { path = "crates/eps" }
vectorcraft-metafile = { path = "crates/metafile" }
vectorcraft-plugins = { path = "crates/plugins" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -43,6 +44,12 @@ log = "0.4"
proptest = "1"
# tests: a TCP port bound without listening (connections to it are refused)
socket2 = "0.6"
# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32
# too. Without `wat` (text parsing is test-only) and memory64; `validate` keeps full module
# validation on; the portable dispatcher can't overflow the host stack on long-running modules.
wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "stable", "portable-dispatch"] }
# Tests: WebAssembly text -> binary for the plug-in fixtures (Apache-2.0 WITH LLVM-exception).
wat = "1.228"
# rendering / formats
vello_cpu = "0.2"
+3
View File
@@ -26,3 +26,6 @@ BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0).
JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is
based in part on the work of the Independent JPEG Group.
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
under the Apache License 2.0 with the LLVM exception.
+2 -2
View File
@@ -172,7 +172,7 @@ milestones, and honest time-to-parity estimates.
**Where we are (2026-10-05):** roughly 68–74% of Illustrator's features exist and work, and about 40–55% of
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and
files (SVG, PDF and PDF-compatible `.ai`, EPS, DXF, raster formats, Print, Package). The scores are
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
**What's missing:**
@@ -187,7 +187,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0
advanced type, then hardening and packaging for 1.0. The prioritized list is in
[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order).
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, format, tools, engine, ui-egui, mcp, testkit}`
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so
the UI can be swapped without touching the engine.
+20 -20
View File
@@ -33,7 +33,7 @@ since 2026-10-01.
| Dimension | Estimate | Evidence and what's missing |
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type, vertical type ×3 and Print Tiling; 51 panel modules |
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type and vertical type ×3; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~68–74% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~35%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, vertical/CJK type, Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
@@ -64,7 +64,7 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
generated in code (never Adobe's); dynamic symbols; Start Global Edit. 14–22 h.
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, the Print Tiling tool, video
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
rulers. 15–25 h.
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, idle-machine perf budgets, Windows, Linux and
browser QA, accessibility, packaging. 50–80 h.
@@ -101,7 +101,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
- **Cutting:** Eraser, Scissors, Knife.
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
@@ -109,7 +109,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
@@ -126,9 +126,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export.
- Place and the Links panel, Package, File Info, Print with print presets and PostScript output, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
@@ -137,20 +137,20 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| # | Milestone | Status | Est. remaining, one agent (h) |
|---|---|---|---|
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; flattener presets not yet used by PDF/print; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ mostly done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, polish | 6–10 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool done, Trim View, middle-button pan; print tiling, multiple windows, presentation polish) | 🟡 | 15–25 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux and content-sized dialogs and menus done; accessibility, Windows/Linux packaging pending | 18–28 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~335–520** |
@@ -169,25 +169,25 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Area | Weight | Done | Missing (main items) | One agent (h) |
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | rotated persistent bounding box, Start Global Edit, transform nuances | 4–6 |
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 |
| Appearance, transparency, graphic styles, masks | 5 | 97% | flattener presets in PDF/EPS/print, raster effect reach in preview bounds | 2–4 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish | 6–10 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, print tiling, Snap to Pixel/Glyph | 10–15 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security and presets are done | 6–10 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 70% | Print Tiling tool and Show Print Tiling, flattener presets in print/PDF, print preview fidelity (Print dialog, presets, PostScript, marks and separations are done) | 5–8 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
@@ -201,7 +201,7 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c
**~85–150 h** to full parity.
_Inventories (2026-10-05):_ 18 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
implemented except five (Touch Type, vertical type ×3, Print Tiling); 51 panel modules; Illustrator-style live
implemented except four (Touch Type, vertical type ×3); 51 panel modules; Illustrator-style live
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
+2
View File
@@ -22,6 +22,8 @@ rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
image = { workspace = true }
webbrowser = { workspace = true }
# The window's monitors, size and position (the same winit eframe runs on).
winit = { version = "0.30", default-features = false }
[target.'cfg(windows)'.dependencies]
clipboard-win = { workspace = true }
+29 -10
View File
@@ -11,6 +11,7 @@ mod control_server;
#[cfg(target_os = "macos")]
mod native_menu;
mod printing;
mod window;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
@@ -30,6 +31,7 @@ impl eframe::App for App {
}
}
self.0.logic(ctx);
window::track(ctx, &mut self.0.ui.window);
if self.0.ui.status == "quit" {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
@@ -71,21 +73,32 @@ fn prefs_path_for(name: &str, lower: &str) -> Option<std::path::PathBuf> {
base.map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut VectorcraftApp) {
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("VECTORCRAFT_NO_PREFS").is_none()
}
/// The saved UI preferences, read before the window opens (they hold its size and position).
fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
if !prefs_enabled() {
return None;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
serde_json::from_slice(&bytes).ok()
}
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
if !prefs_enabled() {
return;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()));
if let Some(bytes) = bytes
&& let Ok(ui) = serde_json::from_slice::<vectorcraft_ui_egui::UiState>(&bytes)
{
if let Some(ui) = saved {
app.ui = ui.sanitized();
}
vectorcraft_ui_egui::prefs_dialog::restore(app);
}
fn save_prefs(app: &VectorcraftApp) {
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
if !prefs_enabled() {
return;
}
if let Some(p) = prefs_path() {
@@ -230,11 +243,13 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
let saved = read_prefs();
let saved_window = saved.as_ref().and_then(|ui| ui.window);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
@@ -249,7 +264,11 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services());
load_prefs(&mut app);
load_prefs(&mut app, saved);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
app.ui.window = Some(window::restore(w, saved_window));
}
// User Defined swatch and graphic style libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
app.session.swatch_libraries.set_user_dir(swatches);
+5 -2
View File
@@ -25,6 +25,8 @@ pub struct NativeMenu {
generation: u64,
/// Saved views and recent files listed (empty slots are left out, so a change rebuilds).
slots: usize,
/// Plug-in registry revision the Plug-ins submenus were built for.
plugins: u64,
}
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
@@ -56,7 +58,7 @@ impl NativeMenu {
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
.map(|(_, _, _, c)| c.clone())
.collect();
Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app) }
Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app), plugins: menus::plugin_revision() }
}
/// Dispatch clicked items and refresh state.
@@ -72,10 +74,11 @@ impl NativeMenu {
return;
}
self.last_refresh = now;
// Shortcuts edited, workspaces added, saved views or recent files changed: rebuild so
// Shortcuts edited, workspaces added, saved views, recent files or plug-ins changed: rebuild so
// accelerators and lists are current.
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|| menus::listed_slots(app) != self.slots
|| menus::plugin_revision() != self.plugins
{
*self = NativeMenu::install(app);
return;
+256
View File
@@ -0,0 +1,256 @@
//! The main window's size and position. The first launch fits the window to its monitor, opening
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved
//! onto a monitor that is still connected.
use vectorcraft_ui_egui::state::WindowGeometry;
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
/// The first window's size, in points.
pub const DEFAULT_SIZE: [f32; 2] = [1440.0, 900.0];
/// The smallest window, in points.
pub const MIN_SIZE: [f32; 2] = [800.0, 500.0];
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
/// say how much of the screen they take): a monitor with less room opens it maximized.
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Monitor {
pos: [i64; 2],
size: [i64; 2],
scale: f32,
}
impl Monitor {
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
let scale = scale as f32;
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
}
/// Its size in points.
fn points(&self) -> [f32; 2] {
self.size.map(|s| s as f32 / self.scale)
}
/// Its size in points less room for a taskbar, Dock or menu bar.
fn usable(&self) -> [f32; 2] {
let points = self.points();
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
}
/// A window of `size` points on this monitor, in physical pixels.
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
size.map(|s| (s * self.scale) as i64)
}
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
let px = self.pixels(size);
let span = |i: usize| {
let start = i64::from(pos[i]).max(self.pos[i]);
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
(end - start).max(0)
};
span(0).saturating_mul(span(1))
}
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
/// points on this monitor.
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
let px = self.pixels(size);
std::array::from_fn(|i| {
let lo = self.pos[i];
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
})
}
}
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
/// doesn't list them, so nothing to check a saved geometry against) the default size.
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
return default;
};
let Some(saved) = saved else {
let usable = home.usable();
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
return default;
}
// A small screen: maximized, with a size that fits once un-maximized.
let size = fit(DEFAULT_SIZE, usable);
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
};
// The monitor showing most of the window; none when it was on a monitor that is gone.
let shown = saved.pos.and_then(|p| {
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
(area > 0).then_some(m)
});
let m = shown.unwrap_or(home);
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
let room = m.points();
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
let size = fit(saved.size, if too_big { m.usable() } else { room });
WindowGeometry {
// Moved fully onto its monitor, or centred on the home monitor.
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
size,
maximized: saved.maximized || too_big,
}
}
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
}
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
/// geometry.
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
let handles: Vec<_> = window.available_monitors().collect();
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
let g = place(saved, &monitors, home);
if let Some([x, y]) = g.pos {
window.set_outer_position(PhysicalPosition::new(x, y));
}
// Returns the new size when it applies at once; either way the window gets it.
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
if g.maximized {
window.set_maximized(true);
}
g
}
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
let zoom = ctx.zoom_factor();
ctx.input(|i| {
let v = i.viewport();
if v.minimized == Some(true) || v.fullscreen == Some(true) {
return;
}
if v.maximized == Some(true) {
if let Some(g) = geometry {
g.maximized = true;
}
return;
}
let Some(inner) = v.inner_rect else { return };
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
});
}
#[cfg(test)]
mod tests {
use super::*;
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
}
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
Some(WindowGeometry { pos, size, maximized })
}
#[test]
fn the_first_window_fits_its_screen() {
// 1920 × 1080: the default size, where the system puts it.
let big = [monitor(0, 0, 1920, 1080, 1.0)];
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
// 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points): maximized, and a
// size that fits the screen with room for the taskbar once un-maximized.
for (m, size) in [
(monitor(0, 0, 1600, 900, 1.0), [1440.0, 772.0]),
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
] {
let g = place(None, &[m], Some(0));
assert!(g.maximized, "{m:?}");
assert_eq!(g.size, size, "{m:?}");
let [x, y] = g.pos.unwrap();
let px = m.pixels(g.size);
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
}
// No monitor information: the default size, whatever was saved.
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
}
#[test]
fn the_saved_window_comes_back_where_it_was() {
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)]
{
assert_eq!(Some(place(g, &two, Some(0))), g);
}
}
#[test]
fn a_saved_window_stays_on_a_connected_monitor() {
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
// Last time on a second monitor that is gone: centred on the remaining one.
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
// Half off the monitor's right and bottom edges: moved fully onto it.
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g.pos, Some([600, 200]));
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
// A monitor left of the primary one (negative coordinates) still counts.
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
// Nonsense sizes from a damaged preferences file are kept within the screen.
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
}
#[test]
fn the_window_geometry_is_tracked_for_the_next_launch() {
let ctx = egui::Context::default();
let mut geometry = None;
let mut frame = |info: egui::ViewportInfo| {
let mut input = egui::RawInput::default();
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
geometry
};
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
let normal = egui::ViewportInfo {
native_pixels_per_point: Some(1.5),
inner_rect: Some(rect),
outer_rect: Some(rect),
maximized: Some(false),
..Default::default()
};
// Position in physical pixels, size in points.
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
assert_eq!(frame(normal.clone()), Some(g));
// Maximized or minimized: the size to restore stays.
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
assert_eq!(frame(normal), Some(g));
// Saved with the preferences; older preference files have no window.
let mut ui = vectorcraft_ui_egui::UiState { window: Some(g), ..Default::default() };
let back: vectorcraft_ui_egui::UiState = serde_json::from_slice(&serde_json::to_vec(&ui).unwrap()).unwrap();
assert_eq!(back.window, Some(g));
ui.window = None;
let json = serde_json::to_value(&ui).unwrap();
assert!(json.get("window").is_none());
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
assert_eq!(back.window, None);
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21L21 3" stroke-dasharray="2 2"/><path d="M4 4H16.5L4 16.5Z" fill="currentColor" fill-opacity="0.35"/><path d="M20 7.5V20H7.5Z" fill="currentColor" fill-opacity="0.35"/></svg>

After

Width:  |  Height:  |  Size: 371 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2V22" stroke-dasharray="2 2"/><path d="M12 5C7.5 5 4 8 4 12C4 16 7.5 19 12 19Z" fill="currentColor" fill-opacity="0.35"/><path d="M12 5C16.5 5 20 8 20 12C20 16 16.5 19 12 19" stroke-opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 400 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="14" height="16" stroke-dasharray="2 2"/><path d="M17 6.34A6 6 0 1 0 17 17.66Z" fill="currentColor" fill-opacity="0.35"/><path d="M17 6.34A6 6 0 0 1 17 17.66" stroke-opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 395 B

+50 -20
View File
@@ -16,6 +16,7 @@ pub mod live;
pub mod marks;
pub mod metadata;
pub mod node;
pub mod orient;
pub mod overprint;
pub mod pattern;
mod pixels;
@@ -23,6 +24,7 @@ pub mod profiles;
pub mod range;
pub mod rastersettings;
mod reach;
pub mod recolor;
pub mod selection;
pub mod setup;
pub mod slices;
@@ -61,6 +63,7 @@ pub use node::Knockout;
pub use node::Scaling;
pub use node::{ImageMap, ObjectAttributes};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use orient::OrientedBox;
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use profiles::ColorProfiles;
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
@@ -175,8 +178,11 @@ impl Unit {
if s == "-0" { "0".into() } else { s.into() }
}
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
///
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
/// math typed before a field's unit suffix works.
pub fn parse(self, s: &str) -> Option<f64> {
parse_measure(s, self)
parse_measure(s, self).filter(|v| v.is_finite())
}
/// The value naming this unit in the Units preferences (`points`, `millimeters`,
/// `feetInches`).
@@ -219,14 +225,20 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
if l.trim().is_empty() {
continue;
}
let a = parse_measure(l, default)?;
return match op {
'+' => Some(a + parse_measure(r, default)?),
'-' => Some(a - parse_measure(r, default)?),
'*' => Some(a * r.trim().parse::<f64>().ok()?),
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
_ => {
let d = r.trim().parse::<f64>().ok()?;
if d == 0.0 { None } else { Some(a / d) }
// A factor or divisor is a plain number; a unit after it is the expression's.
let (k, unit) = number_unit(r)?;
let a = parse_measure(l, unit.unwrap_or(default))?;
if op == '*' {
Some(a * k)
} else if k == 0.0 {
None
} else {
Some(a / k)
}
}
};
}
@@ -240,22 +252,40 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
{
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0));
}
let (v, unit) = number_unit(s)?;
Some(unit.unwrap_or(default).to_pt(v))
}
/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit).
fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
let s = s.trim();
let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len());
let v: f64 = s[..num_end].trim().parse().ok()?;
let unit = match s[num_end..].trim() {
"" => default,
"pt" => Unit::Points,
"px" => Unit::Pixels,
"in" | "\"" => Unit::Inches,
"mm" => Unit::Millimeters,
"cm" => Unit::Centimeters,
"m" => Unit::Meters,
"ft" | "'" => Unit::Feet,
"yd" => Unit::Yards,
"pc" => Unit::Picas,
let v: f64 = s.get(..num_end)?.trim().parse().ok()?;
let unit = match s.get(num_end..)?.trim() {
"" => None,
"pt" => Some(Unit::Points),
"px" => Some(Unit::Pixels),
"in" | "\"" => Some(Unit::Inches),
"mm" => Some(Unit::Millimeters),
"cm" => Some(Unit::Centimeters),
"m" => Some(Unit::Meters),
"ft" | "'" => Some(Unit::Feet),
"yd" => Some(Unit::Yards),
"p" | "pc" => Some(Unit::Picas),
_ => return None,
};
Some(unit.to_pt(v))
Some((v, unit))
}
/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as
/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing.
pub fn parse_number(s: &str) -> Option<f64> {
if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) {
return None;
}
// A leading `+` is a sign (`+5`), as a plain number read it.
let s = s.trim();
Unit::Points.parse(s.strip_prefix('+').unwrap_or(s))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+9
View File
@@ -335,6 +335,11 @@ pub struct Node {
/// these options.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slice: Option<Box<crate::SliceOptions>>,
/// The angle of the object's own axes, counter-clockwise degrees (0: square to the page): its
/// bounding box and handles stand at this angle. Transforms turn it with the object (see
/// [`crate::orient`]); Reset Bounding Box sets it back to 0.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub bbox_angle: f64,
}
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
@@ -383,6 +388,7 @@ impl Node {
graphic_style: None,
attrs: None,
slice: None,
bbox_angle: 0.0,
}
}
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
@@ -572,6 +578,9 @@ impl Node {
self.transform_scaled(a, &scaling.into());
}
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
if !self.is_layer() {
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
}
// Refitting an unplaced gradient only reproduces moves and uniform scales.
if !keeps_gradient_fit(a) {
self.pin_gradients();
+195
View File
@@ -0,0 +1,195 @@
//! Rotated bounding boxes. Every object keeps the angle of its own axes ([`Node::bbox_angle`])
//! through the transforms applied to it, so after a rotation its bounding box and handles stay
//! square to it: scaling by a side handle stretches it along its own width or height. Path
//! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0.
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
use crate::{Document, Node, NodeId, NodeKind};
/// Angles closer than this (degrees) are the same.
const EPS_DEG: f64 = 1e-6;
/// A bounding box square to a frame turned by `angle`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OrientedBox {
/// Counter-clockwise degrees in (-180, 180]; 0: square to the page.
pub angle: f64,
/// The box in the turned frame: [`OrientedBox::to_doc`] places it on the page.
pub rect: Rect,
}
impl OrientedBox {
/// A box square to the page.
pub fn aligned(rect: Rect) -> Self {
Self { angle: 0.0, rect }
}
/// The turned frame → the page (a rotation about the page origin).
pub fn to_doc(&self) -> Affine {
if self.angle == 0.0 { Affine::IDENTITY } else { Affine::rotate(-self.angle.to_radians()) }
}
/// A page point in the turned frame.
pub fn to_local(&self, p: Point) -> Point {
self.to_doc().inverse() * p
}
/// `a`, a transform of the turned frame, as a page transform.
pub fn conjugate(&self, a: Affine) -> Affine {
if self.angle == 0.0 { a } else { self.to_doc() * a * self.to_doc().inverse() }
}
pub fn center(&self) -> Point {
self.to_doc() * self.rect.center()
}
/// One of the 9 reference points (row-major from the box's own top-left), on the page.
pub fn reference_point(&self, index: usize) -> Point {
self.to_doc() * vectorcraft_geom::reference_point(self.rect, index)
}
/// The corners on the page: top-left, top-right, bottom-right, bottom-left of the box itself.
pub fn corners(&self) -> [Point; 4] {
let r = self.rect;
[(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| self.to_doc() * Point::new(x, y))
}
}
/// The angle (counter-clockwise degrees) of an object's axes after `a`: where `a` takes its x axis.
/// A mirroring `a` allows two boxes 180° apart: the one nearer the old angle (then the smaller
/// angle) is kept, so flipping an upright object keeps it upright.
pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
if (m0, m1, m2, m3) == (1.0, 0.0, 0.0, 1.0) {
return angle;
}
let (s, c) = (-angle.to_radians()).sin_cos();
let v = Vec2::new(m0 * c + m2 * s, m1 * c + m3 * s);
// Degenerate (or non-finite) transforms leave the angle alone.
let len = v.hypot();
if !len.is_finite() || len <= 1e-12 {
return angle;
}
let mut out = normalize_deg(-v.y.atan2(v.x).to_degrees());
let dist = |x: f64| normalize_deg(x - angle).abs();
if a.determinant() < 0.0 {
let alt = normalize_deg(out + 180.0);
let (d0, d1) = (dist(out), dist(alt));
if d1 < d0 - EPS_DEG || ((d1 - d0).abs() <= EPS_DEG && alt.abs() < out.abs()) {
out = alt;
}
}
// Moves and scales along the object's own axes keep its angle exactly.
if dist(out) <= EPS_DEG {
angle
} else if out.abs() <= EPS_DEG {
0.0
} else {
out
}
}
impl Node {
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
/// without changing it.
pub fn bounds_in(&self, a: Affine, visual: bool) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: false } => {
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(a, visual)));
// The group's own strokes paint around its members (as in `visual_bounds`).
let o = if visual { self.appearance.outset() } else { 0.0 };
b.map(|b| b.inflate(o, o))
}
NodeKind::Group { children, clip: true } => {
let clip = children.first()?;
let stroked = matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_));
clip.bounds_in(a, visual && stroked)
}
_ => {
let mut n = self.clone();
n.transform(a, false);
if visual { n.visual_bounds() } else { n.geometric_bounds() }
}
}
}
}
impl Document {
/// The angle the bounding box of `ids` stands at: the `bbox_angle` they share, else 0.
pub fn bbox_angle(&self, ids: &[NodeId]) -> f64 {
let mut angles = ids.iter().filter_map(|id| self.node(*id)).map(|n| n.bbox_angle);
let Some(first) = angles.next() else { return 0.0 };
if angles.all(|a| normalize_deg(a - first).abs() <= EPS_DEG) { first } else { 0.0 }
}
/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]):
/// geometric bounds, or `visual` with their strokes.
pub fn oriented_bounds(&self, ids: &[NodeId], visual: bool) -> Option<OrientedBox> {
let angle = self.bbox_angle(ids);
if angle == 0.0 {
return self.bounds_of(ids, visual).map(OrientedBox::aligned);
}
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(to_local, visual)))?;
Some(OrientedBox { angle, rect })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Appearance;
use vectorcraft_geom::shapes;
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn rot(deg: f64) -> Affine {
Affine::rotate(-deg.to_radians())
}
#[test]
fn rotations_add_and_moves_keep_the_angle() {
assert!(close(transformed_angle(0.0, rot(45.0)), 45.0));
assert!(close(transformed_angle(170.0, rot(20.0)), -170.0));
assert_eq!(transformed_angle(30.0, Affine::translate((5.0, 7.0))), 30.0);
assert_eq!(transformed_angle(45.0, rot(-45.0)), 0.0);
}
#[test]
fn local_scales_keep_the_angle_exactly() {
let b = OrientedBox { angle: 45.0, rect: Rect::ZERO };
assert_eq!(transformed_angle(45.0, b.conjugate(Affine::scale_non_uniform(3.0, 0.5))), 45.0);
// A flip along the object's own axis keeps the box too.
let b = OrientedBox { angle: 135.0, rect: Rect::ZERO };
assert_eq!(transformed_angle(135.0, b.conjugate(Affine::scale_non_uniform(-1.0, 1.0))), 135.0);
}
#[test]
fn page_flips_mirror_the_angle() {
let flip_h = Affine::scale_non_uniform(-1.0, 1.0);
let flip_v = Affine::scale_non_uniform(1.0, -1.0);
assert_eq!(transformed_angle(0.0, flip_h), 0.0);
assert_eq!(transformed_angle(0.0, flip_v), 0.0);
assert!(close(transformed_angle(30.0, flip_h), -30.0));
assert!(close(transformed_angle(30.0, flip_v), -30.0));
// Degenerate transforms leave it alone.
assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0);
}
#[test]
fn oriented_bounds_hug_a_rotated_rectangle() {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::default_art())).unwrap();
let c = Point::new(50.0, 25.0);
d.node_mut(id).unwrap().transform(Affine::translate(c.to_vec2()) * rot(30.0) * Affine::translate(-c.to_vec2()), false);
let b = d.oriented_bounds(&[id], false).unwrap();
assert!(close(b.angle, 30.0));
assert!(close(b.rect.width(), 100.0) && close(b.rect.height(), 50.0));
assert!(b.center().distance(c) < 1e-9);
// The page box is bigger.
assert!(d.bounds_of(&[id], false).unwrap().width() > 100.0);
// Grouped: the group has no angle of its own, the bounds are square to the page.
let g = d.alloc_id();
d.insert(Some(l), 1, Node::group(g, vec![])).unwrap();
d.move_node(id, Some(g), 0).unwrap();
assert_eq!(d.oriented_bounds(&[g], false).unwrap().angle, 0.0);
}
}
+28 -1
View File
@@ -118,7 +118,7 @@ pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
}
/// A solid paint of paper white (no ink in any colour model).
fn is_white(p: &Paint) -> bool {
pub fn is_white(p: &Paint) -> bool {
let Paint::Solid { color, .. } = p else { return false };
match *color {
Color::Rgb { r, g, b } => r.min(g).min(b) >= 1.0,
@@ -149,6 +149,33 @@ impl Document {
}
}
/// Whether any fill or stroke of `n`'s subtree overprints, characters' too.
fn overprints_anywhere(n: &Node) -> bool {
n.appearance.items.iter().any(|i| i.overprint())
|| matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke))
|| n.children().is_some_and(|c| c.iter().any(|c| overprints_anywhere(c)))
}
/// Stop every fill and stroke of `a`'s subtree overprinting (characters' too), copying only the
/// nodes on the way to them.
pub fn clear_overprints(a: &mut Arc<Node>) {
if !overprints_anywhere(a) {
return;
}
let n = Arc::make_mut(a);
for it in &mut n.appearance.items {
*it.overprint_mut() = false;
}
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
(r.style.overprint_fill, r.style.overprint_stroke) = (false, false);
}
}
for c in n.children_mut().into_iter().flatten() {
clear_overprints(c);
}
}
#[cfg(test)]
mod tests {
use super::*;
+111
View File
@@ -0,0 +1,111 @@
//! The colour visitor that recolouring shares: Edit Colors, Recolor Artwork and the live colour
//! adjustment effects walk an object's colours through [`recolor_node`] and decide what each
//! colour, mesh point and embedded image becomes through a [`ColorVisitor`].
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use crate::{AppearanceItem, Node, NodeKind};
/// What a recolouring pass does to the colours it meets.
pub trait ColorVisitor {
/// A fill's or stroke's paint (an object's, or a text run's): true when it changed.
fn paint(&mut self, p: &mut Paint) -> bool;
/// One gradient-mesh point's colour.
fn color(&mut self, c: Color) -> Color;
/// The key of embedded image `key` recoloured, `None` when it stays as it is.
fn image(&mut self, key: &str) -> Option<String>;
}
/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of
/// embedded images.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Reach {
pub fill: bool,
pub stroke: bool,
pub images: bool,
}
impl Reach {
/// Every colour.
pub const ALL: Reach = Reach { fill: true, stroke: true, images: true };
}
/// Recolour `n` and everything inside it through `v`: the paints of its fills and strokes and of
/// its text runs, its gradient-mesh points and an embedded image's pixels (linked images stay
/// as they are). Members that change are replaced by copies; the others stay shared. Returns the
/// number of paints, meshes and images that changed.
pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize {
let mut changed = 0;
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize,
AppearanceItem::Stroke(l) if reach.stroke => changed += v.paint(&mut l.paint) as usize,
_ => {}
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
if reach.fill {
changed += v.paint(&mut r.style.fill) as usize;
}
if reach.stroke {
changed += v.paint(&mut r.style.stroke) as usize;
}
}
}
NodeKind::Mesh(m) if reach.fill => {
let mut ch = false;
for p in &mut m.points {
let c = v.color(p.color);
ch |= c != p.color;
p.color = c;
}
changed += ch as usize;
}
NodeKind::Image(im) if reach.images && im.link.is_none() => {
if let Some(k) = v.image(&im.key) {
im.key = k;
changed += 1;
}
}
_ => {
for c in n.children_mut().into_iter().flatten() {
let mut m = (**c).clone();
let k = recolor_node(&mut m, reach, v);
if k > 0 {
*c = Arc::new(m);
changed += k;
}
}
}
}
changed
}
/// Apply `f` to the solid colour or gradient stops of a paint, with their swatch link and tint
/// ([`Paint::map_links`]); a gradient that changes is no longer its gradient swatch's. Returns
/// whether anything changed.
pub fn map_links(p: &mut Paint, f: &dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
let changed = p.map_links(&mut |c, l, t| f(c, l, t));
if changed && let Paint::Gradient(g) = p {
g.swatch = None;
}
changed
}
/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether
/// anything changed.
pub fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
map_links(p, &|c, link, _| {
let n = f(*c);
if n == *c {
return false;
}
*c = n;
*link = None;
true
})
}
+1
View File
@@ -13,6 +13,7 @@ vectorcraft-geom = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-pathops = { workspace = true }
vectorcraft-text = { workspace = true }
vectorcraft-plugins = { workspace = true }
kurbo = { workspace = true }
serde_json = { workspace = true }
+493
View File
@@ -0,0 +1,493 @@
//! Colour adjustment effects (Effect → Color Adjustments): Brightness/Contrast, Curves, Levels,
//! Hue/Saturation, Shift to Color and Temperature/Tint.
//!
//! They recolour what an object paints with, non-destructively: its fills and strokes (solid
//! colours and gradient stops), type, gradient meshes, embedded images and, on a group or layer,
//! every member — through the colour visitor the Edit Colors commands use
//! ([`vectorcraft_doc::recolor`]). Each colour keeps its colour model. An effect on one fill or
//! stroke recolours that paint only. [`adjust`] evaluates them, innermost first and each list in
//! stack order; the renderer draws the result (caching recoloured copies of embedded images) and
//! [`crate::bake_document`] writes it for export, recoloured images as new embedded images.
//! Pattern tiles, and the shadows and glows of raster effects, keep their colours.
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint, keep_model};
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_paint, recolor_node};
use vectorcraft_doc::{Document, Effect, Node, NodeKind};
use crate::merged_params;
use crate::raster::color_param;
use crate::util::{flag, num, text};
/// The colour adjustment effects, in menu order.
pub const ADJUSTMENTS: [&str; 6] =
["adjust.brightnessContrast", "adjust.curves", "adjust.hueSaturation", "adjust.levels", "adjust.shiftToColor", "adjust.temperatureTint"];
/// Is `id` a colour adjustment effect?
pub fn is_adjustment(id: &str) -> bool {
ADJUSTMENTS.contains(&id)
}
fn visible_adjustment(e: &Effect) -> bool {
e.visible && is_adjustment(&e.id)
}
/// Does `n` carry a visible colour adjustment, on the object or on one of its fills or strokes?
pub fn has_adjustment(n: &Node) -> bool {
n.appearance.effects.iter().any(visible_adjustment) || n.appearance.items.iter().any(|i| i.effects().iter().any(visible_adjustment))
}
/// The channels a tone curve (Curves, Levels) applies to.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Channel {
All,
One(usize),
}
impl Channel {
fn of(p: &Value) -> Self {
match text(p, "channel", "rgb").to_ascii_lowercase().as_str() {
"red" | "r" => Channel::One(0),
"green" | "g" => Channel::One(1),
"blue" | "b" => Channel::One(2),
_ => Channel::All,
}
}
}
/// Entries of a sampled tone curve.
const LUT: usize = 256;
/// One adjustment, its parameters normalised.
#[derive(Clone, Debug, PartialEq)]
enum Adjust {
/// Brightness and contrast, -1..1 each.
BrightnessContrast { brightness: f32, contrast: f32 },
/// A tone curve (Curves, Levels) sampled at [`LUT`] inputs, on `channel`.
Tone { channel: Channel, lut: Arc<[f32]> },
/// Hue shift in degrees, saturation and lightness -1..1; `colorize` sets the hue instead.
HueSaturation { hue: f32, saturation: f32, lightness: f32, colorize: bool },
/// Move colours `amount` (0..1) of the way to `target` (sRGB), keeping their lightness when
/// `preserve`.
ShiftToColor { target: [f32; 3], amount: f32, preserve: bool },
/// Warm/cool and magenta/green shifts, -1..1.
TemperatureTint { temperature: f32, tint: f32 },
}
/// The colour adjustments of an effect list, composed in order.
#[derive(Clone, Debug, PartialEq)]
pub struct ColorMap(Vec<Adjust>);
impl ColorMap {
/// `self`, then `next`.
pub fn then(&self, next: &ColorMap) -> ColorMap {
ColorMap(self.0.iter().chain(&next.0).cloned().collect())
}
/// Adjust display (sRGB) components 0..1.
pub fn apply_rgb(&self, rgb: [f32; 3]) -> [f32; 3] {
self.0.iter().fold(rgb, |c, a| a.apply(c).map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }))
}
/// Adjust a colour, keeping its colour model (unchanged when the adjustments leave it as is).
pub fn apply(&self, c: Color) -> Color {
let rgb = c.to_rgb();
let out = self.apply_rgb(rgb);
if out.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-6) {
return c;
}
keep_model(c, Color::rgb(out[0], out[1], out[2]))
}
/// Adjust an 8-bit sRGB pixel.
pub fn apply_rgb8(&self, [r, g, b]: [u8; 3]) -> [u8; 3] {
let out = self.apply_rgb([r, g, b].map(|v| v as f32 / 255.0));
out.map(|v| (v * 255.0).round() as u8)
}
}
/// The visible colour adjustments of `effects` as one map, `None` when there are none.
pub fn color_map(effects: &[Effect]) -> Option<ColorMap> {
let v: Vec<Adjust> =
effects.iter().filter(|e| visible_adjustment(e)).filter_map(|e| Adjust::parse(&e.id, &merged_params(&e.id, &e.params))).collect();
(!v.is_empty()).then_some(ColorMap(v))
}
/// A parameter in -100..100 as -1..1.
fn amount(p: &Value, key: &str) -> f32 {
(num(p, key, 0.0).clamp(-100.0, 100.0) / 100.0) as f32
}
/// A level 0..255 as 0..1.
fn level(p: &Value, key: &str, default: f64) -> f32 {
(num(p, key, default).clamp(0.0, 255.0) / 255.0) as f32
}
impl Adjust {
fn parse(id: &str, p: &Value) -> Option<Self> {
Some(match id {
"adjust.brightnessContrast" => Adjust::BrightnessContrast { brightness: amount(p, "brightness"), contrast: amount(p, "contrast") },
"adjust.levels" => {
let (ib, iw) = (level(p, "inputBlack", 0.0), level(p, "inputWhite", 255.0));
let (ob, ow) = (level(p, "outputBlack", 0.0), level(p, "outputWhite", 255.0));
let gamma = num(p, "gamma", 1.0).clamp(0.1, 10.0) as f32;
let f = |x: f32| {
let t = if iw > ib {
((x - ib) / (iw - ib)).clamp(0.0, 1.0)
} else if x >= ib {
1.0
} else {
0.0
};
ob + t.powf(1.0 / gamma) * (ow - ob)
};
Adjust::Tone { channel: Channel::of(p), lut: sample(f) }
}
"adjust.curves" => {
let pts = curve_points(p.get("points"));
Adjust::Tone { channel: Channel::of(p), lut: sample(|x| curve_at(&pts, x)) }
}
"adjust.hueSaturation" => Adjust::HueSaturation {
hue: num(p, "hue", 0.0).clamp(-360.0, 360.0) as f32,
saturation: amount(p, "saturation"),
lightness: amount(p, "lightness"),
colorize: flag(p, "colorize", false),
},
"adjust.shiftToColor" => Adjust::ShiftToColor {
target: color_param(p, Color::rgb(1.0, 0.5, 0.0)).to_rgb(),
amount: (num(p, "amount", 50.0).clamp(0.0, 100.0) / 100.0) as f32,
preserve: flag(p, "preserveLightness", true),
},
"adjust.temperatureTint" => Adjust::TemperatureTint { temperature: amount(p, "temperature"), tint: amount(p, "tint") },
_ => return None,
})
}
fn apply(&self, [r, g, b]: [f32; 3]) -> [f32; 3] {
match self {
Adjust::BrightnessContrast { brightness, contrast } => {
// Brightness bends the tones (black and white stay), contrast spreads them from the middle.
let gamma = 2f32.powf(-brightness);
let k = if *contrast >= 0.0 { 1.0 / (1.0 - contrast * 0.99) } else { 1.0 + contrast };
[r, g, b].map(|v| (v.clamp(0.0, 1.0).powf(gamma) - 0.5) * k + 0.5)
}
Adjust::Tone { channel, lut } => {
let f = |v: f32| lookup(lut, v);
match channel {
Channel::All => [f(r), f(g), f(b)],
Channel::One(i) => {
let mut c = [r, g, b];
if let Some(v) = c.get_mut(*i) {
*v = f(*v);
}
c
}
}
}
Adjust::HueSaturation { hue, saturation, lightness, colorize } => {
let [h, s, l] = to_hsl([r, g, b]);
let (h, s) = if *colorize {
(hue.rem_euclid(360.0), (saturation + 1.0) / 2.0)
} else {
let s = if *saturation >= 0.0 { s / (1.0 - saturation * 0.99) } else { s * (1.0 + saturation) };
((h + hue).rem_euclid(360.0), s)
};
let l = if *lightness >= 0.0 { l + (1.0 - l) * lightness } else { l * (1.0 + lightness) };
from_hsl([h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)])
}
Adjust::ShiftToColor { target, amount, preserve } => {
let to = if *preserve {
let [th, ts, _] = to_hsl(*target);
from_hsl([th, ts, to_hsl([r, g, b])[2]])
} else {
*target
};
let mix = |a: f32, b: f32| a + (b - a) * amount;
[mix(r, to[0]), mix(g, to[1]), mix(b, to[2])]
}
Adjust::TemperatureTint { temperature, tint } => {
let (t, m) = (temperature * 0.3, tint * 0.3);
[r * (1.0 + t), g * (1.0 - m), b * (1.0 - t)]
}
}
}
}
/// `f` sampled at [`LUT`] inputs 0..1.
fn sample(f: impl Fn(f32) -> f32) -> Arc<[f32]> {
(0..LUT).map(|i| f(i as f32 / (LUT - 1) as f32).clamp(0.0, 1.0)).collect()
}
/// The tone curve `lut` at `v` (linear between samples).
fn lookup(lut: &[f32], v: f32) -> f32 {
let x = v.clamp(0.0, 1.0) * (LUT - 1) as f32;
let i = (x.floor() as usize).min(LUT - 2);
let (a, b) = (lut.get(i).copied().unwrap_or(v), lut.get(i + 1).copied().unwrap_or(v));
a + (b - a) * (x - i as f32)
}
/// Curves' points (0..1 each, sorted by input, one per input) from its `points` parameter:
/// `"x,y x,y …"` or `[[x, y], …]` in 0..255. Missing or unreadable points give the identity line.
pub fn curve_points(v: Option<&Value>) -> Vec<(f32, f32)> {
let mut pts: Vec<(f64, f64)> = match v {
Some(Value::String(s)) => s
.split(|c: char| c.is_whitespace() || c == ';')
.filter_map(|pair| {
let (x, y) = pair.split_once(',')?;
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
})
.collect(),
Some(Value::Array(a)) => a.iter().filter_map(|xy| Some((xy.get(0)?.as_f64()?, xy.get(1)?.as_f64()?))).collect(),
_ => vec![],
};
pts.retain(|(x, y)| x.is_finite() && y.is_finite());
// At most a point per level is meaningful.
pts.truncate(256);
let mut pts: Vec<(f32, f32)> =
pts.into_iter().map(|(x, y)| ((x.clamp(0.0, 255.0) / 255.0) as f32, (y.clamp(0.0, 255.0) / 255.0) as f32)).collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
if pts.len() < 2 {
return vec![(0.0, 0.0), (1.0, 1.0)];
}
pts
}
/// Curves' curve through `pts` ([`curve_points`]) at `x` (0..1): a monotone cubic, flat beyond
/// the end points.
pub fn curve_at(pts: &[(f32, f32)], x: f32) -> f32 {
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return x };
if x <= first.0 {
return first.1;
}
if x >= last.0 {
return last.1;
}
let n = pts.len();
let slope = |i: usize| -> f32 {
match (pts.get(i), pts.get(i + 1)) {
(Some(a), Some(b)) if b.0 > a.0 => (b.1 - a.1) / (b.0 - a.0),
_ => 0.0,
}
};
// Tangents: the harmonic mean of the neighbouring slopes, zero at extrema, so the curve never
// overshoots its points.
let tangent = |i: usize| -> f32 {
if i == 0 {
return slope(0);
}
if i + 1 >= n {
return slope(n - 2);
}
let (a, b) = (slope(i - 1), slope(i));
if a * b <= 0.0 { 0.0 } else { 2.0 * a * b / (a + b) }
};
let k = pts.windows(2).position(|w| x >= w[0].0 && x <= w[1].0).unwrap_or(0);
let (Some(&(x0, y0)), Some(&(x1, y1))) = (pts.get(k), pts.get(k + 1)) else { return x };
let h = x1 - x0;
if h <= 0.0 {
return y0;
}
let t = (x - x0) / h;
let (m0, m1) = (tangent(k) * h, tangent(k + 1) * h);
let (t2, t3) = (t * t, t * t * t);
(2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * m0 + (-2.0 * t3 + 3.0 * t2) * y1 + (t3 - t2) * m1
}
/// HSL with hue in degrees, saturation and lightness 0..1.
fn to_hsl([r, g, b]: [f32; 3]) -> [f32; 3] {
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
let l = (max + min) / 2.0;
let d = max - min;
if d <= 1e-6 {
return [0.0, 0.0, l];
}
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == r {
(g - b) / d + if g < b { 6.0 } else { 0.0 }
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
[h * 60.0, s, l]
}
fn from_hsl([h, s, l]: [f32; 3]) -> [f32; 3] {
if s <= 0.0 {
return [l; 3];
}
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
let p = 2.0 * l - q;
let hue = |t: f32| {
let t = t.rem_euclid(1.0);
if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 0.5 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
}
};
let h = h / 360.0;
[hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
}
/// Gives the key of an embedded image recoloured by a map (`None`: leave it as it is).
pub type ImageHook<'a> = dyn FnMut(&str, &ColorMap) -> Option<String> + 'a;
/// The live visitor: every colour through `map`, embedded images through the hook.
struct Live<'a, 'b> {
map: &'a ColorMap,
images: &'a mut ImageHook<'b>,
}
impl ColorVisitor for Live<'_, '_> {
fn paint(&mut self, p: &mut Paint) -> bool {
map_paint(p, &|c| self.map.apply(c))
}
fn color(&mut self, c: Color) -> Color {
self.map.apply(c)
}
fn image(&mut self, key: &str) -> Option<String> {
(self.images)(key, self.map)
}
}
/// `n` with the colour adjustments in it applied, `None` when it has none: its members' first,
/// then those of its fills and strokes on their paints, then its own followed by `outer` (the
/// adjustments of an object `n` is the art of) on everything it paints. The adjustment effects
/// are gone from the result; embedded images take the keys `images` gives.
pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> Option<Node> {
let mut m: Option<Node> = None;
if let Some(ch) = n.children() {
for (i, c) in ch.iter().enumerate() {
if let Some(new) = adjust(c, None, images)
&& let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i))
{
*slot = Arc::new(new);
}
}
}
for (i, item) in n.appearance.items.iter().enumerate() {
if let Some(map) = color_map(item.effects())
&& let Some(it) = m.get_or_insert_with(|| n.clone()).appearance.items.get_mut(i)
{
map_paint(it.paint_mut(), &|c| map.apply(c));
it.effects_mut().retain(|e| !is_adjustment(&e.id));
}
}
let map = match (color_map(&n.appearance.effects), outer) {
(Some(own), Some(outer)) => Some(own.then(outer)),
(own, None) => own,
(None, Some(outer)) => Some(outer.clone()),
};
if let Some(map) = map {
let mm = m.get_or_insert_with(|| n.clone());
mm.appearance.effects.retain(|e| !is_adjustment(&e.id));
recolor_node(mm, Reach::ALL, &mut Live { map: &map, images });
}
m
}
/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in
/// `d` (named by their content), and a symbol instance stands for its symbol's art.
pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option<Node> {
let art;
let src = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => {
let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone());
art = crate::reshape::as_art(n, symbol.as_deref())?;
&art
}
_ => n,
};
let mut hook = |key: &str, map: &ColorMap| -> Option<String> {
let blob = d.images.get(key)?.map_rgb(|rgb| map.apply_rgb8(rgb))?;
let k = blob.content_key();
d.images.entry(k.clone()).or_insert(blob);
Some(k)
};
adjust(src, None, &mut hook)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vectorcraft_doc::{Appearance, NodeId};
use vectorcraft_geom::{Rect, shapes};
fn map(id: &str, params: Value) -> ColorMap {
color_map(&[Effect { id: id.into(), params, visible: true }]).unwrap()
}
fn close(a: [f32; 3], b: [f32; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01)
}
#[test]
fn neutral_settings_change_nothing() {
for id in ADJUSTMENTS {
let m = color_map(&[Effect { id: id.into(), params: json!({"amount": 0}), visible: true }]).unwrap();
for c in [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] {
assert!(close(m.apply_rgb(c), c), "{id} {c:?} → {:?}", m.apply_rgb(c));
}
}
let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4);
assert_eq!(map("adjust.levels", json!({})).apply(cmyk), cmyk, "an untouched colour keeps its exact values");
}
#[test]
fn adjustments_move_colours_the_expected_way() {
let grey = [0.5, 0.5, 0.5];
assert!(map("adjust.brightnessContrast", json!({"brightness": 50})).apply_rgb(grey)[0] > 0.6);
assert!(map("adjust.brightnessContrast", json!({"contrast": 50})).apply_rgb([0.7; 3])[0] > 0.8);
assert!(close(map("adjust.levels", json!({"inputBlack": 64, "inputWhite": 192})).apply_rgb([0.25, 0.5, 0.75]), [0.0, 0.5, 1.0]));
assert!(close(map("adjust.levels", json!({"outputWhite": 128, "channel": "red"})).apply_rgb([1.0; 3]), [0.502, 1.0, 1.0]));
// An S curve darkens the shadows and lightens the highlights, through its points exactly.
let s = map("adjust.curves", json!({"points": "0,0 64,32 192,224 255,255"}));
assert!(close(s.apply_rgb([64.0 / 255.0; 3]), [32.0 / 255.0; 3]));
assert!(s.apply_rgb([0.85; 3])[0] > 0.9);
let arr = map("adjust.curves", json!({"points": [[0, 255], [255, 0]]}));
assert!(close(arr.apply_rgb([0.0, 1.0, 0.25]), [1.0, 0.0, 0.75]), "an inverting curve");
assert!(close(map("adjust.hueSaturation", json!({"hue": 120})).apply_rgb([1.0, 0.0, 0.0]), [0.0, 1.0, 0.0]));
assert!(close(map("adjust.hueSaturation", json!({"saturation": -100})).apply_rgb([1.0, 0.0, 0.0]), [0.5; 3]));
assert!(close(map("adjust.hueSaturation", json!({"saturation": 100})).apply_rgb(grey), grey), "greys stay grey");
assert!(close(map("adjust.hueSaturation", json!({"colorize": true, "hue": 240, "saturation": 100})).apply_rgb(grey), [0.0, 0.0, 1.0]));
let shift = map("adjust.shiftToColor", json!({"color": "#0000ff", "amount": 100, "preserveLightness": false}));
assert!(close(shift.apply_rgb([1.0, 1.0, 0.0]), [0.0, 0.0, 1.0]));
let warm = map("adjust.temperatureTint", json!({"temperature": 100})).apply_rgb(grey);
assert!(warm[0] > grey[0] && warm[2] < grey[2]);
// Effects compose in stack order.
let both = map("adjust.levels", json!({"outputWhite": 128})).then(&map("adjust.brightnessContrast", json!({"brightness": 100})));
assert!(close(both.apply_rgb([1.0; 3]), [(128.0f32 / 255.0).sqrt(); 3]));
}
#[test]
fn adjust_recolours_paints_members_and_strips_the_effects() {
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let leaf = |id| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), red.clone()));
let mut g = Node::group(NodeId(1), vec![leaf(2), leaf(3)]);
g.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true });
assert!(has_adjustment(&g));
let out = adjust(&g, None, &mut |_, _| None).unwrap();
assert!(out.appearance.effects.is_empty());
for c in out.children().unwrap() {
assert_eq!(c.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
}
// A hidden effect does nothing; a stroke's own effect recolours just that stroke.
g.appearance.effects[0].visible = false;
assert!(!has_adjustment(&g) && adjust(&g, None, &mut |_, _| None).is_none());
let mut p = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
p.appearance.items[1].effects_mut().push(Effect { id: "adjust.levels".into(), params: json!({"outputBlack": 255}), visible: true });
let out = adjust(&p, None, &mut |_, _| None).unwrap();
assert_eq!(out.appearance.items[1].paint().color().unwrap().to_hex(), "#ffffff");
assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint());
assert!(out.appearance.items[1].effects().is_empty());
}
}
+12 -3
View File
@@ -6,6 +6,8 @@
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills,
//! strokes and geometry effects of groups and layers ([`evaluate_container`]). Raster effects
//! (shadows, glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
//! Colour adjustments recolour the objects ([`adjust_in_document`]; recoloured embedded images
//! become images of their own).
//!
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
@@ -17,8 +19,8 @@ use vectorcraft_geom::{FillRule, PathData};
use crate::group::{has_own_paint, paints};
use crate::{
GeomContext, apply_geometry_with, crop_marks_art, evaluate_container, has_crop_marks, has_geometry, has_pathfinder, is_geometry, needs_outline,
reshape,
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
has_pathfinder, is_geometry, needs_outline, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
@@ -37,7 +39,8 @@ fn clear_item_effects(item: &mut AppearanceItem) {
/// Does anything in `n`'s subtree need baking?
pub fn needs_bake(n: &Node) -> bool {
has_crop_marks(n)
has_adjustment(n)
|| has_crop_marks(n)
|| has_pathfinder(n)
|| has_own_paint(n)
|| has_geometry(&n.appearance.effects)
@@ -78,6 +81,12 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if !needs_bake(n) {
return None;
}
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
if has_adjustment(n)
&& let Some(m) = adjust_in_document(d, n)
{
return Some(bake_node(d, &m).unwrap_or(m));
}
// Crop marks: the object and its marks.
if let Some(m) = crop_marks_art(n) {
return Some(bake_pieces(d, m));
+98 -7
View File
@@ -18,11 +18,18 @@
//! art painting its members (its raster effects then apply to the composite).
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
//! PDF writers.
//! - **Plug-in effects** (`plugin.<plug-in id>`, Effect › Plug-ins) are geometry effects run by an
//! installed WebAssembly plug-in (`vectorcraft-plugins`); [`effect_info`] describes them like
//! built-in ones, and an effect whose plug-in isn't installed leaves the geometry as it is.
//! - **Colour adjustments** (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color,
//! Temperature/Tint) recolour what an object paints with: [`adjust`] evaluates them for the
//! renderer and the exporters.
//!
//! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash
//! noise (`seed` parameter, default 0).
#![forbid(unsafe_code)]
mod adjust;
mod bake;
mod clip;
mod distort;
@@ -44,6 +51,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use clip::clip_outline;
pub use group::{
@@ -125,6 +133,8 @@ const STYLIZE: &[&str] = &["Effect", "Stylize"];
const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
const ADJUST: &[&str] = &["Effect", "Color Adjustments"];
/// The warp styles in Illustrator's Style menu order: (id suffix, label).
pub const WARP_STYLES: [(&str, &str); 15] = [
@@ -254,6 +264,50 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
];
v.extend([
g(
"adjust.brightnessContrast",
"Brightness/Contrast…",
ADJUST,
"{brightness: -100..100 (0; bends the tones, black and white stay), contrast: -100..100 (0)} recolours the object's fills, strokes, type, meshes and embedded images (each colour keeps its model)",
json!({"brightness": 0.0, "contrast": 0.0}),
),
g(
"adjust.curves",
"Curves…",
ADJUST,
"{points: \"x,y x,y …\" or [[x, y], …] (input, output 0..255; a smooth monotone curve through them, flat past the ends; \"0,0 128,128 255,255\"), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does",
json!({"points": "0,0 128,128 255,255", "channel": "rgb"}),
),
g(
"adjust.hueSaturation",
"Hue/Saturation…",
ADJUST,
"{hue: deg -180..180 (0), saturation: -100..100 (0), lightness: -100..100 (0), colorize: bool (false; true: every colour takes hue `hue` (0..360) at saturation (100 + saturation) / 2 %)} recolours as Brightness/Contrast does",
json!({"hue": 0.0, "saturation": 0.0, "lightness": 0.0, "colorize": false}),
),
g(
"adjust.levels",
"Levels…",
ADJUST,
"{inputBlack: 0..255 (0), inputWhite: 0..255 (255), gamma: 0.1..10 (1; above 1 lightens the midtones), outputBlack: 0..255 (0), outputWhite: 0..255 (255), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does",
json!({"inputBlack": 0.0, "inputWhite": 255.0, "gamma": 1.0, "outputBlack": 0.0, "outputWhite": 255.0, "channel": "rgb"}),
),
g(
"adjust.shiftToColor",
"Shift to Color…",
ADJUST,
"{color: \"#rrggbb\" (\"#ff8000\"), amount: 0..100 % (50), preserveLightness: bool (true: colours take the target's hue and saturation and keep their lightness; false: they mix with it)} recolours as Brightness/Contrast does",
json!({"color": "#ff8000", "amount": 50.0, "preserveLightness": true}),
),
g(
"adjust.temperatureTint",
"Temperature/Tint…",
ADJUST,
"{temperature: -100 (cooler) .. 100 (warmer) (0), tint: -100 (greener) .. 100 (more magenta) (0)} recolours as Brightness/Contrast does",
json!({"temperature": 0.0, "tint": 0.0}),
),
]);
for (suffix, label) in WARP_STYLES {
let id: &'static str = match suffix {
"arc" => "warp.arc",
@@ -293,21 +347,47 @@ fn catalog_index() -> &'static std::collections::HashMap<&'static str, EffectInf
INDEX.get_or_init(|| effect_catalog().into_iter().map(|e| (e.id, e)).collect())
}
/// Catalogue entry for `id`.
/// Catalogue entry for `id` (built-in, or an installed plug-in effect).
pub fn effect_info(id: &str) -> Option<EffectInfo> {
catalog_index().get(id).cloned()
catalog_index().get(id).cloned().or_else(|| plugin_info(id))
}
/// The catalogue entry of the installed effect plug-in with effect id `id` (`plugin.<id>`).
fn plugin_info(id: &str) -> Option<EffectInfo> {
use vectorcraft_plugins::registry::intern;
let p = vectorcraft_plugins::effect::installed(id)?;
let m = p.manifest();
let dots = if m.params.is_empty() || m.name.ends_with('…') { "" } else { "…" };
Some(EffectInfo {
id: intern(id),
label: intern(&format!("{}{dots}", m.name)),
menu: PLUGINS,
params: intern(&m.params_doc()),
defaults: Value::Object(m.defaults()),
raster: false,
lengths: Lengths::default(),
})
}
/// The installed effect plug-ins as catalogue entries (Effect › Plug-ins), by plug-in id.
pub fn plugin_effects() -> Vec<EffectInfo> {
vectorcraft_plugins::registry::list().iter().filter_map(|p| plugin_info(&vectorcraft_plugins::effect::effect_id(p.id()))).collect()
}
/// Dialog defaults for `id` (`None` for unknown effects).
pub fn default_params(id: &str) -> Option<Value> {
catalog_index().get(id).map(|e| e.defaults.clone())
match catalog_index().get(id) {
Some(e) => Some(e.defaults.clone()),
None => plugin_info(id).map(|e| e.defaults),
}
}
/// `params` layered over the defaults of `id` (unknown keys are kept).
pub fn merged_params(id: &str, params: &Value) -> Value {
let mut out = match catalog_index().get(id).map(|e| &e.defaults) {
Some(Value::Object(m)) => m.clone(),
_ => Map::new(),
Some(_) => Map::new(),
None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(),
};
if let Value::Object(p) = params {
for (k, v) in p {
@@ -319,7 +399,9 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
/// A new effect record with default parameters (overridden by `params`).
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
catalog_index().get(id)?;
if !catalog_index().contains_key(id) {
vectorcraft_plugins::effect::installed(id)?;
}
Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
}
@@ -346,9 +428,15 @@ pub fn is_raster(id: &str) -> bool {
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
}
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`].)
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour
/// adjustments recolour, [`adjust`].) Plug-in effects do, installed or not (a missing plug-in
/// leaves the geometry as it is).
pub fn is_geometry(id: &str) -> bool {
!is_raster(id) && !is_pathfinder(id) && id != CROP_MARKS && catalog_index().contains_key(id)
!is_raster(id)
&& !is_pathfinder(id)
&& !is_adjustment(id)
&& id != CROP_MARKS
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
}
/// Any visible geometry effect in the list?
@@ -420,6 +508,9 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
use util::*;
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule).unwrap_or_else(|| path.clone());
}
match id {
"distort.freeDistort" => distort::free_distort(path, b, p),
"distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)),
+5 -4
View File
@@ -64,7 +64,8 @@ pub fn blend_mode(s: &str) -> BlendMode {
BlendMode::ALL.iter().copied().find(|m| format!("{m:?}").to_ascii_lowercase() == key).unwrap_or(BlendMode::Normal)
}
fn color(p: &Value, default: Color) -> Color {
/// An effect's `color` parameter (`"#rrggbb"`, `[r, g, b]` 0..1 or a colour object).
pub(crate) fn color_param(p: &Value, default: Color) -> Color {
match p.get("color") {
Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default),
Some(Value::Array(a)) if a.len() >= 3 => {
@@ -95,19 +96,19 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
dx: num(&p, "x", 7.0).clamp(-1e4, 1e4),
dy: num(&p, "y", 7.0).clamp(-1e4, 1e4),
blur,
color: color(&p, Color::BLACK),
color: color_param(&p, Color::BLACK),
},
"stylize.outerGlow" => RasterFx::OuterGlow {
mode: blend_mode(text(&p, "mode", "screen")),
opacity: opacity(&p),
blur,
color: color(&p, Color::rgb(1.0, 1.0, 0.0)),
color: color_param(&p, Color::rgb(1.0, 1.0, 0.0)),
},
"stylize.innerGlow" => RasterFx::InnerGlow {
mode: blend_mode(text(&p, "mode", "screen")),
opacity: opacity(&p),
blur,
color: color(&p, Color::WHITE),
color: color_param(&p, Color::WHITE),
center: text(&p, "source", "edge").eq_ignore_ascii_case("center"),
},
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
+1 -1
View File
@@ -131,7 +131,7 @@ pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
}
/// [`reshape`] without its checks (no geometry effects: just the art).
fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
let fx = geometry_effects(n);
let mut art = match &n.kind {
NodeKind::Image(im) => {
+2
View File
@@ -24,6 +24,7 @@ vectorcraft-pdf = { workspace = true }
vectorcraft-cad = { workspace = true }
vectorcraft-eps = { workspace = true }
vectorcraft-metafile = { workspace = true }
vectorcraft-plugins = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
kurbo = { workspace = true }
@@ -35,3 +36,4 @@ log = { workspace = true }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
wat = { workspace = true }
+30 -78
View File
@@ -13,7 +13,9 @@ use vectorcraft_color::harmony::{Guide, GuideOptions, Harmony, Variation};
use vectorcraft_color::{Color, Paint, keep_model};
use vectorcraft_doc::live::lerp_color;
use vectorcraft_doc::pattern::PatternDef;
use vectorcraft_doc::{AppearanceItem, Document, ImageBlob, Node, NodeId, NodeKind};
pub(crate) use vectorcraft_doc::recolor::map_paint;
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node};
use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind};
use super::edit::selected_roots;
use super::*;
@@ -144,30 +146,6 @@ fn gray_level(c: Color) -> f32 {
/// gradient stop): `(colour, link, tint)`, true when it changed them.
pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
/// Apply `f` to the solid colour or gradient stops of a paint ([`Paint::map_links`]); a gradient
/// that changes is no longer its gradient swatch's. Returns whether anything changed.
fn map_links(p: &mut Paint, f: &LinkMap) -> bool {
let changed = p.map_links(&mut |c, l, t| f(c, l, t));
if changed && let Paint::Gradient(g) = p {
g.swatch = None;
}
changed
}
/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether
/// anything changed.
pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
map_links(p, &|c, link, _| {
let n = f(*c);
if n == *c {
return false;
}
*c = n;
*link = None;
true
})
}
/// The identity colour map (a pass that only maps linked colours).
fn same(c: Color) -> Color {
c
@@ -258,68 +236,24 @@ impl<'a> Recolor<'a> {
out
}
/// `n` recoloured, or `None` when nothing changed.
/// `n` recoloured (through the shared colour visitor), or `None` when nothing changed.
fn subtree(&mut self, d: &Document, n: &Node) -> Option<Node> {
let before = self.changed;
let mut m = n.clone();
self.node(d, &mut m);
(self.changed > before).then_some(m)
}
fn node(&mut self, d: &Document, n: &mut Node) {
let Scope { fill, stroke, images, .. } = self.scope;
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) if fill => self.paint(d, &mut l.paint),
AppearanceItem::Stroke(l) if stroke => self.paint(d, &mut l.paint),
_ => {}
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
if fill {
self.paint(d, &mut r.style.fill);
}
if stroke {
self.paint(d, &mut r.style.stroke);
}
}
}
NodeKind::Mesh(m) if fill => {
let mut ch = false;
for p in &mut m.points {
let c = (self.f)(p.color);
ch |= c != p.color;
p.color = c;
}
self.changed += ch as usize;
}
NodeKind::Image(im) if images && im.link.is_none() => {
if let Some(k) = self.image(d, &im.key) {
im.key = k;
self.changed += 1;
}
}
_ => {
for c in n.children_mut().into_iter().flatten() {
if let Some(new) = self.subtree(d, c) {
*c = Arc::new(new);
}
}
}
}
let mut m = n.clone();
let changed = recolor_node(&mut m, Reach { fill, stroke, images }, &mut Visit { pass: self, d });
self.changed += changed;
(changed > 0).then_some(m)
}
fn paint(&mut self, d: &Document, p: &mut Paint) {
let changed = match p {
/// Recolour one paint: true when it changed.
fn paint(&mut self, d: &Document, p: &mut Paint) -> bool {
match p {
Paint::Pattern { pattern, .. } if self.scope.patterns => self.pattern(d, pattern).map(|new| *pattern = new).is_some(),
_ => match self.links {
Some(f) => map_links(p, f),
None => map_paint(p, self.f),
},
};
self.changed += changed as usize;
}
}
/// The key of image `key` recoloured (one copy per pass), `None` when no pixel changes.
@@ -370,6 +304,24 @@ impl<'a> Recolor<'a> {
}
}
/// What a pass over document `d` does to each colour the shared visitor meets.
struct Visit<'p, 'a> {
pass: &'p mut Recolor<'a>,
d: &'p Document,
}
impl ColorVisitor for Visit<'_, '_> {
fn paint(&mut self, p: &mut Paint) -> bool {
self.pass.paint(self.d, p)
}
fn color(&mut self, c: Color) -> Color {
(self.pass.f)(c)
}
fn image(&mut self, key: &str) -> Option<String> {
self.pass.image(self.d, key)
}
}
pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
run_pass(s, p, label, Recolor::new(Scope::of(p), f))
}
+397
View File
@@ -0,0 +1,397 @@
//! Cutting tools' commands: Mirror & Cut, Line Cut and Rectangle Cut.
//!
//! They work on the selected paths and compound paths (inside groups too; a compound path is cut
//! as one shape): filled shapes through `vectorcraft-pathops` booleans against a half-plane or a
//! rectangle, open paths by splitting them where they cross the line or the rectangle's edge (the
//! Knife and Path Eraser helpers). Each command is one undo step; the tools in
//! `vectorcraft_tools::cut` preview it live while dragging.
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Anchor, FillRule, PathData, Point, Rect, SubPath, Vec2};
use vectorcraft_pathops as po;
use vectorcraft_pathops::BoolOp;
use super::draw2::{components, erase_outline, ids_json, replace_with_pieces};
use super::edit::selected_roots;
use super::pathops::node_path;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"path.mirrorCut",
"Mirror & Cut",
[],
None,
"{axis?: \"vertical\"|\"horizontal\"|\"free\" (vertical), from?: [x,y], to?: [x,y] (free: the axis through both; vertical/horizontal: the axis through `from`, default the selection's centre), keep?: \"left\"|\"right\"|\"top\"|\"bottom\" (the side kept; default left, or top for a horizontal-ish axis)} cut the selected paths and compound paths at the axis, reflect the kept half across it and join both halves into one closed shape (open paths are joined where they meet the axis); art wholly on the other side is deleted, as one undo step → {ids, removed}",
has_selection,
mirror_cut
),
cmd!(
"path.lineCut",
"Line Cut",
[],
None,
"{from: [x,y], to: [x,y]} split the selected paths and compound paths along the line through both points, extended across the art: filled shapes become their pieces on each side (holes kept), open paths split where they cross; as one undo step → {ids, removed: 0}",
has_selection,
line_cut
),
cmd!(
"path.rectCut",
"Rectangle Cut",
[],
None,
"{rect: [x, y, width, height]} or {from: [x,y], to: [x,y]} (opposite corners) crop the selected paths and compound paths to the rectangle as real geometry (not a clipping mask): filled shapes are intersected with it, open paths keep their parts inside, art wholly outside is deleted; as one undo step → {ids, removed}",
has_selection,
rect_cut
),
]
}
/// Largest coordinate the cut commands accept (keeps the half-plane polygons finite).
const MAX_COORD: f64 = 1e7;
/// How close an open path's end must be to the axis to be joined to its reflection.
const AXIS_TOL: f64 = 1e-3;
/// A straight line through `p` along the unit vector `d`.
#[derive(Clone, Copy, Debug)]
struct Axis {
p: Point,
d: Vec2,
}
impl Axis {
/// The line through `a` and `b` (`None` when they coincide).
fn through(a: Point, b: Point) -> Option<Self> {
let v = b - a;
let len = v.hypot();
(len > 1e-9 && len.is_finite()).then(|| Axis { p: a, d: v / len })
}
/// Signed distance of `q` from the line: positive on the side `d` points to when turned a
/// quarter turn clockwise on screen (y pointing down).
fn side(&self, q: Point) -> f64 {
self.d.cross(q - self.p)
}
/// `q` moved onto the line.
fn project(&self, q: Point) -> Point {
self.p + self.d * self.d.dot(q - self.p)
}
/// The reflection across the line.
fn reflection(&self) -> Affine {
let (x, y) = (self.d.x, self.d.y);
let (a, b, d) = (2.0 * x * x - 1.0, 2.0 * x * y, 2.0 * y * y - 1.0);
let p = self.p;
Affine::new([a, b, b, d, p.x - (a * p.x + b * p.y), p.y - (b * p.x + d * p.y)])
}
/// A polygon covering everything in `around` on one side of the line (`sign` > 0: where
/// [`Axis::side`] is positive).
fn half_plane(&self, sign: f64, around: Rect) -> PathData {
let r = around.width() + around.height() + self.p.distance(around.center()) + 10.0;
let n = Vec2::new(-self.d.y, self.d.x) * sign.signum() * 2.0 * r;
let (a, b) = (self.p - self.d * r, self.p + self.d * r);
polygon(&[a, b, b + n, a + n])
}
}
fn polygon(pts: &[Point]) -> PathData {
PathData::single(SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true))
}
/// A point param that must be finite and within [`MAX_COORD`].
fn point_req(p: &Value, key: &str, cmd: &str) -> Result<Point> {
let q = point_param(p, key).ok_or_else(|| bad(cmd, format!("missing point `{key}` ([x, y])")))?;
if q.x.abs() > MAX_COORD || q.y.abs() > MAX_COORD {
return Err(bad(cmd, format!("`{key}` is out of range")));
}
Ok(q)
}
/// The editable paths and compound paths in the selection (inside groups and layers too; a
/// compound path is one target; guides and clipping paths are left alone).
fn cut_targets(s: &Session) -> Result<Vec<NodeId>> {
fn visit(n: &Node, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } => out.push(n.id),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)),
_ => {}
}
}
let roots = selected_roots(s)?;
let doc = &s.doc()?.doc;
let mut out = vec![];
for id in roots {
if let Some(n) = doc.node(id) {
visit(n, &mut out);
}
}
out.dedup();
out.retain(|id| doc.is_editable(*id));
Ok(out)
}
/// What a cut does to one object: its new pieces (none deletes it).
type Plan = Vec<(NodeId, Vec<PathData>)>;
/// Run `cut` on every target's geometry (`None`: unchanged) and apply the result as one undo step.
fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, FillRule) -> Option<Vec<PathData>>) -> Result<Value> {
let targets = cut_targets(s)?;
if targets.is_empty() {
return Err(bad(cmd, "select paths or compound paths"));
}
let doc = &s.doc()?.doc;
let mut plan: Plan = vec![];
for id in targets {
let Some((pd, rule)) = doc.node(id).and_then(node_path) else { continue };
if pd.subpaths.iter().flat_map(|sp| &sp.anchors).any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())) {
return Err(bad(cmd, "paths contain invalid coordinates"));
}
if let Some(pieces) = cut(&pd, rule) {
plan.push((id, pieces));
}
}
apply(s, label, plan)
}
fn apply(s: &mut Session, label: &str, plan: Plan) -> Result<Value> {
if plan.is_empty() {
return Ok(json!({ "ids": [], "removed": 0 }));
}
let (ids, removed) = s.edit(label, |d: &mut Document, sel| {
let (mut all, mut removed) = (vec![], 0);
for (id, pieces) in plan {
removed += pieces.is_empty() as usize;
all.extend(replace_with_pieces(d, id, pieces)?);
}
sel.set(all.iter().copied());
Ok((all, removed))
})?;
let mut out = ids_json(&ids);
out["removed"] = json!(removed);
Ok(out)
}
/// Bounds of `pd` grown a little (the reference box of the half-planes).
fn reach(pd: &PathData) -> Rect {
pd.control_bounds().unwrap_or_default().inflate(1.0, 1.0)
}
/// `pd ∩ region`, dropping slivers.
fn intersect(pd: &PathData, rule: FillRule, region: &PathData) -> PathData {
let r = po::boolean(pd, rule, region, FillRule::NonZero, BoolOp::Intersect);
if po::area(&r, FillRule::NonZero) <= 1e-6 { PathData::default() } else { r }
}
// ---------- Line Cut ----------
fn line_cut(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.lineCut";
let axis = Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?;
run_cut(s, C, "Line Cut", &|pd, rule| split_by_line(pd, rule, &axis))
}
/// `pd` split by the line: the pieces on both sides, `None` when the line misses it.
fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option<Vec<PathData>> {
if pd.is_closed() {
let around = reach(pd);
let a = intersect(pd, rule, &axis.half_plane(1.0, around));
let b = intersect(pd, rule, &axis.half_plane(-1.0, around));
if a.is_empty() || b.is_empty() {
return None;
}
return Some(components(&a).into_iter().chain(components(&b)).collect());
}
// Open paths: the parts on each side (each pass removes the other side).
let a = erase_outline(pd, &|q| axis.side(q) < 0.0)?;
let b = erase_outline(pd, &|q| axis.side(q) >= 0.0)?;
Some(if pd.subpaths.len() == 1 {
a.into_iter().chain(b).collect()
} else {
vec![PathData::new(a.into_iter().chain(b).flat_map(|p| p.subpaths).collect())]
})
}
// ---------- Rectangle Cut ----------
fn rect_cut(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.rectCut";
let rect = match p.get("rect") {
Some(v) => {
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let [x, y, w, h] = a[..] else { return Err(bad(C, "rect must be [x, y, width, height]")) };
Rect::new(x, y, x + w, y + h).abs()
}
None => Rect::from_points(point_req(p, "from", C)?, point_req(p, "to", C)?),
};
let finite = [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
if !finite || rect.width() <= 1e-9 || rect.height() <= 1e-9 {
return Err(bad(C, "the rectangle must have a width and a height"));
}
run_cut(s, C, "Rectangle Cut", &|pd, rule| crop(pd, rule, rect))
}
/// `pd` cropped to `rect`: `None` when it lies inside, no pieces when it lies outside.
fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option<Vec<PathData>> {
let b = pd.bounds()?;
if rect.inflate(1e-9, 1e-9).contains_rect(b) {
return None;
}
if pd.is_closed() {
let r =
intersect(pd, rule, &polygon(&[rect.origin(), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)]));
return Some(if r.is_empty() { vec![] } else { vec![r] });
}
let inside = rect.inflate(1e-9, 1e-9);
let pieces = erase_outline(pd, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?;
Some(if pd.subpaths.len() == 1 || pieces.is_empty() {
pieces
} else {
vec![PathData::new(pieces.into_iter().flat_map(|p| p.subpaths).collect())]
})
}
// ---------- Mirror & Cut ----------
fn mirror_cut(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.mirrorCut";
let kind = str_param(p, "axis").unwrap_or("vertical");
let at = || -> Result<Point> {
match p.get("from") {
Some(_) => point_req(p, "from", C),
None => {
let roots = selected_roots(s)?;
let b = s.doc()?.doc.bounds_of(&roots, false).ok_or_else(|| bad(C, "the selection has no bounds"))?;
Ok(b.center())
}
}
};
let axis = match kind {
"vertical" => {
let a = at()?;
Axis { p: a, d: Vec2::new(0.0, 1.0) }
}
"horizontal" => {
let a = at()?;
Axis { p: a, d: Vec2::new(1.0, 0.0) }
}
"free" => Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?,
other => return Err(bad(C, format!("unknown axis `{other}` (vertical|horizontal|free)"))),
};
let sign = keep_sign(&axis, str_param(p, "keep")).map_err(|e| bad(C, e))?;
run_cut(s, C, "Mirror & Cut", &|pd, rule| Some(mirror(pd, rule, &axis, sign)))
}
/// The sign of [`Axis::side`] on the side `keep` names (default: left, or top when the axis runs
/// more across than down).
fn keep_sign(axis: &Axis, keep: Option<&str>) -> std::result::Result<f64, String> {
let keep = keep.unwrap_or(if axis.d.x.abs() > axis.d.y.abs() { "top" } else { "left" });
let toward = match keep {
"left" => Vec2::new(-1.0, 0.0),
"right" => Vec2::new(1.0, 0.0),
"top" => Vec2::new(0.0, -1.0),
"bottom" => Vec2::new(0.0, 1.0),
other => return Err(format!("unknown side `{other}` (left|right|top|bottom)")),
};
let s = axis.d.cross(toward);
if s.abs() < 1e-6 {
return Err(format!("`{keep}` is not a side of this axis"));
}
Ok(s.signum())
}
/// The kept half of `pd` and its reflection, joined (no pieces when nothing is on the kept side).
fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec<PathData> {
let m = axis.reflection();
if pd.is_closed() {
let kept = intersect(pd, rule, &axis.half_plane(sign, reach(pd)));
if kept.is_empty() {
return vec![];
}
let joined = po::boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union);
return if joined.is_empty() { vec![] } else { vec![joined] };
}
let kept = match erase_outline(pd, &|q| axis.side(q) * sign < 0.0) {
Some(pieces) => pieces.into_iter().flat_map(|p| p.subpaths).collect(),
None => pd.subpaths.clone(),
};
let subs: Vec<SubPath> = kept.iter().flat_map(|sp| mirror_open(sp, axis, m)).collect();
if subs.is_empty() { vec![] } else { vec![PathData::new(subs)] }
}
/// An open run and its reflection, joined where its ends touch the axis (closed when both do);
/// the two apart when neither does.
fn mirror_open(sp: &SubPath, axis: &Axis, m: Affine) -> Vec<SubPath> {
let on = |a: Option<&Anchor>| a.is_some_and(|a| axis.side(a.p).abs() <= AXIS_TOL);
let (start_on, end_on) = (on(sp.anchors.first()), on(sp.anchors.last()));
if sp.closed || sp.anchors.len() < 2 || !(start_on || end_on) {
return vec![sp.clone(), SubPath::new(sp.anchors.iter().map(|a| a.transform(m)).collect(), sp.closed)];
}
// The run ends on the axis; the reflection comes back from there.
let mut run = sp.clone();
if !end_on {
run.reverse();
}
let Some((last, body)) = run.anchors.split_last() else { return vec![] };
let q = axis.project(last.p);
let mut anchors = body.to_vec();
anchors.push(Anchor::with_handles(q, last.h_in, m * last.h_in));
anchors.extend(body.iter().rev().map(|a| Anchor::with_handles(m * a.p, m * a.h_out, m * a.h_in)));
let closed = start_on && end_on && anchors.len() > 2;
if closed && let (Some(first), Some(back)) = (anchors.first().copied(), anchors.pop()) {
// The start and its reflection meet on the axis: one closed shape.
if let Some(f) = anchors.first_mut() {
*f = Anchor::with_handles(axis.project(first.p), back.h_in, first.h_out);
}
}
vec![SubPath::new(anchors, closed)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reflection_maps_across_the_axis() {
let a = Axis::through(Point::new(0.0, 0.0), Point::new(10.0, 10.0)).unwrap();
let q = a.reflection() * Point::new(5.0, 0.0);
assert!((q.x - 0.0).abs() < 1e-9 && (q.y - 5.0).abs() < 1e-9, "{q:?}");
let v = Axis { p: Point::new(100.0, 0.0), d: Vec2::new(0.0, 1.0) };
let q = v.reflection() * Point::new(90.0, 7.0);
assert!((q.x - 110.0).abs() < 1e-9 && (q.y - 7.0).abs() < 1e-9);
}
#[test]
fn keep_names_a_side_of_the_axis() {
let v = Axis { p: Point::ZERO, d: Vec2::new(0.0, 1.0) };
let left = keep_sign(&v, Some("left")).unwrap();
assert!(v.side(Point::new(-5.0, 0.0)) * left > 0.0);
assert_eq!(keep_sign(&v, None).unwrap(), left);
assert!(keep_sign(&v, Some("top")).is_err());
let h = Axis { p: Point::ZERO, d: Vec2::new(1.0, 0.0) };
let top = keep_sign(&h, None).unwrap();
assert!(h.side(Point::new(0.0, -5.0)) * top > 0.0);
}
#[test]
fn open_half_mirrors_into_a_closed_shape() {
// An arch from the axis and back to it: its reflection closes it.
let axis = Axis { p: Point::new(50.0, 0.0), d: Vec2::new(0.0, 1.0) };
let sp = SubPath::new(
vec![Anchor::corner(Point::new(50.0, 0.0)), Anchor::corner(Point::new(10.0, 50.0)), Anchor::corner(Point::new(50.0, 100.0))],
false,
);
let out = mirror_open(&sp, &axis, axis.reflection());
assert_eq!(out.len(), 1);
assert!(out[0].closed);
assert_eq!(out[0].anchors.len(), 4);
assert!(out[0].anchors.iter().any(|a| (a.p.x - 90.0).abs() < 1e-9));
// One end on the axis: one open path through it.
let sp = SubPath::new(vec![Anchor::corner(Point::new(10.0, 0.0)), Anchor::corner(Point::new(50.0, 40.0))], false);
let out = mirror_open(&sp, &axis, axis.reflection());
assert_eq!((out.len(), out[0].closed, out[0].anchors.len()), (1, false, 3));
}
}
+36 -10
View File
@@ -1,6 +1,8 @@
//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
//! blob brush, smooth, path eraser, join).
use std::sync::Arc;
use kurbo::{ParamCurve, ParamCurveNearest};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
@@ -224,19 +226,43 @@ fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec<NodeId> {
out
}
/// A copy of path node `id` (fresh id, no live shape) with geometry `pd`.
/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids.
fn compound_members(d: &mut Document, pd: PathData) -> Vec<Arc<Node>> {
pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect()
}
/// Give path or compound path `id` the geometry `pd` (dropping a live shape; a compound's members
/// are rebuilt, one per subpath).
pub(super) fn set_geometry(d: &mut Document, id: NodeId, pd: PathData) -> Result<()> {
if matches!(d.node(id).map(|n| &n.kind), Some(NodeKind::Compound { .. })) {
let members = compound_members(d, pd);
if let Some(NodeKind::Compound { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
*children = members;
}
return Ok(());
}
*path_mut(d, id)? = pd;
Ok(())
}
/// A copy of path or compound path node `id` (fresh ids, no live shape) with geometry `pd`.
fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result<Node> {
let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone();
n.id = d.alloc_id();
if let NodeKind::Path { path, live, .. } = &mut n.kind {
*path = pd;
*live = None;
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
*path = pd;
*live = None;
}
NodeKind::Compound { children, .. } => *children = compound_members(d, pd),
_ => {}
}
Ok(n)
}
/// Replace path `id` with `pieces` (first keeps the id, the rest go right above it). Returns ids.
fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above
/// it; none removes it). Returns ids.
pub(super) fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
let mut it = pieces.into_iter();
let Some(first) = it.next() else {
d.remove(id)?;
@@ -248,16 +274,16 @@ fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> R
let n = sibling_with(d, id, pd)?;
ids.push(d.insert(parent, idx + 1 + k, n)?);
}
*path_mut(d, id)? = first;
set_geometry(d, id, first)?;
Ok(ids)
}
fn ids_json(ids: &[NodeId]) -> Value {
pub(super) fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// Split a filled path into connected pieces (each outer contour with its holes).
fn components(pd: &PathData) -> Vec<PathData> {
pub(super) fn components(pd: &PathData) -> Vec<PathData> {
let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect();
if subs.len() <= 1 {
return if pd.is_empty() { vec![] } else { vec![pd.clone()] };
@@ -382,7 +408,7 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPa
/// Erase from an outline path (open or closed) where `remove` holds. Returns the new geometry per
/// output object (one object per piece when the path had a single subpath), or None if unchanged.
fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
pub(super) fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
let mut changed = false;
let mut subs = vec![];
for sp in &pd.subpaths {
+4 -2
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"file.new",
"New…",
["File"],
Some("Cmd+N"),
None,
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
always,
super::newdoc::file_new
@@ -117,7 +117,9 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
s.cancel_interaction()?;
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
s.commit_interaction()?;
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
+22 -5
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
has_doc,
apply
),
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effects",
[],
None,
"{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}], applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
"{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin.<plug-in id>, menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
always,
list
),
@@ -146,6 +146,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let catalog: Vec<Value> = effects::effect_catalog()
.into_iter()
.chain(effects::plugin_effects())
.map(|e| {
json!({"id": e.id, "label": e.label, "menu": e.menu, "params": e.params, "defaults": e.defaults, "raster": e.raster,
"lengths": {"always": e.lengths.always, "absolute": e.lengths.absolute}})
@@ -321,11 +322,25 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects:
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
let Some(n) = d.node(id).cloned() else { return };
let container = is_container(&n);
let Some(mut n) = d.node(id).cloned() else { return };
if !expandable(&n) {
return;
}
// Colour adjustments: the colours inside become the adjusted ones (an embedded image a
// recoloured copy), then the rest of the appearance expands.
if effects::has_adjustment(&n)
&& let Some(m) = effects::adjust_in_document(d, &n)
&& let Some(slot) = d.node_mut(id)
{
*slot = m.clone();
out.push(id);
n = m;
if !expandable(&n) {
return;
}
}
// (A symbol instance has become its art, a group.)
let container = is_container(&n);
// Crop Marks: a group of the object and its marks, then the object expands on its own.
if let Some(mut m) = effects::crop_marks_art(&n) {
let mut seen = std::collections::HashSet::from([id]);
@@ -385,7 +400,9 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush
&& let Some(slot) = d.node_mut(id)
{
*slot = m;
out.push(id);
if out.last() != Some(&id) {
out.push(id);
}
}
if container {
let children: Vec<NodeId> = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
+7 -11
View File
@@ -135,11 +135,7 @@ fn choice<T>(field: &str, v: Option<String>, read: impl Fn(&str) -> Option<T>, d
/// The flattener options `p` asks for (`flattenerPreset` and `flattener` over it), built-in
/// presets and `saved` ones; discarded overprints are dropped from the flattened art too.
fn flattener(p: &Params, saved: &[super::super::FlattenerPreset]) -> Result<FlattenOptions> {
let mut q = json!({ "options": p.flattener.clone().unwrap_or(Value::Null) });
if let Some(name) = &p.flattener_preset {
q["preset"] = json!(name);
}
FlattenOptions::from_params_with(&q, saved).map_err(|e| bad(C, format!("EPS flattener: {e}")))
FlattenOptions::for_export(p.flattener_preset.as_deref(), p.flattener.as_ref(), saved).map_err(|e| bad(C, format!("EPS flattener: {e}")))
}
fn params(p: &Value) -> Result<Params> {
@@ -176,7 +172,7 @@ fn parse(p: &Value) -> Result<Settings> {
/// `saved` it names written out, as the encoder needs it.
pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::result::Result<Value, String> {
let run = || -> Result<Value> {
let q = expanded(p, saved)?.unwrap_or_else(|| p.clone());
let q = expanded(C, p, saved)?.unwrap_or_else(|| p.clone());
parse(&q)?;
Ok(q)
};
@@ -185,10 +181,10 @@ pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::resul
/// `p` with a saved flattener preset it names written out as `flattener` options, for the
/// encoder, which knows only the built-in presets; `None` when `p` names none.
fn expanded(p: &Value, saved: &[super::super::FlattenerPreset]) -> Result<Option<Value>> {
fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Result<Option<Value>> {
match str_param(p, "flattenerPreset") {
Some(name) if FlattenOptions::preset(name).is_none() => {
let o = flattener(&params(p)?, saved)?;
Some(name) if !name.trim().is_empty() && FlattenOptions::preset(name).is_none() => {
let o = FlattenOptions::for_export(Some(name), p.get("flattener"), saved).map_err(|e| bad(cmd, format!("flattener: {e}")))?;
let mut q = p.clone();
q["flattener"] = serde_json::to_value(o).map_err(|e| EngineError::Other(e.to_string()))?;
if let Some(m) = q.as_object_mut() {
@@ -299,8 +295,8 @@ fn export_eps(s: &mut Session, p: &Value) -> Result<Value> {
}
/// `p` with a saved flattener preset of the session's written out (see [`expanded`]).
pub(super) fn with_flattener<'a>(s: &Session, p: Cow<'a, Value>) -> Result<Cow<'a, Value>> {
Ok(match expanded(&p, &s.prefs.flattener_presets)? {
pub(super) fn with_flattener<'a>(s: &Session, cmd: &str, p: Cow<'a, Value>) -> Result<Cow<'a, Value>> {
Ok(match expanded(cmd, &p, &s.prefs.flattener_presets)? {
Some(q) => Cow::Owned(q),
None => p,
})
+1 -2
View File
@@ -104,8 +104,7 @@ pub(super) fn without_artboards(p: &Value) -> Value {
/// `p` with the saved presets it names (PDF, flattener) written out as options, for the encoders,
/// which know only the built-in ones.
fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Value>> {
let pdf = super::pdf::expand_preset(s, cmd, p)?;
super::eps::with_flattener(s, pdf)
super::pdf::expand_preset(s, cmd, p)
}
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
+12
View File
@@ -623,6 +623,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
/// File → Place dialog filters: "All placeable files", then one per readable format, then text.
@@ -901,8 +902,19 @@ mod tests_pdfoutput;
#[cfg(test)]
mod tests_tiffbmp;
#[cfg(test)]
mod tests_pdfx;
#[cfg(test)]
mod tests_psd;
#[cfg(test)]
mod tests_epsimport;
#[cfg(test)]
mod tests_pdflayers;
#[cfg(test)]
mod tests_pdfwebview;
#[cfg(test)]
mod tests_pdf13;
+144 -21
View File
@@ -3,12 +3,14 @@
//! over them, the warnings that come back, and `document.pdfSettings` (the dialog's Summary).
use std::borrow::Cow;
use std::sync::Arc;
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard, THUMBNAIL_SIZE, Thumbnail};
use super::super::flatten::{FlattenOptions, flatten_document, see_through_images};
use super::super::*;
use super::{ArtboardPick, FormatOption, write_or_return};
use crate::EngineError;
@@ -30,8 +32,18 @@ pub(super) const OPTIONS: &[FormatOption] = &[
default: "\"VectorCraft Default\"",
description: "the PDF preset the other options apply over (built-in or saved: pdf.preset.list)",
},
FormatOption { name: "standard", ty: "string", default: "\"none\"", description: "none | pdfA2b (PDF/X is not supported yet)" },
FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" },
FormatOption {
name: "standard",
ty: "string",
default: "\"none\"",
description: "none | pdfA2b | pdfX1a | pdfX3 | pdfX4 (choosing one sets the compatibility it allows; see document.exportPdf)",
},
FormatOption {
name: "compatibility",
ty: "string",
default: "\"1.7\"",
description: "PDF version: 1.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0",
},
FormatOption {
name: "preserveEditing",
ty: "boolean",
@@ -62,7 +74,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[
name: "security",
ty: "object",
default: "null",
description: "passwords and permissions (the file is encrypted: RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)",
description: "passwords and permissions (the file is encrypted: RC4 40-bit at 1.3, RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)",
},
FormatOption {
name: "includeNonPrinting",
@@ -70,6 +82,24 @@ pub(super) const OPTIONS: &[FormatOption] = &[
default: "false",
description: "keep the layers whose Print option is off (left out otherwise, unless createLayers)",
},
FormatOption {
name: "createLayers",
ty: "boolean",
default: "false",
description: "top-level layers as PDF layers (optional content groups with their visibility, print state and lock; PDF 1.5 or later)",
},
FormatOption {
name: "flattenerPreset",
ty: "string",
default: "\"\"",
description: "the flattener preset PDF 1.3 files (PDF/X-1a and PDF/X-3 too) flatten transparency with: empty (High Resolution), high, medium, low or a saved one",
},
FormatOption {
name: "flattener",
ty: "object",
default: "null",
description: "flattener options over flattenerPreset (as object.flattenTransparency)",
},
];
pub fn specs() -> Vec<CommandSpec> {
@@ -79,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export PDF",
[],
None,
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, PDF/A-2b at 2.0 and a password with a PDF/A or PDF/X standard are refused. document.export {format: pdf} takes the same options",
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each top-level layer but template layers as a PDF layer, an optional content group: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet c Line truncated
has_doc,
export_pdf
),
@@ -145,9 +175,10 @@ pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
/// The settings `p` asks for: its preset (built-in or one of `saved`; default
/// [`DEFAULT_PRESET`]) with `p`'s options applied over it. Choosing another standard turns
/// Preserve Editing off unless `p` asks for it. Checked as a preset is
/// ([`PdfSettings::check_values`]): a standard the writer doesn't produce yet passes. Keys that
/// aren't PDF options (path, format…) are ignored.
/// Preserve Editing off, the layers off where it has none, and sets the latest compatibility it
/// allows, unless `p` asks for those. Checked as a preset is
/// ([`PdfSettings::check_values`]): profiles that aren't there pass. Keys that aren't PDF options
/// (path, format…) are ignored.
pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings> {
let base = preset_settings(cmd, p, saved)?;
if !p.is_object() {
@@ -156,8 +187,17 @@ pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings>
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
merge(&mut v, p);
let mut s = PdfSettings::deserialize(&v).map_err(|e| bad(cmd, format!("PDF options: {e}")))?;
if s.standard != Standard::None && s.standard != base.standard && p.get("preserveEditing").is_none_or(Value::is_null) {
s.preserve_editing = false;
let unasked = |key: &str| p.get(key).is_none_or(Value::is_null);
if s.standard != base.standard {
if s.standard != Standard::None && unasked("preserveEditing") {
s.preserve_editing = false;
}
if !s.standard.allows_layers() && unasked("createLayers") {
s.create_layers = false;
}
if !s.standard.allows(s.compatibility) && unasked("compatibility") {
s.compatibility = s.standard.version();
}
}
s.check_values().map_err(|e| pdf_error(cmd, e))?;
Ok(s)
@@ -175,10 +215,11 @@ pub fn settings(cmd: &str, p: &Value) -> Result<PdfSettings> {
settings_with(cmd, p, &[])
}
/// `p` with a saved preset it names written out as options, for the encoders, which know only the
/// built-in presets. Params naming no preset, or a built-in one, come back as they are.
/// `p` with the saved presets it names (a PDF preset, a flattener preset: see
/// [`super::eps::with_flattener`]) written out as options, for the encoders, which know only the
/// built-in presets. Params naming none come back as they are.
pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Value>> {
match str_param(p, "preset") {
let expanded = match str_param(p, "preset") {
Some(name) if vectorcraft_pdf::builtin_preset(name).is_none() => {
let set = settings_with(cmd, p, &s.prefs.pdf_presets)?;
let mut q = p.clone();
@@ -186,10 +227,11 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
if let Some(o) = q.as_object_mut() {
o.remove("preset");
}
Ok(Cow::Owned(q))
Cow::Owned(q)
}
_ => Ok(Cow::Borrowed(p)),
}
_ => Cow::Borrowed(p),
};
super::eps::with_flattener(s, cmd, expanded)
}
/// The full export options for `doc`: settings plus the artboards `p` picks (default all).
@@ -214,6 +256,9 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Stri
/// native document with its save options, whose pages may be left blank).
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
let mut opts = options(cmd, doc, p)?;
if opts.settings.thumbnails {
opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
}
let mut warnings = vec![];
if opts.settings.preserve_editing {
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
@@ -223,14 +268,92 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
}
}
let r = super::super::rasterfx::export_pdf_with_report(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
warnings.extend(r.warnings);
if opts.settings.advanced.overprint == Overprint::Preserve && doc.layers.iter().any(|l| l.has_overprint()) {
warnings.push("overprinting objects are written without overprint: overprint is not written to PDF yet".into());
let set = &opts.settings;
let flat = flatten_for(cmd, doc, set, p)?;
if flat.is_some() {
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"));
}
let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?;
warnings.extend(r.warnings);
Ok((r.bytes, warnings))
}
/// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty.
pub const DEFAULT_FLATTENER: &str = "High Resolution";
/// `doc` with its transparency flattened for a PDF 1.3 file (asked for, or PDF/X-1a and PDF/X-3,
/// whose files have none), with `flattenerPreset` (default High Resolution) and `p`'s `flattener`
/// options over it; images with see-through pixels count as transparency, rasterized areas are
/// clipped to their regions, and discarded overprints are dropped. `None` when nothing changes (or
/// the file keeps transparency).
fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result<Option<Document>> {
if !set.pdf13() {
return Ok(None);
}
let name = Some(set.flattener_preset.trim()).filter(|n| !n.is_empty()).unwrap_or(DEFAULT_FLATTENER);
let mut o = FlattenOptions::for_export(Some(name), p.get("flattener"), &[]).map_err(|e| bad(cmd, format!("PDF flattener: {e}")))?;
o.preserve_overprints &= set.advanced.overprint == Overprint::Preserve;
o.no_transparency = Some(see_through_images(doc));
// Without soft masks a rasterized area can't be a rectangle with see-through pixels around
// its art: it is clipped to its regions (opaque inside them).
o.clip_complex_regions = true;
flatten_document(doc, &o)
}
/// The pages `opts` export of `doc` drawn on white, [`THUMBNAIL_SIZE`] pixels on their long side:
/// the thumbnails Embed Page Thumbnails embeds. Layers the pages leave out (non-printing ones,
/// unless asked for) are left out of them too.
fn thumbnails(doc: &Document, opts: &PdfOptions) -> std::result::Result<Vec<Thumbnail>, PdfError> {
let areas = vectorcraft_pdf::page_areas(doc, opts)?;
let set = &opts.settings;
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
let shown = printed.map(|layers| {
let mut d = doc.clone();
d.layers = layers;
d
});
let doc = shown.as_ref().unwrap_or(doc);
let mut r = vectorcraft_render::Renderer::new();
Ok(areas
.into_iter()
.map(|a| {
let img = r.render_region(doc, a, f64::from(THUMBNAIL_SIZE) / a.width().max(a.height()).max(1.0), true);
// Drawn on white: every pixel is opaque.
let rgb = img.pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
Thumbnail { width: img.width, height: img.height, rgb }
})
.collect())
}
/// `layers` with the non-printing layers among them (at any depth) hidden; `None` when there are
/// none.
fn without_non_printing(layers: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
let mut out: Option<Vec<Arc<Node>>> = None;
for (i, l) in layers.iter().enumerate() {
let NodeKind::Layer { printable, children, .. } = &l.kind else { continue };
let new = if !printable {
l.visible.then(|| {
let mut n = (**l).clone();
n.visible = false;
Arc::new(n)
})
} else {
without_non_printing(children).map(|c| {
let mut n = (**l).clone();
if let NodeKind::Layer { children, .. } = &mut n.kind {
*children = c;
}
Arc::new(n)
})
};
if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| layers.to_vec()).get_mut(i)?, new))) {
*slot.0 = slot.1;
}
}
out
}
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let expanded = expand_preset(s, C, p)?;
let p = &*expanded;
+2 -1
View File
@@ -146,7 +146,8 @@ fn open_exts_cover_every_readable_format() {
}
let filters: Vec<_> = open_filters().collect();
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(filters.len(), 5 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets");
assert_eq!(filters.len(), 6 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets, plug-ins");
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
}
#[test]
+10 -6
View File
@@ -60,13 +60,15 @@ fn export_pdf_writes_the_compatibility_header_and_the_range() {
fn bad_options_are_refused() {
let mut s = session(2);
for p in [
json!({"compatibility": "1.3"}),
json!({"compatibility": "1.2"}),
json!({"compatibility": "1.3", "standard": "pdfA2b"}),
json!({"compatibility": "1.3", "flattenerPreset": "Nope"}),
json!({"compatibility": 1.5}),
json!({"range": "1-3"}),
json!({"range": "0"}),
json!({"artboards": []}),
json!({"preset": "Nope"}),
json!({"standard": "pdfX4"}),
json!({"standard": "pdfX4", "compatibility": "1.7"}),
json!({"standard": "pdfA2b", "compatibility": "2.0"}),
json!({"compression": {"color": {"ppi": 0}}}),
json!({"compression": "zip"}),
@@ -105,9 +107,11 @@ fn pdf_a_refuses_pdf_2() {
#[test]
fn options_not_applied_yet_and_document_features_warn() {
let mut s = session(1);
let w = warnings(&export(&mut s, json!({"thumbnails": true, "fastWebView": true, "marks": {"trim": true}})));
assert_eq!(w.len(), 2, "{w:?}");
assert!(w.iter().any(|w| w.contains("thumbnails")) && w.iter().any(|w| w.contains("fast web view")), "printer's marks are drawn: no warning");
// Printer's marks are drawn, thumbnails embedded and the file linearised: nothing to report.
let v = export(&mut s, json!({"thumbnails": true, "fastWebView": true, "marks": {"trim": true}}));
assert_eq!(warnings(&v), Vec::<String>::new());
let text = String::from_utf8_lossy(&b64(&v)).into_owned();
assert!(text.contains("/Thumb ") && text.contains("/Linearized 1"));
// A pattern stroke is written as its tiles clipped to the stroke: nothing to report.
let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [tile]})).unwrap();
@@ -156,7 +160,7 @@ fn pdf_settings_summarise_changes_and_warnings() {
let changed: Vec<&str> = v["changed"].as_array().unwrap().iter().map(|c| c["option"].as_str().unwrap()).collect();
assert_eq!(changed, ["compatibility", "compression.compressText", "createLayers", "security.copy"], "key order");
assert_eq!(v["changed"][0]["value"], "1.5");
assert_eq!(v["warnings"].as_array().unwrap().len(), 2, "layers and permissions: {}", v["warnings"]);
assert_eq!(v["warnings"].as_array().unwrap().len(), 1, "permissions (PDF layers are written at 1.5): {}", v["warnings"]);
assert!(!v.to_string().contains("Password"), "passwords never come back");
assert!(s.execute("document.pdfSettings", &json!({"compatibility": "9"})).is_err());
// With the document: the in-memory export adds the document's own warnings.
+155
View File
@@ -0,0 +1,155 @@
//! `document.exportPdf {compatibility: "1.3", flattenerPreset?, flattener?}`: a PDF 1.3 file has
//! no transparency (no soft masks, blend modes, constant opacity or transparency groups): it is
//! flattened with the chosen preset, and reads back looking like the canvas.
use serde_json::{Value, json};
use vectorcraft_geom::Rect;
use vectorcraft_render::{Rendered, Renderer};
use super::*;
/// A document with overlapping transparency: a half-opaque rectangle, a Multiply ellipse, a
/// gradient fading out, a drop shadow and a PNG with see-through pixels, in an isolated page.
fn session() -> Session {
let mut s = Session::new();
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
run(&mut s, "file.new", json!({"width": 200, "height": 160}));
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 90, "height": 70}));
run(&mut s, "paint.setFill", json!({"color": "#2060e0"}));
run(&mut s, "shape.rectangle", json!({"x": 50, "y": 40, "width": 90, "height": 70}));
run(&mut s, "paint.setFill", json!({"color": "#f02010"}));
run(&mut s, "transparency.set", json!({"opacity": 50}));
run(&mut s, "shape.ellipse", json!({"x": 90, "y": 20, "width": 80, "height": 80}));
run(&mut s, "paint.setFill", json!({"color": "#20c040"}));
run(&mut s, "transparency.set", json!({"blendMode": "multiply"}));
let shadowed = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 100, "width": 50, "height": 40}))["id"].clone();
run(&mut s, "paint.setFill", json!({"color": "#e0c020"}));
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [shadowed]}));
let mut png = vec![];
image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
run(&mut s, "file.place", json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 120]}));
s
}
fn export(s: &mut Session, p: Value) -> (Vec<u8>, Vec<String>) {
let v = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
(bytes, v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect())
}
/// The transparency `pdf` has, in its dictionaries.
fn transparency(pdf: &[u8]) -> Vec<&'static str> {
let text = String::from_utf8_lossy(pdf);
let mut found = vec![];
for (what, there) in [
("soft masks", text.contains("/SMask") && !text.contains("/SMask/None")),
("blend modes", text.contains("/BM") && !text.contains("/BM/Normal")),
("transparency groups", text.contains("/S/Transparency")),
("opacity", text.contains("/ca 0.") || text.contains("/CA 0.")),
] {
if there {
found.push(what);
}
}
found
}
fn render(d: &vectorcraft_doc::Document) -> Rendered {
Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 160.0), 1.0, true)
}
/// Mean difference of the colour channels of two renders, as a share of full scale.
fn mean_diff(a: &Rendered, b: &Rendered) -> f64 {
let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum();
sum as f64 / a.pixels.len() as f64 / 255.0
}
#[test]
fn pdf_1_3_flattens_all_transparency_and_looks_the_same() {
let mut s = session();
let before = s.doc().unwrap().doc.clone();
// The same document at PDF 1.4 has transparency.
let (pdf, _) = export(&mut s, json!({"compatibility": "1.4", "compression": {"compressText": false}, "preserveEditing": false}));
assert!(transparency(&pdf).len() >= 3, "{:?}", transparency(&pdf));
let (pdf, warnings) = export(&mut s, json!({"compatibility": "1.3", "compression": {"compressText": false}, "preserveEditing": false}));
assert!(pdf.starts_with(b"%PDF-1.3"));
assert!(String::from_utf8_lossy(&pdf).contains("<pdf:PDFVersion>1.3<"), "the metadata says 1.3 too");
assert_eq!(transparency(&pdf), Vec::<&str>::new());
assert!(warnings.iter().any(|w| w.contains("flattened") && w.contains("PDF 1.3")), "{warnings:?}");
// It reads back looking like the canvas.
let canvas = render(&before);
let diff = mean_diff(&canvas, &render(&vectorcraft_pdf::import(&pdf).unwrap()));
assert!(diff < 0.02, "read back {:.2}% off", diff * 100.0);
// The document itself is untouched.
assert_eq!(s.doc().unwrap().doc, before);
// The writer refuses a document that isn't flat; a flat one with isolated page blending gets
// no page group.
let opts = vectorcraft_pdf::PdfOptions { settings: serde_json::from_value(json!({"compatibility": "1.3"})).unwrap(), ..Default::default() };
let e = vectorcraft_pdf::export(&before, &opts).unwrap_err();
assert!(matches!(&e, vectorcraft_pdf::PdfError::Unsupported(m) if m.contains("PDF 1.3")), "{e}");
let mut flat = vectorcraft_doc::Document::new(100.0, 100.0);
flat.page_isolate = true;
let l = flat.default_layer().unwrap();
let id = flat.alloc_id();
let rect = vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0));
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
flat.insert(Some(l), 0, vectorcraft_doc::Node::path(id, rect, vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0)))
.unwrap();
assert!(transparency(&vectorcraft_pdf::export(&flat, &opts).unwrap()).is_empty());
}
#[test]
fn the_flattener_preset_and_options_apply() {
let mut s = session();
let images = |pdf: &[u8]| String::from_utf8_lossy(pdf).matches("/Subtype/Image").count();
let p = |extra: Value| {
let mut p = json!({"compatibility": "1.3", "preserveEditing": false});
super::pdf::merge(&mut p, &extra);
p
};
let (high, _) = export(&mut s, p(json!({})));
let (default, _) = export(&mut s, p(json!({"flattenerPreset": "High Resolution"})));
assert_eq!(high.len(), default.len(), "the default is High Resolution");
let (low, _) = export(&mut s, p(json!({"flattenerPreset": "low"})));
assert!(low.len() < high.len(), "lower resolution rasters");
// All raster: one image a flattened group at most, and still flat.
let (raster, _) = export(&mut s, p(json!({"flattener": {"balance": 0}})));
assert!(images(&raster) >= 1 && transparency(&raster).is_empty());
// A saved preset by name, through every PDF path; an unknown one is refused.
s.execute("flattener.presets.save", &json!({"name": "All Raster", "preset": "high", "balance": 0})).unwrap();
let (saved, _) = export(&mut s, p(json!({"flattenerPreset": "All Raster"})));
assert_eq!(saved.len(), raster.len());
let v = s
.execute("document.export", &json!({"format": "pdf", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false}))
.unwrap();
assert_eq!(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap().len(), raster.len());
s.execute("pdf.preset.save", &json!({"name": "Old Press", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false}))
.unwrap();
assert_eq!(export(&mut s, json!({"preset": "Old Press"})).0.len(), raster.len(), "a PDF preset keeps its flattener preset");
assert!(s.execute("document.exportPdf", &p(json!({"flattenerPreset": "Nope"}))).is_err());
// Other versions keep their transparency, whatever the preset.
let (kept, warnings) = export(&mut s, json!({"flattenerPreset": "low", "compression": {"compressText": false}}));
assert!(!transparency(&kept).is_empty() && !warnings.iter().any(|w| w.contains("flattened")));
}
#[test]
fn pdf_1_3_files_are_encrypted_with_40_bit_rc4_and_can_be_linearised() {
let mut s = session();
let (pdf, warnings) = export(
&mut s,
json!({"compatibility": "1.3", "fastWebView": true, "thumbnails": true, "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": false}}),
);
assert!(pdf.starts_with(b"%PDF-1.3") && warnings.iter().all(|w| !w.contains("metadata")), "{warnings:?}");
let text = String::from_utf8_lossy(&pdf);
assert!(text.contains("/V 1/R 2/Length 40") && text.contains("/Linearized 1") && text.contains("/Thumb "));
for password in ["open", "owner"] {
s.execute("document.close", &json!({"force": true})).ok();
s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": password})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "{password}");
}
assert!(
s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": "nope"})).is_err()
);
}
@@ -0,0 +1,61 @@
//! `document.exportPdf {createLayers}` writes PDF layers that reopen as layers, and
//! `advanced.overprint` keeps overprinting in the file or drops it.
use serde_json::{Value, json};
use super::*;
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
fn warnings(v: &Value) -> Vec<String> {
v["warnings"].as_array().expect("warnings").iter().map(|w| w.as_str().unwrap().to_string()).collect()
}
/// A document with a rectangle on "Layer 1" and one on a hidden, non-printing layer "Notes".
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let notes = s.execute("layer.new", &json!({"name": "Notes"})).unwrap()["id"].as_u64().unwrap();
s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("layer.setProps", &json!({"id": notes, "visible": false, "printable": false})).unwrap();
s
}
#[test]
fn export_pdf_create_layers_writes_layers_that_reopen_as_layers() {
let mut s = session();
let v = s.execute("document.exportPdf", &json!({"createLayers": true, "preserveEditing": false})).unwrap();
assert!(warnings(&v).is_empty(), "{:?}", warnings(&v));
let back = vectorcraft_pdf::import(&b64(&v)).unwrap();
let layers: Vec<(String, bool, bool)> = back
.layers
.iter()
.map(|l| (l.name.clone().unwrap_or_default(), l.visible, matches!(l.kind, vectorcraft_doc::NodeKind::Layer { printable: true, .. })))
.collect();
assert_eq!(layers, [("Layer 1".to_string(), true, true), ("Notes".to_string(), false, false)]);
// The default preset carries the editing data too.
let v = s.execute("document.exportPdf", &json!({"createLayers": true})).unwrap();
assert!(warnings(&v).is_empty(), "{:?}", warnings(&v));
assert!(vectorcraft_pdf::editing(&b64(&v)).is_some_and(|e| e.intact));
// At PDF 1.4 there are no PDF layers.
let v = s.execute("document.exportPdf", &json!({"createLayers": true, "compatibility": "1.4"})).unwrap();
assert!(warnings(&v).iter().any(|w| w.contains("PDF 1.5")), "{:?}", warnings(&v));
}
#[test]
fn export_pdf_overprint_is_preserved_or_discarded() {
let mut s = session();
s.execute("select.all", &json!({})).unwrap();
// The rectangles' strokes are black (their white fills would knock out: discardWhiteOverprint).
s.execute("object.setOverprint", &json!({"stroke": true})).unwrap();
let overprints = |s: &mut Session, p: Value| {
let v = s.execute("document.exportPdf", &p).unwrap();
assert!(warnings(&v).iter().all(|w| !w.contains("overprint")), "{:?}", warnings(&v));
String::from_utf8_lossy(&b64(&v)).matches("/OP true/op true/OPM 1").count()
};
assert_eq!(overprints(&mut s, json!({"compression": {"compressText": false}})), 1);
assert_eq!(overprints(&mut s, json!({"advanced": {"overprint": "discard"}})), 0);
}
@@ -0,0 +1,98 @@
//! `document.exportPdf {thumbnails, fastWebView}`: each page's thumbnail is the page drawn small
//! (its bleed and marks too, without the layers the page leaves out), and the file is linearised.
use std::io::Read;
use serde_json::{Value, json};
use super::*;
fn export(s: &mut Session, p: Value) -> (Vec<u8>, Vec<Value>) {
let v = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
(bytes, v["warnings"].as_array().unwrap().clone())
}
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from)
}
/// The page thumbnails of `pdf`, in file order: (width, height, RGB pixels).
fn thumbnails(pdf: &[u8]) -> Vec<(u32, u32, Vec<u8>)> {
let mut out = vec![];
let mut from = 0;
while let Some(at) = find(pdf, b"<</Width ", from) {
from = at + 1;
let head = String::from_utf8_lossy(&pdf[at..find(pdf, b">>", at).unwrap()]).into_owned();
if !head.contains("/ColorSpace/DeviceRGB/BitsPerComponent 8/Filter/FlateDecode/Length ") {
continue;
}
let num = |key: &str| -> usize {
let rest = &head[head.find(key).unwrap() + key.len()..];
rest[..rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len())].parse().unwrap()
};
let data = find(pdf, b"stream\n", at).unwrap() + 7;
let mut rgb = vec![];
flate2::read::ZlibDecoder::new(&pdf[data..data + num("/Length ")]).read_to_end(&mut rgb).unwrap();
out.push((num("/Width ") as u32, num("/Height ") as u32, rgb));
}
out
}
#[test]
fn thumbnails_show_the_pages() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
let second = s.doc().unwrap().doc.artboards[1].rect;
// A red first page, a green second one.
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": second.x0, "y": second.y0, "width": 200, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
// A non-printing layer covering the first page in blue.
let notes = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
s.execute("layer.setProps", &json!({"id": notes, "printable": false})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
let (pdf, warnings) = export(&mut s, json!({"thumbnails": true}));
assert_eq!(warnings, Vec::<Value>::new());
let thumbs = thumbnails(&pdf);
assert_eq!(thumbs.len(), 2, "one a page");
for ((w, h, rgb), colour) in thumbs.iter().zip([[255, 0, 0], [0, 255, 0]]) {
assert_eq!((*w, *h), (vectorcraft_pdf::THUMBNAIL_SIZE, 53), "the page's shape, 106 px long");
// The middle of the left half: the page's colour (the non-printing layer is left out).
let at = ((*h / 2 * w + w / 4) * 3) as usize;
assert_eq!(rgb[at..at + 3], colour);
}
// Non-printing layers included: the thumbnail shows them too.
let (pdf, _) = export(&mut s, json!({"thumbnails": true, "includeNonPrinting": true}));
let (w, h, rgb) = &thumbnails(&pdf)[0];
let at = ((*h / 2 * w + w / 4) * 3) as usize;
assert_eq!(rgb[at..at + 3], [0, 0, 255]);
// With the bleed the page grows, and so does its thumbnail.
let (pdf, _) = export(&mut s, json!({"thumbnails": true, "bleed": {"top": 50, "bottom": 50, "left": 0, "right": 0}}));
assert_eq!(thumbnails(&pdf).iter().map(|t| (t.0, t.1)).collect::<Vec<_>>(), [(106, 106), (106, 106)]);
// Off: none.
assert!(thumbnails(&export(&mut s, json!({})).0).is_empty());
}
#[test]
fn fast_web_view_through_every_pdf_path() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 3})).unwrap();
s.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
let linearised = |pdf: &[u8]| String::from_utf8_lossy(&pdf[..200.min(pdf.len())]).contains("/Linearized 1");
let (pdf, warnings) = export(&mut s, json!({"fastWebView": true, "thumbnails": true}));
assert!(linearised(&pdf) && warnings.is_empty(), "{warnings:?}");
// Reopened: the pages, and the document the editing data carries.
s.execute("document.close", &json!({"force": true})).ok();
let v = s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "web.pdf"})).unwrap();
assert!(v.is_object());
assert_eq!(s.doc().unwrap().doc.artboards.len(), 3);
assert!(s.doc().unwrap().doc.layers[0].children().unwrap().iter().any(|n| n.path_data().is_some()), "the ellipse is back");
// document.export takes the same options; without them the file isn't linearised.
let v = s.execute("document.export", &json!({"format": "pdf", "fastWebView": true, "range": "2"})).unwrap();
assert!(linearised(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()));
assert!(!linearised(&export(&mut s, json!({})).0));
}
@@ -0,0 +1,91 @@
//! `document.exportPdf {standard}` with the PDF/X standards: choosing one sets its version and
//! turns off what it forbids, PDF/X-1a and PDF/X-3 flatten transparency (images with see-through
//! pixels too) before writing, PDF/X-4 keeps it, and passwords are refused.
use serde_json::{Value, json};
use super::*;
/// A document with a CMYK rectangle under a half-opaque RGB one, and a PNG with see-through
/// pixels over both.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap();
s.execute("paint.setFill", &json!({"color": {"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 40, "y": 30, "width": 60, "height": 60})).unwrap();
s.execute("paint.setFill", &json!({"color": {"model": "rgb", "r": 1.0, "g": 0.2, "b": 0.0}})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
let mut png = vec![];
image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
s.execute("file.place", &json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 50]})).unwrap();
s
}
/// `document.exportPdf` with `p` (content streams readable) → (the file's text, warnings).
fn pdf(s: &mut Session, mut p: Value) -> (String, Vec<String>) {
p["compression"] = json!({"compressText": false});
let v = s.execute("document.exportPdf", &p).unwrap();
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
let warnings = v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect();
(String::from_utf8_lossy(&bytes).into_owned(), warnings)
}
#[test]
fn pdfx_1a_and_x3_flatten_transparency_and_x4_keeps_it() {
let mut s = session();
let before = s.doc().unwrap().doc.clone();
for (standard, header) in [("pdfX1a", "%PDF-1.3"), ("pdfX3", "%PDF-1.3")] {
let (text, warnings) = pdf(&mut s, json!({"standard": standard}));
assert!(text.starts_with(header), "{standard}");
assert!(warnings.iter().any(|w| w.contains("flattened")), "{standard}: {warnings:?}");
for no in ["/SMask", "/S/Transparency", "/ca 0.5"] {
assert!(!text.contains(no), "{standard}: no {no}");
}
assert!(text.contains("/S/GTS_PDFX") && text.contains("/GTS_PDFXVersion"), "{standard}");
}
// PDF/X-1a: CMYK only, the flattened colours too.
let (text, _) = pdf(&mut s, json!({"standard": "pdfX1a"}));
assert!(!text.contains("/DeviceRGB") && !text.contains("/ICCBased"));
assert!(!text.split_whitespace().any(|t| t == "rg"), "no RGB fills");
// The document itself is untouched.
assert_eq!(s.doc().unwrap().doc, before);
// PDF/X-4 keeps transparency (and the image's soft mask).
let (text, warnings) = pdf(&mut s, json!({"preset": "PDF/X-4:2010"}));
assert!(text.starts_with("%PDF-1.6"));
assert!(text.contains("/SMask") && !warnings.iter().any(|w| w.contains("flattened")), "{warnings:?}");
assert!(text.contains("<pdfxid:GTS_PDFXVersion"));
}
#[test]
fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
let mut s = session();
let settings = |s: &mut Session, p: Value| s.execute("document.pdfSettings", &p).unwrap()["settings"].clone();
// The default preset is PDF 1.7 and keeps editing data.
let v = settings(&mut s, json!({"standard": "pdfX4"}));
assert_eq!((&v["compatibility"], &v["preserveEditing"]), (&json!("1.6"), &json!(false)));
let v = settings(&mut s, json!({"standard": "pdfX1a", "createLayers": null, "preset": "Press Quality"}));
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.3"), &json!(false)));
let v = settings(&mut s, json!({"standard": "pdfX4", "compatibility": "1.5", "createLayers": true}));
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.5"), &json!(true)));
// What is asked for explicitly is checked, not changed.
for p in [
json!({"standard": "pdfX4", "compatibility": "1.7"}),
json!({"standard": "pdfX3", "compatibility": "1.6"}),
json!({"standard": "pdfX1a", "createLayers": true}),
json!({"standard": "pdfX3", "preserveEditing": true}),
json!({"standard": "pdfX4", "security": {"openPassword": "x"}}),
json!({"standard": "pdfX1a", "security": {"permissionsPassword": "x"}}),
json!({"standard": "pdfX1a", "output": {"outputIntent": vectorcraft_color::cms::SRGB}}),
json!({"standard": "pdfX4", "output": {"outputIntent": "No Such Press"}}),
] {
let e = s.execute("document.exportPdf", &p).unwrap_err();
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}");
}
// The built-in PDF/X presets export as they are.
for preset in ["PDF/X-1a:2001", "PDF/X-3:2002", "PDF/X-4:2010"] {
assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}");
}
}
+51 -3
View File
@@ -20,6 +20,7 @@
//! Window → Flattener Preview: the same plan, worked out without rendering, reports what flattening
//! the document would touch ([`FlattenReport`], `flattener.preview`).
use std::collections::BTreeSet;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
@@ -129,6 +130,12 @@ pub struct FlattenOptions {
/// Areas showing a single paint keep it (spot and process colours, swatches) with its
/// overprint; off, the flattened objects lose their overprints.
pub preserve_overprints: bool,
/// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with
/// see-through pixels (these keys, [`see_through_images`]) count as transparency, and
/// rasterized areas clipped to their regions are opaque. `None` for ordinary flattening. Not a
/// preset option.
#[serde(skip)]
pub no_transparency: Option<BTreeSet<String>>,
}
impl Default for FlattenOptions {
@@ -175,6 +182,7 @@ impl FlattenOptions {
anti_alias,
preserve_alpha: false,
preserve_overprints: true,
no_transparency: None,
}
}
@@ -196,6 +204,16 @@ impl FlattenOptions {
Self::from_params_with(p, &[])
}
/// The options an export flattens with: preset `name` (built-in or one of `saved`; default
/// medium) with the export's `flattener` `options` over it.
pub fn for_export(name: Option<&str>, options: Option<&Value>, saved: &[FlattenerPreset]) -> std::result::Result<Self, String> {
let mut q = json!({ "options": options.cloned().unwrap_or(Value::Null) });
if let Some(name) = name {
q["preset"] = json!(name);
}
Self::from_params_with(&q, saved)
}
/// The options `p` asks for: `preset` (built-in or one of `saved`; default medium) adjusted by
/// the option keys given at the top level or in `options`.
pub fn from_params_with(p: &Value, saved: &[FlattenerPreset]) -> std::result::Result<Self, String> {
@@ -222,6 +240,11 @@ impl FlattenOptions {
Ok(o)
}
/// `n` shows an image whose see-through pixels count as transparency.
fn draws_see_through_image(&self, n: &Node) -> bool {
self.no_transparency.as_ref().is_some_and(|keys| draws_image(n, keys))
}
/// Whether a group that splits into `regions` atomic regions is rasterized whole.
fn rasterize_all(&self, regions: usize) -> bool {
self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0))
@@ -792,7 +815,7 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R
let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() };
let plain: Vec<Node> = art.iter().map(|n| sc.plain(n)).collect();
let reaches: Vec<Option<Rect>> = plain.iter().map(reach).collect();
let see_through: Vec<bool> = plain.iter().map(Node::shows_transparency).collect();
let see_through: Vec<bool> = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect();
let cmyk = src.color_mode == ColorMode::Cmyk;
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
for g in overlapping(&reaches) {
@@ -927,7 +950,13 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
);
let mut r = vectorcraft_render::Renderer::new();
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
if !o.preserve_alpha || !o.anti_alias {
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
// Without transparency the clip alone shapes the image: it is opaque, over white (its
// edge pixels too).
for p in img.pixels.as_chunks_mut::<4>().0 {
p[3] = 255;
}
} else if !o.preserve_alpha || !o.anti_alias {
// The art's coverage: drawn opaque, so only its own edges (and soft effects) are partial.
let cover: Vec<Node> = art
.iter()
@@ -988,7 +1017,7 @@ fn opaque(n: &mut Node) {
/// clipping path stays). `None` when that changes nothing.
pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result<Option<Document>> {
let outlining = o.text_to_outlines || o.strokes_to_outlines || !o.preserve_overprints;
if !outlining && !doc.layers.iter().any(|l| l.shows_transparency()) {
if !outlining && !doc.layers.iter().any(|l| l.shows_transparency() || o.draws_see_through_image(l)) {
return Ok(None);
}
let mut roots = vec![];
@@ -1197,6 +1226,25 @@ fn carry(from: &Node, mut to: Node) -> Node {
to
}
/// The keys of `doc`'s images that have see-through pixels (an alpha channel that isn't opaque
/// everywhere).
pub(crate) fn see_through_images(doc: &Document) -> BTreeSet<String> {
let see_through = |b: &ImageBlob| {
b.mime != "image/jpeg" && image::load_from_memory(&b.bytes).is_ok_and(|i| i.color().has_alpha() && i.to_rgba8().pixels().any(|p| p[3] < 255))
};
doc.images.iter().filter(|(_, b)| see_through(b)).map(|(k, _)| k.clone()).collect()
}
/// `n` shows an image of `keys` (itself or inside).
fn draws_image(n: &Node, keys: &BTreeSet<String>) -> bool {
!keys.is_empty()
&& n.visible
&& match &n.kind {
NodeKind::Image(im) => keys.contains(&im.key),
_ => n.children().is_some_and(|ch| ch.iter().any(|c| draws_image(c, keys))),
}
}
fn visible_fx(fx: &[Effect]) -> bool {
fx.iter().any(|e| e.visible)
}
+67 -1
View File
@@ -1,5 +1,6 @@
//! Type → Find Font: the fonts a document uses (flagging missing ones), replacing one font with
//! another everywhere or in the selection, and selecting the text that uses a font.
//! another everywhere or in the selection, and selecting the text that uses a font. Also the fonts
//! available (the bundled and installed ones) and rescanning the installed fonts.
use std::collections::BTreeMap;
@@ -38,6 +39,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
select_font
),
cmd!(
query "text.fontList",
"Font List",
[],
None,
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
always,
font_list
),
cmd!(
"text.rescanFonts",
"Refresh Font List",
[],
None,
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families available, installed faces found)",
always,
rescan
),
]
}
@@ -166,6 +185,53 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "count": n }))
}
fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
let db = vectorcraft_text::FontDb::global();
match str_param(p, "family") {
Some(family) => {
if !db.has_family(family) {
return Err(bad("text.fontList", format!("font family `{family}` is not available")));
}
// The name as the font spells it.
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
Ok(json!({ "family": name, "styles": db.styles(family) }))
}
None => Ok(json!({ "families": *db.family_list() })),
}
}
fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
let db = vectorcraft_text::FontDb::global();
#[cfg(not(target_arch = "wasm32"))]
let faces = db.load_system_fonts();
#[cfg(target_arch = "wasm32")]
let faces = 0;
// The canvas redraws type in the fonts available now (renderers lay it out again when the
// fonts change), and the bounds follow; that isn't an edit to save.
for d in &mut s.docs {
let mut texts = vec![];
d.doc.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
texts.push(n.id);
}
});
if !texts.is_empty() {
let saved = !d.is_dirty();
let doc = std::sync::Arc::make_mut(&mut d.doc);
for id in texts {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
refresh_bounds(t);
}
}
if saved {
d.mark_saved();
}
}
d.revision += 1;
}
Ok(json!({ "families": db.family_list().len(), "faces": faces }))
}
#[cfg(test)]
mod tests {
use super::*;
+298
View File
@@ -0,0 +1,298 @@
//! Object → Vector Halftone: the selected art (vectors, type, images) as vector dots, lines or
//! shapes sized by its tone.
//!
//! The art is rendered offscreen and sampled once per halftone cell on a grid turned by the screen
//! angle; each cell gets a shape whose area follows the tone there. Mono screens follow lightness
//! in one colour; CMYK screens follow the art's ink amounts (as CMYK output separates it), one
//! screen per ink at the classic angles, multiplied over each other. The dots can be clipped to the
//! art's outline as real geometry.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, Node, NodeId};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
use vectorcraft_pathops as po;
use vectorcraft_pathops::BoolOp;
use super::edit::selected_roots;
use super::menucmds::isolated_doc;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"object.vectorHalftone",
"Vector Halftone…",
["Object"],
None,
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots}",
has_selection,
halftone
)]
}
/// Most halftone cells per screen.
const MAX_CELLS: f64 = 100_000.0;
/// Most pixels the tone is sampled from.
const MAX_SAMPLE_PIXELS: f64 = 16.0e6;
/// Cells lighter than this get no dot.
const MIN_TONE: f64 = 0.01;
#[derive(Clone, Copy, Debug, PartialEq)]
enum DotShape {
Circle,
Ellipse,
Square,
Diamond,
Line,
}
impl DotShape {
fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"circle" | "round" | "dot" => DotShape::Circle,
"ellipse" => DotShape::Ellipse,
"square" => DotShape::Square,
"diamond" => DotShape::Diamond,
"line" | "lines" => DotShape::Line,
_ => return None,
})
}
/// The shape for tone `t` (0..1) in a cell of side `s` centred on the origin, the screen
/// running along x.
fn path(self, t: f64, s: f64) -> PathData {
let square = |side: f64| shapes::rectangle(Rect::from_center_size(Point::ZERO, (side, side)));
match self {
DotShape::Circle => {
let r = s * (t / std::f64::consts::PI).sqrt();
shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.0 * r, 2.0 * r)))
}
DotShape::Ellipse => {
let r = s * (t / std::f64::consts::PI).sqrt();
shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.6 * r, 2.0 * r / 1.3)))
}
DotShape::Square => square(s * t.sqrt()),
DotShape::Diamond => square(s * t.sqrt()).transformed(Affine::rotate(std::f64::consts::FRAC_PI_4)),
// Cells along a row join into a line as thick as the tone.
DotShape::Line => shapes::rectangle(Rect::from_center_size(Point::ZERO, (s * 1.001, s * t))),
}
}
}
/// One screen: its ink and angle, and how to read its tone from a sampled pixel.
struct Screen {
paint: Color,
angle: f64,
/// Index into the sampled pixel (RGBA lightness for mono, CMYK inks for cmyk).
channel: usize,
}
/// The sampled art: `w`×`h` pixels of 4 bytes over `region` at `scale` px/pt.
struct Samples {
px: Vec<u8>,
w: usize,
h: usize,
region: Rect,
scale: f64,
/// Mono: tones are 1 − lightness of RGBA pixels; CMYK: ink amounts.
mono: bool,
invert: bool,
}
impl Samples {
/// The tone (0..1) of `channel` at document point `p`.
fn tone(&self, p: Point, channel: usize) -> f64 {
let x = ((p.x - self.region.x0) * self.scale).floor();
let y = ((p.y - self.region.y0) * self.scale).floor();
if !(x >= 0.0 && y >= 0.0 && (x as usize) < self.w && (y as usize) < self.h) {
return if self.invert { 1.0 } else { 0.0 };
}
let i = (y as usize * self.w + x as usize) * 4;
let byte = |k: usize| self.px.get(i + k).copied().unwrap_or(0) as f64 / 255.0;
let t = if self.mono { 1.0 - (0.2126 * byte(0) + 0.7152 * byte(1) + 0.0722 * byte(2)) } else { byte(channel) };
if self.invert { 1.0 - t } else { t }
}
/// The mean tone over a cell of side `s` at `c` turned by `rot` (3 × 3 samples).
fn cell_tone(&self, c: Point, s: f64, rot: Affine, channel: usize) -> f64 {
let mut sum = 0.0;
for i in 0..3 {
for j in 0..3 {
let off = Point::new((i as f64 - 1.0) * s / 3.0, (j as f64 - 1.0) * s / 3.0);
sum += self.tone(c + (rot * off).to_vec2(), channel);
}
}
sum / 9.0
}
}
fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.vectorHalftone";
let shape = match str_param(p, "shape") {
Some(v) => DotShape::parse(v).ok_or_else(|| bad(C, format!("unknown shape `{v}` (circle|ellipse|square|diamond|line)")))?,
None => DotShape::Circle,
};
let frequency = f64_or(p, "frequency", 20.0);
if !(1.0..=300.0).contains(&frequency) {
return Err(bad(C, "frequency must be between 1 and 300 lines per inch"));
}
let angle = f64_or(p, "angle", 45.0);
if !angle.is_finite() {
return Err(bad(C, "invalid angle"));
}
let mono = match str_param(p, "mode").unwrap_or("mono") {
"mono" => true,
"cmyk" => false,
other => return Err(bad(C, format!("unknown mode `{other}` (mono|cmyk)"))),
};
let color = match p.get("color") {
Some(v) => color_value(v).ok_or_else(|| bad(C, "invalid color"))?,
None => Color::BLACK,
};
let (invert, clip, keep) = (bool_or(p, "invert", false), bool_or(p, "clip", true), bool_or(p, "keepOriginal", false));
let cell = 72.0 / frequency;
let roots = selected_roots(s)?;
let st = s.doc()?;
let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "the selection has no bounds"))?;
if b.width() <= 0.0 || b.height() <= 0.0 || !b.area().is_finite() {
return Err(bad(C, "the selection has no area"));
}
// The grid turned by the screen angle covers at most (w + h)² / 2.
if (b.width() + b.height()).powi(2) / 2.0 / (cell * cell) > MAX_CELLS {
return Err(bad(C, "too many dots for this size: lower the frequency"));
}
let nodes: Vec<Node> = roots
.iter()
.filter_map(|id| st.doc.node(*id).cloned())
.map(|mut n| {
n.visible = true;
n
})
.collect();
let outline = if clip {
vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect()))
} else {
None
};
// Sample the art at about four pixels per cell.
let scale = (4.0 / cell).min((MAX_SAMPLE_PIXELS / b.area()).sqrt()).clamp(0.01, 16.0);
let region = b.inflate(cell, cell);
let tmp = isolated_doc(&st.doc, nodes);
let mut r = vectorcraft_render::Renderer::new();
let (w, h) = vectorcraft_render::region_pixels(region, scale);
let (w, h) = (w as usize, h as usize);
let px = if mono {
r.render_region(&tmp, region, scale, true).pixels
} else {
r.render_region_inks(&tmp, region, scale, &vectorcraft_render::RenderOptions { skip_templates: true, ..Default::default() })
};
if px.len() < w * h * 4 {
return Err(EngineError::Other("the art could not be sampled".into()));
}
let samples = Samples { px, w, h, region, scale, mono, invert };
let screens: Vec<Screen> = if mono {
vec![Screen { paint: color, angle, channel: 0 }]
} else {
// Bottom to top: yellow, magenta, cyan, black.
vec![
Screen { paint: Color::cmyk(0.0, 0.0, 1.0, 0.0), angle: angle - 45.0, channel: 2 },
Screen { paint: Color::cmyk(0.0, 1.0, 0.0, 0.0), angle: angle + 30.0, channel: 1 },
Screen { paint: Color::cmyk(1.0, 0.0, 0.0, 0.0), angle: angle - 30.0, channel: 0 },
Screen { paint: Color::cmyk(0.0, 0.0, 0.0, 1.0), angle, channel: 3 },
]
};
let mut layers = vec![];
let mut dots = 0;
for sc in &screens {
let (path, n) = screen_dots(&samples, b, cell, sc, shape, outline.as_ref());
dots += n;
if !path.is_empty() {
layers.push((path, sc.paint));
}
}
if layers.is_empty() {
return Err(EngineError::Other("the art is too light for any dots".into()));
}
let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?;
let id = s.edit("Vector Halftone", |d, sel| {
let children = layers
.into_iter()
.map(|(path, ink)| {
let mut n = Node::path(d.alloc_id(), path, Appearance::basic(Paint::solid(ink), Paint::None, 0.0));
if !mono {
n.blend = BlendMode::Multiply;
}
Arc::new(n)
})
.collect();
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = Some("Vector Halftone".into());
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
d.insert(par, idx + 1, g)?;
if !keep {
for r in &roots {
d.remove(*r)?;
}
}
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": id.0, "dots": dots }))
}
/// The dots of one screen over `bounds` as one path, with their count.
fn screen_dots(samples: &Samples, bounds: Rect, cell: f64, sc: &Screen, shape: DotShape, outline: Option<&(BezPath, FillRule)>) -> (PathData, usize) {
let rot = Affine::rotate(sc.angle.to_radians());
let inv = rot.inverse();
// The grid covers the bounds in screen space.
let corners =
[bounds.origin(), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)].map(|q| inv * q);
let (mut lo, mut hi) = (corners[0], corners[0]);
for q in &corners[1..] {
lo = Point::new(lo.x.min(q.x), lo.y.min(q.y));
hi = Point::new(hi.x.max(q.x), hi.y.max(q.y));
}
let (i0, i1) = ((lo.x / cell).floor() as i64 - 1, (hi.x / cell).ceil() as i64 + 1);
let (j0, j1) = ((lo.y / cell).floor() as i64 - 1, (hi.y / cell).ceil() as i64 + 1);
let near = bounds.inflate(cell, cell);
let (mut inside, mut edge) = (vec![], vec![]);
for j in j0..=j1 {
for i in i0..=i1 {
let c = rot * Point::new((i as f64 + 0.5) * cell, (j as f64 + 0.5) * cell);
if !near.contains(c) {
continue;
}
let t = samples.cell_tone(c, cell, rot, sc.channel).clamp(0.0, 1.0);
if t < MIN_TONE {
continue;
}
let place = Affine::translate(c.to_vec2()) * rot;
let dot = shape.path(t, cell).transformed(place);
// Dots wholly inside the outline need no clipping.
let whole = match (outline, dot.control_bounds()) {
(Some((bp, rule)), Some(db)) => [db.origin(), Point::new(db.x1, db.y0), Point::new(db.x1, db.y1), Point::new(db.x0, db.y1)]
.iter()
.all(|q| fill_contains(bp, *rule, *q)),
_ => true,
};
if whole { inside.push(dot) } else { edge.push(dot) }
}
}
let count = inside.len() + edge.len();
let mut subs: Vec<_> = inside.into_iter().flat_map(|p| p.subpaths).collect();
if let Some((bp, rule)) = outline
&& !edge.is_empty()
{
let edge = PathData::new(edge.into_iter().flat_map(|p| p.subpaths).collect());
let clipped = po::boolean(&edge, FillRule::NonZero, &PathData::from_bezpath(bp), *rule, BoolOp::Intersect);
subs.extend(clipped.subpaths);
} else {
subs.extend(edge.into_iter().flat_map(|p| p.subpaths));
}
(PathData::new(subs), count)
}
+1 -1
View File
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
"object.envelope.editContents",
"Edit Contents",
["Object", "Envelope Distort"],
Some("Cmd+Shift+V"),
None,
"{editing?: bool} toggle Edit Contents / Edit Envelope → {editing}",
has_envelope,
env_edit_contents
+21 -2
View File
@@ -86,9 +86,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset Bounding Box",
["Object", "Transform"],
None,
"{} no-op: VectorCraft bounding boxes are always axis-aligned to the document → {changed: 0}",
"{} square the bounding box of the selected objects to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset}",
has_selection,
|_, _| Ok(json!({ "changed": 0 }))
reset_bounding_box
),
cmd!(
"object.rasterize",
@@ -177,6 +177,25 @@ pub(crate) fn squash(s: &mut Session, from: usize, label: &str) {
}
}
// ---------- Reset Bounding Box ----------
fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let d = &s.doc()?.doc;
let turned: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.bbox_angle != 0.0)).collect();
if !turned.is_empty() {
s.edit("Reset Bounding Box", |d, _| {
for id in &turned {
if let Some(n) = d.node_mut(*id) {
n.bbox_angle = 0.0;
}
}
Ok(())
})?;
}
Ok(json!({ "changed": turned.len() }))
}
// ---------- Lock / Hide ----------
fn above(s: &mut Session, lock: bool) -> Result<Value> {
+9
View File
@@ -11,6 +11,7 @@ pub mod colormgmt;
pub mod colortheme;
mod create;
mod css;
mod cut;
pub(crate) mod distortcmds;
mod docinfo;
mod docmenu;
@@ -26,6 +27,7 @@ mod fonts;
pub(crate) mod freeform;
pub(crate) mod gradient;
pub(crate) mod graph;
mod halftone;
pub mod help;
mod layer;
pub mod links;
@@ -45,9 +47,12 @@ mod pathops;
mod patterncmds;
pub mod pdfcmds;
mod place;
pub mod plugin;
pub mod prefscmds;
pub mod print;
pub(crate) mod printadvanced;
pub mod printpresets;
pub mod printtiling;
pub mod rasterfx;
mod recolor;
pub mod recovery;
@@ -241,6 +246,10 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(assets::specs());
v.extend(css::specs());
v.extend(printpresets::specs());
v.extend(printtiling::specs());
v.extend(plugin::specs());
v.extend(cut::specs());
v.extend(halftone::specs());
v
})
}
+24 -11
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, Scaling};
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, OrientedBox, Scaling};
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
use super::edit::{duplicate_in, selected_roots};
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rotate…",
["Object", "Transform"],
None,
"{angle: deg (counter-clockwise), origin?: [x,y], copy?}",
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?} → {ids}",
has_selection,
rotate
),
@@ -175,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Bounds",
[],
None,
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)",
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)",
has_selection,
set_bounds
),
@@ -188,7 +188,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
if let Some(o) = point_param(p, "origin") {
return Ok(o);
}
s.transform_bounds(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
s.transform_box(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
}
/// What a transform command scales besides geometry: Scale Strokes & Effects and Scale Corners from
@@ -255,8 +255,12 @@ fn about(o: Point, a: Affine) -> Affine {
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
// Illustrator angles are counter-clockwise; y is down, so negate.
let a = about(o, Affine::rotate(-f64_or(p, "angle", 0.0).to_radians()));
let mut angle = f64_or(p, "angle", 0.0);
if bool_or(p, "absolute", false) {
angle = vectorcraft_geom::normalize_deg(angle - s.doc()?.doc.bbox_angle(&ids));
}
// Angles are counter-clockwise; y is down, so negate.
let a = about(o, Affine::rotate(-angle.to_radians()));
apply_transform(s, "Rotate", ids, a, p)
}
@@ -716,6 +720,11 @@ impl Session {
pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> {
self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds)
}
/// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding
/// box, see [`Document::oriented_bounds`]).
pub fn transform_box(&self, ids: &[NodeId]) -> Option<OrientedBox> {
self.doc().ok()?.doc.oriented_bounds(ids, self.prefs.use_preview_bounds)
}
}
/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds.
@@ -876,10 +885,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let d = &s.doc()?.doc;
let g = d.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
// the math below runs in its frame.
let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
let g = frame.rect;
// With Use Preview Bounds the values measure the visual box, whose margins around the
// geometric box (left, top, right, bottom) scale with the strokes or stay.
let v = if s.prefs.use_preview_bounds { d.bounds_of(&ids, true).unwrap_or(g) } else { g };
let v = if s.prefs.use_preview_bounds { d.oriented_bounds(&ids, true).map_or(g, |b| b.rect) } else { g };
let m = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1];
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let rp = vectorcraft_geom::reference_point(v, refi);
@@ -906,9 +918,10 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
// box's reference point moves with the art, the margin from it to the visual one by `f`.
let gp = vectorcraft_geom::reference_point(g, refi);
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
let tx = f64_or(p, "x", rp.x) - new_rp.x;
let ty = f64_or(p, "y", rp.y) - new_rp.y;
apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, p)
// x, y place the reference point on the page.
let page = frame.to_doc() * rp;
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p)
}
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
+1 -5
View File
@@ -757,11 +757,7 @@ fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
visit(c, true, false, false, v, opts);
}
}
NodeKind::Text(t) => {
if empty_text && t.plain_text().trim().is_empty() {
v.push(n.id);
}
}
NodeKind::Text(t) if empty_text && t.plain_text().trim().is_empty() => v.push(n.id),
NodeKind::Layer { children, .. } => {
for c in children {
visit(c, false, false, false, v, opts);
+238
View File
@@ -0,0 +1,238 @@
//! `plugin.*` commands: sandboxed WebAssembly plug-ins (`vectorcraft-plugins`).
//!
//! Plug-ins are installed per process (`plugin.install`, or every `*.wasm` in the Additional
//! Plug-ins Folder preference) and listed with `plugin.list` / `plugin.info`. Object filters run
//! with `plugin.run` on the selected paths and compound paths (one undo step, transactional: a
//! plug-in that fails leaves the document untouched); live-effect plug-ins are applied like any
//! effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`.
use std::sync::{Arc, Mutex};
use serde_json::{Map, Value, json};
use vectorcraft_plugins::{Kind, Plugin, effect, objects, registry};
use super::*;
/// What a plug-in error looks like to the user.
fn err(e: vectorcraft_plugins::Error) -> EngineError {
EngineError::Other(e.to_string())
}
/// JSON description of an installed plug-in.
pub fn describe(p: &Plugin) -> Value {
let m = p.manifest();
let mut v = json!({
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description,
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
"size": p.size(), "source": p.source(),
});
if m.kind == Kind::Effect {
v["effect"] = json!(effect::effect_id(&m.id));
}
v
}
/// The plug-in's own parameters from command params: `params` if given, else the top-level keys
/// minus the ones the command itself uses.
fn plugin_params(p: &Value) -> Value {
if let Some(v) = p.get("params") {
return v.clone();
}
match p {
Value::Object(m) => {
Value::Object(m.iter().filter(|(k, _)| !matches!(k.as_str(), "id" | "ids")).map(|(k, v)| (k.clone(), v.clone())).collect())
}
_ => Value::Object(Map::new()),
}
}
fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
let id = str_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?;
registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into())))
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.run";
let plugin = plugin_by_id(C, p)?;
let m = plugin.manifest();
if m.kind != Kind::Filter {
return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id))));
}
// Bad parameters fail before anything runs.
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
let st = s.doc()?;
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
let inputs = objects::filter_targets(&st.doc, &selected);
let parent = st.insertion_parent();
let bounds = st.doc.bounds_of(&inputs, false);
let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first());
let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect))});
let input = objects::encode_input(&st.doc, &inputs);
let params = serde_json::to_vec(&params).map_err(|e| bad(C, e.to_string()))?;
let out = objects::decode(&plugin.run(&input, &params).map_err(err)?).map_err(err)?;
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
let ids = objects::apply_output(d, &inputs, out, parent).map_err(err)?;
sel.set(ids.iter().copied());
Ok(ids)
})?;
Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// Largest base64 payload `plugin.install` accepts (the module limit, encoded).
const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
/// The `.wasm` extension plug-in files have (File › Open installs them).
pub const EXTS: &[&str] = &["wasm"];
fn install(_: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.install";
let plugin = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
(_, Some(data)) => {
if data.len() > MAX_DATA_CHARS {
return Err(bad(C, "`dataBase64` is larger than the module limit"));
}
let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(C, "`dataBase64` is not valid base64"))?;
let plugin = Plugin::load(&bytes, vectorcraft_plugins::Limits::default()).map_err(err)?;
match str_param(p, "name") {
Some(name) => plugin.with_source(name),
None => plugin,
}
}
(Some(path), None) => load_path(path)?,
(None, None) => return Err(bad(C, "give `path` (a .wasm file) or `dataBase64` (the module)")),
};
if !p.get("replace").and_then(Value::as_bool).unwrap_or(true) && registry::get(plugin.id()).is_some() {
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
}
Ok(describe(&registry::install(plugin)))
}
#[cfg(not(target_arch = "wasm32"))]
fn load_path(path: &str) -> Result<Plugin> {
registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err)
}
#[cfg(target_arch = "wasm32")]
fn load_path(_: &str) -> Result<Plugin> {
Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into()))
}
/// The plug-in folder last loaded from the preferences, and what happened.
static FOLDER: Mutex<Option<(String, Value)>> = Mutex::new(None);
/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and has changed since
/// the last load. Runs whenever preferences are applied (startup, `prefs.set`). Native only.
pub(crate) fn sync_prefs(s: &Session) {
let folder = s.prefs.plugins_folder.trim();
if folder.is_empty() {
return;
}
let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner());
if last.as_ref().is_some_and(|(f, _)| f == folder) {
return;
}
*last = Some((folder.to_string(), load_folder(folder)));
}
#[cfg(not(target_arch = "wasm32"))]
fn load_folder(folder: &str) -> Value {
match registry::load_folder(std::path::Path::new(folder)) {
Ok(r) => {
json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()})
}
Err(e) => json!({"folder": folder, "error": e.to_string()}),
}
}
#[cfg(target_arch = "wasm32")]
fn load_folder(folder: &str) -> Value {
json!({"folder": folder, "error": "plug-in folders are not available on the web"})
}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.reload";
let folder = match p.get("path") {
Some(v) => v.as_str().map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "`path` must be a folder"))?.to_string(),
None => s.prefs.plugins_folder.trim().to_string(),
};
if folder.is_empty() {
return Err(bad(C, "no folder: pass `path` or set Preferences › Performance & Storage › Additional Plug-ins Folder"));
}
let report = load_folder(&folder);
if let Some(e) = report.get("error").and_then(Value::as_str) {
return Err(EngineError::Other(e.to_string()));
}
*FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone()));
Ok(report)
}
fn list(_: &mut Session, _: &Value) -> Result<Value> {
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone());
Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder}))
}
fn info(_: &mut Session, p: &Value) -> Result<Value> {
let plugin = plugin_by_id("plugin.info", p)?;
let mut v = describe(&plugin);
v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null);
v["defaults"] = Value::Object(plugin.manifest().defaults());
v["lastError"] = json!(effect::last_error(plugin.id()));
Ok(v)
}
fn remove(_: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "plugin.list",
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
always,
list
),
cmd!(
query "plugin.info",
"Plug-in Info",
[],
None,
"{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null)}",
always,
info
),
cmd!(
"plugin.run",
"Run Plug-in",
[],
None,
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection)} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
has_doc,
run
),
cmd!(
query "plugin.install",
"Install Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
always,
install
),
cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove),
cmd!(
query "plugin.reload",
"Reload Plug-ins",
["Object", "Plug-ins"],
None,
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped) → {folder, loaded: [id], failed: [{file, error}]}",
always,
reload
),
]
}
+4
View File
@@ -320,6 +320,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool),
// Native saves (`document.save {compress}`)
p!("useCompression", "File Handling", "Files", "Use Compression", bool),
// The W/H link of the Transform panel, the Properties panel and the Control bar
p!("constrainProportions", "General", "Transform Panel", "Constrain Width and Height Proportions", bool),
];
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
@@ -453,6 +455,8 @@ impl Session {
st.revision += 1;
}
}
// Plug-ins in a newly set Additional Plug-ins Folder are installed.
super::plugin::sync_prefs(self);
}
}
+23 -21
View File
@@ -10,7 +10,6 @@ use vectorcraft_pdf::{PrintOptions, PrintSettings};
use super::fileio::pdf::{merge, pdf_error};
use super::fileio::{extension, write_or_return};
use super::flatten::{FlattenOptions, flatten_document};
use super::*;
const SETUP: &str = "print.setup";
@@ -22,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Setup",
[],
None,
"{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values in separations)}}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)",
"{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)",
has_doc,
setup
),
@@ -53,7 +52,7 @@ fn saved(doc: &Document) -> Value {
}
/// The settings of `doc` with `p.settings` over them, checked.
fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSettings> {
pub(crate) fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSettings> {
settings_over(cmd, saved(doc), p)
}
@@ -69,7 +68,7 @@ pub(crate) fn settings_over(cmd: &str, mut v: Value, p: &Value) -> Result<PrintS
Ok(set)
}
fn to_json(set: &PrintSettings) -> Result<Value> {
pub(crate) fn to_json(set: &PrintSettings) -> Result<Value> {
serde_json::to_value(set).map_err(|e| EngineError::Other(e.to_string()))
}
@@ -90,6 +89,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "print.preview";
let doc = &s.doc()?.doc;
let set = settings(C, doc, p)?;
super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?;
let pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?;
v["settings"] = to_json(&set)?;
@@ -110,25 +110,25 @@ fn to_postscript(p: &Value) -> Result<bool> {
fn print(s: &mut Session, p: &Value) -> Result<Value> {
let postscript = to_postscript(p)?;
let flattener = if postscript {
let preset = str_param(p, "flattenerPreset").unwrap_or("medium");
Some(
FlattenOptions::find_preset(preset, &s.prefs.flattener_presets)
.ok_or_else(|| bad(PRINT, format!("flattenerPreset `{preset}`: high, medium, low or a saved one (see flattener.presets.list)")))?,
)
} else {
None
};
let doc = &s.doc()?.doc;
let set = settings(PRINT, doc, p)?;
let mut set = settings(PRINT, doc, p)?;
if postscript {
// PostScript has no transparency: `flattenerPreset`, else the Advanced preset, else medium.
let given = str_param(p, "flattenerPreset").filter(|n| !n.trim().is_empty());
let preset = given.or(Some(set.advanced.flattener_preset.as_str()).filter(|n| !n.trim().is_empty())).unwrap_or("medium");
set.advanced.flattener_preset = preset.to_string();
}
// Raster effects print as images at the document's raster effects resolution.
let flat = super::rasterfx::flatten_raster_effects(doc);
let doc = flat.as_ref().unwrap_or(doc);
// Print as Bitmap and the flattener preset (Advanced) render and flatten it first.
let prepared = super::printadvanced::prepare(PRINT, doc, &set, &s.prefs.flattener_presets)?;
let path = str_param(p, "path");
let Some(flattener) = flattener else {
let r = vectorcraft_pdf::print(doc, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?;
if !postscript {
let r = vectorcraft_pdf::print(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, ..Default::default() })
.map_err(|e| pdf_error(PRINT, e))?;
return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings }));
};
}
let level = match p.get("level").filter(|v| !v.is_null()) {
Some(v) => {
let t = super::fileio::dxf::text(v);
@@ -136,14 +136,16 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
}
None => Level::default(),
};
// PostScript has no transparency.
let flat = flatten_document(doc, &flattener)?;
let mut warnings = vec![];
if flat.is_some() && doc.layers.iter().any(|l| l.shows_transparency()) {
let bitmap = set.advanced.print_as_bitmap && set.output.mode == vectorcraft_pdf::OutputMode::Composite;
if !bitmap && doc.layers.iter().any(|l| l.shows_transparency()) {
warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string());
}
if !set.color.profile.trim().is_empty() && set.output.mode == vectorcraft_pdf::OutputMode::Composite {
warnings.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string());
}
let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness);
let plan = vectorcraft_pdf::plan(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() })
let plan = vectorcraft_pdf::plan(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() })
.map_err(|e| pdf_error(PRINT, e))?;
let pages: Vec<PrintPage> = plan
.sheets
+107
View File
@@ -0,0 +1,107 @@
//! File → Print › Advanced, what needs the renderer and the flattener (the PDF writer does the
//! rest): Print as Bitmap renders each printed region as one image, and a flattener preset
//! flattens transparency, both on the document as it prints (only the layers that print,
//! overprints discarded or simulated in composite output) before it is laid out on paper.
use std::sync::Arc;
use vectorcraft_doc::marks::outset;
use vectorcraft_doc::{Document, ImageBlob, ImageObject, LayerColor, Node, NodeKind};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_pdf::{OutputMode, PrintOverprints, PrintSettings};
use vectorcraft_render::{AntiAlias, RenderOptions};
use super::fileio::pdf::pdf_error;
use super::flatten::{FlattenOptions, FlattenerPreset, flatten_document};
use super::menucmds::{MAX_PIXELS, unique_key};
use super::*;
/// The flattener options of `set`'s preset (`None`: transparency prints live); an error naming
/// `cmd` for a preset that is neither built in nor in `saved`.
pub(crate) fn flattener(cmd: &str, set: &PrintSettings, saved: &[FlattenerPreset]) -> Result<Option<FlattenOptions>> {
let name = set.advanced.flattener_preset.trim();
if name.is_empty() {
return Ok(None);
}
let o = FlattenOptions::find_preset(name, saved)
.ok_or_else(|| bad(cmd, format!("flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list)")))?;
Ok(Some(o))
}
/// `doc` as Print as Bitmap and the flattener preset of `set` print it (from `saved` presets), or
/// `None` when neither applies. Print as Bitmap is for composite output.
pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[FlattenerPreset]) -> Result<Option<Document>> {
let composite = set.output.mode == OutputMode::Composite;
let bitmap = set.advanced.print_as_bitmap && composite;
let flattener = flattener(cmd, set, saved)?;
if !bitmap && flattener.is_none() {
return Ok(None);
}
let printed = vectorcraft_pdf::printed_document(doc, set);
if bitmap {
return bitmap_document(cmd, &printed, set).map(Some);
}
let Some(mut o) = flattener else { return Ok(None) };
// Composite overprints that were discarded or simulated aren't preserved either.
o.preserve_overprints &= !composite || set.advanced.overprints == PrintOverprints::Preserve;
Ok(Some(flatten_document(&printed, &o)?.unwrap_or_else(|| printed.into_owned())))
}
/// Leave guide paths out of `nodes` (the renderer draws them; they never print).
fn drop_guides(nodes: &mut Vec<Arc<Node>>) {
fn has_guides(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: true, .. }) || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c)))
}
nodes.retain(|n| !matches!(n.kind, NodeKind::Path { guide: true, .. }));
for n in nodes.iter_mut().filter(|n| has_guides(n)) {
if let Some(children) = Arc::make_mut(n).children_mut() {
drop_guides(children);
}
}
}
/// `doc` (as it prints) with its art replaced by one image of each printed region (an artboard
/// with its bleed, or all the art), at the document's raster effects resolution; regions where
/// nothing is drawn get none.
fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings) -> Result<Document> {
let regions = vectorcraft_pdf::print_regions(doc, set).map_err(|e| pdf_error(cmd, e))?;
let bleed = set.bleed.of(doc);
let mut art = doc.clone();
drop_guides(&mut art.layers);
let anti_alias = if doc.raster_effects.anti_alias { AntiAlias::Art } else { AntiAlias::None };
let opts = RenderOptions { skip_templates: true, anti_alias, ..Default::default() };
let mut out = doc.clone();
let mut renderer = vectorcraft_render::Renderer::new();
let mut images = vec![];
for (_, r) in regions {
let area = outset(r, bleed);
// At most MAX_PIXELS, and 65535 pixels a side.
let side = area.width().max(area.height()).max(1.0);
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(65_535.0 / side);
if !(scale.is_finite() && scale > 0.0) {
continue;
}
// Whole pixels.
let region = Rect::new(
area.x0,
area.y0,
area.x0 + (area.width() * scale).ceil().max(1.0) / scale,
area.y0 + (area.height() * scale).ceil().max(1.0) / scale,
);
let img = renderer.render_region_with(&art, region, scale, &opts);
if img.pixels.as_chunks::<4>().0.iter().all(|px| px[3] == 0) {
continue;
}
let key = unique_key(&out, "print-bitmap");
out.images.insert(key.clone(), ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?));
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() };
images.push(Arc::new(Node::new(out.alloc_id(), NodeKind::Image(image))));
}
let mut layer = Node::layer(out.alloc_id(), "Bitmap", LayerColor::Preset(0));
if let Some(children) = layer.children_mut() {
*children = images;
}
out.layers = vec![Arc::new(layer)];
Ok(out)
}
+109
View File
@@ -0,0 +1,109 @@
//! View → Show Print Tiling (`view.printTiling`) and the Print Tiling tool (`print.tiling.set`):
//! the pages of the document's print settings drawn on the canvas, from `print.preview`'s layout
//! (`print.tiling`), and where the tool put them ([`vectorcraft_pdf::TileOrigin`], saved with
//! the print settings).
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_geom::Point;
use vectorcraft_pdf::{TileOrigin, TilingPage};
use super::fileio::pdf::pdf_error;
use super::print::{settings, to_json};
use super::*;
const SET: &str = "print.tiling.set";
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "view.printTiling",
"Show Print Tiling",
["View"],
None,
"{on?: bool (default: toggle)} show the pages of the document's print settings on the canvas (each document has its own setting; the Print Tiling tool shows them too) → {on}",
has_doc,
show
),
cmd!(
"print.tiling.set",
"Print Tiling",
[],
None,
"{origin: [x, y] (document point: the top-left corner of the first page's imageable area), artboard? (0-based: the artboard it is measured from; default the printed artboard containing origin, else the first; ignored with artboards: ignore)} | {reset: true} place the printed pages as the Print Tiling tool does, saved with the print settings (tileOrigin: {placed, x, y}, from the artboard's top-left corner; it replaces the placement, and tiles start there), as one undo step; reset → the placement places them again → {tileOrigin}",
has_doc,
set_origin
),
cmd!(
query "print.tiling",
"Print Tiling Pages",
[],
None,
"{settings?: {…print.setup settings} (over the document's)} the pages as View → Show Print Tiling draws them, from print.preview's layout → {tileOrigin, pages: [{artboard (0-based; null with artboards ignored), number (1-based tile across then down, or page), printed (tiles outside tileRange don't), page: [x0, y0, x1, y1] (the paper's edge), imageable: [x0, y0, x1, y1] (inside the unprintable margin)}] (document space; every tile when tiling, else each page once)}",
has_doc,
tiling
),
]
}
fn show(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc_mut()?;
st.print_tiling = p.get("on").and_then(Value::as_bool).unwrap_or(!st.print_tiling);
Ok(json!({ "on": st.print_tiling }))
}
/// The pages of `doc`'s print settings in document space (what View → Show Print Tiling draws);
/// an error when they can't print.
pub fn pages(doc: &Document) -> Result<Vec<TilingPage>> {
pages_with(doc, &json!({}))
}
fn pages_with(doc: &Document, p: &Value) -> Result<Vec<TilingPage>> {
const C: &str = "print.tiling";
let set = settings(C, doc, p)?;
let pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
Ok(vectorcraft_pdf::tiling(&pv, &set))
}
fn tiling(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let origin = settings("print.tiling", doc, p)?.tile_origin;
Ok(json!({ "tileOrigin": origin, "pages": pages_with(doc, p)? }))
}
/// `[x, y]` at `key` of `p`.
fn point(p: &Value, key: &str) -> Option<Point> {
let a = p.get(key)?.as_array()?;
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}
fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let current = settings(SET, doc, &json!({}))?;
let mut set = current.clone();
set.tile_origin = if bool_or(p, "reset", false) {
TileOrigin::default()
} else {
let at = point(p, "origin").filter(|q| q.x.is_finite() && q.y.is_finite());
let at = at.ok_or_else(|| bad(SET, "give origin: [x, y], or reset: true"))?;
let regions = vectorcraft_pdf::print_regions(doc, &set).map_err(|e| pdf_error(SET, e))?;
let picked = match p.get("artboard").filter(|v| !v.is_null()) {
Some(v) => {
let i = v.as_u64().ok_or_else(|| bad(SET, "artboard must be a 0-based index"))? as usize;
regions.iter().find(|(a, _)| a.is_none() || *a == Some(i)).ok_or_else(|| bad(SET, format!("artboard {i} doesn't print")))?
}
None => regions.iter().find(|(_, r)| r.contains(at)).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
};
let corner = picked.1.origin();
TileOrigin { placed: true, x: at.x - corner.x, y: at.y - corner.y }
};
set.check().map_err(|e| pdf_error(SET, e))?;
if set != current {
let stored = to_json(&set)?;
s.edit("Print Tiling", |d, _| {
d.print_setup = Some(stored);
Ok(())
})?;
}
Ok(json!({ "tileOrigin": set.tile_origin }))
}
+37
View File
@@ -58,6 +58,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
fit_headline
),
cmd!(
"text.discardEmpty",
"Discard Empty Type",
[],
None,
"{id} remove text object `id` if it has no characters (what the Type tool does with point type it placed when editing ends). When nothing but that text changed since the step that created it, the steps since are dropped instead, leaving no trace in the history → {removed}",
has_doc,
discard_empty
),
]
}
@@ -449,3 +458,31 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "tracking": applied}))
}
fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.discardEmpty";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
if edit::runs_len(&text_ref(s, id)?.runs) > 0 {
return Ok(json!({"removed": false}));
}
// The newest step whose document lacks the text created it: when the document now is that one
// plus the text (and its id), roll back to it without a trace.
let st = s.doc_mut()?;
if st.interaction.is_none()
&& let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node(id).is_none())
&& let Some(created) = st.history.undo.get(k)
{
let mut now = (*st.doc).clone();
let mut before = (*created.doc).clone();
before.alloc_id();
if now.remove(id).is_ok() && now == before {
st.doc = created.doc.clone();
st.history.undo.truncate(k);
st.selection.prune(&st.doc);
st.revision += 1;
return Ok(json!({"removed": true}));
}
}
s.edit("Discard Empty Type", |d, _| d.remove(id).map(|_| ()).map_err(|_| EngineError::NoNode(id)))?;
Ok(json!({"removed": true}))
}
+6
View File
@@ -25,6 +25,10 @@ pub fn node_summary(n: &Node) -> Value {
if n.opacity < 1.0 {
v["opacity"] = json!(n.opacity);
}
// The angle of its rotated bounding box (counter-clockwise degrees).
if n.bbox_angle != 0.0 {
v["rotation"] = json!(n.bbox_angle);
}
if !n.is_container() {
v["fill"] = json!(n.appearance.fill_paint().label());
v["stroke"] = json!(n.appearance.stroke_paint().label());
@@ -64,6 +68,8 @@ pub fn document(s: &Session) -> Value {
"isolation": st.isolation.map(|l| l.0),
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
"selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)),
// The angle the selection's bounding box stands at (counter-clockwise degrees).
"selectionRotation": d.bbox_angle(&st.selection.objects),
// The layer, group or object targeted through the Layers panel (`layer.target`).
"target": st.selection.target.map(|t| t.0),
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
+33
View File
@@ -129,6 +129,8 @@ pub struct DocState {
pub recovered: bool,
/// The document's Data Recovery copy, once one was written ([`cmd::recovery`]).
pub recovery: Option<cmd::recovery::RecoveryCopy>,
/// View → Show Print Tiling, per document (view state: not saved, not undoable).
pub print_tiling: bool,
}
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -160,6 +162,7 @@ impl DocState {
view,
recovered: false,
recovery: None,
print_tiling: false,
}
}
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
@@ -469,6 +472,10 @@ pub struct Prefs {
/// Preferences dialog field: resetting the preferences keeps it; `print.presets.*` edit it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub print_presets: Vec<cmd::printpresets::PrintPreset>,
/// Constrain Width and Height Proportions: the link between W and H in the Transform panel,
/// the Properties panel and the Control bar (their size fields pass `proportional` to
/// `object.setBounds`).
pub constrain_proportions: bool,
}
impl Default for Prefs {
@@ -601,6 +608,7 @@ impl Default for Prefs {
web_export_presets: vec![],
web_export_settings: None,
print_presets: vec![],
constrain_proportions: false,
}
}
}
@@ -665,6 +673,9 @@ pub struct Session {
pub(crate) batch_stash: Option<Vec<DocState>>,
/// Where Data Recovery keeps its copies ([`cmd::recovery`]).
pub recovery: cmd::recovery::Recovery,
/// While the active tool's actions run: their commands aren't "a command from outside the
/// tool" ([`Session::after_command`]).
pub(crate) in_tool_actions: bool,
}
impl Default for Session {
@@ -705,6 +716,7 @@ impl Session {
note_depth: 1,
batch_stash: None,
recovery: Default::default(),
in_tool_actions: false,
}
}
@@ -858,6 +870,9 @@ impl Session {
let p = self.noted(params);
self.journal.push((id.to_string(), p));
}
if self.depth == 0 {
self.after_command();
}
Ok(r)
}
@@ -1085,12 +1100,16 @@ impl Session {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_adjust;
#[cfg(test)]
mod tests_appearance;
#[cfg(test)]
mod tests_assets;
#[cfg(test)]
mod tests_attributes;
#[cfg(test)]
mod tests_bboxrotate;
#[cfg(test)]
mod tests_brushsym;
#[cfg(test)]
mod tests_build;
@@ -1121,6 +1140,8 @@ mod tests_containers;
#[cfg(test)]
mod tests_css;
#[cfg(test)]
mod tests_cut;
#[cfg(test)]
mod tests_dashalign;
#[cfg(test)]
mod tests_distort;
@@ -1133,6 +1154,8 @@ mod tests_editcolors;
#[cfg(test)]
mod tests_effectedit;
#[cfg(test)]
mod tests_emptytype;
#[cfg(test)]
mod tests_expand;
#[cfg(test)]
mod tests_eyedropper;
@@ -1149,12 +1172,16 @@ mod tests_flatten;
#[cfg(test)]
mod tests_focal;
#[cfg(test)]
mod tests_fontlist;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_gradient;
#[cfg(test)]
mod tests_gradpanel;
#[cfg(test)]
mod tests_halftone;
#[cfg(test)]
mod tests_journal;
#[cfg(test)]
mod tests_knockout;
@@ -1209,18 +1236,24 @@ mod tests_pdfraster;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_plugins;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_previewbounds;
#[cfg(test)]
mod tests_print;
#[cfg(test)]
mod tests_printadvanced;
#[cfg(test)]
mod tests_printpresets;
#[cfg(test)]
mod tests_printpreview;
#[cfg(test)]
mod tests_printps;
#[cfg(test)]
mod tests_printtiling;
#[cfg(test)]
mod tests_proxyitems;
#[cfg(test)]
mod tests_rastersettings;
+137
View File
@@ -0,0 +1,137 @@
//! Live colour adjustments (Effect → Color Adjustments) through the commands: applied, listed,
//! exported, expanded and kept in the native file.
use serde_json::json;
use vectorcraft_doc::{Node, NodeKind};
use super::*;
pub(crate) fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
pub(crate) fn red_rect(s: &mut Session) -> NodeId {
let id = NodeId(s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
id
}
pub(crate) fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
pub(crate) fn fill_hex(n: &Node) -> String {
n.appearance.fill_paint().color().unwrap().to_hex()
}
/// A selected embedded 4×4 red image.
fn red_image(s: &mut Session) -> NodeId {
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.edit("Place", |d, sel| {
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png));
let id = d.alloc_id();
let im = vectorcraft_doc::ImageObject {
key: "pic".into(),
width: 4,
height: 4,
xf: vectorcraft_geom::Affine::scale(10.0),
link: None,
placement: Default::default(),
};
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?;
sel.set([id]);
Ok(id)
})
.unwrap()
}
#[test]
fn the_effects_are_in_the_catalog_under_color_adjustments() {
let mut s = session();
let v = s.execute("effect.list", &json!({})).unwrap();
let ids: Vec<&str> =
v["catalog"].as_array().unwrap().iter().filter(|e| e["menu"][1] == "Color Adjustments").map(|e| e["id"].as_str().unwrap()).collect();
for id in vectorcraft_render::effects::ADJUSTMENTS {
assert!(ids.contains(&id), "{id} in {ids:?}");
}
}
#[test]
fn an_adjustment_is_live_and_one_undo_step() {
let mut s = session();
let id = red_rect(&mut s);
let undo = s.doc().unwrap().history.undo.len();
s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
// Non-destructive: the fill stays red, the effect sits on the object.
let n = node(&s, id);
assert_eq!(fill_hex(&n), "#ff0000");
assert_eq!(n.appearance.effects.len(), 1);
// Drawn green.
let img =
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true);
assert_eq!(img.pixel(50, 50), [0, 255, 0, 255]);
// Editing the parameters changes it live.
s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"hue": -120}})).unwrap();
let img =
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true);
assert_eq!(img.pixel(50, 50), [0, 0, 255, 255]);
}
#[test]
fn export_and_expand_write_the_adjusted_colours() {
let mut s = session();
let id = red_rect(&mut s);
s.execute("effect.apply", &json!({"effect": "adjust.levels", "params": {"outputWhite": 128, "channel": "red"}})).unwrap();
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
assert!(svg.contains("#800000") && !svg.contains("#ff0000"), "{svg}");
// Expand Appearance makes it permanent.
s.execute("effect.expandAppearance", &json!({})).unwrap();
let n = node(&s, id);
assert!(n.appearance.effects.is_empty());
assert_eq!(fill_hex(&n), "#800000");
}
#[test]
fn an_embedded_image_is_adjusted_for_export_without_changing_its_pixels() {
let mut s = session();
let id = red_image(&mut s);
s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap();
let doc = s.doc().unwrap().doc.clone();
let baked = vectorcraft_render::effects::bake_document(&doc).unwrap();
let NodeKind::Image(im) = &baked.node(id).unwrap().kind else { panic!("not an image") };
assert_ne!(im.key, "pic", "a recoloured copy");
let px = image::load_from_memory(&baked.images[&im.key].bytes).unwrap().to_rgba8();
assert_eq!(px.get_pixel(1, 1).0, [0, 255, 0, 255]);
assert_eq!(doc.images.len(), 1, "the document keeps its one image");
// Through Expand Appearance too.
s.execute("effect.expandAppearance", &json!({})).unwrap();
let NodeKind::Image(im) = &node(&s, id).kind else { panic!("not an image") };
let px = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8();
assert_eq!(px.get_pixel(0, 0).0, [0, 255, 0, 255]);
}
#[test]
fn adjustments_survive_the_native_file() {
let mut s = session();
red_rect(&mut s);
s.execute("effect.apply", &json!({"effect": "adjust.curves", "params": {"points": "0,0 64,32 255,255", "channel": "green"}})).unwrap();
let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap();
let mut found = None;
o.doc().unwrap().doc.walk(|n| {
if let Some(e) = n.appearance.effects.first() {
found = Some(e.clone());
}
});
let e = found.expect("the effect is kept");
assert_eq!(e.id, "adjust.curves");
assert_eq!(e.params["points"], "0,0 64,32 255,255");
}
+211
View File
@@ -0,0 +1,211 @@
//! Rotated bounding boxes (#39): a rotation keeps the objects' angle, the bounding box and its
//! handles turn with them, sizes are measured along their own axes, and Reset Bounding Box squares
//! the box to the page again.
use serde_json::{Value, json};
use vectorcraft_doc::OrientedBox;
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::bbox::Handle;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn sel(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn angle(s: &Session, id: NodeId) -> f64 {
s.doc().unwrap().doc.node(id).unwrap().bbox_angle
}
fn selection_box(s: &Session) -> OrientedBox {
s.transform_box(&s.doc().unwrap().selection.objects).unwrap()
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
/// A 100 × 50 rectangle centred on (200, 200), selected and turned to 45°.
fn turned_rect(s: &mut Session) -> NodeId {
let a = rect(s, 150.0, 175.0, 100.0, 50.0);
sel(s, &[a]);
s.execute("object.rotate", &json!({"angle": 45, "absolute": true})).unwrap();
a
}
#[test]
fn rotating_to_45_keeps_45() {
let mut s = session();
let n = undo_len(&s);
let a = turned_rect(&mut s);
assert_eq!(undo_len(&s), n + 2, "the rectangle, then one step for the rotation");
// The panel's angle and the inspector's.
let b = selection_box(&s);
assert!(near(b.angle, 45.0) && near(angle(&s, a), 45.0), "{b:?}");
assert!(near(s.execute("document.inspect", &json!({})).unwrap()["selectionRotation"].as_f64().unwrap(), 45.0));
// The box hugs the rectangle: its own sides, its own centre.
assert!(near(b.rect.width(), 100.0) && near(b.rect.height(), 50.0), "{b:?}");
assert!(b.center().distance(Point::new(200.0, 200.0)) < 1e-6);
// Absolute angles: entering 45 again changes nothing, 90 turns it by 45 more.
s.execute("object.rotate", &json!({"angle": 45, "absolute": true})).unwrap();
assert!(near(angle(&s, a), 45.0));
s.execute("object.rotate", &json!({"angle": 90, "absolute": true})).unwrap();
assert!(near(angle(&s, a), 90.0));
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
assert!(near(b.width(), 50.0) && near(b.height(), 100.0), "{b:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(angle(&s, a), 45.0));
}
#[test]
fn handles_turn_with_the_object_and_scale_along_its_axes() {
let mut s = session();
let a = turned_rect(&mut s);
let b = selection_box(&s);
// The Right handle sits on the turned box, 50 pt from the centre along 45° (up and right).
let right = b.to_doc() * Handle::Right.pos(b.rect);
let d = std::f64::consts::FRAC_1_SQRT_2 * 50.0;
assert!(right.distance(Point::new(200.0 + d, 200.0 - d)) < 1e-6, "{right:?}");
// Drag it 50 pt further out along the object's own x axis.
let to = Point::new(right.x + d, right.y - d);
let n = undo_len(&s);
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
for (kind, p) in [(PointerKind::Down, right), (PointerKind::Drag, to), (PointerKind::Up, to)] {
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap();
}
assert_eq!(undo_len(&s), n + 1);
// 150 × 50 along its own axes, still at exactly 45°, the left side where it was.
assert_eq!(angle(&s, a), b.angle);
let nb = selection_box(&s);
assert!(near(nb.rect.width(), 150.0) && near(nb.rect.height(), 50.0), "{nb:?}");
let left = |b: &OrientedBox| b.to_doc() * Handle::Left.pos(b.rect);
assert!(left(&nb).distance(left(&b)) < 1e-6);
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(selection_box(&s).rect.width(), 100.0));
}
#[test]
fn transform_panel_sizes_follow_the_turned_box() {
let mut s = session();
let a = turned_rect(&mut s);
let n = undo_len(&s);
s.execute("object.setBounds", &json!({"width": 200, "reference": 4})).unwrap();
assert_eq!(undo_len(&s), n + 1);
let b = selection_box(&s);
assert!(near(b.rect.width(), 200.0) && near(b.rect.height(), 50.0) && near(b.angle, 45.0), "{b:?}");
assert!(b.center().distance(Point::new(200.0, 200.0)) < 1e-6);
// X/Y place the box's own reference point on the page.
s.execute("object.setBounds", &json!({"x": 300, "y": 100, "reference": 0})).unwrap();
let b = selection_box(&s);
assert!(b.reference_point(0).distance(Point::new(300.0, 100.0)) < 1e-6, "{b:?}");
assert!(near(angle(&s, a), 45.0));
}
#[test]
fn reset_bounding_box_squares_the_box_in_one_step() {
let mut s = session();
let a = turned_rect(&mut s);
let geometry = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
let n = undo_len(&s);
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
assert_eq!(undo_len(&s), n + 1);
assert_eq!(angle(&s, a), 0.0);
// The art stays; the box is square to the page around it.
let b = selection_box(&s);
assert_eq!(b.angle, 0.0);
assert_eq!(b.rect, geometry);
// Nothing left to reset: no step.
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0);
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(angle(&s, a), 45.0));
}
#[test]
fn page_flips_keep_an_upright_box_upright() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
sel(&mut s, &[a]);
s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
assert_eq!(angle(&s, a), 0.0);
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
assert!(near(angle(&s, a), -30.0));
}
#[test]
fn groups_type_and_multiple_selections_keep_their_angle() {
let mut s = session();
// Objects turned together share the box.
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
let b = rect(&mut s, 300.0, 100.0, 100.0, 50.0);
sel(&mut s, &[a, b]);
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
assert!(near(selection_box(&s).angle, 20.0) && near(angle(&s, a), 20.0) && near(angle(&s, b), 20.0));
// Mixed angles: the box is square to the page.
let c = rect(&mut s, 100.0, 300.0, 50.0, 50.0);
sel(&mut s, &[a, c]);
assert_eq!(selection_box(&s).angle, 0.0);
// A group turns as one; its members keep their own angles inside it.
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
assert_eq!(selection_box(&s).angle, 0.0);
s.execute("object.rotate", &json!({"angle": 30, "absolute": true})).unwrap();
assert!(near(angle(&s, g), 30.0) && near(angle(&s, a), 50.0) && near(angle(&s, c), 30.0));
s.execute("object.ungroup", &json!({})).unwrap();
sel(&mut s, &[c]);
let cb = selection_box(&s);
assert!(near(cb.angle, 30.0) && near(cb.rect.width(), 50.0) && near(cb.rect.height(), 50.0), "{cb:?}");
// Type keeps its angle too.
let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Turn"})).unwrap());
sel(&mut s, &[t]);
let before = selection_box(&s).rect;
s.execute("object.rotate", &json!({"angle": 60, "absolute": true})).unwrap();
let tb = selection_box(&s);
assert!(near(tb.angle, 60.0), "{tb:?}");
assert!(near(tb.rect.width(), before.width()) && near(tb.rect.height(), before.height()), "{tb:?} vs {before:?}");
}
#[test]
fn native_files_keep_the_angle() {
let mut s = session();
let plain = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let a = turned_rect(&mut s);
let data = s.execute("document.save", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string();
let text = String::from_utf8(vectorcraft_format::base64_decode(&data).unwrap()).unwrap();
// Only turned objects write it.
assert_eq!(text.matches("bbox_angle").count(), 1);
s.execute("document.open", &json!({"name": "turned.vectorcraft", "dataBase64": data})).unwrap();
assert!(near(angle(&s, a), 45.0));
assert_eq!(angle(&s, plain), 0.0);
sel(&mut s, &[a]);
assert!(near(selection_box(&s).rect.width(), 100.0));
}
#[test]
fn rotated_box_rect_matches_page_bounds_when_upright() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 30.0, 40.0);
sel(&mut s, &[a]);
assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(10.0, 20.0, 40.0, 60.0)));
}
+268
View File
@@ -0,0 +1,268 @@
//! Tests for the cutting commands: Mirror & Cut, Line Cut and Rectangle Cut.
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::{FillRule, PathData, Rect};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn path(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId {
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap())
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
}
fn geometry(s: &Session, id: NodeId) -> (PathData, FillRule) {
let n = s.doc().unwrap().doc.node(id).cloned().unwrap();
match &n.kind {
NodeKind::Path { path, rule, .. } => (path.clone(), *rule),
NodeKind::Compound { children, rule } => {
(PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()), *rule)
}
_ => panic!("not a path"),
}
}
fn area(s: &Session, id: NodeId) -> f64 {
let (p, r) = geometry(s, id);
vectorcraft_pathops::area(&p, r)
}
fn bounds(s: &Session, id: NodeId) -> Rect {
geometry(s, id).0.bounds().unwrap()
}
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(|(p, q)| (p - q).abs() < 0.05)
}
fn undo_depth(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn line_cut_splits_a_shape_in_one_undo_step() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
select(&mut s, &[r]);
let before = undo_depth(&s);
let out = s.execute("path.lineCut", &json!({"from": [30, -10], "to": [30, 10]})).unwrap();
let pieces = ids(&out);
assert_eq!(pieces.len(), 2);
let mut areas: Vec<f64> = pieces.iter().map(|id| area(&s, *id)).collect();
areas.sort_by(f64::total_cmp);
assert!((areas[0] - 3000.0).abs() < 1.0 && (areas[1] - 7000.0).abs() < 1.0, "{areas:?}");
// The pieces are selected, and the cut is one undo step.
assert_eq!(s.doc().unwrap().selection.len(), 2);
assert_eq!(undo_depth(&s), before + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!((area(&s, r) - 10_000.0).abs() < 1.0);
}
#[test]
fn line_cut_keeps_compound_holes_and_splits_open_paths() {
let mut s = session();
let outer = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
// Wound against the outer rectangle, so the non-zero compound has a hole.
let hole = path(&mut s, &[(40.0, 40.0), (40.0, 60.0), (60.0, 60.0), (60.0, 40.0)], true);
select(&mut s, &[outer, hole]);
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
assert!((area(&s, c) - 9600.0).abs() < 1.0);
let line = path(&mut s, &[(0.0, 200.0), (100.0, 200.0)], false);
select(&mut s, &[c, line]);
// A vertical line at x = 20 misses the hole: the right piece keeps it.
let out = s.execute("path.lineCut", &json!({"from": [20, 0], "to": [20, 300]})).unwrap();
let pieces = ids(&out);
assert_eq!(pieces.len(), 4, "two compound pieces and two line pieces");
let doc = &s.doc().unwrap().doc;
let compounds: Vec<NodeId> = pieces.iter().copied().filter(|id| matches!(doc.node(*id).unwrap().kind, NodeKind::Compound { .. })).collect();
assert_eq!(compounds.len(), 2, "both pieces of a compound path stay compound paths");
let mut areas: Vec<f64> = compounds.iter().map(|id| area(&s, *id)).collect();
areas.sort_by(f64::total_cmp);
assert!((areas[0] - 2000.0).abs() < 1.0 && (areas[1] - 7600.0).abs() < 1.0, "{areas:?}");
let open: Vec<Rect> = pieces.iter().filter(|id| !compounds.contains(id)).map(|id| bounds(&s, *id)).collect();
assert!(open.iter().any(|b| (b.x1 - 20.0).abs() < 0.01) && open.iter().any(|b| (b.x0 - 20.0).abs() < 0.01), "{open:?}");
}
#[test]
fn line_cut_that_misses_changes_nothing() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
select(&mut s, &[r]);
let before = undo_depth(&s);
let out = s.execute("path.lineCut", &json!({"from": [200, 0], "to": [200, 10]})).unwrap();
assert!(ids(&out).is_empty());
assert_eq!(undo_depth(&s), before);
assert!(s.execute("path.lineCut", &json!({"from": [5, 5], "to": [5, 5]})).is_err());
assert!(s.execute("path.lineCut", &json!({"from": [5, 5]})).is_err());
}
#[test]
fn rect_cut_crops_to_real_geometry() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let outside = rect(&mut s, 300.0, 300.0, 10.0, 10.0);
let inside = rect(&mut s, 60.0, 20.0, 10.0, 10.0);
let line = path(&mut s, &[(0.0, 50.0), (200.0, 50.0)], false);
select(&mut s, &[a, outside, inside, line]);
let out = s.execute("path.rectCut", &json!({"rect": [50, 0, 100, 200]})).unwrap();
assert_eq!(out["removed"], 1);
let doc = &s.doc().unwrap().doc;
assert!(doc.node(outside).is_none(), "art wholly outside is deleted");
assert!(matches!(doc.node(a).unwrap().kind, NodeKind::Path { .. }), "no clipping mask");
assert!(near(bounds(&s, a), Rect::new(50.0, 0.0, 100.0, 100.0)));
assert!((area(&s, a) - 5000.0).abs() < 1.0);
assert!(near(bounds(&s, inside), Rect::new(60.0, 20.0, 70.0, 30.0)), "art inside is untouched");
assert!(near(bounds(&s, line), Rect::new(50.0, 50.0, 150.0, 50.0)));
// Two corners work too.
select(&mut s, &[a]);
s.execute("path.rectCut", &json!({"from": [100, 100], "to": [75, 50]})).unwrap();
assert!(near(bounds(&s, a), Rect::new(75.0, 50.0, 100.0, 100.0)));
assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 0, 10]})).is_err());
assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 10]})).is_err());
}
#[test]
fn mirror_cut_makes_a_symmetric_shape() {
let mut s = session();
// A right triangle: mirrored at x = 50 keeping the left, it becomes a house-like pentagon.
let t = path(&mut s, &[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)], true);
select(&mut s, &[t]);
let out = s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap();
assert_eq!(ids(&out), vec![t]);
let (pd, _) = geometry(&s, t);
assert_eq!(pd.subpaths.len(), 1, "both halves are joined into one closed path");
assert!(pd.subpaths[0].closed);
assert!((area(&s, t) - 7500.0).abs() < 1.0);
assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 100.0)));
// Keeping the right half instead.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0], "keep": "right"})).unwrap();
assert!((area(&s, t) - 2500.0).abs() < 1.0);
assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 50.0)));
}
#[test]
fn mirror_cut_axes_and_sides() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 40.0, 100.0);
let gone = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
select(&mut s, &[a, gone]);
// A free axis at x = 50 (drawn upward): art on the discarded side is deleted.
let out = s.execute("path.mirrorCut", &json!({"axis": "free", "from": [50, 100], "to": [50, 0], "keep": "left"})).unwrap();
assert_eq!(out["removed"], 1);
assert!(s.doc().unwrap().doc.node(gone).is_none());
// Wholly on the kept side: the shape and its reflection, one object.
let (pd, _) = geometry(&s, a);
assert_eq!(pd.subpaths.len(), 2);
assert!(near(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0)));
// A horizontal axis through the selection's centre keeps the top by default.
let b = rect(&mut s, 0.0, 200.0, 100.0, 60.0);
select(&mut s, &[b]);
s.execute("path.mirrorCut", &json!({"axis": "horizontal"})).unwrap();
assert!(near(bounds(&s, b), Rect::new(0.0, 200.0, 100.0, 260.0)));
assert!(s.execute("path.mirrorCut", &json!({"axis": "vertical", "keep": "top"})).is_err());
assert!(s.execute("path.mirrorCut", &json!({"axis": "diagonal"})).is_err());
assert!(s.execute("path.mirrorCut", &json!({"axis": "free", "from": [1, 1], "to": [1, 1]})).is_err());
}
#[test]
fn mirror_cut_closes_an_open_half() {
let mut s = session();
let p = path(&mut s, &[(50.0, 0.0), (0.0, 50.0), (50.0, 100.0)], false);
select(&mut s, &[p]);
s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap();
let (pd, _) = geometry(&s, p);
assert_eq!(pd.subpaths.len(), 1);
assert!(pd.subpaths[0].closed);
assert_eq!(pd.subpaths[0].anchors.len(), 4);
assert!(near(bounds(&s, p), Rect::new(0.0, 0.0, 100.0, 100.0)));
}
#[test]
fn cut_commands_need_paths() {
let mut s = session();
assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err(), "nothing selected");
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A"})).unwrap());
select(&mut s, &[t]);
assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err());
}
mod tools {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
use crate::tooling::ViewInfo;
fn view() -> ViewInfo {
ViewInfo { smart_guides: false, ..Default::default() }
}
fn press(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), view()).unwrap();
}
#[test]
fn line_cut_tool_previews_live_and_commits_one_step() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
select(&mut s, &[r]);
let before = undo_depth(&s);
s.select_tool("lineCut", view()).unwrap();
press(&mut s, PointerKind::Down, 50.0, -20.0);
press(&mut s, PointerKind::Drag, 50.0, 40.0);
// The preview already shows the cut.
assert!(s.in_interaction());
assert_eq!(s.doc().unwrap().selection.len(), 2);
press(&mut s, PointerKind::Drag, 50.0, 120.0);
press(&mut s, PointerKind::Up, 50.0, 120.0);
assert!(!s.in_interaction());
assert_eq!(undo_depth(&s), before + 1);
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.lineCut"));
assert!((area(&s, r) - 5000.0).abs() < 1.0);
s.execute("edit.undo", &json!({})).unwrap();
assert!((area(&s, r) - 10_000.0).abs() < 1.0);
}
#[test]
fn rect_and_mirror_tools_cut_and_escape_restores() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
select(&mut s, &[r]);
s.select_tool("rectCut", view()).unwrap();
press(&mut s, PointerKind::Down, 50.0, 50.0);
press(&mut s, PointerKind::Drag, 150.0, 150.0);
assert!(near(bounds(&s, r), Rect::new(50.0, 50.0, 100.0, 100.0)));
s.tool_key(ToolKey::Escape, Mods::default(), view()).unwrap();
assert!(!s.in_interaction());
assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 100.0, 100.0)));
// Mirror & Cut with a vertical axis placed by a click at x = 80.
s.select_tool("mirrorCut", view()).unwrap();
s.set_tool_option("axis", &json!("vertical"));
press(&mut s, PointerKind::Down, 80.0, 50.0);
press(&mut s, PointerKind::Up, 80.0, 50.0);
assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 160.0, 100.0)));
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.mirrorCut"));
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Point type placed with the Type tool and left empty is discarded when editing ends, without a
//! trace in the history; frames (dragged area type, type in or on a path) stay.
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
let v = ViewInfo::default();
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap();
if from != to {
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap();
}
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap();
}
fn click(s: &mut Session, x: f64, y: f64) {
drag(s, (x, y), (x, y));
}
fn key(s: &mut Session, k: ToolKey) {
s.tool_key(k, Mods::default(), ViewInfo::default()).unwrap();
}
/// The text objects in the document: (id, kind is point type, plain text).
fn texts(s: &Session) -> Vec<(NodeId, bool, String)> {
let mut v = vec![];
s.doc().unwrap().doc.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
v.push((n.id, matches!(t.kind, TextKind::Point), t.plain_text()));
}
});
v
}
fn undo_labels(s: &Session) -> Vec<String> {
s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect()
}
#[test]
fn clicking_without_typing_leaves_no_text_and_no_history() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
// Click, then Escape.
click(&mut s, 100.0, 100.0);
assert_eq!(texts(&s).len(), 1, "the click places point type to edit");
assert!(s.tool_wants_text());
key(&mut s, ToolKey::Escape);
assert!(texts(&s).is_empty());
// Click, then switch tools.
click(&mut s, 150.0, 100.0);
s.select_tool("rectangle", v).unwrap();
assert!(texts(&s).is_empty());
// Click in two places: the first is gone when the second is placed.
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 300.0);
click(&mut s, 400.0, 300.0);
assert_eq!(texts(&s).len(), 1);
s.select_tool("selection", v).unwrap();
assert!(texts(&s).is_empty());
assert!(undo_labels(&s).is_empty(), "{:?}", undo_labels(&s));
let st = s.doc().unwrap();
assert!(!st.is_dirty() && st.selection.is_empty());
}
#[test]
fn typed_text_stays_and_typing_it_away_leaves_nothing() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("Hi", v).unwrap();
key(&mut s, ToolKey::Escape);
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Hi"]);
let kept = undo_labels(&s);
assert_eq!(kept, ["Type", "Typing"]);
// Typed, the caret moved (a step of its own), then deleted: all of it goes.
click(&mut s, 300.0, 300.0);
s.tool_text("a", v).unwrap();
key(&mut s, ToolKey::Left);
key(&mut s, ToolKey::Delete);
key(&mut s, ToolKey::Escape);
assert_eq!(texts(&s).len(), 1);
assert_eq!(undo_labels(&s), kept);
}
#[test]
fn frames_stay_when_left_empty() {
let mut s = session();
let v = ViewInfo::default();
// A dragged area-type frame.
s.select_tool("type", v).unwrap();
drag(&mut s, (100.0, 100.0), (300.0, 200.0));
key(&mut s, ToolKey::Escape);
assert!(matches!(texts(&s).as_slice(), [(_, false, t)] if t.is_empty()));
// Area type made from a path.
s.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 200, "height": 100})).unwrap();
s.select_tool("areaType", v).unwrap();
click(&mut s, 500.0, 300.0);
s.select_tool("selection", v).unwrap();
assert_eq!(texts(&s).len(), 2);
assert_eq!(undo_labels(&s), ["Type", "Rectangle", "Area Type"]);
}
#[test]
fn a_command_that_deselects_the_text_ends_the_edit() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
// Commands on the edited text (the Character panel) keep editing it.
let id = texts(&s)[0].0;
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 30})).unwrap();
s.execute("document.inspect", &json!({})).unwrap();
assert!(s.tool_wants_text());
s.execute("select.none", &json!({})).unwrap();
assert!(!s.tool_wants_text());
assert!(texts(&s).is_empty());
assert!(undo_labels(&s).is_empty(), "{:?}", undo_labels(&s));
// A command that edits something else as well: the text goes in a step of its own.
click(&mut s, 100.0, 100.0);
s.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 20, "height": 10})).unwrap();
assert!(!s.tool_wants_text());
assert!(texts(&s).is_empty());
assert_eq!(undo_labels(&s), ["Type", "Rectangle", "Discard Empty Type"]);
}
#[test]
fn switching_documents_discards_it_in_the_document_left() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s.set_active(0);
assert!(texts(&s).is_empty() && undo_labels(&s).is_empty());
}
#[test]
fn discard_empty_command() {
let mut s = session();
let r = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap();
assert_eq!(s.execute("text.discardEmpty", &json!({"id": r["id"]})).unwrap(), json!({"removed": false}));
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
assert!(s.execute("text.discardEmpty", &json!({"id": rect["id"]})).is_err(), "not text");
assert!(s.execute("text.discardEmpty", &json!({})).is_err(), "id required");
// Created by the newest step: that step goes.
let e = s.execute("text.create", &json!({"x": 10, "y": 80, "text": ""})).unwrap();
assert_eq!(s.execute("text.discardEmpty", &json!({"id": e["id"]})).unwrap(), json!({"removed": true}));
assert_eq!(undo_labels(&s), ["Type", "Rectangle"]);
assert_eq!(texts(&s).len(), 1);
}
+46
View File
@@ -0,0 +1,46 @@
//! The fonts available (`text.fontList`), the installed ones listed without asking for them (#36),
//! and Refresh Font List (`text.rescanFonts`).
use serde_json::{Value, json};
use vectorcraft_text::{FontDb, system_font_dirs};
use super::*;
fn names(v: &Value) -> Vec<String> {
serde_json::from_value(v.clone()).unwrap()
}
#[test]
fn the_font_list_names_every_available_family_with_its_styles() {
let mut s = Session::new();
let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]);
assert_eq!(fams, FontDb::global().families());
for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] {
assert!(fams.iter().any(|x| x == f), "{f}");
}
// Every installed family, though nothing asked for system fonts.
let installed = FontDb::with_font_dirs(system_font_dirs());
installed.load_system_fonts();
let missing: Vec<String> = installed.families().into_iter().filter(|f| !fams.contains(f)).collect();
assert!(missing.is_empty(), "{missing:?}");
let r = s.execute("text.fontList", &json!({"family": "source sans 3"})).unwrap();
assert_eq!(r["family"], "Source Sans 3");
let styles = names(&r["styles"]);
assert!(["Regular", "Semibold", "Bold", "Italic"].iter().all(|x| styles.iter().any(|s| s == x)), "{styles:?}");
assert!(s.execute("text.fontList", &json!({"family": "No Such Font"})).is_err());
}
#[test]
fn refreshing_the_font_list_redraws_type_without_editing_the_document() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Type", "font": "No Such Font"})).unwrap();
s.doc_mut().unwrap().mark_saved();
let revision = s.doc().unwrap().revision;
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
assert_eq!(r["families"], json!(FontDb::global().families().len()));
assert!(r["faces"].is_u64());
let d = s.doc().unwrap();
assert!(d.revision > revision, "the canvas redraws");
assert!(!d.is_dirty(), "nothing to save");
}
+53
View File
@@ -0,0 +1,53 @@
//! Object → Vector Halftone.
use serde_json::json;
use super::*;
use crate::tests_adjust::{fill_hex, node, red_rect, session};
#[test]
fn halftone_turns_art_into_dots_by_tone() {
let mut s = session();
let id = red_rect(&mut s);
s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("object.vectorHalftone", &json!({"frequency": 18})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert!(s.doc().unwrap().doc.node(id).is_none(), "the original is replaced");
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
let dots = r["dots"].as_u64().unwrap();
// 60 pt at 18 lpi (4 pt cells) turned 45°: about 225 cells, all black.
assert!((180..=320).contains(&dots), "{dots}");
let ch = g.children().unwrap();
assert_eq!(ch.len(), 1);
let (pd, b) = (ch[0].path_data().unwrap(), ch[0].geometric_bounds().unwrap());
assert!(pd.subpaths.len() as u64 >= dots / 2);
// Clipped to the art.
assert!(b.x0 >= 19.9 && b.y0 >= 19.9 && b.x1 <= 80.1 && b.y1 <= 80.1, "{b:?}");
}
#[test]
fn halftone_options() {
let mut s = session();
let id = red_rect(&mut s);
// Red art in CMYK: magenta and yellow screens, no cyan or black.
let r = s.execute("object.vectorHalftone", &json!({"mode": "cmyk", "shape": "square", "keepOriginal": true})).unwrap();
assert!(s.doc().unwrap().doc.node(id).is_some(), "the original is kept");
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
let inks: Vec<String> = g.children().unwrap().iter().map(|c| fill_hex(c)).collect();
assert_eq!(inks.len(), 2, "{inks:?}");
assert!(g.children().unwrap().iter().all(|c| c.blend == vectorcraft_color::BlendMode::Multiply));
// Light art gets no dots; inverted, it does.
let mut s = session();
let id = red_rect(&mut s);
s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap();
assert!(s.execute("object.vectorHalftone", &json!({})).is_err());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
assert!(s.execute("object.vectorHalftone", &json!({"invert": true, "shape": "line"})).is_ok());
// Bad input is refused.
for bad in [json!({"frequency": 0}), json!({"shape": "star"}), json!({"mode": "rgb"}), json!({"color": "nope"})] {
let mut s = session();
red_rect(&mut s);
assert!(s.execute("object.vectorHalftone", &bad).is_err(), "{bad}");
}
}
+4 -3
View File
@@ -37,7 +37,7 @@ fn built_in_presets_are_listed_and_read_only() {
for n in ["High Quality Print", "Press Quality", "Smallest File Size", "PDF/X-1a:2001", "PDF/X-3:2002", "PDF/X-4:2010"] {
assert_eq!(row(n)["builtIn"], true);
}
assert_eq!(row("PDF/X-4:2010")["supported"], false, "PDF/X is not written yet");
assert!(rows.iter().all(|r| r["supported"] == true), "every built-in standard is written");
assert_eq!(row(DEFAULT_PRESET)["settings"]["preserveEditing"], true);
// Read-only: neither changed nor deleted, in any case.
assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "press quality", "compatibility": "1.4"}))));
@@ -47,7 +47,8 @@ fn built_in_presets_are_listed_and_read_only() {
// Built-in presets apply as named.
let v = s.execute("document.pdfSettings", &json!({"preset": "Smallest File Size"})).unwrap();
assert_eq!((&v["settings"]["fastWebView"], &v["changed"]), (&json!(true), &json!([])), "{v}");
assert!(is_bad_params(s.execute("document.exportPdf", &json!({"preset": "PDF/X-4:2010"}))), "PDF/X is refused on export");
let pdf = s.execute("document.exportPdf", &json!({"preset": "PDF/X-4:2010"})).unwrap();
assert!(pdf["dataBase64"].is_string(), "PDF/X is written");
}
#[test]
@@ -61,7 +62,7 @@ fn saved_presets_apply_wherever_a_preset_is_named() {
// document.exportPdf, document.export, serialize and pdfSettings take it.
let v = s.execute("document.exportPdf", &json!({"preset": "web"})).unwrap();
assert!(pdf(&v).starts_with(b"%PDF-1.5"), "the preset's compatibility");
assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fast web view")), "{v}");
assert!(String::from_utf8_lossy(&pdf(&v)).contains("/Linearized 1"), "the preset's fast web view: {v}");
let v = s.execute("document.export", &json!({"format": "pdf", "preset": "Web"})).unwrap();
assert!(pdf(&v).starts_with(b"%PDF-1.5"));
let v = s.execute("document.serialize", &json!({"format": "pdf", "preset": "Web", "compatibility": "1.6"})).unwrap();
+214
View File
@@ -0,0 +1,214 @@
//! WebAssembly plug-ins (#121): object filters through `plugin.run` (one undo step,
//! transactional), live-effect plug-ins in appearance stacks, and the `plugin.*` management
//! commands.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{PathData, Point, SubPath};
use vectorcraft_plugins::wat as tpl;
use super::*;
/// The example Desaturate plug-in (built from `crates/plugins/example`).
const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm");
const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate";
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn install(s: &mut Session, wasm: &[u8]) -> Value {
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm)}))
}
fn install_wat(s: &mut Session, src: &str) -> Value {
install(s, &wat::parse_str(src).unwrap())
}
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
s
}
/// A 10 × 10 rectangle at `x` filled with `fill` and no stroke.
fn rect(s: &mut Session, x: f64, fill: &str) -> NodeId {
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 10, "height": 10}))["id"].as_u64().unwrap());
run(s, "paint.setFill", json!({"ids": [id.0], "color": fill}));
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
id
}
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn triangle() -> PathData {
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 0.0), Point::new(15.0, 20.0)], true))
}
#[test]
fn object_filter_recolours_the_selection_as_one_undo_step() {
let mut s = session();
let info = install(&mut s, DESATURATE);
assert_eq!((info["id"].as_str(), info["kind"].as_str()), (Some(DESATURATE_ID), Some("filter")));
let a = rect(&mut s, 10.0, "#ff0000");
let b = rect(&mut s, 40.0, "#0000ff");
run(&mut s, "select.all", json!({}));
let undo = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "params": {"amount": 100}}));
assert_eq!(r["ids"], json!([a.0, b.0]));
for id in [a, b] {
let [r, g, b] = node(&s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated();
assert!((r - g).abs() < 1e-6 && (g - b).abs() < 1e-6, "grey: {r} {g} {b}");
}
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1);
assert_eq!(st.history.undo.last().unwrap().label, "Desaturate");
assert_eq!(st.selection.objects, [a, b]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
// Plug-in parameters may also be top-level keys; wrong types are refused before it runs.
run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "amount": 0}));
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
assert!(matches!(s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": "all"})), Err(EngineError::BadParams { .. })));
}
#[test]
fn object_filters_add_replace_and_generate_objects() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
let b = rect(&mut s, 40.0, "#00ff00");
run(&mut s, "select.all", json!({}));
// Keeps A, drops B and adds a triangle above A.
let out =
json!({"objects": [{"id": a.0}, {"path": triangle(), "fills": [{"type": "solid", "color": {"model": "gray", "k": 1}}], "name": "Tri"}]});
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.replace", "filter", "{}"), &out.to_string()));
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.replace"}));
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids[0], a);
let st = s.doc().unwrap();
assert!(st.doc.node(b).is_none());
let layer: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(layer, [a, ids[1]]);
assert_eq!(st.doc.node(ids[1]).unwrap().name.as_deref(), Some("Tri"));
assert_eq!(st.selection.objects, ids);
// A generator with nothing selected adds to the current layer.
let generated = json!({"objects": [{"path": triangle()}]});
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.generate", "filter", "{}"), &generated.to_string()));
run(&mut s, "select.none", json!({}));
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.generate"}));
let made = NodeId(r["ids"][0].as_u64().unwrap());
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap().id, made);
}
#[test]
fn failing_plugins_leave_the_document_and_history_alone() {
let mut s = session();
rect(&mut s, 10.0, "#ff0000");
run(&mut s, "select.all", json!({}));
let man = |id: &str| tpl::manifest(id, "filter", "{}");
install_wat(&mut s, &tpl::module(&man("test.engine.trap"), "unreachable", "", 1));
install_wat(&mut s, &tpl::returning(&man("test.engine.far"), r#"{"objects":[{"path":{"subpaths":[{"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#));
install_wat(&mut s, &tpl::returning(&man("test.engine.declines"), r#"{"error":"Select two paths."}"#));
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
for id in ["test.engine.trap", "test.engine.far", "test.engine.declines", "test.engine.missing"] {
let e = s.execute("plugin.run", &json!({"id": id})).unwrap_err();
if id == "test.engine.declines" {
assert!(e.to_string().contains("Select two paths."), "{e}");
}
assert_eq!(s.doc().unwrap().doc, before, "{id}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{id}");
}
}
#[test]
fn live_effect_plugins_reshape_editable_art_and_expand() {
let mut s = session();
let a = rect(&mut s, 10.0, "#ff0000");
// An effect that draws a triangle, and one that hands the geometry back.
let tri = json!({"objects": [{"path": triangle()}]});
install_wat(
&mut s,
&tpl::returning(&tpl::manifest("test.engine.tri", "effect", r#"{"size":{"type":"number","min":0,"max":10,"default":3}}"#), &tri.to_string()),
);
install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.echo", "effect", "{}"), tpl::ECHO, "", 1));
let catalog = run(&mut s, "effect.list", json!({}))["catalog"].clone();
let entry = catalog.as_array().unwrap().iter().find(|e| e["id"] == "plugin.test.engine.tri").expect("listed");
assert_eq!((entry["label"].as_str(), entry["menu"].clone()), (Some("Test test.engine.tri…"), json!(["Effect", "Plug-ins"])));
assert_eq!(entry["defaults"], json!({"size": 3.0}));
// A filter can't be applied as an effect, nor an effect run as a filter.
install(&mut s, DESATURATE);
assert!(s.execute("effect.apply", &json!({"ids": [a.0], "effect": format!("plugin.{DESATURATE_ID}")})).is_err());
assert!(s.execute("plugin.run", &json!({"id": "test.engine.tri"})).is_err());
let effected = |s: &Session| {
let n = node(s, a);
let path = n.path_data().unwrap().clone();
let b = path.bounds().unwrap();
vectorcraft_render::effects::apply_geometry(&n.appearance.effects, &path, b)
};
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.echo"}));
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap(), "identity");
// Re-evaluated when the object changes (non-destructive).
run(&mut s, "select.all", json!({}));
run(&mut s, "object.move", json!({"dx": 50, "dy": 0}));
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}}));
assert_eq!(effected(&s), triangle());
assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4}));
// Uninstalled: the geometry is drawn as it is.
run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"}));
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.tri", "effect", "{}"), &tri.to_string()));
// Expand Appearance bakes it.
run(&mut s, "select.all", json!({}));
run(&mut s, "effect.expandAppearance", json!({}));
let mut baked = None;
s.doc().unwrap().doc.walk(|n| {
if let Some(p) = n.path_data() {
baked = Some(p.clone());
}
});
assert_eq!(baked.unwrap().bounds(), triangle().bounds());
}
#[test]
fn plugin_management_commands() {
let mut s = session();
install(&mut s, DESATURATE);
let list = run(&mut s, "plugin.list", json!({}));
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == DESATURATE_ID));
let info = run(&mut s, "plugin.info", json!({"id": DESATURATE_ID}));
assert_eq!(info["defaults"], json!({"amount": 100.0}));
assert_eq!(info["params"], "{amount: 0..100 (100)}");
assert_eq!(info["manifest"]["kind"], "filter");
// Refused installs.
for bad in [
json!({}),
json!({"dataBase64": "!!"}),
json!({"dataBase64": "AGFzbQ=="}),
json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE), "replace": false}),
] {
assert!(s.execute("plugin.install", &bad).is_err(), "{bad}");
}
assert!(s.execute("plugin.info", &json!({"id": "test.engine.nothing"})).is_err());
assert!(s.execute("plugin.remove", &json!({"id": "test.engine.nothing"})).is_err());
assert!(s.execute("plugin.reload", &json!({})).is_err(), "no folder set");
// The Additional Plug-ins Folder preference installs what's in it.
let dir = std::env::temp_dir().join(format!("vectorcraft-engine-plugins-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let src = tpl::returning(&tpl::manifest("test.engine.folder", "filter", "{}"), r#"{"objects":[]}"#);
std::fs::write(dir.join("folder.wasm"), wat::parse_str(src).unwrap()).unwrap();
std::fs::write(dir.join("broken.wasm"), b"\0asm?").unwrap();
run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": dir.display().to_string()}));
assert!(vectorcraft_plugins::registry::get("test.engine.folder").is_some());
run(&mut s, "plugin.remove", json!({"id": "test.engine.folder"}));
let r = run(&mut s, "plugin.reload", json!({}));
assert_eq!(r["loaded"], json!(["test.engine.folder"]));
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
assert_eq!(run(&mut s, "plugin.list", json!({}))["folder"]["loaded"], json!(["test.engine.folder"]));
std::fs::remove_dir_all(&dir).ok();
}
+131
View File
@@ -0,0 +1,131 @@
//! File → Print › Advanced: simulated overprints print what Overprint Preview shows, Print as
//! Bitmap prints images of the art, and a flattener preset flattens transparency first.
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_pdf::PrintSettings;
use vectorcraft_render::{RenderOptions, Renderer};
use super::*;
/// A 200 × 100 CMYK document: a cyan square under a yellow one whose fill overprints, and a
/// half-transparent magenta one; their ids.
fn session() -> (Session, [u64; 3]) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "colorMode": "cmyk"})).unwrap();
let mut ids = [0; 3];
for (i, (x, color)) in [(10, "#00ffff"), (40, "#ffff00"), (120, "#ff00ff")].into_iter().enumerate() {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap();
ids[i] = r["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [ids[i]], "color": color})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [ids[i]], "none": true})).unwrap();
}
s.execute("object.setOverprint", &json!({"ids": [ids[1]], "fill": true})).unwrap();
s.execute("transparency.set", &json!({"ids": [ids[2]], "opacity": 50, "item": null})).unwrap();
(s, ids)
}
fn settings(v: Value) -> PrintSettings {
serde_json::from_value(v).unwrap()
}
fn doc(s: &Session) -> &Document {
&s.doc().unwrap().doc
}
/// `doc` rendered over its artboard at 2 px/pt on white.
fn pixels(doc: &Document, opts: RenderOptions) -> Vec<u8> {
let opts = RenderOptions { background: Some([255; 4]), skip_templates: true, ..opts };
Renderer::new().render_region_with(doc, Rect::new(0.0, 0.0, 200.0, 100.0), 2.0, &opts).pixels
}
/// The pixel of the page `pdf` prints at document point `(x, y)` of the first sheet.
fn printed_pixel(s: &mut Session, settings: Value, (x, y): (f64, f64)) -> [u8; 4] {
let v = s.execute("file.print", &json!({ "settings": settings })).unwrap();
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
let pv = s.execute("print.preview", &json!({ "settings": settings })).unwrap();
let t: Vec<f64> = pv["sheets"][0]["transform"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
let back = vectorcraft_pdf::import(&bytes).unwrap();
let page = back.artboards[0].rect;
let (px, py) = (page.x0 + t[0] * x + t[2] * y + t[4], page.y0 + t[1] * x + t[3] * y + t[5]);
let img = Renderer::new().render_region(&back, Rect::new(px - 0.5, py - 0.5, px + 0.5, py + 0.5), 1.0, true);
img.pixels[..4].try_into().unwrap()
}
fn near(a: [u8; 4], b: [u8; 4]) -> bool {
a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 4)
}
#[test]
fn simulated_overprints_print_what_overprint_preview_shows() {
let (mut s, _) = session();
let preview = pixels(doc(&s), RenderOptions { overprint_preview: true, ..Default::default() });
let simulated = vectorcraft_pdf::printed_document(doc(&s), &settings(json!({"advanced": {"overprints": "simulate"}})));
assert_eq!(pixels(&simulated, RenderOptions::default()), preview, "the same pixels as Overprint Preview");
assert_ne!(pixels(doc(&s), RenderOptions::default()), preview, "overprinting shows");
// On the printed page too: where yellow overprints cyan it prints green; discarded, yellow.
let at = (55.0, 30.0);
let i = ((30.0 * 2.0) as usize * 400 + (55.0 * 2.0) as usize) * 4;
let shown: [u8; 4] = preview[i..i + 4].try_into().unwrap();
let sim = printed_pixel(&mut s, json!({"advanced": {"overprints": "simulate"}}), at);
assert!(near(sim, shown), "{sim:?} vs {shown:?}");
let discarded = printed_pixel(&mut s, json!({"advanced": {"overprints": "discard"}}), at);
assert!(!near(discarded, shown) && discarded[2] < 64 && discarded[0] > 192, "yellow knocks out: {discarded:?}");
}
#[test]
fn a_flattener_preset_flattens_transparency_before_printing() {
let (mut s, _) = session();
let saved = s.prefs.flattener_presets.clone();
let transparent = |d: &Document| {
let mut any = false;
d.walk(|n| any |= n.opacity < 1.0);
any
};
assert!(transparent(doc(&s)));
let none = super::cmd::printadvanced::prepare("file.print", doc(&s), &settings(json!({})), &saved).unwrap();
assert!(none.is_none(), "transparency prints live without a preset");
for name in ["High Resolution", "low"] {
let set = settings(json!({"advanced": {"flattenerPreset": name}}));
let flat = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().unwrap();
assert!(!transparent(&flat), "{name}: flattened");
}
// A saved preset by name; an unknown one is refused by preview and print alike.
s.execute("flattener.presets.save", &json!({"name": "Proofs", "preset": "low"})).unwrap();
let ok = json!({"settings": {"advanced": {"flattenerPreset": "Proofs"}}});
assert!(s.execute("print.preview", &ok).is_ok() && s.execute("file.print", &ok).is_ok());
let bad = json!({"settings": {"advanced": {"flattenerPreset": "Nope"}}});
assert!(s.execute("print.preview", &bad).is_err() && s.execute("file.print", &bad).is_err());
}
#[test]
fn print_as_bitmap_prints_an_image_of_each_artboard() {
let (mut s, _) = session();
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 100})).unwrap();
let saved = s.prefs.flattener_presets.clone();
let set = settings(json!({"advanced": {"printAsBitmap": true}}));
let bitmap = super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().unwrap();
let mut kinds = vec![];
bitmap.walk(|n| kinds.push(matches!(n.kind, NodeKind::Image(_))));
assert_eq!(kinds.iter().filter(|k| **k).count(), 1, "one image: the empty artboard gets none");
// The image looks like the art, at the raster effects resolution.
assert_eq!(pixels(&bitmap, RenderOptions::default()).len(), pixels(doc(&s), RenderOptions::default()).len());
let v = s.execute("file.print", &json!({"settings": {"advanced": {"printAsBitmap": true}, "skipBlank": true}})).unwrap();
assert_eq!(v["pages"], 1, "the blank artboard is skipped");
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
let back = vectorcraft_pdf::import(&bytes).unwrap();
let (mut images, mut paths) = (0, 0);
back.walk(|n| match n.kind {
NodeKind::Image(_) => images += 1,
NodeKind::Path { .. } => paths += 1,
_ => {}
});
assert_eq!((images, paths), (1, 0), "no vector art is left");
// Separations print the art as it is.
let seps = json!({"settings": {"advanced": {"printAsBitmap": true}, "output": {"mode": "separations"}}});
let pv = s.execute("print.preview", &seps).unwrap();
assert!(pv["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("Print as Bitmap")));
let set = settings(seps["settings"].clone());
assert!(super::cmd::printadvanced::prepare("file.print", doc(&s), &set, &saved).unwrap().is_none());
}
+162
View File
@@ -0,0 +1,162 @@
//! View → Show Print Tiling (`view.printTiling`, `print.tiling`) and the Print Tiling tool
//! (`print.tiling.set`): the pages drawn are print.preview's, and where the tool puts them is
//! saved with the print settings.
use serde_json::{Value, json};
use vectorcraft_tools::{PointerEvent, PointerKind};
use super::*;
use crate::tooling::ViewInfo;
/// A 300 × 200 document with a rectangle.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
s
}
fn rect(v: &Value) -> [f64; 4] {
let a: Vec<f64> = v.as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
[a[0], a[1], a[2], a[3]]
}
fn close(a: [f64; 4], b: [f64; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
}
/// `t` (a print.preview transform) applied to `(x, y)`.
fn apply(t: &Value, (x, y): (f64, f64)) -> (f64, f64) {
let c: Vec<f64> = t.as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
(c[0] * x + c[2] * y + c[4], c[1] * x + c[3] * y + c[5])
}
fn tiling(s: &mut Session, settings: Value) -> Value {
s.execute("print.tiling", &json!({ "settings": settings })).unwrap()
}
#[test]
fn show_print_tiling_is_a_per_document_view_toggle() {
let mut s = session();
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("view.printTiling", &json!({})).unwrap()["on"], true);
assert!(s.doc().unwrap().print_tiling);
assert_eq!(s.execute("view.printTiling", &json!({})).unwrap()["on"], false);
assert_eq!(s.execute("view.printTiling", &json!({"on": true})).unwrap()["on"], true);
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "a view toggle is no undo step");
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
assert!(!s.doc().unwrap().print_tiling, "each document has its own");
}
#[test]
fn tiling_pages_are_print_preview_tiles() {
let mut s = session();
let set = json!({"scaling": "tileImageable", "scale": {"width": 600, "height": 600}, "margin": 18, "overlap": 9, "tileRange": "1-2, 5"});
let pv = s.execute("print.preview", &json!({ "settings": set })).unwrap();
let grid = &pv["tiles"][0];
let tiles = grid["tiles"].as_array().unwrap();
let pages = tiling(&mut s, set.clone())["pages"].as_array().unwrap().clone();
assert!(tiles.len() > 4);
assert_eq!(pages.len(), tiles.len(), "one page per tile");
for (i, (p, t)) in pages.iter().zip(tiles).enumerate() {
assert_eq!(p["number"], i + 1);
assert!(close(rect(&p["imageable"]), rect(t)), "tiles are imageable areas");
let printed = grid["printed"].as_array().unwrap().contains(&json!(i + 1));
assert_eq!(p["printed"], printed);
// The paper reaches the margin (18 pt on paper, 3 pt of the document at 600%) further.
let (pg, im) = (rect(&p["page"]), rect(&p["imageable"]));
assert!((im[0] - pg[0] - 3.0).abs() < 1e-6 && (pg[3] - im[3] - 3.0).abs() < 1e-6);
}
// Full pages: the tiles are the paper, the imageable area inside it.
let full = json!({"scaling": "tileFull", "scale": {"width": 400, "height": 400}, "margin": 18});
let pv = s.execute("print.preview", &json!({ "settings": full })).unwrap();
let pages = tiling(&mut s, full)["pages"].as_array().unwrap().clone();
assert_eq!(pages.len(), pv["tiles"][0]["tiles"].as_array().unwrap().len());
assert!(close(rect(&pages[0]["page"]), rect(&pv["tiles"][0]["tiles"][0])));
}
#[test]
fn untiled_pages_come_from_the_sheets_once_per_page() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 400, "y": 0, "width": 300, "height": 200})).unwrap();
// Letter turned landscape for the wide artboards, centred on each.
let set = json!({"output": {"mode": "separations"}, "margin": 36});
let pv = s.execute("print.preview", &json!({ "settings": set })).unwrap();
assert!(pv["sheets"].as_array().unwrap().len() >= 8, "a page per ink");
let pages = tiling(&mut s, set)["pages"].as_array().unwrap().clone();
assert_eq!(pages.len(), 2, "each page once, not once per ink");
for (p, cx) in pages.iter().zip([150.0, 550.0]) {
let pg = rect(&p["page"]);
assert!(close(pg, [cx - 396.0, 100.0 - 306.0, cx + 396.0, 100.0 + 306.0]), "{pg:?}");
assert!(close(rect(&p["imageable"]), [pg[0] + 36.0, pg[1] + 36.0, pg[2] - 36.0, pg[3] - 36.0]));
}
}
#[test]
fn the_tool_puts_the_first_page_where_it_is_dragged_as_one_undo_step() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("printTiling", v).unwrap();
let undo = s.doc().unwrap().history.undo.len();
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 45.0, 40.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 40.0, 30.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 40.0, 30.0), v).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo + 1);
assert_eq!(st.history.undo.last().unwrap().label, "Print Tiling");
let set = s.execute("print.setup", &json!({})).unwrap()["settings"].clone();
assert_eq!(set["tileOrigin"], json!({"placed": true, "x": 40.0, "y": 30.0}));
// The page's imageable corner is the point dragged to, on the paper and on the canvas.
let pv = s.execute("print.preview", &json!({"settings": {"margin": 12}})).unwrap();
let (x, y) = apply(&pv["sheets"][0]["transform"], (40.0, 30.0));
assert!((x - 12.0).abs() < 1e-6 && (y - 12.0).abs() < 1e-6, "({x}, {y})");
let page = &tiling(&mut s, json!({"margin": 12}))["pages"][0];
assert!((rect(&page["imageable"])[0] - 40.0).abs() < 1e-6 && (rect(&page["imageable"])[1] - 30.0).abs() < 1e-6);
// Saved in the native file.
let doc = &s.doc().unwrap().doc;
assert_eq!(vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap().print_setup, doc.print_setup);
// A double click puts the pages back where the placement puts them.
s.pointer(&PointerEvent::new(PointerKind::DoubleClick, 10.0, 10.0), v).unwrap();
assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["tileOrigin"]["placed"], false);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["tileOrigin"]["placed"], true);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.print_setup.is_none());
}
#[test]
fn tiles_start_at_the_tile_origin_and_cover_the_art() {
let mut s = session();
let tiles = json!({"scaling": "tileImageable", "scale": {"width": 400, "height": 400}});
s.execute("print.setup", &json!({ "settings": tiles })).unwrap();
let before = tiling(&mut s, json!({}))["pages"].as_array().unwrap().len();
s.execute("print.tiling.set", &json!({"origin": [60, 35]})).unwrap();
let pages = tiling(&mut s, json!({}))["pages"].as_array().unwrap().clone();
let pv = s.execute("print.preview", &json!({})).unwrap();
assert_eq!(pages.len(), pv["tiles"][0]["tiles"].as_array().unwrap().len(), "tile count equals print.preview");
assert_eq!(pages.len(), pv["sheets"].as_array().unwrap().len());
assert!(pages.len() > before, "the grid moved off the corner takes more tiles");
let rects: Vec<[f64; 4]> = pages.iter().map(|p| rect(&p["imageable"])).collect();
assert!(rects.iter().any(|r| (r[0] - 60.0).abs() < 1e-6 && (r[1] - 35.0).abs() < 1e-6), "a tile starts at the origin");
let all = rects.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, r| [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])]);
assert!(all[0] <= 0.0 && all[1] <= 0.0 && all[2] >= 300.0 && all[3] >= 200.0, "the tiles cover the artboard: {all:?}");
// Bad input is refused.
assert!(s.execute("print.tiling.set", &json!({})).is_err());
assert!(s.execute("print.tiling.set", &json!({"origin": [1, 2], "artboard": 7})).is_err());
assert!(s.execute("print.tiling.set", &json!({"origin": [1e9, 2]})).is_err());
}
#[test]
fn settings_saved_before_the_tile_origin_still_load() {
let mut s = session();
let mut old = serde_json::to_value(vectorcraft_pdf::PrintSettings::default()).unwrap();
old.as_object_mut().unwrap().remove("tileOrigin");
old["copies"] = json!(2);
let mut doc = (*s.doc().unwrap().doc).clone();
doc.print_setup = Some(old);
s.doc_mut().unwrap().doc = std::sync::Arc::new(doc);
let set = s.execute("print.setup", &json!({})).unwrap()["settings"].clone();
assert_eq!((set["copies"].clone(), set["tileOrigin"]["placed"].clone()), (json!(2), json!(false)));
assert_eq!(tiling(&mut s, json!({}))["pages"].as_array().unwrap().len(), 1);
}
+38
View File
@@ -119,6 +119,44 @@ fn type_tool_typing_is_one_undo_step() {
assert_eq!(obj(&s, id).plain_text(), "");
}
#[test]
fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("abc", v).unwrap();
key(&mut s, ToolKey::Left, Mods::default());
key(&mut s, ToolKey::Right, Mods::default());
s.tool_text("def", v).unwrap();
assert!(s.in_interaction(), "still typing");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "abc", "only the typing in progress is undone");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "abcdef", "and Redo brings it back");
// Typing on after an undo starts a new session from the restored text.
s.execute("edit.undo", &json!({})).unwrap();
s.tool_text("!", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "abc!");
}
#[test]
fn undo_during_a_drag_takes_back_only_the_drag() {
let mut s = session();
let v = ViewInfo::default();
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.select_tool("selection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 125.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 225.0, 125.0), v).unwrap();
assert!(s.in_interaction(), "dragging");
s.execute("edit.undo", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
let b = doc.bounds_of(&[id], false).expect("the rectangle is still there");
assert_eq!(b.x0, 100.0, "back where it was: {b:?}");
}
#[test]
fn type_tool_selection_replace_and_delete() {
let mut s = session();
+20
View File
@@ -164,8 +164,28 @@ impl Session {
self.tool.set_option(key, v);
}
/// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]):
/// the Type tool stops editing text that is no longer selected. Only while it edits text.
pub(crate) fn after_command(&mut self) {
if self.in_tool_actions || !self.tool.wants_text() {
return;
}
let acts = self.with_tool_cx(self.last_view, |t, cx| t.after_command(cx));
// The command itself succeeded: a failure here only leaves the edit as it was.
if let Err(e) = self.apply_actions(acts) {
log::warn!("ending the text edit: {e}");
}
}
/// Apply tool actions. Errors from previews are reported but keep the interaction alive.
pub fn apply_actions(&mut self, acts: Vec<Action>) -> Result<Vec<UiRequest>> {
let outer = std::mem::replace(&mut self.in_tool_actions, true);
let r = self.apply_tool_actions(acts);
self.in_tool_actions = outer;
r
}
fn apply_tool_actions(&mut self, acts: Vec<Action>) -> Result<Vec<UiRequest>> {
let mut ui = vec![];
for a in acts {
match a {
+271
View File
@@ -0,0 +1,271 @@
//! Untrusted plug-ins never crash the app: mutated and garbage modules handed to `plugin.install`,
//! and plug-ins returning hostile output (random JSON in the object model's shape: huge or
//! malformed coordinates, unknown ids, broken paints and gradients, junk of every type) as object
//! filters (`plugin.run`) or live effects (`effect.apply`). Each run must end as an error that
//! leaves the document as it was, or as a document that then renders and exports, without a
//! panic.
//!
//! `PROPTEST_CASES=2000 cargo test -p vectorcraft-engine --test plugin_fuzz` runs a deeper search.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use proptest::prelude::*;
use serde_json::{Map, Value, json};
use vectorcraft_engine::Session;
use vectorcraft_plugins::wat as tpl;
use vectorcraft_testkit::catch_quiet;
use vectorcraft_testkit::fixtures::rich_session;
const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm");
/// 64 cases each, unless `PROPTEST_CASES` asks for more.
fn config() -> ProptestConfig {
let mut c = ProptestConfig { failure_persistence: None, ..ProptestConfig::default() };
if std::env::var_os("PROPTEST_CASES").is_none() {
c.cases = 64;
}
c
}
fn install(s: &mut Session, wasm: &[u8]) -> Result<Value, String> {
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})).map_err(|e| e.to_string())
}
/// The document renders and exports without panicking.
fn renders(s: &Session, what: &str) -> Result<(), TestCaseError> {
let d = &s.doc().unwrap().doc;
let r = catch_quiet(|| {
let mut r = vectorcraft_render::Renderer::new();
if let Some(ab) = d.artboards.first() {
let _ = r.render_region(d, ab.rect, 0.25, true).to_png();
}
let _ = vectorcraft_svg::export(d, &vectorcraft_svg::ExportOptions::default());
let _ = vectorcraft_engine::export_pdf(d, &vectorcraft_pdf::PdfOptions::default());
let _ = vectorcraft_format::save_file(d);
});
r.map_err(|msg| TestCaseError::fail(format!("{what}: panicked: {msg}")))
}
/// Junk of every JSON type.
fn arb_junk() -> impl Strategy<Value = Value> {
let leaf = prop_oneof![
Just(Value::Null),
any::<bool>().prop_map(Value::from),
any::<i64>().prop_map(Value::from),
(-1e3f64..1e3).prop_map(Value::from),
"[a-zA-Z#]{0,6}".prop_map(Value::from),
];
leaf.prop_recursive(2, 12, 3, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 0..3).prop_map(Value::Array),
prop::collection::btree_map("[a-z]{1,5}", inner, 0..3).prop_map(|m| Value::Object(m.into_iter().collect())),
]
})
}
/// Numbers that tend to break arithmetic.
fn arb_num() -> impl Strategy<Value = Value> {
prop_oneof![
6 => (-600.0f64..600.0).prop_map(Value::from),
1 => Just(json!(0)),
1 => Just(json!(-0.0)),
1 => Just(json!(1e308)),
1 => Just(json!(-1e308)),
1 => Just(json!(4.0e6)),
1 => Just(json!(4.000001e6)),
1 => Just(json!(1e-308)),
1 => Just(json!(u64::MAX)),
1 => Just(json!(i64::MIN)),
1 => arb_junk(),
]
}
fn arb_point() -> impl Strategy<Value = Value> {
prop_oneof![
8 => (arb_num(), arb_num()).prop_map(|(x, y)| json!([x, y])),
1 => (arb_num(), arb_num()).prop_map(|(x, y)| json!({"x": x, "y": y})),
1 => arb_junk(),
]
}
fn arb_path() -> impl Strategy<Value = Value> {
let anchor = (
arb_point(),
prop::option::of(arb_point()),
prop::option::of(arb_point()),
prop::option::of(prop_oneof![Just("Smooth"), Just("Corner"), Just("Weird")]),
)
.prop_map(|(p, i, o, k)| {
let mut a = json!({"p": p});
if let Some(i) = i {
a["in"] = i;
}
if let Some(o) = o {
a["out"] = o;
}
if let Some(k) = k {
a["kind"] = json!(k);
}
a
});
let sub = (prop::collection::vec(anchor, 0..6), any::<bool>()).prop_map(|(anchors, closed)| json!({"anchors": anchors, "closed": closed}));
prop_oneof![9 => prop::collection::vec(sub, 0..3).prop_map(|s| json!({"subpaths": s})), 1 => arb_junk()]
}
fn arb_color() -> impl Strategy<Value = Value> {
prop_oneof![
(arb_num(), arb_num(), arb_num()).prop_map(|(r, g, b)| json!({"model": "rgb", "r": r, "g": g, "b": b})),
(arb_num(), arb_num(), arb_num(), arb_num()).prop_map(|(c, m, y, k)| json!({"model": "cmyk", "c": c, "m": m, "y": y, "k": k})),
arb_num().prop_map(|k| json!({"model": "gray", "k": k})),
(arb_num(), arb_num(), arb_num()).prop_map(|(l, a, b)| json!({"model": "lab", "l": l, "a": a, "b": b})),
arb_junk(),
]
}
fn arb_paint() -> impl Strategy<Value = Value> {
let stop = (arb_num(), arb_color(), arb_num(), arb_num()).prop_map(|(o, c, a, m)| json!({"offset": o, "color": c, "opacity": a, "midpoint": m}));
let freeform = (
prop::collection::vec((arb_point(), arb_color(), arb_num()).prop_map(|(at, c, s)| json!({"at": at, "color": c, "spread": s})), 0..3),
arb_junk(),
)
.prop_map(|(points, lines)| json!({"points": points, "lines": if lines.is_array() { lines } else { json!([[0, 1, 9]]) }}));
let gradient = (
prop_oneof![Just("Linear"), Just("Radial"), Just("Freeform"), Just("Conic")],
prop::collection::vec(stop, 0..4),
prop::option::of((arb_point(), arb_point(), arb_num(), prop::option::of(arb_point()))),
arb_num(),
prop::option::of(freeform),
)
.prop_map(|(kind, stops, geom, angle, ff)| {
let mut g = json!({"type": "gradient", "gradient": {"kind": kind, "stops": stops}, "angle": angle});
if let Some((start, end, aspect, focal)) = geom {
g["geom"] = json!({"start": start, "end": end, "aspect": aspect, "focal": focal});
}
if let Some(ff) = ff {
g["freeform"] = ff;
}
g
});
prop_oneof![
2 => Just(Value::Null),
3 => (arb_color(), prop::option::of(prop_oneof![Just("White"), Just("Nope")]), arb_num())
.prop_map(|(c, sw, t)| json!({"type": "solid", "color": c, "swatch": sw, "tint": t})),
3 => gradient,
1 => (prop_oneof![Just("Nope"), Just("")], prop::collection::vec(arb_num(), 6)).prop_map(|(p, xf)| json!({"type": "pattern", "pattern": p, "xf": xf})),
1 => Just(json!({"type": "none"})),
1 => arb_junk(),
]
}
/// An output object: an input object's id (by index into the inputs) or a random one, and any of
/// the object keys.
fn arb_object() -> impl Strategy<Value = (Option<usize>, Value)> {
let stroke = (arb_paint(), prop::option::of(arb_num())).prop_map(|(p, w)| json!({"paint": p, "width": w}));
(
prop::option::of(0usize..8),
prop::option::of(prop_oneof![Just(json!("path")), Just(json!("compound")), arb_junk()]),
prop::option::of(arb_path()),
prop::option::of(prop_oneof![Just(json!("evenodd")), Just(json!("nonzero")), arb_junk()]),
prop::option::of(prop::collection::vec(arb_paint(), 0..3)),
prop::option::of(prop::collection::vec(stroke, 0..3)),
prop::option::of(arb_num()),
prop::option::of(prop_oneof![Just(json!("name")), arb_junk()]),
)
.prop_map(|(id, t, path, rule, fills, strokes, opacity, name)| {
let mut o = Map::new();
for (k, v) in [
("type", t),
("path", path),
("fillRule", rule),
("fills", fills.map(Value::from)),
("strokes", strokes.map(Value::from)),
("opacity", opacity),
("name", name),
] {
if let Some(v) = v {
o.insert(k.into(), v);
}
}
(id, Value::Object(o))
})
}
/// The output document: objects (ids resolved against `inputs`; out-of-range indices become
/// unknown ids), or junk.
fn output(objects: &[(Option<usize>, Value)], inputs: &[u64], junk: Option<Value>) -> String {
if let Some(j) = junk {
return j.to_string();
}
let list: Vec<Value> = objects
.iter()
.map(|(id, o)| {
let mut o = o.clone();
if let Some(i) = id {
o["id"] = json!(inputs.get(*i).copied().unwrap_or(9_999_999 + *i as u64));
}
o
})
.collect();
json!({ "objects": list }).to_string()
}
proptest! {
#![proptest_config(config())]
#[test]
fn hostile_filter_and_effect_output_never_crashes(
objects in prop::collection::vec(arb_object(), 0..5),
junk in prop::option::weighted(0.1, arb_junk()),
effect in any::<bool>(),
) {
let mut s = rich_session();
s.execute("select.all", &json!({})).unwrap();
let st = s.doc().unwrap();
let inputs: Vec<u64> = vectorcraft_plugins::objects::filter_targets(&st.doc, &st.selection.objects).iter().map(|i| i.0).collect();
let out = output(&objects, &inputs, junk);
let id = if effect { "fuzz.effect" } else { "fuzz.filter" };
let src = tpl::returning(&tpl::manifest(id, if effect { "effect" } else { "filter" }, "{}"), &out);
install(&mut s, &wat::parse_str(src).unwrap()).unwrap();
let before = s.doc().unwrap().doc.clone();
let r = catch_quiet(|| {
if effect {
s.execute("effect.apply", &json!({"effect": format!("plugin.{id}")})).map(|_| ())
} else {
s.execute("plugin.run", &json!({"id": id})).map(|_| ())
}
});
match r {
Err(msg) => return Err(TestCaseError::fail(format!("panicked on {out}: {msg}"))),
Ok(Err(_)) => prop_assert!(s.doc().unwrap().doc == before, "a failed run changed the document: {}", out),
Ok(Ok(())) => {}
}
renders(&s, &out)?;
}
#[test]
fn mutated_and_garbage_modules_never_crash(
flips in prop::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 0..8),
cut in prop::option::of(any::<prop::sample::Index>()),
garbage in prop::collection::vec(any::<u8>(), 0..64),
) {
let mut bytes = DESATURATE.to_vec();
for (i, b) in &flips {
let i = i.index(bytes.len());
bytes[i] = *b;
}
if let Some(c) = cut {
bytes.truncate(c.index(bytes.len()));
}
let mut s = rich_session();
s.execute("select.all", &json!({})).unwrap();
for module in [bytes, [b"\0asm\x01\0\0\0".as_slice(), &garbage].concat(), garbage.clone()] {
let r = catch_quiet(|| {
if let Ok(info) = install(&mut s, &module) {
let _ = s.execute("plugin.run", &json!({"id": info["id"]}));
}
});
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
}
renders(&s, "after mutated modules")?;
}
}
+2
View File
@@ -24,6 +24,8 @@ mod text;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_fontnames;
use std::sync::Arc;
+38
View File
@@ -0,0 +1,38 @@
//! Type names the available family as the font spells it, not as the PostScript name splits into
//! words (#130).
use vectorcraft_doc::NodeKind;
use vectorcraft_text::FontDb;
use crate::import::import;
/// The bundled Source Sans 3 renamed `family` (as long as the original name).
fn renamed(family: &str) -> Vec<u8> {
let mut data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").to_vec();
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
assert_eq!(from.len(), to.len());
let mut i = 0;
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
data[i + at..i + at + to.len()].copy_from_slice(&to);
i += at + to.len();
}
}
data
}
#[test]
fn postscript_names_resolve_to_the_available_family() {
// Split into words, its PostScript name would read "My Face Ya Hei3".
assert!(FontDb::global().add_font(renamed("MyFaceYaHei 3")) > 0);
let body = "/MyFaceYaHei3-Bold findfont 12 scalefont setfont 10 20 moveto (Hi) show";
let eps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n");
let r = import(eps.as_bytes()).unwrap();
let mut styles = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
styles.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone())));
}
});
assert_eq!(styles, [("MyFaceYaHei 3".to_string(), "Bold".to_string())]);
}
+3 -1
View File
@@ -223,7 +223,9 @@ impl Interp<'_> {
if !(size.is_finite() && size > 1e-3 && size < 1e5) {
return ps_err("undefinedresult", "a font size");
}
let (font_family, font_style) = family_style(&name);
let (family, font_style) = family_style(&name);
// The available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei".
let font_family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
let text: String = s.iter().map(|b| char::from(*b)).filter(|c| !c.is_control()).collect();
let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() };
let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() };
+102 -54
View File
@@ -3,10 +3,12 @@
//!
//! The export writes a plain file; [`protect`] then encrypts every string and stream in it (the
//! XMP metadata stays readable with Plaintext Metadata) and adds the encryption dictionary. The
//! algorithm follows the compatibility ([`Encryption`]): 128-bit RC4 for PDF 1.4 (revision 3) and
//! 1.5 (revision 4), 128-bit AES for 1.6 (revision 4) and 256-bit AES for 1.7 and 2.0
//! (revision 6). The random file key, salts and initialisation vectors come from a SHA-256 stream
//! seeded with the process's random hash keys, the time and the file itself.
//! algorithm follows the compatibility ([`Encryption`]): 40-bit RC4 for PDF 1.3 (revision 2),
//! 128-bit RC4 for PDF 1.4 (revision 3) and 1.5 (revision 4), 128-bit AES for 1.6 (revision 4) and
//! 256-bit AES for 1.7 and 2.0 (revision 6). The random file key, salts and initialisation vectors
//! come from a SHA-256 stream seeded with the process's random hash keys, the time and the file
//! itself. A linearised file is encrypted between its two steps ([`protect_keeping`]): what
//! encrypted it then encrypts the hint stream written last ([`Cipher`]).
//!
//! Reading: [`user_password`] turns the permissions (owner) password of a file encrypted with RC4
//! or 128-bit AES into its open (user) password, so either password opens it.
@@ -22,6 +24,7 @@ use crate::{Changes, Compatibility, PdfError, PdfSettings, Printing, SecuritySet
choice! {
/// The encryption algorithm, which follows the PDF version (Compatibility).
Encryption {
Rc440 = "rc440", "40-bit RC4";
Rc4 = "rc4", "128-bit RC4";
Aes128 = "aes128", "128-bit AES";
Aes256 = "aes256", "256-bit AES";
@@ -32,6 +35,7 @@ impl Encryption {
/// The algorithm a file of PDF version `c` is encrypted with.
pub fn for_compatibility(c: Compatibility) -> Self {
match c {
Compatibility::Pdf13 => Self::Rc440,
Compatibility::Pdf14 | Compatibility::Pdf15 => Self::Rc4,
Compatibility::Pdf16 => Self::Aes128,
Compatibility::Pdf17 | Compatibility::Pdf20 => Self::Aes256,
@@ -43,6 +47,7 @@ impl Encryption {
/// encrypted), 4 (crypt filters: RC4 or AES-128) or 6 (AES-256).
fn revision(c: Compatibility) -> u8 {
match c {
Compatibility::Pdf13 => 2,
Compatibility::Pdf14 => 3,
Compatibility::Pdf15 | Compatibility::Pdf16 => 4,
Compatibility::Pdf17 | Compatibility::Pdf20 => 6,
@@ -138,7 +143,7 @@ impl PdfSettings {
pub(crate) fn warnings(set: &PdfSettings) -> Vec<String> {
let s = &set.security;
let mut out = vec![];
if s.protected() && s.plaintext_metadata && revision(set.compatibility) == 3 {
if s.protected() && s.plaintext_metadata && revision(set.compatibility) <= 3 {
out.push(format!("{} encryption covers the metadata too: plaintext metadata needs PDF 1.5 or later", set.compatibility.label()));
}
out
@@ -335,7 +340,7 @@ impl Handler {
// Without a permissions password the open password grants every permission anyway.
let owner = if sec.permissions_password.is_empty() { user } else { sec.permissions_password.as_bytes() };
let p = sec.permission_bits();
let encrypt_metadata = r == 3 || !sec.plaintext_metadata;
let encrypt_metadata = r <= 3 || !sec.plaintext_metadata;
let meta = if encrypt_metadata { "" } else { "/EncryptMetadata false" };
if r == 6 {
let key: [u8; 32] = rng.bytes();
@@ -361,47 +366,59 @@ impl Handler {
);
return Ok(Self { key: key.to_vec(), cipher, encrypt_metadata, dict });
}
// O (algorithm 3), the file key (algorithm 2) and U (algorithm 5), for a 16-byte key.
let owner_key = owner_key(owner, r, 16);
// O (algorithm 3), the file key (algorithm 2) and U (algorithms 4 and 5): a 5-byte key and
// single RC4 passes for revision 2, a 16-byte key for revisions 3 and 4.
let n = if r == 2 { 5 } else { 16 };
let owner_key = owner_key(owner, r, n);
let mut o = rc4(&owner_key, &padded(user));
for i in 1..=19 {
for i in (1..=19).filter(|_| r >= 3) {
o = rc4(&xored(&owner_key, i), &o);
}
let no_meta: &[u8] = if encrypt_metadata { &[] } else { &[0xFF; 4] };
let mut key = md5(&[&padded(user), &o, &p.to_le_bytes(), id, no_meta]);
for _ in 0..50 {
for _ in (0..50).filter(|_| r >= 3) {
key = md5(&[&key]);
}
let mut u = rc4(&key, &md5(&[&PAD, id]));
for i in 1..=19 {
u = rc4(&xored(&key, i), &u);
}
u.extend_from_slice(&rng.bytes::<16>());
let dict = if r == 3 {
format!("<</Filter/Standard/V 2/R 3/Length 128/P {p}/O{}/U{}>>", hex(&o), hex(&u))
let key = key.get(..n).unwrap_or(&key).to_vec();
let u = if r == 2 {
rc4(&key, &PAD)
} else {
let cfm = if cipher == Encryption::Aes128 { "AESV2" } else { "V2" };
format!(
"<</Filter/Standard/V 4/R 4/Length 128/CF<</StdCF<</AuthEvent/DocOpen/CFM/{cfm}/Length 16>>>>/StmF/StdCF/StrF/StdCF/P {p}/O{}/U{}{meta}>>",
hex(&o),
hex(&u)
)
let mut u = rc4(&key, &md5(&[&PAD, id]));
for i in 1..=19 {
u = rc4(&xored(&key, i), &u);
}
u.extend_from_slice(&rng.bytes::<16>());
u
};
Ok(Self { key: key.to_vec(), cipher, encrypt_metadata, dict })
let dict = match r {
2 => format!("<</Filter/Standard/V 1/R 2/Length 40/P {p}/O{}/U{}>>", hex(&o), hex(&u)),
3 => format!("<</Filter/Standard/V 2/R 3/Length 128/P {p}/O{}/U{}>>", hex(&o), hex(&u)),
_ => {
let cfm = if cipher == Encryption::Aes128 { "AESV2" } else { "V2" };
format!(
"<</Filter/Standard/V 4/R 4/Length 128/CF<</StdCF<</AuthEvent/DocOpen/CFM/{cfm}/Length 16>>>>/StmF/StdCF/StrF/StdCF/P {p}/O{}/U{}{meta}>>",
hex(&o),
hex(&u)
)
}
};
Ok(Self { key, cipher, encrypt_metadata, dict })
}
/// `data` of object `num generation` encrypted.
fn encrypt(&self, num: u32, generation: u16, data: &[u8], rng: &mut Entropy) -> Result<Vec<u8>, PdfError> {
// The object's key: the file key's length plus 5 bytes, at most 16 (algorithm 1).
let object_key = || {
let salt: &[u8] = if self.cipher == Encryption::Aes128 { b"sAlT" } else { &[] };
md5(&[&self.key, &num.to_le_bytes()[..3], &generation.to_le_bytes(), salt])
let h = md5(&[&self.key, &num.to_le_bytes()[..3], &generation.to_le_bytes(), salt]);
h.get(..(self.key.len() + 5).min(16)).unwrap_or(&h).to_vec()
};
let aes = |key: &[u8], rng: &mut Entropy| -> Result<Vec<u8>, PdfError> {
let iv: [u8; 16] = rng.bytes();
Ok([&iv[..], &Aes::new(key)?.cbc(iv, data, true)].concat())
};
match self.cipher {
Encryption::Rc4 => Ok(rc4(&object_key(), data)),
Encryption::Rc440 | Encryption::Rc4 => Ok(rc4(&object_key(), data)),
Encryption::Aes128 => aes(&object_key(), rng),
Encryption::Aes256 => aes(&self.key, rng),
}
@@ -421,7 +438,7 @@ fn owner_key(owner: &[u8], r: u8, n: usize) -> Vec<u8> {
// ---------- PDF syntax ----------
fn is_white(b: u8) -> bool {
pub(crate) fn is_white(b: u8) -> bool {
matches!(b, 0 | 9 | 10 | 12 | 13 | 32)
}
@@ -430,7 +447,7 @@ fn is_regular(b: u8) -> bool {
}
#[derive(Debug)]
enum Tok<'a> {
pub(crate) enum Tok<'a> {
DictOpen,
DictClose,
ArrayOpen,
@@ -442,14 +459,14 @@ enum Tok<'a> {
}
/// A token with its byte span.
type Spanned<'a> = (Tok<'a>, usize, usize);
pub(crate) type Spanned<'a> = (Tok<'a>, usize, usize);
/// Deepest nesting of arrays and dictionaries read.
const MAX_DEPTH: usize = 64;
/// A parsed object, with what encryption needs: the strings and dictionaries with their spans.
#[derive(Debug)]
enum Obj<'a> {
pub(crate) enum Obj<'a> {
/// `start` is where `<<` is, `end` just after `>>`.
Dict {
entries: Vec<(&'a [u8], Obj<'a>)>,
@@ -474,21 +491,21 @@ enum Obj<'a> {
}
impl<'a> Obj<'a> {
fn get(&self, key: &[u8]) -> Option<&Obj<'a>> {
pub(crate) fn get(&self, key: &[u8]) -> Option<&Obj<'a>> {
match self {
Self::Dict { entries, .. } => entries.iter().find(|(k, _)| *k == key).map(|(_, v)| v),
_ => None,
}
}
fn name(&self, key: &[u8]) -> Option<&'a [u8]> {
pub(crate) fn name(&self, key: &[u8]) -> Option<&'a [u8]> {
match self.get(key)? {
Self::Name(n) => Some(n),
_ => None,
}
}
fn int(&self, key: &[u8]) -> Option<i64> {
pub(crate) fn int(&self, key: &[u8]) -> Option<i64> {
match self.get(key)? {
Self::Int { value, .. } => Some(*value),
_ => None,
@@ -524,18 +541,18 @@ impl<'a> Obj<'a> {
}
}
fn int(w: &[u8]) -> Option<i64> {
pub(crate) fn int(w: &[u8]) -> Option<i64> {
std::str::from_utf8(w).ok()?.parse().ok()
}
#[derive(Clone)]
struct Lexer<'a> {
pub(crate) struct Lexer<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Lexer<'a> {
fn at(buf: &'a [u8], pos: usize) -> Self {
pub(crate) fn at(buf: &'a [u8], pos: usize) -> Self {
Self { buf, pos }
}
@@ -557,7 +574,7 @@ impl<'a> Lexer<'a> {
}
}
fn next(&mut self) -> Option<Spanned<'a>> {
pub(crate) fn next(&mut self) -> Option<Spanned<'a>> {
self.skip_space();
let start = self.pos;
let b = self.peek_byte(0)?;
@@ -689,7 +706,7 @@ impl<'a> Lexer<'a> {
}
/// One object (a reference `n g R` reads as one).
fn object(&mut self, depth: usize) -> Option<Obj<'a>> {
pub(crate) fn object(&mut self, depth: usize) -> Option<Obj<'a>> {
if depth > MAX_DEPTH {
return None;
}
@@ -748,7 +765,7 @@ impl<'a> Lexer<'a> {
}
/// The header `num generation obj` of an indirect object.
fn header(&mut self) -> Option<(u32, u16)> {
pub(crate) fn header(&mut self) -> Option<(u32, u16)> {
let (Some((Tok::Word(n), ..)), Some((Tok::Word(g), ..)), Some((Tok::Word(b"obj"), ..))) = (self.next(), self.next(), self.next()) else {
return None;
};
@@ -820,14 +837,29 @@ fn object_edits(pdf: &[u8], off: usize, h: &Handler, catalog: bool, rng: &mut En
{
edits.push((start + 2, start + 2, AES256_EXTENSION.to_vec()));
}
let Some((Tok::Word(b"stream"), _, keyword_end)) = lx.next() else { return Ok(()) };
let Some(((len_start, len_end), (data, data_end))) = stream_spans(pdf, &mut lx, &obj).map_err(failed)? else { return Ok(()) };
let ty = obj.name(b"Type");
if matches!(ty, Some(b"XRef" | b"ObjStm")) {
return Err(failed("cross-reference and object streams aren't supported"));
}
let Some(Obj::Int { value, start: len_start, end: len_end }) = obj.get(b"Length") else {
return Err(failed("a stream's length isn't a number"));
};
if ty == Some(&b"Metadata"[..]) && !h.encrypt_metadata {
return Ok(());
}
let bytes = pdf.get(data..data_end).ok_or_else(|| failed("a stream runs past the end"))?;
let enc = h.encrypt(num, generation, bytes, rng)?;
edits.push((len_start, len_end, enc.len().to_string().into_bytes()));
edits.push((data, data_end, enc));
Ok(())
}
/// The spans of a stream's length value and of its data.
pub(crate) type StreamSpans = ((usize, usize), (usize, usize));
/// Where the object whose dictionary `dict` the lexer `lx` has just read keeps its stream data:
/// its [`StreamSpans`]; `None` when it isn't a stream.
pub(crate) fn stream_spans(pdf: &[u8], lx: &mut Lexer<'_>, dict: &Obj<'_>) -> Result<Option<StreamSpans>, &'static str> {
let Some((Tok::Word(b"stream"), _, keyword_end)) = lx.next() else { return Ok(None) };
let Some(Obj::Int { value, start, end }) = dict.get(b"Length") else { return Err("a stream's length isn't a number") };
// The data starts after the end of line that follows `stream`.
let mut data = keyword_end;
if pdf.get(data) == Some(&b'\r') {
@@ -836,26 +868,42 @@ fn object_edits(pdf: &[u8], off: usize, h: &Handler, catalog: bool, rng: &mut En
if pdf.get(data) == Some(&b'\n') {
data += 1;
}
let data_end = usize::try_from(*value).ok().and_then(|n| data.checked_add(n)).ok_or_else(|| failed("bad stream length"))?;
let bytes = pdf.get(data..data_end).ok_or_else(|| failed("a stream runs past the end"))?;
let data_end = usize::try_from(*value).ok().and_then(|n| data.checked_add(n)).ok_or("bad stream length")?;
if data_end > pdf.len() {
return Err("a stream runs past the end");
}
if !matches!(Lexer::at(pdf, data_end).next(), Some((Tok::Word(b"endstream"), ..))) {
return Err(failed("a stream's length is wrong"));
return Err("a stream's length is wrong");
}
if ty == Some(&b"Metadata"[..]) && !h.encrypt_metadata {
return Ok(());
Ok(Some(((*start, *end), (data, data_end))))
}
/// What encrypts the streams of a file [`protect_keeping`] encrypted, for those written after it
/// (the hint stream of a linearised file).
pub(crate) struct Cipher {
h: Handler,
rng: Entropy,
}
impl Cipher {
/// `data`, the stream of object `num` (generation 0), encrypted.
pub(crate) fn stream(&mut self, num: u32, data: &[u8]) -> Result<Vec<u8>, PdfError> {
self.h.encrypt(num, 0, data, &mut self.rng)
}
let enc = h.encrypt(num, generation, bytes, rng)?;
edits.push((*len_start, *len_end, enc.len().to_string().into_bytes()));
edits.push((data, data_end, enc));
Ok(())
}
/// Encrypt `pdf` (as the export writes it: one cross-reference table) as `set`'s Security
/// section says; without a password it comes back as it is.
pub(crate) fn protect(pdf: Vec<u8>, set: &PdfSettings) -> Result<Vec<u8>, PdfError> {
protect_keeping(pdf, set).map(|(pdf, _)| pdf)
}
/// [`protect`], keeping what encrypted the file (`None` when it isn't encrypted). The objects keep
/// their order and numbers; the encryption dictionary comes after them, numbered last.
pub(crate) fn protect_keeping(pdf: Vec<u8>, set: &PdfSettings) -> Result<(Vec<u8>, Option<Cipher>), PdfError> {
let sec = &set.security;
if !sec.protected() {
return Ok(pdf);
return Ok((pdf, None));
}
let xref = xref_offset(&pdf).ok_or_else(|| failed("no cross-reference table"))?;
let (mut objects, trailer) = xref_table(&pdf, xref)?;
@@ -933,7 +981,7 @@ pub(crate) fn protect(pdf: Vec<u8>, set: &PdfSettings) -> Result<Vec<u8>, PdfErr
out.extend_from_slice(format!("/ID[{}{}]", hex(&id), hex(&id)).as_bytes());
}
out.extend_from_slice(format!("/Encrypt {size} 0 R>>\nstartxref\n{table}\n%%EOF").as_bytes());
Ok(out)
Ok((out, Some(Cipher { h, rng })))
}
// ---------- reading ----------
+135 -65
View File
@@ -23,6 +23,7 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
use vectorcraft_text::TextLayout;
use vectorcraft_text::embed::Embedding;
use crate::forms::Mark;
use crate::lab_spot::{find, rfind};
use crate::marks::PageBoxes;
use crate::output::ColorOut;
@@ -41,22 +42,11 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
// Live geometry effects export as their result; raster effects are reported below.
let baked = vectorcraft_effects::bake_document(doc);
let doc = baked.as_ref().unwrap_or(doc);
if doc.artboards.is_empty() {
return Err(PdfError::NoArtboards);
}
let indices: Vec<usize> = match &opts.artboards {
Some(v) => v.clone(),
None => (0..doc.artboards.len()).collect(),
};
if indices.is_empty() {
return Err(PdfError::NoArtboards);
}
if let Some(bad) = indices.iter().find(|i| **i >= doc.artboards.len()) {
return Err(PdfError::BadArtboard(*bad));
}
let pages = pages(doc, opts)?;
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
let created_at = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
crate::pdfx::require_date(set.standard, created_at)?;
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
// Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it).
let mut warnings = set.warnings();
@@ -70,12 +60,15 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
let mut ex = Exporter::new(doc, set);
ex.non_printing = set.include_non_printing || set.create_layers;
ex.out = w.out.clone();
// Marks and Bleeds: each page is its artboard (the trim box) grown by the bleed, and by the
// printer's marks around that.
// A knockout page group draws the layers' objects one by one, not layer by layer.
ex.layers = set.writes_layers() && !doc.page_knockout;
if set.writes_layers() && doc.page_knockout {
warnings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into());
}
ex.overprint = set.advanced.overprint == crate::Overprint::Preserve;
let bleed = set.bleed_of(doc);
let marks = set.marks.printer_marks();
for i in indices {
let boxes = PageBoxes::new(doc.artboards[i].rect, bleed, &marks);
for (i, boxes) in pages {
let (m, r) = (boxes.media, boxes.bleed);
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, created_at) } else { String::new() };
let art = crate::marks::art(doc, &marks, &boxes, bleed, &info);
@@ -96,15 +89,37 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
};
w.page(&mut ex, &sheet).map_err(|_| PdfError::BadArtboard(i))?;
}
let (layers, overprinted) = (ex.layers, ex.overprinted);
w.absorb(ex);
let (bytes, more) = w.finish()?;
let bytes = if native.is_some() { crate::editing::seal(bytes)? } else { bytes };
let bytes = crate::encrypt::protect(bytes, set)?;
let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted)?;
warnings.extend(more);
let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?;
warnings.dedup();
Ok(ExportReport { bytes, warnings })
}
/// The artboards `opts` exports, in page order, with the boxes of their pages: Marks and Bleeds
/// make each page its artboard (the trim box) grown by the bleed, and by the printer's marks
/// around that.
fn pages(doc: &Document, opts: &PdfOptions) -> Result<Vec<(usize, PageBoxes)>, PdfError> {
let indices: Vec<usize> = match &opts.artboards {
Some(v) => v.clone(),
None => (0..doc.artboards.len()).collect(),
};
if doc.artboards.is_empty() || indices.is_empty() {
return Err(PdfError::NoArtboards);
}
let (bleed, marks) = (opts.settings.bleed_of(doc), opts.settings.marks.printer_marks());
indices.into_iter().map(|i| Ok((i, PageBoxes::new(doc.artboards.get(i).ok_or(PdfError::BadArtboard(i))?.rect, bleed, &marks)))).collect()
}
/// What each page of `doc` exported with `opts` shows, in document space (its media box: the
/// artboard with the bleed and the printer's marks), in page order.
pub fn page_areas(doc: &Document, opts: &PdfOptions) -> Result<Vec<Rect>, PdfError> {
Ok(pages(doc, opts)?.into_iter().map(|(_, b)| b.media).collect())
}
/// A PDF being written, shared by export and print: the file's configuration and metadata, then
/// its pages ([`Self::page`]), each drawn by an [`Exporter`].
pub(crate) struct Writer {
@@ -117,6 +132,8 @@ pub(crate) struct Writer {
/// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]).
lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>,
warnings: Vec<String>,
/// A PDF 1.3 file ([`crate::PdfSettings::pdf13`]): flat, without page groups.
pdf13: bool,
}
/// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]),
@@ -156,7 +173,8 @@ impl Writer {
warnings.push(format!("the CMYK profile {} can't be embedded: CMYK colours are written untagged", out.cmyk_profile()));
}
let version = match set.compatibility {
Compatibility::Pdf14 => PdfVersion::Pdf14,
// PDF 1.3 files are written with the PDF 1.4 settings: flat, they use nothing newer.
Compatibility::Pdf13 | Compatibility::Pdf14 => PdfVersion::Pdf14,
Compatibility::Pdf15 => PdfVersion::Pdf15,
Compatibility::Pdf16 => PdfVersion::Pdf16,
Compatibility::Pdf17 => PdfVersion::Pdf17,
@@ -197,7 +215,7 @@ impl Writer {
meta = meta.creation_date(t);
}
pdf.set_metadata(meta);
Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings })
Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings, pdf13: set.pdf13() })
}
/// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks.
@@ -226,10 +244,11 @@ impl Writer {
s.push_clip_path(clip, &krilla::paint::FillRule::NonZero);
}
// Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF
// writer has no page group attributes). In CMYK, transparency at the top of a page is put
// in a non-isolated group of its own, whose blending space `cmyk_blending` rewrites.
// writer has no page group attributes; a flat PDF 1.3 file has none). In CMYK, transparency
// at the top of a page is put in a non-isolated group of its own, whose blending space
// `cmyk_blending` rewrites.
let doc = ex.doc;
let page_group = doc.page_isolate || doc.page_knockout;
let page_group = !self.pdf13 && (doc.page_isolate || doc.page_knockout);
let cmyk_page_group = self.cmyk && !page_group && ex.shows_transparency();
if page_group {
s.push_isolated();
@@ -284,17 +303,19 @@ impl Writer {
}
}
/// The finished file (with the output intent and Trapped entries) and the warnings of its
/// drawing.
/// The finished file (with the output intent and Trapped entries, and checked against its
/// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK
/// alternates.
pub(crate) fn finish(mut self) -> Result<(Vec<u8>, Vec<String>), PdfError> {
let mut bytes = self.pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
if self.cmyk {
cmyk_blending(&mut bytes, self.out.tagged);
}
let bytes = if self.lab_spots.is_empty() { bytes } else { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) };
let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only();
let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes };
let (bytes, more) = self.out.write_catalog(bytes)?;
self.warnings.extend(more);
Ok((bytes, self.warnings))
Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings))
}
}
@@ -432,6 +453,15 @@ pub(crate) struct Exporter<'a> {
/// The fonts real text is written in, by face id, with their units per em; `None` for faces
/// that can't be embedded.
fonts: HashMap<u32, Option<(krilla::text::Font, f64)>>,
/// Top-level layers are drawn as forms marked for their optional content groups
/// ([`crate::forms`]).
pub layers: bool,
/// A top-level layer's form is being drawn.
in_layer: bool,
/// Overprinting fills and strokes are drawn as forms marked to overprint.
pub overprint: bool,
/// An overprinting fill or stroke was drawn so.
pub overprinted: bool,
}
impl<'a> Exporter<'a> {
@@ -454,6 +484,10 @@ impl<'a> Exporter<'a> {
convert: true,
outline_text: set.advanced.outline_text,
fonts: HashMap::new(),
layers: false,
in_layer: false,
overprint: false,
overprinted: false,
}
}
@@ -783,10 +817,12 @@ impl Exporter<'_> {
if !m.clip || m.invert {
cover(&mut ms, page, if m.clip { 0 } else { 255 }, 1.0);
}
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
// Mask art is a picture of its own: it takes no part in a knockout group around the object,
// and has no inks to overprint.
let knockout = std::mem::take(&mut self.knockout);
let overprint = std::mem::take(&mut self.overprint);
self.node(&mut ms, &m.art, page, true);
self.knockout = knockout;
(self.knockout, self.overprint) = (knockout, overprint);
if m.invert {
ms.push_blend_mode(krilla::blend::BlendMode::Difference);
cover(&mut ms, page, 255, 1.0);
@@ -834,7 +870,9 @@ impl Exporter<'_> {
let alpha = {
let mut ab = ms.stream_builder();
let mut als = ab.surface();
let overprint = std::mem::take(&mut self.overprint);
self.node(&mut als, &shape, page, false);
self.overprint = overprint;
als.finish();
krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha)
};
@@ -846,6 +884,18 @@ impl Exporter<'_> {
}
fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) {
if self.layers
&& !self.in_layer
&& let NodeKind::Layer { template: false, printable, .. } = n.kind
&& (printable || self.non_printing)
&& let Some(i) = self.doc.layers.iter().position(|l| std::ptr::eq(&**l, n))
{
// A PDF layer: hidden layers are written too (their group is off).
self.in_layer = true;
crate::forms::form(s, Mark::Layer(i), |s| self.node(s, n, page, true));
self.in_layer = false;
return;
}
if !force && !n.visible {
return;
}
@@ -955,27 +1005,29 @@ impl Exporter<'_> {
continue;
}
self.warn_raster(&fl.effects, || "fills".into());
if self.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) {
continue;
}
let Some(paint) = self.paint(&fl.paint, bounds) else { continue };
let bl = fl.blend != BlendMode::Normal;
if bl {
s.push_blend_mode(blend(fl.blend));
}
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: rule(r) }));
s.draw_path(&path);
if bl {
s.pop();
}
self.overprinting(s, fl.overprint, &fl.paint, |ex, s| {
if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) {
return;
}
let Some(paint) = ex.paint(&fl.paint, bounds) else { return };
let bl = fl.blend != BlendMode::Normal;
if bl {
s.push_blend_mode(blend(fl.blend));
}
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: rule(r) }));
s.draw_path(&path);
if bl {
s.pop();
}
});
}
AppearanceItem::Stroke(st) => {
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
continue;
}
self.warn_raster(&st.effects, || "strokes".into());
self.stroke(s, bp, &path, r, st, page, bounds);
self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, bp, &path, r, st, page, bounds));
}
}
}
@@ -983,6 +1035,19 @@ impl Exporter<'_> {
s.set_stroke(None);
}
/// Draw a fill or stroke of `paint` with `draw`: as a form marked to overprint when it
/// `overprints`, paints and overprints are kept (but not a white one with Discard White
/// Overprint).
fn overprinting(&mut self, s: &mut Surface, overprints: bool, paint: &Paint, draw: impl FnOnce(&mut Self, &mut Surface)) {
let white = || self.doc.setup.discard_white_overprint && vectorcraft_doc::overprint::is_white(paint);
if overprints && self.overprint && !paint.is_none() && !white() {
self.overprinted = true;
crate::forms::form(s, Mark::Overprint, |s| draw(self, s));
} else {
draw(self, s);
}
}
/// Is `p` a paint PDF has no paint for, which [`Self::area`] draws as art: a pattern swatch or
/// a freeform gradient?
fn area_paint(&self, p: &Paint) -> bool {
@@ -1231,25 +1296,28 @@ impl Exporter<'_> {
bp.extend(g.outline.iter());
}
let fill = &run.style.fill;
// The outlines left to fill as paths: all of them, or those real text leaves.
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !self.area_paint(fill)) {
Some(text) => Cow::Owned(self.glyph_text(s, &layout, i, text, fill)),
None => Cow::Borrowed(&bp),
};
let opaque = (1.0, BlendMode::Normal);
if let Some(path) = to_path(&outlines)
&& !self.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box())
&& let Some(paint) = self.paint(fill, layout.bounds)
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&path);
}
self.overprinting(s, run.style.overprint_fill, fill, |ex, s| {
// The outlines left to fill as paths: all of them, or those real text leaves.
let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) {
Some(text) => Cow::Owned(ex.glyph_text(s, &layout, i, text, fill)),
None => Cow::Borrowed(&bp),
};
let opaque = (1.0, BlendMode::Normal);
if let Some(path) = to_path(&outlines)
&& !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box())
&& let Some(paint) = ex.paint(fill, layout.bounds)
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&path);
}
});
if run.style.has_stroke()
&& let Some(path) = to_path(&bp)
{
// Character strokes are drawn in text space, with their cap, join and dashes.
self.stroke(s, &bp, &path, FillRule::NonZero, &run.style.stroke_layer(), page, layout.bounds);
let st = run.style.stroke_layer();
self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, &bp, &path, FillRule::NonZero, &st, page, layout.bounds));
}
}
s.set_fill(None);
@@ -1266,16 +1334,18 @@ impl Exporter<'_> {
fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], bp: &BezPath, path: &Path, page: Rect, tb: Rect) {
for item in items {
match item {
AppearanceItem::Fill(fl) if fl.visible => {
if !self.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box())
&& let Some(paint) = self.paint(&fl.paint, tb)
AppearanceItem::Fill(fl) if fl.visible => self.overprinting(s, fl.overprint, &fl.paint, |ex, s| {
if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box())
&& let Some(paint) = ex.paint(&fl.paint, tb)
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
s.draw_path(path);
}
}),
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => {
self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, bp, path, FillRule::NonZero, st, page, tb))
}
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => self.stroke(s, bp, path, FillRule::NonZero, st, page, tb),
_ => {}
}
}
+315
View File
@@ -0,0 +1,315 @@
//! What the PDF writer has no operators for — optional content (PDF layers) and overprint — drawn
//! as form XObjects of their own (without a transparency group, so they draw as if in place),
//! each starting with a mark: an empty clip whose first point says what the form is. Marked
//! forms also stay apart (the writer shares identical forms). [`finish`] completes them in the
//! written file: a layer's form is drawn as the layer's optional content (`/OC … BDC`), and an
//! overprinting form sets a graphics state that overprints first.
use std::collections::HashMap;
use std::io::Read;
use krilla::geom::PathBuilder;
use krilla::graphic::Graphic;
use krilla::surface::Surface;
use vectorcraft_doc::{Document, NodeKind};
use crate::encrypt::{Lexer, Obj, StreamSpans, Tok, int, stream_spans};
use crate::output::{deflate, text_string};
use crate::patch::{Patch, Xref};
use crate::{PdfError, PdfSettings, Standard};
/// What a marked form is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Mark {
/// The art of top-level layer `i` (`Document::layers`).
Layer(usize),
/// A fill or stroke that overprints.
Overprint,
}
/// The x coordinate of a layer mark's first point (its y is the layer index).
const LAYER_X: i32 = -32000;
/// The x coordinate of an overprint mark's first point.
const OVERPRINT_X: i32 = -32001;
impl Mark {
fn at(self) -> (f32, f32) {
match self {
// Exact in `f32` up to 2^24 layers.
Self::Layer(i) => (LAYER_X as f32, i as f32),
Self::Overprint => (OVERPRINT_X as f32, 0.0),
}
}
}
/// Draw `draw` as a form of its own, marked `mark`.
pub(crate) fn form(s: &mut Surface, mark: Mark, draw: impl FnOnce(&mut Surface)) {
let mut sb = s.stream_builder();
let mut fs = sb.surface();
let (x, y) = mark.at();
let mut pb = PathBuilder::new();
pb.move_to(x, y);
pb.line_to(x + 1.0, y);
pb.line_to(x + 1.0, y + 1.0);
pb.close();
// A form's drawing space is its own: the mark's points are written as they are.
if let Some(p) = pb.finish() {
fs.push_clip_path(&p, &krilla::paint::FillRule::NonZero);
fs.pop();
}
draw(&mut fs);
fs.finish();
let stream = sb.finish();
s.draw_graphic(Graphic::new(stream, false));
}
// ---------- finishing the written file ----------
/// The resource name of the overprinting graphics state.
const OVERPRINT_GS: &str = "VCop";
/// The prefix of the resource names of the layers' optional content groups (then the index).
const LAYER_OC: &str = "VCoc";
/// The most bytes of a form's content read to find its mark.
const HEAD: u64 = 64;
fn bad(why: &str) -> PdfError {
PdfError::Write(format!("can't write PDF layers or overprint: {why}"))
}
/// An object of the written file with a dictionary, and its stream's [`StreamSpans`].
struct Object<'a> {
dict: Obj<'a>,
stream: Option<StreamSpans>,
}
impl Object<'_> {
/// The stream's data, decoded (Flate or none), at most `limit` bytes.
fn data(&self, pdf: &[u8], limit: u64) -> Option<Vec<u8>> {
let (_, (start, end)) = self.stream?;
let data = pdf.get(start..end)?;
let mut out = vec![];
match self.dict.get(b"Filter") {
None => out.extend_from_slice(data.get(..usize::try_from(limit).unwrap_or(usize::MAX).min(data.len()))?),
Some(Obj::Name(b"FlateDecode")) => {
flate2::read::ZlibDecoder::new(data).take(limit).read_to_end(&mut out).ok()?;
}
Some(_) => return None,
}
Some(out)
}
/// Replace the stream's data with `content` (compressed as before).
fn rewrite(&self, patch: &mut Patch, content: &[u8]) -> Result<(), PdfError> {
let ((len_start, len_end), (start, end)) = self.stream.ok_or_else(|| bad("a content stream isn't a stream"))?;
let data = if self.dict.get(b"Filter").is_some() { deflate(content).map_err(|e| bad(&e.to_string()))? } else { content.to_vec() };
patch.replace(len_start, len_end, data.len().to_string().into_bytes());
patch.replace(start, end, data);
Ok(())
}
/// The mark of a marked form.
fn mark(&self, pdf: &[u8]) -> Option<Mark> {
if self.dict.name(b"Subtype") != Some(b"Form") {
return None;
}
let head = self.data(pdf, HEAD)?;
let mut lx = Lexer::at(&head, 0);
let mut word = || match lx.next() {
Some((Tok::Word(w), ..)) => Some(w),
_ => None,
};
let (Some(b"q"), Some(x), Some(y), Some(b"m")) = (word(), word().and_then(int), word().and_then(int), word()) else { return None };
match x {
_ if x == i64::from(LAYER_X) => usize::try_from(y).ok().map(Mark::Layer),
_ if x == i64::from(OVERPRINT_X) && y == 0 => Some(Mark::Overprint),
_ => None,
}
}
}
/// The objects of the written file that have a dictionary, by number.
fn objects<'a>(pdf: &'a [u8], xref: &Xref) -> HashMap<u32, Object<'a>> {
(1..xref.count())
.filter_map(|n| {
let n = u32::try_from(n).ok()?;
let mut lx = Lexer::at(pdf, xref.offset(pdf, n)?);
(lx.header()?.0 == n).then_some(())?;
let dict = lx.object(0)?;
let Obj::Dict { .. } = dict else { return None };
let stream = stream_spans(pdf, &mut lx, &dict).ok().flatten();
Some((n, Object { dict, stream }))
})
.collect()
}
/// The resource dictionary of the content of `o` (a page or a form).
fn resources<'o>(objects: &'o HashMap<u32, Object<'_>>, o: &'o Object<'_>) -> Option<&'o Obj<'o>> {
match o.dict.get(b"Resources")? {
Obj::Ref(n, _) => objects.get(n).map(|r| &r.dict),
d => Some(d),
}
}
/// Add `entries` (`/VCoc0 12 0 R`) to the `category` (`Properties`, `ExtGState`) of resource
/// dictionary `res`.
fn add_resources(patch: &mut Patch, res: &Obj<'_>, category: &str, entries: &str) -> Result<(), PdfError> {
match (res.get(category.as_bytes()), res) {
(Some(Obj::Dict { start, .. }), _) => patch.replace(start + 2, start + 2, entries.as_bytes().to_vec()),
(None, Obj::Dict { start, .. }) => patch.replace(start + 2, start + 2, format!("/{category}<<{entries}>>").into_bytes()),
_ => return Err(bad("unexpected resources")),
}
Ok(())
}
/// `content` with each `Do` of a layer's form (`forms`: its resource name → the layer) marked as
/// that layer's optional content, and the layers marked; `None` when there is none.
fn mark_layers(content: &[u8], forms: &HashMap<&[u8], usize>) -> Option<(Vec<u8>, Vec<usize>)> {
let mut lx = Lexer::at(content, 0);
let mut name = None;
let mut spans = vec![];
while let Some((tok, start, end)) = lx.next() {
name = match tok {
Tok::Name(n) => forms.get(n).map(|layer| (start, *layer)),
Tok::Word(b"Do") => {
spans.extend(name.map(|(name, layer)| (name, end, layer)));
None
}
// Inline image data isn't PDF syntax (the writer writes none).
Tok::Word(b"ID") => break,
_ => None,
};
}
if spans.is_empty() {
return None;
}
let mut out = Vec::with_capacity(content.len() + spans.len() * 24);
let mut at = 0;
for (start, end, layer) in &spans {
out.extend_from_slice(content.get(at..*start)?);
out.extend_from_slice(format!("/OC /{LAYER_OC}{layer} BDC ").as_bytes());
out.extend_from_slice(content.get(*start..*end)?);
out.extend_from_slice(b" EMC");
at = *end;
}
out.extend_from_slice(content.get(at..)?);
let mut layers: Vec<usize> = spans.iter().map(|s| s.2).collect();
layers.sort_unstable();
layers.dedup();
Some((out, layers))
}
/// Finish the marked forms of `pdf` (as [`crate::export`] writes `doc` with `set`): with
/// `layers`, each top-level layer (but template layers) becomes an optional content group —
/// named as the layer, off when it is hidden, not printed (`/PrintState /OFF`) when its Print
/// option is off, locked when it is locked — and the uses of its forms its optional content;
/// with `overprint`, the overprinting forms get a graphics state that overprints (`/OP`, `/op`,
/// `/OPM 1`; `/OPM 0` in PDF/A, which allows no other with ICC-based CMYK colours).
pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bool, overprint: bool) -> Result<Vec<u8>, PdfError> {
if !layers && !overprint {
return Ok(pdf);
}
let xref = Xref::read(&pdf).ok_or_else(|| bad("the written PDF has no cross-reference table"))?;
let objects = objects(&pdf, &xref);
let marks: HashMap<u32, Mark> = objects.iter().filter_map(|(n, o)| Some((*n, o.mark(&pdf)?))).collect();
let mut patch = Patch::new(&xref);
if overprint {
let opm = u8::from(set.standard != Standard::PdfA2b);
let gs = patch.add_object(format!("<</Type/ExtGState/OP true/op true/OPM {opm}>>").into_bytes());
for (n, _) in marks.iter().filter(|(_, m)| **m == Mark::Overprint) {
let Some(o) = objects.get(n) else { continue };
let (Some(content), Some(res)) = (o.data(&pdf, u64::MAX), resources(&objects, o)) else {
return Err(bad("an overprinting form can't be read"));
};
let mut new = format!("/{OVERPRINT_GS} gs\n").into_bytes();
new.extend_from_slice(&content);
o.rewrite(&mut patch, &new)?;
add_resources(&mut patch, res, "ExtGState", &format!("/{OVERPRINT_GS} {gs} 0 R"))?;
}
}
if layers {
write_layers(&pdf, &xref, &objects, &marks, &mut patch, doc, set)?;
}
let pdf = patch.apply(&pdf, &xref)?;
// The file still is what its standard says.
crate::pdfx::verify(&pdf, set.standard)?;
Ok(pdf)
}
/// The optional content groups of `doc`'s top-level layers, the uses of their forms marked as
/// their content, and the catalog's `/OCProperties`.
fn write_layers(
pdf: &[u8],
xref: &Xref,
objects: &HashMap<u32, Object<'_>>,
marks: &HashMap<u32, Mark>,
patch: &mut Patch,
doc: &Document,
set: &PdfSettings,
) -> Result<(), PdfError> {
// One group per layer, in paint order (bottom first).
let mut groups: HashMap<usize, u32> = HashMap::new();
let (mut all, mut off, mut locked) = (vec![], vec![], vec![]);
for (i, l) in doc.layers.iter().enumerate() {
let NodeKind::Layer { template: false, printable, .. } = l.kind else { continue };
let name = l.name.clone().unwrap_or_else(|| format!("Layer {}", i + 1));
let on = |b: bool| if b { "ON" } else { "OFF" };
let g = patch.add_object(
format!("<</Type/OCG/Name{}/Usage<</View<</ViewState/{}>>/Print<</PrintState/{}>>>>>>", text_string(&name), on(l.visible), on(printable))
.into_bytes(),
);
groups.insert(i, g);
all.push(g);
if !l.visible {
off.push(g);
}
if l.locked {
locked.push(g);
}
}
// Every content stream drawing layers' forms: the pages' and the forms'.
for o in objects.values() {
let content = match o.dict.get(b"Contents") {
Some(Obj::Ref(c, _)) if o.dict.name(b"Type") == Some(b"Page") => objects.get(c),
_ if o.dict.name(b"Subtype") == Some(b"Form") => Some(o),
_ => None,
};
let (Some(content), Some(res)) = (content, resources(objects, o)) else { continue };
let Some(Obj::Dict { entries, .. }) = res.get(b"XObject") else { continue };
let forms: HashMap<&[u8], usize> = entries
.iter()
.filter_map(|(name, x)| {
let Obj::Ref(n, _) = x else { return None };
match marks.get(n) {
Some(Mark::Layer(i)) if groups.contains_key(i) => Some((*name, *i)),
_ => None,
}
})
.collect();
if forms.is_empty() {
continue;
}
let data = content.data(pdf, u64::MAX).ok_or_else(|| bad("a content stream can't be read"))?;
let Some((new, marked)) = mark_layers(&data, &forms) else { continue };
content.rewrite(patch, &new)?;
let entries: String = marked.iter().filter_map(|i| Some(format!("/{LAYER_OC}{i} {} 0 R", groups.get(i)?))).collect();
add_resources(patch, res, "Properties", &entries)?;
}
let refs = |v: &[u32]| v.iter().map(|g| format!("{g} 0 R")).collect::<Vec<_>>().join(" ");
// Layers panels list the top layer first.
let order: Vec<u32> = all.iter().rev().copied().collect();
let mut config = format!("/Name(Layers)/Order[{}]", refs(&order));
if !off.is_empty() {
config.push_str(&format!("/OFF[{}]", refs(&off)));
}
if !locked.is_empty() {
config.push_str(&format!("/Locked[{}]", refs(&locked)));
}
// Viewers apply the print states when printing (the standards allow no automatic states).
if set.standard == Standard::None {
config.push_str(&format!("/AS[<</Event/Print/OCGs[{}]/Category[/Print]>>]", refs(&all)));
}
let root = xref.trailer_ref(pdf, b"/Root").and_then(|n| objects.get(&n));
let Some(Object { dict: Obj::Dict { start, .. }, .. }) = root else { return Err(bad("the written PDF has no catalog")) };
patch.replace(start + 2, start + 2, format!("/OCProperties<</OCGs[{}]/D<<{config}>>>>", refs(&all)).into_bytes());
Ok(())
}
+26 -2
View File
@@ -10,11 +10,16 @@
//! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and
//! given an output intent as the Output settings say. Hidden objects,
//! guides and template layers are skipped, and so are non-printing layers unless asked for.
//! Top-level layers can be PDF layers (optional content groups with their visibility, print
//! state and lock), and overprinting fills and strokes overprint (Advanced).
//! Each page is its artboard (the trim box) grown by the bleed (the bleed box) and by the
//! printer's marks around it (the media box), drawn in Registration. [`PdfSettings`] is the Save PDF
//! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output,
//! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve
//! Editing the native document rides along as an embedded file ([`editing()`]).
//! Editing the native document rides along as an embedded file ([`editing()`]). Each page can
//! carry a thumbnail ([`Thumbnail`], drawn by the caller), and Fast Web View writes a linearised
//! file (still linearised when encrypted). A PDF 1.3 file ([`PdfSettings::pdf13`]) must be flat:
//! the app flattens the document first, and the writer refuses transparency that is left.
//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a
//! [`Document`]: one artboard per page, and a layer per page or per optional content group
//! (with its visibility, print state and lock; art that is off comes in as a hidden layer),
@@ -38,6 +43,7 @@
mod editing;
mod encrypt;
mod export;
mod forms;
mod images;
mod import;
mod import_color;
@@ -46,19 +52,24 @@ mod import_scan;
mod import_shading;
mod import_text;
mod lab_spot;
mod linearize;
mod marks;
mod output;
mod pages;
mod patch;
mod pdfx;
mod post;
mod presets;
mod print;
mod settings;
mod syntax;
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
pub use encrypt::Encryption;
pub use export::{export, export_with_report};
pub use export::{export, export_with_report, page_areas};
pub use import::{import, import_with_report};
pub use pages::{PageInfo, PdfInfo, info, is_postscript};
pub use post::{THUMBNAIL_SIZE, Thumbnail};
pub use presets::*;
pub use print::*;
pub use settings::*;
@@ -77,6 +88,9 @@ pub struct PdfOptions {
pub created: Option<i64>,
/// The native document (`.vectorcraft` bytes) Preserve Editing embeds.
pub native: Option<Vec<u8>>,
/// The pages drawn small, in page order: what Embed Page Thumbnails embeds (the writer
/// doesn't draw; [`page_areas`] says what each page shows).
pub thumbnails: Vec<Thumbnail>,
}
impl PdfOptions {
@@ -216,14 +230,22 @@ mod tests_import_layers;
#[cfg(test)]
mod tests_import_options;
#[cfg(test)]
mod tests_layers;
#[cfg(test)]
mod tests_marks;
#[cfg(test)]
mod tests_output;
#[cfg(test)]
mod tests_pdf13;
#[cfg(test)]
mod tests_pdfx;
#[cfg(test)]
mod tests_presets;
#[cfg(test)]
mod tests_print;
#[cfg(test)]
mod tests_printadvanced;
#[cfg(test)]
mod tests_realtext;
#[cfg(test)]
mod tests_settings;
@@ -233,3 +255,5 @@ mod tests_stroke;
mod tests_strokeout;
#[cfg(test)]
mod tests_textstroke;
#[cfg(test)]
mod tests_webview;
+532
View File
@@ -0,0 +1,532 @@
//! Save PDF › Optimize for fast web view: a linearised file (ISO 32000-1 Annex F), whose first page
//! shows while the rest of it downloads.
//!
//! [`prepare`] writes the export's file (one cross-reference table) again with its objects in
//! linearised order and numbering. First, with the highest numbers: the linearization dictionary,
//! the catalog with what opening the document needs, the hint stream and the first page (its page
//! object, then every object it uses, numbered on from it). Then, numbered from 1: each other page
//! with the objects only it uses, the objects several pages share, and the rest (the page tree,
//! document information, metadata, editing data, thumbnails). Encryption runs on that file: it
//! keeps the order and the numbers and adds its dictionary as the last object. [`write`] then lays
//! the file out: the linearization dictionary, the first page's cross-reference table and trailer,
//! the catalog part (with the encryption dictionary), the hint stream (the page offset and shared
//! object hint tables, encrypted like any stream), the first page, the other pages, the shared
//! objects, the rest and the main cross-reference table.
use std::collections::{HashMap, HashSet, VecDeque};
use crate::PdfError;
use crate::encrypt::Cipher;
use crate::encrypt::{Lexer, Obj};
use crate::patch::Xref;
use crate::syntax::{Indirect, indirect, ref_spans};
fn failed(why: &str) -> PdfError {
PdfError::Write(format!("can't linearise the PDF: {why}"))
}
/// Catalog entries whose objects opening the document needs: they go with the catalog.
const OPEN_KEYS: [&[u8]; 5] = [b"ViewerPreferences", b"Threads", b"OpenAction", b"AcroForm", b"OCProperties"];
/// Keys whose objects aren't part of what a page uses: its parent (the page tree) and its
/// thumbnail (shown without the page).
const NOT_USED: [&[u8]; 2] = [b"Parent", b"Thumb"];
/// Deepest page tree read.
const MAX_TREE_DEPTH: usize = 64;
/// Width of the numbers [`write`] fills in once the layout is known (offsets below 10 GB).
const WIDTH: usize = 10;
/// The linearised order of a [`prepare`]d file's objects, by part (object numbers).
pub(crate) struct Plan {
/// The linearization dictionary: the first object of the first page's cross-reference table.
first: u32,
hint: u32,
/// The catalog and the objects opening the document needs.
open: Vec<u32>,
/// The first page: its page object, then every object it uses.
first_page: Vec<u32>,
/// Each other page: its page object, then the objects only it uses.
pages: Vec<Vec<u32>>,
/// The objects several pages (not the first) use.
shared: Vec<u32>,
/// The rest.
other: Vec<u32>,
/// For each page but the first, the shared object hint table entries of the objects it uses
/// from the first page and from [`Self::shared`].
shared_ids: Vec<Vec<u32>>,
}
/// A written file (one cross-reference table, as the export writes it) read for rewriting.
pub(crate) struct Parsed<'a> {
pub xref: Xref,
pub objects: HashMap<u32, Indirect<'a>>,
/// The objects' numbers and offsets, in file order.
pub order: Vec<(u32, usize)>,
pub trailer: Obj<'a>,
/// The catalog.
pub root: u32,
/// The page objects, in order.
pub pages: Vec<u32>,
/// The page tree's nodes.
pub nodes: HashSet<u32>,
}
/// Read `pdf` → its objects, catalog and pages, or why it can't be.
pub(crate) fn parse(pdf: &[u8]) -> Result<Parsed<'_>, &'static str> {
let xref = Xref::read(pdf).ok_or("no cross-reference table")?;
let mut order = xref.objects(pdf);
order.sort_by_key(|e| e.1);
let mut objects = HashMap::with_capacity(order.len());
for &(n, off) in &order {
let o = indirect(pdf, off).filter(|o| o.num == n && o.generation == 0).ok_or("an object can't be read")?;
objects.insert(n, o);
}
let trailer = Lexer::at(pdf, xref.trailer_at()).object(0).ok_or("bad trailer")?;
let Some(&Obj::Ref(root, _)) = trailer.get(b"Root") else { return Err("no catalog") };
let catalog = &objects.get(&root).ok_or("no catalog")?.value;
let Some(&Obj::Ref(tree, _)) = catalog.get(b"Pages") else { return Err("no pages") };
let (mut pages, mut nodes) = (vec![], HashSet::new());
page_tree(&objects, tree, 0, &mut pages, &mut nodes)?;
if pages.is_empty() {
return Err("no pages");
}
Ok(Parsed { xref, objects, order, trailer, root, pages, nodes })
}
/// The pages under page tree node `n`, in order, and the tree's nodes.
pub(crate) fn page_tree(
objects: &HashMap<u32, Indirect<'_>>,
n: u32,
depth: usize,
pages: &mut Vec<u32>,
nodes: &mut HashSet<u32>,
) -> Result<(), &'static str> {
let node = objects.get(&n).ok_or("a page is missing")?;
match node.value.name(b"Type") {
Some(b"Page") if !pages.contains(&n) => pages.push(n),
Some(b"Pages") if depth < MAX_TREE_DEPTH && nodes.insert(n) => {
let Some(Obj::Array(kids)) = node.value.get(b"Kids") else { return Err("a page tree node has no kids") };
for kid in kids {
let Obj::Ref(k, _) = kid else { return Err("a page tree kid isn't a reference") };
page_tree(objects, *k, depth + 1, pages, nodes)?;
}
}
_ => return Err("bad page tree"),
}
Ok(())
}
/// The objects reached from `starts` (themselves included), breadth first, not entering those
/// `stop` names.
fn reach(objects: &HashMap<u32, Indirect<'_>>, starts: &[u32], stop: impl Fn(u32) -> bool) -> Vec<u32> {
let mut seen = HashSet::new();
let mut queue: VecDeque<u32> = starts.iter().copied().filter(|n| objects.contains_key(n) && seen.insert(*n)).collect();
let mut out = vec![];
let mut links = vec![];
while let Some(n) = queue.pop_front() {
out.push(n);
links.clear();
if let Some(o) = objects.get(&n) {
o.value.refs(&NOT_USED, &mut links);
}
for &m in &links {
if objects.contains_key(&m) && !stop(m) && seen.insert(m) {
queue.push_back(m);
}
}
}
out
}
/// `pdf` (as the export writes it: one cross-reference table, no encryption) with its objects in
/// linearised order and numbering, a placeholder linearization dictionary and hint stream, and the
/// plan [`write`] lays it out with.
pub(crate) fn prepare(pdf: &[u8]) -> Result<(Vec<u8>, Plan), PdfError> {
let Parsed { objects, order: entries, trailer, root, pages, nodes, .. } = parse(pdf).map_err(failed)?;
if trailer.get(b"Encrypt").is_some() || trailer.get(b"Prev").is_some() {
return Err(failed("the file was updated or encrypted already"));
}
let catalog = &objects.get(&root).ok_or_else(|| failed("no catalog"))?.value;
let Some(&first_page_object) = pages.first() else { return Err(failed("no pages")) };
let page_set: HashSet<u32> = pages.iter().copied().collect();
let stop = |n: u32| n == root || page_set.contains(&n) || nodes.contains(&n);
// The catalog and what opening the document needs.
let mut starts = vec![];
for key in OPEN_KEYS {
if let Some(v) = catalog.get(key) {
v.refs(&NOT_USED, &mut starts);
}
}
starts.retain(|n| !stop(*n));
let open: Vec<u32> = std::iter::once(root).chain(reach(&objects, &starts, stop)).collect();
let mut taken: HashSet<u32> = open.iter().copied().collect();
// What each page uses (pages don't enter other pages).
let uses: Vec<Vec<u32>> = pages.iter().map(|&p| reach(&objects, &[p], |n| n != p && stop(n))).collect();
let first_page: Vec<u32> = uses.first().map_or(&[][..], Vec::as_slice).iter().copied().filter(|n| !taken.contains(n)).collect();
taken.extend(&first_page);
let rest = uses.get(1..).unwrap_or_default();
let mut users: HashMap<u32, usize> = HashMap::new();
for n in rest.iter().flatten().filter(|n| !taken.contains(n)) {
*users.entry(*n).or_default() += 1;
}
let own: Vec<Vec<u32>> = rest.iter().map(|u| u.iter().copied().filter(|n| users.get(n) == Some(&1)).collect()).collect();
let mut shared = vec![];
for &n in rest.iter().flatten() {
if users.get(&n).is_some_and(|c| *c > 1) && !shared.contains(&n) {
shared.push(n);
}
}
taken.extend(own.iter().flatten().chain(&shared));
let other: Vec<u32> = entries.iter().map(|e| e.0).filter(|n| !taken.contains(n)).collect();
if own.iter().any(|p| p.first().is_none_or(|n| !page_set.contains(n))) || first_page.first() != Some(&first_page_object) {
return Err(failed("a page object is used by something else"));
}
// Their shared object hint table entries: the first page's objects, then the shared ones.
let index: HashMap<u32, u32> = first_page.iter().chain(&shared).enumerate().map(|(i, n)| (*n, i as u32)).collect();
let shared_ids: Vec<Vec<u32>> =
rest.iter().map(|u| u.iter().filter(|n| users.get(n) != Some(&1)).filter_map(|n| index.get(n).copied()).collect()).collect();
// Numbers: the second part from 1, then the first part.
let mut numbers = Numbers { next: 1, of: HashMap::with_capacity(objects.len()) };
let own_new: Vec<Vec<u32>> = own.iter().map(|p| numbers.assign(p)).collect();
let shared_new = numbers.assign(&shared);
let other_new = numbers.assign(&other);
let first = numbers.fresh();
let open_new = numbers.assign(&open);
let hint = numbers.fresh();
let first_page_new = numbers.assign(&first_page);
let (size, number) = (numbers.next, numbers.of);
if number.len() != objects.len() {
return Err(failed("objects were left out"));
}
// The file in that order.
let head = entries.first().and_then(|e| pdf.get(..e.1)).ok_or_else(|| failed("no objects"))?;
let mut out = Vec::with_capacity(pdf.len() + 256);
out.extend_from_slice(head);
let mut offsets = vec![0usize; size as usize];
let mut put = |num: u32, body: &[u8], out: &mut Vec<u8>| {
if let Some(slot) = offsets.get_mut(num as usize) {
*slot = out.len();
}
out.extend_from_slice(format!("{num} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
let body = |n: u32| -> Result<Vec<u8>, PdfError> {
let o = objects.get(&n).ok_or_else(|| failed("an object is missing"))?;
// Up to `endobj`, without the white space before it.
let end = o.endobj - pdf.get(..o.endobj).map_or(0, |b| b.iter().rev().take_while(|c| c.is_ascii_whitespace()).count());
renumber(pdf, o.value_span.0, end.max(o.value_span.1), o.value_span.1, &number)
};
put(first, b"<</Linearized 1>>", &mut out);
for (&n, &new) in open.iter().zip(&open_new) {
put(new, &body(n)?, &mut out);
}
put(hint, b"<</Length 0>>\nstream\n\nendstream", &mut out);
let order = first_page.iter().zip(&first_page_new).chain(own.iter().flatten().zip(own_new.iter().flatten()));
for (&n, &new) in order.chain(shared.iter().zip(&shared_new)).chain(other.iter().zip(&other_new)) {
put(new, &body(n)?, &mut out);
}
let table = out.len();
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f\r\n").as_bytes());
for off in offsets.iter().skip(1) {
out.extend_from_slice(format!("{off:010} 00000 n\r\n").as_bytes());
}
let Obj::Dict { start, end, .. } = &trailer else { return Err(failed("bad trailer")) };
let Some(Obj::Int { start: size_start, end: size_end, .. }) = trailer.get(b"Size") else { return Err(failed("the trailer has no size")) };
let mut dict = renumber(pdf, *start, *size_start, *size_start, &number)?;
dict.extend_from_slice(size.to_string().as_bytes());
dict.extend(renumber(pdf, *size_end, *end, *end, &number)?);
out.extend_from_slice(b"trailer\n");
out.extend(dict);
out.extend_from_slice(format!("\nstartxref\n{table}\n%%EOF\n").as_bytes());
let plan = Plan { first, hint, open: open_new, first_page: first_page_new, pages: own_new, shared: shared_new, other: other_new, shared_ids };
Ok((out, plan))
}
/// New object numbers, given in turn.
struct Numbers {
next: u32,
/// Old number → new.
of: HashMap<u32, u32>,
}
impl Numbers {
/// A number for a new object.
fn fresh(&mut self) -> u32 {
self.next += 1;
self.next - 1
}
/// Numbers for the objects `list` names, in its order.
fn assign(&mut self, list: &[u32]) -> Vec<u32> {
list.iter()
.map(|n| {
let new = self.fresh();
self.of.insert(*n, new);
new
})
.collect()
}
}
/// `pdf[start..end]` with the references in `start..refs_end` renumbered as `number`
/// says (references to objects that aren't there become `null`).
fn renumber(pdf: &[u8], start: usize, end: usize, refs_end: usize, number: &HashMap<u32, u32>) -> Result<Vec<u8>, PdfError> {
let mut out = Vec::with_capacity(end.saturating_sub(start) + 16);
let mut at = start;
for (s, e, n) in ref_spans(pdf, start, refs_end) {
out.extend_from_slice(pdf.get(at..s).ok_or_else(|| failed("bad reference"))?);
match number.get(&n) {
Some(new) => out.extend_from_slice(format!("{new} 0 R").as_bytes()),
None => out.extend_from_slice(b"null"),
}
at = e;
}
out.extend_from_slice(pdf.get(at..end).ok_or_else(|| failed("bad object"))?);
Ok(out)
}
/// Bits written most significant first; [`Self::align`] pads to a byte.
#[derive(Default)]
struct Bits {
out: Vec<u8>,
byte: u8,
used: u32,
}
impl Bits {
fn put(&mut self, value: u64, bits: u32) {
for i in (0..bits).rev() {
self.byte = self.byte << 1 | u8::from(value >> i & 1 == 1);
self.used += 1;
if self.used == 8 {
self.out.push(self.byte);
(self.byte, self.used) = (0, 0);
}
}
}
fn align(&mut self) {
if self.used > 0 {
self.put(0, 8 - self.used);
}
}
/// One item of every entry, then the padding to a byte (each item of a hint table starts on
/// one).
fn column(&mut self, values: impl IntoIterator<Item = u64>, bits: u32) {
for v in values {
self.put(v, bits);
}
self.align();
}
}
/// Bits needed to write `v`.
fn bits(v: u64) -> u32 {
64 - v.leading_zeros()
}
/// A 32-bit hint table value.
fn u32_of(v: usize) -> Result<u64, PdfError> {
u32::try_from(v).map(u64::from).map_err(|_| failed("the file is too large"))
}
/// The page offset and shared object hint tables → (the hint stream's data, where the shared
/// object table starts), from where each object starts and how long it is, the hint stream left
/// out.
fn hint_tables(plan: &Plan, at: &HashMap<u32, (usize, usize)>) -> Result<(Vec<u8>, usize), PdfError> {
let len = |n: &u32| at.get(n).map_or(0, |a| a.1);
let pages: Vec<&[u32]> = std::iter::once(plan.first_page.as_slice()).chain(plan.pages.iter().map(Vec::as_slice)).collect();
let counts: Vec<u64> = pages.iter().map(|p| p.len() as u64).collect();
let lengths = pages.iter().map(|p| u32_of(p.iter().map(len).sum())).collect::<Result<Vec<u64>, _>>()?;
let nshared: Vec<u64> = std::iter::once(0).chain(plan.shared_ids.iter().map(|s| s.len() as u64)).collect();
let span = |v: &[u64]| {
let (min, max) = (v.iter().copied().min().unwrap_or(0), v.iter().copied().max().unwrap_or(0));
(min, bits(max - min))
};
let ((min_count, count_bits), (min_len, len_bits)) = (span(&counts), span(&lengths));
let entries = plan.first_page.len() + plan.shared.len();
let id_bits = bits(entries.saturating_sub(1) as u64);
let first_at = plan.first_page.first().and_then(|n| at.get(n)).map_or(0, |a| a.0);
let mut b = Bits::default();
// The page offset hint table: its header, then each item for every page.
b.put(min_count, 32);
b.put(u32_of(first_at)?, 32);
b.put(u64::from(count_bits), 16);
b.put(min_len, 32);
b.put(u64::from(len_bits), 16);
// Content streams' offsets and lengths: the page's.
b.put(0, 32);
b.put(0, 16);
b.put(min_len, 32);
b.put(u64::from(len_bits), 16);
b.put(u64::from(bits(nshared.iter().copied().max().unwrap_or(0))), 16);
b.put(u64::from(id_bits), 16);
// No fractional positions of shared objects.
b.put(0, 16);
b.put(1, 16);
b.column(counts.iter().map(|c| c - min_count), count_bits);
b.column(lengths.iter().map(|l| l - min_len), len_bits);
b.column(nshared.iter().copied(), bits(nshared.iter().copied().max().unwrap_or(0)));
b.column(plan.shared_ids.iter().flatten().map(|i| u64::from(*i)), id_bits);
b.column(lengths.iter().map(|l| l - min_len), len_bits);
// The shared object hint table: one object a group, the first page's objects first.
let shared_at = b.out.len();
let groups = plan.first_page.iter().chain(&plan.shared).map(|n| u32_of(len(n))).collect::<Result<Vec<u64>, _>>()?;
let (min_group, group_bits) = span(&groups);
let first_shared = plan.shared.first().and_then(|n| Some((*n, at.get(n)?.0))).unwrap_or((0, 0));
b.put(u64::from(first_shared.0), 32);
b.put(u32_of(first_shared.1)?, 32);
b.put(plan.first_page.len() as u64, 32);
b.put(entries as u64, 32);
b.put(0, 16);
b.put(min_group, 32);
b.put(u64::from(group_bits), 16);
b.column(groups.iter().map(|g| g - min_group), group_bits);
// No MD5 signatures.
b.column(groups.iter().map(|_| 0), 1);
Ok((b.out, shared_at))
}
/// A number padded with spaces to [`WIDTH`].
fn padded(v: usize) -> String {
format!("{v:<WIDTH$}")
}
/// Lay out a [`prepare`]d file (encrypted after it, or not) as `plan` says, the hint stream
/// encrypted with `cipher` when the file is.
pub(crate) fn write(pdf: &[u8], plan: &Plan, cipher: Option<&mut Cipher>) -> Result<Vec<u8>, PdfError> {
let xref = Xref::read(pdf).ok_or_else(|| failed("no cross-reference table"))?;
let mut entries = xref.objects(pdf);
entries.sort_by_key(|e| e.1);
// Each object's bytes run to where the next one starts.
let ends = entries.iter().skip(1).map(|e| e.1).chain(std::iter::once(xref.at()));
let bytes: HashMap<u32, &[u8]> = entries.iter().zip(ends).filter_map(|(&(n, off), end)| Some((n, pdf.get(off..end)?))).collect();
let head = entries.first().and_then(|e| pdf.get(..e.1)).ok_or_else(|| failed("no objects"))?;
let trailer = Lexer::at(pdf, xref.trailer_at()).object(0).ok_or_else(|| failed("bad trailer"))?;
let (Obj::Dict { start, end, .. }, Some(Obj::Int { value: size, .. })) = (&trailer, trailer.get(b"Size")) else {
return Err(failed("bad trailer"));
};
let size = u32::try_from(*size).map_err(|_| failed("bad trailer"))?;
let encrypt = match trailer.get(b"Encrypt") {
Some(Obj::Ref(n, _)) => Some(*n),
_ => None,
};
// The first part: the linearization dictionary, the catalog part with the encryption
// dictionary, the hint stream and the first page, which the first page's table covers.
let open: Vec<u32> = plan.open.iter().copied().chain(encrypt).collect();
let mut first_part: Vec<u32> = [plan.first, plan.hint].into_iter().chain(open.iter().copied()).chain(plan.first_page.iter().copied()).collect();
first_part.sort_unstable();
let rest: Vec<u32> = plan.pages.iter().flatten().chain(&plan.shared).chain(&plan.other).copied().collect();
let complete = (1..size).all(|n| bytes.contains_key(&n));
if !complete || !first_part.iter().copied().eq(plan.first..size) || rest.len() + 1 != plan.first as usize {
return Err(failed("the objects don't match the plan"));
}
let len = |n: &u32| bytes.get(n).map_or(0, |b| b.len());
// The layout without the hint stream (where its tables place the objects).
let lin_len = linearization(plan, [0; 5]).len();
let dict = pdf.get(*start..end.saturating_sub(2)).ok_or_else(|| failed("bad trailer"))?;
let first_table = |prev: usize, at: &dyn Fn(u32) -> usize| {
let mut t = format!("xref\n{} {}\n", plan.first, size - plan.first).into_bytes();
for n in plan.first..size {
t.extend_from_slice(format!("{:010} 00000 n\r\n", at(n)).as_bytes());
}
t.extend_from_slice(b"trailer\n");
t.extend_from_slice(dict);
t.extend_from_slice(format!("/Prev {}>>\nstartxref\n0\n%%EOF\n", padded(prev)).as_bytes());
t
};
let table_len = first_table(0, &|_| 0).len();
let mut at: HashMap<u32, (usize, usize)> = HashMap::with_capacity(bytes.len() + 2);
let mut pos = head.len() + lin_len + table_len;
let mut place = |list: &mut dyn Iterator<Item = &u32>, pos: &mut usize| {
for n in list {
at.insert(*n, (*pos, len(n)));
*pos += len(n);
}
};
place(&mut open.iter(), &mut pos);
let hint_at = pos;
place(&mut plan.first_page.iter(), &mut pos);
let first_end = pos;
place(&mut rest.iter(), &mut pos);
let main_at = pos;
// The hint stream, and every offset after it moved by its length.
let (tables, shared_at) = hint_tables(plan, &at)?;
let data = match cipher {
Some(c) => c.stream(plan.hint, &tables)?,
None => tables,
};
let mut hint = format!("{} 0 obj\n<</Length {}/S {shared_at}>>\nstream\n", plan.hint, data.len()).into_bytes();
hint.extend_from_slice(&data);
hint.extend_from_slice(b"\nendstream\nendobj\n");
let shift = |off: usize| if off >= hint_at { off + hint.len() } else { off };
let offset = |n: u32| match n {
n if n == plan.first => head.len(),
n if n == plan.hint => hint_at,
n => at.get(&n).map_or(0, |a| shift(a.0)),
};
let main = {
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", plan.first).into_bytes();
for n in 1..plan.first {
t.extend_from_slice(format!("{:010} 00000 n\r\n", offset(n)).as_bytes());
}
t
};
let main_at = shift(main_at);
let entries_at = main_at + format!("xref\n0 {}\n", plan.first).len();
let tail = format!("trailer\n<</Size {}>>\nstartxref\n{}\n%%EOF\n", plan.first, head.len() + lin_len);
let total = main_at + main.len() + tail.len();
if total >= 10usize.pow(WIDTH as u32) {
return Err(failed("the file is too large"));
}
let lin = linearization(plan, [total, hint_at, hint.len(), shift(first_end), entries_at - 1]);
let table = first_table(main_at, &offset);
if lin.len() != lin_len || table.len() != table_len {
return Err(failed("the layout moved"));
}
let mut out = Vec::with_capacity(total);
out.extend_from_slice(head);
out.extend_from_slice(&lin);
out.extend_from_slice(&table);
let copy = |list: &mut dyn Iterator<Item = &u32>, out: &mut Vec<u8>| {
for n in list {
out.extend_from_slice(bytes.get(n).copied().unwrap_or_default());
}
};
copy(&mut open.iter(), &mut out);
out.extend_from_slice(&hint);
copy(&mut plan.first_page.iter(), &mut out);
copy(&mut rest.iter(), &mut out);
out.extend_from_slice(&main);
out.extend_from_slice(tail.as_bytes());
if out.len() != total {
return Err(failed("the layout moved"));
}
Ok(out)
}
/// The linearization dictionary object, its numbers ([file length, hint stream offset and length,
/// end of the first page, offset of the white space before the main table's first entry]) padded
/// to a fixed width.
fn linearization(plan: &Plan, [l, h, h_len, e, t]: [usize; 5]) -> Vec<u8> {
let o = plan.first_page.first().copied().unwrap_or_default();
let n = 1 + plan.pages.len();
format!(
"{} 0 obj\n<</Linearized 1/L {}/H[{} {}]/O {o}/E {}/N {n}/T {}>>\nendobj\n",
plan.first,
padded(l),
padded(h),
padded(h_len),
padded(e),
padded(t)
)
.into_bytes()
}
+70 -35
View File
@@ -12,11 +12,14 @@
//! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK
//! with the CMYK profile in effect (the destination when converting to CMYK, else the
//! document's), RGB with sRGB (colours of another RGB space as their sRGB equivalents) and grey
//! with the sRGB tone curve. Otherwise they are device colours. PDF/A always tags them.
//! with the sRGB tone curve. Otherwise they are device colours. PDF/A, PDF/X-3 and PDF/X-4
//! always tag them; PDF/X-1a never does, and always converts to a CMYK destination (see
//! [`crate::pdfx`]).
//! - Output intent: the profile `outputIntent` names, embedded as the `/GTS_PDFX` output intent
//! of the catalog with the condition, identifier and registry; Trapped goes in the document
//! information (`/False` when an output intent is written untrapped). PDF/A files carry their
//! own output intent: neither is written there.
//! own output intent: neither is written there. PDF/X files always have one: blank, the CMYK
//! profile in effect.
use std::sync::{Arc, OnceLock};
@@ -58,6 +61,8 @@ pub(crate) struct ColorOut {
intent: Intent,
/// The output intent and Trapped entries to write.
catalog: Option<Catalog>,
/// The standard the file conforms to.
pub standard: Standard,
}
/// An output intent dictionary's entries.
@@ -85,20 +90,34 @@ impl Default for ColorOut {
let source = cms::active();
let cmyk_profile = source.settings().cmyk.clone();
let intent = source.settings().intent;
Self { source, dest: None, tagged: false, cmyk_profile, srgb_note: None, pixels: OnceLock::new(), intent, catalog: None }
Self {
source,
dest: None,
tagged: false,
cmyk_profile,
srgb_note: None,
pixels: OnceLock::new(),
intent,
catalog: None,
standard: Standard::None,
}
}
}
/// The profile of `set`'s destination and its colour model; `None` without conversion. A blank
/// destination is the profile of a document in colour mode `mode`.
/// destination is the profile of a document in colour mode `mode`. PDF/X-1a files always convert,
/// to a CMYK destination: blank, their output intent's profile ([`crate::pdfx::cmyk_destination`]).
fn destination(set: &PdfSettings, mode: ColorMode, source: &Cms) -> Result<Option<(String, Model)>, PdfError> {
let o = &set.output;
if o.conversion == ColorConversion::None {
let cmyk_only = set.standard.cmyk_only();
if o.conversion == ColorConversion::None && !cmyk_only {
return Ok(None);
}
let name = o.destination.trim();
let fallback = if cmyk_only { crate::pdfx::cmyk_destination(set, source) } else { String::new() };
let name = match (name.is_empty(), mode) {
(false, _) => name,
(true, _) if cmyk_only => fallback.as_str(),
(true, ColorMode::Cmyk) => source.settings().cmyk.as_str(),
(true, ColorMode::Rgb) => source.settings().rgb.as_str(),
};
@@ -109,6 +128,9 @@ fn destination(set: &PdfSettings, mode: ColorMode, source: &Cms) -> Result<Optio
ProfileKind::Cmyk => Model::Cmyk,
ProfileKind::Gray => return Err(unknown()),
};
if cmyk_only && model != Model::Cmyk {
return Err(PdfError::BadSetting(format!("output.destination: {} files are CMYK, and “{name}” is an RGB profile", set.standard.label())));
}
Ok(Some((info.name, model)))
}
@@ -136,7 +158,8 @@ pub(crate) fn warnings(set: &PdfSettings) -> Vec<String> {
if !name.is_empty() && cms::profile(name).is_none() {
out.push(format!("no profile is called “{name}”: the output intent names it without embedding it"));
}
if !asks && (!o.output_condition.trim().is_empty() || !o.registry.trim().is_empty()) {
// PDF/X files always have an output intent.
if !asks && !set.standard.is_pdfx() && (!o.output_condition.trim().is_empty() || !o.registry.trim().is_empty()) {
out.push("an output intent needs a profile or a condition identifier: the output condition and registry are left out".into());
}
out
@@ -155,18 +178,20 @@ impl ColorOut {
_ => std::mem::replace(&mut settings.rgb, name.clone()),
};
let cms = Cms::new(&settings).map_err(|e| PdfError::BadSetting(format!("output.destination: {e}")))?;
let keep = set.output.conversion == ColorConversion::PreserveNumbers || cms::canonical_name(&own) == name;
// PDF/X-1a converts without being asked: as Preserve Numbers does.
let keep = set.output.conversion != ColorConversion::Destination || cms::canonical_name(&own) == name;
if model == Model::Cmyk {
out.cmyk_profile = name.clone();
}
out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep });
}
out.tagged = set.standard == Standard::PdfA2b
|| match set.output.profiles {
ProfileInclusion::None => false,
ProfileInclusion::All | ProfileInclusion::Destination => true,
ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(),
};
out.tagged = matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4)
|| (!set.standard.cmyk_only()
&& match set.output.profiles {
ProfileInclusion::None => false,
ProfileInclusion::All | ProfileInclusion::Destination => true,
ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(),
});
let rgb = match &out.dest {
Some(d) if d.model == Model::Rgb => (d.srgb, d.cms.settings().rgb.clone()),
_ => (source.rgb_is_srgb(), st.rgb.clone()),
@@ -174,7 +199,8 @@ impl ColorOut {
if out.tagged && !rgb.0 {
out.srgb_note = Some(format!("RGB colours are tagged with the sRGB profile: those of {} are written as their sRGB equivalents", rgb.1));
}
out.catalog = catalog(set);
out.catalog = catalog(set, &out.cmyk_profile);
out.standard = set.standard;
Ok(out)
}
@@ -250,16 +276,17 @@ impl ColorOut {
.as_ref()
}
/// `pdf` with the output intent and Trapped entries → (the file, warnings).
/// `pdf` with the output intent and Trapped entries, and a PDF/X file's identification
/// ([`crate::pdfx`]) → (the file, warnings).
pub(crate) fn write_catalog(&self, pdf: Vec<u8>) -> Result<(Vec<u8>, Vec<String>), PdfError> {
let Some(cat) = &self.catalog else { return Ok((pdf, vec![])) };
let xref = Xref::read(&pdf).ok_or_else(|| PdfError::Write("the written PDF has no cross-reference table".into()))?;
let mut patch = Patch::new(&xref);
let mut warnings = vec![];
let root = xref.trailer_ref(&pdf, b"/Root").and_then(|n| xref.dict(&pdf, n));
let Some(root) = root else { return Err(PdfError::Write("the written PDF has no catalog".into())) };
if let Some(OutputIntent { id, condition, registry, info, profile }) = &cat.intent {
let root = xref.trailer_ref(&pdf, b"/Root").and_then(|n| xref.dict(&pdf, n));
let Some((root, root_end)) = root else { return Err(PdfError::Write("the written PDF has no catalog".into())) };
if crate::lab_spot::find(pdf.get(root..root_end).unwrap_or_default(), b"/OutputIntents", 0).is_some() {
if crate::lab_spot::find(pdf.get(root.0..root.1).unwrap_or_default(), b"/OutputIntents", 0).is_some() {
warnings.push("the file has an output intent already: the one asked for is left out".into());
} else {
let mut dict = format!("<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier{}", text_string(id));
@@ -278,22 +305,26 @@ impl ColorOut {
}
dict.push_str(">>");
let oi = patch.add_object(dict.into_bytes());
patch.replace(root, root, format!("/OutputIntents[{oi} 0 R]").into_bytes());
patch.replace(root.0, root.0, format!("/OutputIntents[{oi} 0 R]").into_bytes());
}
}
if let Some(trapped) = cat.trapped {
let entry = if trapped { "/Trapped/True" } else { "/Trapped/False" };
match xref.trailer_ref(&pdf, b"/Info").and_then(|n| xref.dict(&pdf, n)) {
Some((info, end)) if crate::lab_spot::find(pdf.get(info..end).unwrap_or_default(), b"/Trapped", 0).is_none() => {
patch.replace(info, info, entry.as_bytes().to_vec());
}
Some(_) => {}
None => {
let n = patch.add_object(format!("<<{entry}>>").into_bytes());
patch.trailer_entry(format!("/Info {n} 0 R").as_bytes());
}
let info = xref.trailer_ref(&pdf, b"/Info").and_then(|n| xref.dict(&pdf, n));
let mut entries = String::new();
if let Some(trapped) = cat.trapped
&& info.is_none_or(|(s, e)| crate::lab_spot::find(pdf.get(s..e).unwrap_or_default(), b"/Trapped", 0).is_none())
{
entries.push_str(if trapped { "/Trapped/True" } else { "/Trapped/False" });
}
entries.push_str(&crate::pdfx::info_entries(self.standard));
match info {
_ if entries.is_empty() => {}
Some((at, _)) => patch.replace(at, at, entries.into_bytes()),
None => {
let n = patch.add_object(format!("<<{entries}>>").into_bytes());
patch.trailer_entry(format!("/Info {n} 0 R").as_bytes());
}
}
crate::pdfx::xmp(&pdf, &xref, root, &mut patch, self.standard, cat.trapped == Some(true))?;
Ok((patch.apply(&pdf, &xref)?, warnings))
}
}
@@ -323,13 +354,17 @@ impl Dest {
}
}
/// The catalog entries `set` asks for (none in PDF/A files, see [`warnings`]).
fn catalog(set: &PdfSettings) -> Option<Catalog> {
/// The catalog entries `set` asks for (none in PDF/A files, see [`warnings`]); a PDF/X file's
/// output intent profile is `cmyk_profile` (the CMYK profile in effect) unless `set` names one.
fn catalog(set: &PdfSettings, cmyk_profile: &str) -> Option<Catalog> {
let o = &set.output;
if set.standard == Standard::PdfA2b {
return None;
}
let (name, id) = (o.output_intent.trim(), o.output_condition_id.trim());
let (mut name, id) = (o.output_intent.trim(), o.output_condition_id.trim());
if name.is_empty() && set.standard.is_pdfx() {
name = cmyk_profile;
}
let intent = (!name.is_empty() || !id.is_empty()).then(|| {
let profile = cms::profile(name).and_then(|p| match p.kind {
ProfileKind::Rgb => Some((p.name, 3)),
@@ -351,7 +386,7 @@ fn catalog(set: &PdfSettings) -> Option<Catalog> {
/// `s` as a PDF text string: a literal string when it is printable ASCII, else UTF-16BE (with its
/// byte order mark) in hex.
fn text_string(s: &str) -> String {
pub(crate) fn text_string(s: &str) -> String {
if s.bytes().all(|b| (b' '..=b'~').contains(&b)) {
let mut out = String::with_capacity(s.len() + 2);
out.push('(');
@@ -373,7 +408,7 @@ fn text_string(s: &str) -> String {
}
}
fn deflate(data: &[u8]) -> std::io::Result<Vec<u8>> {
pub(crate) fn deflate(data: &[u8]) -> std::io::Result<Vec<u8>> {
use std::io::Write;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data)?;
+22 -2
View File
@@ -21,7 +21,7 @@ pub(crate) struct Xref {
const MAX_DIGITS: usize = 10;
/// The unsigned number `pdf` holds at `at` (after white space), and where it ends.
fn number(pdf: &[u8], mut at: usize) -> Option<(usize, usize)> {
pub(crate) fn number(pdf: &[u8], mut at: usize) -> Option<(usize, usize)> {
while pdf.get(at).is_some_and(u8::is_ascii_whitespace) {
at += 1;
}
@@ -48,14 +48,34 @@ impl Xref {
Some(Self { at, entries, count, trailer: (start, end) })
}
/// Number of entries (objects `0..count`).
pub fn count(&self) -> usize {
self.count
}
/// Where in-use object `n` starts.
fn offset(&self, pdf: &[u8], n: u32) -> Option<usize> {
pub fn offset(&self, pdf: &[u8], n: u32) -> Option<usize> {
let e = self.entries + 20 * usize::try_from(n).ok().filter(|n| *n < self.count)?;
let entry = pdf.get(e..e + 20)?;
(entry.get(17) == Some(&b'n')).then_some(())?;
number(entry, 0).map(|(off, _)| off)
}
/// The in-use objects: number and offset, in number order.
pub fn objects(&self, pdf: &[u8]) -> Vec<(u32, usize)> {
(1..u32::try_from(self.count).unwrap_or(u32::MAX)).filter_map(|n| Some((n, self.offset(pdf, n)?))).collect()
}
/// Where the table (`xref`) starts.
pub fn at(&self) -> usize {
self.at
}
/// Where the trailer dictionary starts (its `<<`).
pub fn trailer_at(&self) -> usize {
self.trailer.0
}
/// The object the trailer's `key` (`b"/Root"`) refers to.
pub fn trailer_ref(&self, pdf: &[u8], key: &[u8]) -> Option<u32> {
let dict = pdf.get(self.trailer.0..self.trailer.1)?;
+272
View File
@@ -0,0 +1,272 @@
//! PDF/X files (print exchange): what each standard asks of a file, and the entries that say which
//! one it is.
//!
//! - PDF/X-1a:2001: every colour is CMYK, grey or a spot ink with a CMYK alternate (colours and
//! images are converted to a CMYK destination and written untagged, see [`crate::output`]), no
//! transparency (the app flattens it before writing), no layers, PDF 1.3.
//! - PDF/X-3:2002: colours are ICC-based (always tagged), no transparency, no layers, PDF 1.3.
//! - PDF/X-4:2010: transparency and layers are kept, colours are tagged, PDF 1.6 at most, and the
//! XMP metadata names the standard too.
//! - All of them: a `/GTS_PDFX` output intent (blank, the CMYK profile in effect, embedded), a
//! TrimBox on every page (the artboard), `/Trapped` True or False, `/GTS_PDFXVersion` in the
//! document information, a title and dates, embedded fonts (or text as outlines), no editing
//! data and no encryption (both refused by the settings checks).
//!
//! PDF/X-1a and PDF/X-3 files are PDF 1.3 files ([`crate::PdfSettings::pdf13`]): flat, written with
//! the PDF 1.4 settings, their header and metadata saying 1.3. The written file is checked ([`verify`]): what the
//! standard forbids that is still there refuses the export, rather than a file claiming a
//! standard it breaks.
use vectorcraft_color::cms::{self, Cms, ProfileKind};
use crate::lab_spot::find;
use crate::patch::{Patch, Xref};
use crate::{PdfError, PdfSettings, Standard};
impl Standard {
/// A PDF/X standard.
pub fn is_pdfx(self) -> bool {
matches!(self, Self::PdfX1a | Self::PdfX3 | Self::PdfX4)
}
/// Files of this standard have no transparency (PDF/X-1a and PDF/X-3): it is flattened.
pub fn flattens(self) -> bool {
matches!(self, Self::PdfX1a | Self::PdfX3)
}
/// Every colour is CMYK, grey or a spot ink (PDF/X-1a).
pub fn cmyk_only(self) -> bool {
self == Self::PdfX1a
}
/// Files of this standard may have PDF layers (PDF/X-1a and PDF/X-3 are PDF 1.3, which has
/// none).
pub fn allows_layers(self) -> bool {
!self.flattens()
}
/// `/GTS_PDFXVersion` and `/GTS_PDFXConformance` (when the standard has one).
fn pdfx_ids(self) -> Option<(&'static str, Option<&'static str>)> {
match self {
Self::PdfX1a => Some(("PDF/X-1:2001", Some("PDF/X-1a:2001"))),
Self::PdfX3 => Some(("PDF/X-3:2002", None)),
Self::PdfX4 => Some(("PDF/X-4", None)),
Self::None | Self::PdfA2b => None,
}
}
}
/// Refuse a PDF/X output intent the file can't have: a grey profile, an RGB one in PDF/X-1a, or a
/// name that isn't a profile, unless PDF/X-1a and PDF/X-3 name a registered printing condition
/// (with a registry) without embedding a profile; PDF/X-4 always embeds one.
pub(crate) fn check(set: &PdfSettings) -> Result<(), PdfError> {
let o = &set.output;
let name = o.output_intent.trim();
if !set.standard.is_pdfx() || name.is_empty() {
return Ok(());
}
let std = set.standard.label();
match cms::profile(name).map(|p| p.kind) {
Some(ProfileKind::Cmyk) => Ok(()),
Some(ProfileKind::Rgb) if !set.standard.cmyk_only() => Ok(()),
Some(ProfileKind::Rgb) => {
Err(PdfError::BadSetting(format!("output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile")))
}
Some(ProfileKind::Gray) => {
Err(PdfError::BadSetting(format!("output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent")))
}
None if !o.registry.trim().is_empty() && set.standard != Standard::PdfX4 => Ok(()),
None if set.standard == Standard::PdfX4 => {
Err(PdfError::BadSetting(format!("output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile")))
}
None => Err(PdfError::BadSetting(format!(
"output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry)"
))),
}
}
/// The CMYK profile PDF/X-1a colours are converted to when no destination is named: the output
/// intent's profile when it is a CMYK one, else the colour settings' CMYK profile.
pub(crate) fn cmyk_destination(set: &PdfSettings, source: &Cms) -> String {
match cms::profile(set.output.output_intent.trim()) {
Some(p) if p.kind == ProfileKind::Cmyk => p.name,
_ => source.settings().cmyk.clone(),
}
}
/// The title a file of `standard` gets: PDF/X files need one.
pub(crate) fn title(standard: Standard, title: String) -> String {
if standard.is_pdfx() && title.trim().is_empty() { "Untitled".into() } else { title }
}
/// Refuse a PDF/X file without a creation date (its dates are required).
pub(crate) fn require_date(standard: Standard, created: Option<i64>) -> Result<(), PdfError> {
match created {
None if standard.is_pdfx() => {
Err(PdfError::Unsupported(format!("{} files need a creation date, and this platform has no clock to give one", standard.label())))
}
_ => Ok(()),
}
}
/// The document information entries naming `standard` (empty for other files).
pub(crate) fn info_entries(standard: Standard) -> String {
let Some((version, conformance)) = standard.pdfx_ids() else { return String::new() };
let mut out = format!("/GTS_PDFXVersion({version})");
if let Some(c) = conformance {
out.push_str(&format!("/GTS_PDFXConformance({c})"));
}
out
}
/// Edit the XMP metadata of a PDF/X file (the catalog's `/Metadata` stream; `root` is the
/// catalog dictionary's contents) into `patch`: PDF/X-4 adds the standard, Trapped and a version
/// id (PDF 1.3 files say so in [`finish`]). Files without metadata are left as they are.
pub(crate) fn xmp(pdf: &[u8], xref: &Xref, root: (usize, usize), patch: &mut Patch, standard: Standard, trapped: bool) -> Result<(), PdfError> {
if !standard.is_pdfx() {
return Ok(());
}
let bad = || PdfError::Write("the written PDF's metadata can't be edited".into());
let Some(at) = pdf.get(root.0..root.1).and_then(|d| find(d, b"/Metadata", 0)) else { return Ok(()) };
let n = crate::patch::number(pdf, root.0 + at + b"/Metadata".len()).and_then(|(n, _)| u32::try_from(n).ok()).ok_or_else(bad)?;
let (start, end) = xref.dict(pdf, n).ok_or_else(bad)?;
let obj = pdf.get(start..end).ok_or_else(bad)?;
let length_at = find(obj, b"/Length", 0).ok_or_else(bad)? + b"/Length".len();
let (length, length_end) = crate::patch::number(obj, length_at).ok_or_else(bad)?;
let data = find(obj, b"stream", length_end).map(|i| i + b"stream".len()).ok_or_else(bad)?;
let data = data + obj.get(data..).map_or(0, |r| r.iter().take(2).take_while(|b| matches!(b, b'\r' | b'\n')).count());
let xml = data.checked_add(length).and_then(|e| obj.get(data..e)).ok_or_else(bad)?;
let mut edits: Vec<(usize, usize, Vec<u8>)> = vec![];
if let Some((version, _)) = standard.pdfx_ids().filter(|_| standard == Standard::PdfX4) {
// Into the writer's description, whose namespaces declare the `pdf` and `xmpMM` prefixes.
let close = crate::lab_spot::rfind(xml, b"</rdf:Description>").ok_or_else(bad)?;
let trapped = if trapped { "True" } else { "False" };
let props = format!(
"<pdfxid:GTS_PDFXVersion xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\">{version}</pdfxid:GTS_PDFXVersion><pdf:Trapped>{trapped}</pdf:Trapped><xmpMM:VersionID>1</xmpMM:VersionID>"
);
edits.push((close, close, props.into_bytes()));
}
// Every edit grows the stream (or keeps its length).
let grown: usize = edits.iter().map(|(s, e, new)| new.len().saturating_sub(e - s)).sum();
if grown > 0 {
patch.replace(start + length_at, start + length_end, format!(" {}", length + grown).into_bytes());
}
for (s, e, new) in edits {
patch.replace(start + data + s, start + data + e, new);
}
Ok(())
}
/// The finished file of `standard`: a PDF 1.3 file (`pdf13`: PDF/X-1a, PDF/X-3, or asked for) says
/// so ([`crate::post::declare_pdf13`]) and must be flat, then the file is checked against its
/// standard ([`verify`]).
pub(crate) fn finish(mut pdf: Vec<u8>, standard: Standard, pdf13: bool) -> Result<Vec<u8>, PdfError> {
if pdf13 {
crate::post::declare_pdf13(&mut pdf);
if !standard.flattens() {
flat(&without_streams(&pdf), "PDF 1.3")?;
}
}
verify(&pdf, standard)?;
Ok(pdf)
}
/// Refuse a file (`objects`, without its stream data) with transparency: `kind` files have none.
fn flat(objects: &[u8], kind: &str) -> Result<(), PdfError> {
match transparency(objects) {
Some(what) => Err(PdfError::Unsupported(format!(
"{kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency)"
))),
None => Ok(()),
}
}
/// Refuse a written file that breaks `standard`: transparency in PDF/X-1a and PDF/X-3 (soft
/// masks, transparency groups, constant opacity, blend modes), colour spaces other than CMYK,
/// grey and spot inks in PDF/X-1a, and fonts that aren't embedded.
pub(crate) fn verify(pdf: &[u8], standard: Standard) -> Result<(), PdfError> {
if !standard.is_pdfx() {
return Ok(());
}
let objects = without_streams(pdf);
let std = standard.label();
if standard.flattens() {
flat(&objects, std)?;
}
if standard.cmyk_only()
&& let Some(space) =
["/DeviceRGB", "/CalRGB", "/CalGray", "/Lab", "/ICCBased"].into_iter().find(|n| !names(&objects, n.as_bytes()).is_empty())
{
return Err(PdfError::Unsupported(format!("{std} files are CMYK, and the file would have colours in {}", &space[1..])));
}
let mut from = 0;
while let Some(at) = find(&objects, b"/Type/FontDescriptor", from) {
let end = find(&objects, b"endobj", at).unwrap_or(objects.len());
let dict = objects.get(at..end).unwrap_or_default();
if find(dict, b"/FontFile", 0).is_none() {
return Err(PdfError::Unsupported(format!("{std} files embed their fonts, and the file would have one that isn't")));
}
from = end;
}
Ok(())
}
/// `pdf` without its streams' data (dictionaries and the rest of the syntax), so that what is
/// looked for there isn't found in image or font bytes.
fn without_streams(pdf: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(pdf.len().min(1 << 20));
let mut at = 0;
while let Some(s) = find(pdf, b"stream", at) {
let data = s + b"stream".len();
// `endstream` itself, or a stream keyword that isn't one (inside a name or string).
if (s >= 3 && pdf.get(s - 3..s) == Some(b"end")) || !pdf.get(data).is_some_and(|b| matches!(b, b'\r' | b'\n')) {
out.extend_from_slice(pdf.get(at..data).unwrap_or_default());
at = data;
continue;
}
out.extend_from_slice(pdf.get(at..data).unwrap_or_default());
at = find(pdf, b"endstream", data).unwrap_or(pdf.len());
}
out.extend_from_slice(pdf.get(at..).unwrap_or_default());
out
}
/// Where name `name` (`/SMask`) occurs in `objects` as a whole name: the index just after it.
fn names(objects: &[u8], name: &[u8]) -> Vec<usize> {
let mut out = vec![];
let mut from = 0;
while let Some(at) = find(objects, name, from) {
let end = at + name.len();
if !objects.get(end).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'#')) {
out.push(end);
}
from = end;
}
out
}
/// The token after `at` in `objects` (after white space): a name with its slash, or a number.
fn token(objects: &[u8], at: usize) -> &[u8] {
let rest = objects.get(at..).unwrap_or_default();
let rest = rest.get(rest.iter().take_while(|b| b.is_ascii_whitespace()).count()..).unwrap_or_default();
let len = rest.iter().enumerate().take_while(|(i, b)| *i == 0 || !(b.is_ascii_whitespace() || b"/[]<>()".contains(b))).count();
rest.get(..len).unwrap_or_default()
}
/// The first kind of transparency `objects` has, if any.
fn transparency(objects: &[u8]) -> Option<&'static str> {
if names(objects, b"/SMask").into_iter().any(|at| token(objects, at) != b"/None") {
return Some("soft masks");
}
if !names(objects, b"/Transparency").is_empty() {
return Some("transparency groups");
}
let below_one = |t: &[u8]| std::str::from_utf8(t).ok().and_then(|s| s.parse::<f64>().ok()).is_some_and(|v| v < 1.0);
if names(objects, b"/CA").into_iter().chain(names(objects, b"/ca")).any(|at| below_one(token(objects, at))) {
return Some("opacity");
}
if names(objects, b"/BM").into_iter().any(|at| !matches!(token(objects, at), b"/Normal" | b"/Compatible")) {
return Some("blend modes");
}
None
}
+114
View File
@@ -0,0 +1,114 @@
//! What the export does to the file once it is written, in this order: the pages' thumbnails
//! (General › Embed page thumbnails), the seal of the editing data, fast web view (a linearised
//! file, [`crate::linearize`]) and encryption ([`crate::encrypt`]), which a linearised file goes
//! through between its two steps so that it stays linearised. Also here: what makes a file say it
//! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files.
use std::borrow::Cow;
use crate::encrypt::Obj;
use crate::linearize::{self, Parsed};
use crate::patch::Patch;
use crate::{PdfError, PdfOptions, PdfSettings};
/// Longest side of a page thumbnail, in pixels.
pub const THUMBNAIL_SIZE: u32 = 106;
/// A page thumbnail: 8-bit RGB pixels, row by row, at most [`THUMBNAIL_SIZE`] pixels a side.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Thumbnail {
pub width: u32,
pub height: u32,
pub rgb: Vec<u8>,
}
impl Thumbnail {
/// The image XObject of a page's `/Thumb` entry; `gray`: in DeviceGray (for PDF/X files, whose
/// device colours must suit the printing condition, an RGB thumbnail wouldn't).
fn object(&self, gray: bool) -> Result<Vec<u8>, PdfError> {
let (w, h) = (self.width, self.height);
let sized = (1..=THUMBNAIL_SIZE).contains(&w) && (1..=THUMBNAIL_SIZE).contains(&h);
if !sized || self.rgb.len() as u64 != u64::from(w) * u64::from(h) * 3 {
return Err(PdfError::BadSetting(format!("a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h})")));
}
let (space, pixels) = if gray {
// Rec. 601 luma.
let luma = |p: &[u8; 3]| ((299 * u32::from(p[0]) + 587 * u32::from(p[1]) + 114 * u32::from(p[2]) + 500) / 1000) as u8;
("DeviceGray", Cow::Owned(self.rgb.as_chunks::<3>().0.iter().map(luma).collect()))
} else {
("DeviceRGB", Cow::Borrowed(self.rgb.as_slice()))
};
let data = crate::output::deflate(&pixels).map_err(|e| PdfError::Write(format!("page thumbnail: {e}")))?;
let mut out = format!("<</Width {w}/Height {h}/ColorSpace/{space}/BitsPerComponent 8/Filter/FlateDecode/Length {}>>\nstream\n", data.len())
.into_bytes();
out.extend_from_slice(&data);
out.extend_from_slice(b"\nendstream");
Ok(out)
}
}
/// `pdf` with `thumbs` as the thumbnails of its pages, in page order (in grey when `gray`).
fn thumbnails(pdf: Vec<u8>, thumbs: &[Thumbnail], gray: bool) -> Result<Vec<u8>, PdfError> {
let failed = |why: &str| PdfError::Write(format!("can't add page thumbnails: {why}"));
let Parsed { xref, pages, .. } = linearize::parse(&pdf).map_err(failed)?;
let mut patch = Patch::new(&xref);
for (page, thumb) in pages.iter().zip(thumbs) {
let (dict, _) = xref.dict(&pdf, *page).ok_or_else(|| failed("a page can't be read"))?;
let n = patch.add_object(thumb.object(gray)?);
patch.replace(dict, dict, format!("/Thumb {n} 0 R").into_bytes());
}
patch.apply(&pdf, &xref)
}
/// Linearise `pdf`, encrypting it on the way as `set` says.
fn linearised(pdf: &[u8], set: &PdfSettings) -> Result<Vec<u8>, PdfError> {
let (prepared, plan) = linearize::prepare(pdf)?;
let (encrypted, mut cipher) = crate::encrypt::protect_keeping(prepared, set)?;
linearize::write(&encrypted, &plan, cipher.as_mut())
}
/// The file the export wrote (`pdf`), finished as `opts` say; `editing`: it carries the editing
/// data, to seal once the pages are final. What can't be done comes back in `warnings`.
pub(crate) fn finish(pdf: Vec<u8>, opts: &PdfOptions, editing: bool, warnings: &mut Vec<String>) -> Result<Vec<u8>, PdfError> {
let set = &opts.settings;
let pdf = match (set.thumbnails, opts.thumbnails.is_empty()) {
(true, false) => thumbnails(pdf, &opts.thumbnails, set.standard.is_pdfx())?,
(true, true) => {
warnings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into());
pdf
}
(false, _) => pdf,
};
let pdf = if editing { crate::editing::seal(pdf)? } else { pdf };
if !set.fast_web_view {
return crate::encrypt::protect(pdf, set);
}
match linearised(&pdf, set) {
Ok(out) => Ok(out),
Err(e) => {
warnings.push(format!("the file isn't optimized for fast web view: {e}"));
crate::encrypt::protect(pdf, set)
}
}
}
/// Make a file written with the PDF 1.4 settings say it is PDF 1.3: its header and the version
/// its XMP metadata gives, rewritten in place.
pub(crate) fn declare_pdf13(pdf: &mut [u8]) {
if let Some(v) = pdf.get_mut(..8).filter(|h| h.starts_with(b"%PDF-1.4")) {
v.copy_from_slice(b"%PDF-1.3");
}
if let Some(digit) = metadata_version(pdf).and_then(|at| pdf.get_mut(at)) {
*digit = b'3';
}
}
/// Where the minor version digit is in the `1.4` of the XMP metadata stream of `pdf`'s catalog.
fn metadata_version(pdf: &[u8]) -> Option<usize> {
const VERSION: &[u8] = b"<pdf:PDFVersion>1.4<";
let p = linearize::parse(pdf).ok()?;
let Some(&Obj::Ref(n, _)) = p.objects.get(&p.root)?.value.get(b"Metadata") else { return None };
let o = p.objects.get(&n)?;
let at = crate::lab_spot::find(pdf.get(..o.endobj)?, VERSION, o.value_span.1)?;
Some(at + VERSION.len() - 2)
}
+5 -5
View File
@@ -79,17 +79,17 @@ pub fn builtin_presets() -> Vec<PdfPreset> {
),
preset(
"PDF/X-1a:2001",
"Print exchange with CMYK and spot colours only (PDF/X output is not supported yet).",
pdf_x(Standard::PdfX1a, Compatibility::Pdf14),
"Print exchange with CMYK and spot colours only: transparency flattened, fonts embedded.",
pdf_x(Standard::PdfX1a, Compatibility::Pdf13),
),
preset(
"PDF/X-3:2002",
"Print exchange allowing colour-managed colours (PDF/X output is not supported yet).",
pdf_x(Standard::PdfX3, Compatibility::Pdf14),
"Print exchange allowing colour-managed colours: transparency flattened, fonts embedded.",
pdf_x(Standard::PdfX3, Compatibility::Pdf13),
),
preset(
"PDF/X-4:2010",
"Print exchange keeping live transparency and layers (PDF/X output is not supported yet).",
"Print exchange keeping live transparency and layers, colour-managed colours and embedded fonts.",
pdf_x(Standard::PdfX4, Compatibility::Pdf16),
),
]
+38 -6
View File
@@ -102,7 +102,7 @@ fn art_bounds(doc: &Document) -> Option<Rect> {
}
/// The regions that print: `(artboard, rect)`.
fn regions(doc: &Document, set: &PrintSettings) -> Result<Vec<(Option<usize>, Rect)>, PdfError> {
pub(crate) fn regions(doc: &Document, set: &PrintSettings) -> Result<Vec<(Option<usize>, Rect)>, PdfError> {
let count = doc.artboards.len();
let picked: Vec<usize> = match set.artboards {
PrintArtboards::Ignore => {
@@ -128,6 +128,22 @@ fn scaled(sides: [f64; 4], (sx, sy): (f64, f64)) -> [f64; 4] {
[sides[0] * sy, sides[1] * sy, sides[2] * sx, sides[3] * sx]
}
/// The tiles along one axis of a printed area `extent` long, `tile` long each and `step` apart:
/// the first (tile `k` starts at `start + k × step`) and how many. Tiles showing no more of the area
/// than the `overlap` are left out. Without a `start`, the first tile starts at the area's edge.
fn span(start: Option<f64>, extent: f64, tile: f64, step: f64, overlap: f64) -> (f64, f64) {
// Tiles that end (or start) exactly on an edge show none of the area beyond it.
const EPS: f64 = 1e-6;
match start {
None => (0.0, ((extent - overlap) / step).ceil().max(1.0)),
Some(g) => {
let first = ((overlap - tile - g) / step + EPS).floor() + 1.0;
let last = ((extent - overlap - g) / step - EPS).ceil() - 1.0;
(first, (last - first + 1.0).max(1.0))
}
}
}
/// The page turns of a `w` × `h` paper (flipped, emulsion down, transverse) as one transform from
/// the paper onto the page, and the page's size.
fn turns(set: &PrintSettings, (w, h): (f64, f64), flipped: bool) -> (Affine, (f64, f64)) {
@@ -151,6 +167,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
let marks = set.marks.printer_marks();
let bleed = set.bleed.of(doc);
let (pw, ph) = set.paper();
let origin = set.tile_origin.placed.then_some((set.tile_origin.x, set.tile_origin.y));
let (mut pages, mut grids) = (vec![], vec![]);
for (artboard, r) in regions(doc, set)? {
let what = artboard.map_or_else(|| "The art".to_string(), |i| format!("Artboard {}", i + 1));
@@ -225,9 +242,17 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
page_bleed,
};
if !set.scaling.tiles() {
let (fx, fy) = set.placement.origin.factors();
let x = imageable.x0 + (imageable.width() - extent.0) * fx + set.placement.x;
let y = imageable.y0 + (imageable.height() - extent.1) * fy + set.placement.y;
// The printed area's top-left corner: where the tile origin puts it, else the placement.
let (x, y) = match origin {
Some((ox, oy)) => (imageable.x0 - ox * scale.0 - most[2], imageable.y0 - oy * scale.1 - most[0]),
None => {
let (fx, fy) = set.placement.origin.factors();
(
imageable.x0 + (imageable.width() - extent.0) * fx + set.placement.x,
imageable.y0 + (imageable.height() - extent.1) * fy + set.placement.y,
)
}
};
let trim = Rect::new(x + most[2], y + most[0], x + most[2] + size.0, y + most[0] + size.1);
if !imageable.inflate(0.01, 0.01).contains_rect(Rect::new(x, y, x + extent.0, y + extent.1)) {
warnings.push(format!("{what} with its bleed and marks is larger than the imageable area: part of it doesn't print"));
@@ -242,7 +267,13 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
return Err(PdfError::BadSetting(format!("overlap: {o} pt is half a tile or more")));
}
let step = (tile.0 - o, tile.1 - o);
let (cols, rows) = (((extent.0 - o) / step.0).ceil().max(1.0), ((extent.1 - o) / step.1).ceil().max(1.0));
// The tile origin's page starts the grid: its imageable area's corner is the origin's point
// (a full page's imageable area is inside the margin).
let inside = if set.scaling == PrintScaling::TileFull { m } else { 0.0 };
let start = origin.map(|(ox, oy)| (most[2] + ox * scale.0 - inside, most[0] + oy * scale.1 - inside));
let (first_x, cols) = span(start.map(|s| s.0), extent.0, tile.0, step.0, o);
let (first_y, rows) = span(start.map(|s| s.1), extent.1, tile.1, step.1, o);
let grid = (start.map_or(0.0, |s| s.0) + first_x * step.0, start.map_or(0.0, |s| s.1) + first_y * step.1);
if cols * rows > MAX_TILES as f64 {
return Err(PdfError::BadSetting(format!("{what} would print on more than {MAX_TILES} tiles: scale it down")));
}
@@ -259,7 +290,8 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
let at = if set.scaling == PrintScaling::TileFull { (0.0, 0.0) } else { (imageable.x0, imageable.y0) };
let window = |t: usize| {
let (c, rw) = ((t % cols) as f64, (t / cols) as f64);
Rect::new(c * step.0, rw * step.1, c * step.0 + tile.0, rw * step.1 + tile.1)
let (x, y) = (grid.0 + c * step.0, grid.1 + rw * step.1);
Rect::new(x, y, x + tile.0, y + tile.1)
};
for &t in &pick {
let w = window(t);
+83 -12
View File
@@ -4,39 +4,49 @@
//! - General: copies (collated or not), reverse order, the artboards (all, a range or ignored:
//! all the art as one page), blank artboards skipped, the paper and its orientation (or turned
//! to each artboard's), transverse, which layers print (template layers never do), the
//! placement on the imageable area, and the scale: none, fit, custom, or tiles of the paper or
//! of its imageable area, overlapping, a range of them.
//! placement on the imageable area (or where the Print Tiling tool put the pages,
//! [`TileOrigin`]), and the scale: none, fit, custom, or tiles of the paper or of its imageable
//! area, overlapping, a range of them.
//! - Marks and bleed as PDF export draws them ([`crate::MarkSettings`], [`crate::BleedSettings`]),
//! around the artboard on the paper, at the paper's scale.
//! - Output: composite, or separations, one page per ink that prints ([`print_inks`]) in the
//! grey of its coverage (overprints honoured, spot colours as process if asked), emulsion down
//! (mirrored), negative (inverted).
//! - Colour management: the rendering intent colours are separated with, and whether CMYK
//! colours keep their numbers.
//! - Colour management: the printer profile composite colours are converted to (and
//! separations separate with), the rendering intent, and whether CMYK colours keep their
//! numbers.
//! - Advanced: overprints in composite output preserved, discarded or simulated (as Overprint
//! Preview shows them). Print as Bitmap and the flattener preset need the renderer and the
//! flattener: the app applies them to the document before printing it.
//!
//! Halftone screens and flatness are left to the output device (warnings say so). [`preview`]
//! lists the pages, tiles and inks without writing the file; [`plan`] lays the job out for other
//! writers (PostScript), which can write screens and flatness themselves.
//! writers (PostScript), which can write screens and flatness themselves; [`tiling`] turns the
//! layout into the pages in document space (View → Show Print Tiling).
mod layout;
mod plates;
mod settings;
mod tiling;
use std::borrow::Cow;
use std::sync::Arc;
use kurbo::{Affine, Rect};
use serde::Serialize;
use vectorcraft_color::cms::{self, ProfileKind};
use vectorcraft_doc::marks::PrinterMarks;
use vectorcraft_doc::overprint::{clear_overprints, multiply_overprints};
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
pub use layout::{MAX_TILES, TileGrid};
pub use plates::print_inks;
pub use settings::*;
pub use tiling::{TilingPage, tiling};
use crate::export::{Exporter, Sheet, Writer};
use crate::marks::PageBoxes;
use crate::{CompressionSettings, PdfError, PdfSettings};
use crate::{AdvancedSettings, ColorConversion, CompressionSettings, OutputSettings, Overprint, PdfError, PdfSettings};
use layout::Layout;
use plates::Separator;
@@ -145,12 +155,61 @@ pub fn keep_layers(nodes: &mut Vec<Arc<Node>>, which: PrintLayers) {
});
}
/// `doc` as `set` prints it: live effects applied (raster effects are rendered by the app first),
/// only the layers that print, overprints in composite output as [`PrintAdvanced::overprints`]
/// says.
pub fn printed_document<'a>(doc: &'a Document, set: &PrintSettings) -> Cow<'a, Document> {
let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned);
let d = doc.to_mut();
keep_layers(&mut d.layers, set.print_layers);
composite_overprints(d, set);
doc
}
/// Overprinting fills and strokes in composite output: discarded (they knock out) or simulated
/// (drawn with Multiply, as Overprint Preview shows them), after which nothing overprints any
/// more; or preserved. Separations always honour them.
fn composite_overprints(doc: &mut Document, set: &PrintSettings) {
let how = set.advanced.overprints;
if set.output.mode == OutputMode::Separations || how == PrintOverprints::Preserve {
return;
}
let discard_white = doc.setup.discard_white_overprint;
let symbols = doc.symbols.iter_mut().map(|s| &mut s.art);
let nodes = doc.layers.iter_mut().chain(symbols).chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut()));
for n in nodes {
if how == PrintOverprints::Simulate {
multiply_overprints(n, discard_white);
}
clear_overprints(n);
}
}
/// How composite output writes colours: converted to the printer profile (CMYK colours keep
/// their numbers with Preserve CMYK Numbers), overprints kept unless discarded or simulated.
fn composite_pdf(set: &PrintSettings) -> (OutputSettings, AdvancedSettings) {
let profile = set.color.profile.trim();
let output = if profile.is_empty() {
OutputSettings::default()
} else {
let conversion = if set.color.preserve_numbers { ColorConversion::PreserveNumbers } else { ColorConversion::Destination };
OutputSettings { conversion, destination: profile.to_string(), ..Default::default() }
};
let overprint = if set.advanced.overprints == PrintOverprints::Preserve { Overprint::Preserve } else { Overprint::Discard };
(output, AdvancedSettings { overprint, ..Default::default() })
}
/// What of `doc` prints on pages of its own: each artboard printed (its rect), or all the art
/// (`None`) with artboards ignored.
pub fn print_regions(doc: &Document, set: &PrintSettings) -> Result<Vec<(Option<usize>, kurbo::Rect)>, PdfError> {
set.check()?;
layout::regions(&printed_document(doc, set), set)
}
impl<'a> Job<'a> {
fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> Result<Self, PdfError> {
set.check()?;
// Live effects print as their result (raster effects are rendered by the app first).
let mut doc = vectorcraft_effects::bake_document(doc).map_or(Cow::Borrowed(doc), Cow::Owned);
keep_layers(&mut doc.to_mut().layers, set.print_layers);
let doc = printed_document(doc, set);
let mut warnings = vec![];
let (layouts, tiles) = layout::layout(&doc, set, &mut warnings)?;
let separations = set.output.mode == OutputMode::Separations;
@@ -171,8 +230,11 @@ impl<'a> Job<'a> {
if !postscript && separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) {
warnings.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into());
}
if !separations && doc.layers.iter().any(|l| l.has_overprint()) {
warnings.push("overprinting fills and strokes print as knockouts in composite output (separations honour them)".into());
if separations && set.advanced.print_as_bitmap {
warnings.push("Print as Bitmap is for composite output: separations print the art as it is".into());
}
if separations && cms::profile(set.color.profile.trim()).is_some_and(|p| p.kind != ProfileKind::Cmyk) {
warnings.push("separations separate with a CMYK profile: the RGB printer profile is left out".into());
}
if !postscript && !set.graphics.auto_flatness {
warnings.push("a fixed flatness is not written yet: the output device flattens curves".into());
@@ -330,8 +392,11 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
let plan = plan(doc, opts)?;
let doc = &*plan.doc;
let pdf = PdfSettings { compression: CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() }, ..Default::default() };
let separations = opts.settings.output.mode == OutputMode::Separations;
// Plates are greys already: only composite colours go to the printer profile.
let (output, advanced) = if separations { Default::default() } else { composite_pdf(&opts.settings) };
let compression = CompressionSettings { compress_text: !opts.uncompressed, ..Default::default() };
let pdf = PdfSettings { compression, output, advanced, ..Default::default() };
let mut w = Writer::new(doc, &pdf, &plan.title, plan.created, !separations && doc.color_mode == ColorMode::Cmyk)?;
// One exporter per ink (one for composite), each drawing its own document.
let mut exporters: Vec<Exporter> =
@@ -339,16 +404,22 @@ pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfErro
for ex in &mut exporters {
ex.non_printing = true;
ex.intent = opts.settings.color.intent;
// The writer's colour output: the printer profile's conversion.
ex.out = w.out.clone();
// Composite output keeps overprints as the Advanced option says (plates multiply them).
ex.overprint = !separations && pdf.advanced.overprint == Overprint::Preserve;
}
for &i in &plan.order {
let Some(s) = plan.sheets.get(i) else { continue };
let Some(ex) = exporters.get_mut(s.plate.unwrap_or(0)) else { continue };
w.page(ex, &s.sheet())?;
}
let overprinted = exporters.iter().any(|ex| ex.overprinted);
for ex in exporters {
w.absorb(ex);
}
let (bytes, more) = w.finish()?;
let bytes = crate::forms::finish(bytes, doc, &pdf, false, overprinted)?;
let mut warnings = plan.warnings;
for m in more {
if !warnings.contains(&m) {
+15 -2
View File
@@ -4,7 +4,7 @@
use std::sync::Arc;
use vectorcraft_color::Color;
use vectorcraft_color::cms::{Cms, Model};
use vectorcraft_color::cms::{self, Cms, Model, ProfileKind};
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_doc::inks::{Link, inks, plates, visit_node_colors};
use vectorcraft_doc::overprint::multiply_overprints;
@@ -42,6 +42,19 @@ pub fn print_inks(doc: &Document, set: &PrintSettings) -> (Vec<PrintInk>, Vec<St
(list, warnings)
}
/// The colour settings separations convert with: the active ones, with the printer profile as
/// their CMYK profile when it is a CMYK one.
fn separation_cms(set: &PrintSettings) -> Arc<Cms> {
let active = cms::active();
match cms::profile(set.color.profile.trim()) {
Some(p) if p.kind == ProfileKind::Cmyk && cms::canonical_name(&active.settings().cmyk) != p.name => {
let settings = cms::ColorSettings { cmyk: p.name, ..active.settings().clone() };
Cms::new(&settings).map_or(active, Arc::new)
}
_ => active,
}
}
/// Separates colours into inks as the job's colour options say.
pub(crate) struct Separator<'a> {
pub doc: &'a Document,
@@ -51,7 +64,7 @@ pub(crate) struct Separator<'a> {
impl<'a> Separator<'a> {
pub fn new(doc: &'a Document, set: &'a PrintSettings) -> Self {
Self { doc, cms: vectorcraft_color::cms::active(), set }
Self { doc, cms: separation_cms(set), set }
}
/// How much of ink `plate` colour `c` (with swatch link `link`) prints, 0..1.
+57 -2
View File
@@ -3,7 +3,7 @@
//! separation ([`PrintInk`]).
use serde::{Deserialize, Serialize};
use vectorcraft_color::cms::Intent;
use vectorcraft_color::cms::{self, Intent, ProfileKind};
use crate::settings::{choice, within};
use crate::{BleedSettings, MarkSettings, PdfError};
@@ -214,6 +214,10 @@ pub struct PrintSettings {
pub output: PrintOutput,
pub graphics: PrintGraphics,
pub color: PrintColor,
/// Where the Print Tiling tool put the pages.
pub tile_origin: TileOrigin,
// Advanced.
pub advanced: PrintAdvanced,
}
impl Default for PrintSettings {
@@ -244,6 +248,8 @@ impl Default for PrintSettings {
output: PrintOutput::default(),
graphics: PrintGraphics::default(),
color: PrintColor::default(),
tile_origin: TileOrigin::default(),
advanced: PrintAdvanced::default(),
}
}
}
@@ -359,14 +365,57 @@ pub struct PrintColor {
pub intent: Intent,
/// CMYK colours print with their own values; off, they go through the colour settings too.
pub preserve_numbers: bool,
/// The printer profile (an RGB or CMYK profile by name): composite output converts colours to
/// it with the intent (CMYK colours keep their numbers with `preserve_numbers`), and
/// separations separate with it when it is a CMYK one. Empty: colours print as they are.
pub profile: String,
}
impl Default for PrintColor {
fn default() -> Self {
Self { intent: Intent::RelativeColorimetric, preserve_numbers: true }
Self { intent: Intent::RelativeColorimetric, preserve_numbers: true, profile: String::new() }
}
}
choice! {
/// What composite output does with overprinting fills and strokes (separations always
/// honour them).
PrintOverprints {
Preserve = "preserve", "Preserve";
/// They knock out.
Discard = "discard", "Discard";
/// Composited as Overprint Preview shows them.
Simulate = "simulate", "Simulate";
} default Preserve
}
/// The Advanced section (with [`PrintSettings::margin`]).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PrintAdvanced {
/// Composite pages print as one image each of the art, at the document's raster effects
/// resolution (the app renders them before printing).
pub print_as_bitmap: bool,
pub overprints: PrintOverprints,
/// The transparency flattener preset transparency is flattened with (a built-in or saved
/// one, by name; the app flattens before printing). Empty: transparency prints live.
pub flattener_preset: String,
}
/// Where the Print Tiling tool put the pages (View → Show Print Tiling).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TileOrigin {
/// The tool placed the pages: the top-left corner of the first page's imageable area is at
/// (`x`, `y`) from the top-left corner of the art that prints (the artboard, or all the art
/// with artboards ignored), in document points. It replaces the placement, and tiles start
/// there (and go on to the left and up as far as the art does). Off: the placement places the
/// art and tiles start at the top-left corner of the art with its bleed and marks.
pub placed: bool,
pub x: f64,
pub y: f64,
}
/// Most copies of a job.
pub const MAX_COPIES: u32 = 999;
@@ -385,9 +434,15 @@ impl PrintSettings {
within("scale.height", self.scale.height, 1.0, 1000.0, "%")?;
within("overlap", self.overlap, 0.0, 720.0, " pt")?;
within("margin", self.margin, 0.0, 144.0, " pt")?;
within("tileOrigin.x", self.tile_origin.x, -14_400.0, 14_400.0, " pt")?;
within("tileOrigin.y", self.tile_origin.y, -14_400.0, 14_400.0, " pt")?;
self.marks.check()?;
self.bleed.check()?;
within("graphics.flatness", self.graphics.flatness, 0.2, 100.0, "")?;
let profile = self.color.profile.trim();
if !profile.is_empty() && cms::profile(profile).is_none_or(|p| p.kind == ProfileKind::Gray) {
return Err(PdfError::BadSetting(format!("color.profile: no RGB or CMYK profile is called “{profile}”")));
}
for ink in &self.output.inks {
if let Some(f) = ink.frequency {
within(&format!("output.inks[{}].frequency", ink.name), f, 1.0, 1000.0, " lpi")?;
+80
View File
@@ -0,0 +1,80 @@
//! View → Show Print Tiling: the pages of a job in document space, as [`super::preview`] lays
//! them out: the layout [`super::plan`] gives the PDF and PostScript writers, so the canvas shows
//! what prints (without separating the plates, which the overlay doesn't need).
use kurbo::{Affine, Rect};
use serde::Serialize;
use super::{PrintPreview, PrintScaling, PrintSettings};
/// One page of the print tiling.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TilingPage {
/// 0-based artboard (`None`: artboards ignored).
pub artboard: Option<usize>,
/// 1-based: the tile (across then down), or the page of the job.
pub number: usize,
/// It prints (tiles outside the tile range don't).
pub printed: bool,
/// The paper's edge, `[x0, y0, x1, y1]` in document space.
pub page: [f64; 4],
/// Its imageable area (inside the unprintable margin).
pub imageable: [f64; 4],
}
fn arr(r: Rect) -> [f64; 4] {
[r.x0, r.y0, r.x1, r.y1]
}
/// The pages of the job `pv` previews (printed with `set`), in document space: every tile of each
/// artboard when tiling, else each page once (not once per ink).
pub fn tiling(pv: &PrintPreview, set: &PrintSettings) -> Vec<TilingPage> {
let m = set.margin;
if set.scaling.tiles() {
// Tiles are the paper (full pages) or its imageable area, at the print scale.
let (sx, sy) = (set.scale.width / 100.0, set.scale.height / 100.0);
let inset = (m / sx, m / sy);
return pv
.tiles
.iter()
.flat_map(|g| {
g.tiles.iter().enumerate().map(move |(i, t)| {
let tile = Rect::new(t[0], t[1], t[2], t[3]);
let (page, imageable) = if set.scaling == PrintScaling::TileFull {
(tile, tile.inflate(-inset.0, -inset.1))
} else {
(tile.inflate(inset.0, inset.1), tile)
};
TilingPage {
artboard: g.artboard,
number: i + 1,
printed: g.printed.contains(&(i + 1)),
page: arr(page),
imageable: arr(imageable),
}
})
})
.collect();
}
let mut out: Vec<TilingPage> = vec![];
let mut last = None;
for s in &pv.sheets {
// Separations repeat each page once per ink, one after the other.
if last == Some((s.artboard, s.tile)) {
continue;
}
last = Some((s.artboard, s.tile));
let to_page = Affine::new(s.transform);
let det = to_page.determinant();
if !det.is_finite() || det.abs() < 1e-12 {
continue;
}
let back = to_page.inverse();
let paper = Rect::new(0.0, 0.0, s.width, s.height);
let page = back.transform_rect_bbox(paper);
let imageable = back.transform_rect_bbox(paper.inflate(-m, -m));
out.push(TilingPage { artboard: s.artboard, number: out.len() + 1, printed: true, page: arr(page), imageable: arr(imageable) });
}
out
}
+54 -20
View File
@@ -67,23 +67,35 @@ choice! {
PdfX4 = "pdfX4", "PDF/X-4:2010";
} default None
}
impl Standard {
/// The writer can produce this standard (PDF/X is not supported yet).
pub fn supported(self) -> bool {
matches!(self, Self::None | Self::PdfA2b)
/// A file of this standard can declare PDF version `c`: PDF/A-2 is based on PDF 1.7 (so it
/// can't be PDF 2.0, nor PDF 1.3, which has no transparency), PDF/X-4 on PDF 1.6, and PDF/X-1a
/// and PDF/X-3 on PDF 1.3 (PDF 1.4 is accepted too, and written as 1.3).
pub fn allows(self, c: Compatibility) -> bool {
use Compatibility::*;
match self {
Self::None => true,
Self::PdfA2b => !matches!(c, Pdf13 | Pdf20),
Self::PdfX1a | Self::PdfX3 => matches!(c, Pdf13 | Pdf14),
Self::PdfX4 => matches!(c, Pdf14 | Pdf15 | Pdf16),
}
}
/// A file of this standard can declare PDF version `c` (PDF/A-2 is based on PDF 1.7, so it
/// can't be PDF 2.0).
pub fn allows(self, c: Compatibility) -> bool {
!(self == Self::PdfA2b && c == Compatibility::Pdf20)
/// The PDF version a file of this standard is (what choosing it sets).
pub fn version(self) -> Compatibility {
match self {
Self::None | Self::PdfA2b => Compatibility::Pdf17,
Self::PdfX1a | Self::PdfX3 => Compatibility::Pdf13,
Self::PdfX4 => Compatibility::Pdf16,
}
}
}
choice! {
/// The PDF version written in the file header.
Compatibility {
/// No transparency: it is flattened (see [`PdfSettings::pdf13`]).
Pdf13 = "1.3", "PDF 1.3";
Pdf14 = "1.4", "PDF 1.4";
Pdf15 = "1.5", "PDF 1.5";
Pdf16 = "1.6", "PDF 1.6";
@@ -92,6 +104,13 @@ choice! {
} default Pdf17
}
impl Compatibility {
/// The version has PDF layers (optional content, PDF 1.5).
pub fn has_layers(self) -> bool {
matches!(self, Self::Pdf15 | Self::Pdf16 | Self::Pdf17 | Self::Pdf20)
}
}
choice! {
/// How images above the threshold resolution are resampled.
Downsample {
@@ -218,6 +237,10 @@ pub struct PdfSettings {
pub output: OutputSettings,
pub advanced: AdvancedSettings,
pub security: SecuritySettings,
/// The transparency flattener preset PDF 1.3 files (PDF/X-1a and PDF/X-3 ones too) are
/// flattened with: a built-in or saved preset's name; empty, High Resolution. The app flattens
/// the document before writing; the writer doesn't read it.
pub flattener_preset: String,
}
/// Resampling and compression of one kind of image.
@@ -392,27 +415,28 @@ pub(crate) fn within(name: &str, v: f64, lo: f64, hi: f64, unit: &str) -> Result
}
impl PdfSettings {
/// Refuse settings the writer can't honour: an unsupported standard, or what
/// [`Self::check_values`] refuses.
/// Refuse settings the writer can't honour: what [`Self::check_values`] refuses, a destination
/// profile that isn't there, or a PDF/X output intent it can't write ([`crate::pdfx`]).
pub fn check(&self) -> Result<(), PdfError> {
if !self.standard.supported() {
return Err(PdfError::Unsupported(format!("{} output is not supported yet", self.standard.label())));
}
crate::output::check(self)?;
crate::pdfx::check(self)?;
self.check_values()
}
/// The part of [`Self::check`] a preset must pass (it may name a standard the writer can't
/// produce yet): a standard with a PDF version it doesn't allow, PDF/A with editing data, or
/// an out-of-range value.
/// The part of [`Self::check`] a preset must pass (without the colour settings' profiles): a
/// standard with a PDF version it doesn't allow, a standard with editing data, PDF/X-1a or
/// PDF/X-3 with PDF layers, or an out-of-range value.
pub fn check_values(&self) -> Result<(), PdfError> {
self.security.check(self.standard, self.compatibility)?;
if !self.standard.allows(self.compatibility) {
return Err(PdfError::BadSetting(format!("{} files can't be {}", self.standard.label(), self.compatibility.label())));
}
if self.preserve_editing && self.standard == Standard::PdfA2b {
if self.preserve_editing && self.standard != Standard::None {
return Err(PdfError::BadSetting(format!("{} files can't carry editing data: turn off preserveEditing", self.standard.label())));
}
if self.create_layers && !self.standard.allows_layers() {
return Err(PdfError::BadSetting(format!("{} files can't have PDF layers: turn off createLayers", self.standard.label())));
}
let c = &self.compression;
for (name, img) in
[("color", (c.color.ppi, c.color.above_ppi)), ("gray", (c.gray.ppi, c.gray.above_ppi)), ("mono", (c.mono.ppi, c.mono.above_ppi))]
@@ -425,6 +449,18 @@ impl PdfSettings {
within("advanced.fontSubsetPercent", self.advanced.font_subset_percent, 0.0, 100.0, "%")
}
/// Top-level layers are written as PDF layers: asked for, at a version that has them.
pub fn writes_layers(&self) -> bool {
self.create_layers && self.compatibility.has_layers()
}
/// The file is PDF 1.3 (asked for, or a PDF/X-1a or PDF/X-3 file): it has no transparency (the
/// app flattens it, see [`Self::flattener_preset`]), is written with the PDF 1.4 settings, and
/// its header and metadata say 1.3.
pub fn pdf13(&self) -> bool {
self.compatibility == Compatibility::Pdf13 || self.standard.flattens()
}
/// Forget the passwords (presets never store them).
pub fn clear_passwords(&mut self) {
self.security.open_password.clear();
@@ -436,9 +472,7 @@ impl PdfSettings {
let d = Self::default();
let s = &self.security;
[
(self.thumbnails, "page thumbnails are not embedded yet"),
(self.fast_web_view, "fast web view (a linearised file) is not written yet"),
(self.create_layers, "PDF layers are not written yet: every layer is plain page content"),
(self.create_layers && !self.compatibility.has_layers(), "PDF layers need PDF 1.5 or later: every layer is plain page content"),
(
!self.advanced.outline_text && self.advanced.font_subset_percent < 100.0,
"fonts are embedded as subsets of the characters used: a subset threshold below 100% is not applied",
+76
View File
@@ -0,0 +1,76 @@
//! A written file's indirect objects read for rewriting it (the linearisation and the page
//! thumbnails), on the lexer of [`crate::encrypt`]: where each object's parts lie, what it refers
//! to, and the references to renumber.
use crate::encrypt::{Lexer, Obj, Tok, int, stream_spans};
impl Obj<'_> {
/// Every object this one refers to, in file order, leaving out the values of the dictionary
/// keys in `skip` (`Parent`).
pub(crate) fn refs(&self, skip: &[&[u8]], out: &mut Vec<u32>) {
match self {
Self::Ref(n, _) => out.push(*n),
Self::Dict { entries, .. } => entries.iter().filter(|(k, _)| !skip.contains(k)).for_each(|(_, v)| v.refs(skip, out)),
Self::Array(items) => items.iter().for_each(|v| v.refs(skip, out)),
_ => {}
}
}
}
/// An indirect object of a file, with where its parts lie.
pub(crate) struct Indirect<'a> {
pub num: u32,
pub generation: u16,
pub value: Obj<'a>,
/// Where the value (a stream's dictionary) starts and ends.
pub value_span: (usize, usize),
/// Where `endobj` starts.
pub endobj: usize,
}
/// The indirect object at `off` of `pdf` (a stream's length must be direct); `None` when it can't
/// be read.
pub(crate) fn indirect(pdf: &[u8], off: usize) -> Option<Indirect<'_>> {
let mut lx = Lexer::at(pdf, off);
let (num, generation) = lx.header()?;
let start = lx.clone().next()?.1;
let value = lx.object(0)?;
// The value ends before the white space ahead of the next token (`stream` or `endobj`).
let (next, next_at, _) = lx.clone().next()?;
let end = next_at - pdf.get(..next_at)?.iter().rev().take_while(|b| b.is_ascii_whitespace()).count();
let endobj = match next {
Tok::Word(b"endobj") => next_at,
_ => {
let (_, (_, data_end)) = stream_spans(pdf, &mut lx, &value).ok()??;
let mut lx = Lexer::at(pdf, data_end);
let (Tok::Word(b"endstream"), ..) = lx.next()? else { return None };
let (Tok::Word(b"endobj"), at, _) = lx.next()? else { return None };
at
}
};
Some(Indirect { num, generation, value, value_span: (start, end.max(start)), endobj })
}
/// The references (`n g R`) in `pdf[start..end]` (an object's value: no stream data), outside
/// strings: span and object number.
pub(crate) fn ref_spans(pdf: &[u8], start: usize, end: usize) -> Vec<(usize, usize, u32)> {
let mut out = vec![];
let mut lx = Lexer::at(pdf, start);
// The last two tokens when they are numbers: (start, value).
let mut last: [Option<(usize, i64)>; 2] = [None, None];
while let Some((tok, s, e)) = lx.next().filter(|t| t.1 < end) {
match tok {
Tok::Word(b"R") => {
if let [Some((at, n)), Some(_)] = last
&& let Ok(n) = u32::try_from(n)
{
out.push((at, e, n));
}
last = [None, None];
}
Tok::Word(w) => last = [last[1], int(w).map(|v| (s, v))],
_ => last = [None, None],
}
}
out
}
+5 -2
View File
@@ -522,8 +522,11 @@ fn compatibility_levels() {
}
let a = export(&d, &with(|s| s.standard = Standard::PdfA2b)).unwrap();
assert!(String::from_utf8_lossy(&a).contains("pdfaid"), "PDF/A identification in XMP");
for x in [Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4] {
assert!(matches!(export(&d, &with(|s| s.standard = x)), Err(PdfError::Unsupported(_))), "{x:?}");
// PDF/X files are PDF 1.3 (PDF/X-1a, PDF/X-3) or at most 1.6 (PDF/X-4): not the default 1.7.
for (x, header) in [(Standard::PdfX1a, "%PDF-1.3"), (Standard::PdfX3, "%PDF-1.3"), (Standard::PdfX4, "%PDF-1.6")] {
assert!(matches!(export(&d, &with(|s| s.standard = x)), Err(PdfError::BadSetting(_))), "{x:?}");
let b = export(&d, &with(|s| (s.standard, s.compatibility) = (x, x.version()))).unwrap();
assert!(b.starts_with(header.as_bytes()), "{x:?}");
}
// PDF/A-2b is a PDF 1.7 standard.
let e = export(&d, &with(|s| (s.standard, s.compatibility) = (Standard::PdfA2b, Compatibility::Pdf20)));
+3 -3
View File
@@ -148,11 +148,11 @@ fn permission_bits_follow_the_options() {
fn standards_and_bad_passwords_are_refused() {
let set = |standard, compatibility, sec| PdfSettings { standard, compatibility, security: sec, ..Default::default() };
for standard in [Standard::PdfA2b, Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4] {
let e = set(standard, Compatibility::Pdf17, security("pw", "")).check_values().unwrap_err();
let e = set(standard, standard.version(), security("pw", "")).check_values().unwrap_err();
assert!(matches!(&e, PdfError::BadSetting(m) if m.contains("password-protected")), "{standard:?}: {e}");
let e = set(standard, Compatibility::Pdf17, security("", "pw")).check_values().unwrap_err();
let e = set(standard, standard.version(), security("", "pw")).check_values().unwrap_err();
assert!(e.to_string().contains("password-protected"), "{standard:?}: {e}");
assert!(set(standard, Compatibility::Pdf17, security("", "")).check_values().is_ok(), "{standard:?} without passwords");
assert!(set(standard, standard.version(), security("", "")).check_values().is_ok(), "{standard:?} without passwords");
}
let bad = |c, sec| set(Standard::None, c, sec).check_values().unwrap_err().to_string();
assert!(bad(Compatibility::Pdf17, security("same", "same")).contains("must differ"));
+293
View File
@@ -0,0 +1,293 @@
//! Save PDF › Create PDF layers (an optional content group per top-level layer, with its
//! visibility, print state and lock) and Advanced › Overprint (a graphics state that overprints
//! for the overprinting fills and strokes).
use hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, LayerColor, Node, NodeKind, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
use crate::*;
const RED: Color = Color::Rgb { r: 1.0, g: 0.0, b: 0.0 };
const GREEN: Color = Color::Rgb { r: 0.0, g: 1.0, b: 0.0 };
const BLUE: Color = Color::Rgb { r: 0.0, g: 0.0, b: 1.0 };
fn rect(d: &mut Document, x: f64, c: Color) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 30.0, 40.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0))
}
/// Layers, bottom first: "Back" (red), "Hidden" (green; hidden and locked), "Notes" (blue; not
/// printed) and a template layer (grey).
fn layered() -> Document {
let mut d = Document::new(200.0, 100.0);
d.layers.clear();
for (i, (name, c)) in [("Back", RED), ("Hidden", GREEN), ("Notes", BLUE), ("Template", Color::gray(0.5))].into_iter().enumerate() {
let art = rect(&mut d, 10.0 + 40.0 * i as f64, c);
let mut l = Node::layer(d.alloc_id(), name, LayerColor::Preset(i as u8));
(l.visible, l.locked) = (name != "Hidden", name == "Hidden");
if let NodeKind::Layer { children, printable, template, .. } = &mut l.kind {
children.push(art.into());
*printable = name != "Notes";
*template = name == "Template";
}
d.layers.push(l.into());
}
d
}
fn export(d: &Document, v: Value) -> ExportReport {
let settings: PdfSettings = serde_json::from_value(v).unwrap();
export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap()
}
/// The groups of `refs` (an array of the catalog's optional content properties) by name.
fn names(xref: &hayro_syntax::xref::XRef, refs: Option<Array<'_>>) -> Vec<String> {
let ids = refs.map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect::<Vec<_>>()).unwrap_or_default();
ids.into_iter()
.map(|id| {
let g = xref.get::<Dict<'_>>(id).unwrap();
String::from_utf8(g.get::<hayro_syntax::object::String<'_>>(b"Name").unwrap().as_bytes().to_vec()).unwrap()
})
.collect()
}
/// The catalog's `/OCProperties` of `bytes` (opened with `password`), if any.
fn oc_properties(bytes: &[u8], password: Option<&str>, f: impl FnOnce(Option<Dict<'_>>, &hayro_syntax::xref::XRef)) {
let file = crate::pages::open(bytes, password).unwrap();
let xref = file.xref();
let catalog = xref.get::<Dict<'_>>(xref.root_id()).unwrap();
f(catalog.get::<Dict<'_>>(b"OCProperties"), xref);
}
/// The layers of an import: name, visible, printable, locked and fill colours of their art.
fn layers(d: &Document) -> Vec<(String, bool, bool, bool, Vec<Color>)> {
d.layers
.iter()
.map(|l| {
let printable = matches!(l.kind, NodeKind::Layer { printable: true, .. });
let colors = l.children().unwrap().iter().filter_map(|n| n.appearance.fill().and_then(|f| f.paint.color())).collect();
(l.name.clone().unwrap_or_default(), l.visible, printable, l.locked, colors)
})
.collect()
}
#[test]
fn create_layers_writes_a_group_per_layer_with_its_states() {
let d = layered();
let r = export(&d, json!({"createLayers": true, "compression": {"compressText": false}}));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
oc_properties(&r.bytes, None, |props, xref| {
let props = props.expect("/OCProperties");
// Template layers aren't exported; the groups are in paint order, listed top first.
assert_eq!(names(xref, props.get(b"OCGs")), ["Back", "Hidden", "Notes"]);
let config = props.get::<Dict<'_>>(b"D").unwrap();
assert_eq!(names(xref, config.get(b"Order")), ["Notes", "Hidden", "Back"]);
assert_eq!(names(xref, config.get(b"OFF")), ["Hidden"], "hidden layers are off");
assert_eq!(names(xref, config.get(b"Locked")), ["Hidden"]);
assert!(config.get::<hayro_syntax::object::String<'_>>(b"Name").is_some());
let auto = config.get::<Array<'_>>(b"AS").unwrap().iter::<Dict<'_>>().next().unwrap();
assert_eq!(auto.get::<Name<'_>>(b"Event").unwrap().as_ref(), b"Print");
let print_state = |name: &str| {
let ids =
props.get::<Array<'_>>(b"OCGs").unwrap().raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect::<Vec<_>>();
let g = ids
.into_iter()
.map(|id| xref.get::<Dict<'_>>(id).unwrap())
.find(|g| g.get::<hayro_syntax::object::String<'_>>(b"Name").unwrap().as_bytes() == name.as_bytes());
let usage = g.unwrap().get::<Dict<'_>>(b"Usage").unwrap();
usage.get::<Dict<'_>>(b"Print").unwrap().get::<Name<'_>>(b"PrintState").unwrap().as_ref().to_vec()
};
assert_eq!(print_state("Notes"), b"OFF", "non-printing layers aren't printed");
assert_eq!(print_state("Back"), b"ON");
});
// Each layer's art is its group's optional content.
let text = String::from_utf8_lossy(&r.bytes);
assert_eq!(text.matches(" BDC ").count(), 3, "{text}");
assert!(text.contains("/OC /VCoc0 BDC /x") && text.contains("/Properties<<"));
}
#[test]
fn pdf_layers_import_back_as_layers() {
let d = layered();
for password in [None, Some("secret")] {
let mut set = json!({"createLayers": true});
if let Some(p) = password {
set["security"] = json!({"openPassword": p});
}
let bytes = export(&d, set).bytes;
let opts = ImportOptions { password: password.map(str::to_string), ..Default::default() };
let back = import_with_report(&bytes, &opts).unwrap().document;
assert_eq!(
layers(&back),
[
("Back".to_string(), true, true, false, vec![RED]),
("Hidden".to_string(), false, true, true, vec![GREEN]),
("Notes".to_string(), true, false, false, vec![BLUE]),
],
"password {password:?}"
);
}
}
#[test]
fn identical_layers_stay_apart() {
let mut d = Document::new(200.0, 100.0);
d.layers.clear();
for name in ["One", "Two"] {
let art = rect(&mut d, 10.0, RED);
let mut l = Node::layer(d.alloc_id(), name, LayerColor::Preset(0));
if let NodeKind::Layer { children, .. } = &mut l.kind {
children.push(art.into());
}
d.layers.push(l.into());
}
let back = import(&export(&d, json!({"createLayers": true})).bytes).unwrap();
let got: Vec<(String, usize)> = back.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.children().unwrap().len())).collect();
assert_eq!(got, [("One".to_string(), 1), ("Two".to_string(), 1)]);
}
#[test]
fn without_pdf_layers_the_layers_are_plain_page_content() {
let mut d = layered();
for (v, warning) in [
(json!({}), None),
(json!({"createLayers": true, "compatibility": "1.4"}), Some("1.5")),
(json!({"createLayers": true, "compatibility": "1.5"}), None),
] {
let r = export(&d, v.clone());
let mut has = false;
oc_properties(&r.bytes, None, |props, _| has = props.is_some());
assert_eq!(has, v["createLayers"] == json!(true) && warning.is_none(), "{v}");
match warning {
Some(w) => assert!(r.warnings.iter().any(|x| x.contains(w)), "{v}: {:?}", r.warnings),
None => assert!(r.warnings.is_empty(), "{v}: {:?}", r.warnings),
}
}
// A knockout page group draws objects, not layers.
d.page_knockout = true;
let r = export(&d, json!({"createLayers": true}));
oc_properties(&r.bytes, None, |props, _| assert!(props.is_none()));
assert!(r.warnings.iter().any(|w| w.contains("knockout page group")), "{:?}", r.warnings);
}
#[test]
fn pdf_a_layers_have_no_automatic_states() {
let r = export(&layered(), json!({"standard": "pdfA2b", "createLayers": true}));
oc_properties(&r.bytes, None, |props, _| {
let config = props.unwrap().get::<Dict<'_>>(b"D").unwrap();
assert!(config.get::<Array<'_>>(b"AS").is_none() && config.get::<hayro_syntax::object::String<'_>>(b"Name").is_some());
});
}
#[test]
fn pdf_x4_keeps_pdf_layers_and_x1a_refuses_them() {
let d = layered();
let r = export(&d, json!({"standard": "pdfX4", "compatibility": "1.6", "createLayers": true}));
assert!(r.bytes.starts_with(b"%PDF-1.6"));
oc_properties(&r.bytes, None, |props, xref| {
let props = props.expect("/OCProperties");
assert_eq!(names(xref, props.get(b"OCGs")), ["Back", "Hidden", "Notes"]);
assert!(props.get::<Dict<'_>>(b"D").unwrap().get::<Array<'_>>(b"AS").is_none());
});
let set: PdfSettings = serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.4", "createLayers": true})).unwrap();
assert!(matches!(export_with_report(&d, &PdfOptions { settings: set, ..Default::default() }), Err(PdfError::BadSetting(_))));
}
/// The painted paths of `d`: their bounds' corner and colour, in order.
fn leaves(d: &Document) -> Vec<(i32, i32, Option<[u8; 4]>)> {
let mut out = vec![];
d.walk(|n| {
if let Some(b) = n.path_data().and_then(|p| p.bounds()) {
let paint = n.appearance.fill().map(|f| f.paint.clone()).or_else(|| n.appearance.stroke().map(|s| s.paint.clone()));
out.push((b.x0.round() as i32, b.y0.round() as i32, paint.and_then(|p| p.color()).map(|c| c.to_rgba8(1.0))));
}
});
out.sort_by_key(|l| (l.0, l.1));
out
}
#[test]
fn layered_art_imports_as_it_was_drawn() {
let mut d = layered();
d.layers = d.layers.iter().map(|l| Node { visible: true, ..(**l).clone() }.into()).collect();
let plain = leaves(&import(&export(&d, json!({"includeNonPrinting": true})).bytes).unwrap());
assert_eq!(plain.len(), 3);
assert_eq!(leaves(&import(&export(&d, json!({"createLayers": true})).bytes).unwrap()), plain, "the marks draw nothing");
}
/// A rectangle whose fill overprints, one whose stroke does, a plain one and type whose
/// characters' fill overprints, filled with `fill` (the overprinting rectangle).
fn overprinting(fill: Color) -> Document {
let mut d = Document::new(300.0, 100.0);
let l = d.layers[0].id;
let mut a = rect(&mut d, 10.0, fill);
if let Some(f) = a.appearance.fill_mut() {
f.overprint = true;
}
let mut b =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), Appearance::basic(Paint::None, Paint::solid(BLUE), 2.0));
if let Some(s) = b.appearance.stroke_mut() {
s.overprint = true;
}
let c = rect(&mut d, 90.0, GREEN);
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), overprint_fill: true, ..Default::default() };
let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(130.0, 40.0), "Ink", style))));
for (i, n) in [a, b, c, t].into_iter().enumerate() {
d.insert(Some(l), i, n).unwrap();
}
d
}
/// The uncompressed export of `d` with `v`: the overprinting graphics states it defines, and the
/// forms that set one.
fn overprints(d: &Document, v: Value) -> (usize, usize) {
let mut v = v;
v["compression"] = json!({"compressText": false});
let r = export(d, v);
assert!(r.warnings.iter().all(|w| !w.contains("overprint")), "{:?}", r.warnings);
let text = String::from_utf8_lossy(&r.bytes).into_owned();
(text.matches("/Type/ExtGState/OP true/op true/OPM 1").count(), text.matches("stream\n/VCop gs\n").count())
}
#[test]
fn overprint_preserve_writes_an_overprinting_graphics_state() {
let d = overprinting(RED);
assert_eq!(overprints(&d, json!({})), (1, 3), "the fill, the stroke and the characters' fill");
assert_eq!(overprints(&d, json!({"advanced": {"overprint": "discard"}})), (0, 0));
assert_eq!(overprints(&overprinting(RED), json!({"createLayers": true})), (1, 3), "with PDF layers too");
// Nothing overprints: no graphics state.
assert_eq!(overprints(&layered(), json!({})), (0, 0));
}
#[test]
fn white_overprint_is_discarded_as_the_document_setup_says() {
let mut d = overprinting(Color::WHITE);
assert!(d.setup.discard_white_overprint);
assert_eq!(overprints(&d, json!({})).1, 2, "the white fill knocks out");
d.setup.discard_white_overprint = false;
assert_eq!(overprints(&d, json!({})).1, 3);
}
#[test]
fn pdf_a_overprint_mode_is_0() {
let r = export(&overprinting(RED), json!({"standard": "pdfA2b", "compression": {"compressText": false}}));
let text = String::from_utf8_lossy(&r.bytes);
assert!(text.contains("/OP true/op true/OPM 0") && !text.contains("/OPM 1"));
}
#[test]
fn overprinting_art_imports_as_it_was_drawn() {
let d = overprinting(RED);
let plain = leaves(&import(&export(&d, json!({"advanced": {"overprint": "discard"}})).bytes).unwrap());
for v in [json!({}), json!({"createLayers": true})] {
assert_eq!(leaves(&import(&export(&d, v.clone()).bytes).unwrap()), plain, "{v}: the marks draw nothing");
}
}
#[test]
fn pdf_x1a_keeps_overprint() {
let r = export(&overprinting(RED), json!({"standard": "pdfX1a", "compatibility": "1.4", "compression": {"compressText": false}}));
assert!(r.bytes.starts_with(b"%PDF-1.3") && String::from_utf8_lossy(&r.bytes).contains("/OP true/op true/OPM 1"));
}
+79
View File
@@ -0,0 +1,79 @@
//! Compatibility PDF 1.3: which standards allow it, the header and metadata that say so, the
//! writer refusing transparency (the app flattens it first) and 40-bit RC4 encryption.
use serde_json::json;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_geom::{Rect, shapes};
use crate::*;
/// A document with a black rectangle; `opacity` below 1 makes it transparent.
fn doc(opacity: f32) -> Document {
let mut d = Document::new(100.0, 100.0);
let l = d.default_layer().unwrap();
let mut n = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
n.opacity = opacity;
d.insert(Some(l), 0, n).unwrap();
d
}
fn options(v: serde_json::Value) -> PdfOptions {
PdfOptions { settings: serde_json::from_value(v).unwrap(), created: Some(0), ..PdfOptions::uncompressed() }
}
#[test]
fn which_standards_allow_pdf_1_3() {
use Compatibility::*;
assert!(Standard::None.allows(Pdf13) && Standard::PdfX1a.allows(Pdf13) && Standard::PdfX3.allows(Pdf13));
assert!(!Standard::PdfA2b.allows(Pdf13) && !Standard::PdfX4.allows(Pdf13));
// PDF/X-1a and PDF/X-3 files are PDF 1.3; PDF 1.4 is still accepted, and written as 1.3.
assert_eq!((Standard::PdfX1a.version(), Standard::PdfX3.version()), (Pdf13, Pdf13));
assert!(Standard::PdfX1a.allows(Pdf14));
let s = |v| serde_json::from_value::<PdfSettings>(v).unwrap();
assert!(s(json!({"compatibility": "1.3"})).pdf13() && s(json!({"standard": "pdfX3", "compatibility": "1.4"})).pdf13());
assert!(!s(json!({"compatibility": "1.4"})).pdf13() && !s(json!({"standard": "pdfX4"})).pdf13());
assert!(matches!(s(json!({"standard": "pdfA2b", "compatibility": "1.3"})).check(), Err(PdfError::BadSetting(m)) if m.contains("PDF 1.3")));
assert_eq!(Compatibility::IDS.first(), Some(&"1.3"), "first in the dropdown");
assert_eq!(Encryption::for_compatibility(Pdf13), Encryption::Rc440);
}
#[test]
fn pdf_1_3_files_say_so_and_have_no_transparency() {
let mut d = doc(1.0);
// Isolated page blending is a transparency group: a flat file has none.
d.page_isolate = true;
let r = export_with_report(&d, &options(json!({"compatibility": "1.3"}))).unwrap();
let text = String::from_utf8_lossy(&r.bytes);
assert!(r.bytes.starts_with(b"%PDF-1.3") && text.contains("<pdf:PDFVersion>1.3<"), "the header and the metadata");
assert!(!text.contains("/Transparency") && !text.contains("/SMask"));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
// The same at 1.4 has its page group.
assert!(String::from_utf8_lossy(&export(&d, &options(json!({"compatibility": "1.4"}))).unwrap()).contains("/S/Transparency"));
// Transparency the app didn't flatten is refused.
let e = export(&doc(0.5), &options(json!({"compatibility": "1.3"}))).unwrap_err();
assert!(matches!(&e, PdfError::Unsupported(m) if m.contains("PDF 1.3 files have no transparency")), "{e}");
let mut d = doc(1.0);
if let Some(n) = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id) {
d.node_mut(n).unwrap().blend = BlendMode::Multiply;
}
assert!(matches!(export(&d, &options(json!({"compatibility": "1.3"}))), Err(PdfError::Unsupported(m)) if m.contains("PDF 1.3")));
}
#[test]
fn pdf_1_3_files_are_encrypted_with_40_bit_rc4() {
let o = options(json!({"compatibility": "1.3", "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": true}}));
let r = export_with_report(&doc(1.0), &o).unwrap();
assert!(String::from_utf8_lossy(&r.bytes).contains("/Filter/Standard/V 1/R 2/Length 40/P "));
assert!(r.warnings.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.warnings);
assert!(matches!(import(&r.bytes), Err(PdfError::NeedsPassword)));
for password in ["open", "owner"] {
let opened = import_with_report(&r.bytes, &ImportOptions { password: Some(password.into()), ..Default::default() }).unwrap();
assert_eq!(opened.document.layers[0].children().map_or(0, |c| c.len()), 1, "{password}");
}
assert_eq!(crate::encrypt::user_password(&r.bytes, "owner").as_deref(), Some("open"));
}
+222
View File
@@ -0,0 +1,222 @@
//! PDF/X-1a, PDF/X-3 and PDF/X-4: each standard's header, output intent, boxes and identification,
//! and what each refuses (RGB in PDF/X-1a, transparency in PDF/X-1a and PDF/X-3, passwords).
use serde_json::json;
use vectorcraft_color::cms::{self, GENERIC_CMYK, SRGB};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Affine, Rect, shapes};
use crate::*;
const X: [Standard; 3] = [Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4];
/// A document with a rectangle filled with each of `fills`.
fn doc(fills: &[Color]) -> Document {
let mut d = Document::new(200.0, 100.0);
let l = d.layers[0].id;
for (i, c) in fills.iter().enumerate() {
let x = 10.0 + 30.0 * i as f64;
let n =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), Appearance::basic(Paint::solid(*c), Paint::None, 0.0));
d.insert(Some(l), i, n).unwrap();
}
d
}
/// Options for `standard` at its version, content streams readable, a fixed date, changed by `f`.
fn opts(standard: Standard, f: impl FnOnce(&mut PdfSettings)) -> PdfOptions {
let mut o = PdfOptions::uncompressed();
(o.settings.standard, o.settings.compatibility) = (standard, standard.version());
o.created = Some(1_700_000_000);
f(&mut o.settings);
o
}
fn settings(v: serde_json::Value) -> PdfSettings {
serde_json::from_value(v).unwrap()
}
/// The file's text with its stream data left out (what dictionaries say).
fn text(pdf: &[u8]) -> String {
String::from_utf8_lossy(pdf).into_owned()
}
#[test]
fn each_standard_writes_its_header_output_intent_boxes_and_identification() {
let mut d = doc(&[Color::cmyk(0.0, 1.0, 1.0, 0.0), Color::rgb(0.2, 0.4, 0.9)]);
// PDF/X files have a title: an untitled document gets one.
d.title.clear();
// As a PDF string (parentheses escaped).
let profile = cms::active().settings().cmyk.replace('(', r"\(").replace(')', r"\)");
for (standard, header) in [(Standard::PdfX1a, "%PDF-1.3"), (Standard::PdfX3, "%PDF-1.3"), (Standard::PdfX4, "%PDF-1.6")] {
let r = export_with_report(&d, &opts(standard, |_| {})).unwrap();
let t = text(&r.bytes);
assert!(r.bytes.starts_with(header.as_bytes()), "{standard:?}: {}", &t[..8]);
// The output intent: the CMYK profile in effect, embedded.
for want in ["/OutputIntents[", "/S/GTS_PDFX", "/DestOutputProfile ", &format!("/OutputConditionIdentifier({profile})")] {
assert!(t.contains(want), "{standard:?}: {want}");
}
assert!(t.contains("/N 4/Length"), "{standard:?}: a CMYK profile");
// Every page is trimmed to its artboard; the file is untrapped, titled and dated.
for want in ["/TrimBox[0 0 200 100]", "/Trapped/False", "/Title(Untitled)", "/CreationDate", "/ModDate"] {
assert!(t.contains(want), "{standard:?}: {want}");
}
let (version, conformance) = match standard {
Standard::PdfX1a => ("PDF/X-1:2001", Some("PDF/X-1a:2001")),
Standard::PdfX3 => ("PDF/X-3:2002", None),
_ => ("PDF/X-4", None),
};
assert!(t.contains(&format!("/GTS_PDFXVersion({version})")), "{standard:?}");
assert_eq!(conformance.is_some_and(|c| t.contains(&format!("/GTS_PDFXConformance({c})"))), conformance.is_some(), "{standard:?}");
// The metadata agrees with the header; PDF/X-4 names its standard there too.
let xmp_version = if standard == Standard::PdfX4 { "1.6" } else { "1.3" };
assert!(t.contains(&format!("<pdf:PDFVersion>{xmp_version}</pdf:PDFVersion>")), "{standard:?}");
assert_eq!(t.contains("<pdfxid:GTS_PDFXVersion xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\">PDF/X-4<"), standard == Standard::PdfX4);
assert_eq!(t.contains("<pdf:Trapped>False</pdf:Trapped>"), standard == Standard::PdfX4);
assert!(!t.contains("/Encrypt"), "{standard:?}");
// The file reads back.
let back = import(&r.bytes).unwrap();
assert_eq!(back.artboards.len(), 1, "{standard:?}");
assert!(r.warnings.is_empty(), "{standard:?}: {:?}", r.warnings);
}
// Trapped, a named output intent with its condition and registry.
let o = opts(Standard::PdfX4, |s| {
s.output.trapped = true;
s.output.output_intent = GENERIC_CMYK.into();
s.output.output_condition_id = "CGATS TR 001".into();
s.output.registry = "http://www.color.org".into();
});
let t = text(&export(&d, &o).unwrap());
for want in ["/Trapped/True", "<pdf:Trapped>True<", "/OutputConditionIdentifier(CGATS TR 001)", "/RegistryName(http://www.color.org)"] {
assert!(t.contains(want), "{want}");
}
// A registered condition that isn't a profile is named without one.
let o = opts(Standard::PdfX1a, |s| {
s.output.output_intent = "FOGRA39".into();
s.output.registry = "http://www.color.org".into();
});
let t = text(&export(&d, &o).unwrap());
assert!(t.contains("/OutputConditionIdentifier(FOGRA39)") && !t.contains("/DestOutputProfile"), "{t}");
// PDF/X-4 always embeds its output intent's profile.
let e = export(&d, &opts(Standard::PdfX4, |s| (s.output.output_intent, s.output.registry) = ("FOGRA39".into(), "http://www.color.org".into())));
assert!(matches!(e, Err(PdfError::BadSetting(m)) if m.contains("embeds")));
}
#[test]
fn pdfx_1a_files_have_no_rgb() {
let mut d = doc(&[Color::rgb(0.9, 0.1, 0.1), Color::Lab { l: 50.0, a: 40.0, b: -20.0 }, Color::gray(0.5), Color::cmyk(0.1, 0.2, 0.3, 0.4)]);
// An RGB image too.
let png = {
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([10, 200, 30, 255]));
let mut out = std::io::Cursor::new(vec![]);
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
};
d.images.insert("img".into(), ImageBlob::new("image/png", png));
let l = d.layers[0].id;
let im = ImageObject {
key: "img".into(),
width: 4,
height: 4,
xf: Affine::translate((150.0, 50.0)) * Affine::scale(5.0),
link: None,
placement: Default::default(),
};
let n = Node::new(d.alloc_id(), NodeKind::Image(im));
d.insert(Some(l), 0, n).unwrap();
let t = text(&export(&d, &opts(Standard::PdfX1a, |_| {})).unwrap());
for no in ["/DeviceRGB", "/ICCBased", "/Lab", "/CalRGB"] {
assert!(!t.contains(no), "PDF/X-1a has no {no}");
}
let ops: Vec<&str> = t.split_whitespace().collect();
assert!(!ops.contains(&"rg") && !ops.contains(&"RG"), "no RGB fills or strokes");
assert!(ops.iter().filter(|o| **o == "k").count() >= 3, "the RGB, Lab and CMYK fills are CMYK");
assert!(ops.contains(&"g"), "grey stays grey");
assert!(t.contains("/ColorSpace/DeviceCMYK"), "the image is CMYK");
// An RGB destination or output intent is refused; PDF/X-3 tags colours instead.
let refused = |f: fn(&mut PdfSettings)| export(&d, &opts(Standard::PdfX1a, f)).unwrap_err();
assert!(matches!(refused(|s| s.output.destination = SRGB.into()), PdfError::BadSetting(m) if m.contains("output.destination")));
assert!(matches!(refused(|s| s.output.output_intent = SRGB.into()), PdfError::BadSetting(m) if m.contains("output.outputIntent")));
let x3 = text(&export(&d, &opts(Standard::PdfX3, |_| {})).unwrap());
assert!(x3.contains("/ICCBased") && !x3.contains("/DeviceRGB"), "PDF/X-3 colours are ICC-based");
assert!(export(&d, &opts(Standard::PdfX3, |s| s.output.output_intent = SRGB.into())).is_ok(), "PDF/X-3 may print in RGB");
}
#[test]
fn pdfx_1a_and_x3_files_have_no_transparency() {
let mut d = doc(&[Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)]);
let half = d.layers[0].children().unwrap()[1].id;
d.node_mut(half).unwrap().opacity = 0.5;
for standard in [Standard::PdfX1a, Standard::PdfX3] {
// The writer can't flatten (the app does, before writing): it refuses.
let e = export(&d, &opts(standard, |_| {})).unwrap_err();
assert!(matches!(&e, PdfError::Unsupported(m) if m.contains("transparency") && m.contains("flatten")), "{standard:?}: {e}");
}
// Opaque art passes; PDF/X-4 keeps the transparency.
d.node_mut(half).unwrap().opacity = 1.0;
let t = text(&export(&d, &opts(Standard::PdfX3, |_| {})).unwrap());
assert!(!t.contains("/S/Transparency") && !t.contains("/SMask"));
d.node_mut(half).unwrap().opacity = 0.5;
let t = text(&export(&d, &opts(Standard::PdfX4, |_| {})).unwrap());
assert!(t.contains("/ca 0.5") || t.contains("/S/Transparency"), "PDF/X-4 keeps transparency");
}
#[test]
fn pdfx_settings_rules() {
for standard in X {
// A password is refused, and so are editing data.
let e = opts(standard, |s| s.security.open_password = "x".into()).settings.check().unwrap_err();
assert!(matches!(e, PdfError::BadSetting(_)), "{standard:?}: {e}");
assert!(export(&doc(&[]), &opts(standard, |s| s.security.permissions_password = "x".into())).is_err());
assert!(
matches!(opts(standard, |s| s.preserve_editing = true).settings.check(), Err(PdfError::BadSetting(m)) if m.contains("preserveEditing"))
);
// Its own version, never PDF 1.7 or 2.0.
assert!(standard.allows(standard.version()), "{standard:?}");
assert!(!standard.allows(Compatibility::Pdf17) && !standard.allows(Compatibility::Pdf20), "{standard:?}");
let e = opts(standard, |s| s.compatibility = Compatibility::Pdf17).settings.check().unwrap_err();
assert!(matches!(e, PdfError::BadSetting(ref m) if m.contains(standard.label())), "{standard:?}: {e}");
// A name that is no profile, without a registry.
let e = opts(standard, |s| s.output.output_intent = "No Such Press".into()).settings.check().unwrap_err();
assert!(matches!(e, PdfError::BadSetting(ref m) if m.contains("No Such Press")), "{standard:?}: {e}");
}
assert_eq!(Standard::PdfX4.version(), Compatibility::Pdf16);
assert!(Standard::PdfX4.allows(Compatibility::Pdf15) && !Standard::PdfX1a.allows(Compatibility::Pdf15));
// Layers: PDF/X-4 only.
assert!(
matches!(settings(json!({"standard": "pdfX1a", "compatibility": "1.4", "createLayers": true})).check(), Err(PdfError::BadSetting(m)) if m.contains("createLayers"))
);
assert!(settings(json!({"standard": "pdfX4", "compatibility": "1.6", "createLayers": true})).check_values().is_ok());
// A date is required.
assert!(matches!(crate::pdfx::require_date(Standard::PdfX4, None), Err(PdfError::Unsupported(_))));
assert!(crate::pdfx::require_date(Standard::None, None).is_ok());
// PDF/X settings don't warn about the output condition: the output intent is always written.
assert!(settings(json!({"standard": "pdfX4", "compatibility": "1.6", "output": {"outputCondition": "Coated"}})).warnings().is_empty());
}
#[test]
fn verify_finds_what_each_standard_forbids() {
use crate::pdfx::verify;
let file = |body: &str| format!("%PDF-1.4\n1 0 obj\n{body}\nendobj\n").into_bytes();
for (body, what) in [
("<</Type/XObject/SMask 5 0 R>>", "soft masks"),
("<</Group<</S/Transparency/CS/DeviceCMYK>>>>", "transparency groups"),
("<</Type/ExtGState/ca 0.5>>", "opacity"),
("<</Type/ExtGState/BM/Multiply>>", "blend modes"),
] {
for standard in [Standard::PdfX1a, Standard::PdfX3] {
assert!(matches!(verify(&file(body), standard), Err(PdfError::Unsupported(m)) if m.contains(what)), "{standard:?}: {body}");
}
assert!(verify(&file(body), Standard::PdfX4).is_ok(), "PDF/X-4 keeps {what}");
}
for ok in ["<</Type/ExtGState/SMask/None/ca 1/CA 1.0/BM/Normal>>", "<</Length 9>>\nstream\n/SMask 1 /DeviceRGB\nendstream"] {
assert!(verify(&file(ok), Standard::PdfX1a).is_ok(), "{ok}");
}
assert!(matches!(verify(&file("[/ICCBased 3 0 R]"), Standard::PdfX1a), Err(PdfError::Unsupported(m)) if m.contains("ICCBased")));
assert!(verify(&file("[/ICCBased 3 0 R]"), Standard::PdfX3).is_ok());
assert!(verify(&file("<</PageLabels 3 0 R>>"), Standard::PdfX1a).is_ok(), "whole names only");
let font = "<</Type/FontDescriptor/FontName/A>>";
assert!(matches!(verify(&file(font), Standard::PdfX4), Err(PdfError::Unsupported(m)) if m.contains("fonts")));
assert!(verify(&file("<</Type/FontDescriptor/FontFile2 4 0 R>>"), Standard::PdfX4).is_ok());
}
+5 -3
View File
@@ -14,9 +14,11 @@ fn built_in_presets() {
let back: PdfPreset = serde_json::from_value(serde_json::to_value(p).unwrap()).unwrap();
assert_eq!(&back, p, "round-trips");
}
// PDF/X presets name standards the writer doesn't produce yet.
let x4 = builtin_preset("pdf/x-4:2010").unwrap();
assert!(matches!(x4.settings.check(), Err(PdfError::Unsupported(_))));
// The PDF/X presets are written as their standards say.
for name in ["pdf/x-1a:2001", "PDF/X-3:2002", "pdf/x-4:2010"] {
let x = builtin_preset(name).unwrap();
assert!(x.settings.standard.is_pdfx() && x.settings.check().is_ok(), "{name}");
}
assert_eq!(builtin_preset("default").unwrap().name, DEFAULT_PRESET);
assert!(builtin_preset("Nope").is_none());
}
+145
View File
@@ -0,0 +1,145 @@
//! File → Print › Advanced and Color Management: overprints preserved, discarded or simulated in
//! composite output, and colours converted to the printer profile with the rendering intent.
use std::sync::Arc;
use hayro_syntax::Pdf;
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, Cms, ColorSettings, DEVICE_CMYK, GENERIC_CMYK, Intent};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_geom::{Rect, shapes};
use crate::*;
/// A 200 × 100 document with a rectangle filled with each `(colour, overprints)`, overlapping
/// one another left to right, in paint order.
fn doc(fills: &[(Color, bool)]) -> Document {
let mut d = Document::new(200.0, 100.0);
let l = d.layers[0].id;
for (i, (c, over)) in fills.iter().enumerate() {
let x = 10.0 + 30.0 * i as f64;
let mut a = Appearance::basic(Paint::solid(*c), Paint::None, 0.0);
for it in &mut a.items {
*it.overprint_mut() = *over;
}
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 50.0, 60.0)), a);
d.insert(Some(l), i, n).unwrap();
}
d
}
/// Cyan under a yellow that overprints.
fn overprinted() -> Document {
doc(&[(Color::cmyk(1.0, 0.0, 0.0, 0.0), false), (Color::cmyk(0.0, 0.0, 1.0, 0.0), true)])
}
fn settings(v: Value) -> PrintSettings {
serde_json::from_value(v).unwrap()
}
/// The job's bytes, content streams left readable.
fn job(d: &Document, v: Value) -> PrintReport {
let opts = PrintOptions { settings: settings(v), created: Some(1_791_200_000), uncompressed: true, ..Default::default() };
print(d, &opts).unwrap()
}
/// The fill colours page `page` sets: (operator, operands).
fn colours(pdf: &[u8], page: usize) -> Vec<(String, Vec<f32>)> {
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
let pages = file.pages();
let content = String::from_utf8_lossy(pages.get(page).unwrap().page_stream().unwrap()).into_owned();
let (mut nums, mut out) = (vec![], vec![]);
for t in content.split_whitespace() {
if let Ok(v) = t.parse::<f32>() {
nums.push(v);
continue;
}
if ["rg", "k", "g", "scn"].contains(&t) {
out.push((t.to_string(), nums.clone()));
}
nums.clear();
}
out
}
/// `v` as the writer stores it (8 bits a component).
fn q8(v: &[f32]) -> Vec<f32> {
v.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect()
}
fn close(a: &[f32], b: &[f32]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
}
/// Does any fill or stroke of `d` overprint?
fn overprints(d: &Document) -> bool {
d.layers.iter().any(|l| l.has_overprint())
}
#[test]
fn settings_saved_before_the_advanced_options_load_with_their_defaults() {
let s = settings(json!({"copies": 2, "color": {"intent": "perceptual"}}));
assert_eq!((s.copies, s.color.intent, s.color.preserve_numbers), (2, Intent::Perceptual, true));
assert_eq!(s.advanced, PrintAdvanced::default());
assert_eq!((s.advanced.print_as_bitmap, s.advanced.overprints, s.advanced.flattener_preset.as_str()), (false, PrintOverprints::Preserve, ""));
assert_eq!(s.color.profile, "");
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["advanced"], json!({"printAsBitmap": false, "overprints": "preserve", "flattenerPreset": ""}));
// A printer profile must be one there is.
assert!(settings(json!({"color": {"profile": GENERIC_CMYK}})).check().is_ok());
assert!(matches!(settings(json!({"color": {"profile": "No Such Profile"}})).check(), Err(PdfError::BadSetting(_))));
}
#[test]
fn discarded_and_simulated_overprints_write_no_overprint() {
let d = overprinted();
let preserve = job(&d, json!({}));
assert!(preserve.warnings.is_empty(), "{:?}", preserve.warnings);
assert!(String::from_utf8_lossy(&preserve.bytes).contains("/OP true/op true/OPM 1"), "preserved overprints overprint in the file");
assert!(overprints(&printed_document(&d, &settings(json!({})))), "preserved");
for (how, multiplies) in [("discard", false), ("simulate", true)] {
let set = json!({"advanced": {"overprints": how}});
assert!(!overprints(&printed_document(&d, &settings(set.clone()))), "{how}: nothing overprints");
let r = job(&d, set);
let text = String::from_utf8_lossy(&r.bytes);
assert!(!text.contains("/OP"), "{how}: no overprint in the file");
assert_eq!(text.contains("/Multiply"), multiplies, "{how}");
assert!(!r.warnings.iter().any(|w| w.contains("knockouts")), "{how}: {:?}", r.warnings);
}
// Separations honour overprints whatever the option says.
let seps = settings(json!({"output": {"mode": "separations"}, "advanced": {"overprints": "discard"}}));
assert!(overprints(&printed_document(&d, &seps)));
}
#[test]
fn a_cmyk_printer_profile_writes_device_cmyk_with_the_intent() {
let d = doc(&[(Color::rgb(1.0, 0.0, 0.0), false)]);
assert_eq!(colours(&job(&d, json!({})).bytes, 0), [("rg".to_string(), vec![1.0, 0.0, 0.0])], "as it is without a profile");
let to_cmyk = Cms::new(&ColorSettings { cmyk: GENERIC_CMYK.into(), ..cms::active_settings() }).unwrap();
for intent in [Intent::Perceptual, Intent::Saturation] {
let c = colours(&job(&d, json!({"color": {"profile": GENERIC_CMYK, "intent": intent}})).bytes, 0);
let ink = q8(&to_cmyk.srgb_to_cmyk([1.0, 0.0, 0.0], intent));
assert!(c.len() == 1 && c[0].0 == "k" && close(&c[0].1, &ink), "{intent:?}: {c:?} vs {ink:?}");
}
// CMYK colours keep their numbers with Preserve CMYK Numbers.
let d = doc(&[(Color::cmyk(0.1, 0.2, 0.3, 0.4), false)]);
let c = colours(&job(&d, json!({"color": {"profile": DEVICE_CMYK, "preserveNumbers": true}})).bytes, 0);
assert!(c[0].0 == "k" && close(&c[0].1, &q8(&[0.1, 0.2, 0.3, 0.4])), "{c:?}");
}
#[test]
fn separations_separate_with_a_cmyk_printer_profile() {
// A mid grey: naive device CMYK puts it all on the black plate; a press profile doesn't.
let d = doc(&[(Color::rgb(0.5, 0.5, 0.5), false)]);
let cyan = |profile: &str| {
let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": profile}}));
colours(&r.bytes, 0)
};
let device = cyan(DEVICE_CMYK);
let press = cyan(GENERIC_CMYK);
assert_ne!(device, press, "the profile separates");
// An RGB printer profile doesn't separate.
let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": cms::SRGB}}));
assert!(r.warnings.iter().any(|w| w.contains("RGB printer profile")), "{:?}", r.warnings);
}
+10 -6
View File
@@ -36,7 +36,7 @@ fn json_is_camel_case_and_partial_objects_keep_defaults() {
assert_eq!(s.compression.gray.downsample, Downsample::Bicubic);
assert_eq!(s.compression.gray.ppi, 300.0);
assert!(s.compression.compress_text);
for bad in [json!({"compatibility": "1.3"}), json!({"standard": "pdfX9"}), json!({"marks": {"trim": "yes"}})] {
for bad in [json!({"compatibility": "1.2"}), json!({"standard": "pdfX9"}), json!({"marks": {"trim": "yes"}})] {
assert!(serde_json::from_value::<PdfSettings>(bad.clone()).is_err(), "{bad}");
}
}
@@ -53,7 +53,9 @@ fn passwords_are_never_serialized() {
fn checks_refuse_what_the_writer_cant_honour() {
assert_eq!(PdfSettings::default().check(), Ok(()));
let refused = |v: serde_json::Value| settings(v).check().unwrap_err();
assert!(matches!(refused(json!({"standard": "pdfX4"})), PdfError::Unsupported(_)));
// PDF/X-4 is a PDF 1.6 standard: the default 1.7 is refused.
assert!(matches!(refused(json!({"standard": "pdfX4"})), PdfError::BadSetting(m) if m.contains("PDF 1.7")));
assert_eq!(settings(json!({"standard": "pdfX4", "compatibility": "1.6"})).check(), Ok(()));
// Passwords are written (the file is encrypted); a password with a standard is not.
assert_eq!(settings(json!({"security": {"openPassword": "x"}})).check(), Ok(()));
assert_eq!(settings(json!({"security": {"permissionsPassword": "x"}})).check(), Ok(()));
@@ -67,15 +69,14 @@ fn checks_refuse_what_the_writer_cant_honour() {
assert_eq!(settings(json!({"standard": "pdfA2b", "compatibility": "1.4"})).check(), Ok(()));
assert!(Compatibility::ALL.iter().all(|c| Standard::None.allows(*c)));
assert!(export(&doc(), &PdfOptions { settings: settings(json!({"standard": "pdfX1a"})), ..Default::default() }).is_err());
assert!(export(&doc(), &PdfOptions { settings: settings(json!({"standard": "pdfX1a", "compatibility": "1.4"})), ..Default::default() }).is_ok());
}
#[test]
fn options_not_applied_yet_come_back_as_warnings() {
assert!(PdfSettings::default().warnings().is_empty());
for (v, word) in [
(json!({"thumbnails": true}), "thumbnails"),
(json!({"fastWebView": true}), "fast web view"),
(json!({"createLayers": true}), "layers"),
(json!({"createLayers": true, "compatibility": "1.4"}), "PDF 1.5"),
(json!({"advanced": {"outlineText": false, "fontSubsetPercent": 35}}), "subset"),
(json!({"output": {"outputIntent": "No Such Press"}}), "without embedding"),
(json!({"output": {"registry": "http://example.com"}}), "condition identifier"),
@@ -98,7 +99,9 @@ fn options_not_applied_yet_come_back_as_warnings() {
json!({"bleed": {"useDocument": true, "top": 9}}),
json!({"bleed": {"top": 9}, "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}),
json!({"includeNonPrinting": true}),
json!({"createLayers": true}),
json!({"viewAfterSaving": true}),
json!({"thumbnails": true, "fastWebView": true}),
json!({"compression": {"compressText": false}}),
json!({"compression": {"color": {"compression": "jpeg"}, "mono": {"compression": "ccittG4"}}}),
] {
@@ -107,6 +110,7 @@ fn options_not_applied_yet_come_back_as_warnings() {
let r =
export_with_report(&doc(), &PdfOptions { settings: settings(json!({"thumbnails": true, "marks": {"trim": true}})), ..Default::default() })
.unwrap();
assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
// The writer embeds the thumbnails it is given: without them, the one warning says so.
assert!(r.warnings.len() == 1 && r.warnings[0].contains("thumbnails need the pages drawn"), "{:?}", r.warnings);
assert!(r.bytes.starts_with(b"%PDF-1.7"));
}
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more