mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 20:56:47 -06:00
Merge branch 'main' into fix/776-unknown-params
# Conflicts: # ROADMAP.md # crates/engine/src/cmd/file.rs # crates/engine/src/lib.rs
This commit is contained in:
Generated
+11
-3
@@ -796,6 +796,15 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -1082,6 +1091,7 @@ dependencies = [
|
||||
name = "designcraft-pdf"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
"designcraft-doc",
|
||||
@@ -1092,7 +1102,6 @@ dependencies = [
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
"krilla",
|
||||
"krilla-svg",
|
||||
"kurbo",
|
||||
"log",
|
||||
@@ -2438,8 +2447,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
|
||||
+4
-2
@@ -11,6 +11,8 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/designcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared writer; the immutable revision must be published before this consumer.
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
# internal
|
||||
designcraft-geom = { path = "crates/geom" }
|
||||
@@ -57,12 +59,11 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
|
||||
tiff = "0.11"
|
||||
# SVG (same usvg as krilla-svg).
|
||||
resvg = { version = "0.47", default-features = false, features = ["text"] }
|
||||
krilla-svg = "0.8"
|
||||
krilla-svg = "=0.8.1"
|
||||
# Boolean operations on Bézier paths (Pathfinder).
|
||||
flo_curves = "0.8"
|
||||
# QR codes (Object › Generate QR Code); MIT.
|
||||
qrcodegen = "1.8"
|
||||
krilla = { version = "0.8", features = ["pdf"] }
|
||||
hayro = "0.7"
|
||||
quick-xml = "0.38"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -100,6 +101,7 @@ debug = 0
|
||||
|
||||
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
|
||||
winit = { git = "https://github.com/skylartaylor/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
|
||||
# The hayro crates from pdfcraft's vendor/ directory, with the patches its README lists.
|
||||
hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" }
|
||||
|
||||
@@ -52,6 +52,12 @@ list (author, source, license) is in ASSETS.md.
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
- Norwegian hyphenation patterns, embedded through the hypher crate (feature "norwegian"):
|
||||
hyph-no.tex from hyph-utf8, https://github.com/hyphenation/tex-hyphen.
|
||||
Copyright (C) Rune Kleveland and Ole Michael Selberg. Copying and distribution of this file,
|
||||
with or without modification, is permitted in any medium without royalty provided the copyright
|
||||
notice and this notice are preserved (additionally distributed under the LaTeX Project Public
|
||||
License in CTAN distributions).
|
||||
- Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official
|
||||
releases) embed the fonts listed in
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
|
||||
|
||||
@@ -141,6 +141,7 @@ The file stops growing at 16 MiB. `--version` writes no file.
|
||||
|---|---|
|
||||
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for DesignCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,designcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`designcraft*=debug`). |
|
||||
| `WGPU_BACKEND` | The graphics backend that composites the window: `dx12`, `vulkan`, `gl` or `metal` (a comma-separated list lets wgpu choose among them). Default: DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, with OpenGL as the fallback. Setting it also turns off the start-up fallback described below. |
|
||||
| `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. DesignCraft draws nothing indirectly and turns it off, since the compute shader the check needs fails to compile on some drivers (Metal on an Intel Iris Pro), which lost the device before the window opened. |
|
||||
|
||||
The logger is `apps/designcraft/src/logging.rs`; the web build logs to the browser console instead.
|
||||
|
||||
@@ -222,6 +223,7 @@ Installers and executables are code-signed.
|
||||
| Flatpak | `designcraft-<ver>-linux-x86_64.flatpak` | `designcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `designcraft-<ver>-linux-x86_64.deb` | `designcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `designcraft-<ver>-linux-x86_64.rpm` | `designcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Arch Linux/Manjaro | `designcraft-<ver>-linux-x86_64.pkg.tar.zst` | `designcraft-<ver>-linux-aarch64.pkg.tar.zst` | `sudo pacman -U <file>`; installs as `ai.storyteller.designcraft`, replacing an AUR package |
|
||||
| Tarball | `designcraft-<ver>-linux-x86_64.tar.gz` | `designcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
RISC-V (riscv64): `designcraft-<ver>-linux-riscv64.tar.gz` only, cross-compiled; needs glibc 2.39+ (Ubuntu 24.04 or newer).
|
||||
|
||||
+42
-1
@@ -5,6 +5,9 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text stroke behind fill) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Links source paths) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (table anchors keep their own paragraphs) · **Target:** Adobe InDesign 2026
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
|
||||
format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
@@ -100,7 +103,7 @@ Interface strings translated, of 1,724 measured. Detail: [docs/localization-pari
|
||||
| Korean | ko | none | 0% |
|
||||
| Vietnamese | vi | none | 0% |
|
||||
|
||||
Also shipped: Italian (85%), Ukrainian (83%).
|
||||
Also shipped: Italian (85%), Ukrainian (83%), Georgian (100%).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -132,6 +135,14 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change.
|
||||
- **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change.
|
||||
- **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change.
|
||||
- **2026-10-14** · The Color panel's Tint field keeps what is typed while it has focus and applies it on Enter or when it loses focus, clamped to 0–100 %. Typing 60 over 100 % no longer snaps back to 100 after the first digit (10060), and a typed tint is no longer ignored. No readiness percentage change.
|
||||
- **2026-10-15** · IDML: ruler guides import and export (they were dropped). Each page's guides come in measured from the document's ruler origin (spread, page or spine), with Fit to Page, Locked and their layer, parent pages' included, and go out again under their pages. No readiness percentage change.
|
||||
- **2026-10-15** · Find Font no longer reports a style missing when it differs from the installed one only in spacing or case (InDesign files can name `Regular ` with a trailing space); the text was already drawn in the right face. No readiness percentage change.
|
||||
- **2026-10-15** · A line of text is set when its baseline fits in the frame, as in InDesign: descenders may hang below the frame's bottom. A frame sized to the cap height of a big heading (103 pt in a 2.77 cm frame, as Zero2Graphic's magazine tutorial draws them) no longer oversets. No readiness percentage change.
|
||||
- **2026-10-15** · A story that ends with a return is no longer overset when only its empty last paragraph doesn't fit, as in InDesign (Preflight said "Overset text: 0 characters" for a caption InDesign shows with no errors). No readiness percentage change.
|
||||
- **2026-10-13** · Commands refuse parameter keys their params description doesn't name (#776): the control channel, MCP (`-32602`, every `batch` step checked before the first runs) and `designcraft-cli --cmd` answer with an “unknown parameter” error naming the nearest or the accepted keys instead of running with defaults; journals still replay. Descriptions of the table, anchored-object, data-merge and placing commands now name every key they read. No readiness percentage change.
|
||||
- **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change.
|
||||
- **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change.
|
||||
@@ -145,6 +156,19 @@ Newest first.
|
||||
- **2026-10-14** · With the Page tool, a guide dragged from a ruler is a liquid guide (dashed), as in InDesign; guide-based pages stretch the objects it crosses. No readiness percentage change.
|
||||
- **2026-10-14** · The Pages panel menu follows InDesign's order: Insert Pages…, Move Pages…, Duplicate Spread (and at End of Document), Delete Page/Spread, New Parent…, Apply Parent to Pages, Override All Parent Page Items, Parent Pages, Create Alternate Layout…, Numbering & Section Options…, Allow Selected Spread to Shuffle. No readiness percentage change.
|
||||
- **2026-10-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. No readiness percentage change.
|
||||
- **2026-10-12 (PDFW)** · PDF, booklet and book composition share guarded writing, metadata and font preparation through craft-pdf. Layout, shaping, placed graphics, colour policy, tagging and PDF/X postprocessing remain in DesignCraft. Failed native drawing returns an export error; failed writer cleanup cannot serialize an incomplete document. No readiness percentage change.
|
||||
|
||||
- **2026-10-13** · Copy with a placed graphic selected (Direct Selection) copies just the graphic, uncropped, where it sits on the page and without the frame's stroke, so Paste Into puts the whole image in place; Cut and Delete then remove only the graphic and leave its frame empty. Nested selections copy in their spread coordinates. No readiness percentage change.
|
||||
- **2026-10-13** · Object effects (Basic Feather, Inner Glow, shadows, glows, bevel, satin, directional and gradient feathers) apply to frames with pasted-in content, on screen and in image exports: the frame, its clipped content and its stroke are one object to the effect. PDF export still has no soft effects for any object. No readiness percentage change.
|
||||
- **2026-10-13** · Links rows show their source paths; selecting a link shows its full path wrapped inside the panel, so same-named files from different folders can be distinguished. No readiness percentage change.
|
||||
- **2026-10-13** · The Text Wrap panel is laid out as InDesign's: the wrap buttons with Invert, and the four offsets (Top, Left, Bottom, Right) showing the object's values, with the link button that sets them all the same. It used to show a single offset, always 0. No readiness percentage change.
|
||||
- **2026-10-13** · Radial gradients stretch with the object's box, as InDesign draws them: by default an ellipse reaching the frame's sides (it was a circle reaching only the longer side), and a Gradient Swatch tool drag keeps the box's proportions; on screen and in PDF. No readiness percentage change.
|
||||
- **2026-10-13** · The Window menu follows InDesign's order (Arrange, Workspace, then the panels alphabetically with Color, Editorial, Object & Layout, Styles… submenus), and Window › Workspace lists the workspaces as InDesign does, now with Essentials Classic: the Control panel and an icon column of named panel groups. No readiness percentage change.
|
||||
- **2026-10-14** · In a facing document, pages take their side from their section's page numbers, as InDesign shuffles them: a section that starts at 1 on a left page moves it to the right and the pages after it follow, with their objects. No readiness percentage change.
|
||||
- **2026-10-14** · The Type menu follows InDesign's order (Size, Character ⌘T, Paragraph ⌥⌘T, Glyphs, the style panels, Notes, footnotes and endnotes, then the insert submenus and Show Hidden Characters); Current Page Number has ⌥⇧⌘N. No readiness percentage change.
|
||||
- **2026-10-14** · Colour parameters: a bare `{c,m,y,k}` is always read as 0..100, so C 1 is a 1 % tint and no longer
|
||||
solid cyan; fractions take the colour's own `{model: "cmyk", …}` form (#910). No readiness percentage change.
|
||||
- **2026-10-14** · The Tools panel follows InDesign's: Gradient Swatch and Gradient Feather are tools of their own; in two columns Content Collector and Placer, Apply Color / Gradient / None and Normal / Preview each get a button; View Options holds Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid and Hidden Characters; Apply Color (,) and Apply Gradient (.) reuse the last colour and gradient. No readiness percentage change.
|
||||
- **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load
|
||||
the text cursor, then click a frame to thread it, click empty space for a column-wide frame or
|
||||
drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the
|
||||
@@ -152,9 +176,21 @@ Newest first.
|
||||
`text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers
|
||||
refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No
|
||||
readiness percentage change.
|
||||
- **2026-10-14** · Georgian interface (`ka`, Edit ▸ Interface Language ▸ ქართული): every catalog key
|
||||
translated, count captions as `noun: n`.
|
||||
- **2026-10-13** · Layout ▸ Table of Contents opens a real dialog: title and title style, Include Paragraph Styles and Other Styles lists with Add and Remove, and per style its entry style, page number position (after, before, none), separator and level. Replace Existing regenerates the current table of contents in place; otherwise OK makes a new frame on the page in view. IDML export writes the definition as a TOC style that import reads back, so Update works after a round trip. No readiness percentage change.
|
||||
- **2026-10-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. No readiness percentage change.
|
||||
- **2026-10-13** · Backspace between two consecutive tables, pasting a table next to a table anchor, and pasting several paragraphs right before one keep every table on its own anchor paragraph. Previously a table was dropped or left without its anchor, and the saved file failed to reopen. No readiness percentage change.
|
||||
- **2026-10-13** · Dialogs and the About window move when you drag their title row, so the page behind them can be seen. A dragged dialog may go partly off-screen, but its title row stays reachable; each dialog opens centred, and again after the window is resized. No readiness percentage change.
|
||||
- **2026-10-13** · Command scripts look up only a well-formed `$N.path` / `$last.path` reference; other strings starting with `$`, such as a GREP replacement of `$1` and a nonbreaking space, stay as written, and `$$` writes a literal `$` (#749). No readiness percentage change.
|
||||
- **2026-10-13** · An explicit recovery folder (`file.recovery.save` with `dir`) never overwrites or deletes another running app's checkpoints. Document uids start at 1 in every process, so two apps saving recovery to one chosen folder replaced or removed each other's `<uid>.designcraft`. A run replaces or removes only the entries it wrote or recovered there, refuses an occupied uid, and refuses a folder whose run lock another app holds. No readiness percentage change.
|
||||
- **2026-10-13** · Path edits (`path.setAnchor`, `path.moveAnchors`, `path.appendAnchor`, `path.reverse`, `path.close`, anchor add/delete/convert, Erase, Smooth and Scissors) refuse a path that is locked, sits in a locked group or on a locked layer (#735). Properties links to the Stroke, Character, Paragraph, Effects, Text Wrap and Info panels open them in a customized panel layout too (#736). No readiness percentage change.
|
||||
- **2026-10-13** · IDML export keeps parent item overrides: each page lists its overridden parent items and the local copy keeps its link to the parent item, so a reopened IDML file shows only the local copy (#725). No readiness percentage change.
|
||||
- **2026-10-13** · macOS: a command chosen with the mouse in the menu bar runs at once. It used to wait for the next mouse move or key press over the window, so File › Open, Place and Save As seemed to take 10–20 seconds to open their dialogs. No readiness percentage change.
|
||||
- **2026-10-13** · Text recomposes when the document baseline grid changes, when a layer holding a text-wrap object is shown or hidden, or when a section's start number, numbering style, prefix or marker changes; the composition cache keyed only on the section count and item identities and kept the old layout. No readiness percentage change.
|
||||
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
|
||||
- **2026-10-12** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change.
|
||||
- **2026-10-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. No readiness percentage change.
|
||||
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
|
||||
- **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change.
|
||||
@@ -163,6 +199,7 @@ Newest first.
|
||||
- **2026-10-12** · The Pen tool continues an open path from either end point, as in InDesign: the cursor shows it over an end point, a click there carries on drawing that path, a drag first reshapes the end point's direction lines, and the other end closes it. Object › Paths › Reverse Path (`path.reverse`); `path.setAnchor`; `path.appendAnchor` and `path.close` take a subpath. No readiness percentage change.
|
||||
- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change.
|
||||
- **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change.
|
||||
- **2026-10-12** · The Eyedropper puts a colour sampled from an image on the stroke when the toolbox's Stroke box is in front, as in InDesign (a line takes it on its stroke). No readiness percentage change.
|
||||
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
|
||||
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
|
||||
@@ -179,7 +216,11 @@ Newest first.
|
||||
- **2026-10-12** · The Effects panel shows the level its settings apply to ("Group: Color Dodge 100%") and, as in InDesign, Isolate Blending and Knockout Group for a selected group. No readiness percentage change.
|
||||
- **2026-10-11** · Properties gains InDesign's Pathfinder section (Add, Subtract, Intersect, Exclude Overlap, Minus Back) with several objects selected; section titles and icon buttons have accessible names. No readiness percentage change.
|
||||
- **2026-10-12** · The Eyedropper samples a placed image: clicking the photo with text (or objects) selected fills them with the image's colour under the pointer, as an unnamed colour (`color.sample`), as in InDesign. Images on locked layers can be sampled. No readiness percentage change.
|
||||
- **2026-10-12** · The section marker character shows its section's marker text on document pages (nothing when the section has none) instead of the word "Section", which stays on parent pages; editing the marker recomposes (#556). No readiness percentage change.
|
||||
- **2026-10-12** · On screen, blend modes blend with the objects below and not with the paper, as in InDesign and in PDF export: a Color Dodge or Screen object over blank paper shows as it is. No readiness percentage change.
|
||||
- **2026-10-12** · A gradient feather on a group fades the group as one, on screen and in PDF. Before, the screen ignored it, and PDF masked the group with a box of zero size because a group has no path of its own. No readiness percentage change.
|
||||
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
|
||||
- **2026-10-12** · Load Swatches refuses an `.ase` file with more than 100,000 colors, and `swatch.load` merges a file of 100,000 colors in under 0.1 s (it took 9.6 s of CPU in a test build). No readiness percentage change.
|
||||
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
|
||||
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.
|
||||
- **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
|
||||
//! flag must not silently remove a root and leave the server unconfined.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "doc.designcraft".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--sample", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(args.next().as_deref(), Some("doc.designcraft"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_leave_roots_unchanged() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
//! # run a command script (crates/engine/src/script.rs): `$N.path` references
|
||||
//! designcraft-cli app [--port PORT] COMMAND [JSON] # run a command in the running app (designcraft --control PORT)
|
||||
//! designcraft-cli app [--port PORT] --method METHOD [JSON] # any control-channel method (ui.screenshot, ui.render, …)
|
||||
//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md)
|
||||
//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! # MCP server over stdio (docs/mcp.md); the roots confine the files it reads and writes
|
||||
//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document
|
||||
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
|
||||
//! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks
|
||||
@@ -30,13 +31,14 @@ macro_rules! outln {
|
||||
}};
|
||||
}
|
||||
|
||||
mod automation_args;
|
||||
mod perf;
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).
|
||||
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
|
||||
const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links\n designcraft-cli --version";
|
||||
|
||||
/// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md.
|
||||
const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
|
||||
@@ -55,6 +57,8 @@ References to earlier results (in string parameters):
|
||||
comments and blank lines are not steps. `$last` is the previous step.
|
||||
Paths go through result fields and array indices: "$1.story", "$3.ids.0".
|
||||
"${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}".
|
||||
Any other string starting with `$` stays as written ("$1-$2", "$5 off").
|
||||
"$$" a literal `$` anywhere: "$$1" passes `$1` (a GREP group), "$${0.id}" passes `${0.id}`.
|
||||
|
||||
Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab.
|
||||
Most commands ignore parameters they do not know instead of rejecting them: check names
|
||||
@@ -160,16 +164,31 @@ fn validate_pdf(path: Option<&str>) -> Result<(), String> {
|
||||
/// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app
|
||||
/// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr.
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
use designcraft_engine::file_access::{self, AutomationRoots};
|
||||
use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
|
||||
let mut connect: Option<String> = None;
|
||||
let mut sample = false;
|
||||
let mut it = args.iter();
|
||||
let mut roots_args = automation_args::AutomationArgs::new(None, None);
|
||||
let mut it = args.iter().cloned();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?),
|
||||
"--sample" => sample = true,
|
||||
other => return Err(format!("unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample])")),
|
||||
if roots_args.consume(&a, &mut it)? {
|
||||
continue;
|
||||
}
|
||||
match a.as_str() {
|
||||
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
|
||||
"--sample" => sample = true,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR])"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||||
so drop --connect (or use --connect without the roots)"
|
||||
.into());
|
||||
}
|
||||
let backend: Box<dyn Backend> = match connect {
|
||||
Some(c) => {
|
||||
@@ -181,9 +200,17 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
None => {
|
||||
let mut h = Headless::with_document();
|
||||
// `--sample` is the operator's own choice, made before the roots come into force.
|
||||
if sample {
|
||||
h.session.execute("file.newSample", &json!({})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("designcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
Box::new(h)
|
||||
}
|
||||
};
|
||||
@@ -263,7 +290,11 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
"--page" => page = val()?.parse().map_err(|_| "bad --page")?,
|
||||
"--scale" => scale = val()?.parse().map_err(|_| "bad --scale")?,
|
||||
"--pdf-options" => {
|
||||
pdf_opts = serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))?;
|
||||
pdf_opts = match serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))? {
|
||||
Value::Null => json!({}),
|
||||
o @ Value::Object(_) => o,
|
||||
_ => return Err("--pdf-options: expected a JSON object".to_string()),
|
||||
};
|
||||
}
|
||||
"--export" => {
|
||||
let out = val()?;
|
||||
|
||||
@@ -154,6 +154,24 @@ fn run_export_options_come_before_their_export() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `--pdf-options` must be a JSON object; any other JSON value used to panic when the PDF path was set on it.
|
||||
#[test]
|
||||
fn pdf_options_that_are_not_an_object_are_an_error_not_a_panic() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-pdfopts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pdf = dir.join("out.pdf");
|
||||
let pdf = pdf.to_str().unwrap();
|
||||
for val in ["[]", "true", "1", r#""x""#] {
|
||||
let out = cli().args(["run", "--sample", "--pdf-options", val, "--export", pdf]).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "{val}: {err}");
|
||||
assert!(err.contains("--pdf-options") && !err.contains("panicked"), "{val}: {err}");
|
||||
}
|
||||
let out = cli().args(["run", "--sample", "--pdf-options", "null", "--export", pdf]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn raster_export_preserves_an_existing_reader_and_private_symlink_target() {
|
||||
@@ -185,3 +203,124 @@ fn raster_export_preserves_an_existing_reader_and_private_symlink_target() {
|
||||
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert!(std::fs::symlink_metadata(alias).unwrap().file_type().is_symlink());
|
||||
}
|
||||
|
||||
/// Send `calls` (tool name, arguments) to `designcraft-cli mcp <args>`; returns whether it exited
|
||||
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
|
||||
fn mcp_session(args: &[&str], calls: &[(&str, serde_json::Value)]) -> (bool, String, Vec<serde_json::Value>) {
|
||||
let mut child = cli().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
for (i, (name, arguments)) in calls.iter().enumerate() {
|
||||
let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
|
||||
writeln!(stdin, "{call}").unwrap();
|
||||
}
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
(out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies)
|
||||
}
|
||||
|
||||
fn text(reply: &serde_json::Value) -> &str {
|
||||
reply["result"]["content"][0]["text"].as_str().unwrap()
|
||||
}
|
||||
|
||||
/// A `.designcraft` file with one page, made with the CLI itself.
|
||||
fn make_document(path: &std::path::Path) {
|
||||
let out = cli().args(["run", "--cmd", "file.new={}", "--cmd"]).arg(format!("file.save={}", serde_json::json!({"path": path}))).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_roots_confine_the_server() {
|
||||
let base = std::env::temp_dir().join(format!("dc-cli-roots-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (work, elsewhere) = (base.join("work"), base.join("elsewhere"));
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
make_document(&work.join("in.designcraft"));
|
||||
make_document(&elsewhere.join("secret.designcraft"));
|
||||
let work_s = work.to_str().unwrap();
|
||||
let secret = elsewhere.join("secret.designcraft").to_str().unwrap().to_string();
|
||||
|
||||
// Both spellings of the flags; inside works, outside is refused with a readable message.
|
||||
let read_flag = format!("--automation-read-root={work_s}");
|
||||
let calls = [
|
||||
("open_document", serde_json::json!({"path": work.join("in.designcraft").to_str().unwrap()})),
|
||||
("open_document", serde_json::json!({"path": secret})),
|
||||
("save_document", serde_json::json!({"path": work.join("saved.designcraft").to_str().unwrap()})),
|
||||
("save_document", serde_json::json!({"path": elsewhere.join("saved.designcraft").to_str().unwrap()})),
|
||||
("export_png", serde_json::json!({"path": elsewhere.join("page.png").to_str().unwrap()})),
|
||||
("render_page", serde_json::json!({"path": elsewhere.join("render.png").to_str().unwrap()})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls);
|
||||
assert!(success, "{stderr}");
|
||||
assert!(stderr.contains("files confined"), "{stderr}");
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
|
||||
assert_eq!(replies[1]["result"]["isError"], true);
|
||||
assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1]));
|
||||
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
|
||||
assert!(work.join("saved.designcraft").exists());
|
||||
for reply in &replies[3..] {
|
||||
assert_eq!(reply["result"]["isError"], true, "{reply}");
|
||||
assert!(text(reply).contains("outside the write root"), "{}", text(reply));
|
||||
}
|
||||
for name in ["saved.designcraft", "page.png", "render.png"] {
|
||||
assert!(!elsewhere.join(name).exists(), "{name} was written outside the write root");
|
||||
}
|
||||
|
||||
// A root left out grants none of its access.
|
||||
let save = [("new_document", serde_json::json!({})), calls[2].clone()];
|
||||
let (_, _, replies) = mcp_session(&[&read_flag], &save);
|
||||
assert_eq!(replies[1]["result"]["isError"], true);
|
||||
assert!(text(&replies[1]).contains("write authority is absent"), "{}", text(&replies[1]));
|
||||
let (_, _, replies) = mcp_session(&["--automation-write-root", work_s], &calls[..1]);
|
||||
assert_eq!(replies[0]["result"]["isError"], true);
|
||||
assert!(text(&replies[0]).contains("read authority is absent"), "{}", text(&replies[0]));
|
||||
|
||||
// No flags: nothing is confined.
|
||||
let (success, _, replies) = mcp_session(&[], &calls[1..2]);
|
||||
assert!(success);
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
let base = std::env::temp_dir().join(format!("dc-cli-roots-args-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let dir = base.to_str().unwrap();
|
||||
let missing = base.join("missing");
|
||||
let fails = |args: &[&str], needle: &str| {
|
||||
let mut full = vec!["mcp"];
|
||||
full.extend_from_slice(args);
|
||||
let o = cli().args(&full).output().unwrap();
|
||||
assert!(!o.status.success(), "{args:?}");
|
||||
let err = String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
assert!(err.contains(needle), "{args:?}: {err}");
|
||||
};
|
||||
// The flags confine the in-process server, so they cannot be combined with --connect.
|
||||
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root=x"], "given twice");
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--sample"], "needs a folder");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root="], "needs a non-empty folder");
|
||||
// A file is not a folder.
|
||||
std::fs::write(base.join("f.txt"), "x").unwrap();
|
||||
fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_lists_the_automation_root_flags() {
|
||||
let out = cli().arg("--help").output().unwrap();
|
||||
let usage = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(usage.contains("--automation-read-root DIR") && usage.contains("--automation-write-root DIR"), "{usage}");
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ impl eframe::App for App {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.menu = Some(native_menu::NativeMenu::install(&mut self.app));
|
||||
self.menu = Some(native_menu::NativeMenu::install(&mut self.app, ctx));
|
||||
}
|
||||
if let Some(m) = &mut self.menu {
|
||||
m.poll(&mut self.app, ctx);
|
||||
@@ -290,6 +290,14 @@ fn main() -> eframe::Result {
|
||||
{
|
||||
create.instance_descriptor.backends = s.backends();
|
||||
}
|
||||
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
|
||||
// compute shader when the device is created, one that Metal can't compile on an Intel Iris
|
||||
// Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns
|
||||
// the check back on.
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.instance_descriptor.flags =
|
||||
create.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
}
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping images from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; DESIGNCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use designcraft_ui_egui::DesignApp;
|
||||
use designcraft_ui_egui::menus::{self, Item};
|
||||
@@ -19,6 +20,7 @@ enum Handle {
|
||||
pub struct NativeMenu {
|
||||
_menu: Menu,
|
||||
items: HashMap<String, (String, Value, Handle)>,
|
||||
events: Receiver<MenuEvent>,
|
||||
last_refresh: f64,
|
||||
}
|
||||
|
||||
@@ -31,7 +33,15 @@ fn accel(sc: &str) -> Option<Accelerator> {
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
pub fn install(app: &mut DesignApp) -> Self {
|
||||
pub fn install(app: &mut DesignApp, ctx: &egui::Context) -> Self {
|
||||
// A click in the menu bar reaches the app with no input over the window, so nothing would
|
||||
// draw the frame that runs the command until the pointer or a key comes back: wake egui.
|
||||
let (tx, events) = channel();
|
||||
let repaint = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
|
||||
let _ = tx.send(e);
|
||||
repaint.request_repaint();
|
||||
}));
|
||||
let menu = Menu::new();
|
||||
let mut items = HashMap::new();
|
||||
let mut counter = 0usize;
|
||||
@@ -72,11 +82,11 @@ impl NativeMenu {
|
||||
(p.is_null() && accel(sc).is_some()).then(|| id.clone())
|
||||
})
|
||||
.collect();
|
||||
Self { _menu: menu, items, last_refresh: 0.0 }
|
||||
Self { _menu: menu, items, events, last_refresh: 0.0 }
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, app: &mut DesignApp, ctx: &egui::Context) {
|
||||
while let Ok(ev) = MenuEvent::receiver().try_recv() {
|
||||
while let Ok(ev) = self.events.try_recv() {
|
||||
if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) {
|
||||
menus::activate_native(app, ctx, &cmd.clone(), ¶ms.clone());
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
@@ -30,3 +34,11 @@ sync_github_name = true
|
||||
|
||||
[people.thaynej]
|
||||
real_name = "Jeffrey Thayne"
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
[people.Nmarello]
|
||||
real_name = "Nick Marello"
|
||||
display_name = "Nick Marello"
|
||||
sync_github_name = false
|
||||
|
||||
+24
-1
@@ -11,8 +11,12 @@ pub enum AseError {
|
||||
Signature,
|
||||
#[error("the file ends early")]
|
||||
Truncated,
|
||||
#[error("the file holds more than {MAX_COLORS} colors")]
|
||||
TooManyColors,
|
||||
}
|
||||
|
||||
/// The most colors [`read`] accepts in one file.
|
||||
pub const MAX_COLORS: usize = 100_000;
|
||||
const COLOR: u16 = 0x0001;
|
||||
#[cfg(test)]
|
||||
const GROUP_START: u16 = 0xC001;
|
||||
@@ -56,6 +60,7 @@ impl R<'_> {
|
||||
}
|
||||
|
||||
/// The colour swatches in an `.ase` file (groups are flattened).
|
||||
/// A file with more than [`MAX_COLORS`] colors is an error.
|
||||
pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, AseError> {
|
||||
let mut r = R { b: bytes, i: 0 };
|
||||
if r.take(4)? != b"ASEF" {
|
||||
@@ -63,7 +68,7 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, AseError> {
|
||||
}
|
||||
r.take(4)?; // version
|
||||
let blocks = r.u32()?;
|
||||
let mut out = Vec::new();
|
||||
let (mut out, mut colors) = (Vec::new(), 0usize);
|
||||
for _ in 0..blocks {
|
||||
let kind = r.u16()?;
|
||||
let len = r.u32()? as usize;
|
||||
@@ -72,6 +77,10 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, AseError> {
|
||||
if kind != COLOR {
|
||||
continue;
|
||||
}
|
||||
colors += 1;
|
||||
if colors > MAX_COLORS {
|
||||
return Err(AseError::TooManyColors);
|
||||
}
|
||||
let mut b = R { b: body, i: 0 };
|
||||
let name = b.name()?;
|
||||
let model = b.take(4)?.to_vec();
|
||||
@@ -191,4 +200,18 @@ mod tests {
|
||||
let SwatchValue::Color { color, .. } = &w[0].value else { panic!() };
|
||||
assert!(color.to_rgb().iter().all(|v| *v > 0.98), "Lab white: {color:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_max_colors_is_an_error() {
|
||||
let gray = Swatch {
|
||||
name: "G".into(),
|
||||
value: SwatchValue::Color { color: Color::Gray { k: 0.5 }, color_type: ColorType::Process },
|
||||
locked: false,
|
||||
named: true,
|
||||
hidden: false,
|
||||
};
|
||||
let sw = vec![gray; MAX_COLORS + 1];
|
||||
assert_eq!(read(&write(&sw)).err(), Some(AseError::TooManyColors));
|
||||
assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,13 +230,6 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Load a `.icc`/`.icm` file from disk (native only).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_icc_file(path: &std::path::Path) -> Result<ProfileInfo, CmsError> {
|
||||
let bytes = std::fs::read(path).map_err(|e| CmsError::Io(format!("{}: {e}", path.display())))?;
|
||||
register_icc(&bytes, None)
|
||||
}
|
||||
|
||||
/// A colour-management context: resolved working spaces plus conversion settings.
|
||||
#[derive(Clone)]
|
||||
pub struct Cms {
|
||||
|
||||
@@ -188,10 +188,8 @@ fn bad_icc_rejected() {
|
||||
#[test]
|
||||
fn loads_system_cmyk_icc() {
|
||||
let path = std::path::Path::new("/System/Library/ColorSync/Profiles/Generic CMYK Profile.icc");
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
let info = load_icc_file(path).unwrap();
|
||||
let Ok(bytes) = std::fs::read(path) else { return };
|
||||
let info = register_icc(&bytes, None).unwrap();
|
||||
assert_eq!(info.kind, ProfileKind::Cmyk);
|
||||
assert!(profiles().iter().any(|p| p.name == info.name && !p.builtin));
|
||||
let c = Cms::new(&ColorSettings { cmyk: info.name.clone(), ..Default::default() }).unwrap();
|
||||
|
||||
@@ -19,7 +19,7 @@ designcraft-fonts = { workspace = true }
|
||||
log = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
miniz_oxide = "0.8"
|
||||
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish"] }
|
||||
hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish", "norwegian"] }
|
||||
|
||||
[dev-dependencies]
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -303,8 +303,17 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S
|
||||
let n = glyphs.len();
|
||||
let mut hy_cache = Vec::new();
|
||||
let mut last_space = NONE;
|
||||
// Spaces before the paragraph end or a forced break hang: they take no room on the line (as
|
||||
// in InDesign, where "2 VOOR " fits a frame as wide as "2 VOOR").
|
||||
let mut hangs = vec![false; n];
|
||||
for i in (0..n).rev() {
|
||||
hangs[i] = glyphs[i].is_space() && glyphs.get(i + 1).is_none_or(|nx| hangs[i + 1] || is_forced(nx.ch));
|
||||
}
|
||||
for (i, g) in glyphs.iter().enumerate() {
|
||||
let prev_hang = if i > 0 { right_hang(&glyphs[i - 1], sp) } else { 0.0 };
|
||||
if hangs[i] {
|
||||
continue;
|
||||
}
|
||||
if is_forced(g.ch) {
|
||||
it.push(Item::glue(0.0, INF, 0.0));
|
||||
ig.push(i);
|
||||
|
||||
@@ -47,7 +47,18 @@ fn doc_signature(doc: &Document) -> u64 {
|
||||
}
|
||||
(Arc::as_ptr(&doc.styles) as usize).hash(&mut h);
|
||||
format!("{:?}", doc.text_variables).hash(&mut h);
|
||||
sections_hash(doc).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Everything page names and section markers are made from.
|
||||
fn sections_hash(doc: &Document) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
doc.sections.len().hash(&mut h);
|
||||
for s in &doc.sections {
|
||||
(s.start, s.start_number, s.style as u8, &s.prefix, &s.marker, s.include_prefix).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
@@ -67,10 +78,18 @@ impl Cache {
|
||||
// The key holds the page's index, which a renumbering doesn't change: its name (section
|
||||
// start number, style, prefix) is part of the signature.
|
||||
let page_name = page.map(|p| doc.page_name(p));
|
||||
let section_marker = page.map(|p| crate::section_marker(doc, p));
|
||||
if let Some(name) = &page_name {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
name.hash(&mut h);
|
||||
(name, §ion_marker).hash(&mut h);
|
||||
sig.push(h.finish() as usize);
|
||||
}
|
||||
if story.text.contains(designcraft_doc::story::SECTION_MARKER) {
|
||||
// Frames on document pages show their section's marker text.
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
doc.sections.iter().for_each(|s| (s.start, &s.marker).hash(&mut h));
|
||||
sig.push(h.finish() as usize);
|
||||
}
|
||||
let with_vars = crate::vars::has_vars(story);
|
||||
@@ -144,7 +163,7 @@ impl Cache {
|
||||
let out = Arc::new(crate::compose_story(
|
||||
doc,
|
||||
sid,
|
||||
&ComposeOptions { page_name, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
|
||||
&ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
|
||||
));
|
||||
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let stamp = g.1;
|
||||
@@ -253,8 +272,17 @@ impl Cache {
|
||||
|
||||
fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>) {
|
||||
// The document's font scope: another document's fonts of the same name set it differently.
|
||||
let mut sig =
|
||||
vec![Arc::as_ptr(story) as usize, story.rev as usize, Arc::as_ptr(&doc.styles) as usize, doc.sections.len(), doc.font_scope as usize];
|
||||
// Sections name the pages under the frames (page-number and section markers).
|
||||
let g = &doc.settings.baseline_grid;
|
||||
let mut sig = vec![
|
||||
Arc::as_ptr(story) as usize,
|
||||
story.rev as usize,
|
||||
Arc::as_ptr(&doc.styles) as usize,
|
||||
sections_hash(doc) as usize,
|
||||
doc.font_scope as usize,
|
||||
g.start.to_bits() as usize,
|
||||
g.increment.to_bits() as usize,
|
||||
];
|
||||
let mut keep = Vec::new();
|
||||
let mut spreads = Vec::new();
|
||||
for f in &story.frames {
|
||||
@@ -273,6 +301,8 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
|
||||
for it in &sp.items {
|
||||
if it.wrap.mode != WrapMode::None {
|
||||
sig.push(Arc::as_ptr(it) as usize);
|
||||
// Wrap objects on hidden layers don't wrap text.
|
||||
sig.push(usize::from(doc.layer(it.layer).is_some_and(|l| l.visible)));
|
||||
keep.push(it.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Hyphenation. English uses the bundled dictionary and patterns below. Other languages use the
|
||||
//! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's
|
||||
//! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J.
|
||||
//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause (see NOTICE).
|
||||
//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause; Norwegian is
|
||||
//! `hyph-no.tex` from hyph-utf8 (Rune Kleveland, Ole Michael Selberg), permissive (see NOTICE).
|
||||
//!
|
||||
//! English lookup order for each word:
|
||||
//! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list
|
||||
@@ -411,6 +412,26 @@ mod tests {
|
||||
assert_eq!(h("pós-graduação"), "pós-gra-du-a-ção", "each part of a compound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norwegian_uses_the_hyph_no_patterns() {
|
||||
let nb = Lang::for_language("Norwegian: Bokmål").unwrap();
|
||||
assert_eq!(nb, Lang::for_language("Norwegian").unwrap());
|
||||
assert_eq!(nb, Lang::for_language("Norwegian: Nynorsk").unwrap());
|
||||
assert_eq!(nb, Lang::for_language("nb").unwrap());
|
||||
assert_eq!(nb, Lang::for_language("nn").unwrap());
|
||||
assert_eq!(nb, Lang::for_language("no").unwrap());
|
||||
assert_ne!(nb, Lang::for_language("Spanish").unwrap());
|
||||
let h = |w: &str| {
|
||||
let pts = hyphen_points_in(w, &Limits::default(), nb);
|
||||
w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::<String>()
|
||||
};
|
||||
assert_eq!(h("kaller"), "kal-ler");
|
||||
assert_eq!(h("bilde"), "bil-de");
|
||||
assert_eq!(h("sjøfugl"), "sjø-fugl");
|
||||
assert_eq!(h("sirkulasjonen"), "sir-ku-la-sjo-nen");
|
||||
assert_eq!(h("beveger"), "be-ve-ger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_fallback() {
|
||||
let c: Vec<char> = "happen".chars().collect();
|
||||
|
||||
@@ -50,6 +50,8 @@ pub struct RunStyle {
|
||||
pub stroke: String,
|
||||
pub stroke_tint: f32,
|
||||
pub stroke_weight: f64,
|
||||
/// The stroke goes under the fill at double width, so its full weight shows outside the glyphs.
|
||||
pub stroke_behind_fill: bool,
|
||||
pub underline: bool,
|
||||
pub strikethrough: bool,
|
||||
/// The underline and strikethrough bars (Underline / Strikethrough Options resolved).
|
||||
@@ -317,6 +319,8 @@ pub struct FrameSpec {
|
||||
pub vertical: bool,
|
||||
pub exclusions: Vec<Exclusion>,
|
||||
pub page_name: Option<String>,
|
||||
/// Marker text of the section the frame's page is in (None on parent pages).
|
||||
pub section_marker: Option<String>,
|
||||
/// Absolute document page the frame is on (None on parent pages).
|
||||
pub page: Option<usize>,
|
||||
/// Baseline grid in inner space: (first grid line y, increment).
|
||||
@@ -344,11 +348,18 @@ impl FrameSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker text of the section absolute page `abs` is in (empty when it has none).
|
||||
pub fn section_marker(doc: &Document, abs: usize) -> String {
|
||||
doc.section_of(abs).map(|s| s.marker.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Composition inputs that are not in the story itself.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ComposeOptions {
|
||||
/// Page name for page-number markers when the story is composed for a specific page (parent items).
|
||||
pub page_name: Option<String>,
|
||||
/// Section marker text when the story is composed for a specific page (parent items).
|
||||
pub section_marker: Option<String>,
|
||||
/// Absolute page for text variables when composed for a specific page (parent items).
|
||||
pub page: Option<usize>,
|
||||
/// Running-header index (needed when the story contains running-header variables).
|
||||
@@ -385,6 +396,13 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
|
||||
let b = other.bounds();
|
||||
let r = Rect::new(b.x0 - o[1], b.y0 - o[0], b.x1 + o[3], b.y1 + o[2]);
|
||||
let inner = inv.transform_rect_bbox(r);
|
||||
// Only objects that reach the frame wrap its text: one elsewhere on the spread
|
||||
// leaves it alone (a small frame beside it would otherwise lose its gaps to the
|
||||
// minimum gap width, see `free_slot`).
|
||||
let reach = inner.intersect(item.inner_bounds());
|
||||
if !(reach.width() > 0.0 && reach.height() > 0.0) {
|
||||
continue;
|
||||
}
|
||||
exclusions.push(Exclusion { rect: inner, mode: other.wrap.mode });
|
||||
}
|
||||
}
|
||||
@@ -402,6 +420,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
|
||||
}
|
||||
_ => (None, None, false),
|
||||
};
|
||||
let section_marker = page.map(|abs| section_marker(doc, abs));
|
||||
// The page under the frame and its margins, in inner space (bounding boxes).
|
||||
let page_rect = match (loc.spread, spread) {
|
||||
(_, Some(sp)) => sp
|
||||
@@ -454,6 +473,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
|
||||
vertical,
|
||||
exclusions: if tf.options.path.is_some() { vec![] } else { exclusions },
|
||||
page_name,
|
||||
section_marker,
|
||||
page,
|
||||
grid,
|
||||
left_page,
|
||||
@@ -637,7 +657,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
};
|
||||
let mut sub = SubstCtx {
|
||||
page_name: opts.page_name.clone().or_else(|| cur_frame.and_then(|f| f.page_name.clone())),
|
||||
section_marker: None,
|
||||
section_marker: opts.section_marker.clone().or_else(|| cur_frame.and_then(|f| f.section_marker.clone())),
|
||||
vars: var_values,
|
||||
hidden_conditions: doc.conditions.iter().filter(|c| !c.visible).map(|c| c.name.clone()).collect(),
|
||||
vertical: cur_frame.is_some_and(|f| f.vertical),
|
||||
@@ -688,9 +708,19 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
&[],
|
||||
(drop_end, &pp.drop_cap_style),
|
||||
);
|
||||
// An empty paragraph has no glyphs of its own, so its only addressable character is its
|
||||
// hidden paragraph mark (the `\n` just past `prange.end`). A leading/size override applied
|
||||
// there (e.g. selecting the pilcrow to adjust line height) still needs to set the line's
|
||||
// height, so resolve the mark's own run instead of the paragraph's plain default when present.
|
||||
let mark_chars = prange
|
||||
.is_empty()
|
||||
.then(|| story.runs().find(|(r, _)| r.contains(&prange.end)))
|
||||
.flatten()
|
||||
.map(|(_, fmt)| doc.styles.resolve_char(&base_chars, fmt));
|
||||
let leading_chars = mark_chars.as_ref().unwrap_or(&base_chars);
|
||||
// Drop cap: the size and indent come from the text beside it (`base_*` when there's none).
|
||||
let base_leading = match base_chars.leading {
|
||||
designcraft_doc::Leading::Auto => base_chars.size * pp.auto_leading,
|
||||
let base_leading = match leading_chars.leading {
|
||||
designcraft_doc::Leading::Auto => leading_chars.size * pp.auto_leading,
|
||||
designcraft_doc::Leading::Points(v) => v,
|
||||
};
|
||||
let base_cap = {
|
||||
@@ -936,6 +966,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
pi = j;
|
||||
continue 'paras;
|
||||
}
|
||||
// An empty last paragraph (the story ends with a return) isn't overset text, as in
|
||||
// InDesign: nothing of it is left to show.
|
||||
if prange.is_empty() && prange.start >= story.text.len() {
|
||||
break 'paras;
|
||||
}
|
||||
out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start));
|
||||
// The part of the paragraph that fits keeps its shading and border.
|
||||
para_box_decos(&mut out, pi, &pp);
|
||||
@@ -1024,7 +1059,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
let col_w = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)].width();
|
||||
let reserve = notes.reserve(doc, cur.fi, cur.col, &line_notes, col_w, f, opts);
|
||||
let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts) } else { reserve };
|
||||
let fits = baseline + desc <= col.y1 - reserve + 0.01 && !capped;
|
||||
// As in InDesign, a line is set when its baseline fits: its descenders may hang below the frame.
|
||||
let fits = baseline <= col.y1 - reserve + 0.01 && !capped;
|
||||
if !fits {
|
||||
if cur.can_next_sub() {
|
||||
g0 = s;
|
||||
@@ -2296,15 +2332,22 @@ fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump:
|
||||
None
|
||||
}
|
||||
|
||||
/// Widest free horizontal interval of `col` in the band, or None if blocked.
|
||||
/// Widest free horizontal interval of `col` in the band, or None if blocked. A gap a wrap object
|
||||
/// cuts narrower than 1.5 em of `size` is skipped (a sliver beside the object); when no object
|
||||
/// reaches the column in the band the whole column is free, however narrow (a one-character frame
|
||||
/// such as an article's closing tombstone, or a narrow column beside another that wraps).
|
||||
fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> {
|
||||
let mut free = vec![(col.x0, col.x1)];
|
||||
let mut cut = false;
|
||||
for ex in &f.exclusions {
|
||||
let r = ex.rect;
|
||||
let overlaps_band = r.y0 < y1 && r.y1 > y0;
|
||||
match ex.mode {
|
||||
WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None,
|
||||
WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => {
|
||||
// Held to the minimum width only when the object reaches this column: one beside
|
||||
// another column of the frame leaves this one whole, however narrow.
|
||||
cut |= r.x0 < col.x1 && r.x1 > col.x0;
|
||||
let mut next = Vec::new();
|
||||
for (a, b) in free {
|
||||
if r.x1 <= a || r.x0 >= b {
|
||||
@@ -2323,6 +2366,9 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !cut {
|
||||
return Some((col.x0, col.x1));
|
||||
}
|
||||
free.into_iter().filter(|(a, b)| b - a >= size * 1.5).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))
|
||||
}
|
||||
|
||||
@@ -3709,6 +3755,7 @@ mod column_allocation_safety_tests {
|
||||
vertical: false,
|
||||
exclusions: vec![],
|
||||
page_name: None,
|
||||
section_marker: None,
|
||||
page: None,
|
||||
grid: None,
|
||||
left_page: false,
|
||||
|
||||
+64
-21
@@ -5,6 +5,11 @@
|
||||
//! the first footnote and the space between footnotes). A line that would collide moves to the
|
||||
//! next column with its footnotes. After the story is laid out the footnotes of each column are
|
||||
//! stacked against the column bottom, with the separator rule above the first one.
|
||||
//!
|
||||
//! The footnotes of a column read as one text: the first footnote's first baseline sits at the
|
||||
//! First Baseline offset below the top of the footnote area, and each later footnote's first
|
||||
//! baseline sits its first line's leading (plus Space Between Footnotes) below the previous
|
||||
//! footnote's last baseline. Only the last footnote's descent counts toward the area's height.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
@@ -22,10 +27,38 @@ pub(crate) struct Placed {
|
||||
k: usize,
|
||||
fi: usize,
|
||||
col: usize,
|
||||
h: f64,
|
||||
m: NoteMetrics,
|
||||
text: Arc<ComposedStory>,
|
||||
}
|
||||
|
||||
/// Vertical metrics of a composed footnote, relative to the top of its composed text.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct NoteMetrics {
|
||||
/// First and last baselines (the first one includes the First Baseline offset).
|
||||
first: f64,
|
||||
last: f64,
|
||||
/// Leading of the first line: its baseline's distance below the previous footnote's last.
|
||||
lead: f64,
|
||||
/// Descent of the last line.
|
||||
descent: f64,
|
||||
}
|
||||
|
||||
/// Height of footnotes stacked baseline to baseline, from the top of the footnote area to the
|
||||
/// last footnote's descent.
|
||||
fn stack_height<'a>(notes: impl IntoIterator<Item = &'a NoteMetrics>, space_between: f64) -> f64 {
|
||||
let mut h: Option<f64> = None;
|
||||
let mut descent = 0.0;
|
||||
for m in notes {
|
||||
let last = match h {
|
||||
None => m.last,
|
||||
Some(prev) => prev + space_between + m.lead + (m.last - m.first),
|
||||
};
|
||||
h = Some(last);
|
||||
descent = m.descent;
|
||||
}
|
||||
h.map_or(0.0, |h| h + descent)
|
||||
}
|
||||
|
||||
pub(crate) struct Notes {
|
||||
/// Byte offsets of the reference characters (index = footnote index).
|
||||
refs: Vec<usize>,
|
||||
@@ -36,7 +69,7 @@ pub(crate) struct Notes {
|
||||
pub key: Option<usize>,
|
||||
pub placed: Vec<Placed>,
|
||||
/// Composed footnote text by (index, label, width).
|
||||
cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, f64)>,
|
||||
cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, NoteMetrics)>,
|
||||
notes: Vec<Arc<designcraft_doc::Footnote>>,
|
||||
}
|
||||
|
||||
@@ -88,7 +121,7 @@ impl Notes {
|
||||
v
|
||||
}
|
||||
|
||||
fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc<ComposedStory>, f64) {
|
||||
fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc<ComposedStory>, NoteMetrics) {
|
||||
let o = &doc.footnote_options;
|
||||
let label = format!("{}{}", o.label(self.numbers[k], true), o.separator);
|
||||
let key = (k, label.clone(), w.to_bits());
|
||||
@@ -102,6 +135,7 @@ impl Notes {
|
||||
vertical: false,
|
||||
exclusions: vec![],
|
||||
page_name: f.page_name.clone(),
|
||||
section_marker: f.section_marker.clone(),
|
||||
page: f.page,
|
||||
grid: None,
|
||||
left_page: f.left_page,
|
||||
@@ -109,14 +143,19 @@ impl Notes {
|
||||
};
|
||||
let nopts = ComposeOptions {
|
||||
page_name: opts.page_name.clone(),
|
||||
section_marker: opts.section_marker.clone(),
|
||||
page: opts.page,
|
||||
running: opts.running.clone(),
|
||||
label: Some(label),
|
||||
xrefs: opts.xrefs.clone(),
|
||||
};
|
||||
let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts);
|
||||
let h = cs.frames.first().and_then(|ft| ft.lines.last()).map_or(0.0, |l| l.baseline + l.descent);
|
||||
let v = (Arc::new(cs), h);
|
||||
let lines = cs.frames.first().map(|ft| ft.lines.as_slice()).unwrap_or_default();
|
||||
let m = match (lines.first(), lines.last()) {
|
||||
(Some(a), Some(z)) => NoteMetrics { first: a.baseline, last: z.baseline, lead: a.leading, descent: z.descent },
|
||||
_ => NoteMetrics::default(),
|
||||
};
|
||||
let v = (Arc::new(cs), m);
|
||||
self.cache.insert(key, v.clone());
|
||||
v
|
||||
}
|
||||
@@ -125,25 +164,20 @@ impl Notes {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn reserve(&mut self, doc: &Document, fi: usize, col: usize, extra: &[usize], w: f64, f: &FrameSpec, opts: &ComposeOptions) -> f64 {
|
||||
let o = &doc.footnote_options;
|
||||
let mut n = 0usize;
|
||||
let mut h = 0.0;
|
||||
for p in self.placed.iter().filter(|p| p.fi == fi && p.col == col) {
|
||||
n += 1;
|
||||
h += p.h;
|
||||
}
|
||||
let (space_between, space_before) = (o.space_between, o.space_before);
|
||||
let mut ms: Vec<NoteMetrics> = self.placed.iter().filter(|p| p.fi == fi && p.col == col).map(|p| p.m).collect();
|
||||
for &k in extra {
|
||||
n += 1;
|
||||
h += self.compose(doc, k, w, f, opts).1;
|
||||
ms.push(self.compose(doc, k, w, f, opts).1);
|
||||
}
|
||||
if n == 0 { 0.0 } else { h + o.space_between * (n - 1) as f64 + o.space_before }
|
||||
if ms.is_empty() { 0.0 } else { stack_height(&ms, space_between) + space_before }
|
||||
}
|
||||
|
||||
pub fn place(&mut self, doc: &Document, k: usize, fi: usize, col: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) {
|
||||
if self.placed.iter().any(|p| p.k == k) {
|
||||
return;
|
||||
}
|
||||
let (text, h) = self.compose(doc, k, w, f, opts);
|
||||
self.placed.push(Placed { k, fi, col, h, text });
|
||||
let (text, m) = self.compose(doc, k, w, f, opts);
|
||||
self.placed.push(Placed { k, fi, col, m, text });
|
||||
}
|
||||
|
||||
/// Stack each column's footnotes against its bottom, rule above the first.
|
||||
@@ -159,7 +193,7 @@ impl Notes {
|
||||
for (fi, ci) in groups {
|
||||
let (Some(_), Some(col)) = (frames.get(fi), cols.get(fi).and_then(|c| c.get(ci))) else { continue };
|
||||
let here: Vec<&Placed> = self.placed.iter().filter(|p| p.fi == fi && p.col == ci).collect();
|
||||
let total: f64 = here.iter().map(|p| p.h).sum::<f64>() + o.space_between * (here.len() - 1) as f64;
|
||||
let total = stack_height(here.iter().map(|p| &p.m), o.space_between);
|
||||
let top = col.y1 - total;
|
||||
let ft = &mut out.frames[fi];
|
||||
if o.rule.on && o.rule.weight > 0.0 {
|
||||
@@ -171,20 +205,29 @@ impl Notes {
|
||||
tint: o.rule.tint,
|
||||
});
|
||||
}
|
||||
let mut y = top;
|
||||
// Previous footnote's last baseline and bottom.
|
||||
let mut prev: Option<(f64, f64)> = None;
|
||||
for p in here {
|
||||
let note = &story.notes[p.k];
|
||||
let Some(note) = story.notes.get(p.k) else { continue };
|
||||
let (y, y0) = match prev {
|
||||
None => (top, top),
|
||||
Some((last, bottom)) => {
|
||||
let first = last + o.space_between + p.m.lead;
|
||||
(first - p.m.first, (bottom + o.space_between).min(first))
|
||||
}
|
||||
};
|
||||
let y1 = y + p.m.last + p.m.descent;
|
||||
ft.notes.push(PlacedNote {
|
||||
id: note.id,
|
||||
index: p.k,
|
||||
column: ci as u32,
|
||||
label: o.label(self.numbers[p.k], true),
|
||||
origin: Point::new(col.x0, y),
|
||||
rect: Rect::new(col.x0, y, col.x1, y + p.h),
|
||||
rect: Rect::new(col.x0, y0, col.x1, y1),
|
||||
text: p.text.clone(),
|
||||
source: Arc::from(note.text.text.as_str()),
|
||||
});
|
||||
y += p.h + o.space_between;
|
||||
prev = Some((y + p.m.last, y1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ pub struct ShapedPara {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SubstCtx {
|
||||
pub page_name: Option<String>,
|
||||
/// The section marker text of the page being composed (`None` on parent pages: "Section").
|
||||
pub section_marker: Option<String>,
|
||||
/// Text variable values by index (on the page being composed).
|
||||
pub vars: Arc<Vec<String>>,
|
||||
@@ -146,6 +147,7 @@ impl StyleTable<'_> {
|
||||
stroke: p.stroke.clone(),
|
||||
stroke_tint: p.stroke_tint,
|
||||
stroke_weight: p.stroke_weight,
|
||||
stroke_behind_fill: p.stroke_behind_fill,
|
||||
underline: p.underline,
|
||||
strikethrough: p.strikethrough,
|
||||
underline_rule: crate::Rule {
|
||||
@@ -619,7 +621,14 @@ fn shape_run_raw(
|
||||
} else {
|
||||
sub.page_name.clone().unwrap_or_else(|| "#".into())
|
||||
};
|
||||
shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out, sub.vertical);
|
||||
if s.is_empty() {
|
||||
// A section without marker text: the character takes no space.
|
||||
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
|
||||
g.adv = 0.0;
|
||||
out.push(g);
|
||||
} else {
|
||||
shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out, sub.vertical);
|
||||
}
|
||||
}
|
||||
designcraft_doc::OBJECT_MARK => {
|
||||
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
|
||||
|
||||
@@ -112,6 +112,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left
|
||||
vertical: false,
|
||||
exclusions: vec![],
|
||||
page_name: opts.page_name.clone(),
|
||||
section_marker: opts.section_marker.clone(),
|
||||
page: opts.page,
|
||||
grid: None,
|
||||
left_page,
|
||||
|
||||
+200
-3
@@ -381,6 +381,63 @@ fn hostile_wrap_geometry_terminates() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small frame holding one character (a tombstone at the end of an article: a 7.6 pt symbol in
|
||||
/// a 10.5 pt box), in a paragraph whose style is 14 pt, with a text-wrap object on the spread at
|
||||
/// `wrap`.
|
||||
fn tombstone(frame: Rect, wrap: Rect) -> (Document, StoryId) {
|
||||
let (mut d, sid, _) = doc_with("X", frame, ParaAttrs::default());
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.paras[0].chars = designcraft_doc::CharAttrs { size: Some(14.0), ..Default::default() };
|
||||
st.format_chars(0..1, |f| f.over.size = Some(7.6));
|
||||
let lid = d.default_layer();
|
||||
let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(wrap));
|
||||
it.wrap.mode = WrapMode::BoundingBox;
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
(d, sid)
|
||||
}
|
||||
|
||||
/// A text-wrap object elsewhere on the spread doesn't affect a frame it doesn't overlap: the small
|
||||
/// frame's one character is set (it was overset: every frame took every wrap object on the
|
||||
/// spread, and then no gap narrower than 1.5 em of the paragraph's size was used).
|
||||
#[test]
|
||||
fn small_single_character_frame_sets_beside_unrelated_text_wrap() {
|
||||
let (d, sid) = tombstone(Rect::new(300.0, 300.0, 310.5, 310.5), Rect::new(0.0, 0.0, 120.0, 60.0));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(!cs.is_overset(), "the tombstone is set");
|
||||
assert_eq!(cs.line_count(), 1);
|
||||
}
|
||||
|
||||
/// A narrow frame that a text-wrap object overlaps further down sets the lines the object doesn't
|
||||
/// reach: the minimum gap width only applies to gaps a wrap object cuts.
|
||||
#[test]
|
||||
fn narrow_frame_sets_lines_no_wrap_object_reaches() {
|
||||
let (d, sid) = tombstone(Rect::new(300.0, 300.0, 310.5, 340.0), Rect::new(290.0, 330.0, 330.0, 360.0));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(!cs.is_overset(), "the first line is above the wrap object");
|
||||
assert_eq!(cs.line_count(), 1);
|
||||
let (top, baseline) = (cs.frames[0].columns[0].y0, cs.frames[0].lines[0].baseline);
|
||||
assert!(baseline - top < 20.0, "on the first line: {baseline} (text area from {top})");
|
||||
}
|
||||
|
||||
/// In a frame of narrow columns, a wrap object beside column 2 leaves column 1 alone: only a gap
|
||||
/// the object cuts in its own column is held to the minimum width (it was dropped, as the object
|
||||
/// overlapped the line's band, and the text went past column 1).
|
||||
#[test]
|
||||
fn wrap_object_beside_one_narrow_column_leaves_the_other_alone() {
|
||||
// Two 18 pt columns (12 pt gutter): narrower than 1.5 em of the 14 pt paragraph.
|
||||
let (mut d, sid) = tombstone(Rect::new(300.0, 300.0, 348.0, 340.0), Rect::new(336.0, 300.0, 380.0, 340.0));
|
||||
let fid = d.spreads[0].items.iter().find(|i| i.text_frame().is_some_and(|t| t.story == sid)).unwrap().id;
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let f = &cs.frames[0];
|
||||
assert_eq!(f.columns.len(), 2);
|
||||
assert!(f.columns[0].width() < 21.0, "column 1 is narrower than 1.5 em: {}", f.columns[0].width());
|
||||
assert!(!cs.is_overset());
|
||||
let l = &f.lines[0];
|
||||
assert_eq!(l.column, 0, "set in column 1");
|
||||
assert!(l.baseline - f.columns[0].y0 < 20.0, "on its first line: {}", l.baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_composer_is_no_worse_than_greedy() {
|
||||
// Sum of squared slack over lines (excluding last) should not exceed the greedy result.
|
||||
@@ -1218,6 +1275,52 @@ fn footnotes_sit_at_the_column_bottom_and_push_text() {
|
||||
assert!(note_caret(&cs, n.id, 0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footnotes_stack_baseline_to_baseline() {
|
||||
// Single-line footnotes read as one text: consecutive baselines sit one leading (plus
|
||||
// Space Between Footnotes) apart, and only the last footnote's descent adds to the area.
|
||||
let text = format!("{LOREM} {LOREM} {LOREM} {LOREM}");
|
||||
for space_between in [0.0, 3.0] {
|
||||
let (mut d, sid, fid) = doc_with(&text, Rect::new(36.0, 36.0, 300.0, 236.0), ParaAttrs::default());
|
||||
d.footnote_options.space_between = space_between;
|
||||
d.footnote_options.space_before = 4.0;
|
||||
{
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
for at in [60, 40, 20, 10] {
|
||||
st.insert_note(at, "Note.", ParaFormat::default());
|
||||
}
|
||||
st.check().unwrap();
|
||||
}
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let ft = cs.frame(fid).unwrap();
|
||||
assert_eq!(ft.notes.len(), 4);
|
||||
let first_line = |n: &PlacedNote| {
|
||||
let lines = &n.text.frames[0].lines;
|
||||
assert_eq!(lines.len(), 1);
|
||||
lines[0].clone()
|
||||
};
|
||||
let baselines: Vec<f64> = ft.notes.iter().map(|n| n.origin.y + first_line(n).baseline).collect();
|
||||
let step = first_line(&ft.notes[1]).leading + space_between;
|
||||
for w in baselines.windows(2) {
|
||||
assert!((w[1] - w[0] - step).abs() < 1e-6, "baselines {baselines:?}, step {step}");
|
||||
}
|
||||
for w in ft.notes.windows(2) {
|
||||
assert!(w[0].rect.y1 <= w[1].rect.y0 + 1e-6, "rects stack in order");
|
||||
}
|
||||
// Area: first-baseline offset + (n - 1) steps + the last descent, ending at the column bottom.
|
||||
let col = ft.columns[0];
|
||||
let top = ft.notes[0].rect.y0;
|
||||
let (l0, l3) = (first_line(&ft.notes[0]), first_line(&ft.notes[3]));
|
||||
assert!((col.y1 - top - (l0.baseline + 3.0 * step + l3.descent)).abs() < 1e-6);
|
||||
assert!((ft.notes[3].rect.y1 - col.y1).abs() < 1e-6);
|
||||
// The body text fills the room left above the footnotes and their space before.
|
||||
let room = top - d.footnote_options.space_before;
|
||||
let last = ft.lines.last().unwrap();
|
||||
assert!(last.baseline + last.descent <= room + 0.01, "text clears the footnotes");
|
||||
assert!(last.baseline + last.leading + last.descent > room, "one more line wouldn't fit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footnote_first_line_wraps_at_the_column_edge_after_its_number() {
|
||||
// InDesign sets the footnote number and separator as part of the first line: that line
|
||||
@@ -2617,6 +2720,25 @@ fn portuguese_text_gets_portuguese_hyphenation() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn norwegian_text_gets_norwegian_hyphenation() {
|
||||
let text = "sirkulasjonen beveger nedsynkningen kaller overflaten bilde";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 76.0, 700.0), ParaAttrs::default());
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
let n = st.len();
|
||||
st.format_chars(0..n, |f| f.over.language = Some("Norwegian: Bokmål".into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let breaks: Vec<&str> = all_lines(&cs).into_iter().filter(|l| l.hyphenated).map(|l| text[l.range.clone()].trim_end()).collect();
|
||||
assert!(!breaks.is_empty(), "Norwegian text is hyphenated");
|
||||
for b in breaks {
|
||||
let tail = b.rsplit(' ').next().unwrap_or(b);
|
||||
let whole = text[text.find(b).unwrap() + b.len() - tail.len()..].split(|c: char| !c.is_alphabetic()).next().unwrap_or("");
|
||||
let nb = hyphen::Lang::for_language("Norwegian: Bokmål").unwrap();
|
||||
let pts = hyphen::hyphen_points_in(whole, &hyphen::Limits::default(), nb);
|
||||
assert!(pts.contains(&tail.chars().count()), "break {tail}- in {whole} is a hyph-no point");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_aki_adds_space_without_scaling_outlines() {
|
||||
let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
@@ -3135,7 +3257,7 @@ fn each_document_shapes_words_in_its_own_fonts() {
|
||||
let folder = root.join(name);
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
std::fs::write(folder.join("word.ttf"), font_with_glyph(FAMILY, &['a', 'b', ' '], glyph).unwrap()).unwrap();
|
||||
scopes.push((designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope, glyph));
|
||||
scopes.push((designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|_| Ok(())).scope, glyph));
|
||||
}
|
||||
let text = "ab ab ab ab";
|
||||
let mut faces = Vec::new();
|
||||
@@ -3165,7 +3287,7 @@ fn composite_fonts_draw_from_the_documents_fonts() {
|
||||
let folder = std::env::temp_dir().join(format!("dc-compose-composite-docfont-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
std::fs::write(folder.join("digits.ttf"), font_with_glyph(FAMILY, &['1'], 'X').unwrap()).unwrap();
|
||||
let scope = FontDb::global().load_document_fonts(&folder).scope;
|
||||
let scope = FontDb::global().load_document_fonts(&folder, &|_| Ok(())).scope;
|
||||
let (mut d, sid, _) = doc_with("A1B", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
d.font_scope = scope;
|
||||
std::sync::Arc::make_mut(&mut d.styles).composite_fonts.push(CompositeFont {
|
||||
@@ -3455,7 +3577,7 @@ fn drop_cap_in_a_document_font_aligns_its_left_edge() {
|
||||
let folder = std::env::temp_dir().join(format!("dc-compose-dropcap-docfont-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
std::fs::write(folder.join("dropcap.ttf"), designcraft_fonts::testing::font_with_glyph(FAMILY, &chars, 'O').unwrap()).unwrap();
|
||||
let scope = designcraft_fonts::FontDb::global().load_document_fonts(&folder).scope;
|
||||
let scope = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|_| Ok(())).scope;
|
||||
let para = ParaAttrs { left_indent: Some(10.0), ..drop_cap(2, 1) };
|
||||
let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 1000.0), para);
|
||||
d.font_scope = scope;
|
||||
@@ -4060,3 +4182,78 @@ fn empty_paragraphs_get_no_bullet_or_number() {
|
||||
assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_recomposes_when_the_baseline_grid_changes() {
|
||||
let para = ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() };
|
||||
let (mut d, sid, _) = doc_with(LOREM, Rect::new(0.0, 0.0, 200.0, 600.0), para);
|
||||
let cache = Cache::new();
|
||||
let a = cache.get(&d, sid, None);
|
||||
d.settings.baseline_grid.increment = 30.0;
|
||||
let b = cache.get(&d, sid, None);
|
||||
assert!(!Arc::ptr_eq(&a, &b));
|
||||
let (la, lb) = (&a.frames[0].lines[1], &b.frames[0].lines[1]);
|
||||
assert!((la.baseline - lb.baseline).abs() > 1.0, "{} {}", la.baseline, lb.baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_recomposes_when_a_wrap_layer_is_hidden() {
|
||||
let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default());
|
||||
let lid = d.default_layer();
|
||||
let id = ItemId(d.alloc());
|
||||
let mut it =
|
||||
designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 60.0)));
|
||||
it.wrap.mode = WrapMode::BoundingBox;
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
let cache = Cache::new();
|
||||
let a = cache.get(&d, sid, None);
|
||||
assert!(a.frames[0].lines[0].x0 >= 120.0 - 1e-6);
|
||||
d.layer_mut(lid).unwrap().visible = false;
|
||||
let b = cache.get(&d, sid, None);
|
||||
assert!(!Arc::ptr_eq(&a, &b));
|
||||
assert!(b.frames[0].lines[0].x0 < 1.0, "{}", b.frames[0].lines[0].x0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_recomposes_page_numbers_when_a_section_is_renumbered() {
|
||||
let text = story::PAGE_NUMBER.to_string();
|
||||
let (mut d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
let cache = Cache::new();
|
||||
let a = cache.get(&d, sid, None);
|
||||
assert_eq!(a.frames[0].lines[0].glyphs.len(), 1);
|
||||
d.sections[0].start_number = Some(1000);
|
||||
let b = cache.get(&d, sid, None);
|
||||
assert!(!Arc::ptr_eq(&a, &b));
|
||||
assert_eq!(b.frames[0].lines[0].glyphs.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_at_the_end_of_a_paragraph_take_no_room() {
|
||||
// A frame as wide as "2 VOOR" also holds "2 VOOR " on one line: the trailing space hangs.
|
||||
let (d, sid, _) = doc_with("2 VOOR", Rect::new(0.0, 0.0, 500.0, 100.0), ParaAttrs::default());
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let l = all_lines(&cs)[0];
|
||||
let width = l.end_x - l.x0;
|
||||
for text in ["2 VOOR ", "2 VOOR ", "2 VOOR \u{2028}more"] {
|
||||
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width + 0.02, 100.0), ParaAttrs::default());
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let first = all_lines(&cs)[0];
|
||||
assert_eq!(&text[first.range.clone()].trim_end(), &"2 VOOR", "{text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A story that ends with a return isn't overset when only its empty last paragraph doesn't fit,
|
||||
/// as in InDesign (Montilla Design's file: a caption fitted to its three lines, "No errors"). Text
|
||||
/// that doesn't fit still is.
|
||||
#[test]
|
||||
fn an_empty_last_paragraph_isnt_overset() {
|
||||
// 12 pt, 14.4 leading: two lines fit in 35 pt, a third (baseline ~40) doesn't.
|
||||
let (d, sid, _) = doc_with("One\nTwo\n", Rect::new(0.0, 0.0, 200.0, 35.0), ParaAttrs::default());
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert_eq!(all_lines(&cs).len(), 2);
|
||||
assert!(!cs.is_overset(), "only the empty paragraph after the last return is left");
|
||||
let (d, sid, _) = doc_with("One\nTwo\nThree", Rect::new(0.0, 0.0, 200.0, 35.0), ParaAttrs::default());
|
||||
assert!(compose_story(&d, sid, &ComposeOptions::default()).is_overset());
|
||||
let (d, sid, _) = doc_with("One\nTwo\n\n", Rect::new(0.0, 0.0, 200.0, 35.0), ParaAttrs::default());
|
||||
assert!(compose_story(&d, sid, &ComposeOptions::default()).is_overset(), "an empty paragraph before the last one is text");
|
||||
}
|
||||
|
||||
@@ -190,6 +190,13 @@ impl Digits {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `c` belong to a script that capitalizes the first letter of sentences, words and headings?
|
||||
/// Georgian doesn't: Unicode maps Mkhedruli to Mtavruli capitals, but those are only for all-caps
|
||||
/// text, never for a sentence's, a title's or an index heading's first letter.
|
||||
pub fn capitalizes(c: char) -> bool {
|
||||
!matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}')
|
||||
}
|
||||
|
||||
/// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese:
|
||||
/// Simplified", or a locale code such as "de_DE_2006"), for the shaper's localized forms (`locl`)
|
||||
/// and everything that depends on the language (quotes, hyphenation and spelling, line breaking,
|
||||
@@ -272,6 +279,7 @@ pub fn language_tag(language: &str) -> Option<&'static str> {
|
||||
"persian" | "farsi" => "fa",
|
||||
"urdu" => "ur",
|
||||
"hebrew" => "he",
|
||||
"georgian" => "ka",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -753,6 +761,9 @@ attr_set! {
|
||||
stroke: String = "[None]".into(),
|
||||
stroke_tint: f32 = 1.0,
|
||||
stroke_weight: f64 = 1.0,
|
||||
/// Stroke behind fill: the stroke is painted under the fill and lies wholly outside the
|
||||
/// letters at its full weight, so the letters keep their shape. Off, it straddles the outline.
|
||||
stroke_behind_fill: bool = false,
|
||||
capitalization: Capitalization = Capitalization::Normal,
|
||||
position: Position = Position::Normal,
|
||||
underline: bool = false,
|
||||
@@ -989,6 +1000,20 @@ mod tests {
|
||||
assert_eq!(a.rule_below, None, "null clears the override");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_behind_fill_is_saved_only_when_set() {
|
||||
assert!(!CharProps::default().stroke_behind_fill);
|
||||
assert_eq!(serde_json::to_value(CharAttrs::default()).unwrap(), serde_json::json!({}));
|
||||
let mut a = CharAttrs::default();
|
||||
a.set_json("strokeBehindFill", &serde_json::json!(true)).unwrap();
|
||||
let v = serde_json::to_value(&a).unwrap();
|
||||
assert_eq!(v, serde_json::json!({"strokeBehindFill": true}));
|
||||
assert_eq!(serde_json::from_value::<CharAttrs>(v).unwrap(), a);
|
||||
let mut p = CharProps::default();
|
||||
p.apply(&a);
|
||||
assert!(p.stroke_behind_fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digits_map_to_their_script() {
|
||||
assert_eq!(Digits::Default.map('7', "Arabic"), '7');
|
||||
@@ -1004,6 +1029,7 @@ mod tests {
|
||||
assert_eq!(language_tag("English: USA"), Some("en-US"));
|
||||
assert_eq!(language_tag("English: Australian"), Some("en"));
|
||||
assert_eq!(language_tag("Turkish"), Some("tr"));
|
||||
assert_eq!(language_tag("Georgian"), Some("ka"));
|
||||
assert_eq!(language_tag("Japanese"), Some("ja"));
|
||||
assert_eq!(language_tag("Korean"), Some("ko"));
|
||||
assert_eq!(language_tag("Chinese"), Some("zh"));
|
||||
|
||||
+16
-6
@@ -268,15 +268,26 @@ impl Document {
|
||||
|
||||
/// Redistribute document pages into spreads (facing: page 1 alone on the right, then pairs).
|
||||
/// Items move with the page their centre is on. Spreads with `allow_shuffle == false` are kept.
|
||||
/// Facing pages take their side from their page number, as InDesign shuffles them: an odd
|
||||
/// number is a right page (a left page bound right to left), so a section that starts at 1 on
|
||||
/// a left page moves it to the right and the pages after it follow.
|
||||
pub fn repaginate(&mut self) {
|
||||
// Collect pages with their items (items in page-relative coordinates).
|
||||
let odd: Vec<bool> = (0..self.page_count()).map(|i| self.page_number(i) % 2 == 1).collect();
|
||||
// Collect pages with their items (items in page-relative coordinates) and their numbers' parity.
|
||||
let mut pages: Vec<(Page, Vec<Arc<Item>>)> = Vec::new();
|
||||
let mut parity: Vec<bool> = Vec::new();
|
||||
let mut kept: Vec<(usize, Arc<Spread>)> = Vec::new();
|
||||
let mut abs = 0;
|
||||
for sp in self.spreads.drain(..) {
|
||||
if !sp.allow_shuffle && sp.pages.len() > 1 {
|
||||
abs += sp.pages.len();
|
||||
kept.push((pages.len(), sp));
|
||||
continue;
|
||||
}
|
||||
for _ in &sp.pages {
|
||||
parity.push(odd.get(abs).copied().unwrap_or(abs % 2 == 0));
|
||||
abs += 1;
|
||||
}
|
||||
let mut sp = Arc::unwrap_or_clone(sp);
|
||||
let mut per: Vec<Vec<Arc<Item>>> = vec![Vec::new(); sp.pages.len()];
|
||||
for it in std::mem::take(&mut sp.items) {
|
||||
@@ -327,13 +338,12 @@ impl Document {
|
||||
}
|
||||
spreads.push(sp);
|
||||
};
|
||||
// An even start page number (Document Setup › Start Page #) begins with a left page.
|
||||
let shift = usize::from(self.sections.iter().find(|x| x.start == 0).and_then(|x| x.start_number).is_some_and(|n| n % 2 == 0));
|
||||
for (i, (mut p, items)) in pages.into_iter().enumerate() {
|
||||
if facing {
|
||||
// Page i (0-based) is a right page when its number is odd (1, 3, 5 …).
|
||||
// A page is a right page when its number is odd (1, 3, 5 …), counting each
|
||||
// section from its start number (Document Setup › Start Page # included).
|
||||
// Bound right to left, odd pages are left pages and spreads start on the right.
|
||||
let odd = (i + shift) % 2 == 0;
|
||||
let odd = parity.get(i).copied().unwrap_or(i % 2 == 0);
|
||||
p.side = if odd != rtl { PageSide::Right } else { PageSide::Left };
|
||||
let starts = if rtl { PageSide::Right } else { PageSide::Left };
|
||||
if p.side == starts && !cur.is_empty() {
|
||||
@@ -374,7 +384,7 @@ impl Document {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let n = self.page_count();
|
||||
let at = after.map_or(0, |a| (a + 1).min(n));
|
||||
let at = after.map_or(0, |a| a.saturating_add(1).min(n));
|
||||
let template = self.page(at.saturating_sub(1).min(n.saturating_sub(1))).cloned();
|
||||
let (cols, gutter, margins) =
|
||||
template.as_ref().map(|t| (t.columns.count, t.columns.gutter, t.margins)).unwrap_or((1, 12.0, Margins::uniform(36.0)));
|
||||
|
||||
+33
-2
@@ -137,7 +137,12 @@ pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> S
|
||||
let mut heading: Option<char> = None;
|
||||
for (key, n) in &root {
|
||||
if spec.section_headings {
|
||||
let c = key.chars().next().map(|c| if c.is_alphabetic() { c.to_uppercase().next().unwrap_or(c) } else { '#' });
|
||||
// Georgian headings stay Mkhedruli (no capitals; Mtavruli is only for all-caps text).
|
||||
let c = key.chars().next().map(|c| match c {
|
||||
c if !c.is_alphabetic() => '#',
|
||||
c if crate::capitalizes(c) => c.to_uppercase().next().unwrap_or(c),
|
||||
c => c,
|
||||
});
|
||||
if c != heading {
|
||||
heading = c;
|
||||
if let Some(c) = c {
|
||||
@@ -184,10 +189,15 @@ fn entry_text(spec: &IndexSpec, n: &Node, page_name: &dyn Fn(usize) -> String) -
|
||||
t
|
||||
}
|
||||
|
||||
// `build` limits topic paths to four levels, bounding this traversal.
|
||||
fn run_in_subtopics(spec: &IndexSpec, n: &Node, page_name: &dyn Fn(usize) -> String) -> Vec<String> {
|
||||
n.children.values().flat_map(|c| [entry_text(spec, c, page_name)].into_iter().chain(run_in_subtopics(spec, c, page_name))).collect()
|
||||
}
|
||||
|
||||
fn emit(spec: &IndexSpec, n: &Node, level: usize, page_name: &dyn Fn(usize) -> String, out: &mut Vec<(String, String)>) {
|
||||
if spec.run_in && level == 1 {
|
||||
let mut t = entry_text(spec, n, page_name);
|
||||
let subs: Vec<String> = n.children.values().map(|c| entry_text(spec, c, page_name)).collect();
|
||||
let subs = run_in_subtopics(spec, n, page_name);
|
||||
if !subs.is_empty() {
|
||||
t.push_str(": ");
|
||||
t.push_str(&subs.join(&spec.between_entries));
|
||||
@@ -209,6 +219,19 @@ mod tests {
|
||||
Resolved { r: IndexRef { topics: topics.iter().map(|s| s.to_string()).collect(), sort: vec![], range }, pages }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_in_keeps_all_topic_levels_in_sort_order() {
|
||||
let refs = vec![
|
||||
r(&["Typography", "kerning", "pairs", "optical"], IndexRange::CurrentPage, Some((4, 4))),
|
||||
r(&["Typography", "kerning", "pairs"], IndexRange::CurrentPage, Some((2, 2))),
|
||||
r(&["Typography", "leading"], IndexRange::CurrentPage, Some((6, 6))),
|
||||
r(&["Typography", "kerning", "adjustment"], IndexRange::See("Spacing".into()), None),
|
||||
];
|
||||
let spec = IndexSpec { run_in: true, title: String::new(), section_headings: false, between_entries: " / ".into(), ..IndexSpec::default() };
|
||||
let out = build(&spec, &refs, &|p| (p + 1).to_string());
|
||||
assert_eq!(out, [("Index Level 1".into(), "Typography: kerning / adjustment See Spacing / pairs 3 / optical 5 / leading 7".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_nested_index() {
|
||||
let refs = vec![
|
||||
@@ -243,4 +266,12 @@ mod tests {
|
||||
let run_in = build(&IndexSpec { run_in: true, section_headings: false, title: String::new(), ..spec }, &refs[..2], &|p| (p + 1).to_string());
|
||||
assert_eq!(run_in, [("Index Level 1".to_string(), "Typography 2\u{2013}4: kerning 5".to_string())]);
|
||||
}
|
||||
#[test]
|
||||
fn georgian_section_heads_stay_mkhedruli() {
|
||||
let refs = vec![r(&["ასო"], IndexRange::CurrentPage, Some((0, 0))), r(&["ბადე"], IndexRange::CurrentPage, Some((1, 1)))];
|
||||
let out = build(&IndexSpec::default(), &refs, &|p| (p + 1).to_string());
|
||||
let heads: Vec<&str> = out.iter().filter(|(s, _)| s == "Index Section Head").map(|(_, t)| t.as_str()).collect();
|
||||
assert_eq!(heads, ["ა", "ბ"]);
|
||||
assert!(crate::capitalizes('a') && !crate::capitalizes('ა') && !crate::capitalizes('Ა'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,28 @@ impl Default for Fill {
|
||||
}
|
||||
|
||||
impl Fill {
|
||||
/// A radial gradient's ellipse on an object whose bounds (in its own space) are `b`: centre and
|
||||
/// horizontal and vertical radii. As in InDesign, a radial gradient stretches with the object's
|
||||
/// box: by default it's centred and reaches the box's sides (an ellipse in a non-square box);
|
||||
/// with a Gradient Swatch tool vector it's centred at the vector's start, and the vector's
|
||||
/// length, measured with the box made square, sets its size.
|
||||
pub fn radial_ellipse(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, f64, f64) {
|
||||
let k = if b.width() > 1e-9 && b.height() > 1e-9 { b.height() / b.width() } else { 1.0 };
|
||||
match self.gradient_vector {
|
||||
Some([x0, y0, x1, y1]) => {
|
||||
let r = designcraft_geom::Vec2::new(x1 - x0, (y1 - y0) / k).hypot();
|
||||
(designcraft_geom::Point::new(x0, y0), r, r * k)
|
||||
}
|
||||
// A box with no width or height (a straight line's) has no ellipse: a circle across
|
||||
// its length.
|
||||
None if b.width() <= 1e-9 || b.height() <= 1e-9 => {
|
||||
let r = b.width().max(b.height()).max(1e-3) / 2.0;
|
||||
(b.center(), r, r)
|
||||
}
|
||||
None => (b.center(), b.width() / 2.0, b.height() / 2.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn none() -> Self {
|
||||
Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false }
|
||||
}
|
||||
@@ -1109,6 +1131,29 @@ impl Item {
|
||||
self.path.bounds().unwrap_or(Rect::ZERO)
|
||||
}
|
||||
|
||||
/// The box a gradient feather spans, in item space: the path's bounds, or a group's visible
|
||||
/// objects' (a group has no path of its own). Only the active states contribute, including in
|
||||
/// nested groups. Walk iteratively so deeply nested documents don't exhaust the call stack.
|
||||
pub fn feather_bounds(&self) -> Rect {
|
||||
if !self.is_group() {
|
||||
return self.inner_bounds();
|
||||
}
|
||||
let mut stack: Vec<_> = self.shown_children().map(|c| (c.as_ref(), c.xf)).collect();
|
||||
let mut bounds: Option<Rect> = None;
|
||||
while let Some((it, xf)) = stack.pop() {
|
||||
if it.hidden {
|
||||
continue;
|
||||
}
|
||||
if it.is_group() {
|
||||
stack.extend(it.shown_children().map(|c| (c.as_ref(), xf * c.xf)));
|
||||
} else {
|
||||
let b = xf.transform_rect_bbox(it.inner_bounds());
|
||||
bounds = Some(bounds.map_or(b, |have| have.union(b)));
|
||||
}
|
||||
}
|
||||
bounds.unwrap_or(Rect::ZERO)
|
||||
}
|
||||
|
||||
/// Spread-space geometric bounds (axis-aligned box of the transformed path; groups: union).
|
||||
/// A group (its bounds are its children's). A frame holding pasted-into items is not.
|
||||
pub fn is_group(&self) -> bool {
|
||||
@@ -1265,6 +1310,29 @@ mod tests {
|
||||
assert_eq!(n, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feather_bounds_follow_visible_states_in_nested_groups() {
|
||||
let a = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0)));
|
||||
let mut b = a.clone();
|
||||
b.id = ItemId(2);
|
||||
b.xf = Affine::translate((500.0, 500.0));
|
||||
let mut state = Item::new(ItemId(3), LayerId(1), Shape::Group, PathData::default());
|
||||
state.content = Content::Group { items: vec![Arc::new(a), Arc::new(b)] };
|
||||
state.states = vec!["Visible".into(), "Inactive".into()];
|
||||
assert_eq!(state.feather_bounds(), Rect::new(10.0, 20.0, 30.0, 40.0));
|
||||
state.active_state = 1;
|
||||
assert_eq!(state.feather_bounds(), Rect::new(510.0, 520.0, 530.0, 540.0));
|
||||
state.active_state = 0;
|
||||
state.xf = Affine::translate((5.0, 10.0)) * Affine::scale(2.0);
|
||||
let mut hidden = state.clone();
|
||||
hidden.id = ItemId(4);
|
||||
hidden.hidden = true;
|
||||
hidden.xf = Affine::translate((-1000.0, -1000.0));
|
||||
let mut outer = Item::new(ItemId(5), LayerId(1), Shape::Group, PathData::default());
|
||||
outer.content = Content::Group { items: vec![Arc::new(state), Arc::new(hidden)] };
|
||||
assert_eq!(outer.feather_bounds(), Rect::new(25.0, 50.0, 65.0, 90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Oval, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
|
||||
@@ -31,7 +31,7 @@ pub const FIGURES: &[(&str, &str, &[&str], &str)] = &[
|
||||
|
||||
/// Features shaping turns on by itself: "off" is an explicit `-tag`.
|
||||
fn default_on(tag: &str) -> bool {
|
||||
matches!(tag, "calt" | "locl" | "mark")
|
||||
matches!(tag, "calt" | "liga" | "locl" | "mark")
|
||||
}
|
||||
|
||||
pub fn is_on(list: &[String], tag: &str) -> bool {
|
||||
|
||||
+44
-11
@@ -57,18 +57,25 @@ impl Story {
|
||||
if src.text.is_empty() {
|
||||
return pos;
|
||||
}
|
||||
// Text first (creates empty marker entries), then formats and marker payloads.
|
||||
// The table anchored in the paragraph at `pos` keeps its anchor wherever the paste moves it.
|
||||
let p0 = self.para_at(pos);
|
||||
let own = self.paras.get(p0).and_then(|p| p.table).and_then(|id| {
|
||||
let r = self.para_ranges().get(p0)?.clone();
|
||||
Some((r.start + self.text.get(r)?.find(TABLE_ANCHOR)?, id))
|
||||
});
|
||||
// Text first (creates empty marker entries), then formats and marker payloads; tables are
|
||||
// fixed up once at the end.
|
||||
let mut at = pos;
|
||||
for (r, f) in src.runs() {
|
||||
if r.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.insert_with(at, &src.text[r.clone()], f.clone());
|
||||
self.insert_with_raw(at, &src.text[r.clone()], f.clone());
|
||||
at += r.len();
|
||||
}
|
||||
let end = at;
|
||||
let mut claims: Vec<(usize, u64)> = own.map(|(a, id)| (if a >= pos { a + (end - pos) } else { a }, id)).into_iter().collect();
|
||||
// Paragraph formats of the pasted paragraphs after the first.
|
||||
let p0 = self.para_at(pos);
|
||||
for (k, pf) in src.paras.iter().enumerate().skip(1) {
|
||||
if let Some(p) = self.paras.get_mut(p0 + k) {
|
||||
*p = crate::story::ParaFormat { table: None, ..pf.clone() };
|
||||
@@ -106,25 +113,25 @@ impl Story {
|
||||
*slot = Arc::new(crate::xref::TextAnchor { id, name: a.name.clone() });
|
||||
}
|
||||
}
|
||||
// Tables: re-attach by paragraph (fresh ids so a pasted copy never shares an id).
|
||||
// Tables: re-attach at their anchors (fresh ids so a pasted copy never shares an id).
|
||||
if !src.tables.is_empty() {
|
||||
let mut next = self.tables.keys().max().copied().unwrap_or(0) + 1;
|
||||
for (k, pf) in src.paras.iter().enumerate() {
|
||||
let ranges = src.para_ranges();
|
||||
for (pf, r) in src.paras.iter().zip(ranges) {
|
||||
let Some(tid) = pf.table else { continue };
|
||||
let Some(t) = src.tables.get(&tid) else { continue };
|
||||
let pi = p0 + k;
|
||||
let Some(k) = src.text.get(r.clone()).and_then(|t| t.find(TABLE_ANCHOR)) else { continue };
|
||||
let mut t = (**t).clone();
|
||||
t.id = next;
|
||||
next += 1;
|
||||
claims.push((pos + r.start + k, t.id));
|
||||
self.tables.insert(t.id, Arc::new(t));
|
||||
if let Some(p) = self.paras.get_mut(pi) {
|
||||
p.table = Some(next - 1);
|
||||
}
|
||||
}
|
||||
self.fix_tables();
|
||||
}
|
||||
let splits = self.fix_tables_with(&claims);
|
||||
self.rev += 1;
|
||||
end
|
||||
// Past any return the fix-up put inside the pasted text.
|
||||
end + splits.iter().filter(|&&s| s < end).count()
|
||||
}
|
||||
|
||||
/// Append the whole of `other` as new paragraphs after this story's text (threading two
|
||||
@@ -193,6 +200,32 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::story::ParaFormat;
|
||||
|
||||
#[test]
|
||||
fn pasting_a_table_next_to_another_gives_each_its_own_paragraph() {
|
||||
let mut s = Story::with_text(StoryId(1), "x", ParaFormat::default());
|
||||
let a = s.insert_table(1, crate::Table::new(5, 1, 1, 0, 0, 10.0));
|
||||
let n = TABLE_ANCHOR.len_utf8();
|
||||
let copy = s.extract(a..a + n);
|
||||
// After the anchor: the copy follows the original.
|
||||
let mut after = s.clone();
|
||||
let end = after.insert_story(a + n, ©);
|
||||
after.check().unwrap();
|
||||
assert_eq!(after.text, format!("x\n{TABLE_ANCHOR}\n{TABLE_ANCHOR}\n"));
|
||||
assert_eq!((after.table_anchor(5), after.table_anchor(6)), (Some(a), Some(a + n + 1)));
|
||||
assert_eq!(end, a + 2 * n + 1);
|
||||
// Before the anchor: the copy comes first and the original keeps its table.
|
||||
let mut before = s.clone();
|
||||
let end = before.insert_story(a, ©);
|
||||
before.check().unwrap();
|
||||
assert_eq!((before.table_anchor(6), before.table_anchor(5)), (Some(a), Some(a + n + 1)));
|
||||
assert_eq!(end, a + n);
|
||||
// Paragraphs pasted before the anchor keep the table too.
|
||||
let mut text = s.clone();
|
||||
text.insert_story(a, &Story::with_text(StoryId(2), "Hello\nWorld", ParaFormat::default()));
|
||||
text.check().unwrap();
|
||||
assert_eq!(text.table_anchor(5), Some(a + "Hello\nWorld".len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_keeps_formatting_and_notes() {
|
||||
let mut a = Story::with_text(StoryId(1), "Alpha beta\nGamma delta", ParaFormat::default());
|
||||
|
||||
+14
-8
@@ -255,7 +255,8 @@ impl Story {
|
||||
self.insert_with_raw(pos, text, fmt);
|
||||
}
|
||||
|
||||
fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) {
|
||||
/// [`Story::insert_with`] without the table fix-up.
|
||||
pub(crate) fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -332,20 +333,25 @@ impl Story {
|
||||
self.marks_deleted(a, b);
|
||||
let pi = self.para_at(a);
|
||||
let removed = self.text[a..b].bytes().filter(|c| *c == b'\n').count();
|
||||
// Merged paragraphs keep a table whose anchor survives the deletion.
|
||||
// Merged paragraphs keep the tables whose anchors survive the deletion (the fix-up gives
|
||||
// each its own paragraph again).
|
||||
let mut claims = Vec::new();
|
||||
if removed > 0 && !self.tables.is_empty() {
|
||||
let ranges = self.para_ranges();
|
||||
let first = self.paras[pi].table.filter(|_| self.text[ranges[pi].start..a].contains(TABLE_ANCHOR));
|
||||
let last = self.paras[pi + removed]
|
||||
.table
|
||||
.filter(|_| self.text[b.min(ranges[pi + removed].end)..ranges[pi + removed].end].contains(TABLE_ANCHOR));
|
||||
self.paras[pi].table = first.or(last);
|
||||
let anchor = |pi: usize, r: std::ops::Range<usize>| {
|
||||
self.paras[pi].table.and_then(|id| self.text[r.clone()].find(TABLE_ANCHOR).map(|k| (r.start + k, id)))
|
||||
};
|
||||
let first = anchor(pi, ranges[pi].start..a);
|
||||
let end = ranges[pi + removed].end;
|
||||
let last = anchor(pi + removed, b.min(end)..end).map(|(at, id)| (at.saturating_sub(b - a), id));
|
||||
self.paras[pi].table = first.or(last).map(|(_, id)| id);
|
||||
claims.extend(first.into_iter().chain(last));
|
||||
}
|
||||
self.paras.drain(pi + 1..pi + 1 + removed);
|
||||
self.text.replace_range(a..b, "");
|
||||
self.remove_run_bytes(a, b - a);
|
||||
self.normalize();
|
||||
self.fix_tables();
|
||||
self.fix_tables_with(&claims);
|
||||
self.rev += 1;
|
||||
}
|
||||
|
||||
|
||||
+71
-7
@@ -1105,21 +1105,61 @@ impl Story {
|
||||
|
||||
/// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it
|
||||
/// still contains an anchor character, every table is referenced once, and tables nobody
|
||||
/// references are dropped.
|
||||
/// references are dropped. An anchor that shares a table's paragraph with another anchor gets
|
||||
/// a paragraph of its own again, so a table paragraph always holds exactly one anchor.
|
||||
pub(crate) fn fix_tables(&mut self) {
|
||||
self.fix_tables_with(&[]);
|
||||
}
|
||||
|
||||
/// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are
|
||||
/// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph
|
||||
/// names one table, so merging two anchor paragraphs would otherwise lose one). Every other
|
||||
/// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before
|
||||
/// the fix-up, where it inserted paragraph returns.
|
||||
pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec<usize> {
|
||||
if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) {
|
||||
return;
|
||||
return Vec::new();
|
||||
}
|
||||
let ranges = self.para_ranges();
|
||||
let mut owner = std::collections::BTreeMap::new();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for (p, r) in self.paras.iter_mut().zip(ranges) {
|
||||
if let Some(id) = p.table
|
||||
&& (!self.text[r].contains(TABLE_ANCHOR) || !self.tables.contains_key(&id) || !used.insert(id))
|
||||
for &(at, id) in claims {
|
||||
if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR))
|
||||
&& self.tables.contains_key(&id)
|
||||
&& !owner.contains_key(&at)
|
||||
&& used.insert(id)
|
||||
{
|
||||
p.table = None;
|
||||
owner.insert(at, id);
|
||||
}
|
||||
}
|
||||
let ranges = self.para_ranges();
|
||||
for (p, r) in self.paras.iter().zip(&ranges) {
|
||||
let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue };
|
||||
let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at));
|
||||
if let Some(at) = free {
|
||||
used.insert(id);
|
||||
owner.insert(at, id);
|
||||
}
|
||||
}
|
||||
// A paragraph holding a table's anchor and other anchors is split before each later anchor.
|
||||
let mut splits = Vec::new();
|
||||
for r in &ranges {
|
||||
let anchors: Vec<usize> = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect();
|
||||
if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) {
|
||||
splits.extend(anchors.into_iter().skip(1));
|
||||
}
|
||||
}
|
||||
for &at in splits.iter().rev() {
|
||||
self.insert_raw(at, "\n");
|
||||
}
|
||||
// Each owned anchor, moved past the returns inserted before it, names its paragraph's table.
|
||||
let owner: std::collections::BTreeMap<usize, u64> =
|
||||
owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect();
|
||||
let ranges = self.para_ranges();
|
||||
for (p, r) in self.paras.iter_mut().zip(ranges) {
|
||||
p.table = owner.range(r).next().map(|(_, &id)| id);
|
||||
}
|
||||
self.tables.retain(|id, _| used.contains(id));
|
||||
splits
|
||||
}
|
||||
|
||||
/// Check table invariants (part of [`Story::check`]).
|
||||
@@ -1219,6 +1259,30 @@ mod tests {
|
||||
assert!(s.tables.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_return_between_two_tables_keeps_both() {
|
||||
// Backspace at the start of the second table's paragraph.
|
||||
let mut s = story("x");
|
||||
let a1 = s.insert_table(1, Table::new(1, 1, 1, 0, 0, 10.0));
|
||||
let a2 = s.insert_table(s.len(), Table::new(2, 1, 1, 0, 0, 10.0));
|
||||
let before = s.text.clone();
|
||||
s.delete(a2 - 1..a2);
|
||||
s.check().unwrap();
|
||||
assert_eq!(s.text, before);
|
||||
assert_eq!((s.table_anchor(1), s.table_anchor(2)), (Some(a1), Some(a2)));
|
||||
// Deleting the table between two others keeps the outer ones on their own anchors.
|
||||
let mut s = story("x");
|
||||
for id in 1..=3 {
|
||||
s.insert_table(s.len(), Table::new(id, 1, 1, 0, 0, 10.0));
|
||||
}
|
||||
let (a1, a3) = (s.table_anchor(1).unwrap(), s.table_anchor(3).unwrap());
|
||||
s.delete(a1 + TABLE_ANCHOR.len_utf8()..a3);
|
||||
s.check().unwrap();
|
||||
assert_eq!(s.text, format!("x\n{TABLE_ANCHOR}\n{TABLE_ANCHOR}\n"));
|
||||
assert_eq!(s.tables.keys().copied().collect::<Vec<_>>(), [1, 3]);
|
||||
assert_eq!(s.table_anchor(3), Some(a1 + TABLE_ANCHOR.len_utf8() + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_around_the_anchor_keep_the_table() {
|
||||
let mut s = story("ab\ncd");
|
||||
|
||||
@@ -36,11 +36,6 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
write_atomic_with(path, |f| f.write_all(bytes))
|
||||
}
|
||||
|
||||
/// [`write_atomic`] for a command's `path` parameter: the error names the path.
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> crate::Result<()> {
|
||||
write_atomic(Path::new(path), bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// Atomically replace (or create) `path` with what `fill` writes into the temporary file. When
|
||||
/// `fill`, the flush or the rename fails, the temporary file is removed, `path` is untouched and
|
||||
/// the error is returned.
|
||||
|
||||
@@ -5,6 +5,7 @@ use designcraft_doc::{Document, ItemId, Shape};
|
||||
use designcraft_geom::{Affine, Anchor, AnchorKind, Point};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::object::path_editable;
|
||||
use super::{CommandSpec, bad, cmd, has_doc, point_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -69,6 +70,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result<Value> {
|
||||
.map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect())
|
||||
.unwrap_or_default();
|
||||
s.edit(|d, _| {
|
||||
path_editable(d, id, cmd)?;
|
||||
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let scale = inv.determinant().abs().sqrt().max(1e-9);
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
@@ -135,6 +137,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?;
|
||||
let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0);
|
||||
s.edit(|d, _| {
|
||||
path_editable(d, id, "path.addAnchor")?;
|
||||
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let q = inv * at;
|
||||
@@ -154,6 +157,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn delete_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (id, si, ai) = anchor_param(p, "path.deleteAnchor")?;
|
||||
s.edit(|d, sel| {
|
||||
path_editable(d, id, "path.deleteAnchor")?;
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.deleteAnchor", "no such subpath"))?;
|
||||
if ai >= sp.anchors.len() {
|
||||
@@ -181,6 +185,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let to = point_param(p, "to");
|
||||
let corner = p.get("corner").and_then(Value::as_bool);
|
||||
s.edit(|d, _| {
|
||||
path_editable(d, id, "path.convertAnchor")?;
|
||||
let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.convertAnchor", "no such subpath"))?;
|
||||
|
||||
@@ -28,7 +28,7 @@ fn no_book() -> EngineError {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn save(b: &Book) -> Result<()> {
|
||||
let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
crate::atomic::write_file(&b.path, &bytes)
|
||||
crate::io::write_file(&b.path, &bytes).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -38,14 +38,14 @@ fn save(_: &Book) -> Result<()> {
|
||||
|
||||
fn load(path: &str) -> Result<designcraft_doc::Document> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let bytes: Vec<u8> = {
|
||||
let _ = path;
|
||||
vec![]
|
||||
};
|
||||
if path.to_lowercase().ends_with(".idml") {
|
||||
return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
|
||||
return super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
|
||||
}
|
||||
super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
@@ -90,7 +90,7 @@ impl ChapterBatch {
|
||||
}
|
||||
|
||||
fn commit(self) -> Result<()> {
|
||||
self.commit_with(write_staged_chapter)
|
||||
self.commit_with(|path, staged| crate::io::replace_with(path, staged))
|
||||
}
|
||||
|
||||
fn commit_with(self, mut write: impl FnMut(&std::path::Path, &std::path::Path) -> std::io::Result<()>) -> Result<()> {
|
||||
@@ -110,12 +110,6 @@ impl ChapterBatch {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_staged_chapter(path: &std::path::Path, staged: &std::path::Path) -> std::io::Result<()> {
|
||||
let mut input = std::fs::File::open(staged)?;
|
||||
crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ()))
|
||||
}
|
||||
|
||||
/// Batch commands only write closed native chapters. IDML is an imported source, and an open
|
||||
/// chapter's model (including unsaved edits) cannot be reconciled by a disk-only book update.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -125,16 +119,16 @@ fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()
|
||||
if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
|
||||
return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first")));
|
||||
}
|
||||
let target = std::fs::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| std::fs::canonicalize(open).is_ok_and(|open| open == target))) {
|
||||
let target = crate::io::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if s.docs.iter().any(|doc| doc.path.as_ref().is_some_and(|open| crate::io::canonicalize(open).is_ok_and(|open| open == target))) {
|
||||
return Err(bad(command, format!("chapter {path} is open; close it before updating the book")));
|
||||
}
|
||||
if !std::fs::metadata(&target).is_ok_and(|metadata| metadata.is_file()) {
|
||||
if !crate::io::is_file(&target) {
|
||||
return Err(bad(command, format!("chapter {path} is not a regular file")));
|
||||
}
|
||||
// Check both the file and its resolved parent without truncating it. Replacement needs a
|
||||
// writable directory even when an in-place write would be permitted.
|
||||
std::fs::OpenOptions::new().write(true).open(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::io::writable(&target).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let parent = target.parent().ok_or_else(|| bad(command, format!("chapter {path} has no parent folder")))?;
|
||||
tempfile::Builder::new().prefix(".designcraft-book-check-").tempfile_in(parent).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(())
|
||||
@@ -248,7 +242,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "book.open", "Open Book", [], None, "{path} → {documents}", always, |s, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("book.open", "`path` required"))?.to_string();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let text = crate::io::read_to_string(&path).map_err(EngineError::Other)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let text = String::new();
|
||||
let mut b: Book = serde_json::from_str(&text).map_err(|e| bad("book.open", format!("not a book: {e}")))?;
|
||||
@@ -331,7 +325,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
crate::io::write_file(path, &bytes).map_err(EngineError::Other)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages}))
|
||||
}
|
||||
None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{ChapterBatch, MAX_BATCH_CHAPTERS, write_staged_chapter};
|
||||
use super::{ChapterBatch, MAX_BATCH_CHAPTERS};
|
||||
use crate::Session;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -26,7 +26,7 @@ fn a_commit_failure_reports_the_completed_prefix_and_cleans_staging() {
|
||||
let error = batch
|
||||
.commit_with(|path, staged| {
|
||||
calls += 1;
|
||||
if path == second { Err(std::io::Error::other("synthetic disk full")) } else { write_staged_chapter(path, staged) }
|
||||
if path == second { Err(std::io::Error::other("synthetic disk full")) } else { crate::io::replace_with(path, staged) }
|
||||
})
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
@@ -45,7 +45,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("color.loadProfile", "`path` required"))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad("color.loadProfile", e.to_string()))?;
|
||||
let bytes = crate::io::read_file(path).map_err(|e| bad("color.loadProfile", e))?;
|
||||
let info = cms::register_icc(&bytes, None).map_err(|e| bad("color.loadProfile", e.to_string()))?;
|
||||
Ok(json!(info))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -340,11 +340,11 @@ fn place_image(
|
||||
let cands = image_candidates(cell, data_dir, doc_dir);
|
||||
let found = cands.iter().find_map(|p| {
|
||||
// Refuse huge files before reading them; a too-large image counts as missing.
|
||||
let len = std::fs::metadata(p).ok()?.len();
|
||||
let len = crate::io::metadata(p).ok()?.len();
|
||||
if len == 0 || len > super::MAX_IMAGE_FILE_BYTES {
|
||||
return None;
|
||||
}
|
||||
std::fs::read(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b))
|
||||
crate::io::read_file(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b))
|
||||
});
|
||||
let Some((path, bytes)) = found else {
|
||||
if let Some(it) = doc.item_mut(item) {
|
||||
|
||||
@@ -619,7 +619,7 @@ pub(crate) fn refresh_relative_paths(doc: &mut Document, doc_path: &Path) {
|
||||
/// again as `relative_path` beside the document.
|
||||
pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path>) {
|
||||
for src in &mut doc.data_merge.sources {
|
||||
let abs_exists = src.path.as_deref().is_some_and(|p| Path::new(p).is_file());
|
||||
let abs_exists = src.path.as_deref().is_some_and(crate::io::is_file);
|
||||
if abs_exists {
|
||||
if let Some(path) = src.path.clone() {
|
||||
src.status = status_of(Path::new(&path), src.fingerprint);
|
||||
@@ -630,7 +630,7 @@ pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path
|
||||
&& let Some(dir) = doc_path.parent()
|
||||
{
|
||||
let candidate = dir.join(&rel);
|
||||
if candidate.is_file() {
|
||||
if crate::io::is_file(&candidate) {
|
||||
let saved = candidate.to_string_lossy().into_owned();
|
||||
src.fingerprint = fingerprint_of(Path::new(&saved));
|
||||
src.status = SourceStatus::Ok;
|
||||
@@ -768,12 +768,14 @@ fn read_path(path: &str) -> std::result::Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
fn read_path_capped(path: &str, cap: u64) -> std::result::Result<Vec<u8>, String> {
|
||||
match std::fs::metadata(path) {
|
||||
match crate::io::metadata(path) {
|
||||
Ok(m) if m.len() > cap => return Err(format!("{path}: the data file is larger than {} MiB", cap / (1024 * 1024))),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("{path}: the data file is missing")),
|
||||
_ => {}
|
||||
}
|
||||
match std::fs::read(path) {
|
||||
// The roots' own message when they refuse the path.
|
||||
crate::file_access::check_read(path)?;
|
||||
match crate::io::read(path) {
|
||||
Ok(b) => Ok(b),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(format!("{path}: the data file is missing")),
|
||||
Err(e) => Err(format!("{path}: {e}")),
|
||||
|
||||
@@ -330,7 +330,7 @@ pub fn relative_between(from_dir: &Path, target: &Path) -> String {
|
||||
}
|
||||
|
||||
pub fn fingerprint_of(path: &Path) -> Option<Fingerprint> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let meta = crate::io::metadata(path).ok()?;
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()
|
||||
|
||||
@@ -257,11 +257,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return super::text::delete_selection(s);
|
||||
}
|
||||
let ids = super::targets(s, p)?;
|
||||
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
|
||||
// frame stays, empty and selected (as in InDesign). Other items are deleted.
|
||||
let content = s.doc()?.selection.content && p.get("ids").is_none();
|
||||
s.edit(|d, sel| {
|
||||
let mut kept = Vec::new();
|
||||
for id in &ids {
|
||||
let _ = d.remove_item(*id);
|
||||
if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
|
||||
it.content = designcraft_doc::Content::Unassigned;
|
||||
kept.push(*id);
|
||||
} else {
|
||||
let _ = d.remove_item(*id);
|
||||
}
|
||||
}
|
||||
*sel = Selection::default();
|
||||
*sel = Selection::items(kept);
|
||||
Ok(json!({"deleted": ids.len()}))
|
||||
})
|
||||
}),
|
||||
@@ -341,12 +350,15 @@ pub(crate) fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> {
|
||||
let st = s.doc()?;
|
||||
let mut d = (*st.doc).clone();
|
||||
let keep: Vec<ItemId> = st.selection.items.clone();
|
||||
// Gather the selected items (keep their spread coordinates) into spread 0.
|
||||
// Gather the selected items (in their spread coordinates, nested ones too) into spread 0.
|
||||
let mut items = Vec::new();
|
||||
for id in &keep {
|
||||
if let Some(it) = st.doc.item(*id) {
|
||||
items.push(Arc::new(it.clone()));
|
||||
}
|
||||
let (Some(it), Some(loc)) = (st.doc.item(*id), st.doc.find(*id)) else { continue };
|
||||
let parent = st.doc.parent_xf(&loc);
|
||||
items.push(Arc::new(match &it.content {
|
||||
designcraft_doc::Content::Graphic(g) if st.selection.content => graphic_alone(it, g, parent),
|
||||
_ => designcraft_doc::Item { xf: parent * it.xf, ..it.clone() },
|
||||
}));
|
||||
}
|
||||
for sp in d.spreads.iter_mut() {
|
||||
Arc::make_mut(sp).items.clear();
|
||||
@@ -382,6 +394,22 @@ pub(crate) fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> {
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// A placed graphic on its own, as InDesign copies one selected with Direct Selection: a frame of
|
||||
/// the whole graphic's bounds (uncropped), where the graphic sits on the spread (`parent` is the
|
||||
/// frame's parent transform), without the frame's fill, stroke or effects.
|
||||
fn graphic_alone(frame: &designcraft_doc::Item, g: &designcraft_doc::Graphic, parent: designcraft_geom::Affine) -> designcraft_doc::Item {
|
||||
let r = designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1);
|
||||
let mut it = designcraft_doc::Item::new(frame.id, frame.layer, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
|
||||
it.xf = parent * frame.xf * g.xf;
|
||||
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
auto_fit: designcraft_doc::Fitting::None,
|
||||
crop: [0.0; 4],
|
||||
..g.clone()
|
||||
});
|
||||
it
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod text_clipboard_tests {
|
||||
use super::*;
|
||||
@@ -649,6 +677,22 @@ mod text_clipboard_tests {
|
||||
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len());
|
||||
st.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_case_leaves_georgian_without_initial_capitals() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = frame(&mut s, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ word");
|
||||
let len = s.doc().unwrap().doc.story(a).unwrap().len();
|
||||
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap();
|
||||
// Georgian has no title or sentence capitals: Mtavruli is only for all-caps text.
|
||||
s.execute("type.changeCase", &json!({"case": "title"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი Word");
|
||||
s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი word");
|
||||
s.execute("type.changeCase", &json!({"case": "upper"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ WORD");
|
||||
}
|
||||
}
|
||||
|
||||
fn paste_into(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -728,4 +772,44 @@ mod paste_into_tests {
|
||||
assert!(img.pixel(120, 120)[0] > 200, "{:?}", img.pixel(120, 120));
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_graphic_copies_and_clears_on_its_own() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-graphic-alone-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = dir.join("photo.png");
|
||||
std::fs::write(&png, designcraft_render::Rendered { width: 200, height: 100, pixels: vec![40u8; 200 * 100 * 4] }.to_png()).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// A square frame showing the middle of a wide photo (cropped left and right).
|
||||
let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap();
|
||||
s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [frame]})).unwrap();
|
||||
s.execute("object.stroke", &json!({"ids": [frame], "swatch": "[Black]", "weight": 4})).unwrap();
|
||||
// Direct Selection on the photo, Copy: the whole photo, where it is, without the frame's stroke.
|
||||
s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let clip = s.clipboard.clone().unwrap();
|
||||
let it = &clip.spreads[0].items[0];
|
||||
assert!(matches!(&it.content, designcraft_doc::Content::Graphic(g) if g.crop == [0.0; 4]));
|
||||
assert_eq!(it.bounds(), designcraft_geom::Rect::new(50.0, 100.0, 250.0, 200.0));
|
||||
assert!(it.stroke.weight == 0.0 || it.stroke.swatch == "[None]", "{:?}", it.stroke);
|
||||
// Paste Into a shape over it: the photo lands in place, inside the shape.
|
||||
let shape = s.execute("frame.create", &json!({"rect": [60, 90, 240, 210], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [shape]})).unwrap();
|
||||
s.execute("edit.pasteInto", &json!({})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let kid = d.item(ItemId(shape)).unwrap().children()[0].clone();
|
||||
let at = d.parent_xf(&d.find(kid.id).unwrap()) * kid.xf;
|
||||
assert!((at * designcraft_geom::Point::new(0.0, 0.0) - designcraft_geom::Point::new(50.0, 100.0)).hypot() < 1e-6);
|
||||
assert!((at * designcraft_geom::Point::new(200.0, 100.0) - designcraft_geom::Point::new(250.0, 200.0)).hypot() < 1e-6);
|
||||
// Delete with the photo selected: the frame stays, empty.
|
||||
s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap();
|
||||
assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 1);
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert!(matches!(d.item(ItemId(frame)).unwrap().content, designcraft_doc::Content::Unassigned));
|
||||
assert_eq!(s.doc().unwrap().selection, Selection::items(vec![ItemId(frame)]));
|
||||
d.check().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic::write_file(path, &r.bytes)?;
|
||||
crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?;
|
||||
Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings}))
|
||||
}
|
||||
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})),
|
||||
@@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic::write_file(path, text.as_bytes())?;
|
||||
crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?;
|
||||
Ok(json!({"path": path, "bytes": text.len()}))
|
||||
}
|
||||
None => Ok(json!({"bytes": text.len(), "text": text})),
|
||||
@@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match path {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic::write_file(path, text.as_bytes())?;
|
||||
crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?;
|
||||
Ok(json!({"path": path, "bytes": text.len()}))
|
||||
}
|
||||
None => Ok(json!({"bytes": text.len(), "text": text})),
|
||||
@@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?;
|
||||
Ok(serde_json::json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
@@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
crate::atomic::write_file(path, bytes)
|
||||
crate::io::write_file(path, bytes).map_err(EngineError::Other)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
@@ -850,6 +850,45 @@ mod placed_pdf_print_tests {
|
||||
let e = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_err().to_string();
|
||||
assert!(e.contains("logo.pdf is PDF 2.0") && e.contains("PDF 1.6"), "{e}");
|
||||
}
|
||||
|
||||
/// A one-page PDF with a black box whose catalog says `/Version /1.7`, as TCPDF writes it.
|
||||
fn catalog_v17(header: &str) -> Vec<u8> {
|
||||
let content = "0 0 0 1 k 0 0 200 200 re f";
|
||||
let mut pdf = format!("{header}\n").into_bytes();
|
||||
let mut offsets = Vec::new();
|
||||
for body in [
|
||||
"<</Type/Catalog/Pages 2 0 R/Version /1.7>>".to_owned(),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_owned(),
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]/Contents 4 0 R/Resources<<>>>>".to_owned(),
|
||||
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
|
||||
] {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n");
|
||||
for offset in offsets {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<</Size 5/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
pdf
|
||||
}
|
||||
|
||||
/// A placed PDF whose catalog `/Version /1.7` overrides its header failed the PDF/X-4 export
|
||||
/// with "logo.pdf is PDF 1.7", though a 1.7 header alone is relabelled (#907).
|
||||
#[test]
|
||||
fn catalog_version_17_placed_in_pdfx4() {
|
||||
for header in ["%PDF-1.7", "%PDF-1.4"] {
|
||||
let pdf = catalog_v17(header);
|
||||
assert_eq!(hayro_syntax::Pdf::new(pdf.clone()).unwrap().version(), hayro_syntax::PdfVersion::Pdf17);
|
||||
let mut s = page_with(&pdf);
|
||||
let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_or_else(|e| panic!("{header}: {e}"));
|
||||
assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]);
|
||||
let out = base64_decode(r["base64"].as_str().unwrap());
|
||||
assert!(out.starts_with(b"%PDF-1.6"));
|
||||
assert_eq!(hayro_syntax::Pdf::new(out).unwrap().version(), hayro_syntax::PdfVersion::Pdf16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+116
-34
@@ -29,20 +29,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path));
|
||||
let bytes = to_bytes(©);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic::write_file(&path, &bytes)?;
|
||||
crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}),
|
||||
cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert),
|
||||
cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| {
|
||||
let dir = recovery_dir(s, p)?;
|
||||
let dir = recovery_dir(s, p, FolderAccess::Read)?;
|
||||
Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect()))
|
||||
}),
|
||||
cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| {
|
||||
let dir = recovery_dir(s, p)?;
|
||||
let dir = recovery_dir(s, p, FolderAccess::Write)?;
|
||||
Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?}))
|
||||
}),
|
||||
cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| {
|
||||
let dir = recovery_dir(s, p)?;
|
||||
// Reopening removes the entries it read.
|
||||
let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?;
|
||||
Ok(json!({"opened": crate::recovery::open(s, &dir)?}))
|
||||
}),
|
||||
cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| {
|
||||
@@ -102,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+D"),
|
||||
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`",
|
||||
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, loadCursor?: with no frame to fill and no x/y an image loads into the place cursor (File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`",
|
||||
has_doc,
|
||||
file_place
|
||||
),
|
||||
@@ -157,14 +158,30 @@ fn file_sample(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
|
||||
/// An InDesign file that DesignCraft can't open yet, with how to bring it over. Decoding one as a
|
||||
/// DesignCraft document only reported "not a DesignCraft document: expected value at line 1
|
||||
/// column 1" (#21).
|
||||
fn foreign_document(name: &str) -> Option<&'static str> {
|
||||
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str())?.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"indd" | "indt" => Some(
|
||||
"InDesign documents (.indd) and templates (.indt) can't be opened yet. In InDesign, choose File ▸ Export… ▸ InDesign Markup (IDML) and open the .idml in DesignCraft.",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
|
||||
if let Some(hint) = foreign_document(path) {
|
||||
return Err(EngineError::Other(format!("{path}: {hint}")));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if path.to_ascii_lowercase().ends_with(".idml") {
|
||||
return super::interchange::open_idml(s, p);
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let mut d = from_bytes(&bytes)?;
|
||||
super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent());
|
||||
super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path)));
|
||||
@@ -189,7 +206,10 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c
|
||||
{
|
||||
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
|
||||
let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) };
|
||||
let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder);
|
||||
// A document's folder is the agent's to name (a recovery entry or a book lists paths too):
|
||||
// the fonts crate asks the automation roots about the folder and each font file, so fonts
|
||||
// outside the read root stay unloaded.
|
||||
let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|p| crate::file_access::check_read(p));
|
||||
d.font_scope = r.scope;
|
||||
let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope)));
|
||||
(scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect())
|
||||
@@ -207,18 +227,23 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c
|
||||
fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf {
|
||||
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
|
||||
let exact = dir.join(DOCUMENT_FONTS_FOLDER);
|
||||
if exact.is_dir() {
|
||||
if crate::io::is_dir(&exact) {
|
||||
return exact;
|
||||
}
|
||||
std::fs::read_dir(dir)
|
||||
crate::io::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && e.path().is_dir())
|
||||
.find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path()))
|
||||
.map_or(exact, |e| e.path())
|
||||
}
|
||||
|
||||
fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(name) = str_param(p, "name")
|
||||
&& let Some(hint) = foreign_document(name)
|
||||
{
|
||||
return Err(EngineError::Other(format!("{name}: {hint}")));
|
||||
}
|
||||
let b = base64_decode(str_param(p, "base64").unwrap_or(""));
|
||||
// IDML packages are recognised by their stored `mimetype` first entry.
|
||||
if designcraft_idml::is_idml(&b) {
|
||||
@@ -239,6 +264,8 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(str::to_string)
|
||||
.or_else(|| st.path.clone())
|
||||
.ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?;
|
||||
// Before anything about the document changes: a refused save leaves it as it was.
|
||||
crate::file_access::check_write(&path).map_err(EngineError::Other)?;
|
||||
// Prepare paths for the destination without changing the live document until persistence succeeds.
|
||||
let mut saved = (*st.doc).clone();
|
||||
super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path));
|
||||
@@ -246,7 +273,7 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() };
|
||||
let bytes = to_bytes(&saved);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic::write_file(&path, &bytes)?;
|
||||
crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?;
|
||||
if paths_changed {
|
||||
st.doc = saved;
|
||||
st.revision = st.revision.saturating_add(1);
|
||||
@@ -268,7 +295,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)
|
||||
} else if let Some(path) = str_param(p, "path") {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let b: Vec<u8> = vec![];
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into());
|
||||
@@ -340,6 +367,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let px = p.get("x").and_then(Value::as_f64);
|
||||
let py = p.get("y").and_then(Value::as_f64);
|
||||
let want_w = p.get("width").and_then(Value::as_f64);
|
||||
if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) {
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
let asset = Asset { page: pdf_page, id: AssetId(0), name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) };
|
||||
return load_cursor(s, asset, (nw, nh), crop_box);
|
||||
}
|
||||
s.edit(|d, sel| {
|
||||
let fresh = AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
@@ -435,7 +467,7 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)
|
||||
}
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let b: Vec<u8> = vec![];
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into());
|
||||
@@ -446,18 +478,32 @@ fn place_load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, name, link) = read_source(p)?;
|
||||
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?;
|
||||
let natural = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64));
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
let asset = Asset { page: 0, id: AssetId(0), name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) };
|
||||
load_cursor(s, asset, natural, None)
|
||||
}
|
||||
|
||||
/// Adds `asset` to the document (with a fresh id) and loads it into the place cursor.
|
||||
fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64), crop: Option<(f64, f64, f64, f64)>) -> Result<Value> {
|
||||
let name = asset.name.clone();
|
||||
let (pw, ph) = asset.pixels.unwrap_or_default();
|
||||
let aid = s.edit(|d, _| {
|
||||
let fresh = AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
Ok(d.add_asset(Asset { page: 0, id: fresh, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }))
|
||||
asset.id = AssetId(d.alloc());
|
||||
Ok(d.add_asset(asset))
|
||||
})?;
|
||||
s.loaded = Some((aid, natural));
|
||||
s.loaded = Some(crate::LoadedGraphic { asset: aid, size, crop });
|
||||
s.set_tool("placeGun");
|
||||
Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph}))
|
||||
Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph, "loaded": true}))
|
||||
}
|
||||
|
||||
fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (aid, (nw, nh)) = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?;
|
||||
let crate::LoadedGraphic { asset: aid, size: (nw, nh), crop } =
|
||||
s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?;
|
||||
// Undo can remove the loaded graphic's asset.
|
||||
if !s.doc()?.doc.assets.contains_key(&aid) {
|
||||
s.loaded = None;
|
||||
return Err(bad("place.drop", "the loaded graphic is no longer in the document"));
|
||||
}
|
||||
let sr = super::spread_param(p, "place.drop")?;
|
||||
let lid = s.doc()?.active_layer;
|
||||
let frame = super::id_param(p, "frame");
|
||||
@@ -481,12 +527,14 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => {
|
||||
// Drag: fit proportionally into the dragged rect; click: actual size at the point.
|
||||
// The frame shows the crop box when one was chosen.
|
||||
let (bx, by, bw, bh) = crop.unwrap_or((0.0, 0.0, nw, nh));
|
||||
let (frame_r, k) = match rect {
|
||||
Some(r) if r.width() > 2.0 && r.height() > 2.0 => {
|
||||
let k = (r.width() / nw).min(r.height() / nh);
|
||||
(Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k)
|
||||
let k = (r.width() / bw).min(r.height() / bh);
|
||||
(Rect::new(r.x0, r.y0, r.x0 + bw * k, r.y0 + bh * k), k)
|
||||
}
|
||||
_ => (Rect::new(x, y, x + nw, y + nh), 1.0),
|
||||
_ => (Rect::new(x, y, x + bw, y + bh), 1.0),
|
||||
};
|
||||
let id = ItemId(d.alloc());
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_r));
|
||||
@@ -494,7 +542,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((frame_r.x0, frame_r.y0)) * Affine::scale(k),
|
||||
xf: Affine::translate((frame_r.x0 - bx * k, frame_r.y0 - by * k)) * Affine::scale(k),
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
@@ -522,7 +570,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
crate::io::write_file(path, &bytes).map_err(EngineError::Other)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)}))
|
||||
}
|
||||
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
@@ -534,7 +582,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
const ID: &str = "file.place";
|
||||
let src = if name.to_lowercase().ends_with(".idml") {
|
||||
designcraft_idml::import_idml(bytes).map_err(|e| bad(ID, e.to_string()))?
|
||||
super::interchange::import_idml_confined(bytes).map_err(|e| bad(ID, e.to_string()))?
|
||||
} else {
|
||||
from_bytes(bytes)?
|
||||
};
|
||||
@@ -612,17 +660,36 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn recovery_dir(s: &Session, p: &Value) -> Result<std::path::PathBuf> {
|
||||
/// Which access a recovery command needs to the folder it is given.
|
||||
#[derive(Clone, Copy)]
|
||||
enum FolderAccess {
|
||||
Read,
|
||||
Write,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
/// The recovery folder: `dir` when the command names one, else the session's own (set by the
|
||||
/// host application, so not the automation roots' business). A named folder is checked against
|
||||
/// the roots for `access`.
|
||||
fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result<std::path::PathBuf> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (s, p);
|
||||
let _ = (s, p, access);
|
||||
Err(EngineError::Other("no recovery folder on the web".into()))
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
str_param(p, "dir")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| s.recovery_dir.clone())
|
||||
.ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)"))
|
||||
{
|
||||
let Some(named) = str_param(p, "dir") else {
|
||||
return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)"));
|
||||
};
|
||||
if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) {
|
||||
crate::file_access::check_read(named).map_err(EngineError::Other)?;
|
||||
}
|
||||
if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) {
|
||||
crate::file_access::check_write(named).map_err(EngineError::Other)?;
|
||||
}
|
||||
Ok(std::path::PathBuf::from(named))
|
||||
}
|
||||
}
|
||||
|
||||
fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -639,10 +706,10 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut d = if path.to_ascii_lowercase().ends_with(".idml") {
|
||||
let bytes = std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?;
|
||||
super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
} else {
|
||||
from_bytes(&std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?)?
|
||||
from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)?
|
||||
};
|
||||
// As opened again: the fonts beside it now (unchanged files are shared, not read again).
|
||||
let (fonts, _, _) = load_document_fonts(&mut d, &path);
|
||||
@@ -866,6 +933,21 @@ mod document_fonts_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_indesign_file_says_how_to_bring_it_over() {
|
||||
// The extension decides (any case), not the bytes: no InDesign-made file is needed.
|
||||
let dir = temp_dir("indd");
|
||||
let path = dir.join("Brochure.INDD");
|
||||
std::fs::write(&path, b"not a DesignCraft document").unwrap();
|
||||
let mut s = Session::new();
|
||||
let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(e.contains("InDesign Markup (IDML)") && !e.contains("expected value"), "{e}");
|
||||
let e = s.execute("file.openBytes", &json!({"name": "Brochure.indt", "base64": ""})).unwrap_err().to_string();
|
||||
assert!(e.contains("InDesign Markup (IDML)"), "{e}");
|
||||
assert!(s.doc().is_err(), "nothing was opened");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_document_fonts_folder_is_found_whatever_its_case() {
|
||||
const FAMILY: &str = "DocFont Case Test";
|
||||
|
||||
@@ -73,7 +73,7 @@ fn list(d: &Document) -> Vec<Value> {
|
||||
.into_iter()
|
||||
.map(|((family, style), n)| {
|
||||
let missing = !db.has_family(&family);
|
||||
let style_missing = !missing && !db.styles(&family).iter().any(|s| s.eq_ignore_ascii_case(&style));
|
||||
let style_missing = !missing && !db.styles(&family).iter().any(|s| designcraft_fonts::same_style(s, &style));
|
||||
let source = (!missing).then(|| match db.face(&family, &style).source {
|
||||
FontSource::Bundled => "bundled",
|
||||
FontSource::Installed(_) => "installed",
|
||||
@@ -225,4 +225,22 @@ mod tests {
|
||||
assert_eq!((&l[0]["family"], &l[0]["missing"]), (&json!("Nope (OTF)"), &json!(true)), "{l}");
|
||||
assert!(kinds(&mut s).iter().any(|k| k == "missingFont"));
|
||||
}
|
||||
|
||||
/// A style name that differs only in spacing (Montilla Design's file sets `Regular ` with a
|
||||
/// trailing space) is the face the text is drawn in, not a missing style.
|
||||
#[test]
|
||||
fn a_style_with_extra_spacing_is_not_missing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Text"})).unwrap();
|
||||
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap();
|
||||
s.execute("type.char", &json!({"fontFamily": "Source Serif 4", "fontStyle": "Regular "})).unwrap();
|
||||
let l = s.execute("font.list", &json!({})).unwrap();
|
||||
let entry = l.as_array().unwrap().iter().find(|f| f["style"] == "Regular ").unwrap();
|
||||
assert_eq!((&entry["missing"], &entry["styleMissing"]), (&json!(false), &json!(false)), "{l}");
|
||||
s.execute("type.char", &json!({"fontFamily": "Source Serif 4", "fontStyle": "Nope"})).unwrap();
|
||||
let l = s.execute("font.list", &json!({})).unwrap();
|
||||
assert_eq!(l[0]["styleMissing"], json!(true), "{l}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,13 +25,19 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic::write_file(path, &bytes)?;
|
||||
crate::io::write_file(path, &bytes).map_err(EngineError::Other)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`], so the
|
||||
/// automation read root applies to the paths the package names.
|
||||
pub(crate) fn import_idml_confined(bytes: &[u8]) -> designcraft_idml::Result<Document> {
|
||||
designcraft_idml::import_idml_with(bytes, &|path| crate::io::read_file(path).ok())
|
||||
}
|
||||
|
||||
/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup).
|
||||
pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
import_report(bytes, dir).map(|(document, _)| document)
|
||||
@@ -112,10 +118,10 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std::
|
||||
let Some(dir) = dir else { return };
|
||||
for asset in document.assets.values_mut() {
|
||||
let Some(link) = asset.link.as_deref() else { continue };
|
||||
if link.len() > MAX_LINK_PATH_BYTES || std::path::Path::new(link).is_file() {
|
||||
if link.len() > MAX_LINK_PATH_BYTES || crate::io::is_file(link) {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| p.is_file()) else { continue };
|
||||
let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| crate::io::is_file(p)) else { continue };
|
||||
// Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI.
|
||||
let path = std::path::absolute(&path).unwrap_or(path);
|
||||
std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned());
|
||||
@@ -125,7 +131,7 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std::
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec<u8>)> {
|
||||
for path in packaged_link_candidates(link, dir) {
|
||||
if let Ok(bytes) = std::fs::read(&path) {
|
||||
if let Ok(bytes) = crate::io::read_file(&path) {
|
||||
// Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize
|
||||
// would introduce a verbatim prefix that is not an IDML file URI.
|
||||
let path = std::path::absolute(&path).unwrap_or(path);
|
||||
@@ -140,7 +146,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(base64_decode(b), None, str_param(p, "name").map(|n| n.trim_end_matches(".idml").to_string()))
|
||||
} else if let Some(path) = str_param(p, "path") {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let b: Vec<u8> = Vec::new();
|
||||
let pp = std::path::Path::new(path);
|
||||
|
||||
@@ -88,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize;
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.page_count();
|
||||
let after = p.get("after").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(n - 1);
|
||||
let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1));
|
||||
let parent = parent_ref(&st.doc, p.get("parent")).unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
|
||||
s.edit(|d, _| {
|
||||
let ids = d.insert_pages(Some(after), count, parent)?;
|
||||
@@ -476,11 +476,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let start = page - 1;
|
||||
let p = p.clone();
|
||||
s.edit(|d, _| {
|
||||
// The settings the command does not name stay as the section was: read it
|
||||
// before the list is rebuilt (#717).
|
||||
let existing = d.sections.iter().find(|x| x.start == start).cloned();
|
||||
d.sections.retain(|x| x.start != start || start == 0);
|
||||
if p.get("remove").and_then(Value::as_bool).unwrap_or(false) && start != 0 {
|
||||
// The pages after it follow the previous section's numbers again.
|
||||
d.repaginate();
|
||||
return ok();
|
||||
}
|
||||
let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section {
|
||||
let mut sec = existing.unwrap_or(designcraft_doc::Section {
|
||||
start,
|
||||
start_number: None,
|
||||
style: Default::default(),
|
||||
@@ -508,6 +513,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
d.sections.retain(|x| x.start != start);
|
||||
d.sections.push(sec);
|
||||
d.sections.sort_by_key(|x| x.start);
|
||||
// Facing pages take their sides from their numbers: odd on the right.
|
||||
d.repaginate();
|
||||
Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::<Vec<_>>()}))
|
||||
})
|
||||
}
|
||||
@@ -1034,6 +1041,28 @@ mod page_numbering_view_tests {
|
||||
assert_eq!(s.resolve_page("1"), Some(0));
|
||||
assert_eq!(s.resolve_page("9"), None);
|
||||
}
|
||||
|
||||
/// `layout.section` edits one named setting; the others stay as the section was (#717).
|
||||
#[test]
|
||||
fn updating_a_section_keeps_settings_the_command_does_not_name() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 4})).unwrap();
|
||||
s.execute(
|
||||
"layout.section",
|
||||
&json!({"page": 2, "startNumber": 1, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "marker": "First"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.page_label(1), "A-i");
|
||||
s.execute("layout.section", &json!({"page": 2, "marker": "Second"})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let sec = d.sections.iter().find(|x| x.start == 1).expect("the page-2 section is still the only one there");
|
||||
assert_eq!(sec.marker, "Second");
|
||||
assert_eq!(sec.start_number, Some(1), "startNumber is kept");
|
||||
assert_eq!(sec.style, designcraft_doc::NumberStyle::LowerRoman, "style is kept");
|
||||
assert_eq!(sec.prefix, "A-", "prefix is kept");
|
||||
assert!(sec.include_prefix, "includePrefix is kept");
|
||||
assert_eq!(s.page_label(1), "A-i");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1060,3 +1089,89 @@ mod adjust_tests {
|
||||
assert_eq!(b, b2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod section_side_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Each spread's pages as (name, side), side `L`, `R` or `S`.
|
||||
fn spreads(s: &Session) -> Vec<Vec<(String, char)>> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut abs = 0;
|
||||
d.spreads
|
||||
.iter()
|
||||
.map(|sp| {
|
||||
sp.pages
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let side = match p.side {
|
||||
designcraft_doc::PageSide::Left => 'L',
|
||||
designcraft_doc::PageSide::Right => 'R',
|
||||
designcraft_doc::PageSide::Single => 'S',
|
||||
};
|
||||
abs += 1;
|
||||
(d.page_name(abs - 1), side)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pages(v: &[&[(&str, char)]]) -> Vec<Vec<(String, char)>> {
|
||||
v.iter().map(|sp| sp.iter().map(|(n, c)| (n.to_string(), *c)).collect()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_starting_at_one_on_a_left_page_moves_it_to_the_right() {
|
||||
// Chris Nielsen's "Numbering Page Sections" (7:03): with the spread 8–9 selected, the
|
||||
// section starts on page 8, which becomes a lone right-hand page 1; the pages after it
|
||||
// shuffle and their objects go with them. Undo puts them back.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": true, "pages": 10})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"spread": 4, "rect": [700, 100, 800, 200], "content": "text", "text": "TOPIC"})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let page_of = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.page_of_item(designcraft_doc::ItemId(id)).unwrap()
|
||||
};
|
||||
assert_eq!(page_of(&s), 8, "page 9, the right page of the spread 8–9");
|
||||
let before = spreads(&s);
|
||||
s.execute("layout.section", &json!({"page": 8, "startNumber": 1})).unwrap();
|
||||
assert_eq!(
|
||||
spreads(&s),
|
||||
pages(&[
|
||||
&[("1", 'R')],
|
||||
&[("2", 'L'), ("3", 'R')],
|
||||
&[("4", 'L'), ("5", 'R')],
|
||||
&[("6", 'L'), ("7", 'R')],
|
||||
&[("1", 'R')],
|
||||
&[("2", 'L'), ("3", 'R')],
|
||||
])
|
||||
);
|
||||
assert_eq!(page_of(&s), 8, "the object stays on its page, now the left page of a spread");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spreads(&s), before);
|
||||
// On a right page (the video's page 9) nothing moves; an even start on a right page moves
|
||||
// it to the left.
|
||||
s.execute("layout.section", &json!({"page": 9, "startNumber": 1})).unwrap();
|
||||
assert_eq!(spreads(&s)[4], pages(&[&[("8", 'L'), ("1", 'R')]])[0]);
|
||||
s.execute("layout.section", &json!({"page": 9, "startNumber": 2})).unwrap();
|
||||
assert_eq!(spreads(&s)[4..], pages(&[&[("8", 'L')], &[("2", 'L'), ("3", 'R')]]));
|
||||
// Removing the section numbers the pages on from 9 again.
|
||||
s.execute("layout.section", &json!({"page": 9, "remove": true})).unwrap();
|
||||
assert_eq!(spreads(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_sided_documents_keep_their_pages() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"facingPages": false, "pages": 4})).unwrap();
|
||||
s.execute("layout.section", &json!({"page": 2, "startNumber": 1})).unwrap();
|
||||
let sp = spreads(&s);
|
||||
assert_eq!(sp.len(), 4);
|
||||
assert!(sp.iter().all(|p| p.len() == 1 && p[0].1 == 'S'), "{sp:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ impl Library {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(p) = &self.path {
|
||||
let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
crate::atomic::write_file(p, &bytes)?;
|
||||
crate::io::write_file(p, &bytes).map_err(crate::EngineError::Other)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
(Some(j), _) => (j.to_string(), None),
|
||||
(None, Some(path)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let t = std::fs::read_to_string(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
|
||||
let t = crate::io::read_to_string(path).map_err(crate::EngineError::Other)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let t = String::new();
|
||||
(t, Some(path.to_string()))
|
||||
|
||||
@@ -93,11 +93,11 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str {
|
||||
let Some(path) = &a.link else { return "embedded" };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
match std::fs::metadata(path) {
|
||||
match crate::io::metadata(path) {
|
||||
Err(_) => "missing",
|
||||
Ok(m) if m.len() as usize != a.data.len() => "modified",
|
||||
// Same size: compare the bytes (cheap for the sizes involved, and exact).
|
||||
Ok(_) => match std::fs::read(path) {
|
||||
Ok(_) => match crate::io::read_file(path) {
|
||||
Ok(b) if b == *a.data => "ok",
|
||||
Ok(_) => "modified",
|
||||
Err(_) => "missing",
|
||||
@@ -145,7 +145,7 @@ fn list(d: &Document) -> Vec<Value> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn reload(path: &str) -> Result<(Vec<u8>, (u32, u32))> {
|
||||
let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{path}: unsupported or corrupt image")))?;
|
||||
Ok((b, px))
|
||||
}
|
||||
@@ -174,6 +174,10 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = str_param(p, "dir").map(std::path::PathBuf::from);
|
||||
if let Some(dir) = &dir {
|
||||
// Refused outright: candidates outside the automation read root would only look missing.
|
||||
crate::file_access::check_read(dir).map_err(EngineError::Other)?;
|
||||
}
|
||||
let ext = str_param(p, "extension").map(|e| e.trim_start_matches('.').to_string());
|
||||
if dir.is_none() && ext.is_none() {
|
||||
return Err(bad("links.relinkFolder", "give `dir` and/or `extension`"));
|
||||
@@ -194,7 +198,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let folder = dir.clone().or_else(|| old.parent().map(|p| p.to_path_buf())).unwrap_or_default();
|
||||
let cand = folder.join(&name);
|
||||
if cand.exists() {
|
||||
if crate::io::exists(&cand) {
|
||||
todo.push((a.id, cand));
|
||||
} else {
|
||||
not_found.push(name.to_string_lossy().to_string());
|
||||
@@ -427,4 +431,46 @@ mod shared_asset_tests {
|
||||
assert_eq!((g.asset, g.crop), (same, [1.0; 4]));
|
||||
assert_eq!(dst.assets.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_missing_graphic_does_not_show_an_unrelated_target_asset() {
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{Asset, Graphic, Item, ItemId, Shape, SpreadRef};
|
||||
let mut src = Document::new(&NewDocument::default());
|
||||
let mut dst = Document::new(&NewDocument::default());
|
||||
// The source graphic points at an asset the source does not have; the target uses that id for another image.
|
||||
let missing = AssetId(src.alloc());
|
||||
dst.assets.insert(
|
||||
missing,
|
||||
Arc::new(Asset {
|
||||
id: missing,
|
||||
name: "p.png".into(),
|
||||
mime: "image/png".into(),
|
||||
link: None,
|
||||
data: Arc::new(png(8, 4)),
|
||||
pixels: None,
|
||||
page: 0,
|
||||
}),
|
||||
);
|
||||
let iid = ItemId(src.alloc());
|
||||
let mut it = Item::new(
|
||||
iid,
|
||||
src.default_layer(),
|
||||
Shape::Rectangle,
|
||||
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
|
||||
);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: missing,
|
||||
size: (4.0, 4.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [1.0; 4],
|
||||
});
|
||||
src.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
let ids = super::super::object::duplicate_from(&mut dst, &src, &[iid], SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap();
|
||||
let g = dst.item(ids[0]).and_then(|i| i.graphic()).unwrap();
|
||||
assert_ne!(g.asset, missing);
|
||||
assert!(!dst.assets.contains_key(&g.asset));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::sync::Arc;
|
||||
|
||||
use designcraft_color::BlendMode;
|
||||
use designcraft_doc::{
|
||||
Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, TextSel,
|
||||
AssetId, Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions,
|
||||
TextSel,
|
||||
};
|
||||
use designcraft_geom::{Affine, Point, Rect, Vec2, shapes};
|
||||
use serde_json::{Value, json};
|
||||
@@ -994,13 +995,24 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The id to reference in `dst` for asset `id` of `src`. When `src` lacks the asset (a missing graphic), an id that `dst`
|
||||
/// already uses for a different asset is swapped for a fresh unused one so the reference stays missing instead of showing
|
||||
/// an unrelated image.
|
||||
fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId {
|
||||
match dst.adopt_asset(src, id) {
|
||||
Some(a) => a,
|
||||
None if dst.assets.contains_key(&id) => AssetId(dst.alloc()),
|
||||
None => id,
|
||||
}
|
||||
}
|
||||
|
||||
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
|
||||
it.id = ItemId(dst.alloc());
|
||||
let my_id = it.id;
|
||||
if let Some(m) = it.media.as_mut()
|
||||
&& let Some(p) = m.poster
|
||||
{
|
||||
m.poster = dst.adopt_asset(src, p).or(Some(p));
|
||||
m.poster = Some(adopt_or_dangle(dst, src, p));
|
||||
}
|
||||
match &mut it.content {
|
||||
Content::Text(tf) => {
|
||||
@@ -1012,9 +1024,8 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has
|
||||
st.id = n;
|
||||
let mut assets = HashMap::new();
|
||||
for a in designcraft_doc::story_asset_refs(&st) {
|
||||
if let Some(na) = dst.adopt_asset(src, a)
|
||||
&& na != a
|
||||
{
|
||||
let na = adopt_or_dangle(dst, src, a);
|
||||
if na != a {
|
||||
assets.insert(a, na);
|
||||
}
|
||||
}
|
||||
@@ -1031,9 +1042,7 @@ fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut Has
|
||||
}
|
||||
}
|
||||
Content::Graphic(g) => {
|
||||
if let Some(a) = dst.adopt_asset(src, g.asset) {
|
||||
g.asset = a;
|
||||
}
|
||||
g.asset = adopt_or_dangle(dst, src, g.asset);
|
||||
}
|
||||
Content::Group { items } => {
|
||||
for c in items.iter_mut() {
|
||||
@@ -1517,6 +1526,20 @@ fn unlocked(d: &Document, it: &Item) -> bool {
|
||||
!it.locked && d.layer(it.layer).is_none_or(|l| !l.locked)
|
||||
}
|
||||
|
||||
/// Refuses a path edit on `id` when it, a group it sits in, or its layer is locked.
|
||||
pub(super) fn path_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> {
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let mut items = d.spread(loc.spread).map(|sp| &sp.items[..]).unwrap_or_default();
|
||||
for &i in &loc.path {
|
||||
let Some(it) = items.get(i) else { break };
|
||||
if !unlocked(d, it) {
|
||||
return Err(bad(cmd, "the path is locked"));
|
||||
}
|
||||
items = it.children();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_editable_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?;
|
||||
@@ -2103,6 +2126,7 @@ fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64);
|
||||
s.edit(|d, _| {
|
||||
path_editable(d, id, "path.appendAnchor")?;
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
@@ -2138,12 +2162,10 @@ fn path_set_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64);
|
||||
let which = p.get("anchor").cloned().unwrap_or(Value::Null);
|
||||
s.edit(|d, _| {
|
||||
path_editable(d, id, ID)?;
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
if it.locked {
|
||||
return Err(bad(ID, "the path is locked"));
|
||||
}
|
||||
let sp = match subpath {
|
||||
Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)),
|
||||
None => it.path.subpaths.last_mut(),
|
||||
@@ -2185,12 +2207,19 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad("path.reverse", "no such subpath"));
|
||||
}
|
||||
let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids };
|
||||
let ids: Vec<ItemId> = if only.is_some() { ids.into_iter().take(1).collect() } else { ids };
|
||||
let doc = &s.doc()?.doc;
|
||||
let editable: Vec<ItemId> = ids.iter().copied().filter(|id| path_editable(doc, *id, "path.reverse").is_ok()).collect();
|
||||
if editable.is_empty()
|
||||
&& let Some(id) = ids.first()
|
||||
{
|
||||
path_editable(doc, *id, "path.reverse")?;
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
let mut reversed = 0;
|
||||
for id in &ids {
|
||||
for id in &editable {
|
||||
let Some(it) = d.item_mut(*id) else { continue };
|
||||
if it.locked || it.is_group() {
|
||||
if it.is_group() {
|
||||
continue;
|
||||
}
|
||||
for (i, sp) in it.path.subpaths.iter_mut().enumerate() {
|
||||
@@ -2212,6 +2241,7 @@ fn path_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
|
||||
let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX));
|
||||
s.edit(|d, _| {
|
||||
path_editable(d, id, "path.close")?;
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
if only.is_some_and(|i| i >= it.path.subpaths.len()) {
|
||||
return Err(bad("path.close", "no such subpath"));
|
||||
@@ -2234,6 +2264,7 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let handle = str_param(p, "handle").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
path_editable(d, id, "path.moveAnchors")?;
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
|
||||
// Spread-space delta → inner-space delta (linear part only).
|
||||
@@ -3328,6 +3359,50 @@ mod path_edit_tests {
|
||||
assert!(s.execute("path.appendAnchor", &json!({"id": id, "anchor": {"p": [0, 0]}})).is_err());
|
||||
assert!(s.execute("path.close", &json!({"id": id, "subpath": 4})).is_err());
|
||||
}
|
||||
|
||||
/// Every edit that `path_editable` guards, aimed at the path `id` of `session_with_path`.
|
||||
fn path_edits(id: u64) -> Vec<(&'static str, serde_json::Value)> {
|
||||
vec![
|
||||
("path.appendAnchor", json!({"id": id, "anchor": {"p": [400, 250]}})),
|
||||
("path.setAnchor", json!({"id": id, "anchor": 0, "p": [90, 110]})),
|
||||
("path.close", json!({"id": id})),
|
||||
("path.moveAnchors", json!({"id": id, "anchors": [[0, 0]], "dx": 10, "dy": 10})),
|
||||
("path.reverse", json!({"ids": [id]})),
|
||||
("path.addAnchor", json!({"id": id, "at": [150, 100]})),
|
||||
("path.deleteAnchor", json!({"id": id, "subpath": 0, "anchor": 0})),
|
||||
("path.convertAnchor", json!({"id": id, "subpath": 0, "anchor": 0})),
|
||||
("path.erase", json!({"id": id, "points": [[120, 100], [180, 100]]})),
|
||||
("path.smooth", json!({"id": id, "points": [[120, 100], [180, 100]]})),
|
||||
("path.split", json!({"id": id, "at": [150, 100]})),
|
||||
]
|
||||
}
|
||||
|
||||
fn assert_no_path_edit(s: &mut crate::Session, id: u64, why: &str) {
|
||||
let before = anchors(s, id);
|
||||
for (cmd, p) in path_edits(id) {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {why}");
|
||||
}
|
||||
assert_eq!(anchors(s, id), before, "{why}");
|
||||
assert!(!s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].closed, "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_edits_leave_a_path_on_a_locked_layer_alone() {
|
||||
let (mut s, id) = session_with_path();
|
||||
let layer = s.doc().unwrap().doc.item(ItemId(id)).unwrap().layer;
|
||||
s.execute("layer.set", &json!({"id": layer.0, "locked": true})).unwrap();
|
||||
assert_no_path_edit(&mut s, id, "on a locked layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_edits_leave_a_path_in_a_locked_group_alone() {
|
||||
let (mut s, id) = session_with_path();
|
||||
let other = s.execute("frame.create", &json!({"rect": [400, 400, 450, 450]})).unwrap()["id"].as_u64().unwrap();
|
||||
let group = s.execute("object.group", &json!({"ids": [id, other]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.lock", &json!({"ids": [group]})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.item(ItemId(id)).unwrap().locked, "only the group is locked");
|
||||
assert_no_path_edit(&mut s, id, "in a locked group");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -74,6 +74,8 @@ struct OutputRoot {
|
||||
|
||||
impl OutputRoot {
|
||||
fn new(path: PathBuf) -> Result<Self> {
|
||||
// Before anything is created: the folder must be inside the automation write root.
|
||||
crate::file_access::check_write(&path).map_err(EngineError::Other)?;
|
||||
ensure_real_directory(&path, false)?;
|
||||
let canonical = std::fs::canonicalize(&path).map_err(|e| path_error(&path, e))?;
|
||||
Ok(Self { path, canonical })
|
||||
@@ -292,6 +294,11 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset
|
||||
continue;
|
||||
}
|
||||
let Some(a) = d.assets.get(&id) else { continue };
|
||||
// A missing link has no bytes: an empty file under its name would read as present, so it
|
||||
// keeps its old (missing) path instead.
|
||||
if a.data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken);
|
||||
let path = dir.join(&name);
|
||||
root.write(&path, a.data.as_slice())?;
|
||||
@@ -472,6 +479,49 @@ mod tests {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A link whose file is gone has no bytes to copy: the package leaves it out of Links/, the
|
||||
/// report lists it as missing, and the packaged document keeps it missing (#908).
|
||||
#[test]
|
||||
fn package_leaves_missing_links_missing() {
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("dc-package-missing-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40"/></svg>"##;
|
||||
let (gone, kept) = (dir.join("gone.svg"), dir.join("kept.svg"));
|
||||
std::fs::write(&gone, svg).unwrap();
|
||||
std::fs::write(&kept, svg.replace("<rect", "<circle r=\"9\"/><rect")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"title": "TwoLinks"})).unwrap();
|
||||
s.execute("file.place", &json!({"path": gone.to_string_lossy(), "x": 10, "y": 10})).unwrap();
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
s.execute("file.place", &json!({"path": kept.to_string_lossy(), "x": 10, "y": 200})).unwrap();
|
||||
let idml = dir.join("TwoLinks.idml");
|
||||
s.execute("file.exportIdml", &json!({"path": idml.to_string_lossy(), "embedImages": false})).unwrap();
|
||||
std::fs::remove_file(&gone).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.openIdml", &json!({"path": idml.to_string_lossy()})).unwrap();
|
||||
let status = |s: &mut Session, name: &str| {
|
||||
let links = s.execute("links.list", &json!({})).unwrap();
|
||||
links.as_array().unwrap().iter().find(|l| l["name"] == name).map(|l| l["status"].clone())
|
||||
};
|
||||
assert_eq!(status(&mut s, "gone.svg"), Some(json!("missing")));
|
||||
|
||||
let out = dir.join("Pkg");
|
||||
let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": true})).unwrap();
|
||||
assert!(!out.join("Links").join("gone.svg").exists(), "a missing link is not copied");
|
||||
assert!(out.join("Links").join("kept.svg").exists());
|
||||
assert!(!r["files"].to_string().contains("gone.svg"), "{}", r["files"]);
|
||||
let report = r["report"].as_str().unwrap();
|
||||
assert!(report.contains("gone.svg (missing)"), "{report}");
|
||||
for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] {
|
||||
let mut opened = Session::new();
|
||||
opened.execute(command, &json!({"path": out.join(format!("TwoLinks.{extension}")).to_string_lossy()})).unwrap();
|
||||
assert_eq!(status(&mut opened, "gone.svg"), Some(json!("missing")), "{extension}");
|
||||
assert_eq!(status(&mut opened, "kept.svg"), Some(json!("ok")), "{extension}");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_writes_document_links_and_report() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id()));
|
||||
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pathfinder",
|
||||
["Object", "Pathfinder"],
|
||||
None,
|
||||
"{op: add|subtract|intersect|exclude|minusBack, ids?} — combine the selected shapes into one (the backmost keeps its look; the frontmost for minusBack) → {id}",
|
||||
"{op: add|subtract|intersect|exclude|minusBack, ids?} — combine the selected shapes into one, which keeps the frontmost object's look and content (a placed graphic stays in it); Subtract keeps the backmost → {id}",
|
||||
has_selection,
|
||||
pathfinder
|
||||
),
|
||||
@@ -145,7 +145,9 @@ fn pathfinder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if order.len() < 2 {
|
||||
return Err(bad("object.pathfinder", "select two or more shapes on one spread (not text frames or groups)"));
|
||||
}
|
||||
let keep = if op == Op::MinusBack { order[order.len() - 1] } else { order[0] };
|
||||
// InDesign: the result takes the frontmost object's look and content (a photo placed over
|
||||
// shapes shows through their intersection); Subtract keeps the backmost, the front cuts it.
|
||||
let keep = if op == Op::Subtract { order[0] } else { order[order.len() - 1] };
|
||||
let shapes: Vec<PathData> =
|
||||
order.iter().filter_map(|id| spread_xf(d, *id).and_then(|(_, xf)| Some(d.item(*id)?.path.transformed(xf)))).collect();
|
||||
let Some(result) = combine(op, &shapes) else { return Err(bad("object.pathfinder", "the shapes don't overlap: nothing is left")) };
|
||||
@@ -246,7 +248,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cs = designcraft_compose::compose_story(d, tf.story, &Default::default());
|
||||
let Some(ft) = cs.frame(*id) else { continue };
|
||||
// One path per text look (fill, stroke).
|
||||
let mut runs: Vec<((String, f32, String, f32, f64), BezPath)> = Vec::new();
|
||||
let mut runs: Vec<((String, f32, String, f32, f64, bool), BezPath)> = Vec::new();
|
||||
for l in &ft.lines {
|
||||
for g in l.glyphs.iter().filter(|g| g.visible) {
|
||||
let st = &cs.styles[g.style as usize];
|
||||
@@ -256,7 +258,7 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy);
|
||||
let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight);
|
||||
let key = (st.fill.clone(), st.fill_tint, st.stroke.clone(), st.stroke_tint, st.stroke_weight, st.stroke_behind_fill);
|
||||
let k = match runs.iter().position(|r| r.0 == key) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
@@ -274,14 +276,16 @@ fn create_outlines(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (sr, _) = spread_xf(d, *id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
|
||||
let at = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| i.id == *id));
|
||||
let text_xf = d.text_xf(&it);
|
||||
let make = |d: &mut Document, ((fill, ft, stroke, stt, sw), bp): ((String, f32, String, f32, f64), BezPath)| {
|
||||
let make = |d: &mut Document, ((fill, ft, stroke, stt, sw, behind), bp): ((String, f32, String, f32, f64, bool), BezPath)| {
|
||||
let mut o = Item::new(ItemId(d.alloc()), it.layer, Shape::Path, PathData::from_bezpath(&bp));
|
||||
o.xf = text_xf;
|
||||
o.fill = Fill { swatch: fill, tint: ft, ..Fill::none() };
|
||||
o.stroke = if stroke == designcraft_color::swatch::NONE {
|
||||
Stroke::none()
|
||||
} else {
|
||||
Stroke { swatch: stroke, tint: stt, weight: sw, ..Stroke::default() }
|
||||
// A stroke behind the fill lies wholly outside the letters, as an outside-aligned stroke.
|
||||
let align = if behind { designcraft_doc::StrokeAlign::Outside } else { designcraft_doc::StrokeAlign::Center };
|
||||
Stroke { swatch: stroke, tint: stt, weight: sw, align, ..Stroke::default() }
|
||||
};
|
||||
o
|
||||
};
|
||||
@@ -312,6 +316,7 @@ fn split_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.split", "missing id"))?;
|
||||
let at = point_param(p, "at").ok_or_else(|| bad("path.split", "missing at"))?;
|
||||
s.edit(|d, sel| {
|
||||
super::object::path_editable(d, id, "path.split")?;
|
||||
let (sr, xf) = spread_xf(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let it = d.item(id).cloned().ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
if !matches!(it.content, Content::Unassigned) {
|
||||
@@ -417,6 +422,55 @@ mod tests {
|
||||
assert!(s.execute("object.pathfinder", &json!({"op": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// The result keeps the frontmost object's look (Subtract: the backmost's), as InDesign's.
|
||||
#[test]
|
||||
fn pathfinder_keeps_the_frontmost_object() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("swatch.create", &json!({"name": "Red", "color": [255, 0, 0]})).unwrap();
|
||||
for op in ["add", "intersect", "exclude", "minusBack"] {
|
||||
let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200, 100, 400, 300]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap();
|
||||
s.execute("object.fill", &json!({"ids": [b], "swatch": "Red"})).unwrap();
|
||||
let r = s.execute("object.pathfinder", &json!({"op": op, "ids": [a, b]})).unwrap();
|
||||
assert_eq!(r["id"], b, "{op}: the front object stays");
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert!(d.item(ItemId(a)).is_none(), "{op}");
|
||||
assert_eq!(d.item(ItemId(b)).unwrap().fill.swatch, "Red", "{op}");
|
||||
s.execute("edit.clear", &json!({"ids": [b]})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A photo placed over strips and intersected with them shows through the strips (it was
|
||||
/// dropped: the black strips were kept).
|
||||
#[test]
|
||||
fn pathfinder_intersect_keeps_a_placed_photo_in_front() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 140]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [100, 160, 300, 200]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.fill", &json!({"ids": [a, b], "swatch": "[Black]"})).unwrap();
|
||||
let strips = s.execute("object.pathfinder", &json!({"op": "add", "ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
let png = designcraft_render::Rendered { width: 20, height: 20, pixels: [255u8, 255, 255, 255].repeat(400) }.to_png();
|
||||
let photo = s.execute("frame.create", &json!({"rect": [80, 80, 320, 220], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "white.png", "frame": photo})).unwrap();
|
||||
s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [photo]})).unwrap();
|
||||
let r = s.execute("object.pathfinder", &json!({"op": "intersect", "ids": [strips, photo]})).unwrap();
|
||||
assert_eq!(r["id"], photo);
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert!(d.item(ItemId(strips)).is_none());
|
||||
let it = d.item(ItemId(photo)).unwrap();
|
||||
assert!(matches!(it.content, Content::Graphic(_)), "the photo stays in the result");
|
||||
assert_eq!(it.path.subpaths.len(), 2, "cut to the two strips");
|
||||
let b = it.bounds();
|
||||
assert!((b.x0 - 100.0).abs() < 0.5 && (b.y0 - 100.0).abs() < 0.5 && (b.x1 - 300.0).abs() < 0.5 && (b.y1 - 200.0).abs() < 0.5, "{b:?}");
|
||||
let mut rr = designcraft_render::Renderer::new();
|
||||
rr.threads = 0;
|
||||
let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
|
||||
assert!(img.pixel(200, 120)[0] > 230, "the photo (white) shows in a strip: {:?}", img.pixel(200, 120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_feather_fades_on_screen_and_in_pdf() {
|
||||
let mut s = Session::new();
|
||||
@@ -435,6 +489,89 @@ mod tests {
|
||||
assert!(t.contains("/SMask") || t.contains("/Luminosity"), "a soft mask");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_groups_gradient_feather_fades_it_on_screen_and_in_pdf() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for rect in [[100, 100, 200, 200], [200, 100, 300, 200]] {
|
||||
let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("transform.move", &json!({"ids": [g], "dx": 20, "dy": 30})).unwrap();
|
||||
// The Gradient Feather tool's drag, then by angle (a group has no path of its own: the
|
||||
// gradient spans its objects). The gradient still follows the translated group even
|
||||
// though the mask geometry covers the whole output.
|
||||
for p in [json!({"ids": [g], "from": [120, 180], "to": [320, 180]}), json!({"ids": [g], "angle": 0})] {
|
||||
s.execute("object.gradientFeather", &p).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let mut rr = designcraft_render::Renderer::new();
|
||||
rr.threads = 0;
|
||||
let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap();
|
||||
let (l, m, r) = (img.pixel(125, 180)[0], img.pixel(220, 180)[0], img.pixel(315, 180)[0]);
|
||||
assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right on screen: {l} {m} {r}");
|
||||
let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap();
|
||||
let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap();
|
||||
// Over white paper.
|
||||
let at = |x: usize, y: usize| {
|
||||
let c = pm.data()[y * pm.width() as usize + x];
|
||||
c.r as u32 + 255 - c.a as u32
|
||||
};
|
||||
let (l, m, r) = (at(125, 180), at(220, 180), at(315, 180));
|
||||
assert!(l < 60 && m > l + 40 && r > 220, "{p}: fades left to right in PDF: {l} {m} {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_gradient_feather_keeps_distant_shadows_and_thick_strokes() {
|
||||
for shadow in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let mut ids = vec![];
|
||||
for rect in [[120, 120, 170, 170], [175, 120, 225, 170]] {
|
||||
let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
|
||||
if shadow {
|
||||
s.execute("object.dropShadow", &json!({"ids": [id], "on": true, "distance": 180, "angle": 90, "size": 0, "opacity": 1})).unwrap();
|
||||
} else {
|
||||
s.execute("object.stroke", &json!({"ids": [id], "swatch": "[Black]", "weight": 180})).unwrap();
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
let g = s.execute("object.group", &json!({"ids": ids})).unwrap()["id"].as_u64().unwrap();
|
||||
// Translate the group so mask geometry and gradient paint exercise distinct spaces.
|
||||
s.execute("transform.move", &json!({"ids": [g], "dx": 30, "dy": 20})).unwrap();
|
||||
let point = if shadow { (175, 345) } else { (175, 60) };
|
||||
let sample = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut rr = designcraft_render::Renderer::new();
|
||||
rr.threads = 0;
|
||||
// Zoomed viewport: the mask must extend over every device pixel.
|
||||
let img = rr.render_page(d, &s.cache, 0, 2.0, false, &Default::default()).unwrap();
|
||||
let screen = img.pixel(point.0 * 2, point.1 * 2)[0];
|
||||
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &Default::default()).unwrap();
|
||||
let pm = designcraft_render::render_pdf_page(&pdf, 0, 400).unwrap();
|
||||
let c = pm.data()[point.1 as usize * pm.width() as usize + point.0 as usize];
|
||||
[f32::from(screen), c.r as f32 + 255.0 - c.a as f32]
|
||||
};
|
||||
let before = sample(&s);
|
||||
// The sample lies past either end of the gradient, and outside the old fixed padding.
|
||||
s.execute("object.gradientFeather", &json!({"ids": [g], "from": [130, 120], "to": [130, 220], "start": 40, "end": 80})).unwrap();
|
||||
let after = sample(&s);
|
||||
let opacity = if shadow { 0.8 } else { 0.4 };
|
||||
for (kind, (before, after)) in ["screen", "PDF"].into_iter().zip(before.into_iter().zip(after)) {
|
||||
assert!(before < 120.0, "{kind}, shadow={shadow}: sample contains the effect before feathering: {before}");
|
||||
let expected = 255.0 - (255.0 - before) * opacity;
|
||||
assert!(
|
||||
(after - expected).abs() < 16.0,
|
||||
"{kind}, shadow={shadow}: end opacity continues past geometry: {after}, expected {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_outlines_turns_text_into_paths() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -88,7 +88,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
missing_glyphs(&cs, &story.text, 0, &mut missing);
|
||||
if cs.is_overset() {
|
||||
let last = story.frames.last().copied();
|
||||
let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, Affine::IDENTITY));
|
||||
let page = last.and_then(|f| d.find(f).zip(d.item(f))).and_then(|(loc, it)| page_of(d, loc.spread, it, d.parent_xf(&loc)));
|
||||
let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count();
|
||||
out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page });
|
||||
}
|
||||
@@ -329,3 +329,29 @@ mod missing_glyph_tests {
|
||||
assert!(found[0].1.ends_with(" and 10 more"), "{}", found[0].1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod overset_page_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// An overset frame inside a group moved onto the right-hand page was reported on the left one.
|
||||
#[test]
|
||||
fn overset_frame_in_a_moved_group_names_its_displayed_page() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"facingPages": true, "pages": 3, "width": 612, "height": 792})).unwrap();
|
||||
let frame = json!({"spread": 1, "rect": [72, 72, 140, 100], "content": "text", "text": "overflowing words ".repeat(100)});
|
||||
let a = s.execute("frame.create", &frame).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"spread": 1, "rect": [300, 300, 350, 350]})).unwrap()["id"].as_u64().unwrap();
|
||||
let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
let overset_page = |s: &mut Session| {
|
||||
let r = s.execute("preflight.run", &json!({})).unwrap();
|
||||
let issue = r["issues"].as_array().unwrap().iter().find(|i| i["kind"] == "overset").unwrap().clone();
|
||||
issue["page"].as_u64().unwrap()
|
||||
};
|
||||
assert_eq!(overset_page(&mut s), 1);
|
||||
s.execute("transform.move", &json!({"dx": 612, "dy": 0, "ids": [g]})).unwrap();
|
||||
assert_eq!(overset_page(&mut s), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Window › Utilities › Scripts: run a command script (see [`crate::script`]) as one undo step.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
@@ -8,15 +10,23 @@ use crate::Result;
|
||||
fn run(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("script.run", "`text` required"))?;
|
||||
let steps = crate::script::parse(text).map_err(|e| bad("script.run", e))?;
|
||||
// The steps' own undo entries collapse into this command's single one.
|
||||
let uid = s.active().map(|d| d.uid);
|
||||
let before = s.active().map_or(0, |d| d.history.undo.len());
|
||||
// The steps' own undo entries collapse into this command's single one. A step that fails
|
||||
// rolls the successful prefix back (document, selection and history), so a half-run script
|
||||
// never strands edits that can't be undone (#709).
|
||||
let before = s.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.history.clone()));
|
||||
let report = s.run_script(&steps);
|
||||
if let Some(st) = s.active_mut()
|
||||
&& Some(st.uid) == uid
|
||||
&& st.history.undo.len() > before
|
||||
&& let Some((uid, doc, selection, history)) = &before
|
||||
&& st.uid == *uid
|
||||
{
|
||||
st.history.undo.truncate(before);
|
||||
if report.failed.is_some() {
|
||||
st.doc = Arc::clone(doc);
|
||||
st.selection = selection.clone();
|
||||
st.history = history.clone();
|
||||
st.revision = st.revision.saturating_add(1);
|
||||
} else if st.history.undo.len() > history.undo.len() {
|
||||
st.history.undo.truncate(history.undo.len());
|
||||
}
|
||||
}
|
||||
let out = json!({"results": report.results, "steps": steps.len()});
|
||||
match report.failed {
|
||||
@@ -56,4 +66,22 @@ mod tests {
|
||||
assert_eq!(count(&s), 0, "one undo removes the whole script's work");
|
||||
assert!(s.execute("script.run", &json!({"text": "nope.command"})).is_err());
|
||||
}
|
||||
|
||||
/// A script that fails after a successful step leaves the document and its history exactly as
|
||||
/// they were: the half-run prefix is not stranded without an undo entry (#709).
|
||||
#[test]
|
||||
fn a_failed_script_rolls_the_successful_prefix_back() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let count = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len();
|
||||
let err = s.execute("script.run", &json!({"text": "frame.create {\"rect\": [72, 72, 200, 200]}\nnope.command"})).unwrap_err();
|
||||
assert!(err.to_string().contains("step 1") && err.to_string().contains("nope.command"), "{err}");
|
||||
assert_eq!(count(&s), 0, "the successful prefix is rolled back");
|
||||
assert!(s.execute("edit.undo", &json!({})).is_err(), "nothing was left to undo");
|
||||
// The session keeps working and its undo history is as it was.
|
||||
s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap();
|
||||
assert_eq!(count(&s), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(count(&s), 0);
|
||||
}
|
||||
}
|
||||
|
||||
+180
-28
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Color Swatch…",
|
||||
[],
|
||||
None,
|
||||
"{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b], spot?: bool}",
|
||||
"{name?, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b]|{model: cmyk|rgb|gray, …}(0..1), spot?: bool}",
|
||||
has_doc,
|
||||
create_swatch
|
||||
),
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
[],
|
||||
None,
|
||||
"{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}|[r,g,b], opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)",
|
||||
"{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b]|{model, …}(0..1), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), ids?} → {swatch, kind, stops} — edits the fill's gradient (an unnamed gradient unless it equals a gradient swatch)",
|
||||
super::has_selection,
|
||||
apply_gradient
|
||||
),
|
||||
@@ -180,7 +180,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Color",
|
||||
[],
|
||||
None,
|
||||
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)",
|
||||
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: cmyk|rgb|gray, …}(0..1), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)",
|
||||
has_text_or_selection,
|
||||
apply_color
|
||||
),
|
||||
@@ -438,6 +438,8 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string();
|
||||
let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?;
|
||||
let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
// Validate caller-supplied fills, not selection fills retained after a swatch is deleted.
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?;
|
||||
if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false)
|
||||
&& let Ok(cur) = s.execute("type.selectionAttrs", &json!({}))
|
||||
&& !cur.is_null()
|
||||
@@ -453,7 +455,6 @@ fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chars = full;
|
||||
}
|
||||
}
|
||||
super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?;
|
||||
let based_on = str_param(p, "basedOn").map(str::to_string);
|
||||
let next = str_param(p, "nextStyle").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
@@ -747,18 +748,20 @@ pub fn parse_color(v: &Value) -> Option<designcraft_color::Color> {
|
||||
use designcraft_color::Color;
|
||||
match v {
|
||||
Value::String(s) => Color::from_hex(s),
|
||||
// One scale per colour, not per component: {c: 0.7, y: 12.2} is C 0.7 %, Y 12.2 %. Only a colour whose
|
||||
// One scale per colour, not per component: [1, 128, 255] is 0..255. Only an RGB array whose
|
||||
// components are all within 0..1 is read as fractions (the older form).
|
||||
Value::Array(a) if a.len() >= 3 => {
|
||||
let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?];
|
||||
let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 };
|
||||
Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32))
|
||||
}
|
||||
// `{model, …}` is the colour's own form (as `color.convert` takes and returns it): fractions.
|
||||
Value::Object(o) if o.contains_key("model") => serde_json::from_value(v.clone()).ok(),
|
||||
// Bare CMYK is always 0..100, as documented: C 1 % is a real tint, so values of 1 or less can't
|
||||
// mean fractions (#910).
|
||||
Value::Object(o) => {
|
||||
let g = |k: &str| o.get(k).and_then(Value::as_f64);
|
||||
let v = [g("c")?, g("m")?, g("y")?, g("k")?];
|
||||
let scale = if v.iter().any(|&x| x > 1.0) { 100.0 } else { 1.0 };
|
||||
Some(Color::cmyk((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32, (v[3] / scale) as f32))
|
||||
let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| (x / 100.0) as f32);
|
||||
Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -836,15 +839,20 @@ fn value_name(color: designcraft_color::Color) -> String {
|
||||
fn load_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = match (str_param(p, "path"), str_param(p, "base64")) {
|
||||
(_, Some(b)) => super::file::base64_decode(b),
|
||||
(Some(path), None) => std::fs::read(path).map_err(|e| bad("swatch.load", format!("{path}: {e}")))?,
|
||||
(Some(path), None) => crate::io::read_file(path).map_err(|e| bad("swatch.load", e))?,
|
||||
_ => return Err(bad("swatch.load", "give `path` or `base64`")),
|
||||
};
|
||||
let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?;
|
||||
let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false);
|
||||
s.edit(|d, _| {
|
||||
// The position of the first swatch with each name, kept up to date as the file's colors are added.
|
||||
let mut at: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for (i, x) in d.swatches.iter().enumerate() {
|
||||
at.entry(x.name.clone()).or_insert(i);
|
||||
}
|
||||
let (mut added, mut replaced) = (0, 0);
|
||||
for w in incoming {
|
||||
match d.swatches.iter_mut().find(|x| x.name == w.name) {
|
||||
match at.get(&w.name).and_then(|&i| d.swatches.get_mut(i)) {
|
||||
Some(x) if x.locked => {}
|
||||
Some(x) => {
|
||||
if replace {
|
||||
@@ -854,6 +862,7 @@ fn load_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
None => {
|
||||
at.insert(w.name.clone(), d.swatches.len());
|
||||
d.swatches.push(w);
|
||||
added += 1;
|
||||
}
|
||||
@@ -871,11 +880,7 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = designcraft_color::ase::write(&chosen);
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let written = std::fs::write(path, &bytes);
|
||||
written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?;
|
||||
crate::io::write_file(path, &bytes).map_err(|e| bad("swatch.save", e))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(json!({"base64": super::file::base64_encode(&bytes)})),
|
||||
@@ -1329,17 +1334,38 @@ mod color_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_rejects_inherited_unknown_name_when_creating_from_selection() {
|
||||
let mut session = fill_session();
|
||||
session
|
||||
.edit(|document, selection| {
|
||||
let selected = selection.text.unwrap();
|
||||
document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into()));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
fn char_fill_preserves_deleted_swatch_when_creating_from_selection() {
|
||||
for params in
|
||||
[json!({"name": "Inherited", "fromSelection": true}), json!({"name": "Inherited", "fromSelection": true, "chars": {"size": 24}})]
|
||||
{
|
||||
let mut session = fill_session();
|
||||
let swatch = session.execute("swatch.create", &json!({"name": "Deleted Ink", "color": "#108080"})).unwrap();
|
||||
let fill = swatch["name"].as_str().unwrap();
|
||||
session.execute("type.char", &json!({"fill": fill})).unwrap();
|
||||
session.execute("swatch.delete", &json!({"name": fill})).unwrap();
|
||||
assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill);
|
||||
|
||||
assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true}));
|
||||
session.execute("style.paragraph.create", ¶ms).unwrap();
|
||||
|
||||
let chars = &session.doc().unwrap().doc.styles.para("Inherited").unwrap().chars;
|
||||
assert_eq!(chars.fill.as_deref(), Some(fill));
|
||||
if params.get("chars").is_some() {
|
||||
assert_eq!(chars.size, Some(24.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_fill_rejects_explicit_unknown_name_when_creating_from_selection() {
|
||||
for fill in ["Missing Ink", "#ff0000"] {
|
||||
let mut session = fill_session();
|
||||
|
||||
assert_fill_rejected(
|
||||
&mut session,
|
||||
"style.paragraph.create",
|
||||
json!({"name": "Explicit", "fromSelection": true, "chars": {"fill": fill, "size": 24}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1420,14 +1446,43 @@ mod color_tests {
|
||||
// {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %.
|
||||
let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") };
|
||||
assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}");
|
||||
// Fractions (every component within 0..1) keep working.
|
||||
let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") };
|
||||
// Fractions take the colour's own form, with its model.
|
||||
let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"model": "cmyk", "c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") };
|
||||
assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6);
|
||||
// RGB arrays follow the same rule: [1, 128, 255] is 0..255.
|
||||
let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") };
|
||||
assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// #910: `{c: 1, m: 0, y: 0, k: 0}` is a 1 % cyan tint, not solid cyan, though every component is within 0..1.
|
||||
#[test]
|
||||
fn cmyk_percentages_of_one_or_less_stay_percent() {
|
||||
use designcraft_color::Color;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("swatch.create", &json!({"name": "Tint1", "color": {"c": 1, "m": 0, "y": 0, "k": 0}})).unwrap();
|
||||
s.execute("object.fill", &json!({"ids": [id], "swatch": "Tint1"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let Some(Color::Cmyk { c, m, y, k }) = d.resolve_color("Tint1", 1.0) else { panic!("not CMYK") };
|
||||
assert!((c - 0.01).abs() < 1e-6 && m == 0.0 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}");
|
||||
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &designcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap();
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
// Operands of every `k` (DeviceCMYK fill), last first: [k, y, m, c]. Inks are written in 1/255 steps (C 1 % is 3/255).
|
||||
let fills: Vec<Vec<f32>> =
|
||||
text.split(" k ").filter_map(|before| before.rsplit(' ').take(4).map(|x| x.parse().ok()).collect::<Option<Vec<f32>>>()).collect();
|
||||
let cyan = |want: f32| fills.iter().any(|f| (f[3] - want).abs() < 0.5 / 255.0 + 1e-6 && f[..3] == [0.0; 3]);
|
||||
assert!(cyan(0.01), "C 1 % in the PDF: {fills:?}");
|
||||
assert!(!cyan(1.0), "no solid cyan: {fills:?}");
|
||||
// A light tint whose components are all 1 or less, through object.color.
|
||||
s.execute("selection.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("object.color", &json!({"color": {"c": 0.5, "m": 0.5, "y": 0, "k": 0}})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let fill = &d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch;
|
||||
let Some(Color::Cmyk { c, m, .. }) = d.resolve_color(fill, 1.0) else { panic!("not CMYK") };
|
||||
assert!((c - 0.005).abs() < 1e-6 && (m - 0.005).abs() < 1e-6, "got {c} {m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_groups_rename_every_use() {
|
||||
let mut s = Session::new();
|
||||
@@ -1531,6 +1586,39 @@ mod color_tests {
|
||||
assert!(s.execute("swatch.load", &json!({"base64": "AAAA"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_swatches_matches_the_first_swatch_of_each_name() {
|
||||
use designcraft_color::{Color, ColorType};
|
||||
let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process };
|
||||
let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false };
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// Two swatches share a name, and an unnamed color is hidden.
|
||||
s.edit(|d, _| {
|
||||
d.swatches.extend([sw("Twin", 0.1), sw("Twin", 0.2), Swatch { named: false, hidden: true, ..sw("Unnamed", 0.3) }]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let file = designcraft_color::ase::write(&[sw("New", 0.4), sw("New", 0.5), sw("Twin", 0.6), sw("Unnamed", 0.7), sw("[Black]", 0.8)]);
|
||||
let b64 = crate::cmd::base64_encode(&file);
|
||||
let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> {
|
||||
s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.hidden)).collect()
|
||||
};
|
||||
// With `replace`, the file's second "New" replaces its first, only the first "Twin" changes, the hidden
|
||||
// swatch becomes visible, and the locked [Black] stays.
|
||||
let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap();
|
||||
assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(1), Some(3)));
|
||||
assert_eq!(named(&s, "New"), [(ink(0.5), false)]);
|
||||
assert_eq!(named(&s, "Twin"), [(ink(0.6), false), (ink(0.2), false)]);
|
||||
assert_eq!(named(&s, "Unnamed"), [(ink(0.7), false)]);
|
||||
assert_eq!(named(&s, "[Black]"), [(ink(1.0), false)]);
|
||||
// Without it, every name is already there and nothing changes.
|
||||
let before = s.doc().unwrap().doc.swatches.clone();
|
||||
let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap();
|
||||
assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0)));
|
||||
assert_eq!(s.doc().unwrap().doc.swatches, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_colors_then_add_to_swatches() {
|
||||
let mut s = Session::new();
|
||||
@@ -1856,6 +1944,70 @@ mod sample_color_tests {
|
||||
assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_with_the_stroke_in_front_colours_a_lines_stroke() {
|
||||
let (mut s, _) = session_with_image();
|
||||
let line = s.execute("line.create", &json!({"a": [300, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [line]})).unwrap();
|
||||
let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap();
|
||||
let it = s.doc().unwrap().doc.item(ItemId(line)).unwrap().clone();
|
||||
assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap(), "{r}");
|
||||
assert!(it.fill.is_none(), "the fill stays empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod radial_gradient_tests {
|
||||
use serde_json::json;
|
||||
|
||||
/// White at the side and the top of a 100×200 radial (black → white) rectangle at (50, 20) on
|
||||
/// a 200×240 page, rendered at 1 px/pt.
|
||||
fn sides(img: &designcraft_render::Rendered) -> (u8, u8, u8) {
|
||||
(img.pixel(100, 120)[0], img.pixel(52, 120)[0], img.pixel(100, 22)[0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radial_gradient_is_the_objects_ellipse_on_screen_and_in_pdf() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap();
|
||||
let id = s.execute("frame.create", &json!({"rect": [50, 20, 150, 220], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.stroke", &json!({"ids": [id], "swatch": "[None]"})).unwrap();
|
||||
s.execute(
|
||||
"object.gradient",
|
||||
&json!({"ids": [id], "kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}),
|
||||
)
|
||||
.unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let mut r = designcraft_render::Renderer::new();
|
||||
let (c, side, top) = sides(&r.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap());
|
||||
assert!(c < 30 && side > 220 && top > 220, "screen: centre {c}, side {side}, top {top}");
|
||||
// The PDF, placed back and drawn: the same ellipse.
|
||||
let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap();
|
||||
let mut p = crate::Session::new();
|
||||
p.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap();
|
||||
p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "radial.pdf", "x": 0, "y": 0, "width": 200}))
|
||||
.unwrap();
|
||||
let d = p.doc().unwrap().doc.clone();
|
||||
let (c, side, top) = sides(&r.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap());
|
||||
assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_with_no_height_or_width_has_a_circle_across_its_length() {
|
||||
let f = designcraft_doc::Fill::swatch("g");
|
||||
let line = designcraft_geom::Rect::new(20.0, 60.0, 180.0, 60.0);
|
||||
let (c, rx, ry) = f.radial_ellipse(line);
|
||||
assert_eq!((c.x, c.y, rx, ry), (100.0, 60.0, 80.0, 80.0));
|
||||
let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(10.0, 0.0, 10.0, 50.0));
|
||||
assert_eq!((rx, ry), (25.0, 25.0));
|
||||
// A point: a tiny circle, never a zero radius.
|
||||
let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(5.0, 5.0, 5.0, 5.0));
|
||||
assert!(rx > 0.0 && rx == ry);
|
||||
// A box keeps its ellipse.
|
||||
let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(0.0, 0.0, 100.0, 200.0));
|
||||
assert_eq!((rx, ry), (50.0, 100.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+236
-12
@@ -292,6 +292,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
break_link
|
||||
),
|
||||
cmd!(query "type.selectionAttrs", "Selection Attributes", [], None, "{} → resolved character/paragraph attributes at the text selection, and its start as `selectedText` (one line, up to 60 characters)", has_doc, selection_attrs),
|
||||
cmd!(query "type.openTypeFeatures", "OpenType Features", [], None, "{} → the OpenType features and stylistic sets the selected text's fonts offer, each with its applied and available state (none, some or all of the selection)", has_doc, open_type_features),
|
||||
cmd!(
|
||||
"type.feature",
|
||||
"Set OpenType Features",
|
||||
[],
|
||||
None,
|
||||
"{features: {tag: true|false, …}} — turns OpenType features on or off across the selected text, keeping each run's other features",
|
||||
has_doc,
|
||||
set_features
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -420,8 +430,13 @@ fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> {
|
||||
fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
|
||||
let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?;
|
||||
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
|
||||
// Empty frames become text frames.
|
||||
let needs_convert = s.doc()?.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned));
|
||||
// Empty frames become text frames; a locked one stays as it is.
|
||||
let st = s.doc()?;
|
||||
let locked = st.doc.item(frame).is_some_and(|i| i.locked || st.doc.layer(i.layer).is_some_and(|l| l.locked));
|
||||
let needs_convert = st.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned));
|
||||
if needs_convert && locked {
|
||||
return Err(bad("text.placeCaret", "the frame is locked"));
|
||||
}
|
||||
if needs_convert {
|
||||
s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?;
|
||||
}
|
||||
@@ -1031,6 +1046,14 @@ pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &C
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that
|
||||
/// line, and so the one a line-affecting attribute like leading can land on right away instead of
|
||||
/// waiting for the next typed character (otherwise an empty line could never get its own leading).
|
||||
fn empty_para_mark(story: &designcraft_doc::Story, pos: usize) -> bool {
|
||||
let ranges = story.para_ranges();
|
||||
ranges.get(story.para_at(pos)).is_some_and(|r| r.start == pos && r.end == pos) && story.text.as_bytes().get(pos) == Some(&b'\n')
|
||||
}
|
||||
|
||||
pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||||
let mut a = CharAttrs::default();
|
||||
if let Some(o) = attrs.as_object() {
|
||||
@@ -1041,11 +1064,13 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||||
validate_char_fill(&s.doc()?.doc, &a, "type.char")?;
|
||||
let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
|
||||
let targets = format_targets(s);
|
||||
// A caret in text: the format is for the next typing.
|
||||
// A caret in text: the format is for the next typing — unless it sits on an empty paragraph's
|
||||
// hidden mark, which takes the format immediately (there is nothing else on that line to type
|
||||
// into first).
|
||||
if let [t] = targets.as_slice()
|
||||
&& t.range.is_empty()
|
||||
&& let Some(at) = s.doc()?.selection.text
|
||||
&& s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty())
|
||||
&& s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty() && !empty_para_mark(st, t.range.start))
|
||||
{
|
||||
let doc = s.doc()?.uid;
|
||||
let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] });
|
||||
@@ -1067,12 +1092,21 @@ pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Tar
|
||||
let r = &t.range;
|
||||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||||
if r.is_empty() {
|
||||
// An empty story: its one (empty) run is the format typing takes.
|
||||
if st.is_empty()
|
||||
&& let Some(run) = st.chars.first_mut()
|
||||
{
|
||||
run.format.over.merge(a);
|
||||
st.rev += 1;
|
||||
if st.is_empty() {
|
||||
// An empty story: its one (empty) run is the format typing takes.
|
||||
if let Some(run) = st.chars.first_mut() {
|
||||
run.format.over.merge(a);
|
||||
st.rev += 1;
|
||||
}
|
||||
} else if empty_para_mark(st, r.start) {
|
||||
// An empty paragraph elsewhere in the story: its hidden paragraph mark is its only
|
||||
// character, and the one that carries line-affecting attributes like leading.
|
||||
st.format_chars(r.start..r.start + 1, |f| {
|
||||
f.over.merge(a);
|
||||
for k in cleared {
|
||||
let _ = f.over.set_json(k, &Value::Null);
|
||||
}
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1379,6 +1413,125 @@ fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result<Value> {
|
||||
Ok(json!({"on": on, "changed": changed}))
|
||||
}
|
||||
|
||||
/// One stretch of the selection: its length in bytes, the font it is set in and its features.
|
||||
struct FeatureSegment {
|
||||
len: usize,
|
||||
font: (String, String),
|
||||
applied: Vec<String>,
|
||||
}
|
||||
|
||||
/// The OpenType features of the fonts in the selection, with how much of the selection each one is
|
||||
/// applied to and how much of it the fonts offer. A caret counts as one byte. `state` is `none`,
|
||||
/// `some` or `all`.
|
||||
fn open_type_features(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mut segments: Vec<FeatureSegment> = Vec::new();
|
||||
for Target { story: sid, cell, range: r } in format_targets(s) {
|
||||
let Some(story) = st.doc.text_story(sid, cell) else { continue };
|
||||
let covered: Vec<(usize, usize, designcraft_doc::story::CharFormat)> = if r.is_empty() {
|
||||
vec![(r.start, 1, story.format_after(r.start).clone())]
|
||||
} else {
|
||||
story
|
||||
.runs()
|
||||
.filter(|(run, _)| run.start < r.end && run.end > r.start)
|
||||
.map(|(run, f)| (run.start, run.end.min(r.end).saturating_sub(run.start.max(r.start)), f.clone()))
|
||||
.collect()
|
||||
};
|
||||
for (pos, len, cf) in covered {
|
||||
let Some(para) = story.paras.get(story.para_at(pos)) else { continue };
|
||||
let (_, base) = st.doc.styles.resolve_para(para);
|
||||
let cp = st.doc.styles.resolve_char(&base, &cf);
|
||||
segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features });
|
||||
}
|
||||
}
|
||||
if segments.is_empty() {
|
||||
let (_, base) = st.doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH);
|
||||
let cp = st.doc.styles.resolve_char(&base, &designcraft_doc::story::CharFormat::default());
|
||||
segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features });
|
||||
}
|
||||
let db = designcraft_fonts::FontDb::global();
|
||||
let mut fonts: std::collections::BTreeMap<(String, String), designcraft_fonts::FontFeatures> = std::collections::BTreeMap::new();
|
||||
for seg in &segments {
|
||||
fonts.entry(seg.font.clone()).or_insert_with(|| designcraft_fonts::font_features(&db.face(&seg.font.0, &seg.font.1)));
|
||||
}
|
||||
let total: usize = segments.iter().map(|seg| seg.len).sum::<usize>().max(1);
|
||||
let state = |covered: usize| match covered {
|
||||
0 => "none",
|
||||
n if n >= total => "all",
|
||||
_ => "some",
|
||||
};
|
||||
// Bytes the feature is applied to, and bytes whose font offers it.
|
||||
let measure = |tag: &str| {
|
||||
let (mut applied, mut available) = (0, 0);
|
||||
for seg in &segments {
|
||||
if designcraft_doc::otf::is_on(&seg.applied, tag) {
|
||||
applied += seg.len;
|
||||
}
|
||||
if fonts.get(&seg.font).is_some_and(|f| f.tags.contains(tag)) {
|
||||
available += seg.len;
|
||||
}
|
||||
}
|
||||
(state(applied), state(available))
|
||||
};
|
||||
let tags: std::collections::BTreeSet<String> = fonts.values().flat_map(|f| f.tags.iter().cloned()).collect();
|
||||
let mut features = Vec::new();
|
||||
for tag in tags.iter().filter(|t| !(t.len() == 4 && t.starts_with("ss") && t[2..].parse::<u32>().is_ok())) {
|
||||
let (applied, available) = measure(tag);
|
||||
features.push(json!({"tag": tag, "applied": applied, "available": available}));
|
||||
}
|
||||
// A stylistic set's name is shared when every font that offers it gives it the same name.
|
||||
let mut sets = Vec::new();
|
||||
for set in 1..=20u32 {
|
||||
let tag = format!("ss{set:02}");
|
||||
if !tags.contains(&tag) {
|
||||
continue;
|
||||
}
|
||||
let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect();
|
||||
let name = if names.len() == 1 { names.first().map(|n| n.to_string()) } else { None };
|
||||
let (applied, available) = measure(&tag);
|
||||
sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available}));
|
||||
}
|
||||
let used: Vec<Value> = fonts.keys().map(|(family, style)| json!({"family": family, "style": style})).collect();
|
||||
Ok(json!({"fonts": used, "features": features, "stylisticSets": sets}))
|
||||
}
|
||||
|
||||
/// Turns OpenType features on or off across the selected text: `features` maps tags to whether they
|
||||
/// are on. Each run keeps its other features, whichever font it is set in, and the change is one
|
||||
/// undo step.
|
||||
fn set_features(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "type.feature";
|
||||
let changes: Vec<(String, bool)> = p
|
||||
.get("features")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| bad(ID, "missing features: a map of tags to true or false"))?
|
||||
.iter()
|
||||
.map(|(tag, on)| Ok((tag.clone(), on.as_bool().ok_or_else(|| bad(ID, format!("`{tag}` needs true or false")))?)))
|
||||
.collect::<Result<_>>()?;
|
||||
let targets: Vec<Target> = format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect();
|
||||
if targets.is_empty() {
|
||||
return Err(bad(ID, "select text"));
|
||||
}
|
||||
let styles = s.doc()?.doc.styles.clone();
|
||||
s.edit(|d, _| {
|
||||
for t in &targets {
|
||||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||||
let runs: Vec<(std::ops::Range<usize>, designcraft_doc::story::CharFormat)> =
|
||||
st.runs().filter(|(run, _)| run.start < t.range.end && run.end > t.range.start).map(|(run, f)| (run.clone(), f.clone())).collect();
|
||||
for (run, format) in runs {
|
||||
let Some(para) = st.paras.get(st.para_at(run.start)).cloned() else { continue };
|
||||
let (_, base) = styles.resolve_para(¶);
|
||||
let mut list = styles.resolve_char(&base, &format).otf_features;
|
||||
for (tag, on) in &changes {
|
||||
designcraft_doc::otf::set(&mut list, tag, *on);
|
||||
}
|
||||
let range = run.start.max(t.range.start)..run.end.min(t.range.end);
|
||||
st.format_chars(range, |f| f.over.otf_features = Some(list.clone()));
|
||||
}
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// Our own filler text (not Adobe's).
|
||||
pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \
|
||||
et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \
|
||||
@@ -1436,19 +1589,21 @@ pub(crate) fn case_map(src: &str, case: &str) -> Vec<String> {
|
||||
let word_start = !(prev.is_alphanumeric() || matches!(prev, '\'' | '\u{2019}'));
|
||||
let up = |c: char| c.to_uppercase().collect::<String>();
|
||||
let low = |c: char| c.to_lowercase().collect::<String>();
|
||||
// Title and sentence case capitalize only scripts with initial capitals (not Georgian).
|
||||
let cap = |c: char| if designcraft_doc::capitalizes(c) { up(c) } else { low(c) };
|
||||
let m = match case {
|
||||
"upper" => up(c),
|
||||
"lower" => low(c),
|
||||
"title" => {
|
||||
if word_start {
|
||||
up(c)
|
||||
cap(c)
|
||||
} else {
|
||||
low(c)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if sentence_start && c.is_alphanumeric() {
|
||||
up(c)
|
||||
cap(c)
|
||||
} else {
|
||||
low(c)
|
||||
}
|
||||
@@ -1639,6 +1794,32 @@ What's new in typography this season, and why it matters to every designer"}),
|
||||
s.execute("type.openType", &json!({"figures": "proportionalOldstyle"})).unwrap();
|
||||
assert_ne!(gids(&s), lining);
|
||||
}
|
||||
|
||||
/// A blank line (an empty paragraph) has no characters to select, but its hidden paragraph
|
||||
/// mark can still take a leading change right away: otherwise there'd be no way to adjust the
|
||||
/// line height of a blank line at all.
|
||||
#[test]
|
||||
fn leading_applies_to_an_empty_lines_hidden_paragraph_mark() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let text = "Para one\n\nPara two";
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
// The blank line between the two paragraphs: a collapsed caret, since it has no text.
|
||||
let blank = text.find("\n\n").unwrap() + 1;
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"leading": {"kind": "points", "value": 40.0}}})).unwrap();
|
||||
let st = s.doc().unwrap().doc.story(sid).unwrap();
|
||||
// `char_format_at` resolves a boundary to the *preceding* run (new typing's format), so
|
||||
// check just past the mark's own one-byte run, which the mark alone occupies.
|
||||
assert_eq!(st.char_format_at(blank + 1).over.leading, Some(designcraft_doc::Leading::Points(40.0)));
|
||||
// No pending typing format was left behind: it applied immediately.
|
||||
assert!(s.typing_format.is_none());
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
|
||||
let blank_line = cs.frames[0].lines.iter().find(|l| l.range == (blank..blank)).unwrap();
|
||||
assert_eq!(blank_line.leading, 40.0, "the blank line's own height changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2559,3 +2740,46 @@ mod sentence_selection_tests {
|
||||
assert_eq!(para_bounds(s, s.len()), (s.len(), s.len()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod open_type_features_tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn feature<'a>(v: &'a Value, tag: &str) -> &'a Value {
|
||||
v["features"].as_array().unwrap().iter().find(|f| f["tag"] == tag).unwrap_or(&Value::Null)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selected_fonts_features_are_listed_with_their_state() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello fi"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap();
|
||||
let v = s.execute("type.openTypeFeatures", &json!({})).unwrap();
|
||||
assert_eq!(v["fonts"][0]["family"], "Source Serif 4", "{v}");
|
||||
assert_eq!(feature(&v, "liga")["applied"], "all", "liga is on by default: {v}");
|
||||
assert_eq!(feature(&v, "kern")["available"], "all", "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feature_on_part_of_the_selection_is_some_and_on_all_of_it_is_all() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello world"})).unwrap();
|
||||
let story = r["story"].clone();
|
||||
s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap();
|
||||
s.execute("type.feature", &json!({"features": {"lnum": true}})).unwrap();
|
||||
s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap();
|
||||
let v = s.execute("type.openTypeFeatures", &json!({})).unwrap();
|
||||
assert_eq!(feature(&v, "lnum")["applied"], "some", "{v}");
|
||||
s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap();
|
||||
let v = s.execute("type.openTypeFeatures", &json!({})).unwrap();
|
||||
assert_eq!(feature(&v, "lnum")["applied"], "all", "{v}");
|
||||
s.execute("type.feature", &json!({"features": {"lnum": false}})).unwrap();
|
||||
s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap();
|
||||
let v = s.execute("type.openTypeFeatures", &json!({})).unwrap();
|
||||
assert_eq!(feature(&v, "lnum")["applied"], "none", "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,38 @@ mod tests {
|
||||
assert_eq!(shown(&s, 2), 3, "page 102 after renumbering");
|
||||
}
|
||||
|
||||
/// A section marker character shows the marker text of the section of the page it is composed
|
||||
/// for: on each document page a parent-page frame is shown on, and in a frame on a document
|
||||
/// page. A section without marker text shows nothing; the parent page itself shows "Section".
|
||||
#[test]
|
||||
fn section_marker_shows_the_pages_section_marker() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap();
|
||||
s.execute("layout.section", &json!({"page": 3, "startNumber": 1, "marker": "Intro"})).unwrap();
|
||||
let marker = designcraft_doc::story::SECTION_MARKER.to_string();
|
||||
let r = s
|
||||
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker}))
|
||||
.unwrap();
|
||||
let parent = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
||||
let sp = s.doc().unwrap().doc.page_loc(3).unwrap().0;
|
||||
let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": marker})).unwrap();
|
||||
let own = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
||||
let shown = |s: &Session, sid, page: Option<usize>| -> usize {
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let cs = s.cache.get(&d, sid, page);
|
||||
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum()
|
||||
};
|
||||
assert_eq!(shown(&s, parent, Some(0)), 0, "the first section has no marker");
|
||||
assert_eq!(shown(&s, parent, Some(2)), "Intro".len());
|
||||
assert_eq!(shown(&s, parent, Some(3)), "Intro".len());
|
||||
assert_eq!(shown(&s, own, None), "Intro".len(), "a frame on a document page");
|
||||
assert_eq!(shown(&s, parent, None), "Section".len(), "the parent page itself");
|
||||
// Editing the marker recomposes.
|
||||
s.execute("layout.section", &json!({"page": 3, "marker": "Appendix"})).unwrap();
|
||||
assert_eq!(shown(&s, parent, Some(2)), "Appendix".len());
|
||||
assert_eq!(shown(&s, own, None), "Appendix".len());
|
||||
}
|
||||
|
||||
/// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running
|
||||
/// header is composed for the page it is shown on, not the first page with that name.
|
||||
#[test]
|
||||
|
||||
@@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::atomic::write_file(path, text.as_bytes())?;
|
||||
crate::io::write_file(path, text.as_bytes()).map_err(crate::EngineError::Other)?;
|
||||
Ok(json!({"path": path, "bytes": text.len()}))
|
||||
}
|
||||
None => Ok(json!({"text": text, "bytes": text.len()})),
|
||||
@@ -385,7 +385,7 @@ fn text_or_path(p: &Value, id: &str) -> Result<String> {
|
||||
(Some(t), _) => t.to_string(),
|
||||
(None, Some(path)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let t = std::fs::read_to_string(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
|
||||
let t = crate::io::read_to_string(path).map_err(crate::EngineError::Other)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let t = {
|
||||
let _ = path;
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// How much text parameter entities may add during expansion (a pass count is not an output
|
||||
/// bound: each pass replaces whole strings, so references can inflate multiplicatively).
|
||||
const MAX_EXPANDED_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// A content particle: a name, a sequence, a choice, or one of those repeated (`?`, `*`, `+`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Cp {
|
||||
@@ -177,10 +181,17 @@ pub fn parse(src: &str) -> Result<Dtd, String> {
|
||||
}
|
||||
}
|
||||
let mut text = src.to_string();
|
||||
// A pass count bounds the work, not the output: each pass replaces whole strings with the
|
||||
// entities' original values, so a few hundred bytes of nested parameter entities can inflate
|
||||
// to gigabytes. Expansion may not add more than this much text over the input.
|
||||
let limit = src.len().saturating_add(MAX_EXPANDED_BYTES);
|
||||
for _ in 0..8 {
|
||||
let before = text.clone();
|
||||
for (n, v) in &ents {
|
||||
text = text.replace(&format!("%{n};"), v);
|
||||
if text.len() > limit {
|
||||
return Err(format!("parameter entities expand the DTD beyond {limit} bytes"));
|
||||
}
|
||||
}
|
||||
if text == before {
|
||||
break;
|
||||
@@ -434,6 +445,23 @@ mod tests {
|
||||
assert!(parse("just text").is_err());
|
||||
}
|
||||
|
||||
/// A few hundred bytes of nested parameter entities used to inflate multiplicatively across
|
||||
/// the eight wholly-string-replacing passes (#713); expansion is capped.
|
||||
#[test]
|
||||
fn parameter_entities_cannot_expand_without_a_budget() {
|
||||
let mut src = String::from("<!ENTITY % e1 \"xx\">\n");
|
||||
for i in 2..=6 {
|
||||
src.push_str(&format!("<!ENTITY % e{i} \"{}\">\n", format!("%e{};", i - 1).repeat(16)));
|
||||
}
|
||||
src.push_str("<!ELEMENT r (%e6;)>");
|
||||
assert!(src.len() < 500, "the payload is small: {} bytes", src.len());
|
||||
let err = parse(&src).unwrap_err();
|
||||
assert!(err.contains("expand the DTD beyond"), "{err}");
|
||||
// Entities that expand a little still parse.
|
||||
let dtd = parse("<!ENTITY % inline \"#PCDATA | em\">\n<!ELEMENT r (%inline;)*>").unwrap();
|
||||
assert_eq!(dtd.element_names().collect::<Vec<_>>(), ["r"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repetition_matches_like_a_regular_expression() {
|
||||
let dtd = parse("<!ELEMENT r ((a, b?)+, c*)><!ELEMENT a EMPTY><!ELEMENT b EMPTY><!ELEMENT c EMPTY>").unwrap();
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
//! The folders automation may read and write.
|
||||
//!
|
||||
//! `designcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
|
||||
//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command
|
||||
//! reads goes through [`check_read`] and every path it writes through [`check_write`]:
|
||||
//!
|
||||
//! - The path is made absolute against the working directory, as the file system reads it. Its
|
||||
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
||||
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
||||
//! is a link that leads nowhere).
|
||||
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
||||
//! `/work`), without regard to case on Windows.
|
||||
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
||||
//!
|
||||
//! Roots are in force on a thread inside [`confine`], and in the whole process after
|
||||
//! [`confine_process`] (`designcraft-cli mcp`), except inside [`unconfined`]. Without roots
|
||||
//! nothing is checked.
|
||||
//!
|
||||
//! A path is checked, then opened: a process that can change the folders in between (swapping in
|
||||
//! a link) can still win that race. The roots keep an agent to the files it was given; they don't
|
||||
//! guard against other programs on the computer.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Read access or write access.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// The folders automation may read and write; a root left out grants no access of its kind.
|
||||
#[derive(Debug)]
|
||||
pub struct AutomationRoots {
|
||||
read: Option<Root>,
|
||||
write: Option<Root>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Root {
|
||||
/// The folder made absolute, links followed: what paths are compared with.
|
||||
resolved: PathBuf,
|
||||
/// The same without Windows' `\\?\` prefix, for people.
|
||||
shown: PathBuf,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
/// The folder at `path`, which must exist.
|
||||
fn open(path: &Path, access: Access) -> Result<Self, String> {
|
||||
let kind = match access {
|
||||
Access::Read => "read",
|
||||
Access::Write => "write",
|
||||
};
|
||||
let resolved = std::fs::canonicalize(path)
|
||||
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
|
||||
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
|
||||
Ok(Self { shown: plain(resolved.clone()), resolved })
|
||||
}
|
||||
}
|
||||
|
||||
impl AutomationRoots {
|
||||
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
||||
/// (nothing is confined then).
|
||||
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_none() && write.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
||||
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
||||
Ok(Some(Arc::new(Self { read, write })))
|
||||
}
|
||||
|
||||
/// The read root, absolute with its links followed.
|
||||
pub fn read_root(&self) -> Option<&Path> {
|
||||
self.read.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// The write root, absolute with its links followed.
|
||||
pub fn write_root(&self) -> Option<&Path> {
|
||||
self.write.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
pub fn check(&self, path: impl AsRef<Path>, access: Access) -> Result<(), String> {
|
||||
let path = path.as_ref();
|
||||
let root = match access {
|
||||
Access::Read => self.read.as_ref(),
|
||||
Access::Write => self.write.as_ref(),
|
||||
};
|
||||
let Some(Root { resolved: folder, shown }) = root else {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {}", path.display())),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {}", path.display())),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(path)?, folder) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = shown.display();
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the roots are in force on a thread ([`confine`], [`unconfined`]).
|
||||
#[derive(Clone, Debug)]
|
||||
enum Scope {
|
||||
Confined(Arc<AutomationRoots>),
|
||||
Unconfined,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// This thread's scope; `None`: the process's roots ([`confine_process`]).
|
||||
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The roots in force in every thread outside [`confine`] and [`unconfined`].
|
||||
static PROCESS: OnceLock<Arc<AutomationRoots>> = OnceLock::new();
|
||||
|
||||
/// Put `roots` in force in the whole process, for good. `false` when roots were in force already
|
||||
/// (they stay as they were).
|
||||
pub fn confine_process(roots: Arc<AutomationRoots>) -> bool {
|
||||
PROCESS.set(roots).is_ok()
|
||||
}
|
||||
|
||||
/// Puts the scope it replaced back when dropped (also when `f` panics).
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
SCOPE.with(|s| *s.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
fn with_scope<T>(scope: Option<Scope>, f: impl FnOnce() -> T) -> T {
|
||||
let _restore = Restore(SCOPE.with(|s| s.replace(scope)));
|
||||
f()
|
||||
}
|
||||
|
||||
/// Run `f` with `roots` in force on this thread (`None`: as things stand).
|
||||
pub fn confine<T>(roots: Option<&Arc<AutomationRoots>>, f: impl FnOnce() -> T) -> T {
|
||||
match roots {
|
||||
Some(r) => with_scope(Some(Scope::Confined(r.clone())), f),
|
||||
None => f(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the
|
||||
/// app's own folders.
|
||||
pub fn unconfined<T>(f: impl FnOnce() -> T) -> T {
|
||||
with_scope(Some(Scope::Unconfined), f)
|
||||
}
|
||||
|
||||
/// The roots in force on this thread, if any.
|
||||
pub fn current() -> Option<Arc<AutomationRoots>> {
|
||||
match SCOPE.with(|s| s.borrow().clone()) {
|
||||
Some(Scope::Confined(r)) => Some(r),
|
||||
Some(Scope::Unconfined) => None,
|
||||
None => PROCESS.get().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read.
|
||||
fn plain(path: PathBuf) -> PathBuf {
|
||||
if !cfg!(windows) {
|
||||
return path;
|
||||
}
|
||||
let text = path.to_string_lossy();
|
||||
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
||||
return PathBuf::from(format!(r"\\{rest}"));
|
||||
}
|
||||
match text.strip_prefix(r"\\?\") {
|
||||
Some(rest) => PathBuf::from(rest),
|
||||
None => path,
|
||||
}
|
||||
}
|
||||
|
||||
/// May `path` be read here? Always, without roots in force.
|
||||
pub fn check_read(path: impl AsRef<Path>) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Read))
|
||||
}
|
||||
|
||||
/// May `path` be written here? Always, without roots in force.
|
||||
pub fn check_write(path: impl AsRef<Path>) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
||||
}
|
||||
|
||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||
/// existing folder's links followed and the rest appended.
|
||||
fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {}", path.display()));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {}: {e}", path.display()))?;
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut found) => {
|
||||
found.extend(missing.iter().rev());
|
||||
return Ok(found);
|
||||
}
|
||||
Err(e) => match std::fs::symlink_metadata(at) {
|
||||
Ok(m) if m.file_type().is_symlink() => {
|
||||
return Err(format!("automation path rejected: a link that leads nowhere: {}", path.display()));
|
||||
}
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())),
|
||||
},
|
||||
}
|
||||
let parent = at.parent();
|
||||
match (at.components().next_back(), parent) {
|
||||
(Some(Component::Normal(name)), Some(parent)) => {
|
||||
missing.push(name.to_os_string());
|
||||
at = parent;
|
||||
}
|
||||
(Some(Component::ParentDir), _) => {
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {}", path.display()));
|
||||
}
|
||||
// Down to the root (or drive) and it doesn't exist either.
|
||||
_ => {
|
||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
|
||||
/// Windows, whose file systems ignore it)?
|
||||
fn inside(path: &Path, root: &Path) -> bool {
|
||||
let mut path = path.components();
|
||||
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
|
||||
}
|
||||
|
||||
fn same_component(a: Component, b: Component) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
|
||||
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
||||
fn is_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
|
||||
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
|
||||
let base = std::env::temp_dir().join(format!("dc-file-access-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in/sub", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/a.designcraft"), "x").unwrap();
|
||||
std::fs::write(base.join("outside/secret.designcraft"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
fn s(p: PathBuf) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_inside_the_roots_pass_and_others_dont() {
|
||||
let (base, roots) = folders("inside");
|
||||
assert_eq!(roots.check(s(base.join("in/a.designcraft")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(s(base.join("in")), Access::Read), Ok(()), "the root itself");
|
||||
assert_eq!(roots.check(s(base.join("in/sub/new.designcraft")), Access::Read), Ok(()), "missing files are checked by their folder");
|
||||
assert_eq!(roots.check(s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder");
|
||||
let e = roots.check(s(base.join("outside/secret.designcraft")), Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
|
||||
// Read and write access are separate.
|
||||
let e = roots.check(s(base.join("in/a.designcraft")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
assert!(roots.check(s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_with_the_same_prefix_is_outside() {
|
||||
let (base, roots) = folders("prefix");
|
||||
std::fs::write(base.join("in2/b.designcraft"), "x").unwrap();
|
||||
assert!(roots.check(s(base.join("in2/b.designcraft")), Access::Read).is_err(), "in2 is not inside in");
|
||||
assert!(roots.check(format!("{}2/c.designcraft", s(base.join("in"))), Access::Read).is_err());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_traversal_is_resolved_or_refused() {
|
||||
let (base, roots) = folders("dotdot");
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let up = format!("{}{sep}..{sep}outside{sep}secret.designcraft", s(base.join("in")));
|
||||
assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
|
||||
let back = format!("{}{sep}..{sep}a.designcraft", s(base.join("in/sub")));
|
||||
assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");
|
||||
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.designcraft", s(base.join("out")));
|
||||
let e = roots.check(&missing, Access::Write).unwrap_err();
|
||||
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
|
||||
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_resolve_against_the_working_directory() {
|
||||
let (base, roots) = folders("relative");
|
||||
// The tests' working directory is the crate's folder: not inside the roots.
|
||||
assert!(roots.check("a.designcraft", Access::Read).unwrap_err().contains("outside the read root"));
|
||||
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(None, Some(&base.join("in/a.designcraft"))).unwrap_err();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
let e = read_only.check(s(base.join("in/x.designcraft")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("write authority is absent"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_are_followed_before_comparing() {
|
||||
let (base, roots) = folders("links");
|
||||
#[cfg(unix)]
|
||||
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
|
||||
#[cfg(windows)]
|
||||
let made = |target: &Path, link: &Path| {
|
||||
let made =
|
||||
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
|
||||
made.is_ok()
|
||||
};
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let made = |_: &Path, _: &Path| false;
|
||||
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
|
||||
if !made(&base.join("outside/secret.designcraft"), &base.join("in/leak.designcraft")) {
|
||||
eprintln!("skipped: symbolic links can't be made here");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
return;
|
||||
}
|
||||
assert!(roots.check(s(base.join("in/leak.designcraft")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
|
||||
assert!(made(&base.join("outside"), &base.join("out/away")));
|
||||
let e = roots.check(s(base.join("out/away/new.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "a folder link out: {e}");
|
||||
assert!(made(&base.join("outside/gone.designcraft"), &base.join("out/dangling.png")));
|
||||
let e = roots.check(s(base.join("out/dangling.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
|
||||
// A link to a folder inside the root stays inside.
|
||||
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
|
||||
assert_eq!(roots.check(s(base.join("in/alias/x.designcraft")), Access::Read), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
assert_eq!(linked.check(s(base.join("in/a.designcraft")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_ignores_case_and_refuses_devices() {
|
||||
let (base, roots) = folders("case");
|
||||
let upper = s(base.join("IN/a.designcraft"));
|
||||
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
|
||||
assert_eq!(roots.check(s(base.join("In/Sub/New.designcraft")), Access::Read), Ok(()));
|
||||
let e = roots.check(s(base.join("out/NUL")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("reserved device names"), "{e}");
|
||||
assert!(roots.check(s(base.join("out/com1.txt")), Access::Write).is_err());
|
||||
assert_eq!(roots.check(s(base.join("out/console.txt")), Access::Write), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_nest_and_stay_on_their_thread() {
|
||||
let (base, roots) = folders("scopes");
|
||||
let outside = s(base.join("outside/secret.designcraft"));
|
||||
assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default");
|
||||
confine(Some(&roots), || {
|
||||
assert!(check_read(&outside).is_err());
|
||||
assert_eq!(unconfined(|| check_read(&outside)), Ok(()));
|
||||
assert!(check_read(&outside).is_err(), "restored after unconfined");
|
||||
let o = outside.clone();
|
||||
let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap();
|
||||
assert!(there, "another thread is not confined by this thread's scope");
|
||||
});
|
||||
assert_eq!(check_read(&outside), Ok(()), "restored after confine");
|
||||
let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom")));
|
||||
assert!(r.is_err());
|
||||
assert_eq!(check_read(&outside), Ok(()), "restored after a panic");
|
||||
assert_eq!(confine(None, || check_read(&outside)), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_names() {
|
||||
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.designcraft", "NUL "] {
|
||||
assert!(is_device_name(d), "{d}");
|
||||
}
|
||||
for f in ["console", "com10", "COM0", "lpt", "a.designcraft", "nul-x"] {
|
||||
assert!(!is_device_name(f), "{f}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,17 +35,23 @@ pub fn install_panic_hook() {
|
||||
impl Session {
|
||||
/// Run command `id` (`f`) inside the guard.
|
||||
pub(crate) fn guarded(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.revision));
|
||||
// The whole command transaction: every open document with its undo/redo history and
|
||||
// selection, which one is active, and the journal (nested commands journal too).
|
||||
let docs = self.docs.clone();
|
||||
let active = self.active;
|
||||
let revision = self.active().map_or(0, |d| d.revision);
|
||||
let journal = self.journal.len();
|
||||
match catch_unwind(AssertUnwindSafe(|| f(self))) {
|
||||
Ok(r) => r,
|
||||
Err(payload) => {
|
||||
let msg = panic_message(payload.as_ref());
|
||||
// Keep the document as it was before the command.
|
||||
if let (Some((uid, doc, selection, revision)), Some(st)) = (before, self.active_mut())
|
||||
&& st.uid == uid
|
||||
{
|
||||
st.doc = doc;
|
||||
st.selection = selection;
|
||||
// Keep the documents as they were before the command: a nested edit that
|
||||
// succeeded before the panic is rolled back with its history entry, a document
|
||||
// opened or switched by the command is gone, and redo it cleared is back.
|
||||
self.docs = docs;
|
||||
self.active = active;
|
||||
self.journal.truncate(journal);
|
||||
if let Some(st) = self.active_mut() {
|
||||
st.interaction = None;
|
||||
st.revision = revision.max(st.revision).saturating_add(1);
|
||||
}
|
||||
@@ -86,4 +92,47 @@ mod tests {
|
||||
// The session keeps working.
|
||||
s.execute("frame.create", &json!({"rect": [36, 236, 200, 400]})).unwrap();
|
||||
}
|
||||
|
||||
/// A nested edit that succeeded before the panic is rolled back with its history entry, and
|
||||
/// the redo it cleared is back (#742).
|
||||
#[test]
|
||||
fn a_panicking_command_restores_history_with_the_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let (undo, redo) = {
|
||||
let h = &s.active().unwrap().history;
|
||||
(h.undo.len(), h.redo.len())
|
||||
};
|
||||
assert_eq!(redo, 1, "there is redo to lose");
|
||||
let err = s
|
||||
.guarded("test.panic", |s| {
|
||||
s.execute("frame.create", &json!({"rect": [36, 236, 200, 400]}))?;
|
||||
panic!("boom");
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("boom"), "{err}");
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo, "the nested edit's undo entry is gone with the edit");
|
||||
assert_eq!(st.history.redo.len(), redo, "the redo the nested edit cleared is back");
|
||||
assert_eq!(st.doc.spreads[0].items.len(), 0, "the document is the one from before the command");
|
||||
}
|
||||
|
||||
/// A command that opens another document and then panics leaves none of it behind (#742).
|
||||
#[test]
|
||||
fn a_panicking_command_restores_the_document_set() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let err = s
|
||||
.guarded("test.panic", |s| {
|
||||
s.execute("file.new", &json!({}))?;
|
||||
assert_eq!(s.docs.len(), 2);
|
||||
panic!("boom");
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("boom"), "{err}");
|
||||
assert_eq!(s.docs.len(), 1, "the document the command opened is gone");
|
||||
assert_eq!(s.active, Some(0), "the document from before the command is active");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Reading and writing the files a command names by path.
|
||||
//!
|
||||
//! Every such read and write goes through this module, so the automation roots
|
||||
//! ([`crate::file_access`]) are checked in one place. A command module that called `std::fs`
|
||||
//! itself would bypass them: a test in `file_access` keeps that from happening.
|
||||
//!
|
||||
//! A path the roots refuse reads as a file that isn't there where the caller only asks whether
|
||||
//! it is ([`is_file`], [`is_dir`], [`exists`], [`metadata`]), and as the roots' own message where
|
||||
//! it asks for the bytes.
|
||||
|
||||
use std::fs::Metadata;
|
||||
use std::path::Path;
|
||||
|
||||
/// The bytes of the file at `path`, inside the automation read root when there is one.
|
||||
pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>, String> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_read(path)?;
|
||||
std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// [`read_file`] with the file system's own error, for callers that tell "missing" from other
|
||||
/// failures. A refused path is a `PermissionDenied` error carrying the roots' message.
|
||||
pub fn read(path: impl AsRef<Path>) -> std::io::Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
std::fs::read(path)
|
||||
}
|
||||
|
||||
/// The text of the file at `path`, inside the automation read root when there is one.
|
||||
pub fn read_to_string(path: impl AsRef<Path>) -> Result<String, String> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_read(path)?;
|
||||
std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
|
||||
pub fn write_file(path: impl AsRef<Path>, bytes: &[u8]) -> Result<(), String> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_write(path)?;
|
||||
// Replaced atomically where there is a real file system (see `atomic`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let written = crate::atomic::write_atomic(path, bytes);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let written = std::fs::write(path, bytes);
|
||||
written.map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Atomically replace the file at `path` with the contents of `staged`, a private temporary file
|
||||
/// the engine made itself (so it is not checked), inside the automation write root when there is
|
||||
/// one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn replace_with(path: impl AsRef<Path>, staged: impl AsRef<Path>) -> std::io::Result<()> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
let mut input = std::fs::File::open(staged)?;
|
||||
crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ()))
|
||||
}
|
||||
|
||||
/// Could the file at `path` be opened for writing (without truncating it)? Inside the automation
|
||||
/// write root when there is one.
|
||||
pub fn writable(path: impl AsRef<Path>) -> std::io::Result<()> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
std::fs::OpenOptions::new().write(true).open(path).map(drop)
|
||||
}
|
||||
|
||||
/// The canonical form of `path` (links resolved), inside the automation read root when there is
|
||||
/// one.
|
||||
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<std::path::PathBuf> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
std::fs::canonicalize(path)
|
||||
}
|
||||
|
||||
/// What the file system says about `path` (links followed), inside the automation read root when
|
||||
/// there is one. A refused path is a `PermissionDenied` error carrying the roots' message.
|
||||
pub fn metadata(path: impl AsRef<Path>) -> std::io::Result<Metadata> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
std::fs::metadata(path)
|
||||
}
|
||||
|
||||
/// Is there a file at `path` that automation may read?
|
||||
pub fn is_file(path: impl AsRef<Path>) -> bool {
|
||||
metadata(path).is_ok_and(|m| m.is_file())
|
||||
}
|
||||
|
||||
/// Is there a folder at `path` that automation may read?
|
||||
pub fn is_dir(path: impl AsRef<Path>) -> bool {
|
||||
metadata(path).is_ok_and(|m| m.is_dir())
|
||||
}
|
||||
|
||||
/// Is there anything at `path` that automation may read?
|
||||
pub fn exists(path: impl AsRef<Path>) -> bool {
|
||||
metadata(path).is_ok()
|
||||
}
|
||||
|
||||
/// The entries of the folder at `path`, inside the automation read root when there is one.
|
||||
pub fn read_dir(path: impl AsRef<Path>) -> std::io::Result<std::fs::ReadDir> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
std::fs::read_dir(path)
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
pub mod atomic;
|
||||
pub mod cmd;
|
||||
pub mod dtd;
|
||||
pub mod file_access;
|
||||
pub mod guard;
|
||||
pub mod io;
|
||||
pub mod links;
|
||||
pub mod math;
|
||||
pub mod recovery;
|
||||
@@ -111,6 +113,16 @@ pub struct DocState {
|
||||
#[derive(Debug)]
|
||||
pub struct FontScope(pub u32);
|
||||
|
||||
/// A graphic loaded in the place cursor (`place.load`, File › Place with no frame to fill).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LoadedGraphic {
|
||||
pub asset: designcraft_doc::AssetId,
|
||||
/// Natural size in points.
|
||||
pub size: (f64, f64),
|
||||
/// Image Import Options › Crop to: the box of the graphic a new frame shows (x, y, w, h).
|
||||
pub crop: Option<(f64, f64, f64, f64)>,
|
||||
}
|
||||
|
||||
impl Drop for FontScope {
|
||||
fn drop(&mut self) {
|
||||
designcraft_fonts::FontDb::global().close_scope(self.0);
|
||||
@@ -269,8 +281,8 @@ pub struct Session {
|
||||
pub text_clipboard: Option<(Arc<designcraft_doc::Story>, String)>,
|
||||
/// Requests for the UI (dialogs, view changes) produced by tools/commands.
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// Graphic loaded in the place cursor: (asset, natural size in points).
|
||||
pub loaded: Option<(designcraft_doc::AssetId, (f64, f64))>,
|
||||
/// Graphic loaded in the place cursor.
|
||||
pub loaded: Option<LoadedGraphic>,
|
||||
pub(crate) untitled: u32,
|
||||
/// Transforms applied to the current selection, oldest first (Transform Again / Sequence
|
||||
/// Again), with the selection they were applied to and whether the last one came from a
|
||||
@@ -587,8 +599,12 @@ pub(crate) use push_undo as record_undo;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_file_guard;
|
||||
#[cfg(test)]
|
||||
mod tests_idml;
|
||||
#[cfg(test)]
|
||||
mod tests_params;
|
||||
#[cfg(test)]
|
||||
mod tests_roots;
|
||||
#[cfg(test)]
|
||||
mod tests_table;
|
||||
|
||||
@@ -160,9 +160,15 @@ fn big_op(name: &str) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Deepest expression nesting parsed. Recursive descent must not overflow the stack on hostile
|
||||
/// input (`\frac\frac…`, `{{{{…`, `\left(\left(…`); past the cap groups stop descending.
|
||||
const MAX_NESTING: usize = 256;
|
||||
|
||||
struct Parser<'a> {
|
||||
s: &'a [char],
|
||||
i: usize,
|
||||
/// Enclosing group depth, at most [`MAX_NESTING`].
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
@@ -220,14 +226,19 @@ impl Parser<'_> {
|
||||
out
|
||||
}
|
||||
fn arg(&mut self) -> Node {
|
||||
if self.depth >= MAX_NESTING {
|
||||
return Node::Group(Vec::new());
|
||||
}
|
||||
self.depth += 1;
|
||||
self.skip_ws();
|
||||
if self.peek() == Some('{') {
|
||||
let n = if self.peek() == Some('{') {
|
||||
self.i += 1;
|
||||
let g = self.list(Some('}'));
|
||||
Node::Group(g)
|
||||
Node::Group(self.list(Some('}')))
|
||||
} else {
|
||||
self.atom().unwrap_or(Node::Group(vec![]))
|
||||
}
|
||||
};
|
||||
self.depth -= 1;
|
||||
n
|
||||
}
|
||||
fn delimiter(&mut self) -> String {
|
||||
self.skip_ws();
|
||||
@@ -280,7 +291,7 @@ impl Parser<'_> {
|
||||
}
|
||||
s.push(c);
|
||||
}
|
||||
Box::new(parse(&s))
|
||||
Box::new(parse_at(&s, self.depth + 1))
|
||||
});
|
||||
let a = self.arg();
|
||||
Node::Sqrt(idx, Box::new(a))
|
||||
@@ -329,6 +340,10 @@ impl Parser<'_> {
|
||||
})
|
||||
}
|
||||
fn list(&mut self, end: Option<char>) -> Vec<Node> {
|
||||
if self.depth >= MAX_NESTING {
|
||||
return Vec::new();
|
||||
}
|
||||
self.depth += 1;
|
||||
let mut out: Vec<Node> = Vec::new();
|
||||
loop {
|
||||
self.skip_ws();
|
||||
@@ -362,9 +377,13 @@ impl Parser<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.depth -= 1;
|
||||
out
|
||||
}
|
||||
fn list_until_right(&mut self) -> Vec<Node> {
|
||||
if self.depth >= MAX_NESTING {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
self.skip_ws();
|
||||
@@ -375,7 +394,7 @@ impl Parser<'_> {
|
||||
self.i += 6;
|
||||
break;
|
||||
}
|
||||
let mut p = Parser { s: self.s, i: self.i };
|
||||
let mut p = Parser { s: self.s, i: self.i, depth: self.depth + 1 };
|
||||
let one = p.list_one();
|
||||
self.i = p.i;
|
||||
out.extend(one);
|
||||
@@ -410,8 +429,12 @@ impl Parser<'_> {
|
||||
}
|
||||
|
||||
fn parse(src: &str) -> Node {
|
||||
parse_at(src, 0)
|
||||
}
|
||||
|
||||
fn parse_at(src: &str, depth: usize) -> Node {
|
||||
let chars: Vec<char> = src.chars().collect();
|
||||
let mut p = Parser { s: &chars, i: 0 };
|
||||
let mut p = Parser { s: &chars, i: 0, depth };
|
||||
Node::Group(p.list(None))
|
||||
}
|
||||
|
||||
@@ -714,4 +737,18 @@ mod tests {
|
||||
let (svg, _) = to_svg(r"\sqrt{a^2+b^2} \ne \pi", 14.0);
|
||||
assert!(designcraft_images::svg_tree(&svg).is_some());
|
||||
}
|
||||
|
||||
/// Hostile input must not overflow the stack: nesting past the cap stops descending instead
|
||||
/// of recursing once per group (100k levels used to abort the process).
|
||||
#[test]
|
||||
fn an_over_deep_expression_is_bounded() {
|
||||
for latex in [
|
||||
format!("{}x{}", "{".repeat(100_000), "}".repeat(100_000)),
|
||||
format!("{}x", r"\frac".repeat(100_000)),
|
||||
format!("{}x", r"\left(".repeat(100_000)),
|
||||
] {
|
||||
let (svg, _) = to_svg(&latex, 12.0);
|
||||
assert!(!svg.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+174
-40
@@ -11,6 +11,10 @@
|
||||
//! An app that starts moves into its own folder what apps that have ended left in theirs, and
|
||||
//! leaves the folder of one still running alone.
|
||||
//!
|
||||
//! A folder given to `file.recovery.save` that is not this run's own may hold other runs' entries
|
||||
//! under the same uids. This run writes there only entries it finds free, replaces and removes
|
||||
//! only those it wrote or recovered, and refuses the folder of a run whose lock is still held.
|
||||
//!
|
||||
//! Each file is replaced atomically, but the pair is not a transaction. The document is published
|
||||
//! and made durable before its metadata so a crash can leave metadata missing or stale, never a
|
||||
//! listed metadata record without a complete primary document. Listing and opening recovery data
|
||||
@@ -192,10 +196,11 @@ fn lock_marker(file: &std::fs::File) -> std::io::Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// A run's folder that was removed while it runs is made again by the next save: so is its lock.
|
||||
/// Returns whether `dir` is this run's folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn relock(dir: &Path, created: bool) -> Result<()> {
|
||||
fn relock(dir: &Path, created: bool) -> Result<bool> {
|
||||
let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(()) };
|
||||
let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(false) };
|
||||
validate_recovery_dir(&run.root)?;
|
||||
#[cfg(unix)]
|
||||
let directory = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
@@ -219,7 +224,7 @@ fn relock(dir: &Path, created: bool) -> Result<()> {
|
||||
return Err(EngineError::Other(format!("{}: this run no longer owns its recovery folder", dir.display())));
|
||||
}
|
||||
match std::fs::symlink_metadata(dir.join(LOCK)) {
|
||||
Ok(_) => return validate_lock(dir, &run.lock),
|
||||
Ok(_) => return validate_lock(dir, &run.lock).map(|()| true),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
||||
}
|
||||
@@ -242,7 +247,93 @@ fn relock(dir: &Path, created: bool) -> Result<()> {
|
||||
{
|
||||
run.directory = replacement_directory;
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Entries this process wrote or recovered in folders that are not its run's own: (canonical
|
||||
/// folder, uid). Document uids start at 1 in every process, so a shared folder's entry under
|
||||
/// one of this run's uids may be another run's.
|
||||
static WRITTEN: std::sync::Mutex<std::collections::BTreeSet<(PathBuf, u64)>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
|
||||
|
||||
/// What this run may do in a recovery folder.
|
||||
struct Access {
|
||||
/// This run's own folder (`run_dir`): every entry in it is this run's.
|
||||
own: bool,
|
||||
key: PathBuf,
|
||||
}
|
||||
|
||||
impl Access {
|
||||
fn owns(&self, uid: u64) -> bool {
|
||||
self.own || WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&(self.key.clone(), uid))
|
||||
}
|
||||
|
||||
fn record(&self, uid: u64) {
|
||||
if !self.own {
|
||||
WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert((self.key.clone(), uid));
|
||||
}
|
||||
}
|
||||
|
||||
fn forget(&self, uid: u64) {
|
||||
WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&(self.key.clone(), uid));
|
||||
}
|
||||
|
||||
/// Refuse an entry of another run before anything is written: this run never replaces it.
|
||||
fn check_free(&self, dir: &Path, uid: u64) -> Result<()> {
|
||||
if self.owns(uid) {
|
||||
return Ok(());
|
||||
}
|
||||
let (doc, meta) = files(dir, uid);
|
||||
for path in [doc, meta] {
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
_ => {
|
||||
return Err(EngineError::Other(format!(
|
||||
"{}: recovery data this run did not write is there; recover it or choose another recovery folder",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check what this run may do in `dir`. A folder that is not this run's own may be the folder
|
||||
/// of another running app, whose lock that app holds: this run leaves it alone.
|
||||
fn access(dir: &Path, created: bool) -> Result<Access> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let own = relock(dir, created)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !own {
|
||||
check_unheld(dir)?;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let own = {
|
||||
let _ = created;
|
||||
false
|
||||
};
|
||||
Ok(Access { own, key: directory_key(dir)? })
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn check_unheld(dir: &Path) -> Result<()> {
|
||||
let path = dir.join(LOCK);
|
||||
let in_use =
|
||||
|| EngineError::Other(format!("{}: another running DesignCraft keeps its recovery data here; choose another recovery folder", dir.display()));
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(metadata) if is_link_like(&metadata) || !metadata.file_type().is_file() => return Err(in_use()),
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(EngineError::Other(format!("{}: {e}", path.display()))),
|
||||
}
|
||||
// Opened for writing: over NFS an exclusive lock on a file opened for reading is refused.
|
||||
let lock = std::fs::OpenOptions::new().read(true).write(true).open(&path).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
match lock.try_lock() {
|
||||
// Free: the run that held it has ended. Dropping the handle releases it again.
|
||||
Ok(()) => Ok(()),
|
||||
Err(std::fs::TryLockError::WouldBlock) => Err(in_use()),
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move into `own`, this run's folder, what is left to recover beside it: the entries of runs
|
||||
@@ -329,15 +420,21 @@ fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
|
||||
(dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json")))
|
||||
}
|
||||
|
||||
/// Remove a document's recovery entry (it was saved or closed).
|
||||
/// Remove a document's recovery entry (it was saved or closed). In a folder that is not this
|
||||
/// run's own, only an entry this run wrote or recovered is removed.
|
||||
pub fn discard(dir: &Path, uid: u64) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() {
|
||||
if validate_recovery_dir(dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(access) = access(dir, false) else { return };
|
||||
if !access.owns(uid) {
|
||||
return;
|
||||
}
|
||||
let (d, m) = files(dir, uid);
|
||||
let _ = std::fs::remove_file(d);
|
||||
let _ = std::fs::remove_file(m);
|
||||
access.forget(uid);
|
||||
}
|
||||
|
||||
/// Consume this document's checkpoint provenance after Save/Close/Revert. A default-directory
|
||||
@@ -385,29 +482,29 @@ fn protect_retained_sources(s: &Session, dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_save(s: &Session, dir: &Path) -> Result<()> {
|
||||
fn prepare_save(s: &Session, dir: &Path) -> Result<Access> {
|
||||
// Check before any write or deletion: a failed republish can retain a source whose uid
|
||||
// coincides with an unrelated live document in a manually configured recovery directory.
|
||||
protect_retained_sources(s, dir)?;
|
||||
let created = secure_recovery_dir(dir)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
relock(dir, created)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = created;
|
||||
Ok(())
|
||||
let access = access(dir, created)?;
|
||||
for document in s.docs.iter().filter(|d| d.is_dirty()) {
|
||||
access.check_free(dir, document.uid)?;
|
||||
}
|
||||
Ok(access)
|
||||
}
|
||||
|
||||
/// Write recovery entries for dirty documents and drop clean checkpoints. Returns how many
|
||||
/// were written. Command callers use the tracked variant without configuring automatic recovery.
|
||||
pub fn save(s: &Session, dir: &Path) -> Result<usize> {
|
||||
prepare_save(s, dir)?;
|
||||
let access = prepare_save(s, dir)?;
|
||||
let mut n = 0;
|
||||
for d in &s.docs {
|
||||
if !d.is_dirty() {
|
||||
discard(dir, d.uid);
|
||||
continue;
|
||||
}
|
||||
write_entry(dir, d)?;
|
||||
write_entry(&access, dir, d)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
@@ -424,7 +521,7 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
|
||||
));
|
||||
}
|
||||
}
|
||||
prepare_save(s, dir)?;
|
||||
let access = prepare_save(s, dir)?;
|
||||
let mut n = 0;
|
||||
for index in 0..s.docs.len() {
|
||||
if s.docs.get(index).is_some_and(|document| !document.is_dirty()) {
|
||||
@@ -435,25 +532,30 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
|
||||
let (doc, meta) = files(dir, document.uid);
|
||||
let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref()))
|
||||
.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
write_recovery_file(&doc, &bytes)?;
|
||||
// An entry this run did not write is never replaced, even one made since it checked.
|
||||
let replace = access.owns(document.uid);
|
||||
publish_recovery_file(&doc, &bytes, replace)?;
|
||||
access.record(document.uid);
|
||||
// The primary is useful even if optional metadata fails next. Track it immediately.
|
||||
if !has_checkpoint(document, dir) {
|
||||
document.recovery_entries.push((dir.to_path_buf(), document.uid));
|
||||
}
|
||||
let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()});
|
||||
write_recovery_file(&meta, metadata.to_string().as_bytes())?;
|
||||
publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Publish a document's recovery entry before its optional metadata.
|
||||
fn write_entry(dir: &Path, d: &DocState) -> Result<()> {
|
||||
fn write_entry(access: &Access, dir: &Path, d: &DocState) -> Result<()> {
|
||||
let (doc, meta) = files(dir, d.uid);
|
||||
let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
write_recovery_file(&doc, &bytes)?;
|
||||
let replace = access.owns(d.uid);
|
||||
publish_recovery_file(&doc, &bytes, replace)?;
|
||||
access.record(d.uid);
|
||||
let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()});
|
||||
write_recovery_file(&meta, metadata.to_string().as_bytes())
|
||||
publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -499,11 +601,6 @@ fn secure_recovery_dir(_dir: &Path) -> Result<bool> {
|
||||
Err(EngineError::Other("recovery files are unavailable on the web".into()))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
publish_recovery_file(path, bytes, true)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> {
|
||||
let Some(dir) = path.parent() else {
|
||||
@@ -581,11 +678,6 @@ fn sync_recovery_dir(dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> {
|
||||
Err(EngineError::Other("recovery files are unavailable on the web".into()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<()> {
|
||||
Err(EngineError::Other("recovery files are unavailable on the web".into()))
|
||||
@@ -661,18 +753,13 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
|
||||
fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result<Vec<usize>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
validate_recovery_dir(dir)?;
|
||||
relock(dir, false)?;
|
||||
}
|
||||
validate_recovery_dir(dir)?;
|
||||
let source = access(dir, false)?;
|
||||
let destination = s.recovery_dir.clone().unwrap_or_else(|| dir.to_path_buf());
|
||||
let created = secure_recovery_dir(&destination)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
relock(&destination, created)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = created;
|
||||
let source_key = directory_key(dir)?;
|
||||
let destination_key = directory_key(&destination)?;
|
||||
let target = access(&destination, created)?;
|
||||
let source_key = source.key.clone();
|
||||
let destination_key = target.key.clone();
|
||||
let mut opened = Vec::new();
|
||||
// For this run's own folder, first bring in what runs that ended left in theirs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -705,6 +792,8 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat
|
||||
let mut st = DocState::new(d, path);
|
||||
reserve_uid(&mut st, &mut occupied)?;
|
||||
st.recovery_entries.push((dir.to_path_buf(), uid));
|
||||
// Recovered, the entry is this run's: Save and Close of its document may remove it.
|
||||
source.record(uid);
|
||||
st.fonts = fonts;
|
||||
// Unsaved: the copy on disk (if any) is older than what was recovered.
|
||||
st.saved_doc = std::sync::Arc::new((*st.doc).clone());
|
||||
@@ -721,6 +810,7 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat
|
||||
.and_then(|bytes| publish(&document, &bytes));
|
||||
match published {
|
||||
Ok(()) => {
|
||||
target.record(state.uid);
|
||||
state.recovery_entries.push((destination.clone(), state.uid));
|
||||
let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()});
|
||||
match publish(&metadata, meta.to_string().as_bytes()) {
|
||||
@@ -1477,6 +1567,50 @@ mod tests {
|
||||
assert!(!left.join(LOCK).exists());
|
||||
assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock");
|
||||
}
|
||||
|
||||
/// Another run's entry under uid 7 in a chosen folder, with a document of this run numbered
|
||||
/// 7 too: document uids start at 1 in every process.
|
||||
fn chosen_folder_with_another_runs_entry() -> (tempfile::TempDir, PathBuf, PathBuf) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let (doc, meta) = files(root.path(), 7);
|
||||
std::fs::write(&doc, b"another run's document").unwrap();
|
||||
std::fs::write(&meta, b"{\"title\": \"theirs\"}").unwrap();
|
||||
(root, doc, meta)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_document_leaves_another_runs_entry_in_a_chosen_folder() {
|
||||
let (root, doc, meta) = chosen_folder_with_another_runs_entry();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.docs[0].uid = 7;
|
||||
assert!(!s.docs[0].is_dirty());
|
||||
s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap();
|
||||
s.execute("file.close", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(doc).unwrap(), b"another run's document");
|
||||
assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dirty_document_does_not_overwrite_another_runs_entry_in_a_chosen_folder() {
|
||||
let (root, doc, meta) = chosen_folder_with_another_runs_entry();
|
||||
let mut s = unsaved("this run's work");
|
||||
s.docs[0].uid = 7;
|
||||
assert!(s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err());
|
||||
assert!(save(&s, root.path()).is_err());
|
||||
assert_eq!(std::fs::read(doc).unwrap(), b"another run's document");
|
||||
assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}");
|
||||
}
|
||||
|
||||
/// A chosen folder that is a running app's run folder is that app's.
|
||||
#[test]
|
||||
fn a_folder_another_running_app_holds_is_refused() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let held = new_lock(root.path()).unwrap();
|
||||
held.try_lock().unwrap();
|
||||
assert!(save(&dirty_session(), root.path()).is_err());
|
||||
assert!(list(root.path()).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+54
-10
@@ -5,7 +5,8 @@
|
||||
//! becomes the JSON value at `path` in the result of step `N` (0-based): after
|
||||
//! `frame.create → {"id": 8, "story": 9}`, `{"story": "$0.story"}` passes `9`. Paths are dotted
|
||||
//! keys and array indices (`$2.ids.0`). Inside longer strings `${N.path}` is replaced by the
|
||||
//! value's text (`"Frame ${0.id}"`).
|
||||
//! value's text (`"Frame ${0.id}"`). Any other string starting with `$` is plain text (`"$1-$2"`,
|
||||
//! `"$5 off"`), and `$$` anywhere writes a literal `$` (`"$$1"` passes `$1`, a GREP group).
|
||||
//!
|
||||
//! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`),
|
||||
//! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and
|
||||
@@ -92,27 +93,44 @@ fn short(v: &Value) -> String {
|
||||
if s.len() > 120 { format!("{}…", &s[..s.floor_char_boundary(120)]) } else { s }
|
||||
}
|
||||
|
||||
/// Whether `r` (the text after `$`) is a whole reference: digits or `last`, then zero or more
|
||||
/// `.segment` of ASCII letters, digits, `_` or `-`.
|
||||
fn is_reference(r: &str) -> bool {
|
||||
let mut parts = r.split('.');
|
||||
let head = parts.next().unwrap_or("");
|
||||
let head_ok = head == "last" || (!head.is_empty() && head.bytes().all(|b| b.is_ascii_digit()));
|
||||
head_ok && parts.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'))
|
||||
}
|
||||
|
||||
/// Replace references in `params` with values from earlier `results`.
|
||||
pub fn resolve(params: &Value, results: &[Value]) -> Result<Value, String> {
|
||||
Ok(match params {
|
||||
Value::String(s) => {
|
||||
if let Some(r) = s.strip_prefix('$').filter(|r| !r.is_empty() && !r.contains(['{', ' '])) {
|
||||
if let Some(r) = s.strip_prefix('$').filter(|r| is_reference(r)) {
|
||||
return lookup(r, results);
|
||||
}
|
||||
if !s.contains("${") {
|
||||
if !s.contains("${") && !s.contains("$$") {
|
||||
return Ok(params.clone());
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut rest = s.as_str();
|
||||
while let Some(i) = rest.find("${") {
|
||||
while let Some(i) = rest.find('$') {
|
||||
out.push_str(&rest[..i]);
|
||||
let end = rest[i..].find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))? + i;
|
||||
let v = lookup(&rest[i + 2..end], results)?;
|
||||
match v {
|
||||
Value::String(t) => out.push_str(&t),
|
||||
other => out.push_str(&other.to_string()),
|
||||
let after = &rest[i + 1..];
|
||||
if let Some(tail) = after.strip_prefix('$') {
|
||||
out.push('$');
|
||||
rest = tail;
|
||||
} else if let Some(inner) = after.strip_prefix('{') {
|
||||
let end = inner.find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))?;
|
||||
match lookup(&inner[..end], results)? {
|
||||
Value::String(t) => out.push_str(&t),
|
||||
other => out.push_str(&other.to_string()),
|
||||
}
|
||||
rest = &inner[end + 1..];
|
||||
} else {
|
||||
out.push('$');
|
||||
rest = after;
|
||||
}
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
Value::String(out)
|
||||
@@ -196,6 +214,32 @@ mod tests {
|
||||
assert_eq!(r, json!({"story": 9, "id": 5, "label": "Frame 8 of A", "keep": "$", "list": ["A"]}));
|
||||
assert!(resolve(&json!({"x": "$5.id"}), &results).unwrap_err().contains("hasn't run"));
|
||||
assert!(resolve(&json!({"x": "$0.nope"}), &results).unwrap_err().contains("no `nope`"));
|
||||
assert!(resolve(&json!({"x": "Frame ${0.id"}), &results).unwrap_err().contains("unclosed"));
|
||||
}
|
||||
|
||||
/// GREP replacements start with `$` too; only a well-formed reference is looked up (#749).
|
||||
#[test]
|
||||
fn only_well_formed_references_resolve() {
|
||||
let results = vec![json!({"id": 8})];
|
||||
let p = json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$$1", "e": "$${0.id}", "f": "$0.id", "g": "${0.id}x", "h": "$1$$", "i": "$1."});
|
||||
let r = resolve(&p, &results).unwrap();
|
||||
assert_eq!(r, json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$1", "e": "${0.id}", "f": 8, "g": "8x", "h": "$1$", "i": "$1."}));
|
||||
}
|
||||
|
||||
/// Czech typesetting: a nonbreaking space after one-letter prepositions.
|
||||
#[test]
|
||||
fn a_script_grep_change_keeps_its_group_reference() {
|
||||
let mut s = crate::Session::new();
|
||||
let steps = vec![
|
||||
Step { command: "file.new".into(), params: json!({}) },
|
||||
Step { command: "frame.create".into(), params: json!({"rect": [36, 36, 300, 300], "content": "text", "text": "a v Praze"}) },
|
||||
Step { command: "find.change".into(), params: json!({"find": r"\b([av]) ", "change": "$1\u{a0}", "grep": true, "caseSensitive": true}) },
|
||||
Step { command: "story.get".into(), params: json!({"story": "$1.story"}) },
|
||||
];
|
||||
let r = s.run_script(&steps);
|
||||
assert!(r.failed.is_none(), "{r:?}");
|
||||
assert_eq!(r.results[2]["count"], 2);
|
||||
assert_eq!(r.results[3]["text"], "a\u{a0}v\u{a0}Praze");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -877,3 +877,58 @@ fn duplicating_a_page_to_an_index_is_one_undo_step() {
|
||||
assert!(s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "to": 4})).is_err());
|
||||
assert_eq!(widths(&s), [300.0, 400.0, 500.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_type_tool_draws_a_text_frame_over_a_locked_frame() {
|
||||
// Adobe's "Set up a modern magazine layout" (4:03): with the page frame locked (Cmd+L), the
|
||||
// Type tool draws text frames on top of it; the locked frame stays an empty frame.
|
||||
use designcraft_tools::{PointerEvent, PointerKind, SnapView};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap();
|
||||
let big = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.stroke", &json!({"ids": [big], "swatch": "[Black]", "weight": 6})).unwrap();
|
||||
s.execute("object.lock", &json!({"ids": [big]})).unwrap();
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
s.set_tool("type");
|
||||
let v = ViewInfo { snap: SnapView::OFF, ..ViewInfo::at_zoom(1.0) };
|
||||
let layout = s.layout();
|
||||
let at = |x: f64, y: f64| layout.to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(x, y));
|
||||
let (a, b) = (at(100.0, 100.0), at(300.0, 200.0));
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0 + a.x - 100.0, a.y + 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let it = d.item(designcraft_doc::ItemId(big)).unwrap();
|
||||
assert!(matches!(it.content, designcraft_doc::Content::Unassigned), "the locked frame stays empty");
|
||||
let frames: Vec<_> = d.spreads[0].items.iter().filter(|i| i.is_text_frame()).collect();
|
||||
assert_eq!(frames.len(), 1, "a new text frame");
|
||||
let r = frames[0].bounds();
|
||||
assert!((r.x0 - 100.0).abs() < 1e-6 && (r.y1 - 200.0).abs() < 1e-6, "{r:?}");
|
||||
// The engine refuses to turn a locked empty frame into a text frame.
|
||||
assert!(s.execute("text.placeCaret", &json!({"frame": big, "point": [300, 400]})).is_err());
|
||||
// Unlocked, a press inside converts it, as before.
|
||||
s.execute("object.unlockAll", &json!({})).unwrap();
|
||||
s.execute("text.placeCaret", &json!({"frame": big, "point": [300, 400]})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(big)).unwrap().is_text_frame());
|
||||
}
|
||||
|
||||
/// A line is set when its baseline fits in the frame; its descenders may hang below the frame, as
|
||||
/// in InDesign. A 103 pt heading in a 78.5 pt frame (Cap Height first baseline) was overset.
|
||||
#[test]
|
||||
fn a_line_fits_when_its_baseline_does() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let overset = |s: &mut Session, height: f64| {
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 36, 400, 36.0 + height], "content": "text", "text": "Gyp"})).unwrap();
|
||||
s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "firstBaseline": "capHeight", "inset": 0})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 3})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"size": 103}})).unwrap();
|
||||
let st = s.execute("story.get", &json!({"story": r["story"]})).unwrap();
|
||||
!st["overset"].is_null()
|
||||
};
|
||||
// Cap height of the default font at 103 pt is about 70 pt: the baseline fits in 78.5 pt, the
|
||||
// descent (about 25 pt more) doesn't.
|
||||
assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame");
|
||||
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! The guarantee behind `--automation-read-root` and `--automation-write-root`: every file a
|
||||
//! command reads or writes by path goes through a checked reader, in every crate a command can
|
||||
//! reach, not only this one.
|
||||
//!
|
||||
//! In the engine that reader is the [`crate::io`] module. The lower crates cannot depend on the
|
||||
//! engine (`cargo xtask layers`), so they either have no file access at all or take a reader or
|
||||
//! an access check from the caller (`designcraft_idml::import_idml_with`,
|
||||
//! `designcraft_fonts::FontDb::load_document_fonts`).
|
||||
//!
|
||||
//! The scan reads the source of every crate under `crates/` and flags what touches the file
|
||||
//! system, however it is imported or spelled: `std::fs`, a bare `fs::` after `use std::fs` or
|
||||
//! `use std::{fs, ...}`, `File::open`/`File::create`, `OpenOptions`, and the `Path` probes
|
||||
//! (`.metadata(`, `.exists(`, `.canonicalize(`, `.read_dir(`...), as methods or as
|
||||
//! `Path::exists`. The host applications under `apps/` are out of scope: they run with the
|
||||
//! operator's own authority, and the roots are applied inside the process they start.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Files that may touch the file system, with why. Paths are relative to `crates/`. A file not
|
||||
/// listed here, and not a test file or test module, must not.
|
||||
const ALLOWED: &[(&str, &str)] = &[
|
||||
("engine/src/io.rs", "the checked gateway: every read and write a command makes"),
|
||||
("engine/src/file_access.rs", "the root check itself (resolves links with the file system)"),
|
||||
("engine/src/atomic.rs", "how a write reaches the disk; commands call it only through io::write_file and io::replace_with"),
|
||||
(
|
||||
"engine/src/recovery.rs",
|
||||
"the unsaved documents live in a folder the host application chooses (a command's own `dir` is checked in cmd/file.rs before it gets here)",
|
||||
),
|
||||
(
|
||||
"engine/src/cmd/package.rs",
|
||||
"`OutputRoot` checks the folder against the write root before it creates anything and then only writes inside it. Its reads are of the font files that draw the document's text (installed fonts and the `Document Fonts` folder), never of a path a command names",
|
||||
),
|
||||
(
|
||||
"fonts/src/fontdb.rs",
|
||||
"the installed-font scan reads the platform's fixed font folders (`system_font_dirs`: no command parameter or document field names one), and `load_document_fonts` asks the caller's `allow` check about its folder and each font file before opening either",
|
||||
),
|
||||
(
|
||||
"mcp/src/tools.rs",
|
||||
"`screenshot` reads back the PNG the connected app just wrote. Connected mode cannot be combined with the roots (the CLI refuses the pair), and the app's own engine checks the path it writes",
|
||||
),
|
||||
];
|
||||
|
||||
fn rust_files(dir: &Path, files: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, files);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test files (`tests.rs`, `tests_*.rs`, `*_tests.rs`, anything under a `tests/` folder) build
|
||||
/// their own fixtures on disk.
|
||||
fn is_test_file(rel: &str) -> bool {
|
||||
let name = rel.rsplit('/').next().unwrap_or(rel);
|
||||
name.starts_with("tests") || name.ends_with("_tests.rs") || name == "testing.rs" || rel.split('/').any(|part| part == "tests")
|
||||
}
|
||||
|
||||
/// The file's lines but its test modules: a module is skipped from its `#[cfg(test)]` (or
|
||||
/// `#[cfg(all(test, ...))]`) to the closing brace at the same indent. A `mod x;` declaration
|
||||
/// has no body to skip.
|
||||
fn code_outside_tests(text: &str) -> Vec<(usize, &str)> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut code = vec![];
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let attr = lines[i].trim();
|
||||
let module = lines.get(i + 1).map(|l| l.trim_start().trim_start_matches("pub "));
|
||||
if attr.starts_with("#[cfg(") && attr.contains("test") && module.is_some_and(|m| m.starts_with("mod ")) {
|
||||
let header = lines[i + 1];
|
||||
i += 2;
|
||||
if header.trim_end().ends_with('{') {
|
||||
let indent = &header[..header.len() - header.trim_start().len()];
|
||||
let close = format!("{indent}}}");
|
||||
while i < lines.len() && lines[i].trim_end() != close {
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
code.push((i + 1, lines[i]));
|
||||
i += 1;
|
||||
}
|
||||
code
|
||||
}
|
||||
|
||||
/// `line` without its `//` comment (a `//` inside a string or char is rare enough in this code
|
||||
/// to leave alone: it only hides the rest of that line from the scan).
|
||||
fn without_comment(line: &str) -> &str {
|
||||
line.find("//").map_or(line, |at| &line[..at])
|
||||
}
|
||||
|
||||
fn is_ident(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// Does `line` contain `word` not glued to a longer identifier on its left (`std::fs::` and
|
||||
/// ` fs::` match `fs::`; `ofs::` does not)?
|
||||
fn has_word(line: &str, word: &str) -> bool {
|
||||
line.match_indices(word).any(|(at, _)| !line[..at].chars().next_back().is_some_and(is_ident))
|
||||
}
|
||||
|
||||
/// What in `line` reaches the file system, or `None`.
|
||||
fn file_system_use(line: &str) -> Option<String> {
|
||||
let line = without_comment(line);
|
||||
// `std::fs::read`, `fs::read` after `use std::fs;` or `use std::{fs, io};`, `std::os::unix::fs::...`.
|
||||
for word in ["fs::", "File::open", "File::create", "File::options", "OpenOptions", "atomic::write_atomic"] {
|
||||
if has_word(line, word) {
|
||||
return Some(word.into());
|
||||
}
|
||||
}
|
||||
// The probes on a path, as `p.exists()` or `Path::exists`.
|
||||
for method in ["metadata", "symlink_metadata", "canonicalize", "read_dir", "try_exists", "exists", "is_file", "is_dir", "read_link"] {
|
||||
if line.contains(&format!(".{method}(")) || line.contains(&format!("Path::{method}")) {
|
||||
return Some(method.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `use` statements (which can span lines) that bring the file system module in under any name:
|
||||
/// `use std::{fs as files, io};` leaves no `fs::` to find at the call.
|
||||
fn imports_fs(text: &str) -> Vec<usize> {
|
||||
let mut hits = vec![];
|
||||
let mut statement = String::new();
|
||||
let mut start = 0;
|
||||
for (n, line) in code_outside_tests(text) {
|
||||
let line = without_comment(line);
|
||||
if statement.is_empty() {
|
||||
if !line.trim_start().starts_with("use ") && !line.trim_start().starts_with("pub use ") {
|
||||
continue;
|
||||
}
|
||||
start = n;
|
||||
}
|
||||
statement.push_str(line);
|
||||
statement.push(' ');
|
||||
if line.contains(';') {
|
||||
let words: Vec<&str> = statement.split(|c: char| !is_ident(c)).collect();
|
||||
if words.contains(&"fs") && (words.contains(&"std") || words.contains(&"tokio") || words.contains(&"async_std")) {
|
||||
hits.push(start);
|
||||
}
|
||||
statement.clear();
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// Every offending line of the crate sources under `crates_dir`, minus [`ALLOWED`] and test code.
|
||||
fn offenders(crates_dir: &Path) -> (Vec<String>, usize) {
|
||||
let mut files = vec![];
|
||||
rust_files(crates_dir, &mut files);
|
||||
let mut scanned = 0;
|
||||
let mut found = vec![];
|
||||
for path in files {
|
||||
let rel = path.strip_prefix(crates_dir).unwrap_or(&path).to_string_lossy().replace('\\', "/");
|
||||
// `crates/<name>/src/...` only: build scripts, benches and `tests/` are not shipped code.
|
||||
if !rel.split('/').nth(1).is_some_and(|d| d == "src") || is_test_file(&rel) || ALLOWED.iter().any(|(allowed, _)| *allowed == rel) {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||
scanned += 1;
|
||||
for (n, line) in code_outside_tests(&text) {
|
||||
if let Some(what) = file_system_use(line) {
|
||||
found.push(format!("crates/{rel}:{n}: `{what}`: {}", line.trim()));
|
||||
}
|
||||
}
|
||||
for n in imports_fs(&text) {
|
||||
found.push(format!("crates/{rel}:{n}: imports the file system module"));
|
||||
}
|
||||
}
|
||||
(found, scanned)
|
||||
}
|
||||
|
||||
fn crates_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||
}
|
||||
|
||||
/// Every command reads and writes files through [`crate::io`], in the engine and in every crate
|
||||
/// below it: none may reach the file system itself, so the automation roots cannot be walked
|
||||
/// around by a path that one of them opens. The exceptions are [`ALLOWED`], each with its reason.
|
||||
#[test]
|
||||
fn no_crate_touches_the_file_system_outside_the_checked_readers() {
|
||||
let (found, scanned) = offenders(&crates_dir());
|
||||
assert!(scanned > 100, "the scan found only {scanned} source files: has the layout moved?");
|
||||
assert!(
|
||||
found.is_empty(),
|
||||
"route these through crate::io (engine) or a reader the caller injects (lower crates), or add the file to ALLOWED with the reason it is safe: {found:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The allow list names files that exist, so a rename cannot leave a stale exception behind (or
|
||||
/// quietly exempt a new file of the same name).
|
||||
#[test]
|
||||
fn every_allowed_file_exists_and_still_needs_its_exception() {
|
||||
for (rel, reason) in ALLOWED {
|
||||
let path = crates_dir().join(rel);
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{rel}: {e}"));
|
||||
assert!(!reason.is_empty());
|
||||
let touches = code_outside_tests(&text).iter().any(|(_, line)| file_system_use(line).is_some()) || !imports_fs(&text).is_empty();
|
||||
assert!(touches, "{rel} no longer touches the file system: drop it from ALLOWED");
|
||||
}
|
||||
}
|
||||
|
||||
/// The scan must not be foolable by how a call is spelled.
|
||||
#[test]
|
||||
fn the_scan_sees_every_spelling() {
|
||||
let flagged = [
|
||||
"let b = std::fs::read(p)?;",
|
||||
"let b = fs::read(p)?;",
|
||||
" fs::write(p, b)",
|
||||
"let f = File::open(p)?;",
|
||||
"let f = std::fs::File::create(p)?;",
|
||||
"let f = OpenOptions::new().write(true).open(p)?;",
|
||||
"let m = path.metadata()?;",
|
||||
"let m = path.symlink_metadata()?;",
|
||||
"let c = path.canonicalize()?;",
|
||||
"for e in path.read_dir()? {}",
|
||||
"if path.try_exists()? {}",
|
||||
"if p.exists() {}",
|
||||
"if p.is_file() {}",
|
||||
"if p.is_dir() {}",
|
||||
"let l = p.read_link()?;",
|
||||
"files.retain(Path::exists);",
|
||||
"crate::atomic::write_atomic(p, b)?;",
|
||||
"(a, fs::read(x))",
|
||||
];
|
||||
for line in flagged {
|
||||
assert!(file_system_use(line).is_some(), "not flagged: {line}");
|
||||
}
|
||||
let fine = [
|
||||
"let b = crate::io::read_file(p)?;",
|
||||
"if crate::io::is_dir(p) {}",
|
||||
"let m = crate::io::metadata(p)?;",
|
||||
"// std::fs::read(p) is what io wraps",
|
||||
"let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);",
|
||||
"let ofs::x = 1;",
|
||||
];
|
||||
for line in fine {
|
||||
assert!(file_system_use(line).is_none(), "wrongly flagged: {line}");
|
||||
}
|
||||
for import in ["use std::fs;", "use std::{fs, io};", "use std::{\n io,\n fs as files,\n};", "use std::fs::File;"] {
|
||||
assert!(!imports_fs(import).is_empty(), "import not flagged: {import}");
|
||||
}
|
||||
assert!(imports_fs("use std::io::Read;\nuse std::path::Path;").is_empty());
|
||||
assert!(imports_fs("#[cfg(test)]\nmod tests {\n use std::fs;\n}\n").is_empty(), "test modules are skipped");
|
||||
}
|
||||
@@ -107,7 +107,8 @@ fn keep_lines_idml(body_attrs: &str, lines: &[usize]) -> Vec<u8> {
|
||||
})
|
||||
.collect();
|
||||
let frame = |me: &str, x: f64, prev: &str, next: &str| {
|
||||
let pts: String = [(x, 36.0), (x, 276.0), (x + 180.0, 276.0), (x + 180.0, 36.0)]
|
||||
// 236 pt tall: 19 lines of 12 pt leading, whether or not the last line's descent must fit.
|
||||
let pts: String = [(x, 36.0), (x, 272.0), (x + 180.0, 272.0), (x + 180.0, 36.0)]
|
||||
.iter()
|
||||
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
|
||||
.collect();
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! The automation roots at the engine: the files a document names (links, data sources, book
|
||||
//! chapters, packaged fonts) are read and written through the same checks as the paths a command
|
||||
//! is given.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
use crate::{Session, cmd};
|
||||
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
/// A fresh folder with `in` (the read root), `out` (the write root) and `outside`, and the roots.
|
||||
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
|
||||
let base = std::env::temp_dir().join(format!("dc-engine-roots-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
fn p(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// A document with `pic.png` placed from `dir`, so the asset is embedded and linked to the file.
|
||||
fn linked_picture(dir: &Path) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("file.place", &json!({"path": p(&dir.join("pic.png")), "x": 36, "y": 36, "width": 72})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn link_status(s: &mut Session) -> Value {
|
||||
s.execute("links.list", &json!({})).unwrap()[0]["status"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_outside_the_read_root_reads_as_missing() {
|
||||
let (base, roots) = folders("links");
|
||||
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("in/pic.png"), PNG).unwrap();
|
||||
let mut outside = linked_picture(&base.join("outside"));
|
||||
let mut inside = linked_picture(&base.join("in"));
|
||||
assert_eq!(link_status(&mut outside), "ok");
|
||||
confine(Some(&roots), || {
|
||||
assert_eq!(link_status(&mut outside), "missing", "the file is not the agent's to look at, not even to compare");
|
||||
assert_eq!(link_status(&mut inside), "ok");
|
||||
assert!(outside.execute("links.relinkFolder", &json!({"dir": p(&base.join("outside"))})).is_err());
|
||||
// A relink to a link that leads out is refused too, and the asset keeps what it had.
|
||||
let asset = outside.execute("links.list", &json!({})).unwrap()[0]["asset"].clone();
|
||||
let e = outside.execute("links.relink", &json!({"asset": asset, "path": p(&base.join("outside/pic.png"))})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the read root"), "{e}");
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idml_links_are_read_through_the_roots() {
|
||||
let (base, roots) = folders("idml");
|
||||
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
|
||||
let mut s = linked_picture(&base.join("outside"));
|
||||
// Linked, not embedded: opening the package has to read the file its link names.
|
||||
let idml = base64_idml(&mut s);
|
||||
let linked = |doc: &designcraft_doc::Document| doc.assets.values().filter(|a| !a.data.is_empty()).count();
|
||||
let unconfined = cmd::interchange::import_idml_confined(&idml).unwrap();
|
||||
assert_eq!(linked(&unconfined), 1, "without roots the link is followed");
|
||||
let confined = confine(Some(&roots), || cmd::interchange::import_idml_confined(&idml)).unwrap();
|
||||
assert_eq!(linked(&confined), 0, "with roots a link outside the read root is not read");
|
||||
// The same through the commands that open or place a package.
|
||||
let b64 = cmd::base64_encode(&idml);
|
||||
let mut t = Session::new();
|
||||
t.execute("file.new", &json!({})).unwrap();
|
||||
confine(Some(&roots), || {
|
||||
t.execute("file.openBytes", &json!({"name": "x.idml", "base64": b64})).unwrap();
|
||||
let doc = &t.doc().unwrap().doc;
|
||||
assert_eq!(linked(doc), 0);
|
||||
t.execute("file.place", &json!({"base64": b64, "name": "x.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap();
|
||||
assert_eq!(linked(&t.doc().unwrap().doc), 0);
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
fn base64_idml(s: &mut Session) -> Vec<u8> {
|
||||
let r = s.execute("file.exportIdml", &json!({"embedImages": false})).unwrap();
|
||||
cmd::base64_decode(r["base64"].as_str().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merge_does_not_read_image_files_outside_the_read_root() {
|
||||
let (base, roots) = folders("merge");
|
||||
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("in/data.csv"), format!("@Photo\n{}\n", base.join("outside/pic.png").display())).unwrap();
|
||||
let merge = |s: &mut Session| {
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("data.source.select", &json!({"path": p(&base.join("in/data.csv"))})).unwrap();
|
||||
let frame = s.execute("frame.create", &json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})).unwrap();
|
||||
s.execute("data.placeholder.add", &json!({"field": "Photo", "item": frame["id"]})).unwrap();
|
||||
s.execute("data.merge", &json!({})).unwrap()["missingImages"].as_array().unwrap().len()
|
||||
};
|
||||
assert_eq!(merge(&mut Session::new()), 0, "without roots the picture is found");
|
||||
assert_eq!(confine(Some(&roots), || merge(&mut Session::new())), 1, "with roots it reads as missing");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_data_source_path_stored_in_a_document_is_read_through_the_roots() {
|
||||
let (base, roots) = folders("source");
|
||||
std::fs::write(base.join("outside/data.csv"), "name\nTOP SECRET\n").unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("data.source.select", &json!({"path": p(&base.join("outside/data.csv"))})).unwrap();
|
||||
confine(Some(&roots), || {
|
||||
let e = s.execute("data.source.update", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the read root") && !e.contains("TOP SECRET"), "{e}");
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_writes_its_chapters_only_inside_the_write_root() {
|
||||
let (base, roots) = folders("book");
|
||||
let chapter = base.join("in/a.designcraft");
|
||||
let second = base.join("in/b.designcraft");
|
||||
{
|
||||
// Made by another session: a book only rewrites chapters that are not open.
|
||||
let mut maker = Session::new();
|
||||
maker.execute("file.new", &json!({})).unwrap();
|
||||
maker.execute("file.save", &json!({"path": p(&chapter)})).unwrap();
|
||||
maker.execute("file.saveAs", &json!({"path": p(&second)})).unwrap();
|
||||
}
|
||||
let mut s = Session::new();
|
||||
let before = (std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap());
|
||||
confine(Some(&roots), || {
|
||||
s.execute("book.new", &json!({"path": p(&base.join("out/b.dcbook"))})).unwrap();
|
||||
s.execute("book.add", &json!({"path": p(&chapter)})).unwrap();
|
||||
s.execute("book.add", &json!({"path": p(&second)})).unwrap();
|
||||
// Paginate and Synchronize take no parameters: they rewrite the chapters themselves.
|
||||
let pdf = json!({"path": p(&base.join("outside/book.pdf"))});
|
||||
for (command, params) in [("book.paginate", json!({})), ("book.syncStyles", json!({})), ("book.exportPdf", pdf)] {
|
||||
let r = s.execute(command, ¶ms);
|
||||
let e = r.unwrap_err().to_string();
|
||||
assert!(e.contains("outside the write root"), "{command}: {e}");
|
||||
}
|
||||
let e = s.execute("book.new", &json!({"path": p(&base.join("outside/b.dcbook"))})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
});
|
||||
assert_eq!((std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap()), before, "no chapter was rewritten");
|
||||
assert!(!base.join("outside/b.dcbook").exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_and_copy_links_create_nothing_outside_the_write_root() {
|
||||
let (base, roots) = folders("package");
|
||||
std::fs::write(base.join("in/pic.png"), PNG).unwrap();
|
||||
let mut s = linked_picture(&base.join("in"));
|
||||
confine(Some(&roots), || {
|
||||
for command in ["file.package", "links.copyTo"] {
|
||||
let e = s.execute(command, &json!({"dir": p(&base.join("outside/pkg"))})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the write root"), "{command}: {e}");
|
||||
// The read root is not writable either.
|
||||
let e = s.execute(command, &json!({"dir": p(&base.join("in/pkg"))})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the write root"), "{command}: {e}");
|
||||
}
|
||||
s.execute("file.package", &json!({"dir": p(&base.join("out/pkg"))})).unwrap();
|
||||
s.execute("links.copyTo", &json!({"dir": p(&base.join("out/copies"))})).unwrap();
|
||||
});
|
||||
assert!(!base.join("outside/pkg").exists() && !base.join("in/pkg").exists());
|
||||
assert!(base.join("out/pkg/Instructions.txt").exists() && base.join("out/copies/pic.png").exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_folders_named_by_a_command_are_checked() {
|
||||
let (base, roots) = folders("recovery");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap();
|
||||
confine(Some(&roots), || {
|
||||
let out = p(&base.join("outside"));
|
||||
let inside = p(&base.join("in"));
|
||||
let to_write = p(&base.join("out"));
|
||||
let e = s.execute("file.recovery.save", &json!({"dir": out})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
let e = s.execute("file.recovery.save", &json!({"dir": inside})).unwrap_err().to_string();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
assert!(s.execute("file.recovery.list", &json!({"dir": out})).unwrap_err().to_string().contains("outside the read root"));
|
||||
// Reopening removes what it read, so it needs both.
|
||||
assert!(s.execute("file.recovery.open", &json!({"dir": inside})).unwrap_err().to_string().contains("outside the write root"));
|
||||
assert!(s.execute("file.recovery.open", &json!({"dir": to_write})).unwrap_err().to_string().contains("outside the read root"));
|
||||
assert_eq!(s.execute("file.recovery.save", &json!({"dir": to_write})).unwrap()["saved"], 1);
|
||||
});
|
||||
assert!(std::fs::read_dir(base.join("outside")).unwrap().next().is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_beside_a_document_outside_the_read_root_are_not_loaded() {
|
||||
let (base, roots) = folders("fonts");
|
||||
std::fs::create_dir_all(base.join("outside/Document Fonts")).unwrap();
|
||||
std::fs::write(base.join("outside/Document Fonts/x.ttf"), b"not a font").unwrap();
|
||||
let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let (_, faces, warnings) = confine(Some(&roots), || cmd::load_document_fonts(&mut d, &p(&base.join("outside/doc.designcraft"))));
|
||||
assert_eq!(faces, 0);
|
||||
assert!(warnings.iter().any(|w| w.contains("outside the read root")), "{warnings:?}");
|
||||
let (_, _, warnings) = cmd::load_document_fonts(&mut d, &p(&base.join("outside/doc.designcraft")));
|
||||
assert!(warnings.iter().any(|w| w.contains("x.ttf")), "without roots the folder is read and the bad font reported: {warnings:?}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_font_file_that_links_out_of_the_read_root_is_skipped() {
|
||||
let (base, roots) = folders("fontlink");
|
||||
std::fs::create_dir_all(base.join("in/Document Fonts")).unwrap();
|
||||
std::fs::write(base.join("outside/secret.ttf"), b"not a font").unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside/secret.ttf"), base.join("in/Document Fonts/link.ttf")).unwrap();
|
||||
let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let doc = p(&base.join("in/doc.designcraft"));
|
||||
let (_, _, warnings) = confine(Some(&roots), || cmd::load_document_fonts(&mut d, &doc));
|
||||
assert!(warnings.iter().any(|w| w.contains("outside the read root")), "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w.contains("not a font")), "the file was opened: {warnings:?}");
|
||||
let (_, _, warnings) = cmd::load_document_fonts(&mut d, &doc);
|
||||
assert!(warnings.iter().any(|w| w.contains("link.ttf")), "without roots the link is followed: {warnings:?}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
@@ -492,6 +492,43 @@ fn file_roundtrip_keeps_tables() {
|
||||
assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "A1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_and_paste_between_tables_keep_both_and_the_file_reopens() {
|
||||
let n = designcraft_doc::TABLE_ANCHOR.len_utf8();
|
||||
let anchor_of = |s: &Session, sid: u64, tid: u64| s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().table_anchor(tid).unwrap();
|
||||
let reopens_with = |s: &Session, sid: u64, ids: &[u64]| {
|
||||
let back = crate::cmd::file_from(&crate::cmd::file_bytes(&s.doc().unwrap().doc)).unwrap();
|
||||
let st = back.story(doc::StoryId(sid)).unwrap();
|
||||
assert!(ids.iter().all(|id| st.table_anchor(*id).is_some()), "{:?}", st.tables.keys());
|
||||
assert_eq!(st.tables.len(), ids.len());
|
||||
};
|
||||
// Backspace at the start of the second of two consecutive tables.
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
let t1 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
let len = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().len();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": len})).unwrap();
|
||||
let t2 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
let a2 = anchor_of(&s, sid, t2);
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": a2})).unwrap();
|
||||
s.execute("text.delete", &json!({})).unwrap();
|
||||
check(&s);
|
||||
reopens_with(&s, sid, &[t1, t2]);
|
||||
// Pasting a copy of a table right after its anchor.
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
let t1 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
let a1 = anchor_of(&s, sid, t1);
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": a1, "focus": a1 + n})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": a1 + n})).unwrap();
|
||||
let pasted = s.execute("edit.paste", &json!({})).unwrap();
|
||||
check(&s);
|
||||
assert_eq!(pasted["pos"], a1 + 2 * n + 1);
|
||||
let st = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().clone();
|
||||
let copy = st.tables.keys().copied().find(|id| *id != t1).unwrap();
|
||||
assert_eq!((st.table_anchor(t1), st.table_anchor(copy)), (Some(a1), Some(a1 + n + 1)));
|
||||
reopens_with(&s, sid, &[t1, copy]);
|
||||
}
|
||||
|
||||
fn table_for(s: &Session, sid: u64, tid: u64) -> &doc::Table {
|
||||
s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap()
|
||||
}
|
||||
|
||||
@@ -35,11 +35,13 @@ pub struct ViewInfo {
|
||||
pub zoom: f64,
|
||||
pub snap: SnapView,
|
||||
pub unit: Unit,
|
||||
/// The toolbox's Stroke box is in front (X): colour tools apply to strokes.
|
||||
pub stroke_in_front: bool,
|
||||
}
|
||||
|
||||
impl ViewInfo {
|
||||
pub const fn at_zoom(zoom: f64) -> Self {
|
||||
Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points }
|
||||
Self { zoom, snap: SnapView::FACTORY, unit: Unit::Points, stroke_in_front: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +61,10 @@ impl Session {
|
||||
return;
|
||||
}
|
||||
self.tool = designcraft_tools::create(id);
|
||||
// Any other tool unloads the place cursor.
|
||||
if id != "placeGun" {
|
||||
self.loaded = None;
|
||||
}
|
||||
// Leaving the Type tool keeps the text frame selected.
|
||||
if id != "type"
|
||||
&& let Some(st) = self.active_mut()
|
||||
@@ -93,6 +99,7 @@ impl Session {
|
||||
layer,
|
||||
snap: view.snap,
|
||||
unit: view.unit,
|
||||
stroke_in_front: view.stroke_in_front,
|
||||
};
|
||||
Some(f(self.tool.as_mut(), &cx))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//! `layout.pages.insert` clamps an out-of-range `after` to the end instead of overflowing.
|
||||
use designcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn insert_after_u64_max_appends_at_the_end() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages":2})).unwrap();
|
||||
let before: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect();
|
||||
let out = s.execute("layout.pages.insert", &json!({"after": u64::MAX, "count": 2})).unwrap();
|
||||
assert_eq!(out["total"], 4);
|
||||
let after: Vec<_> = s.doc().unwrap().doc.pages().map(|(_, _, p)| p.id).collect();
|
||||
assert_eq!(after.len(), 4);
|
||||
assert_eq!(after[..2], before[..], "the original pages must stay first; new pages go at the end");
|
||||
assert!(after[2..].iter().all(|id| !before.contains(id)));
|
||||
}
|
||||
+160
-53
@@ -425,8 +425,14 @@ fn class_selector(tag: &str, class: &str) -> Option<String> {
|
||||
Some(selector)
|
||||
}
|
||||
|
||||
/// One story as XHTML paragraphs.
|
||||
/// One story as XHTML paragraphs (anchored objects left out; see [`story_html_with`]).
|
||||
pub fn story_html(doc: &Document, sid: StoryId) -> String {
|
||||
story_html_with(doc, sid, &mut |_| String::new())
|
||||
}
|
||||
|
||||
/// One story as XHTML paragraphs, each anchored object written where it is anchored as the
|
||||
/// markup `object` returns for it.
|
||||
fn story_html_with(doc: &Document, sid: StoryId, object: &mut dyn FnMut(&designcraft_doc::AnchoredObject) -> String) -> String {
|
||||
let Some(st) = doc.story(sid) else { return String::new() };
|
||||
let (pc, cc) = (para_classes(&doc.styles), char_classes(&doc.styles));
|
||||
let mut out = String::new();
|
||||
@@ -451,18 +457,32 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
|
||||
continue;
|
||||
}
|
||||
let raw = &st.text[a..b];
|
||||
let t = if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) {
|
||||
let sub: String = raw
|
||||
.chars()
|
||||
.map(|c| match designcraft_doc::vars::var_index(c) {
|
||||
Some(i) => doc.variable_value(i, None).unwrap_or_default(),
|
||||
None => c.to_string(),
|
||||
})
|
||||
.collect();
|
||||
story_markup(&sub)
|
||||
} else {
|
||||
story_markup(raw)
|
||||
// Text with variables substituted; each anchored object's markup in place of its mark.
|
||||
let text = |seg: &str| {
|
||||
if seg.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) {
|
||||
let sub: String = seg
|
||||
.chars()
|
||||
.map(|c| match designcraft_doc::vars::var_index(c) {
|
||||
Some(i) => doc.variable_value(i, None).unwrap_or_default(),
|
||||
None => c.to_string(),
|
||||
})
|
||||
.collect();
|
||||
story_markup(&sub)
|
||||
} else {
|
||||
story_markup(seg)
|
||||
}
|
||||
};
|
||||
let mut mark = st.text.get(..a).map_or(0, |t| t.matches(designcraft_doc::OBJECT_MARK).count());
|
||||
let mut t = String::new();
|
||||
for (i, seg) in raw.split(designcraft_doc::OBJECT_MARK).enumerate() {
|
||||
if i > 0 {
|
||||
if let Some(o) = st.objects.get(mark) {
|
||||
t.push_str(&object(o));
|
||||
}
|
||||
mark += 1;
|
||||
}
|
||||
t.push_str(&text(seg));
|
||||
}
|
||||
let (ctag, cls) = if f.style != story::NO_CHAR_STYLE {
|
||||
let (tag, class) = export_mapping(&doc.styles, &f.style, true, &cc);
|
||||
(tag, format!(" class=\"{}\"", xml_attr(&class)))
|
||||
@@ -563,6 +583,50 @@ fn ext(mime: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The markup for one graphic item: an `<img>`, or an HTML5 player for video and sound (with
|
||||
/// `true`), its file registered in `images` under the name `img_src` is given. `None` for an
|
||||
/// item that isn't a graphic or whose asset has no data.
|
||||
fn graphic_html<'a>(
|
||||
doc: &'a Document,
|
||||
it: &Item,
|
||||
images: &mut BTreeMap<String, (&'a str, &'a [u8])>,
|
||||
img_src: &dyn Fn(&str, &str, &[u8]) -> String,
|
||||
) -> Option<(String, bool)> {
|
||||
let Content::Graphic(g) = &it.content else { return None };
|
||||
let a = doc.assets.get(&g.asset)?;
|
||||
if a.data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mime = safe_mime(&a.mime);
|
||||
// Video and sound: an HTML5 player (with the poster, when there is one).
|
||||
if let Some(kind) = designcraft_doc::media_kind(mime) {
|
||||
let name = format!("media/{}.{}", g.asset.0, ext(mime));
|
||||
images.insert(name.clone(), (mime, a.data.as_slice()));
|
||||
let m = it.media.clone().unwrap_or_default();
|
||||
let mut attrs = String::new();
|
||||
for (on, attr) in [(m.controls, " controls=\"controls\""), (m.play_on_page_load, " autoplay=\"autoplay\""), (m.looping, " loop=\"loop\"")] {
|
||||
if on {
|
||||
attrs.push_str(attr);
|
||||
}
|
||||
}
|
||||
if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) {
|
||||
let poster_mime = safe_mime(&pa.mime);
|
||||
let pn = format!("images/{}.{}", pa.id.0, ext(poster_mime));
|
||||
images.insert(pn.clone(), (poster_mime, pa.data.as_slice()));
|
||||
if kind == "video" {
|
||||
attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data))));
|
||||
}
|
||||
}
|
||||
let markup = format!("<{kind} src=\"{}\"{attrs}>{}</{kind}>", xml_attr(&img_src(&name, mime, &a.data)), xml_text(&a.name));
|
||||
return Some((markup, true));
|
||||
}
|
||||
let name = format!("images/{}.{}", g.asset.0, ext(mime));
|
||||
images.insert(name.clone(), (mime, a.data.as_slice()));
|
||||
// Alt text from Object Export Options, else the file name.
|
||||
let alt = if it.alt_text.is_empty() { a.name.as_str() } else { it.alt_text.as_str() };
|
||||
Some((format!("<img src=\"{}\" alt=\"{}\"/>", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(alt)), false))
|
||||
}
|
||||
|
||||
/// The content in reading order as HTML sections and figures; `img_src` names each image
|
||||
/// (a package path, or a data URI for single-file HTML). Returns (body, images by name, table of
|
||||
/// contents).
|
||||
@@ -587,50 +651,38 @@ fn body_html<'a>(
|
||||
toc.push((anchor.clone(), first));
|
||||
}
|
||||
}
|
||||
let _ = write!(body, "<section id=\"{anchor}\">\n{}</section>\n", story_html(doc, *sid));
|
||||
// Anchored objects go where they are anchored: their graphics in the text, an
|
||||
// above-line object as a block of its own.
|
||||
let mut object = |o: &designcraft_doc::AnchoredObject| {
|
||||
let mut out = String::new();
|
||||
o.item.walk(&mut |it| {
|
||||
if let Some((markup, _)) = graphic_html(doc, it, &mut images, img_src) {
|
||||
out.push_str(&markup);
|
||||
}
|
||||
});
|
||||
match &o.position {
|
||||
designcraft_doc::AnchorPosition::AboveLine { align, .. } if !out.is_empty() => {
|
||||
let align = match align {
|
||||
designcraft_doc::anchored::AnchorAlign::Left => "left",
|
||||
designcraft_doc::anchored::AnchorAlign::Center => "center",
|
||||
designcraft_doc::anchored::AnchorAlign::Right => "right",
|
||||
};
|
||||
format!("<span class=\"anchored\" style=\"display:block;text-align:{align}\">{out}</span>")
|
||||
}
|
||||
_ => out,
|
||||
}
|
||||
};
|
||||
let _ = write!(body, "<section id=\"{anchor}\">\n{}</section>\n", story_html_with(doc, *sid, &mut object));
|
||||
}
|
||||
Block::Image(iid) => {
|
||||
let Some(Content::Graphic(g)) = doc.item(*iid).map(|i| &i.content) else { continue };
|
||||
let Some(a) = doc.assets.get(&g.asset) else { continue };
|
||||
if a.data.is_empty() {
|
||||
let Some(it) = doc.item(*iid) else { continue };
|
||||
let Some((markup, media)) = graphic_html(doc, it, &mut images, img_src) else { continue };
|
||||
if media {
|
||||
let _ = writeln!(body, "<figure>{markup}</figure>");
|
||||
continue;
|
||||
}
|
||||
let mime = safe_mime(&a.mime);
|
||||
// Video and sound: an HTML5 player (with the poster, when there is one).
|
||||
if let Some(kind) = designcraft_doc::media_kind(mime) {
|
||||
let name = format!("media/{}.{}", g.asset.0, ext(mime));
|
||||
images.insert(name.clone(), (mime, a.data.as_slice()));
|
||||
let m = doc.item(*iid).and_then(|i| i.media.clone()).unwrap_or_default();
|
||||
let mut attrs = String::new();
|
||||
for (on, attr) in
|
||||
[(m.controls, " controls=\"controls\""), (m.play_on_page_load, " autoplay=\"autoplay\""), (m.looping, " loop=\"loop\"")]
|
||||
{
|
||||
if on {
|
||||
attrs.push_str(attr);
|
||||
}
|
||||
}
|
||||
if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) {
|
||||
let poster_mime = safe_mime(&pa.mime);
|
||||
let pn = format!("images/{}.{}", pa.id.0, ext(poster_mime));
|
||||
images.insert(pn.clone(), (poster_mime, pa.data.as_slice()));
|
||||
if kind == "video" {
|
||||
attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data))));
|
||||
}
|
||||
}
|
||||
let _ = writeln!(
|
||||
body,
|
||||
"<figure><{kind} src=\"{}\"{attrs}>{}</{kind}></figure>",
|
||||
xml_attr(&img_src(&name, mime, &a.data)),
|
||||
xml_text(&a.name)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let name = format!("images/{}.{}", g.asset.0, ext(mime));
|
||||
images.insert(name.clone(), (mime, a.data.as_slice()));
|
||||
// Alt text from Object Export Options, else the file name.
|
||||
let alt = doc.item(*iid).map(|i| i.alt_text.as_str()).filter(|t| !t.is_empty()).unwrap_or(&a.name);
|
||||
// Object Export Options: alignment and a page break before.
|
||||
let eo = doc.item(*iid).map(|i| i.export_options.clone()).unwrap_or_default();
|
||||
let eo = &it.export_options;
|
||||
let mut css = String::new();
|
||||
if matches!(eo.align.as_str(), "left" | "center" | "right") {
|
||||
css.push_str(&format!("text-align:{};", eo.align));
|
||||
@@ -639,8 +691,7 @@ fn body_html<'a>(
|
||||
css.push_str("page-break-before:always;break-before:page;");
|
||||
}
|
||||
let style = if css.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&css)) };
|
||||
let _ =
|
||||
writeln!(body, "<figure{style}><img src=\"{}\" alt=\"{}\"/></figure>", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(alt));
|
||||
let _ = writeln!(body, "<figure{style}>{markup}</figure>");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -788,6 +839,62 @@ mod tests {
|
||||
assert!(z.by_name("OEBPS/nav.xhtml").is_ok());
|
||||
}
|
||||
|
||||
/// #766: anchored graphics are exported where they are anchored (inline in the text, an
|
||||
/// above-line object as a block of its own), and their images go into the package.
|
||||
#[test]
|
||||
fn anchored_objects_are_exported_in_place() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 200.0), lid, "AB\nCD", ParaFormat::default()).unwrap();
|
||||
let aid = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset {
|
||||
page: 0,
|
||||
id: aid,
|
||||
name: "dot.png".into(),
|
||||
mime: "image/png".into(),
|
||||
link: None,
|
||||
data: Arc::new(vec![7]),
|
||||
pixels: Some((1, 1)),
|
||||
}),
|
||||
);
|
||||
let graphic = |d: &mut Document, alt: &str| {
|
||||
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
item.alt_text = alt.into();
|
||||
item.content = Content::Graphic(Graphic {
|
||||
asset: aid,
|
||||
size: (1.0, 1.0),
|
||||
xf: Affine::IDENTITY,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
});
|
||||
item
|
||||
};
|
||||
let inline = designcraft_doc::AnchoredObject::new(graphic(&mut d, "inline"), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 });
|
||||
let above = designcraft_doc::AnchoredObject::new(
|
||||
graphic(&mut d, "above"),
|
||||
designcraft_doc::AnchorPosition::AboveLine { align: designcraft_doc::anchored::AnchorAlign::Center, space_before: 0.0, space_after: 0.0 },
|
||||
);
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.insert_object(1, inline);
|
||||
// Byte offsets: "A" (1), the mark (3), "B\nC" (3): the second object goes between C and D.
|
||||
st.insert_object(7, above);
|
||||
let bytes = export_epub(&d, &EpubOptions::default()).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut html = String::new();
|
||||
z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap();
|
||||
let img = |alt: &str| format!("<img src=\"images/{}.png\" alt=\"{alt}\"/>", aid.0);
|
||||
assert!(html.contains(&format!("A{}B</p>", img("inline"))), "{html}");
|
||||
assert!(html.contains(&format!("C<span class=\"anchored\" style=\"display:block;text-align:center\">{}</span>D", img("above"))), "{html}");
|
||||
assert!(z.by_name(&format!("OEBPS/images/{}.png", aid.0)).is_ok(), "the image is in the package");
|
||||
let page = export_html(&d, &HtmlOptions::default());
|
||||
assert!(page.contains("alt=\"inline\"") && page.contains("alt=\"above\""), "{page}");
|
||||
// story_html on its own still leaves objects out.
|
||||
assert!(!story_html(&d, sid).contains("<img"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_whose_names_slug_alike_keep_their_own_classes() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The OpenType features a font offers (its GSUB and GPOS feature lists) and the names it gives
|
||||
//! its stylistic sets, for the Typography panel to show only what the selected fonts can do.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::raw::TableProvider;
|
||||
use skrifa::raw::tables::layout::FeatureParams;
|
||||
|
||||
use crate::FontFace;
|
||||
|
||||
/// What one font's layout tables offer.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FontFeatures {
|
||||
/// The feature tags its GSUB and GPOS tables define, such as `liga` or `ss01`.
|
||||
pub tags: BTreeSet<String>,
|
||||
/// The name each stylistic set (1 to 20) gives itself, from its feature parameters.
|
||||
pub stylistic_sets: BTreeMap<u32, String>,
|
||||
}
|
||||
|
||||
/// The features `face` offers. A face without layout tables offers none.
|
||||
pub fn font_features(face: &FontFace) -> FontFeatures {
|
||||
let mut out = FontFeatures::default();
|
||||
let Some(font) = face.skrifa() else { return out };
|
||||
let mut lists = Vec::new();
|
||||
if let Ok(gsub) = font.gsub()
|
||||
&& let Ok(list) = gsub.feature_list()
|
||||
{
|
||||
lists.push(list);
|
||||
}
|
||||
if let Ok(gpos) = font.gpos()
|
||||
&& let Ok(list) = gpos.feature_list()
|
||||
{
|
||||
lists.push(list);
|
||||
}
|
||||
for list in &lists {
|
||||
for record in list.feature_records() {
|
||||
let tag = record.feature_tag().to_string();
|
||||
let set = tag.strip_prefix("ss").and_then(|n| n.parse::<u32>().ok()).filter(|n| (1..=20).contains(n));
|
||||
if let Some(set) = set
|
||||
&& !out.stylistic_sets.contains_key(&set)
|
||||
&& let Ok(feature) = record.feature(list.offset_data())
|
||||
&& let Some(Ok(FeatureParams::StylisticSet(params))) = feature.feature_params()
|
||||
&& let Some(name) = font.localized_strings(params.ui_name_id()).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())
|
||||
{
|
||||
out.stylistic_sets.insert(set, name);
|
||||
}
|
||||
out.tags.insert(tag);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
+81
-11
@@ -125,6 +125,8 @@ pub struct FontFace {
|
||||
em: std::sync::OnceLock<((f64, f64), bool)>,
|
||||
/// The font menu group and native family name, read on first use.
|
||||
group: std::sync::OnceLock<(FontGroup, Option<String>)>,
|
||||
/// The PostScript name, read on first use.
|
||||
postscript: std::sync::OnceLock<Option<String>>,
|
||||
/// Format, class and PANOSE numbers (the font menus), read on first use.
|
||||
kind: std::sync::OnceLock<crate::kind::FaceKind>,
|
||||
}
|
||||
@@ -309,6 +311,13 @@ impl FontFace {
|
||||
let (g, n) = self.group.get_or_init(|| self.skrifa().map(|f| crate::group::classify(&f, &self.family)).unwrap_or_default());
|
||||
(*g, n.as_deref())
|
||||
}
|
||||
/// The PostScript name (`name` ID 6), read from the font once. None for a variable font's
|
||||
/// named instance: the file's name belongs to its default instance.
|
||||
pub fn postscript_name(&self) -> Option<&str> {
|
||||
self.postscript
|
||||
.get_or_init(|| if self.is_variable() { None } else { self.skrifa().and_then(|f| name(&f, &[StringId::POSTSCRIPT_NAME])) })
|
||||
.as_deref()
|
||||
}
|
||||
/// Glyph id for `c` (0 = .notdef).
|
||||
pub fn glyph_for(&self, c: char) -> u32 {
|
||||
self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0)
|
||||
@@ -342,6 +351,7 @@ struct CatalogEntry {
|
||||
/// The font menu group and native family name, read by the scan.
|
||||
group: FontGroup,
|
||||
native: Option<String>,
|
||||
postscript: Option<String>,
|
||||
}
|
||||
|
||||
/// What the scan reads of one face in a font file: its names and font menu group.
|
||||
@@ -352,6 +362,7 @@ struct ScannedFace {
|
||||
style: String,
|
||||
group: FontGroup,
|
||||
native: Option<String>,
|
||||
postscript: Option<String>,
|
||||
}
|
||||
|
||||
/// Process-wide font database: the fonts every document shares (bundled, installed, added), and
|
||||
@@ -628,7 +639,8 @@ fn file_face_names(path: &std::path::Path) -> Vec<ScannedFace> {
|
||||
crate::group::classify(&skrifa::FontRef::new(&font).ok()?, &family)
|
||||
}
|
||||
};
|
||||
Some(ScannedFace { family, style, group, native })
|
||||
let postscript = name(&f, &[StringId::POSTSCRIPT_NAME]);
|
||||
Some(ScannedFace { family, style, group, native, postscript })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -779,6 +791,7 @@ fn make_face(bytes: FontBytes, source: FontSource, index: u32, family: String, s
|
||||
bmp: std::sync::OnceLock::new(),
|
||||
em: std::sync::OnceLock::new(),
|
||||
group: std::sync::OnceLock::new(),
|
||||
postscript: std::sync::OnceLock::new(),
|
||||
kind: std::sync::OnceLock::new(),
|
||||
})
|
||||
}
|
||||
@@ -845,6 +858,12 @@ pub fn base_style(style: &str) -> &str {
|
||||
style.split('{').next().unwrap_or(style).trim()
|
||||
}
|
||||
|
||||
/// Do two style names name the same face? Spacing, punctuation and case don't count, as when a face
|
||||
/// is looked up (InDesign documents can carry `Regular ` with a trailing space).
|
||||
pub fn same_style(a: &str, b: &str) -> bool {
|
||||
norm(a) == norm(b)
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
|
||||
}
|
||||
@@ -1010,6 +1029,15 @@ impl FontDb {
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the installed fonts are cataloged: a lookup by family name won't wait for the
|
||||
/// scan (always true on wasm, which has none). See [`FontDb::scan_in_background`].
|
||||
pub fn system_fonts_cataloged(&self) -> bool {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return true;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.cataloged.get().is_some()
|
||||
}
|
||||
|
||||
/// Catalog the installed fonts on a background thread, so the first lookup by family name
|
||||
/// (opening a file, the font menus) doesn't wait for the scan. A no-op once they are
|
||||
/// cataloged, and on wasm.
|
||||
@@ -1054,7 +1082,14 @@ impl FontDb {
|
||||
continue;
|
||||
}
|
||||
for f in file_face_names(&p) {
|
||||
found.push(CatalogEntry { family: f.family, style: f.style, path: p.clone(), group: f.group, native: f.native });
|
||||
found.push(CatalogEntry {
|
||||
family: f.family,
|
||||
style: f.style,
|
||||
path: p.clone(),
|
||||
group: f.group,
|
||||
native: f.native,
|
||||
postscript: f.postscript,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1092,18 +1127,33 @@ impl FontDb {
|
||||
/// [`MAX_DOCUMENT_FONT_FILES`] (in name order), files over [`MAX_DOCUMENT_FONT_BYTES`] or past
|
||||
/// [`MAX_DOCUMENT_FONTS_TOTAL`] in all, and files that aren't fonts are skipped with a note. A
|
||||
/// missing folder loads nothing (scope 0).
|
||||
///
|
||||
/// The folder is named by a document, so this crate does not trust it: `allow` (`Ok`, or why a
|
||||
/// path may not be read) is asked about
|
||||
/// the folder and about every font file in it before either is opened, and a path it refuses
|
||||
/// is skipped with its message (the engine passes its automation-root check; a host with no
|
||||
/// roots passes `&|_| Ok(())`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_document_fonts(&self, folder: &std::path::Path) -> DocumentFonts {
|
||||
pub fn load_document_fonts(&self, folder: &std::path::Path, allow: &dyn Fn(&std::path::Path) -> Result<(), String>) -> DocumentFonts {
|
||||
let mut out = DocumentFonts::default();
|
||||
if let Err(why) = allow(folder) {
|
||||
out.skipped.push(why);
|
||||
return out;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(folder) else { return out };
|
||||
let mut files: Vec<std::path::PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) && p.is_file()
|
||||
})
|
||||
.collect();
|
||||
let mut files: Vec<std::path::PathBuf> = Vec::new();
|
||||
for path in entries.flatten().map(|e| e.path()) {
|
||||
let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
// Each file is vetted before it is touched at all, a link out of the folder included.
|
||||
match allow(&path) {
|
||||
Ok(()) if path.is_file() => files.push(path),
|
||||
Ok(()) => {}
|
||||
Err(why) => out.skipped.push(why),
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut total = 0u64;
|
||||
let mut own: Vec<Arc<FontFace>> = Vec::new();
|
||||
@@ -1203,6 +1253,11 @@ impl FontDb {
|
||||
self.scoped(0).face(family, style)
|
||||
}
|
||||
|
||||
/// The shared face with PostScript name `name` (see [`ScopedFonts::postscript_face`]).
|
||||
pub fn postscript_face(&self, name: &str) -> Option<(String, String)> {
|
||||
self.scoped(0).postscript_face(name)
|
||||
}
|
||||
|
||||
/// Is `family` among the shared fonts (loaded, or installed on the system)?
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
self.scoped(0).has_family(family)
|
||||
@@ -1494,6 +1549,21 @@ impl ScopedFonts<'_> {
|
||||
self.has_exact_family(family) || without_format_suffix(family).is_some_and(|base| self.has_exact_family(base))
|
||||
}
|
||||
|
||||
/// The family and style of the face whose PostScript name is `name`, ignoring case (the
|
||||
/// document's, loaded, or installed on the system, not loading it). SVGs exported from
|
||||
/// Illustrator name fonts this way (`font-family="'MyriadPro-Regular'"`).
|
||||
pub fn postscript_face(&self, name: &str) -> Option<(String, String)> {
|
||||
let named = |ps: Option<&str>| ps.is_some_and(|ps| ps.eq_ignore_ascii_case(name));
|
||||
if let Some(f) = self.own().iter().chain(self.db.read_faces().iter()).find(|f| named(f.postscript_name())) {
|
||||
return Some((f.family.clone(), f.style.clone()));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(c) = self.db.read_catalog().iter().find(|c| named(c.postscript.as_deref())) {
|
||||
return Some((c.family.clone(), c.style.clone()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn has_exact_family(&self, family: &str) -> bool {
|
||||
if self.own().iter().any(|f| f.family.eq_ignore_ascii_case(family)) || self.db.is_loaded(family) {
|
||||
return true;
|
||||
|
||||
+13
-1
@@ -7,6 +7,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod features;
|
||||
mod fontdb;
|
||||
mod group;
|
||||
mod kind;
|
||||
@@ -14,9 +15,10 @@ mod kind;
|
||||
pub mod testing;
|
||||
mod vertical;
|
||||
|
||||
pub use features::{FontFeatures, font_features};
|
||||
pub use fontdb::{
|
||||
DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES,
|
||||
MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, system_font_dirs,
|
||||
MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, same_style, system_font_dirs,
|
||||
};
|
||||
pub use group::{FamilyInfo, FontGroup, sort_for_menu};
|
||||
pub use harfrust::Feature;
|
||||
@@ -82,6 +84,16 @@ pub struct ShapedGlyph {
|
||||
pub rtl: bool,
|
||||
}
|
||||
|
||||
/// Line metrics of the font at `index` of `data`, in font units: (units per em, ascent, descent
|
||||
/// (negative below the baseline), line gap) — what text layouts such as egui's build lines from.
|
||||
/// `None` if the font can't be read.
|
||||
pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> {
|
||||
use skrifa::MetadataProvider;
|
||||
let f = skrifa::FontRef::from_index(data, index).ok()?;
|
||||
let m = f.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
|
||||
Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading))
|
||||
}
|
||||
|
||||
/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
|
||||
pub fn feature(tag: &str) -> Option<Feature> {
|
||||
let (on, t) = match tag.strip_prefix('-') {
|
||||
|
||||
@@ -36,7 +36,7 @@ fn document_fonts_load_ahead_of_installed_ones() {
|
||||
let installed = db.face("DocFont Precedence", "Regular");
|
||||
assert!(installed.covers('a') && matches!(installed.source, FontSource::Installed(_)), "{:?}", installed.source);
|
||||
|
||||
let r = db.load_document_fonts(&folder);
|
||||
let r = db.load_document_fonts(&folder, &|_| Ok(()));
|
||||
assert_eq!((r.faces, r.skipped.len()), (1, 0), "{:?}", r.skipped);
|
||||
let face = db.scoped(r.scope).face("DocFont Precedence", "Regular");
|
||||
assert!(face.covers('b'), "the document's face wins");
|
||||
@@ -45,7 +45,7 @@ fn document_fonts_load_ahead_of_installed_ones() {
|
||||
assert!(db.face("DocFont Precedence", "Regular").covers('a'), "outside the document the installed face stays");
|
||||
|
||||
// Loading the folder again (another document beside it) shares the face, in a scope of its own.
|
||||
let again = db.load_document_fonts(&folder);
|
||||
let again = db.load_document_fonts(&folder, &|_| Ok(()));
|
||||
assert_eq!((again.faces, again.skipped.len()), (1, 0));
|
||||
assert_ne!(again.scope, r.scope);
|
||||
assert!(Arc::ptr_eq(&db.scoped(again.scope).face("DocFont Precedence", "Regular"), &face));
|
||||
@@ -64,7 +64,7 @@ fn damaged_oversized_and_other_files_are_skipped() {
|
||||
write_font(&folder.join("nested.ttf"), "inner.ttf", "DocFont Nested", &['n']);
|
||||
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let r = db.load_document_fonts(&folder);
|
||||
let r = db.load_document_fonts(&folder, &|_| Ok(()));
|
||||
assert_eq!(r.faces, 1);
|
||||
assert!(db.scoped(r.scope).has_family("DocFont Good"));
|
||||
assert!(!db.has_family("DocFont Good"), "the document's alone");
|
||||
@@ -85,7 +85,7 @@ fn the_number_of_files_read_is_capped() {
|
||||
write_font(&folder, "zz-real.ttf", "DocFont Past Cap", &['z']);
|
||||
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let r = db.load_document_fonts(&folder);
|
||||
let r = db.load_document_fonts(&folder, &|_| Ok(()));
|
||||
assert_eq!((r.faces, r.scope), (0, 0));
|
||||
assert!(!db.scoped(r.scope).has_family("DocFont Past Cap"));
|
||||
assert_eq!(r.skipped.len(), MAX_DOCUMENT_FONT_FILES + 1, "one note for each file read, one for the rest");
|
||||
@@ -98,7 +98,7 @@ fn large_cjk_collections_are_within_the_limit() {
|
||||
// macOS's Songti collection is 67 MB; full Noto/Source Han CJK collections are larger.
|
||||
let folder = temp_dir("cjk-size");
|
||||
std::fs::File::create(folder.join("cjk.ttc")).unwrap().set_len(200 << 20).unwrap();
|
||||
let r = FontDb::with_font_dirs(vec![]).load_document_fonts(&folder);
|
||||
let r = FontDb::with_font_dirs(vec![]).load_document_fonts(&folder, &|_| Ok(()));
|
||||
assert_eq!(r.skipped.len(), 1, "{:?}", r.skipped);
|
||||
assert!(!r.skipped[0].contains("larger than"), "skipped for not being a font, not for its size: {:?}", r.skipped);
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
@@ -107,7 +107,7 @@ fn large_cjk_collections_are_within_the_limit() {
|
||||
#[test]
|
||||
fn a_missing_folder_loads_nothing() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER));
|
||||
let r = db.load_document_fonts(&std::env::temp_dir().join("dc-docfonts-no-such-folder").join(DOCUMENT_FONTS_FOLDER), &|_| Ok(()));
|
||||
assert_eq!((r.faces, r.scope, r.skipped.len()), (0, 0, 0));
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ fn each_scope_sees_its_own_fonts_only() {
|
||||
write_font(&a, "a.ttf", FAMILY, &['a']);
|
||||
write_font(&b, "b.ttf", FAMILY, &['b', '\u{E123}']);
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let (ra, rb) = (db.load_document_fonts(&a), db.load_document_fonts(&b));
|
||||
let (ra, rb) = (db.load_document_fonts(&a, &|_| Ok(())), db.load_document_fonts(&b, &|_| Ok(())));
|
||||
let (in_a, in_b) = (db.scoped(ra.scope), db.scoped(rb.scope));
|
||||
|
||||
assert!(in_a.face(FAMILY, "Regular").covers('a') && in_b.face(FAMILY, "Regular").covers('b'));
|
||||
@@ -139,16 +139,16 @@ fn a_closed_scope_finds_nothing_and_reopening_reuses_the_faces() {
|
||||
let folder = temp_dir("reopen");
|
||||
let file = write_font(&folder, "r.ttf", FAMILY, &['r']);
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let r = db.load_document_fonts(&folder);
|
||||
let r = db.load_document_fonts(&folder, &|_| Ok(()));
|
||||
let face = db.scoped(r.scope).face(FAMILY, "Regular");
|
||||
db.close_scope(r.scope);
|
||||
assert!(!db.scoped(r.scope).has_family(FAMILY), "closed");
|
||||
|
||||
let again = db.load_document_fonts(&folder);
|
||||
let again = db.load_document_fonts(&folder, &|_| Ok(()));
|
||||
assert!(Arc::ptr_eq(&db.scoped(again.scope).face(FAMILY, "Regular"), &face), "an unchanged file isn't read again");
|
||||
// A file changed since is read again.
|
||||
std::fs::write(&file, font_with(FAMILY, &['r', 's']).unwrap()).unwrap();
|
||||
let changed = db.load_document_fonts(&folder);
|
||||
let changed = db.load_document_fonts(&folder, &|_| Ok(()));
|
||||
let new_face = db.scoped(changed.scope).face(FAMILY, "Regular");
|
||||
assert!(new_face.covers('s') && new_face.id() != face.id());
|
||||
assert!(!db.scoped(again.scope).face(FAMILY, "Regular").covers('s'), "the document open before keeps what it opened with");
|
||||
@@ -166,3 +166,27 @@ fn restricted_licence_reads_the_embedding_bits() {
|
||||
assert!(!db.face(FALLBACK_FAMILY, "Regular").restricted_licence(), "the bundled fonts are OFL");
|
||||
assert_eq!(db.face(FALLBACK_FAMILY, "Regular").source, FontSource::Bundled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_access_check_vets_the_folder_and_every_file_before_it_is_opened() {
|
||||
let folder = temp_dir("access");
|
||||
write_font(&folder, "kept.ttf", "DocFont Kept", &['k']);
|
||||
write_font(&folder, "refused.ttf", "DocFont Refused", &['r']);
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let asked = std::sync::Mutex::new(Vec::new());
|
||||
let allow = |p: &Path| {
|
||||
asked.lock().unwrap().push(p.to_path_buf());
|
||||
if p.ends_with("refused.ttf") { Err("refused.ttf: outside the read root".to_string()) } else { Ok(()) }
|
||||
};
|
||||
let r = db.load_document_fonts(&folder, &allow);
|
||||
assert_eq!(r.faces, 1, "{:?}", r.skipped);
|
||||
assert_eq!(r.skipped, vec!["refused.ttf: outside the read root".to_string()]);
|
||||
let asked = asked.into_inner().unwrap();
|
||||
assert_eq!(asked.first().map(PathBuf::as_path), Some(folder.as_path()), "the folder is asked about first");
|
||||
assert!(asked.iter().any(|p| p.ends_with("kept.ttf")) && asked.iter().any(|p| p.ends_with("refused.ttf")));
|
||||
|
||||
// A refused folder is not even listed.
|
||||
let r = db.load_document_fonts(&folder, &|_| Err("no".to_string()));
|
||||
assert_eq!((r.faces, r.scope, r.skipped), (0, 0, vec!["no".to_string()]));
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
@@ -181,3 +181,23 @@ fn font_menus_group_installed_and_loaded_fonts_without_loading_them() {
|
||||
assert_eq!(infos.iter().filter(|i| i.family == "Groups Mincho").count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_and_loaded_fonts_are_found_by_postscript_name() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-sysfonts-postscript-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("ps.ttf"), crate::testing::font_with("DC Test PS Installed", &['a']).unwrap()).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
let found = Some(("DC Test PS Installed".to_string(), "Regular".to_string()));
|
||||
// From the catalog, without loading the font; case doesn't matter.
|
||||
assert_eq!(db.postscript_face("dctestpsinstalled-regular"), found);
|
||||
assert!(!db.is_loaded("DC Test PS Installed"));
|
||||
// Bundled and added (loaded) faces.
|
||||
assert_eq!(db.postscript_face("SourceSerif4-Semibold"), Some(("Source Serif 4".to_string(), "Semibold".to_string())));
|
||||
db.add_font(crate::testing::font_with("DC Test PS Added", &['a']).unwrap());
|
||||
assert_eq!(db.postscript_face("DCTestPSAdded-Regular"), Some(("DC Test PS Added".to_string(), "Regular".to_string())));
|
||||
// A family name isn't a PostScript name.
|
||||
assert_eq!(db.postscript_face("Source Serif 4"), None);
|
||||
assert_eq!(db.postscript_face("NoSuchFont-Regular"), None);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,13 @@ pub fn decompose(m: Affine) -> Decomposed {
|
||||
let det = a * d - b * c;
|
||||
let rotation = (-b).atan2(a).to_degrees();
|
||||
let rotation = if rotation <= -180.0 + 1e-9 { 180.0 } else { rotation };
|
||||
Decomposed { scale_x: sx, scale_y: det / sx, rotation, shear: ((a * c + b * d) / det).atan().to_degrees() }
|
||||
// A flattened transform (e.g. scale_y = 0, so `det` and the shear numerator are both 0) has
|
||||
// no recoverable shear: 0 / 0 would be NaN, and a NaN here poisons the object when the
|
||||
// caller feeds the decomposed values back into `compose` (transform.set does exactly that).
|
||||
// Report 0 instead; a non-zero numerator over a zero determinant still limits to ±90°.
|
||||
let shear = ((a * c + b * d) / det).atan().to_degrees();
|
||||
let shear = if shear.is_nan() { 0.0 } else { shear };
|
||||
Decomposed { scale_x: sx, scale_y: det / sx, rotation, shear }
|
||||
}
|
||||
|
||||
/// The linear transform with these values (no translation); `decompose` inverts it.
|
||||
@@ -50,4 +56,29 @@ mod tests {
|
||||
// transform.rotate's convention: rotate(-θ) is θ counter-clockwise.
|
||||
assert!((decompose(Affine::rotate(-0.5)).rotation - 0.5f64.to_degrees()).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattened_transform_has_no_nan_shear() {
|
||||
// Regression: an object scaled to zero height (scale_y = 0) is singular — its
|
||||
// determinant and shear numerator are both 0, and decompose used to report NaN shear,
|
||||
// which compose then turned into a NaN transform. Shear is unrecoverable there, so
|
||||
// it reports 0 and every other field stays finite and exact.
|
||||
for v in [
|
||||
Decomposed { scale_x: 1.0, scale_y: 0.0, rotation: 0.0, shear: 20.0 },
|
||||
Decomposed { scale_x: 2.0, scale_y: 0.0, rotation: 30.0, shear: -45.0 },
|
||||
] {
|
||||
let back = decompose(compose(&v));
|
||||
assert!(back.scale_x.is_finite() && back.scale_y.is_finite() && back.rotation.is_finite() && back.shear.is_finite(), "{v:?} → {back:?}");
|
||||
assert_eq!(back.shear, 0.0, "{v:?} → {back:?}");
|
||||
assert!((back.scale_x - v.scale_x).abs() < 1e-9, "{v:?} → {back:?}");
|
||||
assert_eq!(back.scale_y, 0.0, "{v:?} → {back:?}");
|
||||
assert!((back.rotation - v.rotation).abs() < 1e-9, "{v:?} → {back:?}");
|
||||
// Feeding the decomposed values back into compose stays finite too.
|
||||
assert!(compose(&back).as_coeffs().iter().all(|c| c.is_finite()), "{v:?} → {back:?}");
|
||||
}
|
||||
// Directly singular matrices, including one whose shear numerator is non-zero
|
||||
// (that case limits to ±90°, not NaN).
|
||||
assert_eq!(decompose(Affine::new([1.0, 0.0, 0.0, 0.0, 0.0, 0.0])).shear, 0.0);
|
||||
assert!((decompose(Affine::new([1.0, 0.0, 1.0, 0.0, 0.0, 0.0])).shear.abs() - 90.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1164,6 +1164,9 @@ impl<'a> Ex<'a> {
|
||||
if let Some(v) = a.stroke_weight {
|
||||
el.set("StrokeWeight", num(v));
|
||||
}
|
||||
if let Some(v) = a.stroke_behind_fill {
|
||||
el.set("StrokeAlignment", if v { "OutsideAlignment" } else { "CenterAlignment" });
|
||||
}
|
||||
if let Some(v) = a.capitalization {
|
||||
el.set("Capitalization", names::caps_out(v));
|
||||
}
|
||||
@@ -1659,14 +1662,35 @@ impl<'a> Ex<'a> {
|
||||
for (i, pg) in sp.pages.iter().enumerate() {
|
||||
let name = if master { sp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default() } else { d.page_name(first_page + i) };
|
||||
let applied = if master { based_on } else { pg.parent };
|
||||
let overrides: Vec<String> = pg.overridden.iter().map(|i| uid(i.0)).collect();
|
||||
let pe = El::new("Page")
|
||||
.attr("Self", uid(pg.id.0))
|
||||
.attr("Name", name)
|
||||
.attr("AppliedMaster", applied.map(|s| uid(s.0)).unwrap_or_else(|| "n".into()))
|
||||
.attr("OverrideList", "")
|
||||
.attr("OverrideList", overrides.join(" "))
|
||||
.attr("GeometricBounds", format!("0 0 {} {}", num(pg.height), num(pg.width)))
|
||||
.attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy)))
|
||||
.child(margin_el(pg));
|
||||
let mut pe = pe;
|
||||
// Ruler guides, measured from the spread's top left (RulerOrigin is SpreadOrigin).
|
||||
let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
|
||||
let left = if left.is_finite() { left } else { 0.0 };
|
||||
for g in &pg.guides {
|
||||
let vertical = g.orientation == designcraft_doc::Orientation::Vertical;
|
||||
let mut ge = El::new("Guide")
|
||||
.attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count()))
|
||||
.attr("Orientation", if vertical { "Vertical" } else { "Horizontal" })
|
||||
.attr("Location", num(if vertical { g.position - left } else { g.position }))
|
||||
.attr("FitToPage", bool_s(!g.spread))
|
||||
.attr("ViewThreshold", "5")
|
||||
.attr("Locked", bool_s(g.locked))
|
||||
.attr("PageIndex", (first_page + i).to_string())
|
||||
.attr("GuideType", "Ruler");
|
||||
if let Some(l) = g.layer {
|
||||
ge.set("ItemLayer", uid(l.0));
|
||||
}
|
||||
pe.push(ge);
|
||||
}
|
||||
el.push(pe);
|
||||
}
|
||||
let origin = Affine::translate((-ox, -oy));
|
||||
@@ -1695,6 +1719,9 @@ impl<'a> Ex<'a> {
|
||||
_ => "Rectangle",
|
||||
};
|
||||
let mut el = El::new(tag).attr("Self", uid(it.id.0));
|
||||
if let Some(o) = it.overrides {
|
||||
el.set("OverriddenPageItem", uid(o.0));
|
||||
}
|
||||
if let Content::Text(t) = &it.content
|
||||
&& let Some(pt) = &t.options.path
|
||||
{
|
||||
|
||||
+104
-21
@@ -9,8 +9,8 @@ use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop,
|
||||
use designcraft_doc::{
|
||||
Asset, AssetId, BaselineGrid, CellRange, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document,
|
||||
DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle,
|
||||
Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, SpreadId,
|
||||
Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st,
|
||||
Orientation, Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread,
|
||||
SpreadId, Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st,
|
||||
};
|
||||
use designcraft_geom::corners::{Corner, CornerOptions};
|
||||
use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath};
|
||||
@@ -29,21 +29,11 @@ fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option<
|
||||
Some(designcraft_doc::CellStroke { weight: first.weight?, color: first.color.clone()?, tint: first.tint?, kind: first.kind.clone()? })
|
||||
}
|
||||
|
||||
/// Import an IDML package. Linked images are read from disk when available (not on wasm).
|
||||
/// Import an IDML package without reading its linked images from disk: this crate never touches
|
||||
/// the file system, so a caller that wants the links brings its own reader
|
||||
/// ([`import_idml_with`]). The engine passes one that checks the automation roots.
|
||||
pub fn import_idml(bytes: &[u8]) -> Result<Document> {
|
||||
import_idml_with(bytes, &default_reader)
|
||||
}
|
||||
|
||||
fn default_reader(path: &str) -> Option<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::fs::read(path).ok()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
None
|
||||
}
|
||||
import_idml_with(bytes, &|_| None)
|
||||
}
|
||||
|
||||
/// Import with a custom reader for linked files (`path` → bytes).
|
||||
@@ -68,7 +58,9 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u
|
||||
let mut lenient: HashMap<String, String> = HashMap::new();
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).map_err(|e| IdmlError::NotIdml(e.to_string()))?;
|
||||
if f.is_dir() {
|
||||
// A folder entry of the archive (the zip crate's own `is_dir`; spelled out so no file-system
|
||||
// probe appears in this crate).
|
||||
if f.name().ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
let name = f.name().to_string();
|
||||
@@ -116,6 +108,14 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option<Vec<u
|
||||
Ok(Imported { document, warnings })
|
||||
}
|
||||
|
||||
/// View Preferences › Ruler Origin.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RulerOrigin {
|
||||
Spread,
|
||||
Page,
|
||||
Spine,
|
||||
}
|
||||
|
||||
/// A package part name in the form the lookup compares: InDesign and other IDML writers differ
|
||||
/// in percent-encoding, case, path separators and a leading `./` or `/`.
|
||||
fn normalize_part_name(name: &str) -> String {
|
||||
@@ -159,6 +159,8 @@ struct ItemCtx {
|
||||
|
||||
struct Importer<'r> {
|
||||
read_link: &'r dyn Fn(&str) -> Option<Vec<u8>>,
|
||||
/// View Preferences › Ruler Origin: where a guide's Location is measured from.
|
||||
ruler_origin: RulerOrigin,
|
||||
next_id: u64,
|
||||
settings: DocSettings,
|
||||
default_margins: Option<(Margins, Columns)>,
|
||||
@@ -242,6 +244,40 @@ fn nums(s: &str) -> Vec<f64> {
|
||||
s.split_whitespace().filter_map(|v| v.parse().ok()).collect()
|
||||
}
|
||||
|
||||
/// The viewBox size of an SVG whose root has a viewBox but no width/height (percentages count as
|
||||
/// none): InDesign places such a file at one viewBox unit to the point.
|
||||
fn svg_viewbox_size(data: &[u8]) -> Option<(f64, f64)> {
|
||||
use quick_xml::events::Event;
|
||||
if data.iter().find(|b| !b.is_ascii_whitespace()) != Some(&b'<') {
|
||||
return None;
|
||||
}
|
||||
let mut r = quick_xml::Reader::from_reader(data);
|
||||
let mut buf = Vec::new();
|
||||
let root = loop {
|
||||
match r.read_event_into(&mut buf).ok()? {
|
||||
Event::Start(e) | Event::Empty(e) => break e.into_owned(),
|
||||
Event::Eof => return None,
|
||||
_ => buf.clear(),
|
||||
}
|
||||
};
|
||||
if root.local_name().as_ref() != b"svg" {
|
||||
return None;
|
||||
}
|
||||
let attr = |name: &[u8]| {
|
||||
root.attributes().with_checks(false).flatten().find(|a| a.key.as_ref() == name).and_then(|a| a.unescape_value().ok().map(|v| v.to_string()))
|
||||
};
|
||||
let absolute = |name: &[u8]| attr(name).is_some_and(|v| !v.trim().ends_with('%'));
|
||||
if absolute(b"width") || absolute(b"height") {
|
||||
return None;
|
||||
}
|
||||
let vb: Vec<f64> =
|
||||
attr(b"viewBox")?.split(|c: char| c.is_whitespace() || c == ',').filter(|v| !v.is_empty()).filter_map(|v| v.parse().ok()).collect();
|
||||
match vb[..] {
|
||||
[_, _, w, h] if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => Some((w, h)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_affine(s: Option<&str>) -> Affine {
|
||||
match s.map(nums) {
|
||||
Some(v) if v.len() == 6 => Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]),
|
||||
@@ -275,6 +311,7 @@ impl<'r> Importer<'r> {
|
||||
styles.paragraph.iter_mut().for_each(|s| s.based_on = None);
|
||||
Importer {
|
||||
read_link,
|
||||
ruler_origin: RulerOrigin::Spread,
|
||||
next_id: 0,
|
||||
settings: DocSettings::default(),
|
||||
default_margins: None,
|
||||
@@ -362,6 +399,11 @@ impl<'r> Importer<'r> {
|
||||
if let Some(v) = e.num("CursorKeyIncrement") {
|
||||
self.settings.keyboard_increment = v;
|
||||
}
|
||||
self.ruler_origin = match e.get("RulerOrigin") {
|
||||
Some("PageOrigin") => RulerOrigin::Page,
|
||||
Some("SpineOrigin") => RulerOrigin::Spine,
|
||||
_ => RulerOrigin::Spread,
|
||||
};
|
||||
}
|
||||
"GridPreference" => self.grid_prefs(e),
|
||||
"PasteboardPreference" => {
|
||||
@@ -1235,6 +1277,12 @@ impl<'r> Importer<'r> {
|
||||
}
|
||||
a.stroke_tint = tint(e.num("StrokeTint"));
|
||||
a.stroke_weight = e.num("StrokeWeight");
|
||||
// Text strokes are centred on the outline or lie outside it, behind the fill.
|
||||
a.stroke_behind_fill = e.prop("StrokeAlignment").and_then(|v| match v.trim() {
|
||||
"OutsideAlignment" => Some(true),
|
||||
"CenterAlignment" => Some(false),
|
||||
_ => None,
|
||||
});
|
||||
a.capitalization = e.prop("Capitalization").and_then(|v| names::caps_in(v.trim()));
|
||||
a.position = e.prop("Position").and_then(|v| names::position_in(v.trim()));
|
||||
a.underline = e.boolean("Underline");
|
||||
@@ -1964,6 +2012,33 @@ impl<'r> Importer<'r> {
|
||||
|
||||
// ---------- spreads and items ----------
|
||||
|
||||
/// Ruler guides of a `<Page>` (its `<Guide>` children), `page` its rectangle in our spread
|
||||
/// coordinates. A guide's Location is measured from the ruler origin: the spread's top left, the
|
||||
/// page's, or the spine. Fit to Page off: the guide crosses the spread.
|
||||
fn guides_of(&self, p: &El, page: Rect, spine: f64) -> Vec<Guide> {
|
||||
p.find_all("Guide")
|
||||
.filter_map(|g| {
|
||||
let at = g.num("Location").filter(|v| v.is_finite() && v.abs() < 1e7)?;
|
||||
let orientation = if g.get("Orientation") == Some("Horizontal") { Orientation::Horizontal } else { Orientation::Vertical };
|
||||
let position = match (orientation, self.ruler_origin) {
|
||||
(Orientation::Horizontal, RulerOrigin::Page) => page.y0 + at,
|
||||
(Orientation::Horizontal, _) => at,
|
||||
(Orientation::Vertical, RulerOrigin::Page) => page.x0 + at,
|
||||
(Orientation::Vertical, RulerOrigin::Spine) => spine + at,
|
||||
(Orientation::Vertical, RulerOrigin::Spread) => at,
|
||||
};
|
||||
Some(Guide {
|
||||
orientation,
|
||||
position,
|
||||
spread: g.get("FitToPage") == Some("false"),
|
||||
locked: g.get("Locked") == Some("true"),
|
||||
layer: g.get("ItemLayer").and_then(|l| self.layer_ids.get(l)).copied(),
|
||||
liquid: false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn spread(&mut self, e: &El, id: SpreadId, master: bool) -> Spread {
|
||||
let facing = self.settings.facing_pages;
|
||||
// Page rectangles in IDML spread coordinates.
|
||||
@@ -1981,6 +2056,9 @@ impl<'r> Importer<'r> {
|
||||
let min_y = pages.iter().map(|(r, _)| r.y0).fold(f64::INFINITY, f64::min);
|
||||
let (min_x, min_y) = if min_x.is_finite() { (min_x, min_y) } else { (0.0, 0.0) };
|
||||
let shift = Affine::translate((-min_x, -min_y));
|
||||
// The spine: between a left and a right page (else the spread's left edge).
|
||||
let spine = pages.iter().map(|(r, _)| r.x0 - min_x).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min);
|
||||
let spine = if spine.is_finite() && facing { spine } else { 0.0 };
|
||||
let show = e.get("ShowMasterItems") != Some("false");
|
||||
let first_abs: usize = self.spreads.iter().map(|s| s.pages.len()).sum();
|
||||
let mut out_pages = Vec::new();
|
||||
@@ -2013,7 +2091,7 @@ impl<'r> Importer<'r> {
|
||||
parent,
|
||||
side,
|
||||
overridden: vec![],
|
||||
guides: Vec::<Guide>::new(),
|
||||
guides: self.guides_of(p, Rect::new(r.x0 - min_x, r.y0 - min_y, r.x1 - min_x, r.y1 - min_y), spine),
|
||||
show_parent_items: show,
|
||||
liquid: Default::default(),
|
||||
transition: None,
|
||||
@@ -2157,6 +2235,7 @@ impl<'r> Importer<'r> {
|
||||
it.name = e.get("Name").filter(|n| *n != "$ID/").unwrap_or("").to_string();
|
||||
it.locked = e.get("Locked") == Some("true");
|
||||
it.hidden = e.get("Visible") == Some("false");
|
||||
it.overrides = e.get("OverriddenPageItem").and_then(|s| self.item_ids.get(s)).copied().filter(|i| self.is_parent_item(*i));
|
||||
it.nonprinting = e.get("Nonprinting") == Some("true");
|
||||
it.object_style = e
|
||||
.get("AppliedObjectStyle")
|
||||
@@ -2366,7 +2445,7 @@ impl<'r> Importer<'r> {
|
||||
let size = ((r - l).abs(), (b - t).abs());
|
||||
let link = g.find("Link");
|
||||
let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path);
|
||||
let graphic = |asset| {
|
||||
let graphic = |asset, size| {
|
||||
Content::Graphic(designcraft_doc::Graphic {
|
||||
asset,
|
||||
size,
|
||||
@@ -2383,13 +2462,17 @@ impl<'r> Importer<'r> {
|
||||
{
|
||||
// Every placement of a linked file shares one asset; read the file once.
|
||||
if let Some(id) = self.linked_assets.get(p) {
|
||||
return graphic(*id);
|
||||
let size = self.assets.get(id).and_then(|a| svg_viewbox_size(&a.data)).unwrap_or(size);
|
||||
return graphic(*id, size);
|
||||
}
|
||||
if let Some(d) = (self.read_link)(p) {
|
||||
data = d;
|
||||
read_from = Some(p.clone());
|
||||
}
|
||||
}
|
||||
// InDesign draws a viewBox-only SVG at one unit to the point: its GraphicBounds are the
|
||||
// art's bounds, which can overhang the viewBox, so they must not size the viewBox.
|
||||
let size = svg_viewbox_size(&data).unwrap_or(size);
|
||||
let (mime, px) = sniff_image(&data);
|
||||
let mime = mime.map(str::to_string).unwrap_or_else(|| match link.and_then(|k| k.get("LinkResourceFormat")).unwrap_or("") {
|
||||
f if f.contains("JPEG") => "image/jpeg".into(),
|
||||
@@ -2411,7 +2494,7 @@ impl<'r> Importer<'r> {
|
||||
if let Some(p) = read_from {
|
||||
self.linked_assets.insert(p, id);
|
||||
}
|
||||
graphic(id)
|
||||
graphic(id, size)
|
||||
}
|
||||
|
||||
fn link_threads(&mut self) {
|
||||
|
||||
@@ -578,6 +578,36 @@ fn round_trips_small_document() {
|
||||
assert!(back.swatch("Brand 30").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_overridden_parent_items() {
|
||||
let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() });
|
||||
let lid = d.default_layer();
|
||||
let (ppi, ppage) = d.parent_page_for(0).unwrap();
|
||||
let px = d.parents[ppi].pages[ppage].x;
|
||||
let pid = designcraft_doc::ItemId(d.alloc());
|
||||
let frame = designcraft_doc::Item::new(pid, lid, Shape::Rectangle, shapes::rectangle(Rect::new(px + 40.0, 40.0, px + 200.0, 120.0)));
|
||||
d.insert_item(SpreadRef::Parent(ppi), frame.clone(), None).unwrap();
|
||||
// What layout.overrideParentItems leaves behind: a linked local copy, and the parent item listed on the page.
|
||||
let mut copy = frame;
|
||||
copy.id = designcraft_doc::ItemId(d.alloc());
|
||||
copy.xf = designcraft_geom::Affine::translate((d.spreads[0].pages[0].x - px, 0.0)) * copy.xf;
|
||||
copy.overrides = Some(pid);
|
||||
d.insert_item(SpreadRef::Doc(0), copy, None).unwrap();
|
||||
std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].overridden.push(pid);
|
||||
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
let parent_ids: Vec<_> = back.parents[ppi].items.iter().map(|i| i.id).collect();
|
||||
assert_eq!(parent_ids.len(), 1);
|
||||
let page = &back.spreads[0].pages[0];
|
||||
assert_eq!(page.overridden, parent_ids);
|
||||
let local: Vec<_> = back.spreads[0].items.iter().collect();
|
||||
assert_eq!(local.len(), 1);
|
||||
assert_eq!(local[0].overrides, Some(parent_ids[0]));
|
||||
// Rendering skips overridden parent items, so page 1 shows only the local copy.
|
||||
let shown_parent = back.parents[ppi].items.iter().filter(|i| !page.overridden.contains(&i.id)).count();
|
||||
assert_eq!(shown_parent + local.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_and_base64_helpers() {
|
||||
assert_eq!(import::uri_to_path("file:/a%20b/c.png"), "/a b/c.png");
|
||||
@@ -868,6 +898,25 @@ fn cjk_character_attributes_import_from_independent_xml_and_round_trip() {
|
||||
assert_eq!(a.leading_model, b.leading_model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_stroke_alignment_imports_as_stroke_behind_fill_and_round_trips() {
|
||||
let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" StrokeColor="Color/Black" StrokeWeight="2" StrokeAlignment="OutsideAlignment"><Content>Behind</Content></CharacterStyleRange>
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" StrokeColor="Color/Black" StrokeAlignment="CenterAlignment"><Content>Over</Content></CharacterStyleRange>
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Plain</Content></CharacterStyleRange>
|
||||
</ParagraphStyleRange></Story></idPkg:Story>"#;
|
||||
let flags = |d: &Document| -> Vec<(String, Option<bool>)> {
|
||||
let st = d.stories.values().find(|s| s.text.contains("Behind")).unwrap();
|
||||
st.runs().map(|(r, f)| (st.text[r].to_string(), f.over.stroke_behind_fill)).filter(|(t, _)| !t.trim().is_empty()).collect()
|
||||
};
|
||||
let want = vec![("Behind".to_string(), Some(true)), ("Over".to_string(), Some(false)), ("Plain".to_string(), None)];
|
||||
let doc = import_idml(&fixture_with_story(story)).unwrap();
|
||||
assert_eq!(flags(&doc), want);
|
||||
let back = import_idml(&export_idml(&doc)).unwrap();
|
||||
assert_eq!(flags(&back), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warichu_imports_from_idml_and_round_trips() {
|
||||
let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
|
||||
@@ -2169,3 +2218,54 @@ fn frame_preference_property_color_resolves_for_frames_and_object_styles() {
|
||||
assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]);
|
||||
assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near");
|
||||
}
|
||||
|
||||
/// A two-page spread with ruler guides on its pages (and one on the parent), in `origin` ruler
|
||||
/// coordinates.
|
||||
fn guides_package(origin: &str) -> Vec<u8> {
|
||||
let prefs = PREFS.replace("</idPkg:Preferences>", &format!(r#"<ViewPreference RulerOrigin="{origin}"/></idPkg:Preferences>"#));
|
||||
let spread = SPREAD.replace(
|
||||
r#"<Page Self="p2" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 -350"/>"#,
|
||||
r#"<Page Self="p2" AppliedMaster="m1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 0 -350">
|
||||
<Guide Self="g1" Orientation="Vertical" Location="40" FitToPage="true" ItemLayer="L1" Locked="true"/>
|
||||
<Guide Self="g2" Orientation="Horizontal" Location="100" FitToPage="false"/>
|
||||
</Page>"#,
|
||||
);
|
||||
let master = MASTER.replace(
|
||||
r#"<Page Self="mp1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350"/>"#,
|
||||
r#"<Page Self="mp1" GeometricBounds="0 0 700 500" ItemTransform="1 0 0 1 -500 -350"><Guide Self="mg" Orientation="Vertical" Location="84"/></Page>"#,
|
||||
);
|
||||
zip_files(&[
|
||||
("designmap.xml", DESIGNMAP),
|
||||
("Resources/Graphic.xml", GRAPHIC),
|
||||
("Resources/Styles.xml", STYLES),
|
||||
("Resources/Preferences.xml", &prefs),
|
||||
("MasterSpreads/MasterSpread_m1.xml", &master),
|
||||
("Spreads/Spread_sp1.xml", &spread),
|
||||
("Stories/Story_s1.xml", STORY),
|
||||
])
|
||||
}
|
||||
|
||||
/// Ruler guides come in from IDML (they were dropped): on their page, measured from the ruler
|
||||
/// origin, with Fit to Page, Locked and their layer; parent pages' too. They go out again.
|
||||
#[test]
|
||||
fn ruler_guides_import_and_round_trip() {
|
||||
use designcraft_doc::Orientation::{Horizontal, Vertical};
|
||||
let guides = |d: &Document| {
|
||||
let p = &d.spreads[0].pages[1];
|
||||
p.guides.iter().map(|g| (g.orientation, g.position, g.spread, g.locked, g.layer.is_some())).collect::<Vec<_>>()
|
||||
};
|
||||
// The second page starts at x 500 in our spread coordinates.
|
||||
let d = import_idml(&guides_package("PageOrigin")).unwrap();
|
||||
assert_eq!(guides(&d), [(Vertical, 540.0, false, true, true), (Horizontal, 100.0, true, false, false)]);
|
||||
let d = import_idml(&guides_package("SpineOrigin")).unwrap();
|
||||
assert_eq!(guides(&d)[0].1, 540.0, "the spine is the second page's left edge");
|
||||
let d = import_idml(&guides_package("SpreadOrigin")).unwrap();
|
||||
assert_eq!(guides(&d)[0].1, 40.0);
|
||||
assert_eq!(d.parents[0].pages[0].guides.len(), 1, "a parent page's guide");
|
||||
assert_eq!(d.parents[0].pages[0].guides[0].position, 84.0);
|
||||
// Round trip: written under each page, read back where they were.
|
||||
let d = import_idml(&guides_package("PageOrigin")).unwrap();
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
assert_eq!(guides(&back), guides(&d));
|
||||
assert_eq!(back.parents[0].pages[0].guides.len(), 1);
|
||||
}
|
||||
|
||||
@@ -108,3 +108,46 @@ fn one_file_placed_several_times_imports_as_one_asset() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An `<SVG>` placement linking `uri`, whose GraphicBounds are `bounds` (left top right bottom).
|
||||
fn svg(n: usize, uri: &str, bounds: [f64; 4]) -> String {
|
||||
let [l, t, r, b] = bounds;
|
||||
format!(
|
||||
r#"<SVG Self="sv{n}" ItemTransform="1 0 0 1 0 0">
|
||||
<Properties><GraphicBounds Left="{l}" Top="{t}" Right="{r}" Bottom="{b}"/></Properties>
|
||||
<Link Self="lk{n}" LinkResourceURI="{uri}" LinkResourceFormat="$ID/SVG" StoredState="Normal"/>
|
||||
</SVG>"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_svg_with_only_a_viewbox_keeps_its_viewbox_size() {
|
||||
const OVERHANG: &str =
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><line x1="-10" y1="50" x2="0" y2="50" stroke="#000"/></svg>"##;
|
||||
const SIZED: &str =
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100"><rect width="200" height="100"/></svg>"#;
|
||||
let reader = |p: &str| match p {
|
||||
"/art/overhang.svg" => Some(OVERHANG.as_bytes().to_vec()),
|
||||
"/art/sized.svg" => Some(SIZED.as_bytes().to_vec()),
|
||||
_ => None,
|
||||
};
|
||||
// GraphicBounds are the art's bounds: 211 wide for the overhanging one, placed twice (the second
|
||||
// reuses the first's asset); a sized SVG is still stretched onto its bounds.
|
||||
let frames = vec![
|
||||
frame(1, 0.0, 0.0, &svg(1, "file:/art/overhang.svg", [-11.0, 0.0, 200.0, 100.0])),
|
||||
frame(2, 100.0, 0.0, &svg(2, "file:/art/overhang.svg", [-11.0, 0.0, 200.0, 100.0])),
|
||||
frame(3, 200.0, 0.0, &svg(3, "file:/art/sized.svg", [-10.0, 0.0, 200.0, 100.0])),
|
||||
];
|
||||
let d = import_idml_with(&package(&frames), &reader).unwrap();
|
||||
let mut gs: Vec<_> = d.spreads[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| match &i.content {
|
||||
Content::Graphic(g) => Some((i.xf.translation().x, g.size, g.xf.translation().x)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
gs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let got: Vec<_> = gs.into_iter().map(|(_, size, x)| (size, x)).collect();
|
||||
assert_eq!(got, vec![((200.0, 100.0), -11.0), ((200.0, 100.0), -11.0), ((210.0, 100.0), -10.0)]);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ pub fn bounding_box(bytes: &[u8]) -> Option<[f64; 4]> {
|
||||
let read = |key: &str| {
|
||||
head.lines().find_map(|l| {
|
||||
let v: Vec<f64> = l.strip_prefix(key)?.split_whitespace().filter_map(|t| t.parse().ok()).collect();
|
||||
(v.len() == 4 && v[2] > v[0] && v[3] > v[1]).then(|| [v[0], v[1], v[2], v[3]])
|
||||
// Finite corners and a finite size (subtracting extreme corners can overflow to infinity).
|
||||
let [x0, y0, x1, y1] = <[f64; 4]>::try_from(v).ok()?;
|
||||
let finite = [x0, y0, x1, y1, x1 - x0, y1 - y0].iter().all(|c| c.is_finite());
|
||||
(finite && x1 > x0 && y1 > y0).then_some([x0, y0, x1, y1])
|
||||
})
|
||||
};
|
||||
read("%%HiResBoundingBox:").or_else(|| read("%%BoundingBox:"))
|
||||
@@ -241,4 +244,16 @@ mod tests {
|
||||
assert_eq!(crate::pixel_size(&p), Some((10, 5)));
|
||||
assert!(!is_eps(b"%!PS-Adobe-3.0\nnot encapsulated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_bounding_box_rejects_nonfinite_sizes() {
|
||||
let bb = |line: &str| bounding_box(format!("%!PS-Adobe-3.0 EPSF-3.0\n{line}\n").as_bytes());
|
||||
assert_eq!(bb("%%BoundingBox: 0 0 100 50"), Some([0.0, 0.0, 100.0, 50.0]));
|
||||
assert_eq!(bb("%%BoundingBox: 0 0 inf 50"), None);
|
||||
assert_eq!(bb("%%BoundingBox: -inf 0 100 50"), None);
|
||||
assert_eq!(bb("%%BoundingBox: -1e308 0 1e308 50"), None);
|
||||
assert_eq!(bb("%%BoundingBox: 100 50 0 0"), None);
|
||||
// A bad high-resolution box falls back to the plain one.
|
||||
assert_eq!(bb("%%HiResBoundingBox: 0 0 inf inf\n%%BoundingBox: 0 0 10 20"), Some([0.0, 0.0, 10.0, 20.0]));
|
||||
}
|
||||
}
|
||||
|
||||
+225
-5
@@ -73,25 +73,54 @@ fn svg_options() -> &'static usvg::Options<'static> {
|
||||
}
|
||||
|
||||
/// A family the SVG names that isn't bundled comes from the shared fonts (installed ones too),
|
||||
/// as it would in a text frame.
|
||||
/// as it would in a text frame. A name that is no family's but a face's PostScript name (as
|
||||
/// Illustrator's SVG export writes them: `font-family="'MyriadPro-Regular'"`) sets the text in
|
||||
/// that face, whatever font-weight and font-style say: the name already picks the weight and
|
||||
/// style, and a family's other faces would contradict it. Families listed before it come first.
|
||||
fn select_font() -> usvg::FontSelectionFn<'static> {
|
||||
let bundled = usvg::FontResolver::default_font_selector();
|
||||
Box::new(move |font, db| {
|
||||
let fonts = designcraft_fonts::FontDb::global();
|
||||
for family in font.families() {
|
||||
if let usvg::FontFamily::Named(name) = family
|
||||
&& !db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)))
|
||||
&& fonts.has_family(name)
|
||||
&& !has_family(db, name)
|
||||
{
|
||||
for style in fonts.styles(name) {
|
||||
add_face(db, fonts.face(name, &style));
|
||||
if fonts.has_family(name) {
|
||||
for style in fonts.styles(name) {
|
||||
add_face(db, fonts.face(name, &style));
|
||||
}
|
||||
} else if postscript_face(db, name).is_none()
|
||||
&& let Some((family, style)) = fonts.postscript_face(name)
|
||||
{
|
||||
add_face(db, fonts.face(&family, &style));
|
||||
}
|
||||
}
|
||||
}
|
||||
for family in font.families() {
|
||||
match family {
|
||||
usvg::FontFamily::Named(name) if !has_family(db, name) => {
|
||||
if let Some(id) = postscript_face(db, name) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
// A family (or generic family) that matches: usvg's own selection.
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
bundled(font, db)
|
||||
})
|
||||
}
|
||||
|
||||
/// Does a face in the SVG's font database have family `name` (ignoring case)?
|
||||
fn has_family(db: &usvg::fontdb::Database, name: &str) -> bool {
|
||||
db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
/// The face in the SVG's font database with PostScript name `name` (ignoring case).
|
||||
fn postscript_face(db: &usvg::fontdb::Database, name: &str) -> Option<usvg::fontdb::ID> {
|
||||
db.faces().find(|f| f.post_script_name.eq_ignore_ascii_case(name)).map(|f| f.id)
|
||||
}
|
||||
|
||||
/// Characters the chosen fonts lack (Korean, Arabic…) fall back to a shared face that has them.
|
||||
fn select_fallback() -> usvg::FallbackSelectionFn<'static> {
|
||||
let loaded = usvg::FontResolver::default_fallback_selector();
|
||||
@@ -150,6 +179,9 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> {
|
||||
|
||||
/// Pixel dimensions of a raster.
|
||||
pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
if let Some(p) = palette_tiff(bytes) {
|
||||
return Some((p.width, p.height));
|
||||
}
|
||||
if is_psd(bytes) && bytes.len() >= 26 {
|
||||
// Header: signature, version, 6 reserved, channels, height, width (big-endian).
|
||||
let be = |i: usize| u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]);
|
||||
@@ -163,6 +195,10 @@ pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
if is_psd(bytes) {
|
||||
return psd::decode(bytes);
|
||||
}
|
||||
// `image` rejects indexed-colour TIFFs (an old GIF logo saved from Photoshop).
|
||||
if let Some(img) = decode_palette_tiff(bytes) {
|
||||
return Some(img);
|
||||
}
|
||||
// An EPS is drawn as its proxy (preview, simple PostScript or placeholder); the document
|
||||
// keeps the EPS itself.
|
||||
if is_eps(bytes) {
|
||||
@@ -228,6 +264,98 @@ fn tiff_decoder(bytes: &[u8]) -> Option<tiff::decoder::Decoder<std::io::Cursor<&
|
||||
tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).ok()
|
||||
}
|
||||
|
||||
/// An indexed-colour TIFF (PhotometricInterpretation 3): its size, bits per index and ColorMap
|
||||
/// in 8 bits per channel.
|
||||
struct PaletteTiff {
|
||||
width: u32,
|
||||
height: u32,
|
||||
bits: u8,
|
||||
colours: Vec<[u8; 3]>,
|
||||
}
|
||||
|
||||
/// The header of a palette TIFF with 1-, 2-, 4- or 8-bit indices and a full ColorMap, which
|
||||
/// neither `image` nor `tiff` decodes. `None` for anything else.
|
||||
fn palette_tiff(bytes: &[u8]) -> Option<PaletteTiff> {
|
||||
use tiff::tags::Tag;
|
||||
let mut d = tiff_decoder(bytes)?;
|
||||
if d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok()?? != 3 {
|
||||
return None;
|
||||
}
|
||||
if d.find_tag_unsigned::<u16>(Tag::SamplesPerPixel).ok()?.unwrap_or(1) != 1 {
|
||||
return None;
|
||||
}
|
||||
let bits = d.find_tag_unsigned::<u8>(Tag::BitsPerSample).ok()?.unwrap_or(1);
|
||||
if !matches!(bits, 1 | 2 | 4 | 8) {
|
||||
return None;
|
||||
}
|
||||
// 2^bits reds, then greens, then blues; 16-bit entries whose high byte is the 8-bit value.
|
||||
let map = d.find_tag_unsigned_vec::<u16>(Tag::ColorMap).ok()??;
|
||||
let n = 1usize << bits;
|
||||
let (r, gb) = map.split_at_checked(n)?;
|
||||
let (g, b) = gb.split_at_checked(n)?;
|
||||
if b.len() != n {
|
||||
return None;
|
||||
}
|
||||
let colours = r.iter().zip(g).zip(b).map(|((&r, &g), &b)| [r, g, b].map(|v| (v >> 8) as u8)).collect();
|
||||
let (width, height) = d.dimensions().ok()?;
|
||||
Some(PaletteTiff { width, height, bits, colours })
|
||||
}
|
||||
|
||||
/// A palette TIFF as RGBA, each index looked up in the ColorMap.
|
||||
fn decode_palette_tiff(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
let p = palette_tiff(bytes)?;
|
||||
// Read as gray, `tiff` hands back the indices themselves, whatever the compression or layout.
|
||||
let gray = with_photometric(bytes, 1)?;
|
||||
let tiff::decoder::DecodingResult::U8(px) = tiff_decoder(&gray)?.read_image().ok()? else { return None };
|
||||
let (w, h) = (usize::try_from(p.width).ok()?, usize::try_from(p.height).ok()?);
|
||||
let bits = usize::from(p.bits);
|
||||
// Indices below 8 bits are packed high bits first, each row starting on a byte.
|
||||
let row = w.checked_mul(bits)?.div_ceil(8);
|
||||
if row == 0 || px.len() < row.checked_mul(h)? {
|
||||
return None;
|
||||
}
|
||||
let per_byte = 8 / bits;
|
||||
let mask = ((1u16 << bits) - 1) as u8;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(w.checked_mul(h)?.checked_mul(4)?).ok()?;
|
||||
for line in px.chunks_exact(row).take(h) {
|
||||
for x in 0..w {
|
||||
let shift = 8 - bits * (x % per_byte + 1);
|
||||
let i = (line.get(x / per_byte)? >> shift) & mask;
|
||||
let [r, g, b] = *p.colours.get(usize::from(i))?;
|
||||
out.extend([r, g, b, 255]);
|
||||
}
|
||||
}
|
||||
image::RgbaImage::from_raw(p.width, p.height, out)
|
||||
}
|
||||
|
||||
/// A copy of a TIFF with its first image's PhotometricInterpretation (tag 262, one SHORT) set
|
||||
/// to `value`. Classic and BigTIFF, either byte order.
|
||||
fn with_photometric(bytes: &[u8], value: u16) -> Option<Vec<u8>> {
|
||||
let le = bytes.starts_with(b"II");
|
||||
let int = |at: usize, len: usize| -> Option<usize> {
|
||||
let b = bytes.get(at..at.checked_add(len)?)?;
|
||||
let v = |acc: u64, &x: &u8| acc << 8 | u64::from(x);
|
||||
usize::try_from(if le { b.iter().rev().fold(0, v) } else { b.iter().fold(0, v) }).ok()
|
||||
};
|
||||
// Classic: 4-byte offsets and value counts, 2-byte entry count, 12-byte entries. BigTIFF:
|
||||
// 8, 8, 8 and 20.
|
||||
let (ifd, word, count_len, entry_len) = if int(2, 2)? == 43 { (int(8, 8)?, 8, 8, 20) } else { (int(4, 4)?, 4, 2, 12) };
|
||||
let count = int(ifd, count_len)?;
|
||||
for k in 0..count {
|
||||
let at = ifd.checked_add(count_len)?.checked_add(k.checked_mul(entry_len)?)?;
|
||||
// Tag, type (3 = SHORT), value count, then the value itself.
|
||||
if int(at, 2)? == 262 && int(at + 2, 2)? == 3 && int(at + 4, word)? == 1 {
|
||||
let mut out = bytes.to_vec();
|
||||
let v = if le { value.to_le_bytes() } else { value.to_be_bytes() };
|
||||
let value_at = at + 4 + word;
|
||||
out.get_mut(value_at..value_at + 2)?.copy_from_slice(&v);
|
||||
return Some(out);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A TIFF whose pixels are CMYK (PhotometricInterpretation Separated, four inks).
|
||||
pub fn is_cmyk_tiff(bytes: &[u8]) -> bool {
|
||||
tiff_decoder(bytes).and_then(|mut d| d.colortype().ok()).is_some_and(|c| matches!(c, tiff::ColorType::CMYK(_) | tiff::ColorType::CMYKA(_)))
|
||||
@@ -351,6 +479,45 @@ mod tests {
|
||||
assert!(set.len() == 2 && set.iter().all(|(f, gid)| f != "Source Sans 3" && *gid != 0), "{set:?}");
|
||||
}
|
||||
|
||||
/// The PostScript name of the face that sets the text of `svg` (one face for all of it).
|
||||
fn text_face(svg_attrs: &str) -> String {
|
||||
let svg =
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="40"><text y="30" font-size="20"{svg_attrs}>ab</text></svg>"#);
|
||||
let tree = svg_tree(svg.as_bytes()).unwrap();
|
||||
let mut faces = Vec::new();
|
||||
for n in tree.root().children() {
|
||||
if let usvg::Node::Text(t) = n {
|
||||
for g in t.layouted().iter().flat_map(|s| &s.positioned_glyphs) {
|
||||
faces.extend(tree.fontdb().face(g.font).map(|f| f.post_script_name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
faces.dedup();
|
||||
assert_eq!(faces.len(), 1, "{faces:?}");
|
||||
faces.remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_text_finds_fonts_by_postscript_name() {
|
||||
// Bundled, as Illustrator's SVG export names it; any case.
|
||||
assert_eq!(text_face(r#" font-family="'SourceSerif4-Semibold'""#), "SourceSerif4-Semibold");
|
||||
assert_eq!(text_face(r#" font-family="sourceserif4-semibold""#), "SourceSerif4-Semibold");
|
||||
// A shared font (as an installed one would be).
|
||||
let font = designcraft_fonts::testing::font_with("DC Test SVG PostScript", &['a', 'b']).unwrap();
|
||||
designcraft_fonts::FontDb::global().add_font(font);
|
||||
assert_eq!(text_face(r#" font-family="DCTestSVGPostScript-Regular""#), "DCTestSVGPostScript-Regular");
|
||||
// The name picks that face: font-weight and font-style don't change it.
|
||||
assert_eq!(text_face(r#" font-family="SourceSerif4-Semibold" font-weight="bold" font-style="italic""#), "SourceSerif4-Semibold");
|
||||
// A family listed before it still comes first; one after it is a fallback.
|
||||
assert_eq!(text_face(r#" font-family="'Source Sans 3', SourceSerif4-Bold""#), "SourceSans3-Regular");
|
||||
assert_eq!(text_face(r#" font-family="NoSuch-Font, SourceSerif4-Bold, 'Source Sans 3'""#), "SourceSerif4-Bold");
|
||||
// Family names work as before.
|
||||
assert_eq!(text_face(r#" font-family="'Source Serif 4'" font-weight="bold""#), "SourceSerif4-Bold");
|
||||
// An unknown name falls back as before (the serif default).
|
||||
assert_eq!(text_face(r#" font-family="NoSuch-Font""#), text_face(r#" font-family="No Such Font""#));
|
||||
assert_eq!(text_face(r#" font-family="NoSuch-Font""#), "SourceSerif4-Regular");
|
||||
}
|
||||
|
||||
/// A minimal Photoshop file: 2×1 RGB, no layers, raw merged image.
|
||||
fn tiny_psd() -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
@@ -435,6 +602,59 @@ mod tests {
|
||||
assert_eq!(decode_cmyk_tiff(&b[..b.len() / 2]), None, "truncated");
|
||||
}
|
||||
|
||||
/// Indexed colour (PhotometricInterpretation 3), which the `tiff` encoder has no type for.
|
||||
struct Palette<const BITS: u16>;
|
||||
impl<const BITS: u16> tiff::encoder::colortype::ColorType for Palette<BITS> {
|
||||
type Inner = u8;
|
||||
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::RGBPalette;
|
||||
const BITS_PER_SAMPLE: &'static [u16] = &[BITS];
|
||||
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint];
|
||||
fn horizontal_predict(row: &[u8], result: &mut Vec<u8>) {
|
||||
result.extend_from_slice(row);
|
||||
}
|
||||
}
|
||||
|
||||
/// A palette TIFF as Photoshop and Pillow write it: 16-bit ColorMap entries (reds, then
|
||||
/// greens, then blues), each 8-bit value times 256.
|
||||
fn palette_tiff<const BITS: u16>(w: u32, h: u32, px: &[u8], colours: &[[u8; 3]], lzw: bool) -> Vec<u8> {
|
||||
let n = 1usize << BITS;
|
||||
let mut map = vec![0u16; 3 * n];
|
||||
for (i, c) in colours.iter().enumerate() {
|
||||
for (ch, &v) in c.iter().enumerate() {
|
||||
map[ch * n + i] = u16::from(v) << 8;
|
||||
}
|
||||
}
|
||||
let mut b = Vec::new();
|
||||
let mut enc = tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut b)).unwrap();
|
||||
if lzw {
|
||||
enc = enc.with_compression(tiff::encoder::Compression::Lzw);
|
||||
}
|
||||
let mut img = enc.new_image::<Palette<BITS>>(w, h).unwrap();
|
||||
img.encoder().write_tag(tiff::tags::Tag::ColorMap, &map[..]).unwrap();
|
||||
img.write_data(px).unwrap();
|
||||
b
|
||||
}
|
||||
|
||||
/// Indexed-colour TIFFs (an old GIF logo saved from Photoshop) are sized and drawn through
|
||||
/// their palette, uncompressed or LZW, 8- or 4-bit.
|
||||
#[test]
|
||||
fn palette_tiff_decodes_through_its_colormap() {
|
||||
let colours = [[0, 128, 255], [255, 0, 0], [10, 20, 30]];
|
||||
for lzw in [false, true] {
|
||||
let b = palette_tiff::<8>(3, 1, &[2, 0, 1], &colours, lzw);
|
||||
assert_eq!(pixel_size(&b), Some((3, 1)), "lzw {lzw}");
|
||||
let img = decode_rgba(&b).expect("decodes");
|
||||
let px: Vec<_> = img.pixels().map(|p| p.0).collect();
|
||||
assert_eq!(px, [[10, 20, 30, 255], [0, 128, 255, 255], [255, 0, 0, 255]], "lzw {lzw}");
|
||||
}
|
||||
// Two indices per byte, high nibble first: 2, 1 | 0, 2 (the encoder counts samples, not
|
||||
// bytes, so the strip carries two spare bytes the decoder doesn't read).
|
||||
let b = palette_tiff::<4>(2, 2, &[0x21, 0x02, 0, 0], &colours, false);
|
||||
assert_eq!(pixel_size(&b), Some((2, 2)));
|
||||
let px: Vec<_> = decode_rgba(&b).expect("decodes").pixels().map(|p| p.0).collect();
|
||||
assert_eq!(px, [[10, 20, 30, 255], [255, 0, 0, 255], [0, 128, 255, 255], [10, 20, 30, 255]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bmp_decodes() {
|
||||
let mut bmp = Vec::new();
|
||||
|
||||
@@ -13,6 +13,12 @@ pub trait Backend {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
|
||||
/// True when a real UI is attached (`ui.screenshot`, `ui.click`, dialogs… work).
|
||||
fn has_ui(&self) -> bool;
|
||||
/// Write `bytes` to the file at `path` for a tool that was asked to keep a file (the PNG of
|
||||
/// `render_page`). A backend that confines file access (see [`crate::Headless`]) applies it
|
||||
/// here too.
|
||||
fn write_file(&mut self, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
designcraft_engine::io::write_file(path, bytes)
|
||||
}
|
||||
/// Short human description ("headless", "connected to 127.0.0.1:7979").
|
||||
fn describe(&self) -> String;
|
||||
}
|
||||
|
||||
+33
-11
@@ -1,5 +1,8 @@
|
||||
//! An in-process engine session that answers the engine-level control-channel methods itself.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_engine::file_access::{self, AutomationRoots};
|
||||
use designcraft_engine::{Session, ViewInfo};
|
||||
use designcraft_geom::Point;
|
||||
use designcraft_render::{RenderOptions, Renderer};
|
||||
@@ -12,6 +15,7 @@ use crate::backend::Backend;
|
||||
pub struct Headless {
|
||||
pub session: Session,
|
||||
pub renderer: Renderer,
|
||||
roots: Option<Arc<AutomationRoots>>,
|
||||
}
|
||||
|
||||
impl Default for Headless {
|
||||
@@ -82,7 +86,14 @@ fn mods_from(p: &Value) -> Mods {
|
||||
impl Headless {
|
||||
/// A session with no document (the first `file.new` / `file.open` creates one).
|
||||
pub fn new() -> Self {
|
||||
Self { session: Session::new(), renderer: Renderer::new() }
|
||||
Self { session: Session::new(), renderer: Renderer::new(), roots: None }
|
||||
}
|
||||
|
||||
/// Confine every file the session's commands read and write to `roots`
|
||||
/// (`designcraft-cli mcp --automation-read-root … --automation-write-root …`).
|
||||
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
|
||||
self.roots = roots;
|
||||
self
|
||||
}
|
||||
|
||||
/// A session with a fresh default (Letter) document, ready to lay out.
|
||||
@@ -204,7 +215,7 @@ impl Headless {
|
||||
let png = img.to_png();
|
||||
match s(p, "path") {
|
||||
Some(path) => {
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
designcraft_engine::io::write_file(path, &png)?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
||||
}
|
||||
None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})),
|
||||
@@ -224,13 +235,32 @@ impl Headless {
|
||||
.ok_or_else(|| format!("no page {page}"))?;
|
||||
let lower = path.to_ascii_lowercase();
|
||||
let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| format!("write {path}: {e}"))?;
|
||||
designcraft_engine::io::write_file(path, &bytes)?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let roots = self.roots.clone();
|
||||
file_access::confine(roots.as_ref(), || self.answer(method, params))
|
||||
}
|
||||
|
||||
fn write_file(&mut self, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
file_access::confine(self.roots.as_ref(), || designcraft_engine::io::write_file(path, bytes))
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
"headless (in-process engine, no window)".into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Headless {
|
||||
fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let p = ¶ms;
|
||||
match method {
|
||||
"engine.execute" | "ui.menu.invoke" | "command" => {
|
||||
@@ -257,14 +287,6 @@ impl Backend for Headless {
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
"headless (in-process engine, no window)".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// A supplied page must be a representable unsigned index; only omission selects page zero.
|
||||
|
||||
@@ -167,6 +167,16 @@ fn tool_definitions_are_well_formed() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_that_write_files_are_not_read_only() {
|
||||
// `render_page` and `screenshot` write a file when given `path`, so they must not advertise `readOnlyHint: true`.
|
||||
for name in ["render_page", "screenshot", "export_png"] {
|
||||
let definition = tool_definitions().into_iter().find(|v| v["name"] == name).unwrap();
|
||||
assert!(definition["inputSchema"]["properties"].get("path").is_some(), "{name} takes a path");
|
||||
assert_eq!(definition["annotations"]["readOnlyHint"], false, "{name} writes files");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- layout through the tools ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -118,7 +118,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
and message (earlier commands stay applied; use edit.undo to revert them). Later steps can use earlier results: a \
|
||||
parameter string \"$N.path\" is replaced by that value of step N's result (0-based; \"$last.path\" for the previous \
|
||||
step), e.g. after frame.create → {\"id\": 8, \"story\": 9}, {\"story\": \"$0.story\"} passes 9; \"${N.path}\" \
|
||||
interpolates into longer strings. Give `commands`, or `script` text (one `command.id {json}` per line).",
|
||||
interpolates into longer strings. Other strings starting with $ stay as written; \"$$\" is a literal $ \
|
||||
(\"$$1\" passes the GREP group $1). Give `commands`, or `script` text (one `command.id {json}` per line).",
|
||||
obj(
|
||||
json!({
|
||||
"commands": {
|
||||
@@ -232,7 +233,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
true,
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"export_png",
|
||||
@@ -249,7 +250,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"Screenshot",
|
||||
&app_only("Capture the whole app window (menus, panels, canvas) and return it as a PNG image. Optionally also keep it at `path`."),
|
||||
obj(json!({"path": string("Also write the PNG here")}), &[]),
|
||||
true,
|
||||
false,
|
||||
),
|
||||
// ----- input -----
|
||||
tool(
|
||||
@@ -480,7 +481,7 @@ fn render_page(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
let path = a.get("path").and_then(Value::as_str);
|
||||
if let Some(path) = path {
|
||||
let png = designcraft_engine::cmd::base64_decode(&b64);
|
||||
designcraft_engine::atomic::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?;
|
||||
b.write_file(path, &png)?;
|
||||
}
|
||||
Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path})))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! `designcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>`: through the MCP
|
||||
//! protocol, every file a command reads comes from inside the read root and every file it writes
|
||||
//! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the
|
||||
//! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing
|
||||
//! is written outside.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use designcraft_engine::file_access::AutomationRoots;
|
||||
use designcraft_mcp::{Headless, Server};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// What the secret files hold, so a test can look for it in everything the agent is told.
|
||||
const SECRET: &str = "TOP SECRET";
|
||||
|
||||
/// A 1x1 PNG, so `place_image` has something real to read.
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
/// A `.designcraft` file of `pages` pages with `text` in a frame on the first, made outside any roots.
|
||||
fn designcraft_file(path: &Path, pages: u32, text: &str) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": pages})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 300, 100], "content": "text", "text": text})).unwrap();
|
||||
s.execute("file.save", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
}
|
||||
|
||||
/// A fresh folder holding `in` (the read root), `out` (the write root), `outside` and `in2` (a
|
||||
/// sibling whose name starts with `in`), and a server confined to `in` and `out` (either may be
|
||||
/// left out). `in` and `outside` each hold the same kinds of files, those outside carrying
|
||||
/// [`SECRET`]. The server starts with a new one-page document.
|
||||
fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
|
||||
let base = std::env::temp_dir().join(format!("dc-mcp-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
for (dir, text) in [("in", "Inside text"), ("outside", SECRET), ("in2", "Sibling")] {
|
||||
let dir = base.join(dir);
|
||||
designcraft_file(&dir.join("doc.designcraft"), if dir.ends_with("in") { 1 } else { 3 }, text);
|
||||
std::fs::write(dir.join("pic.png"), PNG).unwrap();
|
||||
std::fs::write(dir.join("data.csv"), format!("name,city\n{text},Oslo\n")).unwrap();
|
||||
std::fs::write(dir.join("swatches.ase"), b"not a swatch file").unwrap();
|
||||
std::fs::write(dir.join("profile.icc"), b"not a profile").unwrap();
|
||||
std::fs::write(dir.join("lib.json"), format!("{{\"items\": [], \"note\": \"{text}\"}}")).unwrap();
|
||||
std::fs::write(dir.join("shape.dtd"), format!("<!ELEMENT {} (#PCDATA)>", text.replace(' ', "_"))).unwrap();
|
||||
std::fs::write(dir.join("tags.xml"), format!("<root><p>{text}</p></root>")).unwrap();
|
||||
}
|
||||
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
|
||||
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots)));
|
||||
ok(&mut server, "new_document", json!({}));
|
||||
(server, base)
|
||||
}
|
||||
|
||||
fn s(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// `tools/call` -> (isError, every text block).
|
||||
fn call(server: &mut Server, name: &str, args: Value) -> (bool, String) {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string();
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap();
|
||||
let result = &reply["result"];
|
||||
let text = result["content"].as_array().unwrap().iter().filter_map(|c| c["text"].as_str()).collect::<Vec<_>>().join("\n");
|
||||
(result["isError"] == true, text)
|
||||
}
|
||||
|
||||
fn ok(server: &mut Server, name: &str, args: Value) -> String {
|
||||
let (error, text) = call(server, name, args.clone());
|
||||
assert!(!error, "{name} {args}: {text}");
|
||||
text
|
||||
}
|
||||
|
||||
/// A command through the `execute` tool.
|
||||
fn run(server: &mut Server, command: &str, params: Value) -> (bool, String) {
|
||||
call(server, "execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
fn run_ok(server: &mut Server, command: &str, params: Value) -> Value {
|
||||
let (error, text) = run(server, command, params.clone());
|
||||
assert!(!error, "{command} {params}: {text}");
|
||||
serde_json::from_str(&text).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// `command` is refused with `why` in its message, and the message says nothing of the file.
|
||||
fn refused(server: &mut Server, command: &str, params: Value, why: &str) {
|
||||
let (error, text) = run(server, command, params.clone());
|
||||
assert!(error && text.contains(why), "{command} {params} should be refused ({why}): {text}");
|
||||
assert!(!text.contains(SECRET), "{command}: {text}");
|
||||
}
|
||||
|
||||
/// The open document's page count, which tells the three-page files outside from the one-page one.
|
||||
fn pages(server: &mut Server) -> usize {
|
||||
let doc: Value = serde_json::from_str(&ok(server, "inspect_document", json!({}))).unwrap();
|
||||
doc["pageCount"].as_u64().unwrap() as usize
|
||||
}
|
||||
|
||||
fn outside_names(base: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> =
|
||||
std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_open_only_from_inside_the_read_root() {
|
||||
let (mut server, base) = confined("open", true, true);
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
ok(&mut server, "open_document", json!({"path": s(&base.join("in/doc.designcraft"))}));
|
||||
assert_eq!(pages(&mut server), 1, "the file inside the root is the open document");
|
||||
|
||||
let escape = format!("{}{sep}..{sep}outside{sep}doc.designcraft", s(&base.join("in")));
|
||||
let paths = [s(&base.join("outside/doc.designcraft")), escape, s(&base.join("in2/doc.designcraft")), s(&base.join("out/new.designcraft"))];
|
||||
for path in paths {
|
||||
let (error, text) = call(&mut server, "open_document", json!({"path": path}));
|
||||
assert!(error && text.contains("outside the read root"), "{path}: {text}");
|
||||
assert!(!text.contains(SECRET), "{text}");
|
||||
refused(&mut server, "file.open", json!({"path": path}), "outside the read root");
|
||||
refused(&mut server, "file.openIdml", json!({"path": path}), "outside the read root");
|
||||
}
|
||||
// Nothing from the refused files reached the session: the open document is still the one it was.
|
||||
assert_eq!(pages(&mut server), 1);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_that_reads_a_path_is_refused_outside_the_read_root() {
|
||||
let (mut server, base) = confined("reads", true, true);
|
||||
let outside = |name: &str| s(&base.join("outside").join(name));
|
||||
let inside = |name: &str| s(&base.join("in").join(name));
|
||||
|
||||
// A placed image to relink and a data source to update, both from inside the root.
|
||||
ok(&mut server, "place_image", json!({"path": inside("pic.png"), "x": 36, "y": 36, "width": 72}));
|
||||
let links = run_ok(&mut server, "links.list", json!({}));
|
||||
let asset = links[0]["asset"].clone();
|
||||
let fields = run_ok(&mut server, "data.source.select", json!({"path": inside("data.csv")}));
|
||||
assert!(fields["fields"].to_string().contains("city"), "{fields}");
|
||||
let before = ok(&mut server, "inspect_document", json!({}));
|
||||
|
||||
let reads: Vec<(&str, Value)> = vec![
|
||||
("file.place", json!({"path": outside("pic.png")})),
|
||||
("place.load", json!({"path": outside("pic.png")})),
|
||||
("place.styles", json!({"path": outside("tags.xml")})),
|
||||
("links.relink", json!({"asset": asset, "path": outside("pic.png")})),
|
||||
("links.relinkFolder", json!({"dir": s(&base.join("outside"))})),
|
||||
("media.options", json!({"poster": {"path": outside("pic.png")}})),
|
||||
("color.loadProfile", json!({"path": outside("profile.icc")})),
|
||||
("swatch.load", json!({"path": outside("swatches.ase")})),
|
||||
("library.open", json!({"path": outside("lib.json")})),
|
||||
("book.open", json!({"path": outside("lib.json")})),
|
||||
("data.source.select", json!({"path": outside("data.csv")})),
|
||||
("data.merge", json!({"path": outside("data.csv")})),
|
||||
("xml.loadDtd", json!({"path": outside("shape.dtd")})),
|
||||
("file.importXml", json!({"path": outside("tags.xml")})),
|
||||
("file.recovery.list", json!({"dir": s(&base.join("outside"))})),
|
||||
("file.recovery.open", json!({"dir": s(&base.join("outside"))})),
|
||||
];
|
||||
for (command, params) in reads {
|
||||
refused(&mut server, command, params, "outside the read root");
|
||||
}
|
||||
let (error, text) = call(&mut server, "place_image", json!({"path": outside("pic.png")}));
|
||||
assert!(error && text.contains("outside the read root") && !text.contains(SECRET), "{text}");
|
||||
// The control-channel method behind `open_document`.
|
||||
let (error, text) = call(&mut server, "execute", json!({"command": "app.open", "params": {"path": outside("doc.designcraft")}}));
|
||||
assert!(error && !text.contains(SECRET), "app.open: {text}");
|
||||
assert_eq!(ok(&mut server, "inspect_document", json!({})), before, "the refused reads changed nothing");
|
||||
assert_eq!(run_ok(&mut server, "data.source.select", json!({"path": inside("data.csv")}))["records"], 1);
|
||||
|
||||
// Inside the root the same commands get as far as reading the file, so the table above is not
|
||||
// refusing for some other reason: a file that isn't a profile or swatch file is reported as such.
|
||||
for (command, params) in [("color.loadProfile", json!({"path": inside("profile.icc")})), ("swatch.load", json!({"path": inside("swatches.ase")}))]
|
||||
{
|
||||
let (error, text) = run(&mut server, command, params);
|
||||
assert!(error && !text.contains("automation"), "{command}: {text}");
|
||||
}
|
||||
run_ok(&mut server, "xml.loadDtd", json!({"path": inside("shape.dtd")}));
|
||||
run_ok(&mut server, "file.importXml", json!({"path": inside("tags.xml")}));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_named_inside_a_document_are_read_through_the_roots_too() {
|
||||
let (mut server, base) = confined("stored", true, true);
|
||||
// A document in the read root whose picture is linked to a file outside it, and whose data
|
||||
// source is a file outside it, made by someone without roots.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
std::fs::write(base.join("outside/linked.png"), PNG).unwrap();
|
||||
s.execute("file.place", &json!({"path": self::s(&base.join("outside/linked.png")), "x": 36, "y": 36, "width": 72})).unwrap();
|
||||
s.execute("data.source.select", &json!({"path": self::s(&base.join("outside/data.csv"))})).unwrap();
|
||||
s.execute("file.save", &json!({"path": self::s(&base.join("in/linked.designcraft"))})).unwrap();
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
|
||||
|
||||
ok(&mut server, "open_document", json!({"path": self::s(&base.join("in/linked.designcraft"))}));
|
||||
let links = run_ok(&mut server, "links.list", json!({}));
|
||||
assert_eq!(links[0]["status"], "missing", "a link outside the read root reads as a file that isn't there: {links}");
|
||||
refused(&mut server, "data.source.update", json!({}), "outside the read root");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_writing_command_is_refused_outside_the_write_root() {
|
||||
let (mut server, base) = confined("write", true, true);
|
||||
let outside = |name: &str| s(&base.join("outside").join(name));
|
||||
let inside = |name: &str| s(&base.join("out").join(name));
|
||||
let fixtures = outside_names(&base);
|
||||
// Something to export: a picture to package and a text frame, selected so `file.exportText`
|
||||
// and `snippet.export` have their input.
|
||||
ok(&mut server, "place_image", json!({"path": s(&base.join("in/pic.png")), "x": 36, "y": 200, "width": 72}));
|
||||
run_ok(&mut server, "frame.create", json!({"rect": [36, 300, 300, 100], "content": "text", "text": "Selected"}));
|
||||
run_ok(&mut server, "select.firstAbove", json!({}));
|
||||
|
||||
// Every command that writes a file, with the path parameter it takes.
|
||||
let writes: Vec<(&str, &str)> = vec![
|
||||
("file.save", "path"),
|
||||
("file.saveAs", "path"),
|
||||
("file.saveACopy", "path"),
|
||||
("file.exportPdf", "path"),
|
||||
("file.exportEpub", "path"),
|
||||
("file.exportFixedEpub", "path"),
|
||||
("file.exportHtml", "path"),
|
||||
("file.exportText", "path"),
|
||||
("file.exportXml", "path"),
|
||||
("file.exportIdml", "path"),
|
||||
("file.printBooklet", "path"),
|
||||
("snippet.export", "path"),
|
||||
("swatch.save", "path"),
|
||||
("library.new", "path"),
|
||||
("book.exportPdf", "path"),
|
||||
("book.new", "path"),
|
||||
("file.package", "dir"),
|
||||
("links.copyTo", "dir"),
|
||||
("file.recovery.save", "dir"),
|
||||
];
|
||||
// A book with a chapter to export, so `book.exportPdf` gets as far as its path.
|
||||
run_ok(&mut server, "book.new", json!({"path": inside("first.dcbook")}));
|
||||
run_ok(&mut server, "book.add", json!({"path": s(&base.join("in/doc.designcraft"))}));
|
||||
for (command, key) in &writes {
|
||||
let target = if *key == "dir" { outside("folder") } else { outside(&format!("out-{command}")) };
|
||||
refused(&mut server, command, json!({*key: target}), "outside the write root");
|
||||
}
|
||||
for tool in ["save_document", "export_png", "render_page"] {
|
||||
let (error, text) = call(&mut server, tool, json!({"path": outside("tool.out")}));
|
||||
assert!(error && text.contains("outside the write root"), "{tool}: {text}");
|
||||
}
|
||||
// The control-channel methods behind `save_document` and `export_png`.
|
||||
for method in ["app.save", "app.export"] {
|
||||
let (error, text) = call(&mut server, "execute", json!({"command": method, "params": {"path": outside("app.out")}}));
|
||||
assert!(error, "{method}: {text}");
|
||||
}
|
||||
// A sibling folder with the same prefix, `..` out of the root, and the read root.
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
for path in [
|
||||
s(&base.join("out2/x.designcraft")),
|
||||
format!("{}{sep}..{sep}outside{sep}x.designcraft", s(&base.join("out"))),
|
||||
s(&base.join("in/x.designcraft")),
|
||||
] {
|
||||
let (error, text) = call(&mut server, "save_document", json!({"path": path}));
|
||||
assert!(error && text.contains("outside the write root"), "{path}: {text}");
|
||||
}
|
||||
assert_eq!(outside_names(&base), fixtures, "nothing was written outside beside the files made at the start");
|
||||
assert!(!base.join("out2").exists() && !base.join("in/x.designcraft").exists());
|
||||
|
||||
// Inside the write root every one of them works, and the files appear.
|
||||
for (command, key) in &writes {
|
||||
let target = if *key == "dir" { inside(&format!("dir-{command}")) } else { inside(&format!("out-{command}")) };
|
||||
run_ok(&mut server, command, json!({*key: target}));
|
||||
}
|
||||
for tool in ["export_png", "render_page"] {
|
||||
ok(&mut server, tool, json!({"path": inside(&format!("{tool}.png"))}));
|
||||
assert!(base.join("out").join(format!("{tool}.png")).exists(), "{tool}");
|
||||
}
|
||||
for file in ["out-file.save", "out-file.exportPdf", "out-file.exportIdml", "out-book.exportPdf", "out-swatch.save", "out-snippet.export"] {
|
||||
assert!(base.join("out").join(file).exists(), "{file}");
|
||||
}
|
||||
assert!(base.join("out/dir-file.package/Instructions.txt").exists() && base.join("out/dir-links.copyTo/pic.png").exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_root_grants_only_its_own_access() {
|
||||
let (mut read_only, base) = confined("read-only", true, false);
|
||||
ok(&mut read_only, "open_document", json!({"path": s(&base.join("in/doc.designcraft"))}));
|
||||
run_ok(&mut read_only, "select.firstAbove", json!({}));
|
||||
for (command, key) in [("file.save", "path"), ("file.exportPdf", "path"), ("file.package", "dir"), ("file.exportText", "path")] {
|
||||
refused(&mut read_only, command, json!({key: s(&base.join("out/x"))}), "write authority is absent");
|
||||
}
|
||||
for tool in ["save_document", "export_png", "render_page"] {
|
||||
let (error, text) = call(&mut read_only, tool, json!({"path": s(&base.join("out/x.png"))}));
|
||||
assert!(error && text.contains("write authority is absent"), "{tool}: {text}");
|
||||
}
|
||||
// Calls that return bytes instead of writing a path keep working.
|
||||
ok(&mut read_only, "render_page", json!({"scale": 0.25}));
|
||||
run_ok(&mut read_only, "file.exportPdf", json!({}));
|
||||
assert!(std::fs::read_dir(base.join("out")).unwrap().next().is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
|
||||
let (mut write_only, base) = confined("write-only", false, true);
|
||||
for (command, key) in
|
||||
[("file.open", "path"), ("file.place", "path"), ("swatch.load", "path"), ("data.source.select", "path"), ("file.recovery.list", "dir")]
|
||||
{
|
||||
refused(&mut write_only, command, json!({key: s(&base.join("in/doc.designcraft"))}), "read authority is absent");
|
||||
}
|
||||
let (error, text) = call(&mut write_only, "place_image", json!({"path": s(&base.join("in/pic.png"))}));
|
||||
assert!(error && text.contains("read authority is absent"), "{text}");
|
||||
ok(&mut write_only, "save_document", json!({"path": s(&base.join("out/x.designcraft"))}));
|
||||
assert!(base.join("out/x.designcraft").exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_out_of_a_root_are_refused() {
|
||||
let (mut server, base) = confined("links", true, true);
|
||||
std::os::unix::fs::symlink(base.join("outside/doc.designcraft"), base.join("in/leak.designcraft")).unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside/data.csv"), base.join("in/leak.csv")).unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside/gone.designcraft"), base.join("out/dangling.designcraft")).unwrap();
|
||||
refused(&mut server, "file.open", json!({"path": s(&base.join("in/leak.designcraft"))}), "outside the read root");
|
||||
refused(&mut server, "data.source.select", json!({"path": s(&base.join("in/leak.csv"))}), "outside the read root");
|
||||
refused(&mut server, "file.saveAs", json!({"path": s(&base.join("out/away/x.designcraft"))}), "outside the write root");
|
||||
refused(&mut server, "file.exportPdf", json!({"path": s(&base.join("out/away/x.pdf"))}), "outside the write root");
|
||||
refused(&mut server, "file.saveAs", json!({"path": s(&base.join("out/dangling.designcraft"))}), "a link that leads nowhere");
|
||||
assert!(!base.join("outside/x.designcraft").exists() && !base.join("outside/x.pdf").exists() && !base.join("outside/gone.designcraft").exists());
|
||||
assert_eq!(pages(&mut server), 1, "nothing from the file the link led to reached the session");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_roots_nothing_is_confined() {
|
||||
let base = std::env::temp_dir().join(format!("dc-mcp-roots-none-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
designcraft_file(&base.join("a.designcraft"), 3, "Anywhere");
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap())));
|
||||
ok(&mut server, "open_document", json!({"path": s(&base.join("a.designcraft"))}));
|
||||
assert_eq!(pages(&mut server), 3);
|
||||
ok(&mut server, "save_document", json!({"path": s(&base.join("b.designcraft"))}));
|
||||
ok(&mut server, "export_png", json!({"path": s(&base.join("b.png"))}));
|
||||
assert!(base.join("b.designcraft").exists() && base.join("b.png").exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ designcraft-color = { workspace = true }
|
||||
designcraft-doc = { workspace = true }
|
||||
designcraft-fonts = { workspace = true }
|
||||
designcraft-compose = { workspace = true }
|
||||
krilla = { workspace = true }
|
||||
craft-pdf = { workspace = true, features = ["write-pdf"] }
|
||||
krilla-svg = { workspace = true }
|
||||
designcraft-images = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
+151
-65
@@ -3,6 +3,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use craft_pdf::write::{self, Metadata, Writer};
|
||||
use designcraft_color::swatch::{ColorType, SwatchValue};
|
||||
use designcraft_color::{BlendMode as DcBlend, Color, GradientKind};
|
||||
use designcraft_compose::Cache;
|
||||
@@ -10,10 +12,9 @@ use designcraft_doc::{AssetId, Content, Document, Item, PageSide, Spread, Stroke
|
||||
use designcraft_geom::kurbo::{PathEl, Shape as _};
|
||||
use designcraft_geom::{Affine, BezPath, Rect, Vec2, corners};
|
||||
use krilla::color::{cmyk, luma, rgb};
|
||||
use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion};
|
||||
use krilla::configure::{Archival, PdfVersion};
|
||||
use krilla::geom::{Path, PathBuilder, Size, Transform};
|
||||
use krilla::image::Image;
|
||||
use krilla::metadata::Metadata;
|
||||
use krilla::num::NormalizedF32;
|
||||
use krilla::page::PageSettings;
|
||||
use krilla::paint::{Fill, FillRule, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
|
||||
@@ -59,13 +60,13 @@ fn sheets(doc: &Document, opts: &PdfOptions) -> Result<Vec<Sheet>> {
|
||||
return Err(PdfError::NoPages);
|
||||
}
|
||||
if let Some(bad) = pages.iter().find(|p| **p >= count) {
|
||||
return Err(PdfError::BadPage(bad + 1));
|
||||
return Err(PdfError::BadPage(bad.saturating_add(1)));
|
||||
}
|
||||
let b = doc.settings.bleed.map(|v| if opts.bleed { v.max(0.0) } else { 0.0 });
|
||||
let mut out: Vec<Sheet> = Vec::new();
|
||||
let mut seen_spreads: Vec<usize> = Vec::new();
|
||||
for abs in pages {
|
||||
let (si, pi) = doc.page_loc(abs).ok_or(PdfError::BadPage(abs + 1))?;
|
||||
let (si, pi) = doc.page_loc(abs).ok_or(PdfError::BadPage(abs.saturating_add(1)))?;
|
||||
let sp: &Spread = &doc.spreads[si];
|
||||
let (trim, l, r, label) = if opts.spreads {
|
||||
if seen_spreads.contains(&si) {
|
||||
@@ -101,6 +102,10 @@ pub(crate) fn mark_start(opts: &PdfOptions, bleed: [f64; 4]) -> f64 {
|
||||
|
||||
/// Like [`export_pdf`], also returning warnings about approximated or dropped features.
|
||||
pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
|
||||
write::guard(|| export_pdf_with_report_inner(doc, cache, opts))
|
||||
}
|
||||
|
||||
fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
|
||||
let sheets = sheets(doc, opts)?;
|
||||
let warnings = Vec::new();
|
||||
let (version, archival) = match opts.standard {
|
||||
@@ -108,37 +113,20 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
|
||||
Standard::PdfX4 => (PdfVersion::Pdf16, None),
|
||||
Standard::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)),
|
||||
};
|
||||
let mut cb = ConfigurationBuilder::new().with_version(version);
|
||||
if let Some(a) = archival {
|
||||
cb = cb.with_archival_validator(a);
|
||||
}
|
||||
let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
let configuration = write::configuration(version, archival)?;
|
||||
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() };
|
||||
let mut pdf = krilla::Document::new_with(settings);
|
||||
|
||||
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
|
||||
let mut meta = Metadata::new().creator("DesignCraft".into()).producer("DesignCraft".into());
|
||||
if !title.is_empty() {
|
||||
meta = meta.title(title.clone());
|
||||
}
|
||||
if let Some(a) = &opts.author {
|
||||
meta = meta.authors(vec![a.clone()]);
|
||||
}
|
||||
let created = opts.created.or_else(now_unix);
|
||||
if let Some(t) = created {
|
||||
let c = civil(t);
|
||||
meta = meta.creation_date(
|
||||
krilla::metadata::DateTime::new(c.0.clamp(0, 9999) as u16)
|
||||
.month(c.1)
|
||||
.day(c.2)
|
||||
.hour(c.3)
|
||||
.minute(c.4)
|
||||
.second(c.5)
|
||||
.utc_offset_hour(0)
|
||||
.utc_offset_minute(0),
|
||||
);
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
let metadata = Metadata {
|
||||
creator: "DesignCraft".into(),
|
||||
producer: "DesignCraft".into(),
|
||||
title: Some(title.clone()),
|
||||
authors: opts.author.iter().cloned().collect(),
|
||||
created: created.map(date_time),
|
||||
..Default::default()
|
||||
};
|
||||
let mut writer = Writer::new(settings, metadata);
|
||||
let pdf = writer.document_mut();
|
||||
|
||||
let mut ex = Exporter {
|
||||
doc,
|
||||
@@ -182,6 +170,8 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
|
||||
let settings = PageSettings::new(size).with_trim_box(local(sh.trim)).with_bleed_box(local(sh.bleed));
|
||||
let mut page = pdf.start_page_with(settings);
|
||||
let mut s = page.surface();
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
unwind_tests::inject(&mut s);
|
||||
s.push_transform(&tf(Affine::translate(-sh.media.origin().to_vec2())));
|
||||
ex.clip = sh.bleed;
|
||||
if let Some(clip) = to_path(&sh.bleed.to_path(0.1)) {
|
||||
@@ -207,7 +197,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
|
||||
pdf.set_tag_tree(ex.tag_tree());
|
||||
}
|
||||
ex.check_placed_versions()?;
|
||||
let mut bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
let mut bytes = writer.finish().map_err(PdfError::from)?;
|
||||
// Transparency blends in the document's blend space: CMYK for print (and always in PDF/X).
|
||||
let mut warnings = ex.warnings;
|
||||
if !ex.rgb_only && (doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk || opts.standard == Standard::PdfX4) {
|
||||
@@ -215,12 +205,16 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
|
||||
// group that got past it, with its page.
|
||||
crate::pdfx::cmyk_group_spaces(&mut bytes);
|
||||
}
|
||||
// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X.
|
||||
// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X
|
||||
// or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and
|
||||
// Screen annotations aren't allowed in PDF/A.
|
||||
let fields = crate::forms::collect(doc, &sheets);
|
||||
let media = if opts.media { crate::forms::collect_media(doc, &sheets) } else { Vec::new() };
|
||||
if !fields.is_empty() || !media.is_empty() {
|
||||
if opts.standard == Standard::PdfX4 {
|
||||
warnings.push("PDF/X-4: form fields and media were left out".into());
|
||||
} else if opts.standard == Standard::PdfA2b {
|
||||
warnings.push("PDF/A-2b: form fields and media were left out".into());
|
||||
} else {
|
||||
match crate::forms::add_fields(&bytes, &fields, &media) {
|
||||
Some(b) => bytes = b,
|
||||
@@ -245,12 +239,13 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
|
||||
/// One PDF from several (a book's documents), every page carried over as vectors; each part
|
||||
/// comes with its pages' sizes (points).
|
||||
pub fn merge_pdfs(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -> Result<Vec<u8>> {
|
||||
let mut pdf = krilla::Document::new_with(krilla::SerializeSettings::default());
|
||||
let mut meta = Metadata::new().creator("DesignCraft".into()).producer("DesignCraft".into());
|
||||
if let Some(t) = title {
|
||||
meta = meta.title(t.to_string());
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
write::guard(|| merge_pdfs_inner(parts, title))
|
||||
}
|
||||
|
||||
fn merge_pdfs_inner(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -> Result<Vec<u8>> {
|
||||
let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: title.map(str::to_owned), ..Default::default() };
|
||||
let mut writer = Writer::new(krilla::SerializeSettings::default(), metadata);
|
||||
let pdf = writer.document_mut();
|
||||
let mut pages = 0;
|
||||
for (bytes, sizes) in parts {
|
||||
let src = krilla::pdf::Pdf::new(Arc::new(bytes.clone())).map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
@@ -259,6 +254,8 @@ pub fn merge_pdfs(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) ->
|
||||
let size = Size::from_wh(w.max(1.0), h.max(1.0)).ok_or(PdfError::NoPages)?;
|
||||
let mut page = pdf.start_page_with(PageSettings::new(size));
|
||||
let mut s = page.surface();
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
unwind_tests::inject(&mut s);
|
||||
s.draw_pdf_page(&doc, size, i);
|
||||
s.finish();
|
||||
page.finish();
|
||||
@@ -268,7 +265,7 @@ pub fn merge_pdfs(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) ->
|
||||
if pages == 0 {
|
||||
return Err(PdfError::NoPages);
|
||||
}
|
||||
pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))
|
||||
writer.finish().map_err(PdfError::from)
|
||||
}
|
||||
|
||||
/// File › Print Booklet: how pages pair up on printer spreads.
|
||||
@@ -303,19 +300,20 @@ pub fn booklet_pairs(n: usize, kind: BookletKind) -> Vec<(Option<usize>, Option<
|
||||
|
||||
/// An imposed PDF: two document pages per sheet, in booklet order.
|
||||
pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> Result<ExportReport> {
|
||||
write::guard(|| export_booklet_inner(doc, cache, opts))
|
||||
}
|
||||
|
||||
fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> Result<ExportReport> {
|
||||
let n = doc.page_count();
|
||||
if n == 0 {
|
||||
return Err(PdfError::NoPages);
|
||||
}
|
||||
let pdf_opts = PdfOptions { title: opts.title.clone(), ..PdfOptions::default() };
|
||||
let settings = krilla::SerializeSettings { compress_content_streams: true, ..Default::default() };
|
||||
let mut pdf = krilla::Document::new_with(settings);
|
||||
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
|
||||
let mut meta = Metadata::new().creator("DesignCraft".into()).producer("DesignCraft".into());
|
||||
if !title.is_empty() {
|
||||
meta = meta.title(title);
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: Some(title), ..Default::default() };
|
||||
let mut writer = Writer::new(settings, metadata);
|
||||
let pdf = writer.document_mut();
|
||||
let mut ex = Exporter {
|
||||
doc,
|
||||
cache,
|
||||
@@ -355,6 +353,8 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R
|
||||
for (left, right) in &pairs {
|
||||
let mut page = pdf.start_page_with(PageSettings::new(size));
|
||||
let mut s = page.surface();
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
unwind_tests::inject(&mut s);
|
||||
for (slot, abs) in [(0.0, left), (pw + gap, right)] {
|
||||
let Some((si, pi)) = abs.and_then(|a| doc.page_loc(a)) else { continue };
|
||||
let p = &doc.spreads[si].pages[pi];
|
||||
@@ -374,7 +374,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R
|
||||
page.finish();
|
||||
}
|
||||
ex.check_placed_versions()?;
|
||||
let mut bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
let mut bytes = writer.finish().map_err(PdfError::from)?;
|
||||
if doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk {
|
||||
crate::pdfx::cmyk_group_spaces(&mut bytes);
|
||||
}
|
||||
@@ -393,6 +393,18 @@ fn now_unix() -> Option<i64> {
|
||||
None
|
||||
}
|
||||
|
||||
fn date_time(t: i64) -> krilla::metadata::DateTime {
|
||||
let c = civil(t);
|
||||
krilla::metadata::DateTime::new(c.0.clamp(0, 9999) as u16)
|
||||
.month(c.1)
|
||||
.day(c.2)
|
||||
.hour(c.3)
|
||||
.minute(c.4)
|
||||
.second(c.5)
|
||||
.utc_offset_hour(0)
|
||||
.utc_offset_minute(0)
|
||||
}
|
||||
|
||||
/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian.
|
||||
pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) {
|
||||
let days = t.div_euclid(86_400);
|
||||
@@ -757,7 +769,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
// Gradient feather: a luminosity mask (white = opaque) over the object.
|
||||
let xf = parent * it.xf;
|
||||
let (p0, p1) = gf.points(it.inner_bounds());
|
||||
let (p0, p1) = gf.points(it.feather_bounds());
|
||||
let gray = |a: f32| -> krilla::color::Color { luma::Color::new((a.clamp(0.0, 1.0) * 255.0).round() as u8).into() };
|
||||
let stops = vec![
|
||||
Stop { offset: NormalizedF32::ZERO, color: gray(gf.start), opacity: NormalizedF32::ONE },
|
||||
@@ -772,7 +784,7 @@ impl Exporter<'_> {
|
||||
cx: p0.x as f32,
|
||||
cy: p0.y as f32,
|
||||
cr: r,
|
||||
transform: Transform::identity(),
|
||||
transform: tf(xf),
|
||||
spread_method: SpreadMethod::Pad,
|
||||
stops,
|
||||
anti_alias: false,
|
||||
@@ -784,22 +796,21 @@ impl Exporter<'_> {
|
||||
y1: p0.y as f32,
|
||||
x2: p1.x as f32,
|
||||
y2: p1.y as f32,
|
||||
transform: Transform::identity(),
|
||||
transform: tf(xf),
|
||||
spread_method: SpreadMethod::Pad,
|
||||
stops,
|
||||
anti_alias: false,
|
||||
}
|
||||
.into()
|
||||
};
|
||||
let area = it.inner_bounds().inflate(it.stroke.extent() + 4.0, it.stroke.extent() + 4.0);
|
||||
// The gradient keeps its item-space geometry, but its mask covers the complete exported
|
||||
// area (including bleed), so distant child shadows and thick strokes are never cut off.
|
||||
let mut sb = s.stream_builder();
|
||||
let mut ms = sb.surface();
|
||||
ms.push_transform(&tf(xf));
|
||||
if let Some(p) = to_path(&area.to_path(0.1)) {
|
||||
if let Some(p) = to_path(&self.clip.to_path(0.1)) {
|
||||
ms.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
|
||||
ms.draw_path(&p);
|
||||
}
|
||||
ms.pop();
|
||||
ms.finish();
|
||||
let stream = sb.finish();
|
||||
s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity));
|
||||
@@ -1023,11 +1034,10 @@ impl Exporter<'_> {
|
||||
let v = fill.gradient_vector;
|
||||
return Some(match kind {
|
||||
GradientKind::Radial => {
|
||||
let (c, r) = match v {
|
||||
Some([x0, y0, x1, y1]) => (designcraft_geom::Point::new(x0, y0), Vec2::new(x1 - x0, y1 - y0).hypot()),
|
||||
None => (c, bounds.width().max(bounds.height()) / 2.0),
|
||||
};
|
||||
let r = r.max(1e-3) as f32;
|
||||
// A circle stretched into the object's ellipse, as on screen.
|
||||
let (c, rx, ry) = fill.radial_ellipse(bounds);
|
||||
let rx = rx.max(1e-3);
|
||||
let stretch = Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2());
|
||||
let (cx, cy) = (c.x as f32, c.y as f32);
|
||||
RadialGradient {
|
||||
fx: cx,
|
||||
@@ -1035,8 +1045,8 @@ impl Exporter<'_> {
|
||||
fr: 0.0,
|
||||
cx,
|
||||
cy,
|
||||
cr: r,
|
||||
transform: Transform::identity(),
|
||||
cr: rx as f32,
|
||||
transform: tf(stretch),
|
||||
spread_method: SpreadMethod::Pad,
|
||||
stops,
|
||||
anti_alias: false,
|
||||
@@ -1180,7 +1190,8 @@ impl Exporter<'_> {
|
||||
/// A placed PDF as krilla embeds it. krilla refuses a PDF newer than the file it writes (the
|
||||
/// export fails at the end with `VersionMismatch(Pdf17)`), and PDF/X-4 is PDF 1.6 while
|
||||
/// VectorCraft and most tools write 1.7. PDF 1.7 changed nothing in how a page draws, so a 1.7
|
||||
/// file goes in as 1.6 (its header relabelled, as `qpdf --force-version=1.6` does). A newer
|
||||
/// file goes in as 1.6 (its header and catalog `/Version` relabelled, as
|
||||
/// `qpdf --force-version=1.6` does). A newer
|
||||
/// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]).
|
||||
fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option<krilla::pdf::PdfDocument> {
|
||||
use hayro_syntax::PdfVersion as V;
|
||||
@@ -1201,8 +1212,8 @@ impl Exporter<'_> {
|
||||
}
|
||||
if have == V::Pdf17
|
||||
&& max == V::Pdf16
|
||||
&& let Some(bytes) = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6")
|
||||
&& let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(bytes))
|
||||
&& let bytes = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| asset.data.to_vec())
|
||||
&& let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(relabel_catalog_version(bytes)))
|
||||
&& pdf.version() <= max
|
||||
{
|
||||
self.warn(format!("{}: a PDF 1.7 placed in a PDF 1.6 file, embedded as PDF 1.6", asset.name));
|
||||
@@ -1368,6 +1379,30 @@ fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option<Vec<u8>> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `data` with every `/Version /1.7` changed to `/Version /1.6` in place, so the xref offsets
|
||||
/// hold. The catalog's `/Version` wins over the header (TCPDF writes both), so relabelling the
|
||||
/// header alone leaves the file a PDF 1.7. A catalog inside a compressed object stream is left as
|
||||
/// it is, and the version check still names the file.
|
||||
fn relabel_catalog_version(mut data: Vec<u8>) -> Vec<u8> {
|
||||
const KEY: &[u8] = b"/Version";
|
||||
let mut from = 0;
|
||||
while let Some(at) = data.get(from..).and_then(|rest| rest.windows(KEY.len()).position(|w| w == KEY)) {
|
||||
let mut i = from + at + KEY.len();
|
||||
while data.get(i).is_some_and(|b| b" \t\r\n\x0c\0".contains(b)) {
|
||||
i += 1;
|
||||
}
|
||||
let ends = data.get(i + 4).is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'.');
|
||||
if data.get(i..i + 4) == Some(b"/1.7".as_slice())
|
||||
&& ends
|
||||
&& let Some(b) = data.get_mut(i + 3)
|
||||
{
|
||||
*b = b'6';
|
||||
}
|
||||
from = i;
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// The sRGB profile (ICC v2.1) that untagged RGB images carry in PDF/X-4.
|
||||
pub(crate) static SRGB_ICC: &[u8] = include_bytes!("../../../assets/icc/sRGB-v2-magic.icc");
|
||||
|
||||
@@ -1759,3 +1794,54 @@ mod image_content_cache_tests {
|
||||
assert!(duplicate.upgrade().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod unwind_tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
thread_local! {
|
||||
static FAIL_NEXT_SURFACE: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
// Fail with live backend drawing state, so a missing guard or an unpatched destructor
|
||||
// makes the child fail. Thread-local state keeps unrelated export tests independent.
|
||||
#[allow(clippy::panic)] // Test-only fault injection exercises recovery with a live surface.
|
||||
pub(super) fn inject(surface: &mut krilla::surface::Surface<'_>) {
|
||||
if FAIL_NEXT_SURFACE.replace(false) {
|
||||
surface.push_transform(&krilla::geom::Transform::from_translate(2.0, 3.0));
|
||||
panic!("synthetic export drawing failure");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_entry_points_recover_in_a_subprocess() {
|
||||
const CHILD: &str = "DESIGNCRAFT_PDF_EXPORT_PANIC_PROBE";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "export::unwind_tests::export_entry_points_recover_in_a_subprocess", "--nocapture"])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "export child failed: {:?}\n{}", output.status, String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("all three export entry points recovered"));
|
||||
return;
|
||||
}
|
||||
let doc = Document::new(&designcraft_doc::build::NewDocument { pages: 1, ..Default::default() });
|
||||
let cache = Cache::new();
|
||||
let source = export_pdf(&doc, &cache, &Default::default()).unwrap();
|
||||
let parts = [(source, vec![(612.0, 792.0)])];
|
||||
for entry in ["document", "booklet", "merge"] {
|
||||
let export = || match entry {
|
||||
"document" => export_pdf(&doc, &cache, &Default::default()),
|
||||
"booklet" => export_booklet(&doc, &cache, &Default::default()).map(|report| report.bytes),
|
||||
_ => merge_pdfs(&parts, None),
|
||||
};
|
||||
FAIL_NEXT_SURFACE.set(true);
|
||||
assert!(matches!(export(), Err(PdfError::Write(message)) if message.contains("synthetic export drawing failure")), "{entry}");
|
||||
let fresh = export().unwrap();
|
||||
assert!(!hayro_syntax::Pdf::new(Arc::new(fresh)).unwrap().pages().is_empty(), "fresh {entry} export must still work");
|
||||
}
|
||||
println!("all three export entry points recovered");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,12 @@ pub fn parse_page_range(s: &str, count: usize) -> Result<Vec<usize>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl From<craft_pdf::write::WriteError> for PdfError {
|
||||
fn from(error: craft_pdf::write::WriteError) -> Self {
|
||||
Self::Write(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Interactive PDF: hyperlink annotations (frames and text ranges → URLs, e-mail, pages) and the
|
||||
//! bookmark outline.
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use designcraft_doc::{Bookmark, Document, HyperlinkDest, HyperlinkSource, SpreadRef};
|
||||
use designcraft_geom::{Affine, Rect};
|
||||
use krilla::action::{Action, LinkAction};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Printer's marks drawn in the slug area: crop marks at the trim corners, bleed marks at the bleed
|
||||
//! corners, and a page-information line. All marks use `[Registration]` (every plate).
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use designcraft_geom::{BezPath, Point};
|
||||
use krilla::num::NormalizedF32;
|
||||
use krilla::paint::{LineCap, LineJoin, Stroke};
|
||||
|
||||
@@ -452,6 +452,7 @@ impl RgbScan {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_color::{Color, Swatch};
|
||||
|
||||
@@ -414,6 +414,93 @@ fn drawn(bytes: &[u8]) -> (Vec<(String, kurbo::Rect)>, Vec<kurbo::Rect>) {
|
||||
(rec.0, rec.1)
|
||||
}
|
||||
|
||||
/// The page's paint operations in order: `fill`/`stroke` + ` text`/` path` and the stroke width.
|
||||
fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> {
|
||||
use hayro_interpret::font::Glyph;
|
||||
use hayro_interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
|
||||
interpret_page,
|
||||
};
|
||||
#[derive(Default)]
|
||||
struct Rec(Vec<(String, f32)>);
|
||||
impl Device<'_> for Rec {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, m: &PathDrawMode) {
|
||||
self.0.push(match m {
|
||||
PathDrawMode::Fill(_) => ("fill path".into(), 0.0),
|
||||
PathDrawMode::Stroke(p) => ("stroke path".into(), p.line_width),
|
||||
});
|
||||
}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, m: &GlyphDrawMode) {
|
||||
self.0.push(match m {
|
||||
GlyphDrawMode::Fill => ("fill text".into(), 0.0),
|
||||
GlyphDrawMode::Stroke(p) => ("stroke text".into(), p.line_width),
|
||||
GlyphDrawMode::Invisible => ("invisible text".into(), 0.0),
|
||||
});
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse");
|
||||
let cache = InterpreterCache::new();
|
||||
let page = &pdf.pages()[0];
|
||||
let mut ctx = Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
|
||||
let mut rec = Rec::default();
|
||||
interpret_page(page, &mut ctx, &mut rec);
|
||||
rec.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_behind_fill_strokes_the_run_before_filling_it() {
|
||||
let export = |behind: bool| {
|
||||
let mut d = doc_with_text("I");
|
||||
let sid = *d.stories.keys().next().unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(0..1, |f| {
|
||||
f.over.stroke = Some(designcraft_color::swatch::BLACK.into());
|
||||
f.over.stroke_weight = Some(4.0);
|
||||
f.over.stroke_behind_fill = Some(behind);
|
||||
});
|
||||
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
|
||||
let ops: Vec<(String, f32)> = paint_order(&bytes).into_iter().filter(|(op, w)| op.ends_with("text") || *w > 0.0).collect();
|
||||
(ops, extract_text(&bytes)[0].clone())
|
||||
};
|
||||
let (over, _) = export(false);
|
||||
assert_eq!(over, [("fill text".to_string(), 0.0), ("stroke text".to_string(), 4.0)]);
|
||||
let (behind, text) = export(true);
|
||||
assert_eq!(behind, [("stroke path".to_string(), 8.0), ("fill text".to_string(), 0.0)], "the double-width stroke goes first");
|
||||
assert_eq!(text.trim(), "I", "the text is not duplicated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfa_leaves_out_form_fields_added_after_validation() {
|
||||
let mut d = doc_with_text("Archive me");
|
||||
let lid = d.default_layer();
|
||||
let id = ItemId(d.alloc());
|
||||
let mut b = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(36.0, 400.0, 200.0, 430.0)));
|
||||
b.form_field = Some(designcraft_doc::FormField {
|
||||
kind: designcraft_doc::FieldKind::TextField,
|
||||
name: "name".into(),
|
||||
value: String::new(),
|
||||
options: Vec::new(),
|
||||
multiline: false,
|
||||
required: false,
|
||||
font_size: 0.0,
|
||||
});
|
||||
d.insert_item(SpreadRef::Doc(0), b, None).unwrap();
|
||||
let plain = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
|
||||
assert!(String::from_utf8_lossy(&plain).contains("/AcroForm"), "control: a plain export has the field");
|
||||
let opts = PdfOptions { standard: Standard::PdfA2b, created: Some(1_700_000_000), ..Default::default() };
|
||||
let a = export_pdf_with_report(&d, &Cache::new(), &opts).unwrap();
|
||||
let text = String::from_utf8_lossy(&a.bytes).to_string();
|
||||
assert!(text.contains("pdfaid"));
|
||||
assert!(!text.contains("/AcroForm") && !text.contains("NeedAppearances"), "PDF/A must not get the form update");
|
||||
assert!(a.warnings.iter().any(|w| w.contains("PDF/A-2b") && w.contains("left out")), "{:?}", a.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_glyphs_print_as_boxes() {
|
||||
// Source Serif 4 lacks 語: its .notdef box is drawn as a path, not as a .notdef glyph
|
||||
@@ -458,3 +545,26 @@ fn column_rules_are_drawn() {
|
||||
assert!((xs[0] - 214.0).abs() < 0.01 && (xs[1] - 398.0).abs() < 0.01, "{xs:?}");
|
||||
assert!(rules(false).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_page_selection_is_a_page_error_without_overflow() {
|
||||
let doc = doc_with_text("Bounded page selection");
|
||||
let opts = PdfOptions { pages: Some(vec![usize::MAX]), ..Default::default() };
|
||||
assert_eq!(export_pdf(&doc, &Cache::new(), &opts), Err(PdfError::BadPage(usize::MAX)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_populated_export_metadata_preserve_key_presence() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
d.title.clear();
|
||||
let s = uncompressed(&d, PdfOptions::default());
|
||||
for key in ["/Title", "/Author", "<dc:title>", "<dc:creator>"] {
|
||||
assert!(!s.contains(key), "unexpected empty metadata {key}");
|
||||
}
|
||||
d.title = "Synthetic title".into();
|
||||
let s = uncompressed(&d, PdfOptions { author: Some("Test author".into()), created: Some(1_700_000_000), ..Default::default() });
|
||||
for key in ["/Title", "/Author", "/CreationDate", "<dc:title>", "<dc:creator>"] {
|
||||
assert!(s.contains(key), "missing populated metadata {key}");
|
||||
}
|
||||
assert!(s.contains("Synthetic title") && s.contains("Test author"));
|
||||
}
|
||||
|
||||
+81
-31
@@ -10,6 +10,7 @@ use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
use designcraft_compose::{ComposedStory, FrameText, PlacedGlyph};
|
||||
use designcraft_fonts::{FontDb, FontFace};
|
||||
use designcraft_geom::kurbo::Shape as _;
|
||||
@@ -66,13 +67,11 @@ impl Exporter<'_> {
|
||||
self.fonts
|
||||
.entry(face.id())
|
||||
.or_insert_with(|| {
|
||||
if face.is_variable() {
|
||||
// A named instance: krilla embeds the font at these axis settings.
|
||||
let coords: Vec<(krilla::text::Tag, f32)> = face.coords.iter().map(|(t, v)| (krilla::text::Tag::new(t), *v)).collect();
|
||||
Font::new_variable(face.data().to_vec().into(), face.index(), &coords)
|
||||
} else {
|
||||
Font::new(face.data().to_vec().into(), face.index())
|
||||
}
|
||||
// Keep the existing outline fallback and warning when embedding fails.
|
||||
let variations = face.is_variable().then_some(face.coords.as_slice());
|
||||
craft_pdf::write::font(face.data(), face.index(), variations)
|
||||
.inspect_err(|error| log::warn!("PDF font `{} {}` could not be embedded: {error}", face.family, face.style))
|
||||
.ok()
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
@@ -300,16 +299,53 @@ impl Exporter<'_> {
|
||||
let hs = g0.sx / g0.sy;
|
||||
let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0);
|
||||
// Stroke behind fill: a double-width stroke first, as outlines so the text is not
|
||||
// duplicated, then the fill over its inner half. With no fill, the stroke is clipped
|
||||
// to the outside of the glyphs instead.
|
||||
let behind = st.stroke_behind_fill && stroke.is_some();
|
||||
let weight = if behind { st.stroke_weight * 2.0 } else { st.stroke_weight };
|
||||
let mut clipped = false;
|
||||
let stroke = if behind {
|
||||
let outlines = glyph_outlines(glyphs, size);
|
||||
if fill.is_some() {
|
||||
self.paint_outlines(s, &outlines, origin, None, stroke, weight);
|
||||
None
|
||||
} else {
|
||||
clipped = self.push_outside_clip(s, &outlines, origin, weight);
|
||||
stroke
|
||||
}
|
||||
} else {
|
||||
stroke
|
||||
};
|
||||
// A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text.
|
||||
if g0.gid == 0 {
|
||||
self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight);
|
||||
return;
|
||||
}
|
||||
let Some(font) = self.font(&face) else {
|
||||
self.paint_outlines(s, &glyph_outlines(glyphs, size), origin, fill, stroke, weight);
|
||||
} else if let Some(font) = self.font(&face) {
|
||||
self.text_run(s, glyphs, story, font, origin, size, hs, fill, stroke, weight);
|
||||
} else {
|
||||
self.warn(format!("font `{} {}` could not be embedded; its text was drawn as outlines", face.family, face.style));
|
||||
self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight);
|
||||
return;
|
||||
};
|
||||
self.paint_outlines(s, &glyph_outlines(glyphs, size), origin, fill, stroke, weight);
|
||||
}
|
||||
if clipped {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// A run as real text.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn text_run(
|
||||
&mut self,
|
||||
s: &mut Surface,
|
||||
glyphs: &[PlacedGlyph],
|
||||
story: &str,
|
||||
font: Font,
|
||||
origin: Affine,
|
||||
size: f64,
|
||||
hs: f64,
|
||||
fill: Option<krilla::color::Color>,
|
||||
stroke: Option<krilla::color::Color>,
|
||||
stroke_weight: f64,
|
||||
) {
|
||||
let (text, ranges) = self.run_text(glyphs, story);
|
||||
let k = 1.0 / (hs * size);
|
||||
let kg: Vec<KrillaGlyph> = glyphs
|
||||
@@ -323,36 +359,24 @@ impl Exporter<'_> {
|
||||
.collect();
|
||||
s.push_transform(&tf(origin));
|
||||
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
|
||||
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() }));
|
||||
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() }));
|
||||
s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false);
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
s.pop();
|
||||
}
|
||||
|
||||
/// Fallback for faces krilla can't embed: glyph outlines as paths.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn outline_run(
|
||||
/// Glyph outlines as paths (faces krilla can't embed, missing glyphs, strokes behind fill).
|
||||
fn paint_outlines(
|
||||
&mut self,
|
||||
s: &mut Surface,
|
||||
glyphs: &[PlacedGlyph],
|
||||
outlines: &BezPath,
|
||||
origin: Affine,
|
||||
size: f64,
|
||||
fill: Option<krilla::color::Color>,
|
||||
stroke: Option<krilla::color::Color>,
|
||||
stroke_weight: f64,
|
||||
) {
|
||||
let db = FontDb::global();
|
||||
let g0 = &glyphs[0];
|
||||
let k = size / g0.face.upem;
|
||||
let mut bp = BezPath::new();
|
||||
for g in glyphs {
|
||||
let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k);
|
||||
let mut o = (*db.outline(&g.face, g.gid)).clone();
|
||||
o.apply_affine(a);
|
||||
bp.extend(o.iter());
|
||||
}
|
||||
let Some(p) = to_path(&bp) else { return };
|
||||
let Some(p) = to_path(outlines) else { return };
|
||||
s.push_transform(&tf(origin));
|
||||
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
|
||||
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() }));
|
||||
@@ -362,6 +386,17 @@ impl Exporter<'_> {
|
||||
s.pop();
|
||||
}
|
||||
|
||||
/// Clip to everything outside the glyphs (a frame around them plus their outlines,
|
||||
/// even-odd). False when there was nothing to clip with.
|
||||
fn push_outside_clip(&mut self, s: &mut Surface, outlines: &BezPath, origin: Affine, weight: f64) -> bool {
|
||||
let m = weight * 2.0 + 10.0;
|
||||
let mut outside = outlines.bounding_box().inflate(m, m).to_path(0.1);
|
||||
outside.extend(outlines.iter());
|
||||
let Some(p) = to_path(&(origin * outside)) else { return false };
|
||||
s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd);
|
||||
true
|
||||
}
|
||||
|
||||
/// One line of plain text in the bundled UI face (page information mark).
|
||||
pub(crate) fn plain_text(&mut self, s: &mut Surface, text: &str, at: (f64, f64), size: f64, color: krilla::color::Color) {
|
||||
let face = FontDb::global().face(designcraft_fonts::FALLBACK_FAMILY, "Regular");
|
||||
@@ -391,3 +426,18 @@ impl Exporter<'_> {
|
||||
s.set_fill(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// A run's glyph outlines at `size`, relative to its first glyph's origin.
|
||||
fn glyph_outlines(glyphs: &[PlacedGlyph], size: f64) -> BezPath {
|
||||
let db = FontDb::global();
|
||||
let mut bp = BezPath::new();
|
||||
let Some(g0) = glyphs.first() else { return bp };
|
||||
let k = size / g0.face.upem;
|
||||
for g in glyphs {
|
||||
let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k);
|
||||
let mut o = (*db.outline(&g.face, g.gid)).clone();
|
||||
o.apply_affine(a);
|
||||
bp.extend(o.iter());
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! All images and placed PDFs are tiny procedural fixtures.
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_pdf::write::backend as krilla;
|
||||
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef};
|
||||
use designcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
+82
-35
@@ -4,7 +4,7 @@
|
||||
//! vello_cpu only supports filter layers in single-threaded contexts, so on the multithreaded
|
||||
//! pipeline each effect is rendered into an offscreen single-threaded context cropped to its
|
||||
//! reach and composited back as an image ([`Renderer::with_filters`]); single-threaded contexts
|
||||
//! draw directly.
|
||||
//! draw directly. A very wide blur goes offscreen in both, at a lower resolution.
|
||||
//!
|
||||
//! - Drop shadow / outer glow: the object's silhouette (everything it paints: fill, image, text,
|
||||
//! stroke) in a `DropShadowOnly` layer under the object. `size` sets the blur (σ = size / 2),
|
||||
@@ -64,6 +64,28 @@ fn shadow(sigma: f64, color: peniko::Color) -> Filter {
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep what was drawn in the current layer as much as `it`'s gradient feather says. Cover the
|
||||
/// whole viewport: strokes, text and child effects can extend arbitrarily past the geometry,
|
||||
/// and past the gradient's ends its end opacities carry on.
|
||||
pub(crate) fn gradient_feather_mask(ctx: &mut RenderContext, f: &Frame, it: &Item, xf: Affine) {
|
||||
let gf = &it.effects.gradient_feather;
|
||||
let (p0, p1) = gf.points(it.feather_bounds());
|
||||
let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8)));
|
||||
let stops = [stop(0.0, gf.start), stop(1.0, gf.end)];
|
||||
let grad = if gf.radial {
|
||||
peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice())
|
||||
} else {
|
||||
peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice())
|
||||
};
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
|
||||
ctx.set_paint(grad);
|
||||
ctx.set_paint_transform(f.view * xf);
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())));
|
||||
ctx.reset_paint_transform();
|
||||
ctx.pop_layer();
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Draw an item with soft effects: below-effects, the (feathered) body, inner shadow.
|
||||
pub(crate) fn draw_item_fx(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: Option<usize>) {
|
||||
@@ -74,7 +96,7 @@ impl Renderer {
|
||||
let reach = xf.transform_rect_bbox(bp.bounding_box()).inflate(sw + 1.0, sw + 1.0);
|
||||
// The silhouette is the shape when the object paints its area (fill / image); text frames
|
||||
// without a fill cast the shadow of their text only, which can't be grown.
|
||||
let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_));
|
||||
let solid = !it.fill.is_none() || matches!(it.content, designcraft_doc::Content::Graphic(_)) || it.has_nested_items();
|
||||
let mut below = vec![];
|
||||
if e.outer_glow.on {
|
||||
let g = &e.outer_glow;
|
||||
@@ -170,22 +192,7 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
if gf.on {
|
||||
let (p0, p1) = gf.points(it.inner_bounds());
|
||||
let stop = |o: f32, a: f32| peniko::ColorStop::from((o, peniko::Color::from_rgba8(0, 0, 0, (a.clamp(0.0, 1.0) * 255.0).round() as u8)));
|
||||
let stops = [stop(0.0, gf.start), stop(1.0, gf.end)];
|
||||
let grad = if gf.radial {
|
||||
peniko::Gradient::new_radial(p0, (p1 - p0).hypot().max(1e-3) as f32).with_stops(stops.as_slice())
|
||||
} else {
|
||||
peniko::Gradient::new_linear(p0, p1).with_stops(stops.as_slice())
|
||||
};
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None);
|
||||
ctx.set_transform(f.view * xf);
|
||||
ctx.set_paint(grad);
|
||||
let area = if xf.determinant().abs() > 1e-12 { xf.inverse().transform_rect_bbox(reach) } else { it.inner_bounds() };
|
||||
ctx.fill_rect(&area.inflate(4.0, 4.0));
|
||||
ctx.reset_paint_transform();
|
||||
ctx.pop_layer();
|
||||
gradient_feather_mask(ctx, f, it, xf);
|
||||
}
|
||||
if gf.on || directional {
|
||||
ctx.pop_layer();
|
||||
@@ -337,8 +344,9 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a
|
||||
/// multithreaded context the layer is drawn into an offscreen single-threaded context
|
||||
/// covering `reach` (spread space) plus the blur's spread, then composited back.
|
||||
/// multithreaded context, or for a blur wider than [`MAX_BLUR_SPREAD`], the layer is drawn into
|
||||
/// an offscreen single-threaded context covering `reach` (spread space) plus the blur's spread,
|
||||
/// then composited back.
|
||||
pub(crate) fn with_filters(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
@@ -347,28 +355,67 @@ impl Renderer {
|
||||
sigma: f64,
|
||||
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),
|
||||
) {
|
||||
if !f.mt {
|
||||
return draw(self, ctx, f);
|
||||
}
|
||||
// 3σ of the Gaussian in pixels, plus a pixel of antialiasing.
|
||||
let spread = sigma.max(0.0) * 3.0 / f.px + 2.0;
|
||||
let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread);
|
||||
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen);
|
||||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||||
let (w, h) = ((r.x1.ceil() - x0).min(crate::MAX_SIDE as f64), (r.y1.ceil() - y0).min(crate::MAX_SIDE as f64));
|
||||
if w < 1.0 || h < 1.0 {
|
||||
return;
|
||||
if !f.mt && spread <= MAX_BLUR_SPREAD {
|
||||
return draw(self, ctx, f);
|
||||
}
|
||||
let (w, h) = (w as u16, h as u16);
|
||||
let mut off = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
|
||||
let Some(o) = offscreen(f.view.transform_rect_bbox(reach), spread, ctx.width(), ctx.height()) else { return };
|
||||
let mut off = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -o.y0)) * f.view;
|
||||
let shifted = Frame { mt: false, view, px: f.px * o.scale, ..*f };
|
||||
draw(self, &mut off, &shifted);
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
let mut pm = vello_cpu::Pixmap::new(o.width, o.height);
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
ctx.set_transform(Affine::translate((x0, y0)));
|
||||
ctx.set_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale));
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(pm)), sampler: peniko::ImageSampler::default() });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, f64::from(o.width), f64::from(o.height)));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest blur (3σ, device pixels) drawn at full resolution. vello pads a filter layer by the
|
||||
/// blur's width on every side, so a wide blur needs gigabytes; it is drawn at a lower resolution
|
||||
/// instead, where it is this wide, and has no detail to lose there.
|
||||
const MAX_BLUR_SPREAD: f64 = 1024.0;
|
||||
|
||||
/// An offscreen image for [`Renderer::with_filters`]: where it goes on the device, how many device
|
||||
/// pixels one of its pixels covers, and its size.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Offscreen {
|
||||
x0: f64,
|
||||
y0: f64,
|
||||
scale: f64,
|
||||
width: u16,
|
||||
height: u16,
|
||||
}
|
||||
|
||||
/// The offscreen image an effect is drawn in: `reach` (device pixels) widened by the blur's
|
||||
/// `spread`, within the window widened the same way, at a resolution that keeps the blur within
|
||||
/// [`MAX_BLUR_SPREAD`], at most `MAX_SIDE` a side. `None` when none of it shows.
|
||||
fn offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option<Offscreen> {
|
||||
let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread, spread);
|
||||
let r = reach.inflate(spread, spread).intersect(screen);
|
||||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||||
let scale = (spread / MAX_BLUR_SPREAD).max(1.0);
|
||||
let side = |len: f64| (len / scale).ceil().min(crate::MAX_SIDE as f64);
|
||||
let (w, h) = (side(r.x1.ceil() - x0), side(r.y1.ceil() - y0));
|
||||
(w >= 1.0 && h >= 1.0).then_some(Offscreen { x0, y0, scale, width: w as u16, height: h as u16 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_wide_blur_is_drawn_offscreen_at_a_lower_resolution() {
|
||||
let object = Rect::new(150.0, 150.0, 350.0, 350.0);
|
||||
let small = Offscreen { x0: 140.0, y0: 140.0, scale: 1.0, width: 220, height: 220 };
|
||||
assert_eq!(offscreen(object, 10.0, 1440, 900), Some(small));
|
||||
// A 10,000 pt drop shadow at 100%: 3σ is 15,000 px, which asked for 30,204 px a side and as
|
||||
// much padding again inside vello's filter layer. Drawn at 1/14.65 it is 1,024 px wide.
|
||||
let wide = Offscreen { x0: -14852.0, y0: -14852.0, scale: 15_002.0 / 1024.0, width: 2062, height: 2062 };
|
||||
assert_eq!(offscreen(object, 15_002.0, 1440, 900), Some(wide));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//!
|
||||
//! Entries are keyed by the address of the asset's encoded bytes (`Arc<Vec<u8>>`) and hold a
|
||||
//! clone of that `Arc`, so the address can't be reused while cached. Least recently used levels
|
||||
//! are evicted beyond a memory budget.
|
||||
//! are evicted beyond a memory budget; at most `MAX_BAD` failed assets are remembered.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -15,6 +15,9 @@ use crate::{decode_pixmap_page, halve};
|
||||
/// Decoded pixels kept (bytes).
|
||||
const BUDGET: usize = 768 << 20;
|
||||
|
||||
/// Failed assets remembered; the oldest is forgotten (and retried if drawn again) beyond this.
|
||||
const MAX_BAD: usize = 64;
|
||||
|
||||
struct Entry {
|
||||
_bytes: Arc<Vec<u8>>,
|
||||
pm: Arc<Pixmap>,
|
||||
@@ -28,7 +31,20 @@ struct Cache {
|
||||
bytes: usize,
|
||||
clock: u64,
|
||||
/// Assets that failed to decode (don't retry every frame).
|
||||
bad: HashMap<(usize, u32), Arc<Vec<u8>>>,
|
||||
/// Value: (insertion stamp, the encoded bytes keeping the address reserved).
|
||||
bad: HashMap<(usize, u32), (u64, Arc<Vec<u8>>)>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// Remember a failed asset, evicting the oldest failures beyond [`MAX_BAD`].
|
||||
fn mark_bad(&mut self, key: (usize, u32), bytes: &Arc<Vec<u8>>) {
|
||||
self.clock += 1;
|
||||
self.bad.insert(key, (self.clock, bytes.clone()));
|
||||
while self.bad.len() > MAX_BAD {
|
||||
let Some(oldest) = self.bad.iter().min_by_key(|(_, (stamp, _))| *stamp).map(|(k, _)| *k) else { break };
|
||||
self.bad.remove(&oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
|
||||
@@ -97,7 +113,7 @@ pub fn decoded(bytes: &Arc<Vec<u8>>, page: u32) -> Option<Arc<Pixmap>> {
|
||||
Some(pm)
|
||||
}
|
||||
None => {
|
||||
with(|c| c.bad.insert((id, page), bytes.clone()));
|
||||
with(|c| c.mark_bad((id, page), bytes));
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -133,3 +149,23 @@ pub fn clear() {
|
||||
c.bytes = 0;
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failed_assets_are_capped_and_oldest_is_dropped() {
|
||||
let mut c = Cache::default();
|
||||
let buffers: Vec<Arc<Vec<u8>>> = (0..MAX_BAD + 10).map(|i| Arc::new(vec![i as u8; 4])).collect();
|
||||
for b in &buffers {
|
||||
c.mark_bad((Arc::as_ptr(b) as usize, 0), b);
|
||||
}
|
||||
assert_eq!(c.bad.len(), MAX_BAD);
|
||||
let first = &buffers[0];
|
||||
assert!(!c.bad.contains_key(&(Arc::as_ptr(first) as usize, 0)));
|
||||
assert_eq!(Arc::strong_count(first), 1, "evicted buffer must be released");
|
||||
let last = &buffers[MAX_BAD + 9];
|
||||
assert!(c.bad.contains_key(&(Arc::as_ptr(last) as usize, 0)));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user